- JavaScript 97.6%
- CSS 2.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .github/workflows | ||
| deploy | ||
| docs | ||
| migrations | ||
| public/css | ||
| scripts | ||
| server | ||
| test | ||
| .env.example | ||
| .gitignore | ||
| CHANGELOG.md | ||
| LICENSE | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| SECURITY.md | ||
| STATUS.json | ||
OpenVibe.Coupons
Coupon codes with merchant matching, restrictions, expiry and real-people validity reports.
Status: alpha (roadmap Wave 18, Coupons part). The service runs and its tests pass. It is
public at openvibe.coupons since 2026-10-09, after an independent pre-launch security review
whose fixes shipped first, and it holds no codes (nothing is seeded, no coupons source is
enabled). Its capabilities and service manifest are registered in openvibe-contracts v0.97.0.
Domain: openvibe.coupons · Port: 4850 · Service id: coupons
Plan: OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §12.10; roadmap §4.2 D, §15.13, §29, §32.
License: AGPL-3.0 (same as every OpenVibe service).
Purpose
Coupons keeps merchants and their normalized domains, codes, restrictions, validity reports, expiry and confidence, and the public API the OpenVibe browser helper (OpenVibers/OpenVibe.Extensions) uses. It is distinct from Deals even where they cross-link.
Four rules shape everything here:
- No code is "working" because a model guessed it. A code's status comes only from recent reports by people. A submission, a source, staff and AI output can't set it.
- Unknown stays unknown. An expiry nobody stated is "Expiry unknown" (
nullin JSON). No recent reports means no confidence number. "No restrictions stated" is never shown as "no restrictions". - Expired codes leave active results predictably. They leave at the instant their known expiry passes, in the API, pages, feeds, sitemaps and Search.
- A merchant page (or any page) can't obtain anyone's data. Lookups return the same bytes to every caller. The API ignores cookies. The extension holds no OpenVibe session.
Owns
The nine charter tables live in Coupons' own PostgreSQL database (ov_coupons on the host's data role, ADR-035; schema in migrations/):
| Charter table | What it is |
|---|---|
coupon_merchants |
Merchants: pending (proposed by a member's submission, invisible until staff approve), active, disabled. |
coupon_merchant_domains |
Domain rules: a normalized host, its registrable domain (eTLD+1), whether subdomains are included, and an optional path prefix. |
coupons |
Codes. Status is exactly unknown|reported_working|reported_failed|expired|disabled. Confidence is NULL when nobody reported recently. expires_at is NULL when the expiry is unknown, with its precision (instant/date) and basis (evidence/submitter/source/staff). Review state is pending|published. Origin is member|staff|source|ai. |
coupon_sources |
Evidence: member and staff submissions (with the evidence URL, and whether it's on the merchant's own domain), OpenVibe.Sources items (id, revision, retrieval time) and AI runs. |
coupon_restrictions |
Min spend (minor units and ISO 4217 currency), categories, new customers only, regions (ISO 3166-1), other. |
coupon_validation_reports |
Worked/failed reports. One row per (reporter, code, UTC day). The reporter is an HMAC of the person's subject. |
coupon_status_history |
Every status and confidence change, with its reason (created, report, decay, expiry, evidence, staff:…, service:…) and actor. |
coupon_application_hints |
How to apply a code, for one code or for all of a merchant's codes. |
coupon_watches |
Members watching a merchant. Private. |
Other tables in the same database:
extension_installs: browser-helper tokens, stored as hashes.import_stateandcoupon_import_holds: the Sources importer.event_outbox: the SDK transactional outbox.coupons_index_revisions: the openvibe-publishing index sequencer.
Request telemetry lives in this database too, in telemetry_samples (migration
0003_telemetry.sql). Each HTTP request is aggregated by route template, method and status class,
never by raw URL; once a minute server/index.js writes one http.request sample per key, plus
the active-requests, p95 latency and event-loop-lag gauges (openvibe-sdk 0.46 telemetry, the
platform.telemetry-sample@1 shape), and deletes rows older than 14 days daily. /shared files,
static assets, health, readiness and /metrics are not counted. server/app.js builds the
collector (createHttpTelemetry) and mounts its middleware; the flush also runs on stop.
Nothing is seeded, and no code, merchant or report is invented for a demo.
Does not own
- Deal offers. They belong to OpenVibe.Deals.
- Discussion. It belongs to OpenVibe.Community. Code pages have no comment thread yet (see "Known gaps").
- Identity. It belongs to OpenVibe.Network: SSO, subjects and service principals.
- Notifications. They belong to the Network's notification store. Watches are recorded, but nothing is sent yet.
- Search. OpenVibe.Search indexes what Coupons sends through Events.
- Ingestion. It belongs to OpenVibe.Sources. Coupons only reads the
couponscategory. An item names its shop by its link's host or, when the link is another site's page (DealNews:dealnews-coupon-codes), byfields.merchant, the shop's name as the source wrote it, which must match exactly one active merchant's name (any case); otherwise it is heldno_merchantwith that name. Such a page is never merchant evidence, so it never raises confidence. - The browser helper. It lives in OpenVibe.Extensions (ADR-023).
Statuses and confidence
The formula lives in server/domain/confidence.js. test/confidence.test.js pins it with numbers,
and /about explains it on the site.
-
Deduplication: one stored report per (person, code, UTC day). Every channel shares one reporter key per person: the site, each extension install and the API. Of a person's reports, only the most recent counts.
-
Recency: a report of age a days weighs
0.5^(a/7). It weighs 0 after 30 days. -
With W the weight of "worked" and F the weight of "didn't work" reports, and e = 0.5 when the code was published on one of the merchant's own domains (merchant evidence), otherwise 0:
confidence = null if W + F = 0 (no counted report in 30 days) = (1 + e + W) / (2 + e + W + F) otherwise, rounded to 2 decimals -
Status:
disabledif staff or a service took the code down.- Otherwise
expiredif a known expiry has passed, or staff recorded that it ended. - Otherwise
unknownif W + F < 0.5. - Otherwise
reported_workingif confidence ≥ 0.6,reported_failedif confidence ≤ 0.4, andunknownin between.
So:
- One fresh "worked" report gives 0.67 and
reported_working. - One fresh "didn't work" report gives 0.33 and
reported_failed(0.43 and stillunknownfor a code with merchant evidence). - One of each gives 0.5 and
unknown. - A lone report stops deciding the status after a week. After 30 days there is no confidence at all.
The sweep records the decay: status history, coupons.confidence.changed, and a new Search document.
- Expiry:
- A date without a time means the end of that day in UTC. The merchant's time zone isn't known, and the page says so.
- A time needs an offset. A zone-less time is refused as ambiguous.
- Every active list filters on
expires_at > nowin the query itself. A code leaves the API, pages, feeds and sitemaps at that instant, whether or not the sweep has run yet. The sweep then recordsexpiredat the stated instant, with history,coupons.coupon.expiredand a Search tombstone. Running it again changes nothing.
- Nobody sets working or failed:
- A submission carrying
status,confidence,verified,working,validorvalidityis refused (422coupon.status_not_accepted). That applies to people, services and AI output alike. - Staff and services with
coupons.status.updatecan only disable a code, mark it expired, or return it to active. Returning it recomputes the status from reports and time. - Reports marked
X-OV-Origin: aiare refused. - A person who submitted a code, or added evidence for it, can't report on it (403
report.own_submission). Otherwise one account could submit a made-up code and mark it working. - AI-extracted codes (
coupons.extract_couponoutput) are held for staff review. They startunknownand carry an on-page disclosure.
- A submission carrying
Merchant and domain resolution
The code is in server/domain/hosts.js and server/domain/psl.js. The tests are in
test/domains.test.js.
- Hosts:
- Hosts are lowercased, converted to punycode (IDNA), and stripped of their trailing dot and port.
- A host needs at least two valid labels.
- IP literals,
localhostand special-use names (.local,.internal,.invalid,.test,.example,.arpa…) are refused.
- Registrable domain (eTLD+1):
- It comes from a small bundled subset of the Public Suffix List. That includes multi-label
ccTLD suffixes, wildcard and exception rules (
*.ck,!www.ck), and private-section platforms such asmyshopify.com,github.ioandnetlify.app, so each customer's subdomain there is its own site. - A suffix missing from the subset makes a registrable domain one label too short. Only staff create domain rules (members' submissions only propose a pending merchant), so a gap can't make an unrelated site resolve by itself. Update the subset when a real merchant needs a suffix.
- It comes from a small bundled subset of the Public Suffix List. That includes multi-label
ccTLD suffixes, wildcard and exception rules (
- Rules:
- A rule names a host at or below a registrable domain. A rule on a public suffix is refused.
include_subdomainscovers every subdomain, but never across the looked-up host's registrable domain.- A
path_prefixmatches only at a segment boundary, and only when a path is known. - Host-only lookups never match path rules. The browser helper sends only a hostname, so it gets the host's own rule.
- The most specific rule wins: the longest host, then the longest path.
- One (host, path) belongs to one merchant.
Routes (server-rendered, useful without JavaScript)
| Route | What |
|---|---|
/ |
The front page (server/render/home.js): a shop search and the site's numbers (active codes, shops with one, ending this week, added this week, with a stated end, people's reports), then codes reported working, ending soon and new (each a ticket with the code, the shop, what it is for, its end or "No end date stated" and its source's attribution; a code shows once), the shops, the Companion, what every code tells you, and six questions (FAQPage JSON-LD). A section with nothing to show is left out. A search (?q=) lists matching shops (noindex). |
/about |
Statuses, the confidence formula, expiry, privacy and the browser helper. |
/m/:slug, /m/:slug.json |
A shop, and the same as data. The page shows active codes with status, confidence, the last report time (to the hour), restrictions, expiry or "unknown", evidence and hints. Recently ended codes are listed apart. |
/c/:id, /c/:id.json |
One code, with its status history. Expired: served but noindex. Taken down: 410. |
POST /c/:id/report |
Worked or didn't work, with an optional reason. Needs sign-in and a form token. |
POST /m/:slug/watch |
Watch or stop watching a shop. |
/submit |
Submit a code: shop, code, what it gives, details, evidence link, expiry (empty means unknown) and whether the evidence page states it, restrictions, and how to apply. |
/companion |
The OpenVibe Companion browser extension (OpenVibers/OpenVibe.Extensions browser/): what it does across Coupons, Food, Rent and Watch, what it sees and sends, and how to install it from its release zip. Linked from the home and the navbar. |
/connect-extension |
Create, list and revoke browser-helper tokens. |
/watching |
Your watched shops. Private. |
/staff |
Add shops, approve or reject pending shops and codes, take down, expire or re-enable a code, and see the import holds. |
/feed.xml, /atom.xml, /feed.json, /m/:slug/feed.xml |
Feeds of active codes. |
/sitemap.xml → /sitemaps/merchants.xml, /sitemaps/coupons.xml |
Sitemaps with active, indexable entries only. |
/robots.txt, /llms.txt |
The automated-consumer policy and orientation for language models. |
/auth/* |
Network SSO, the same session layer as Blog and Community. |
/api/health, /api/ready, /release.json, /metrics |
/metrics is loopback only. |
Sitemap entries carry lastmod from each code or shop page's latest change.
Discoverability (roadmap §32)
- Every page gets its robots meta, canonical and
X-Robots-Tagfrom theopenvibe-publishing/seogate:- A shop with no active code is
thin: served, but noindex and left out of the sitemap. - An expired code is
expired: noindex. - A taken-down code or shop is
takedown(410). - Pending codes and shops are hidden.
- A shop with no active code is
- JSON-LD is
BreadcrumbListandWebPageonly, built from real fields. There is noOffer,validThroughor rating, because a code has no price and its validity isn't a fact to mark up. - Feeds and sitemaps start from the active-results query. They are never built for a viewer.
Caching
- Pages rendered for anonymous visitors are
public, max-age=60. Signed-in pages (which carry form tokens) areprivate, no-store. Everything varies on Cookie and Authorization. - Lookup API answers are
public, max-age=60, because they are identical for every caller. Everything else in the API isprivate, no-store.
API (/api/v1)
Identity comes from the Authorization header only. Cookies are ignored here, so a page can't
use a visitor's session against the API. Errors are RFC 9457 problems.
| Route | Who | Capability or install scope |
|---|---|---|
GET /merchants/resolve?host= |
Anyone. Anonymous callers are rate-limited per IP, installs per install. | coupons.merchant.resolve / coupons.lookup |
GET /merchants/:id/coupons |
Anyone (as above). | coupons.coupon.lookup / coupons.lookup |
GET /merchants/:id, GET /coupons/:id |
Anyone. | coupons.coupon.lookup / coupons.lookup |
A source's terms can keep its codes out of browser extensions (server/domain/source-policy.js): DealNews' feed terms
say its content may not be used in a browser extension made available for public use, so a code from
dealnews-coupon-codes is never in an answer to an extension (an Origin of chrome-extension://, moz-extension://
or another *-extension:// scheme, or an install's cpx_ token): left out of /merchants/:id/coupons and of
active_codes, and GET /coupons/:id answers 404. These answers vary on Origin. Every code view carries
attribution ({ text: 'DealNews', url }, shown as "via DealNews" wherever the code is) and extension_excluded.
| POST /coupons/:id/report {outcome, reason?} | A person: a Bearer Network token, an install token with coupons.report, or a service acting for X-OV-Subject. | coupons.report.create / coupons.report |
| POST /coupons/submit {merchant_id \| host \| url, code, title, description?, evidence_url?, expires?, expiry_basis?, restrictions?, hint?, ai_run_id?} | A person, or X-OV-Origin: ai (held for review). Install tokens can't submit. | coupons.coupon.submit |
| POST /coupons/:id/status {status: disabled\|expired\|active, note?} | Staff (Network admin, or COUPONS_STAFF_SUBJECTS) or a service. | coupons.status.update |
| POST /merchants, POST /merchants/:id/domains, POST /merchants/:id/status | Staff or a service. | coupons.merchant.manage |
- CORS:
- Only the two lookup routes send CORS headers, and only to origins in
COUPONS_EXTENSION_ORIGINS. Those are exactchrome-extension://<id>origins, and optionally the literalmoz-extension://*, because Firefox gives every install a random origin. - No credentials header is ever sent.
- Reports, submissions, pages and everything else send no CORS headers.
- The extension doesn't depend on CORS, because its host permission for this origin covers it.
- Only the two lookup routes send CORS headers, and only to origins in
- Rate limits:
- Lookups:
COUPONS_LOOKUP_ANON_PER_MIN(30) per IP,COUPONS_LOOKUP_TOKEN_PER_MIN(120) per install, andCOUPONS_LOOKUP_SERVICE_PER_MIN(600) per service. - Reports:
COUPONS_REPORTS_PER_HOUR(20) andCOUPONS_REPORTS_PER_DAY(60) new reports per person, across all channels. Deduplicated repeats don't count. - Submissions:
COUPONS_SUBMISSIONS_PER_HOUR(10) and_PER_DAY(30) per person, ×10 for services. - Plus a per-IP ceiling in Express and the nginx zones.
- Per-actor limits (
server/http/actor-limits.js, openvibe-sdk/limits, roadmap WS-R task 4) on the API and the page forms, once the caller is known and before any work. A person counts asuser:usr_…whether they call with their Network token, a service names them, or one of their browser helpers' install tokens sends the request: every browser a person connected counts together, never by the address it reports from. A first-party service relaying a signed-out visitor counts by the address it forwards, a service or app acting as itself by its principal, and a signed-out caller by address. A first-party service reading for itself is not counted on reads (the lookup limit above bounds it). API reads:COUPONS_LIMITS_MINUTE/COUPONS_LIMITS_HOUR(120 a minute, 3000 an hour). Each write cap sits above the per-person limit for the same action, and a form shares it with the API route: reports 30 a minute and 120 an hour; submissions 20 and 60 (a service submitting for people 120 and 600); staff and service moderation (coupon and merchant status, merchants, domains, approvals) 60 and 600; connecting a browser helper 5 and 20; watching a merchant 30 and 300. Revoking a browser helper is never limited. Past a limit:429problem+jsonrate_limitedwithRetry-After, one[Limits]log line andcoupons_rate_limited_total{limit,window}.test/actor-limits.test.js.
- Lookups:
- Privacy:
- No response, page or event names a submitter or reporter.
- Report times are published to the hour.
- Events always have the service as actor.
Extension credentials (the browser helper)
- A signed-in person creates a token on
/connect-extensionand pastes it into the helper's settings. - The token is
cpx_followed by 256 random bits. It is shown once and stored as a SHA-256 hash. - It has one or both scopes:
coupons.lookup(always) andcoupons.report(optional). It can't submit, moderate or read anything private. - It isn't a Network session. Pages treat it as anonymous, and it's refused wherever a Network token is expected.
- Revocation is immediate: every request reads the row, so the next request after "Revoke"
gets
401 token.revoked. - Tokens expire after
COUPONS_INSTALL_TTL_DAYS(365). A person can have at mostCOUPONS_INSTALLS_PER_SUBJECT(10) active tokens. - Lookups work without any token, with tighter limits.
Events (SDK outbox, same transaction as the change)
| Event | When |
|---|---|
coupons.coupon.created |
A code is created. It is public only when it's listed at once. |
coupons.coupon.updated |
Approval, re-enable, a status change among unknown, working and failed, or an expiry change. |
coupons.coupon.expired |
The code leaves active results as expired. |
coupons.coupon.disabled |
The code is taken down (by staff, a service, or because its last Sources evidence was removed). |
coupons.report.created |
A new report (not a deduplicated repeat). The payload is the outcome, reason, channel and day only. |
coupons.confidence.changed |
The confidence moved, because of reports or decay. The payload is from, to and status. |
coupons.moderation.action |
Staff (or a service with coupons.status.update / coupons.merchant.manage) disabled, expired, re-enabled or approved someone else's code (coupon.*), or approved, disabled or re-enabled a shop (merchant.*). For the network's moderation audit log (ADR-022, common.moderation-action@1). It names the staff member who acted, never the submitter, and never the code. |
coupons.index_document.upserted / .deleted |
search.index-document@1 documents for coupon and merchant, and tombstones when a code leaves active results. A resource that was never indexed gets no tombstone. |
The charter's coupons.created|updated|expired|disabled are these coupons.coupon.* 3-segment
types. Every envelope validates as events.event-envelope@1 (tested), with actor
{type: service, id: coupons}, except coupons.moderation.action, whose actor is the staff member.
Coupons subscribes to sources.item.* and sources.fetch.failed with scripts/subscribe.js
(endpoint /internal/events, delivery signed with COUPONS_EVENTS_SECRET; the import's cursor pull
works without it). scripts/subscribe.js --reconcile re-stamps every merchant and every code (any status, so a code that
left active results gets its tombstone) in one bounded, idempotent pass and reports { sent, unchanged }.
Capabilities (registered in openvibe-contracts v0.97.0)
Service tokens use audience openvibe.coupons, with one capability per route:
coupons.coupon.submit(the charter'scoupons.submit)coupons.report.create(the charter'scoupons.report)coupons.merchant.resolvecoupons.coupon.lookup(the charter'scoupons.lookup)coupons.status.updatecoupons.merchant.manage(new: the staff and service merchant administration the lookups depend on)
Grants for these ids are decided with the contracts library's matching rule
(server/auth/capabilities.js). The ids and the service manifest are released in
openvibe-contracts v0.97.0 from the proposals in docs/capabilities-proposal/ and
docs/service-manifest-proposal.json. Install scopes (coupons.lookup, coupons.report) are
Coupons-local and aren't capabilities.
What Coupons calls elsewhere, and with which grant, is under Grants the Network must hold.
Depends on
- PostgreSQL 18 and Valkey 9 (OpenVibe.Host
roles/data/, ADR-035): every read and write is async throughopenvibe-sdk/db; Valkey holds the per-actor limit counters (optional: withoutVALKEY_URLthey count per process). - Packages (all pinned by release tarball):
openvibe-publishingv1.3.0 (seo gate, ssr, index-hooks),openvibe-contractsv0.97.0,openvibe-sharedv4.15.2 (Frame, app icon, footer, legal, release, metrics, ready, seo),openvibe-sdkv0.65.0 (events outbox, service tokens, per-actor limits). - OpenVibe.Network:
- SSO: an OAuth client
couponswith redirecthttps://openvibe.coupons/auth/callback(registered in production). - JWKS.
- SSO: an OAuth client
- OpenVibe.Sources:
sources.item.read, categorycoupons. The seededstaff-coupon-codessource is disabled until a person enables it. - OpenVibe.Events:
events.event.publish. - OpenVibe.Search: consumes
coupons.index_document.*.couponshas to be in Search'sSEARCH_EVENT_OWNERS(it is, in production).
Grants the Network must hold
Each grant is [client, capability, audience]:
[coupons, events.event.publish, openvibe.events][coupons, events.subscription.manage, openvibe.events](only to create the Sources subscriptions:scripts/subscribe.js)[coupons, sources.item.read, openvibe.sources](only if the Sources import is turned on)- For OpenVibe.AI to deliver
coupons.extract_coupondrafts:[ai, coupons.coupon.submit, openvibe.coupons] - Optional, for services that look up or moderate:
[<client>, coupons.coupon.lookup, openvibe.coupons],[<client>, coupons.merchant.resolve, openvibe.coupons],[<client>, coupons.status.update, openvibe.coupons]and[<client>, coupons.merchant.manage, openvibe.coupons]
Acceptance (automated: npm test)
| Charter / roadmap requirement | Test |
|---|---|
Unknown stays unknown: no expiry means "Expiry unknown" and null, no reports means no confidence, no stated restrictions means "none stated". Nothing changes across time and sweeps. There's no Offer or validity in JSON-LD. |
test/lifecycle.test.js |
| No code is labelled working because a model guessed it: status fields are refused from everyone, AI codes are held for review, AI reports are refused, and nobody can set working or failed. | test/lifecycle.test.js |
Expired codes leave active results at the instant of expiry: API, page, feed and sitemap agree at t-1ms and at t. The expired page is noindex. The sweep records the status, history, event and tombstone once. |
test/lifecycle.test.js |
| Reports are deduplicated per person, code and day across site, extension and API. Only the latest per person counts. They're rate-limited per hour and day, never anonymous, never cookie-authenticated on the API, and decay back to unknown. | test/reports.test.js |
| The confidence formula, with numbers. | test/confidence.test.js |
| Token revocation is immediate, tokens are hashed at rest, scopes are enforced, and expired or unknown tokens are refused. | test/extension-privacy.test.js |
| A lookup response never leaks anyone's data: the same bytes for anonymous callers and any two people, CORS only for the extension origin on lookups, and a malicious merchant page riding the visitor's cookies gets nothing. The connect page can't be framed. | test/extension-privacy.test.js |
| Domain normalization: PSL subset, wildcards and exceptions, platform subdomains, path rules, never across sites. | test/domains.test.js |
| Sources import: evidence with provenance, holds with reasons (never dropped), retries, withdrawal, and a failed fetch changes nothing. | test/sources.test.js |
| Useful without JavaScript: every flow is a plain form. Caching, robots, llms.txt, sitemaps, feeds, JSON twins, readiness and staff-only pages. | test/pages-discovery.test.js |
| The contract proposals are valid against the released schemas and match the code and its events. | test/contracts.test.js |
Security and threat review
Reporting a vulnerability: SECURITY.md.
- Identity:
- Only verified Network JWTs (offline RS256 against JWKS), service tokens for audience
openvibe.coupons, andcpx_install tokens. - A presented credential that fails is refused, never downgraded to anonymous.
- Identity never comes from a body or query.
- Only verified Network JWTs (offline RS256 against JWKS), service tokens for audience
- Malicious merchant pages:
- The API ignores cookies.
- Every form needs an HMAC form token (
COUPONS_FORM_SECRET) on top of the SameSite=Lax session cookie. - No CORS for web origins.
- Lookups hold no personal data and are identical for every caller.
- The connect page is
frame-ancestors 'none'andX-Frame-Options: DENY. - The browser helper never exposes an interface to pages: it has no content scripts, no
externally_connectableand no web-accessible resources (see OpenVibe.Extensions).
- Privacy:
- Reporter keys are HMACs (
COUPONS_REPORTER_KEY_SECRET). - Submitters and reporters are never returned or emitted.
- Report times are published to the hour.
- Watches are private.
- Reporter keys are HMACs (
- XSS: every value goes through
openvibe-publishing/ssrauto-escaping. There's no page script of Coupons' own beyond the shared navbar init, and CSP comes from helmet. Evidence links arerel="nofollow ugc noopener". - SSRF: Coupons never fetches a user-supplied URL. Evidence URLs are stored and linked, never requested. The only outbound calls go to the configured Network, Sources and Events hosts.
- Abuse:
- Per-person report and submission limits, per-install and per-IP lookup limits, and nginx zones.
- Members can't create merchants that resolve: new sites are pending until staff approve them.
- A taken-down code can't be resubmitted.
- Known gaps:
- No Community discussion on code pages yet. The charter lists comments, and none are implemented.
- Watches don't notify: OpenVibe.Network has no notification consumer yet.
- There's no staff UI to edit a code's text or restrictions. Staff can take a code down and ask for a resubmission.
- The public-suffix subset needs maintenance as merchants are added.
- Report moderation (voiding one person's reports) isn't built. Rate limits and "latest per person counts" bound the damage.
Launch rule
The plan §12.12 launch rule is met: openvibe.coupons is served from this service's own domain
since 2026-10-09, no longer as a placeholder on
OpenVibers/OpenVibe.Sites (plan T11, which deletes
OpenVibe.Sites). Status against each point:
- Runtime, health, readiness, observability: done.
- Canonical identity and auth: done. The OAuth client, service principal and grants exist in production.
- SSR public routes useful without JS: done.
- Persistence and end-to-end workflows: done and deployed on the host (empty database).
- Capability and event registration against OpenVibe.Contracts: done (openvibe-contracts v0.97.0).
- Migration and seed strategy, threat review, sitemap/robots/feed behaviour:
- There's nothing to migrate and nothing is seeded.
- Codes come from members, staff and the Sources
couponscategory. - The independent pre-launch security review (2026-10-09) and the sitemap, robots and feed behaviour are done.
- Acceptance tests: done.
The launch release did all of these in one release:
- Removed
openvibe.couponsfromOpenVibe.Sites/sites.json. - Switched routing: nginx vhost, DNS and TLS.
- Flipped the Network hub entry (
server/chrome/sites.js,status: 'soon'). - Registered maturity in the ecosystem registry.
A placeholder never counted as an implemented service: openvibe.coupons is served by this
repository's code.
Development
fnm exec --using=22.22.1 npm install
fnm exec --using=22.22.1 npm test # temp PostgreSQL databases (PGlite) and in-process mocks, no network
fnm exec --using=22.22.1 npm run dev # http://localhost:4850 (set OV_OAUTH_CLIENT_SECRET to sign in)
Deploy (for the lead)
Production deploys with sudo ovhost deploy coupons on the host (strategy git-checkout: fetch,
fast-forward /opt/openvibe.coupons, install on a lockfile change, restart, wait for /api/ready).
The unit is openvibe-coupons.service on 127.0.0.1:4850, the env file /etc/openvibe/coupons.env. The database is
ov_coupons on the host's data role (sudo /opt/openvibe.host/roles/data/add-service.sh coupons writes its settings); the
release migrates it at boot.
Rollback: ovhost puts the previous sha back by itself when /api/ready does not answer 2xx after the
restart; afterwards sudo ovhost rollback coupons --to <sha>. Migrations only add tables and columns.
First install (done once; kept for a rebuild):
- Network:
- Create the service principal and OAuth client
coupons(server/setup/service-principal.js), and put the secret in the env file. - Add the redirect
https://openvibe.coupons/auth/callbackto the Network's seeded OAuth clients. - Add the grants above.
- Create the service principal and OAuth client
- Code and config:
- Put the code at
/opt/openvibe.couponsand runnpm ci --omit=devon Node 22. - Create
/etc/openvibe/coupons.env(0600) from.env.example. Set these values:OV_OAUTH_CLIENT_SECRETCOUPONS_FORM_SECRETCOUPONS_REPORTER_KEY_SECRETCOUPONS_STAFF_SUBJECTS(optional)EVENTS_URL=http://127.0.0.1:4300BASE_URL=https://openvibe.couponsINDEXNOW_KEY(optional; enables IndexNow — the key file at/<key>.txtand a ping when an indexable page changes. Unset: off, and drills leave it unset)
- Optionally set
OV_SOURCES_INTERNAL_URL=http://127.0.0.1:4720. - Once the extension has a store id, set
COUPONS_EXTENSION_ORIGINS.
- Put the code at
- systemd: install
deploy/systemd/openvibe-coupons.service(port 4850). - nginx: install
deploy/nginx/openvibe.coupons.conf./metricsis never proxied. - Search: add
couponstoSEARCH_EVENT_OWNERS. - Contracts: done: the capabilities and manifest are released in openvibe-contracts v0.97.0, and CI's contracts check runs against them.
- Launch: done on 2026-10-09:
openvibe.couponswas removed from OpenVibe.Sites and the Network hub entry was flipped (see the launch rule above).
Part of the OpenVibe network. Built in the open by OpenVibers.
- openvibe-contracts: v0.215.0
- openvibe-sdk: v0.65.0
- openvibe-shared: v4.15.2
- openvibe-publishing: v1.3.0