openvibers/openvibe.coupons README.md

README.md

main 33.0 KB · 507 lines · markdown

1# OpenVibe.Coupons
2
3> Coupon codes with merchant matching, restrictions, expiry and real-people validity reports.
4
5**Live site:** https://openvibe.coupons · **API docs:** https://openvibe.services · **For AI:** https://openvibe.coupons/llms.txt
6**Run it:** `npm ci && npm run dev` (Node 22 or newer) · **Tests:** `npm test`
7
8**Status:** alpha (roadmap Wave 18, Coupons part). The service runs and its tests pass. It is
9**public at `openvibe.coupons` since 2026-10-09**, after an independent pre-launch security review
10whose fixes shipped first, and it holds **no codes** (nothing is seeded, no coupons source is
11enabled). Its capabilities and service manifest are registered in openvibe-contracts v0.97.0.
12**Domain:** `openvibe.coupons` · **Port:** 4850 · **Service id:** `coupons`
13**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §12.10; roadmap §4.2 D, §15.13, §29, §32.
14**License:** AGPL-3.0 (same as every OpenVibe service).
15
16## Purpose
17
18Coupons keeps merchants and their normalized domains, codes, restrictions, validity reports,
19expiry and confidence, and the public API the OpenVibe browser helper
20([OpenVibers/OpenVibe.Extensions](https://github.com/OpenVibers/OpenVibe.Extensions)) uses. It is
21distinct from Deals even where they cross-link.
22
23Four rules shape everything here:
24
25- **No code is "working" because a model guessed it.** A code's status comes only from recent
26 reports by people. A submission, a source, staff and AI output can't set it.
27- **Unknown stays unknown.** An expiry nobody stated is "Expiry unknown" (`null` in JSON). No
28 recent reports means no confidence number. "No restrictions stated" is never shown as "no
29 restrictions".
30- **Expired codes leave active results predictably.** They leave at the instant their known
31 expiry passes, in the API, pages, feeds, sitemaps and Search.
32- **A merchant page (or any page) can't obtain anyone's data.** Lookups return the same bytes to
33 every caller. The API ignores cookies. The extension holds no OpenVibe session.
34
35## Owns
36
37The nine charter tables live in Coupons' own PostgreSQL database (`ov_coupons` on the host's data role, ADR-035; schema in [migrations/](migrations/)):
38
39| Charter table | What it is |
40|---|---|
41| `coupon_merchants` | Merchants: `pending` (proposed by a member's submission, invisible until staff approve), `active`, `disabled`. |
42| `coupon_merchant_domains` | Domain rules: a normalized host, its registrable domain (eTLD+1), whether subdomains are included, and an optional path prefix. |
43| `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`. |
44| `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. |
45| `coupon_restrictions` | Min spend (minor units and ISO 4217 currency), categories, new customers only, regions (ISO 3166-1), other. |
46| `coupon_validation_reports` | Worked/failed reports. One row per (reporter, code, UTC day). The reporter is an HMAC of the person's subject. |
47| `coupon_status_history` | Every status and confidence change, with its reason (`created`, `report`, `decay`, `expiry`, `evidence`, `staff:…`, `service:…`) and actor. |
48| `coupon_application_hints` | How to apply a code, for one code or for all of a merchant's codes. |
49| `coupon_watches` | Members watching a merchant. Private. |
50
51Other tables in the same database:
52
53- `extension_installs`: browser-helper tokens, stored as hashes.
54- `import_state` and `coupon_import_holds`: the Sources importer.
55- `event_outbox`: the SDK transactional outbox.
56- `coupons_index_revisions`: the openvibe-publishing index sequencer.
57
58Request telemetry lives in this database too, in `telemetry_samples` (migration
59`0003_telemetry.sql`). Each HTTP request is aggregated by route template, method and status class,
60never by raw URL; once a minute `server/index.js` writes one `http.request` sample per key, plus
61the active-requests, p95 latency and event-loop-lag gauges (openvibe-sdk 0.46 telemetry, the
62`platform.telemetry-sample@1` shape), and deletes rows older than 14 days daily. `/shared` files,
63static assets, health, readiness and `/metrics` are not counted. `server/app.js` builds the
64collector (`createHttpTelemetry`) and mounts its middleware; the flush also runs on stop.
65
66Nothing is seeded, and no code, merchant or report is invented for a demo.
67
68## Does not own
69
70- **Deal offers.** They belong to OpenVibe.Deals.
71- **Discussion.** It belongs to OpenVibe.Community. Code pages have no comment thread yet (see
72 "Known gaps").
73- **Identity.** It belongs to OpenVibe.Network: SSO, subjects and service principals.
74- **Notifications.** They belong to the Network's notification store. Watches are recorded, but
75 nothing is sent yet.
76- **Search.** OpenVibe.Search indexes what Coupons sends through Events.
77- **Ingestion.** It belongs to OpenVibe.Sources. Coupons only reads the `coupons` category. An item names its shop by its link's host or, when the link is another site's page (DealNews: `dealnews-coupon-codes`), by `fields.merchant`, the shop's name as the source wrote it, which must match exactly one active merchant's name (any case); otherwise it is held `no_merchant` with that name. Such a page is never merchant evidence, so it never raises confidence.
78- **The browser helper.** It lives in OpenVibe.Extensions (ADR-023).
79
80## Statuses and confidence
81
82The formula lives in `server/domain/confidence.js`. `test/confidence.test.js` pins it with numbers,
83and `/about` explains it on the site.
84
85- **Deduplication:** one stored report per (person, code, UTC day). Every channel shares one
86 reporter key per person: the site, each extension install and the API. Of a person's reports,
87 only the **most recent** counts.
88- **Recency:** a report of age *a* days weighs `0.5^(a/7)`. It weighs 0 after 30 days.
89- With *W* the weight of "worked" and *F* the weight of "didn't work" reports, and *e* = 0.5 when
90 the code was published on one of the merchant's own domains (merchant evidence), otherwise 0:
91
92 ```
93 confidence = null if W + F = 0 (no counted report in 30 days)
94 = (1 + e + W) / (2 + e + W + F) otherwise, rounded to 2 decimals
95 ```
96
97- **Status:**
98 - `disabled` if staff or a service took the code down.
99 - Otherwise `expired` if a known expiry has passed, or staff recorded that it ended.
100 - Otherwise `unknown` if W + F < 0.5.
101 - Otherwise `reported_working` if confidence ≥ 0.6, `reported_failed` if confidence ≤ 0.4, and
102 `unknown` in between.
103
104So:
105
106- One fresh "worked" report gives 0.67 and `reported_working`.
107- One fresh "didn't work" report gives 0.33 and `reported_failed` (0.43 and still `unknown` for a
108 code with merchant evidence).
109- One of each gives 0.5 and `unknown`.
110- A lone report stops deciding the status after a week. After 30 days there is no confidence at
111 all.
112
113The sweep records the decay: status history, `coupons.confidence.changed`, and a new Search document.
114
115- **Expiry:**
116 - A date without a time means the end of that day in UTC. The merchant's time zone isn't known,
117 and the page says so.
118 - A time needs an offset. A zone-less time is refused as ambiguous.
119 - Every active list filters on `expires_at > now` in the query itself. A code leaves the API,
120 pages, feeds and sitemaps at that instant, whether or not the sweep has run yet. The sweep then
121 records `expired` at the stated instant, with history, `coupons.coupon.expired` and a Search
122 tombstone. Running it again changes nothing.
123- **Nobody sets working or failed:**
124 - A submission carrying `status`, `confidence`, `verified`, `working`, `valid` or `validity` is
125 refused (422 `coupon.status_not_accepted`). That applies to people, services and AI output
126 alike.
127 - Staff and services with `coupons.status.update` can only disable a code, mark it expired, or
128 return it to active. Returning it recomputes the status from reports and time.
129 - Reports marked `X-OV-Origin: ai` are refused.
130 - A person who submitted a code, or added evidence for it, can't report on it (403
131 `report.own_submission`). Otherwise one account could submit a made-up code and mark it working.
132 - AI-extracted codes (`coupons.extract_coupon` output) are held for staff review. They start
133 `unknown` and carry an on-page disclosure.
134
135## Merchant and domain resolution
136
137The code is in `server/domain/hosts.js` and `server/domain/psl.js`. The tests are in
138`test/domains.test.js`.
139
140- **Hosts:**
141 - Hosts are lowercased, converted to punycode (IDNA), and stripped of their trailing dot and
142 port.
143 - A host needs at least two valid labels.
144 - IP literals, `localhost` and special-use names (`.local`, `.internal`, `.invalid`, `.test`,
145 `.example`, `.arpa` …) are refused.
146- **Registrable domain (eTLD+1):**
147 - It comes from a small **bundled subset of the Public Suffix List**. That includes multi-label
148 ccTLD suffixes, wildcard and exception rules (`*.ck`, `!www.ck`), and private-section platforms
149 such as `myshopify.com`, `github.io` and `netlify.app`, so each customer's subdomain there is
150 its own site.
151 - A suffix missing from the subset makes a registrable domain one label too short. Only staff
152 create domain rules (members' submissions only propose a pending merchant), so a gap can't make
153 an unrelated site resolve by itself. Update the subset when a real merchant needs a suffix.
154- **Rules:**
155 - A rule names a host at or below a registrable domain. A rule on a public suffix is refused.
156 - `include_subdomains` covers every subdomain, but never across the looked-up host's registrable
157 domain.
158 - A `path_prefix` matches only at a segment boundary, and only when a path is known.
159 - **Host-only lookups never match path rules.** The browser helper sends only a hostname, so it
160 gets the host's own rule.
161 - The most specific rule wins: the longest host, then the longest path.
162 - One (host, path) belongs to one merchant.
163
164## Routes (server-rendered, useful without JavaScript)
165
166| Route | What |
167|---|---|
168| `/` | 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). |
169| `/about` | Statuses, the confidence formula, expiry, privacy and the browser helper. |
170| `/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. |
171| `/c/:id`, `/c/:id.json` | One code, with its status history. Expired: served but noindex. Taken down: 410. |
172| `POST /c/:id/report` | Worked or didn't work, with an optional reason. Needs sign-in and a form token. |
173| `POST /m/:slug/watch` | Watch or stop watching a shop. |
174| `/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. |
175| `/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. |
176| `/connect-extension` | Create, list and revoke browser-helper tokens. |
177| `/watching` | Your watched shops. Private. |
178| `/staff` | Add shops, approve or reject pending shops and codes, take down, expire or re-enable a code, and see the import holds. |
179| `/feed.xml`, `/atom.xml`, `/feed.json`, `/m/:slug/feed.xml` | Feeds of active codes. |
180| `/sitemap.xml` → `/sitemaps/merchants.xml`, `/sitemaps/coupons.xml` | Sitemaps with active, indexable entries only. |
181| `/robots.txt`, `/llms.txt` | The automated-consumer policy and orientation for language models. |
182| `/auth/*` | Network SSO, the same session layer as Blog and Community. |
183| `/api/health`, `/api/ready`, `/release.json`, `/metrics` | `/metrics` is loopback only. |
184
185Sitemap entries carry lastmod from each code or shop page's latest change.
186
187### Discoverability (roadmap §32)
188
189- Every page gets its robots meta, canonical and `X-Robots-Tag` from the `openvibe-publishing/seo`
190 gate:
191 - A shop with no active code is `thin`: served, but noindex and left out of the sitemap.
192 - An expired code is `expired`: noindex.
193 - A taken-down code or shop is `takedown` (410).
194 - Pending codes and shops are hidden.
195- JSON-LD is `BreadcrumbList` and `WebPage` only, built from real fields. There is no `Offer`,
196 `validThrough` or rating, because a code has no price and its validity isn't a fact to mark up.
197- Feeds and sitemaps start from the active-results query. They are never built for a viewer.
198
199### Caching
200
201- Pages rendered for anonymous visitors are `public, max-age=60`. Signed-in pages (which carry
202 form tokens) are `private, no-store`. Everything varies on Cookie and Authorization.
203- Lookup API answers are `public, max-age=60`, because they are identical for every caller.
204 Everything else in the API is `private, no-store`.
205
206## API (`/api/v1`)
207
208Identity comes from the `Authorization` header **only**. Cookies are ignored here, so a page can't
209use a visitor's session against the API. Errors are RFC 9457 problems.
210
211| Route | Who | Capability or install scope |
212|---|---|---|
213| `GET /merchants/resolve?host=` | Anyone. Anonymous callers are rate-limited per IP, installs per install. | `coupons.merchant.resolve` / `coupons.lookup` |
214| `GET /merchants/:id/coupons` | Anyone (as above). | `coupons.coupon.lookup` / `coupons.lookup` |
215| `GET /merchants/:id`, `GET /coupons/:id` | Anyone. | `coupons.coupon.lookup` / `coupons.lookup` |
216| `GET /coupons/:id/success`, `GET /stores/:slug/success` | Anyone. | `coupons.coupon.lookup` / `coupons.lookup` |
217
218A source's terms can keep its codes out of browser extensions (`server/domain/source-policy.js`): DealNews' feed terms
219say its content may not be used in a browser extension made available for public use, so a code from
220`dealnews-coupon-codes` is never in an answer to an extension (an `Origin` of `chrome-extension://`, `moz-extension://`
221or another `*-extension://` scheme, or an install's `cpx_` token): left out of `/merchants/:id/coupons` and of
222`active_codes`, and `GET /coupons/:id` answers 404. These answers vary on `Origin`. Every code view carries
223`attribution` (`{ text: 'DealNews', url }`, shown as "via DealNews" wherever the code is) and `extension_excluded`.
224| `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` |
225| `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` |
226| `POST /coupons/:id/status` `{status: disabled\|expired\|active, note?}` | Staff (Network admin, or `COUPONS_STAFF_SUBJECTS`) or a service. | `coupons.status.update` |
227| `POST /merchants`, `POST /merchants/:id/domains`, `POST /merchants/:id/status` | Staff or a service. | `coupons.merchant.manage` |
228
229- **Success over time.** `GET /coupons/:id/success` and `GET /stores/:slug/success` return the last twelve weeks,
230 Monday to Sunday UTC, oldest first: `{ from, to, min_people, weeks: [null | { week, worked, failed }] }`. A week
231 counts the people whose most recent report that week said it worked or didn't (a person once a week, even across a
232 shop's codes). `null` means fewer than 3 people — too few to show — so one person's report cannot be read off it.
233 The answer never contains a reporter key, install id, channel or day-level row.
234- **CORS:**
235 - Only the two lookup routes send CORS headers, and only to origins in
236 `COUPONS_EXTENSION_ORIGINS`. Those are exact `chrome-extension://<id>` origins, and optionally
237 the literal `moz-extension://*`, because Firefox gives every install a random origin.
238 - No credentials header is ever sent.
239 - Reports, submissions, pages and everything else send no CORS headers.
240 - The extension doesn't depend on CORS, because its host permission for this origin covers it.
241- **Rate limits:**
242 - Lookups: `COUPONS_LOOKUP_ANON_PER_MIN` (30) per IP, `COUPONS_LOOKUP_TOKEN_PER_MIN` (120) per
243 install, and `COUPONS_LOOKUP_SERVICE_PER_MIN` (600) per service.
244 - Reports: `COUPONS_REPORTS_PER_HOUR` (20) and `COUPONS_REPORTS_PER_DAY` (60) new reports per
245 person, across all channels. Deduplicated repeats don't count.
246 - Submissions: `COUPONS_SUBMISSIONS_PER_HOUR` (10) and `_PER_DAY` (30) per person, ×10 for
247 services.
248 - Plus a per-IP ceiling in Express and the nginx zones.
249 - Per-actor limits (`server/http/actor-limits.js`, openvibe-sdk/limits, roadmap WS-R task 4) on
250 the API and the page forms, once the caller is known and before any work. A person counts as
251 `user:usr_…` whether they call with their Network token, a service names them, or one of their
252 browser helpers' install tokens sends the request: every browser a person connected counts
253 together, never by the address it reports from. A first-party service relaying a signed-out
254 visitor counts by the address it forwards, a service or app acting as itself by its principal,
255 and a signed-out caller by address. A first-party service reading for itself is not counted on
256 reads (the lookup limit above bounds it). API reads: `COUPONS_LIMITS_MINUTE` /
257 `COUPONS_LIMITS_HOUR` (120 a minute, 3000 an hour). Each write cap sits above the per-person
258 limit for the same action, and a form shares it with the API route: reports 30 a minute and 120
259 an hour; submissions 20 and 60 (a service submitting for people 120 and 600); staff and service
260 moderation (coupon and merchant status, merchants, domains, approvals) 60 and 600; connecting a
261 browser helper 5 and 20; watching a merchant 30 and 300. Revoking a browser helper is never
262 limited. Past a limit: `429` problem+json `rate_limited` with `Retry-After`, one `[Limits]` log
263 line and `coupons_rate_limited_total{limit,window}`. `test/actor-limits.test.js`.
264- **Privacy:**
265 - No response, page or event names a submitter or reporter.
266 - Report times are published to the hour.
267 - Events always have the service as actor.
268
269## Extension credentials (the browser helper)
270
271- A signed-in person creates a token on `/connect-extension` and pastes it into the helper's
272 settings.
273- The token is `cpx_` followed by 256 random bits. It is **shown once** and stored as a SHA-256
274 hash.
275- It has one or both scopes: `coupons.lookup` (always) and `coupons.report` (optional). It can't
276 submit, moderate or read anything private.
277- It isn't a Network session. Pages treat it as anonymous, and it's refused wherever a Network
278 token is expected.
279- **Revocation is immediate:** every request reads the row, so the next request after "Revoke"
280 gets `401 token.revoked`.
281- Tokens expire after `COUPONS_INSTALL_TTL_DAYS` (365). A person can have at most
282 `COUPONS_INSTALLS_PER_SUBJECT` (10) active tokens.
283- Lookups work without any token, with tighter limits.
284
285## Events (SDK outbox, same transaction as the change)
286
287| Event | When |
288|---|---|
289| `coupons.coupon.created` | A code is created. It is `public` only when it's listed at once. |
290| `coupons.coupon.updated` | Approval, re-enable, a status change among unknown, working and failed, or an expiry change. |
291| `coupons.coupon.expired` | The code leaves active results as expired. |
292| `coupons.coupon.disabled` | The code is taken down (by staff, a service, or because its last Sources evidence was removed). |
293| `coupons.report.created` | A new report (not a deduplicated repeat). The payload is the outcome, reason, channel and day only. |
294| `coupons.confidence.changed` | The confidence moved, because of reports or decay. The payload is from, to and status. |
295| `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. |
296| `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. |
297
298The charter's `coupons.created|updated|expired|disabled` are these `coupons.coupon.*` 3-segment
299types. Every envelope validates as `events.event-envelope@1` (tested), with actor
300`{type: service, id: coupons}`, except `coupons.moderation.action`, whose actor is the staff member.
301
302Coupons subscribes to `sources.item.*` and `sources.fetch.failed` with `scripts/subscribe.js`
303(endpoint `/internal/events`, delivery signed with `COUPONS_EVENTS_SECRET`; the import's cursor pull
304works without it). `scripts/subscribe.js --reconcile` re-stamps every merchant and every code (any status, so a code that
305left active results gets its tombstone) in one bounded, idempotent pass and reports `{ sent, unchanged }`.
306
307## Capabilities (registered in openvibe-contracts v0.97.0)
308
309Service tokens use audience `openvibe.coupons`, with one capability per route:
310
311- `coupons.coupon.submit` (the charter's `coupons.submit`)
312- `coupons.report.create` (the charter's `coupons.report`)
313- `coupons.merchant.resolve`
314- `coupons.coupon.lookup` (the charter's `coupons.lookup`)
315- `coupons.status.update`
316- `coupons.merchant.manage` (new: the staff and service merchant administration the lookups
317 depend on)
318
319Grants for these ids are decided with the contracts library's matching rule
320(`server/auth/capabilities.js`). The ids and the service manifest are released in
321openvibe-contracts v0.97.0 from the proposals in `docs/capabilities-proposal/` and
322`docs/service-manifest-proposal.json`. Install scopes (`coupons.lookup`, `coupons.report`) are
323Coupons-local and aren't capabilities.
324
325What Coupons calls elsewhere, and with which grant, is under
326[Grants the Network must hold](#grants-the-network-must-hold).
327
328## Depends on
329
330- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through
331 `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional: without `VALKEY_URL` they count per process).
332- **Packages** (all pinned by release tarball): `openvibe-publishing` v1.7.0 (seo gate, ssr,
333 index-hooks), `openvibe-contracts` v0.97.0, `openvibe-shared` v4.19.0 (Frame, app icon, footer,
334 legal, release, metrics, ready, seo), `openvibe-sdk` v0.67.0 (events outbox, service tokens,
335 per-actor limits).
336- **OpenVibe.Network:**
337 - SSO: an OAuth client `coupons` with redirect `https://openvibe.coupons/auth/callback`
338 (registered in production).
339 - JWKS.
340- **OpenVibe.Sources:** `sources.item.read`, category `coupons`. The seeded `staff-coupon-codes`
341 source is disabled until a person enables it.
342- **OpenVibe.Events:** `events.event.publish`.
343- **OpenVibe.Search:** consumes `coupons.index_document.*`. `coupons` has to be in Search's
344 `SEARCH_EVENT_OWNERS` (it is, in production).
345
346### Grants the Network must hold
347
348Each grant is `[client, capability, audience]`:
349
350- `[coupons, events.event.publish, openvibe.events]`
351- `[coupons, events.subscription.manage, openvibe.events]` (only to create the Sources
352 subscriptions: `scripts/subscribe.js`)
353- `[coupons, sources.item.read, openvibe.sources]` (only if the Sources import is turned on)
354- For OpenVibe.AI to deliver `coupons.extract_coupon` drafts:
355 `[ai, coupons.coupon.submit, openvibe.coupons]`
356- Optional, for services that look up or moderate:
357 `[<client>, coupons.coupon.lookup, openvibe.coupons]`,
358 `[<client>, coupons.merchant.resolve, openvibe.coupons]`,
359 `[<client>, coupons.status.update, openvibe.coupons]` and
360 `[<client>, coupons.merchant.manage, openvibe.coupons]`
361
362## Acceptance (automated: `npm test`)
363
364| Charter / roadmap requirement | Test |
365|---|---|
366| 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` |
367| 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` |
368| 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` |
369| 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` |
370| The confidence formula, with numbers. | `test/confidence.test.js` |
371| Token revocation is immediate, tokens are hashed at rest, scopes are enforced, and expired or unknown tokens are refused. | `test/extension-privacy.test.js` |
372| 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` |
373| Domain normalization: PSL subset, wildcards and exceptions, platform subdomains, path rules, never across sites. | `test/domains.test.js` |
374| Sources import: evidence with provenance, holds with reasons (never dropped), retries, withdrawal, and a failed fetch changes nothing. | `test/sources.test.js` |
375| 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` |
376| The contract proposals are valid against the released schemas and match the code and its events. | `test/contracts.test.js` |
377
378## Security and threat review
379
380Reporting a vulnerability: [SECURITY.md](SECURITY.md).
381
382- **Identity:**
383 - Only verified Network JWTs (offline RS256 against JWKS), service tokens for audience
384 `openvibe.coupons`, and `cpx_` install tokens.
385 - A presented credential that fails is refused, never downgraded to anonymous.
386 - Identity never comes from a body or query.
387- **Malicious merchant pages:**
388 - The API ignores cookies.
389 - Every form needs an HMAC form token (`COUPONS_FORM_SECRET`) on top of the SameSite=Lax session
390 cookie.
391 - No CORS for web origins.
392 - Lookups hold no personal data and are identical for every caller.
393 - The connect page is `frame-ancestors 'none'` and `X-Frame-Options: DENY`.
394 - The browser helper never exposes an interface to pages: it has no content scripts, no
395 `externally_connectable` and no web-accessible resources (see OpenVibe.Extensions).
396- **Privacy:**
397 - Reporter keys are HMACs (`COUPONS_REPORTER_KEY_SECRET`).
398 - Submitters and reporters are never returned or emitted.
399 - Report times are published to the hour.
400 - Watches are private.
401- **XSS:** every value goes through `openvibe-publishing/ssr` auto-escaping. There's no page script
402 of Coupons' own beyond the shared navbar init, and CSP comes from helmet. Evidence links are
403 `rel="nofollow ugc noopener"`.
404- **SSRF:** Coupons never fetches a user-supplied URL. Evidence URLs are stored and linked, never
405 requested. The only outbound calls go to the configured Network, Sources and Events hosts.
406- **Abuse:**
407 - Per-person report and submission limits, per-install and per-IP lookup limits, and nginx
408 zones.
409 - Members can't create merchants that resolve: new sites are pending until staff approve them.
410 - A taken-down code can't be resubmitted.
411- **Known gaps:**
412 - No Community discussion on code pages yet. The charter lists comments, and none are
413 implemented.
414 - Watches don't notify: OpenVibe.Network has no notification consumer yet.
415 - There's no staff UI to edit a code's text or restrictions. Staff can take a code down and ask
416 for a resubmission.
417 - The public-suffix subset needs maintenance as merchants are added.
418 - Report moderation (voiding one person's reports) isn't built. Rate limits and "latest per
419 person counts" bound the damage.
420
421## Launch rule
422
423The plan §12.12 launch rule is met: `openvibe.coupons` is served from this service's own domain
424since 2026-10-09, no longer as a placeholder on
425[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) (plan T11, which deletes
426OpenVibe.Sites). Status against each point:
427
4281. **Runtime, health, readiness, observability:** done.
4292. **Canonical identity and auth:** done. The OAuth client, service principal and grants exist in production.
4303. **SSR public routes useful without JS:** done.
4314. **Persistence and end-to-end workflows:** done and deployed on the host (empty database).
4325. **Capability and event registration against OpenVibe.Contracts:** done (openvibe-contracts
433 v0.97.0).
4346. **Migration and seed strategy, threat review, sitemap/robots/feed behaviour:**
435 - There's nothing to migrate and nothing is seeded.
436 - Codes come from members, staff and the Sources `coupons` category.
437 - The independent pre-launch security review (2026-10-09) and the sitemap, robots and feed
438 behaviour are done.
4397. **Acceptance tests:** done.
440
441**The launch release did all of these in one release:**
442
443- Removed `openvibe.coupons` from `OpenVibe.Sites/sites.json`.
444- Switched routing: nginx vhost, DNS and TLS.
445- Flipped the Network hub entry (`server/chrome/sites.js`, `status: 'soon'`).
446- Registered maturity in the ecosystem registry.
447
448A placeholder never counted as an implemented service: `openvibe.coupons` is served by this
449repository's code.
450
451## Development
452
453```bash
454fnm exec --using=22.22.1 npm install
455fnm exec --using=22.22.1 npm test # temp PostgreSQL databases (PGlite) and in-process mocks, no network
456fnm exec --using=22.22.1 npm run dev # http://localhost:4850 (set OV_OAUTH_CLIENT_SECRET to sign in)
457```
458
459## Deploy (for the lead)
460
461Production deploys with `sudo ovhost deploy coupons` on the host (strategy `git-checkout`: fetch,
462fast-forward `/opt/openvibe.coupons`, install on a lockfile change, restart, wait for `/api/ready`).
463The unit is `openvibe-coupons.service` on `127.0.0.1:4850`, the env file `/etc/openvibe/coupons.env`. The database is
464`ov_coupons` on the host's data role (`sudo /opt/openvibe.host/roles/data/add-service.sh coupons` writes its settings); the
465release migrates it at boot.
466Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the
467restart; afterwards `sudo ovhost rollback coupons --to <sha>`. Migrations only add tables and columns.
468
469First install (done once; kept for a rebuild):
470
4711. **Network:**
472 - Create the service principal and OAuth client `coupons` (`server/setup/service-principal.js`),
473 and put the secret in the env file.
474 - Add the redirect `https://openvibe.coupons/auth/callback` to the Network's seeded OAuth
475 clients.
476 - Add the grants above.
4772. **Code and config:**
478 - Put the code at `/opt/openvibe.coupons` and run `npm ci --omit=dev` on Node 22.
479 - Create `/etc/openvibe/coupons.env` (0600) from `.env.example`. Set these values:
480 - `OV_OAUTH_CLIENT_SECRET`
481 - `COUPONS_FORM_SECRET`
482 - `COUPONS_REPORTER_KEY_SECRET`
483 - `COUPONS_STAFF_SUBJECTS` (optional)
484 - `EVENTS_URL=http://127.0.0.1:4300`
485 - `BASE_URL=https://openvibe.coupons`
486 - `INDEXNOW_KEY` (optional; enables IndexNow — the key file at `/<key>.txt` and a ping when an indexable page changes. Unset: off, and drills leave it unset)
487 - Optionally set `OV_SOURCES_INTERNAL_URL=http://127.0.0.1:4720`.
488 - Once the extension has a store id, set `COUPONS_EXTENSION_ORIGINS`.
4893. **systemd:** install `deploy/systemd/openvibe-coupons.service` (port 4850).
4904. **nginx:** install `deploy/nginx/openvibe.coupons.conf`. `/metrics` is never proxied.
4915. **Search:** add `coupons` to `SEARCH_EVENT_OWNERS`.
4926. **Contracts:** done: the capabilities and manifest are released in openvibe-contracts v0.97.0,
493 and CI's contracts check runs against them.
4947. **Launch:** done on 2026-10-09: `openvibe.coupons` was removed from OpenVibe.Sites and the
495 Network hub entry was flipped (see the launch rule above).
496
497---
498
499Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
500
501<!-- versions:start -->
502- openvibe-contracts: v0.419.0
503- openvibe-sdk: v0.167.0
504- openvibe-shared: v4.249.0
505- openvibe-publishing: v1.7.0
506<!-- versions:end -->
507