openvibers/openvibe.coupons Commits
OpenVibe.Coupons service (Wave 18): merchants and normalized domains (bundled public-suffix subset, subdomain and path rules), codes with restrictions, honest expiry and evidence, status unknown|reported_working|reported_failed|expired|disabled from deduplicated, rate-limited people's reports only (documented confidence formula with recency decay), public lookup API with extension CORS and per-caller limits, revocable cpx_ extension install tokens, SSR pages useful without JavaScript, feeds, sitemaps, Sources import with holds, events and Search documents through the SDK outbox; README, STATUS alpha W18, .env.example, deploy (systemd, nginx on 4850), CI, capability and service manifest proposals
Changes
nnew file mode 100644@@@ -0,0 +1,73 @@++# ═══════════════════════════════════════════════════════════════++# openvibe.coupons — environment configuration (names only; never commit values)++# Copy to .env for development. Production: /etc/openvibe/coupons.env (0600).++# ═══════════════════════════════════════════════════════════════++++# Server++PORT=4850++HOST=127.0.0.1++NODE_ENV=development++# Public origin: canonical URLs, feeds, sitemaps and JSON-LD are built from it++BASE_URL=http://localhost:4850++# Proxy hops that set X-Forwarded-For (Cloudflare → nginx → Node = 2). Per-IP rate limits use it.++TRUST_PROXY=2++++# SQLite database (production unit: /var/lib/openvibe-coupons/coupons.db)++COUPONS_DB_PATH=./data/coupons.db++++# OpenVibe.Network — SSO (OAuth2), JWKS, client-credentials tokens++OV_NETWORK_URL=https://openvibe.network++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000++# OAuth client `coupons` (redirect https://openvibe.coupons/auth/callback). The same client is the++# service principal svc:coupons for client-credentials grants (see README, "Grants").++OV_OAUTH_CLIENT_ID=coupons++OV_OAUTH_CLIENT_SECRET=++# Production: https://openvibe.coupons/auth/callback (default: BASE_URL + /auth/callback)++#OV_OAUTH_REDIRECT_URI=++# Cookies: Secure by default in production; set false for plain-http local development++#COOKIE_SECURE=false++# Signs the form tokens (CSRF). Unset: random per process.++#COUPONS_FORM_SECRET=++# Keys the reporter column (HMAC of the person). Set it in production; changing it restarts++# same-day deduplication. Unset: a development key (and /api/ready says so).++#COUPONS_REPORTER_KEY_SECRET=++++# Staff (review, moderation): Network admins, plus these subjects (usr_…, comma-separated)++#COUPONS_STAFF_SUBJECTS=++++# Browser helper: extension origins allowed by CORS on the two lookup routes only.++# Exact origins (chrome-extension://<id>) or the literal moz-extension://* (Firefox install origins++# are random). Empty: no CORS; the helper still works through its host permission.++#COUPONS_EXTENSION_ORIGINS=++# Extension install tokens expire after this many days++#COUPONS_INSTALL_TTL_DAYS=365++++# Rate limits++#COUPONS_LOOKUP_ANON_PER_MIN=30++#COUPONS_LOOKUP_TOKEN_PER_MIN=120++#COUPONS_LOOKUP_SERVICE_PER_MIN=600++#COUPONS_REPORTS_PER_HOUR=20++#COUPONS_REPORTS_PER_DAY=60++#COUPONS_SUBMISSIONS_PER_HOUR=10++#COUPONS_SUBMISSIONS_PER_DAY=30++#COUPONS_INSTALLS_PER_SUBJECT=10++++# OpenVibe.Sources — import the `coupons` category (runs when this and OV_OAUTH_CLIENT_SECRET are set)++#OV_SOURCES_INTERNAL_URL=http://127.0.0.1:4720++# Only these source keys (comma-separated); empty: every coupons source. Others are held as not_selected.++#COUPONS_SOURCES_KEYS=staff-coupon-codes++#COUPONS_SOURCES_INTERVAL_MS=600000++# true: imported codes are listed without staff review (still status unknown)++#COUPONS_SOURCES_AUTO_PUBLISH=false++++# OpenVibe.Events — the outbox relay runs only when EVENTS_URL and OV_OAUTH_CLIENT_SECRET are set++#EVENTS_URL=http://127.0.0.1:4300++#EVENTS_RELAY_INTERVAL_MS=2000++++# Expiry / confidence-decay sweep (in-process); off: COUPONS_WORKER=off++#COUPONS_WORKER=on++#COUPONS_SWEEP_INTERVAL_MS=60000++++# release.json (openvibe-shared/release): the deployed commit when git is not available++#RELEASE_COMMIT=++#MIN_CLIENT_RELEASE=nnew file mode 100644@@@ -0,0 +1,20 @@++name: ci++on:++ push:++ branches: [main]++ pull_request:++jobs:++ test:++ runs-on: ubuntu-latest++ steps:++ - uses: actions/checkout@v4++ - uses: actions/setup-node@v4++ with:++ node-version: 22.22.1 # production runs Node 22++ cache: npm++ - run: npm ci --no-audit --no-fund++ - run: npm test++ # Coupons' service manifest and capabilities are proposals (docs/) until the lead releases them++ # in openvibe-contracts; until then the check reports the placeholder manifest's range.++ - run: npx openvibe-contracts-check --service coupons --src server++ continue-on-error: truennew file mode 100644@@@ -0,0 +1,12 @@++node_modules/++data/++dist/++.env++.env.*++!.env.example++*.db++*.db-wal++*.db-shm++*.log++*.pem++*.key@@@ -2,76 +2,424 @@> Coupon codes with merchant matching, restrictions, expiry and real-people validity reports.--**Status:** placeholder — planning only, no runnable code yet.--**Domain:** `openvibe.coupons`--**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §12.10.++**Status:** alpha (roadmap Wave 18, Coupons part). The service runs and its tests pass. It is **not++deployed**, `openvibe.coupons` still shows its placeholder from OpenVibe.Sites, it holds **no codes**++(nothing is seeded), and its capabilities and service manifest are proposals that the next++openvibe-contracts release has to include.++**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--Merchants, normalised domains, codes, restrictions, validity reports, expiry/confidence and the API a future browser helper will use. Distinct from Deals even where they cross-link.++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](https://github.com/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" (`null` in 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--- `coupon_merchants`, `coupon_merchant_domains`, `coupons`, `coupon_sources`, `coupon_restrictions`, `coupon_validation_reports`, `coupon_status_history`, `coupon_application_hints`, `coupon_watches`--- status model `unknown|reported_working|reported_failed|expired|disabled`++The nine charter tables live in Coupons' own SQLite (`COUPONS_DB_PATH`):++++| 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_state` and `coupon_import_holds`: the Sources importer.++- `event_outbox`: the SDK transactional outbox.++- `coupons_index_revisions`: the openvibe-publishing index sequencer.++++Nothing is seeded, and no code, merchant or report is invented for a demo.## Does not own--- deal offers (Deals)--- discussion (Community)++- **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 `coupons` category.++- **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:**++ - `disabled` if staff or a service took the code down.++ - Otherwise `expired` if a known expiry has passed, or staff recorded that it ended.++ - Otherwise `unknown` if W + F < 0.5.++ - Otherwise `reported_working` if confidence ≥ 0.6, `reported_failed` if confidence ≤ 0.4, and++ `unknown` in 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 still `unknown` for 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 > now` in 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++ records `expired` at the stated instant, with history, `coupons.coupon.expired` and a Search++ tombstone. Running it again changes nothing.++- **Nobody sets working or failed:**++ - A submission carrying `status`, `confidence`, `verified`, `working`, `valid` or `validity` is++ refused (422 `coupon.status_not_accepted`). That applies to people, services and AI output++ alike.++ - Staff and services with `coupons.status.update` can 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: ai` are refused.++ - AI-extracted codes (`coupons.extract_coupon` output) are held for staff review. They start++ `unknown` and carry an on-page disclosure.++++## 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, `localhost` and 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 as `myshopify.com`, `github.io` and `netlify.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.++- **Rules:**++ - A rule names a host at or below a registrable domain. A rule on a public suffix is refused.++ - `include_subdomains` covers every subdomain, but never across the looked-up host's registrable++ domain.++ - A `path_prefix` matches 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 |++|---|---|++| `/` | Shops with their active-code counts, recently added codes, and search (`?q=`, 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. |++| `/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. |++++### Discoverability (roadmap §32)--## Planned surfaces++- Every page gets its robots meta, canonical and `X-Robots-Tag` from the `openvibe-publishing/seo`++ gate:++ - 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.++- JSON-LD is `BreadcrumbList` and `WebPage` only, built from real fields. There is no `Offer`,++ `validThrough` or 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.--- ingest/submit, merchant/domain normalisation, restriction parsing, community validity reports with abuse controls, confidence from evidence only, expiry cleanup--- `GET /api/v1/merchants/resolve?host=`, `GET /api/v1/merchants/:id/coupons`, `POST /api/v1/coupons/:id/report`, `POST /api/v1/coupons/submit`; scoped extension credentials++### Caching--## Data (authority tables / families)++- Pages rendered for anonymous visitors are `public, max-age=60`. Signed-in pages (which carry++ form tokens) are `private, 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 is `private, no-store`.--- see above++## API (`/api/v1`)--## Capabilities and events++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.--- `coupons.submit`, `coupons.report`, `coupons.merchant.resolve`, `coupons.lookup`, `coupons.status.update`++| 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` |++| `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` |--Events: ``coupons.created|updated|expired|disabled``, ``coupons.report.created``, ``coupons.confidence.changed``++- **CORS:**++ - Only the two lookup routes send CORS headers, and only to origins in++ `COUPONS_EXTENSION_ORIGINS`. Those are exact `chrome-extension://<id>` origins, and optionally++ the literal `moz-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.++- **Rate limits:**++ - Lookups: `COUPONS_LOOKUP_ANON_PER_MIN` (30) per IP, `COUPONS_LOOKUP_TOKEN_PER_MIN` (120) per++ install, and `COUPONS_LOOKUP_SERVICE_PER_MIN` (600) per service.++ - Reports: `COUPONS_REPORTS_PER_HOUR` (20) and `COUPONS_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.++- **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-extension` and 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) and `coupons.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 most++ `COUPONS_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.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}`.++++## Capabilities (proposed: `docs/capabilities-proposal/`)++++Service tokens use audience `openvibe.coupons`, with one capability per route:++++- `coupons.coupon.submit` (the charter's `coupons.submit`)++- `coupons.report.create` (the charter's `coupons.report`)++- `coupons.merchant.resolve`++- `coupons.coupon.lookup` (the charter's `coupons.lookup`)++- `coupons.status.update`++- `coupons.merchant.manage` (new: the staff and service merchant administration the lookups++ depend on)++++Until the proposals are released, grants for these ids are decided locally with the contracts++library's matching rule (`server/auth/capabilities.js`). Nothing changes when they are released.++Install scopes (`coupons.lookup`, `coupons.report`) are Coupons-local and aren't capabilities. The++service manifest proposal is `docs/service-manifest-proposal.json`.## Depends on--- source registry--- OpenVibe.Community--- Notifications--- Search--- OpenVibe.Events++- **Packages** (all pinned by release tarball): `openvibe-publishing` v0.2.0 (seo gate, ssr,++ index-hooks), `openvibe-contracts` v0.19.0, `openvibe-shared` v1.3.0 (chrome, app icon, footer,++ legal, release, metrics, ready, seo), `openvibe-sdk` v0.2.2 (events outbox, service tokens).++- **OpenVibe.Network:**++ - SSO: an OAuth client `coupons` with redirect `https://openvibe.coupons/auth/callback`. It is++ **not seeded yet**.++ - JWKS.++- **OpenVibe.Sources:** `sources.item.read`, category `coupons`. The seeded `staff-coupon-codes`++ source is disabled until a person enables it.++- **OpenVibe.Events:** `events.event.publish`.++- **OpenVibe.Search:** consumes `coupons.index_document.*`. `coupons` has to be in Search's++ `SEARCH_EVENT_OWNERS`.++++### Grants the Network must hold--## Acceptance (must be true before "done")++Each grant is `[client, capability, audience]`:--- expired/dead codes leave active results predictably--- reports are deduplicated and rate-limited--- a malicious merchant page cannot obtain another user's account data--- no code is labelled working because a model guessed it++- `[coupons, events.event.publish, openvibe.events]`++- `[coupons, sources.item.read, openvibe.sources]` (only if the Sources import is turned on)++- For OpenVibe.AI to deliver `coupons.extract_coupon` drafts:++ `[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]`--## Bootstrap / extraction source++## Acceptance (automated: `npm test`)--No current implementation; Wave 16.++| 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++++- **Identity:**++ - Only verified Network JWTs (offline RS256 against JWKS), service tokens for audience++ `openvibe.coupons`, and `cpx_` install tokens.++ - A presented credential that fails is refused, never downgraded to anonymous.++ - Identity never comes from a body or query.++- **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'` and `X-Frame-Options: DENY`.++ - The browser helper never exposes an interface to pages: it has no content scripts, no++ `externally_connectable` and 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.++- **XSS:** every value goes through `openvibe-publishing/ssr` auto-escaping. There's no page script++ of Coupons' own beyond the shared navbar init, and CSP comes from helmet. Evidence links are++ `rel="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: there's no Network notification grant 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--This repository does not make the product real, and the domain keeps its placeholder page on--[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of the--following exist here (plan §12.12):----1. an owning runtime with health/readiness endpoints and observability;--2. canonical identity/auth integration (OpenVibe.Network subjects, scoped service principals);--3. server-rendered or static public routes that are useful without JavaScript;--4. real persistence and end-to-end workflows;--5. capability and event registration against `OpenVibe.Contracts`;--6. a migration/seed strategy, a security/threat review, and sitemap/robots/feed behaviour;--7. acceptance tests proving the advertised functionality.----The launch release removes the domain from `OpenVibe.Sites/sites.json`, switches routing and--registers maturity in the ecosystem registry atomically. A placeholder is never counted as an--implemented service.++This repository alone doesn't make the product live. `openvibe.coupons` keeps its placeholder on++[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of plan §12.12++holds. Status against each point:++++1. **Runtime, health, readiness, observability:** done.++2. **Canonical identity and auth:** done. The OAuth client still has to be seeded in the Network.++3. **SSR public routes useful without JS:** done.++4. **Persistence and end-to-end workflows:** done.++5. **Capability and event registration against OpenVibe.Contracts:** proposals are in `docs/`,++ waiting on the release.++6. **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 `coupons` category.++ - The threat review and the sitemap, robots and feed behaviour are done.++7. **Acceptance tests:** done.++++**The launch release does all of these in one release:**++++- Removes `openvibe.coupons` from `OpenVibe.Sites/sites.json`.++- Switches routing: nginx vhost, DNS and TLS.++- Flips the Network hub entry (`server/chrome/sites.js`, `status: 'soon'`).++- Registers maturity in the ecosystem registry.++++A placeholder never counts as an implemented service, and this README doesn't call the service++live.++++## Development++++```bash++fnm exec --using=22.22.1 npm install++fnm exec --using=22.22.1 npm test # temp databases 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)++++1. **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/callback` to the Network's seeded OAuth++ clients.++ - Add the grants above.++2. **Code and config:**++ - Put the code at `/opt/openvibe.coupons` and run `npm ci --omit=dev` on Node 22.++ - Create `/etc/openvibe/coupons.env` (0600) from `.env.example`. Set these values:++ - `OV_OAUTH_CLIENT_SECRET`++ - `COUPONS_FORM_SECRET`++ - `COUPONS_REPORTER_KEY_SECRET`++ - `COUPONS_STAFF_SUBJECTS` (optional)++ - `EVENTS_URL=http://127.0.0.1:4300`++ - `BASE_URL=https://openvibe.coupons`++ - Optionally set `OV_SOURCES_INTERNAL_URL=http://127.0.0.1:4720`.++ - Once the extension has a store id, set `COUPONS_EXTENSION_ORIGINS`.++3. **systemd:** install `deploy/systemd/openvibe-coupons.service` (port 4850,++ `StateDirectory=openvibe-coupons`).++4. **nginx:** install `deploy/nginx/openvibe.coupons.conf`. `/metrics` is never proxied.++5. **Search:** add `coupons` to `SEARCH_EVENT_OWNERS`.++6. **Contracts:** release the capability and manifest proposals in openvibe-contracts. Then CI's++ contracts check can drop `continue-on-error`.++7. **Launch:** in the same release, remove `openvibe.coupons` from OpenVibe.Sites and flip the++ Network hub entry (see the launch rule above).---@@@ -1,7 +1,33 @@{"repository": "OpenVibers/OpenVibe.Coupons",-- "stage": "placeholder",-- "code": false,++ "stage": "alpha",++ "code": true,++ "wave": "W18","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",-- "updated": "2026-09-21"++ "runtime": "node 22, express 4, better-sqlite3 11",++ "port": 4850,++ "domain": "openvibe.coupons",++ "packages": [++ "openvibe-publishing v0.2.0",++ "openvibe-contracts v0.19.0",++ "openvibe-shared v1.3.0",++ "openvibe-sdk v0.2.2"++ ],++ "capabilities": [++ "coupons.coupon.submit",++ "coupons.report.create",++ "coupons.merchant.resolve",++ "coupons.coupon.lookup",++ "coupons.status.update",++ "coupons.merchant.manage"++ ],++ "installScopes": ["coupons.lookup", "coupons.report"],++ "capabilitiesRegistered": false,++ "serviceManifestRegistered": false,++ "seedCoupons": "none",++ "notifications": "watches are recorded; no notification is sent yet",++ "client": "OpenVibers/OpenVibe.Extensions browser/ (not published to any store)",++ "deployed": false,++ "placeholderInSites": true,++ "updated": "2026-09-22"}nnew file mode 100644@@@ -0,0 +1,109 @@++## ═══════════════════════════════════════════════════════════════++## openvibe.coupons — Nginx configuration (REFERENCE)++##++## Deployed to /etc/nginx/sites-available/openvibe.coupons.conf — keep them in sync and reload nginx++## after changing it. The Node app (port 4850, systemd unit openvibe-coupons) renders every page;++## nginx terminates TLS, redirects www → apex, rate-limits auth, forms and API, and compresses text.++##++## No proxy_cache here on purpose: the app decides cacheability per response (public only for pages++## rendered for anonymous visitors and for the lookup API, whose answers are the same for everyone;++## private, no-store for everything else). CORS headers come from the app (extension origins on the++## two lookup routes only) — do not add any here.++## ═══════════════════════════════════════════════════════════════++++limit_req_zone $binary_remote_addr zone=ovcoupons_lookup:10m rate=5r/s;++limit_req_zone $binary_remote_addr zone=ovcoupons_api:10m rate=5r/s;++limit_req_zone $binary_remote_addr zone=ovcoupons_auth:10m rate=10r/m;++limit_req_zone $binary_remote_addr zone=ovcoupons_forms:10m rate=2r/s;++++server {++ listen 80;++ listen [::]:80;++ server_name openvibe.coupons www.openvibe.coupons;++++ location /.well-known/acme-challenge/ { root /var/www/certbot; }++ location / { return 301 https://openvibe.coupons$request_uri; }++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name www.openvibe.coupons;++++ ssl_certificate /etc/letsencrypt/live/openvibe.coupons/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.coupons/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ return 301 https://openvibe.coupons$request_uri;++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name openvibe.coupons;++++ ssl_certificate /etc/letsencrypt/live/openvibe.coupons/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.coupons/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ access_log /var/log/nginx/openvibe.coupons.access.log;++ error_log /var/log/nginx/openvibe.coupons.error.log;++++ # Forms and JSON bodies are small (no uploads).++ client_max_body_size 64k;++++ add_header X-Content-Type-Options nosniff always;++ add_header Referrer-Policy strict-origin-when-cross-origin always;++ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;++++ gzip on;++ gzip_proxied any;++ gzip_vary on;++ gzip_comp_level 5;++ gzip_min_length 1024;++ gzip_types text/css application/javascript text/javascript application/json application/problem+json application/feed+json image/svg+xml text/plain application/xml text/xml application/rss+xml application/atom+xml;++++ proxy_http_version 1.1;++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;++ proxy_set_header X-Forwarded-Proto $scheme;++++ # ── Network SSO session endpoints (strict) ──────────────++ location /auth/ {++ limit_req zone=ovcoupons_auth burst=10 nodelay;++ proxy_pass http://127.0.0.1:4850;++ }++++ # ── The browser helper's lookups (the app also limits per install) ──++ location ~ ^/api/v1/merchants/(resolve|[^/]+/coupons)$ {++ limit_req zone=ovcoupons_lookup burst=20 nodelay;++ proxy_pass http://127.0.0.1:4850;++ }++++ # ── API ─────────────────────────────────────────────────++ location /api/ {++ limit_req zone=ovcoupons_api burst=20 nodelay;++ proxy_pass http://127.0.0.1:4850;++ }++++ # ── Forms (submit, reports, connect-extension, staff) ───++ location ~ ^/(submit|connect-extension|staff|c/[^/]+/report|m/[^/]+/watch) {++ limit_req zone=ovcoupons_forms burst=10 nodelay;++ proxy_pass http://127.0.0.1:4850;++ }++++ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4850/metrics) ──++ location = /metrics { return 404; }++++ # ── Pages, feeds, sitemaps, robots.txt, llms.txt, static assets ──++ location / {++ proxy_pass http://127.0.0.1:4850;++ }++++ location ~ /\.(?!well-known) {++ deny all;++ }++}nnew file mode 100644@@@ -0,0 +1,32 @@++[Unit]++Description=OpenVibe.Coupons — coupon codes with merchant matching, honest expiry and people's validity reports++# Network (sign-in, JWKS, service tokens), Events (the outbox relay) and Sources (imports) should be++# up first, but Coupons degrades gracefully without them: pages, lookups and feeds serve; sign-in,++# events and imports wait (see /api/ready).++After=network.target++Wants=openvibe-network.service++++[Service]++Type=simple++User=ubuntu++Group=ubuntu++WorkingDirectory=/opt/openvibe.coupons++EnvironmentFile=/etc/openvibe/coupons.env++Environment=NODE_ENV=production++Environment=PORT=4850++Environment=PATH=/usr/local/bin:/usr/bin:/bin++ExecStart=/usr/bin/env node /opt/openvibe.coupons/server/index.js++Restart=always++RestartSec=5++KillSignal=SIGTERM++TimeoutStopSec=10++NoNewPrivileges=true++PrivateTmp=true++ProtectSystem=strict++ProtectHome=true++# Coupons' own database lives in /var/lib/openvibe-coupons; the code tree stays read-only.++StateDirectory=openvibe-coupons++Environment=COUPONS_DB_PATH=/var/lib/openvibe-coupons/coupons.db++++[Install]++WantedBy=multi-user.targetnnew file mode 100644@@@ -0,0 +1,21 @@++{++ "id": "coupons.coupon.lookup",++ "version": "1.0",++ "owner": "coupons",++ "status": "active",++ "visibility": "first-party",++ "description": "Read a merchant's active codes (status, confidence, report aggregates, restrictions, expiry or unknown, evidence) or one code. Public data only, the same response for every caller; anonymous callers may use the routes with tighter rate limits. The charter's coupons.lookup.",++ "permissions": [++ "coupons:read"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "coupons-lookup",++ "events": [],++ "implementedBy": [++ "GET /api/v1/merchants/:id/coupons",++ "GET /api/v1/merchants/:id",++ "GET /api/v1/coupons/:id"++ ]++}nnew file mode 100644@@@ -0,0 +1,25 @@++{++ "id": "coupons.coupon.submit",++ "version": "1.0",++ "owner": "coupons",++ "status": "active",++ "visibility": "first-party",++ "description": "Submit a coupon code for a merchant (by merchant id, host or URL) on behalf of a person (X-OV-Subject) or as AI output (X-OV-Origin: ai, held for staff review). The code starts with status unknown; a status, confidence or 'verified' field is refused. A site no merchant covers becomes a pending merchant for staff review. The charter's coupons.submit.",++ "permissions": [++ "coupons:write"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "coupons-write",++ "events": [++ "coupons.coupon.created",++ "coupons.coupon.updated",++ "coupons.confidence.changed",++ "coupons.index_document.upserted",++ "coupons.index_document.deleted"++ ],++ "implementedBy": [++ "POST /api/v1/coupons/submit"++ ]++}nnew file mode 100644@@@ -0,0 +1,25 @@++{++ "id": "coupons.merchant.manage",++ "version": "1.0",++ "owner": "coupons",++ "status": "active",++ "visibility": "first-party",++ "description": "Create merchants with their domain rules (never on a public suffix), add domain rules, and approve or disable merchants. Approving a pending merchant publishes the codes members submitted for it; disabling one takes its codes out of active results and Search.",++ "permissions": [++ "coupons:moderate"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "coupons-write",++ "events": [++ "coupons.coupon.updated",++ "coupons.index_document.upserted",++ "coupons.index_document.deleted"++ ],++ "implementedBy": [++ "POST /api/v1/merchants",++ "POST /api/v1/merchants/:id/domains",++ "POST /api/v1/merchants/:id/status"++ ]++}nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "id": "coupons.merchant.resolve",++ "version": "1.0",++ "owner": "coupons",++ "status": "active",++ "visibility": "first-party",++ "description": "Resolve a hostname to an active merchant (normalized host, eTLD+1 from a bundled public-suffix subset, subdomain rules; path rules never match host-only lookups). Public data only; anonymous callers may use the route with tighter rate limits.",++ "permissions": [++ "coupons:read"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "coupons-lookup",++ "events": [],++ "implementedBy": [++ "GET /api/v1/merchants/resolve?host="++ ]++}nnew file mode 100644@@@ -0,0 +1,24 @@++{++ "id": "coupons.report.create",++ "version": "1.0",++ "owner": "coupons",++ "status": "active",++ "visibility": "first-party",++ "description": "Report that a code worked or failed, on behalf of a person (X-OV-Subject). One report per person, code and UTC day; only the latest per person counts; rate-limited per person; AI output (X-OV-Origin: ai) is refused. The response carries aggregates only, never a reporter. The charter's coupons.report.",++ "permissions": [++ "coupons:report"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "coupons-report",++ "events": [++ "coupons.report.created",++ "coupons.coupon.updated",++ "coupons.confidence.changed",++ "coupons.index_document.upserted"++ ],++ "implementedBy": [++ "POST /api/v1/coupons/:id/report"++ ]++}nnew file mode 100644@@@ -0,0 +1,26 @@++{++ "id": "coupons.status.update",++ "version": "1.0",++ "owner": "coupons",++ "status": "active",++ "visibility": "first-party",++ "description": "Moderate a code: disable it, mark it expired, or return it to active results (its status is then recomputed from people's reports and time). reported_working and reported_failed cannot be set by anyone.",++ "permissions": [++ "coupons:moderate"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "coupons-write",++ "events": [++ "coupons.coupon.disabled",++ "coupons.coupon.expired",++ "coupons.coupon.updated",++ "coupons.confidence.changed",++ "coupons.index_document.upserted",++ "coupons.index_document.deleted"++ ],++ "implementedBy": [++ "POST /api/v1/coupons/:id/status"++ ]++}nnew file mode 100644@@@ -0,0 +1,39 @@++{++ "version": "0.1.0",++ "contractRanges": {++ "openvibe-contracts": ">=0.19.0 <1.0.0"++ },++ "id": "coupons",++ "name": "OpenVibe.Coupons",++ "status": "alpha",++ "repository": "OpenVibers/OpenVibe.Coupons",++ "domains": [++ "openvibe.coupons"++ ],++ "publicOrigin": "https://openvibe.coupons",++ "health": "/api/health",++ "ready": "/api/ready",++ "capabilities": [++ "coupons.coupon.submit",++ "coupons.report.create",++ "coupons.merchant.resolve",++ "coupons.coupon.lookup",++ "coupons.status.update",++ "coupons.merchant.manage"++ ],++ "eventsProduced": [++ "coupons.coupon.created",++ "coupons.coupon.updated",++ "coupons.coupon.expired",++ "coupons.coupon.disabled",++ "coupons.report.created",++ "coupons.confidence.changed",++ "coupons.index_document.upserted",++ "coupons.index_document.deleted"++ ],++ "eventsConsumed": [],++ "namespacesOwned": [++ "coupons.*"++ ],++ "notes": "Port 4850. Owns coupon_merchants, coupon_merchant_domains, coupons, coupon_sources, coupon_restrictions, coupon_validation_reports, coupon_status_history, coupon_application_hints, coupon_watches in its own SQLite, plus extension_installs (cpx_ browser-helper tokens, scopes coupons.lookup and coupons.report, hashed, revocable). Consumes OpenVibe.Network (SSO, JWKS) and OpenVibe.Sources (sources.item.read, category coupons); publishes through OpenVibe.Events (events.event.publish); OpenVibe.Search reads coupons.index_document.* from Events. The browser helper lives in OpenVibers/OpenVibe.Extensions (ADR-023)."++}nnew file mode 100644@@@ -0,0 +1,1562 @@++{++ "name": "openvibe-coupons",++ "version": "0.1.0",++ "lockfileVersion": 3,++ "requires": true,++ "packages": {++ "": {++ "name": "openvibe-coupons",++ "version": "0.1.0",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "better-sqlite3": "^11.10.0",++ "cookie-parser": "^1.4.7",++ "dotenv": "^16.4.7",++ "express": "^4.21.2",++ "express-rate-limit": "^7.5.0",++ "helmet": "^8.1.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.19.0",++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.2.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"++ },++ "devDependencies": {++ "jsonwebtoken": "^9.0.2"++ },++ "engines": {++ "node": ">=22.0.0"++ }++ },++ "node_modules/accepts": {++ "version": "1.3.8",++ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",++ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",++ "license": "MIT",++ "dependencies": {++ "mime-types": "~2.1.34",++ "negotiator": "0.6.3"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/ajv": {++ "version": "8.20.0",++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",++ "license": "MIT",++ "dependencies": {++ "fast-deep-equal": "^3.1.3",++ "fast-uri": "^3.0.1",++ "json-schema-traverse": "^1.0.0",++ "require-from-string": "^2.0.2"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/epoberezkin"++ }++ },++ "node_modules/ajv-formats": {++ "version": "3.0.1",++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",++ "license": "MIT",++ "dependencies": {++ "ajv": "^8.0.0"++ },++ "peerDependencies": {++ "ajv": "^8.0.0"++ },++ "peerDependenciesMeta": {++ "ajv": {++ "optional": true++ }++ }++ },++ "node_modules/array-flatten": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",++ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",++ "license": "MIT"++ },++ "node_modules/base64-js": {++ "version": "1.5.1",++ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",++ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT"++ },++ "node_modules/better-sqlite3": {++ "version": "11.10.0",++ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",++ "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",++ "hasInstallScript": true,++ "license": "MIT",++ "dependencies": {++ "bindings": "^1.5.0",++ "prebuild-install": "^7.1.1"++ }++ },++ "node_modules/bindings": {++ "version": "1.5.0",++ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",++ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",++ "license": "MIT",++ "dependencies": {++ "file-uri-to-path": "1.0.0"++ }++ },++ "node_modules/bl": {++ "version": "4.1.0",++ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",++ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",++ "license": "MIT",++ "dependencies": {++ "buffer": "^5.5.0",++ "inherits": "^2.0.4",++ "readable-stream": "^3.4.0"++ }++ },++ "node_modules/body-parser": {++ "version": "1.20.8",++ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",++ "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",++ "license": "MIT",++ "dependencies": {++ "bytes": "~3.1.2",++ "content-type": "~1.0.5",++ "debug": "2.6.9",++ "depd": "2.0.0",++ "destroy": "~1.2.0",++ "http-errors": "~2.0.1",++ "iconv-lite": "~0.4.24",++ "on-finished": "~2.4.1",++ "qs": "~6.16.0",++ "raw-body": "~2.5.3",++ "type-is": "~1.6.18",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8",++ "npm": "1.2.8000 || >= 1.4.16"++ }++ },++ "node_modules/buffer": {++ "version": "5.7.1",++ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",++ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT",++ "dependencies": {++ "base64-js": "^1.3.1",++ "ieee754": "^1.1.13"++ }++ },++ "node_modules/buffer-equal-constant-time": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",++ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",++ "license": "BSD-3-Clause"++ },++ "node_modules/bytes": {++ "version": "3.1.2",++ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",++ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/call-bind-apply-helpers": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",++ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "function-bind": "^1.1.2"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/call-bound": {++ "version": "1.0.4",++ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",++ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",++ "license": "MIT",++ "dependencies": {++ "call-bind-apply-helpers": "^1.0.2",++ "get-intrinsic": "^1.3.0"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/chownr": {++ "version": "1.1.4",++ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",++ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",++ "license": "ISC"++ },++ "node_modules/content-disposition": {++ "version": "0.5.4",++ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",++ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",++ "license": "MIT",++ "dependencies": {++ "safe-buffer": "5.2.1"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/content-type": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",++ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/cookie": {++ "version": "0.7.2",++ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",++ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/cookie-parser": {++ "version": "1.4.7",++ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",++ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",++ "license": "MIT",++ "dependencies": {++ "cookie": "0.7.2",++ "cookie-signature": "1.0.6"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/cookie-signature": {++ "version": "1.0.6",++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",++ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",++ "license": "MIT"++ },++ "node_modules/debug": {++ "version": "2.6.9",++ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",++ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",++ "license": "MIT",++ "dependencies": {++ "ms": "2.0.0"++ }++ },++ "node_modules/decompress-response": {++ "version": "6.0.0",++ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",++ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",++ "license": "MIT",++ "dependencies": {++ "mimic-response": "^3.1.0"++ },++ "engines": {++ "node": ">=10"++ },++ "funding": {++ "url": "https://github.com/sponsors/sindresorhus"++ }++ },++ "node_modules/deep-extend": {++ "version": "0.6.0",++ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",++ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",++ "license": "MIT",++ "engines": {++ "node": ">=4.0.0"++ }++ },++ "node_modules/depd": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",++ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/destroy": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",++ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8",++ "npm": "1.2.8000 || >= 1.4.16"++ }++ },++ "node_modules/detect-libc": {++ "version": "2.1.2",++ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",++ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=8"++ }++ },++ "node_modules/dotenv": {++ "version": "16.6.1",++ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",++ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=12"++ },++ "funding": {++ "url": "https://dotenvx.com"++ }++ },++ "node_modules/dunder-proto": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",++ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",++ "license": "MIT",++ "dependencies": {++ "call-bind-apply-helpers": "^1.0.1",++ "es-errors": "^1.3.0",++ "gopd": "^1.2.0"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/ecdsa-sig-formatter": {++ "version": "1.0.11",++ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",++ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",++ "license": "Apache-2.0",++ "dependencies": {++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/ee-first": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",++ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",++ "license": "MIT"++ },++ "node_modules/encodeurl": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",++ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/end-of-stream": {++ "version": "1.4.5",++ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",++ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",++ "license": "MIT",++ "dependencies": {++ "once": "^1.4.0"++ }++ },++ "node_modules/es-define-property": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",++ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/es-errors": {++ "version": "1.3.0",++ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",++ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/es-object-atoms": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",++ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/escape-html": {++ "version": "1.0.3",++ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",++ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",++ "license": "MIT"++ },++ "node_modules/etag": {++ "version": "1.8.1",++ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",++ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/expand-template": {++ "version": "2.0.3",++ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",++ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",++ "license": "(MIT OR WTFPL)",++ "engines": {++ "node": ">=6"++ }++ },++ "node_modules/express": {++ "version": "4.22.3",++ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",++ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",++ "license": "MIT",++ "dependencies": {++ "accepts": "~1.3.8",++ "array-flatten": "1.1.1",++ "body-parser": "~1.20.5",++ "content-disposition": "~0.5.4",++ "content-type": "~1.0.4",++ "cookie": "~0.7.1",++ "cookie-signature": "~1.0.6",++ "debug": "2.6.9",++ "depd": "2.0.0",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "etag": "~1.8.1",++ "finalhandler": "~1.3.1",++ "fresh": "~0.5.2",++ "http-errors": "~2.0.0",++ "merge-descriptors": "1.0.3",++ "methods": "~1.1.2",++ "on-finished": "~2.4.1",++ "parseurl": "~1.3.3",++ "path-to-regexp": "~0.1.13",++ "proxy-addr": "~2.0.7",++ "qs": "~6.16.0",++ "range-parser": "~1.2.1",++ "safe-buffer": "5.2.1",++ "send": "~0.19.0",++ "serve-static": "~1.16.2",++ "setprototypeof": "1.2.0",++ "statuses": "~2.0.1",++ "type-is": "~1.6.18",++ "utils-merge": "1.0.1",++ "vary": "~1.1.2"++ },++ "engines": {++ "node": ">= 0.10.0"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/express-rate-limit": {++ "version": "7.5.1",++ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",++ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",++ "license": "MIT",++ "engines": {++ "node": ">= 16"++ },++ "funding": {++ "url": "https://github.com/sponsors/express-rate-limit"++ },++ "peerDependencies": {++ "express": ">= 4.11"++ }++ },++ "node_modules/fast-deep-equal": {++ "version": "3.1.3",++ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",++ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",++ "license": "MIT"++ },++ "node_modules/fast-uri": {++ "version": "3.1.8",++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",++ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fastify"++ },++ {++ "type": "opencollective",++ "url": "https://opencollective.com/fastify"++ }++ ],++ "license": "BSD-3-Clause"++ },++ "node_modules/file-uri-to-path": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",++ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",++ "license": "MIT"++ },++ "node_modules/finalhandler": {++ "version": "1.3.2",++ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",++ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",++ "license": "MIT",++ "dependencies": {++ "debug": "2.6.9",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "on-finished": "~2.4.1",++ "parseurl": "~1.3.3",++ "statuses": "~2.0.2",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/forwarded": {++ "version": "0.2.0",++ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",++ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/fresh": {++ "version": "0.5.2",++ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",++ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/fs-constants": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",++ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",++ "license": "MIT"++ },++ "node_modules/function-bind": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",++ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",++ "license": "MIT",++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/get-intrinsic": {++ "version": "1.3.0",++ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",++ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",++ "license": "MIT",++ "dependencies": {++ "call-bind-apply-helpers": "^1.0.2",++ "es-define-property": "^1.0.1",++ "es-errors": "^1.3.0",++ "es-object-atoms": "^1.1.1",++ "function-bind": "^1.1.2",++ "get-proto": "^1.0.1",++ "gopd": "^1.2.0",++ "has-symbols": "^1.1.0",++ "hasown": "^2.0.2",++ "math-intrinsics": "^1.1.0"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/get-proto": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",++ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",++ "license": "MIT",++ "dependencies": {++ "dunder-proto": "^1.0.1",++ "es-object-atoms": "^1.0.0"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/github-from-package": {++ "version": "0.0.0",++ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",++ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",++ "license": "MIT"++ },++ "node_modules/gopd": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",++ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/has-symbols": {++ "version": "1.1.0",++ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",++ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/hasown": {++ "version": "2.0.4",++ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",++ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",++ "license": "MIT",++ "dependencies": {++ "function-bind": "^1.1.2"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/helmet": {++ "version": "8.3.0",++ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",++ "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",++ "license": "MIT",++ "engines": {++ "node": ">=18.0.0"++ },++ "funding": {++ "url": "https://github.com/sponsors/EvanHahn"++ }++ },++ "node_modules/http-errors": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",++ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",++ "license": "MIT",++ "dependencies": {++ "depd": "~2.0.0",++ "inherits": "~2.0.4",++ "setprototypeof": "~1.2.0",++ "statuses": "~2.0.2",++ "toidentifier": "~1.0.1"++ },++ "engines": {++ "node": ">= 0.8"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/iconv-lite": {++ "version": "0.4.24",++ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",++ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",++ "license": "MIT",++ "dependencies": {++ "safer-buffer": ">= 2.1.2 < 3"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/ieee754": {++ "version": "1.2.1",++ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",++ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "BSD-3-Clause"++ },++ "node_modules/inherits": {++ "version": "2.0.4",++ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",++ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",++ "license": "ISC"++ },++ "node_modules/ini": {++ "version": "1.3.8",++ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",++ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",++ "license": "ISC"++ },++ "node_modules/ipaddr.js": {++ "version": "1.9.1",++ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",++ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.10"++ }++ },++ "node_modules/json-schema-traverse": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",++ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",++ "license": "MIT"++ },++ "node_modules/jsonwebtoken": {++ "version": "9.0.3",++ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",++ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",++ "license": "MIT",++ "dependencies": {++ "jws": "^4.0.1",++ "lodash.includes": "^4.3.0",++ "lodash.isboolean": "^3.0.3",++ "lodash.isinteger": "^4.0.4",++ "lodash.isnumber": "^3.0.3",++ "lodash.isplainobject": "^4.0.6",++ "lodash.isstring": "^4.0.1",++ "lodash.once": "^4.0.0",++ "ms": "^2.1.1",++ "semver": "^7.5.4"++ },++ "engines": {++ "node": ">=12",++ "npm": ">=6"++ }++ },++ "node_modules/jsonwebtoken/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },++ "node_modules/jwa": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",++ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",++ "license": "MIT",++ "dependencies": {++ "buffer-equal-constant-time": "^1.0.1",++ "ecdsa-sig-formatter": "1.0.11",++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/jws": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",++ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",++ "license": "MIT",++ "dependencies": {++ "jwa": "^2.0.1",++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/lodash.includes": {++ "version": "4.3.0",++ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",++ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",++ "license": "MIT"++ },++ "node_modules/lodash.isboolean": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",++ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",++ "license": "MIT"++ },++ "node_modules/lodash.isinteger": {++ "version": "4.0.4",++ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",++ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",++ "license": "MIT"++ },++ "node_modules/lodash.isnumber": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",++ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",++ "license": "MIT"++ },++ "node_modules/lodash.isplainobject": {++ "version": "4.0.6",++ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",++ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",++ "license": "MIT"++ },++ "node_modules/lodash.isstring": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",++ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",++ "license": "MIT"++ },++ "node_modules/lodash.once": {++ "version": "4.1.1",++ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",++ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",++ "license": "MIT"++ },++ "node_modules/math-intrinsics": {++ "version": "1.1.0",++ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",++ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/media-typer": {++ "version": "0.3.0",++ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",++ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/merge-descriptors": {++ "version": "1.0.3",++ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",++ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",++ "license": "MIT",++ "funding": {++ "url": "https://github.com/sponsors/sindresorhus"++ }++ },++ "node_modules/methods": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",++ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/mime": {++ "version": "1.6.0",++ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",++ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",++ "license": "MIT",++ "bin": {++ "mime": "cli.js"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/mime-db": {++ "version": "1.52.0",++ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",++ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/mime-types": {++ "version": "2.1.35",++ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",++ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",++ "license": "MIT",++ "dependencies": {++ "mime-db": "1.52.0"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/mimic-response": {++ "version": "3.1.0",++ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",++ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",++ "license": "MIT",++ "engines": {++ "node": ">=10"++ },++ "funding": {++ "url": "https://github.com/sponsors/sindresorhus"++ }++ },++ "node_modules/minimist": {++ "version": "1.2.8",++ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",++ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",++ "license": "MIT",++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/mkdirp-classic": {++ "version": "0.5.3",++ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",++ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",++ "license": "MIT"++ },++ "node_modules/ms": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",++ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",++ "license": "MIT"++ },++ "node_modules/napi-build-utils": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",++ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",++ "license": "MIT"++ },++ "node_modules/negotiator": {++ "version": "0.6.3",++ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",++ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/node-abi": {++ "version": "3.96.0",++ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",++ "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",++ "license": "MIT",++ "dependencies": {++ "semver": "^7.3.5"++ },++ "engines": {++ "node": ">=10"++ }++ },++ "node_modules/object-inspect": {++ "version": "1.13.4",++ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",++ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/on-finished": {++ "version": "2.4.1",++ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",++ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",++ "license": "MIT",++ "dependencies": {++ "ee-first": "1.1.1"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/once": {++ "version": "1.4.0",++ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",++ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",++ "license": "ISC",++ "dependencies": {++ "wrappy": "1"++ }++ },++ "node_modules/openvibe-contracts": {++ "version": "0.19.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.19.0",++ "integrity": "sha512-5DjPvXIfc68wZhFXgKlCzDarbbFD5dUV4yYQIKEK1qD+VIuR+4twbON1brdyM9rYZyRyYClPigAqReocmRGytg==",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "ajv": "^8.17.1",++ "ajv-formats": "^3.0.1"++ },++ "bin": {++ "openvibe-contracts-check": "bin/check-service.js"++ },++ "engines": {++ "node": ">=20"++ }++ },++ "node_modules/openvibe-publishing": {++ "version": "0.2.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.2.0",++ "integrity": "sha512-ZIvDLEIzOk+7A20FTywuiJsvUNVEfkpsa7qbm1PXxezDzIBFcEMJFXxGsm7Ran6rXQ1+Uio+LoBzp3YCC4kHiQ==",++ "license": "MIT",++ "dependencies": {++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0"++ },++ "engines": {++ "node": ">=20"++ },++ "peerDependencies": {++ "better-sqlite3": ">=11"++ },++ "peerDependenciesMeta": {++ "better-sqlite3": {++ "optional": true++ }++ }++ },++ "node_modules/openvibe-publishing/node_modules/openvibe-shared": {++ "version": "1.0.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0",++ "integrity": "sha512-JOSH6LbIVzE9oiVWZmUnjZ+Ab8dRQ/SPk9aqBjbJGXp6kBoJA+tj7x1mp6vCusaxGHp17b2r9gmJQXk1TRL/JA==",++ "license": "MIT",++ "dependencies": {++ "jsonwebtoken": "^9.0.0"++ }++ },++ "node_modules/openvibe-sdk": {++ "version": "0.2.2",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",++ "integrity": "sha512-kDifUp3EcmnTWzElAmaCZnX659WErcnm4+qVEoAiYQWX/w/1p4TWvoHbUPsrX65TSROVR3tiyVkl801n/0yyCw==",++ "license": "MIT",++ "engines": {++ "node": ">=20"++ }++ },++ "node_modules/openvibe-shared": {++ "version": "1.3.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",++ "integrity": "sha512-w8yl/5+9eend8I2O/DzAJnEAL2N3GTUY7c7pzK+MLJuLAGD8FmJJQ9gwbgyrq70Snjt3ebrg3TlpORhRAp2kgA==",++ "license": "MIT",++ "dependencies": {++ "jsonwebtoken": "^9.0.0"++ }++ },++ "node_modules/parseurl": {++ "version": "1.3.3",++ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",++ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/path-to-regexp": {++ "version": "0.1.13",++ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",++ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",++ "license": "MIT"++ },++ "node_modules/prebuild-install": {++ "version": "7.1.3",++ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",++ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",++ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",++ "license": "MIT",++ "dependencies": {++ "detect-libc": "^2.0.0",++ "expand-template": "^2.0.3",++ "github-from-package": "0.0.0",++ "minimist": "^1.2.3",++ "mkdirp-classic": "^0.5.3",++ "napi-build-utils": "^2.0.0",++ "node-abi": "^3.3.0",++ "pump": "^3.0.0",++ "rc": "^1.2.7",++ "simple-get": "^4.0.0",++ "tar-fs": "^2.0.0",++ "tunnel-agent": "^0.6.0"++ },++ "bin": {++ "prebuild-install": "bin.js"++ },++ "engines": {++ "node": ">=10"++ }++ },++ "node_modules/proxy-addr": {++ "version": "2.0.8",++ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",++ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",++ "license": "MIT",++ "dependencies": {++ "forwarded": "0.2.0",++ "ipaddr.js": "1.9.1"++ },++ "engines": {++ "node": ">= 0.10"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/pump": {++ "version": "3.0.4",++ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",++ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",++ "license": "MIT",++ "dependencies": {++ "end-of-stream": "^1.1.0",++ "once": "^1.3.1"++ }++ },++ "node_modules/qs": {++ "version": "6.16.0",++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",++ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",++ "license": "BSD-3-Clause",++ "dependencies": {++ "es-define-property": "^1.0.1",++ "side-channel": "^1.1.1"++ },++ "engines": {++ "node": ">=0.6"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/range-parser": {++ "version": "1.2.1",++ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",++ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/raw-body": {++ "version": "2.5.3",++ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",++ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",++ "license": "MIT",++ "dependencies": {++ "bytes": "~3.1.2",++ "http-errors": "~2.0.1",++ "iconv-lite": "~0.4.24",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/rc": {++ "version": "1.2.8",++ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",++ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",++ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",++ "dependencies": {++ "deep-extend": "^0.6.0",++ "ini": "~1.3.0",++ "minimist": "^1.2.0",++ "strip-json-comments": "~2.0.1"++ },++ "bin": {++ "rc": "cli.js"++ }++ },++ "node_modules/readable-stream": {++ "version": "3.6.2",++ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",++ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",++ "license": "MIT",++ "dependencies": {++ "inherits": "^2.0.3",++ "string_decoder": "^1.1.1",++ "util-deprecate": "^1.0.1"++ },++ "engines": {++ "node": ">= 6"++ }++ },++ "node_modules/require-from-string": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/safe-buffer": {++ "version": "5.2.1",++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",++ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT"++ },++ "node_modules/safer-buffer": {++ "version": "2.1.2",++ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",++ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",++ "license": "MIT"++ },++ "node_modules/semver": {++ "version": "7.8.5",++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",++ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",++ "license": "ISC",++ "bin": {++ "semver": "bin/semver.js"++ },++ "engines": {++ "node": ">=10"++ }++ },++ "node_modules/send": {++ "version": "0.19.2",++ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",++ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",++ "license": "MIT",++ "dependencies": {++ "debug": "2.6.9",++ "depd": "2.0.0",++ "destroy": "1.2.0",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "etag": "~1.8.1",++ "fresh": "~0.5.2",++ "http-errors": "~2.0.1",++ "mime": "1.6.0",++ "ms": "2.1.3",++ "on-finished": "~2.4.1",++ "range-parser": "~1.2.1",++ "statuses": "~2.0.2"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/send/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },++ "node_modules/serve-static": {++ "version": "1.16.3",++ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",++ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",++ "license": "MIT",++ "dependencies": {++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "parseurl": "~1.3.3",++ "send": "~0.19.1"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/setprototypeof": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",++ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",++ "license": "ISC"++ },++ "node_modules/side-channel": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",++ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "object-inspect": "^1.13.4",++ "side-channel-list": "^1.0.1",++ "side-channel-map": "^1.0.1",++ "side-channel-weakmap": "^1.0.2"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-list": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",++ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "object-inspect": "^1.13.4"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-map": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",++ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",++ "license": "MIT",++ "dependencies": {++ "call-bound": "^1.0.2",++ "es-errors": "^1.3.0",++ "get-intrinsic": "^1.2.5",++ "object-inspect": "^1.13.3"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-weakmap": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",++ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",++ "license": "MIT",++ "dependencies": {++ "call-bound": "^1.0.2",++ "es-errors": "^1.3.0",++ "get-intrinsic": "^1.2.5",++ "object-inspect": "^1.13.3",++ "side-channel-map": "^1.0.1"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/simple-concat": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",++ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT"++ },++ "node_modules/simple-get": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",++ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT",++ "dependencies": {++ "decompress-response": "^6.0.0",++ "once": "^1.3.1",++ "simple-concat": "^1.0.0"++ }++ },++ "node_modules/statuses": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",++ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/string_decoder": {++ "version": "1.3.0",++ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",++ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",++ "license": "MIT",++ "dependencies": {++ "safe-buffer": "~5.2.0"++ }++ },++ "node_modules/strip-json-comments": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",++ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/tar-fs": {++ "version": "2.1.5",++ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",++ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",++ "license": "MIT",++ "dependencies": {++ "chownr": "^1.1.1",++ "mkdirp-classic": "^0.5.2",++ "pump": "^3.0.0",++ "tar-stream": "^2.1.4"++ }++ },++ "node_modules/tar-stream": {++ "version": "2.2.0",++ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",++ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",++ "license": "MIT",++ "dependencies": {++ "bl": "^4.0.3",++ "end-of-stream": "^1.4.1",++ "fs-constants": "^1.0.0",++ "inherits": "^2.0.3",++ "readable-stream": "^3.1.1"++ },++ "engines": {++ "node": ">=6"++ }++ },++ "node_modules/toidentifier": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",++ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",++ "license": "MIT",++ "engines": {++ "node": ">=0.6"++ }++ },++ "node_modules/tunnel-agent": {++ "version": "0.6.0",++ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",++ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",++ "license": "Apache-2.0",++ "dependencies": {++ "safe-buffer": "^5.0.1"++ },++ "engines": {++ "node": "*"++ }++ },++ "node_modules/type-is": {++ "version": "1.6.18",++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",++ "license": "MIT",++ "dependencies": {++ "media-typer": "0.3.0",++ "mime-types": "~2.1.24"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/unpipe": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",++ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/util-deprecate": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",++ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",++ "license": "MIT"++ },++ "node_modules/utils-merge": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4.0"++ }++ },++ "node_modules/vary": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",++ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/wrappy": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",++ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",++ "license": "ISC"++ }++ }++}nnew file mode 100644@@@ -0,0 +1,34 @@++{++ "name": "openvibe-coupons",++ "version": "0.1.0",++ "private": true,++ "description": "OpenVibe.Coupons — coupon codes with merchant and domain resolution, restrictions, honest expiry, deduplicated and rate-limited people's validity reports, and the public lookup API the OpenVibe browser helper uses. Server-rendered and useful without JavaScript.",++ "main": "server/index.js",++ "scripts": {++ "start": "node server/index.js",++ "dev": "NODE_ENV=development node server/index.js",++ "test": "node test/run.js"++ },++ "keywords": ["openvibe", "coupons", "merchants", "browser-extension"],++ "author": "OpenVibers",++ "license": "AGPL-3.0-only",++ "repository": "github:OpenVibers/OpenVibe.Coupons",++ "dependencies": {++ "better-sqlite3": "^11.10.0",++ "cookie-parser": "^1.4.7",++ "dotenv": "^16.4.7",++ "express": "^4.21.2",++ "express-rate-limit": "^7.5.0",++ "helmet": "^8.1.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.19.0",++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.2.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"++ },++ "devDependencies": {++ "jsonwebtoken": "^9.0.2"++ },++ "engines": {++ "node": ">=22.0.0"++ }++}nnew file mode 100644@@@ -0,0 +1,45 @@++/* OpenVibe.Coupons — theme-token driven (openvibe-shared presets). No JavaScript needed. */++:root { color-scheme: dark; }++* { box-sizing: border-box; }++body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }++a { color: var(--accent-light, #60a5fa); }++a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 3px solid var(--accent, #3b82f6); outline-offset: 2px; }++.skip { position: absolute; left: -9999px; }++.skip:focus { left: 16px; top: 8px; background: var(--bg-card, #131c2e); padding: 8px 12px; z-index: 10; }++.account-bar { max-width: 820px; margin: 0 auto; padding: 6px 16px 0; text-align: right; font-size: 14px; color: var(--text-secondary, #96a7c2); }++.page { max-width: 820px; margin: 0 auto; padding: 16px 16px 64px; min-height: 60vh; }++h1 { font-size: 1.9rem; line-height: 1.2; margin: 1.1rem 0 .6rem; }++h2 { font-size: 1.3rem; margin: 1.6rem 0 .5rem; }++h3 { font-size: 1.1rem; margin: 0 0 .4rem; }++.lede { color: var(--text-secondary, #96a7c2); font-size: 1.05rem; }++.meta, .empty { color: var(--text-muted, #7386a3); font-size: .9rem; margin: .25rem 0; }++.unknown { font-style: italic; color: var(--text-secondary, #96a7c2); }++.coupon { border: 1px solid var(--border, #1f2d47); border-radius: 10px; padding: 12px 16px; margin: 12px 0; background: var(--bg-card, #131c2e); }++.code-line { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: .3rem 0; }++.code-line .label { color: var(--text-muted, #7386a3); font-size: .85rem; }++.code { font: 600 1.15rem/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; padding: 4px 10px; border: 1px dashed var(--accent, #3b82f6); border-radius: 6px; user-select: all; -webkit-user-select: all; word-break: break-all; }++.status { display: inline-block; font-size: .78rem; padding: 1px 9px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); }++.status-reported_working { border-color: var(--success, #22c55e); color: var(--success, #22c55e); }++.status-reported_failed { border-color: var(--error, #ef4444); color: var(--error, #ef4444); }++.status-expired, .status-disabled { opacity: .8; }++.restrictions, .hints { margin: .4rem 0; padding-left: 1.2rem; font-size: .95rem; }++.report { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 8px; }++form.inline { display: inline-flex; gap: 6px; align-items: center; flex-wrap: wrap; }++button { font: inherit; padding: 5px 12px; border-radius: 6px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-tertiary, #172238); color: var(--text-primary, #e6edf7); cursor: pointer; }++button:hover { border-color: var(--accent, #3b82f6); }++input, select, textarea { font: inherit; padding: 5px 8px; border-radius: 6px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); max-width: 100%; }++.form label { display: block; margin: 8px 0; }++.form fieldset { border: 1px solid var(--border, #1f2d47); border-radius: 8px; margin: 12px 0; }++.search { margin: 12px 0; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }++.merchant-list, .recent, .ended, .history { padding-left: 1.2rem; }++.notice { border-left: 3px solid var(--info, #38bdf8); padding: 6px 12px; background: var(--bg-card, #131c2e); }++.errors { border-left: 3px solid var(--error, #ef4444); padding: 6px 12px; background: var(--bg-card, #131c2e); }++.disclosure { border: 1px solid var(--warning, #f59e0b); border-radius: 8px; padding: 6px 10px; font-size: .9rem; }++.token-once { border: 1px solid var(--warning, #f59e0b); border-radius: 8px; padding: 8px 14px; }++.token { user-select: all; -webkit-user-select: all; word-break: break-all; font-size: 1rem; }++.installs { border-collapse: collapse; width: 100%; font-size: .92rem; }++.installs th, .installs td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border, #1f2d47); }++.formula { font-family: ui-monospace, monospace; }++.ov-breadcrumbs ol { list-style: none; padding: 0; margin: 8px 0; display: flex; flex-wrap: wrap; gap: 6px; font-size: .9rem; }++.ov-breadcrumbs li + li::before { content: "›"; margin-right: 6px; color: var(--text-muted); }++@media (max-width: 600px) { .installs thead { display: none; } .installs td { display: block; border: 0; padding: 2px 0; } .installs tr { display: block; border-bottom: 1px solid var(--border, #1f2d47); padding: 6px 0; } }nnew file mode 100644@@@ -0,0 +1,140 @@++'use strict';++++/**++ * OpenVibe.Coupons — Express app factory. server/index.js listens and starts the worker; tests build++ * their own instance with a temp database, an injectable clock and mock neighbours.++ *++ * Pages (server-rendered, http/public.js) API (/api/v1, http/api.js; Authorization header only)++ * Discovery (robots, llms, sitemaps, feeds) /auth/* (Network SSO)++ * /api/health, /api/ready, /release.json, /metrics++ */++const path = require('path');++const express = require('express');++const helmet = require('helmet');++const cookieParser = require('cookie-parser');++const rateLimit = require('express-rate-limit');++const contracts = require('openvibe-contracts');++++const configLib = require('./config');++const { openStore } = require('./db');++const { createAuthClient, createAuthRoutes } = require('./auth/sso');++const { createViewerResolver } = require('./auth/viewer');++const { createCouponsOutbox } = require('./events/outbox');++const { createPublication } = require('./domain/publication');++const { createMerchants } = require('./domain/merchants');++const { createCoupons } = require('./domain/coupons');++const { createReports } = require('./domain/reports');++const { createInstalls } = require('./domain/installs');++const { createWatches } = require('./domain/watches');++const { createModeration } = require('./domain/moderation');++const { createSourcesImporter } = require('./clients/sources');++const { createApi } = require('./http/api');++const { createPublicRoutes } = require('./http/public');++const { createDiscoveryRoutes } = require('./http/discovery');++const { createCouponsReadiness } = require('./observability');++const { createWorker } = require('./worker');++const { assetVersion } = require('./render/layout');++++const PUBLIC_DIR = path.join(__dirname, '..', 'public');++const VERSION = require('../package.json').version;++++/** opts: config, store | dbPath, now (clock), fetchImpl, auth (a createAuthClient-like object), log */++function createApp(opts = {}) {++ const config = opts.config || configLib.load();++ const log = opts.log || console;++ const fetchImpl = opts.fetchImpl || globalThis.fetch;++ const store = opts.store || openStore(opts.dbPath || config.dbPath, { now: opts.now });++++ const outbox = createCouponsOutbox({ db: store.db, config, fetchImpl, now: store.now, log });++ const publication = createPublication({ store, config, outbox });++ const merchants = createMerchants({ store });++ const coupons = createCoupons({ store, merchants, publication });++ const reports = createReports({ store, config, coupons, merchants, publication });++ const installs = createInstalls({ store, config });++ const watches = createWatches({ store });++ const moderation = createModeration({ store, merchants, coupons });++ const importer = createSourcesImporter({ store, config, merchants, coupons, fetchImpl, log });++ const auth = opts.auth || createAuthClient(config);++ const viewers = createViewerResolver({ auth, config, installs });++ const worker = createWorker({ config, coupons, importer, outbox, log });++++ const ctx = { config, store, outbox, publication, merchants, coupons, reports, installs, watches, moderation, importer, auth, viewers, worker };++++ const app = express();++ app.disable('x-powered-by');++ app.set('trust proxy', config.trustProxy);++ const release = require('openvibe-shared/release').createRelease({ service: 'coupons', root: path.join(__dirname, '..') });++ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'coupons', release: release.release });++ app.locals.metrics = metrics.registry;++ app.locals.ctx = ctx;++++ app.use(contracts.http.middleware());++ app.use(helmet({++ contentSecurityPolicy: {++ directives: {++ defaultSrc: ["'self'"],++ // The shared chrome (theme-loader, navbar) comes from the Network; the inline init is ours.++ scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network'],++ styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],++ fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],++ imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media'],++ connectSrc: ["'self'", 'https://openvibe.network'],++ frameSrc: ["'self'", 'https://openvibe.network'],++ frameAncestors: ["'none'"],++ objectSrc: ["'none'"],++ baseUri: ["'self'"],++ formAction: ["'self'", 'https://openvibe.network'],++ },++ },++ frameguard: { action: 'deny' },++ crossOriginEmbedderPolicy: false,++ crossOriginResourcePolicy: { policy: 'same-site' },++ referrerPolicy: { policy: 'strict-origin-when-cross-origin' },++ }));++ app.use(cookieParser());++++ // ── Machine endpoints ───────────────────────────────────++ app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-coupons', version: VERSION }));++ app.get('/release.json', release.handler);++ const readiness = createCouponsReadiness({ store, auth, outbox, worker, importer, reports, config, release: release.release });++ app.get('/api/ready', readiness.handler);++++ // ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────++ app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, standardHeaders: true, legacyHeaders: false }));++ app.use('/auth', createAuthRoutes(config, auth));++ { const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'coupons', service: 'coupons', host: 'openvibe.coupons', name: 'OpenVibe.Coupons', profile: 'ugc' })); }++++ // ── Static assets (content-hashed ?v= → immutable) ──────++ app.use(express.static(PUBLIC_DIR, {++ index: false, redirect: false,++ setHeaders(res, filePath) {++ const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');++ const v = res.req && res.req.query && res.req.query.v;++ res.setHeader('Cache-Control', v && v === assetVersion(rel) ? 'public, max-age=31536000, immutable' : 'public, max-age=300');++ },++ }));++++ // ── API ─────────────────────────────────────────────────++ app.use('/api/v1', createApi(ctx));++ app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));++++ // ── Discovery and pages ─────────────────────────────────++ app.use(createDiscoveryRoutes(ctx));++ const pages = rateLimit({ windowMs: 60_000, limit: 300, standardHeaders: true, legacyHeaders: false });++ const publicRoutes = createPublicRoutes(ctx);++ app.use(pages, publicRoutes.router);++ app.use((req, res) => publicRoutes.notFound(req, res));++++ // eslint-disable-next-line no-unused-vars++ app.use((err, req, res, _next) => {++ log.error('[Coupons]', err && err.stack ? err.stack : err);++ if (res.headersSent) return;++ res.set('Cache-Control', 'private, no-store');++ if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });++ res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');++ });++++ return { app, ctx };++}++++module.exports = { createApp };nnew file mode 100644@@@ -0,0 +1,42 @@++'use strict';++++/**++ * Capability checks for service tokens (audience openvibe.coupons), and the scopes of extension++ * install tokens.++ *++ * Capabilities (roadmap §15.13, as 3-segment ids) are for OpenVibe services holding a Network++ * client-credentials token. Coupons' ids are proposed in docs/capabilities-proposal/ for the next++ * contracts release; until then a grant is decided locally with the library's own matching rule++ * (the exact id, or a `prefix.*` grant covering it). An id the library knows always goes through++ * the library, so the day the release lands nothing changes here.++ *++ * Install scopes (coupons.lookup, coupons.report) are NOT Network capabilities: they are the two++ * things a browser-helper install token may do, issued and revoked on this site++ * (/connect-extension). They map onto the capabilities of the same routes.++ */++const { capabilities } = require('openvibe-contracts');++++const CAPABILITIES = Object.freeze({++ COUPON_SUBMIT: 'coupons.coupon.submit', // charter coupons.submit++ REPORT_CREATE: 'coupons.report.create', // charter coupons.report++ MERCHANT_RESOLVE: 'coupons.merchant.resolve',++ COUPON_LOOKUP: 'coupons.coupon.lookup', // charter coupons.lookup++ STATUS_UPDATE: 'coupons.status.update',++ MERCHANT_MANAGE: 'coupons.merchant.manage',++});++const PROPOSED = new Set(Object.values(CAPABILITIES));++++const SCOPES = Object.freeze({ LOOKUP: 'coupons.lookup', REPORT: 'coupons.report' });++const ALL_SCOPES = Object.freeze([SCOPES.LOOKUP, SCOPES.REPORT]);++++/** → { allowed, code, reason } like capabilities.check(). */++function checkCapability(claims, capabilityId) {++ if (!capabilities.get(capabilityId) && PROPOSED.has(capabilityId)) {++ return capabilities.grants(claims && claims.cap, capabilityId)++ ? { allowed: true, code: null, reason: null }++ : { allowed: false, code: 'capability.denied', reason: `${capabilityId} not granted` };++ }++ return capabilities.check(claims, capabilityId);++}++++module.exports = { CAPABILITIES, PROPOSED, SCOPES, ALL_SCOPES, checkCapability };nnew file mode 100644@@@ -0,0 +1,24 @@++'use strict';++++/**++ * Form tokens for the no-JavaScript forms (submit, report, watch, connect-extension, staff) (CSRF). The session cookie is++ * SameSite=Lax already; the token is the second lock: an HMAC of the signed-in subject under++ * COUPONS_FORM_SECRET (a random per-process key when unset, so forms opened before a restart must be++ * reloaded). Never derived from anything a cross-site page can read.++ */++const crypto = require('crypto');++++const fallback = crypto.randomBytes(32).toString('hex');++++function csrfToken(config, viewer) {++ if (!viewer || !viewer.subject) return '';++ return crypto.createHmac('sha256', config.formSecret || fallback).update(`coupons-form:${viewer.subject}`).digest('base64url').slice(0, 32);++}++++function checkCsrf(config, viewer, token) {++ const expected = csrfToken(config, viewer);++ if (!expected || typeof token !== 'string' || token.length !== expected.length) return false;++ return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected));++}++++module.exports = { csrfToken, checkCsrf };nnew file mode 100644@@@ -0,0 +1,420 @@++'use strict';++++// ═══════════════════════════════════════════════════════════════++// openvibe.coupons — OAuth2 CLIENT session layer (sign-in via OpenVibe.Network SSO)++//++// The same session layer OpenVibe.Community uses (server/auth/routes.js there, MIT), with++// Coupons' client id: registered in the Network as OAuth client `coupons`, redirect++// https://openvibe.coupons/auth/callback.++//++// GET /auth/login → redirect to Network /oauth/authorize (?silent=1: prompt=none;++// ?next=… same-site path or an https://openvibe.network/… URL)++// GET /auth/callback → server-side code exchange, set cookies++// POST /auth/fedcm → the shared navbar's FedCM assertion → tokens (jwt-bearer grant)++// GET /auth/logout → clear cookies (+ best-effort refresh revoke), hint=guest++// GET /auth/me → offline-verify ov_token, return profile++// POST /auth/refresh → rotate tokens via refresh_token grant++//++// Cookies (host-only for openvibe.coupons):++// ov_token access JWT, SameSite=Lax, Secure, JS-readable (navbar.js reads it)++// ov_refresh opaque refresh token, httpOnly, Path=/auth++// ov_sso_hint 'account' after a sign-in, 'guest' after a sign-out (navbar silent-login hint)++// Pages never trust a body or query for identity: only the verified ov_token (or a Bearer token).++// ═══════════════════════════════════════════════════════════════++++const express = require('express');++const crypto = require('crypto');++const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');++++const ACCESS_COOKIE = 'ov_token';++const REFRESH_COOKIE = 'ov_refresh';++const HINT_COOKIE = 'ov_sso_hint';++const STATE_COOKIE = 'ov_oauth_state';++const NEXT_COOKIE = 'ov_oauth_next';++const SILENT_COOKIE = 'ov_oauth_silent';++++/**++ * Create the auth client + JWKS fetcher shared by the whole app.++ * Verification is OFFLINE: we cache the Network's RS256 public key from++ * GET /api/.well-known/jwks and verify JWTs locally on every request.++ */++function createAuthClient(config) {++ const client = new OpenVibeAuthClient({++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ redirectUri: config.oauth.redirectUri,++ publicKey: null, // filled by ensureKey()++ authBase: config.networkUrl,++ internalBase: config.networkInternalUrl,++ });++++ let lastFetch = 0;++ let inflight = null;++ async function ensureKey() {++ if (client.publicKey) return client.publicKey;++ // A request that arrives while the key is being fetched waits for that fetch rather than++ // being treated as signed out.++ if (inflight) return inflight;++ // Don't hammer the Network if it's down — retry at most every 30s++ if (Date.now() - lastFetch < 30_000) return null;++ lastFetch = Date.now();++ inflight = fetchKey().finally(() => { inflight = null; });++ return inflight;++ }++ async function fetchKey() {++ for (const base of [config.networkInternalUrl, config.networkUrl]) {++ if (!base) continue;++ try {++ const res = await fetch(`${base}/api/.well-known/jwks`, { signal: AbortSignal.timeout(5000) });++ if (!res.ok) continue;++ const jwks = await res.json();++ if (jwks.public_key) {++ client.publicKey = jwks.public_key;++ console.log(`[Coupons] Network public key loaded from ${base} (${jwks.algorithm || 'RS256'})`);++ return client.publicKey;++ }++ } catch (err) {++ console.warn(`[Coupons auth] JWKS fetch failed from ${base}: ${err.message}`);++ }++ }++ return null;++ }++++ /** Offline JWT verification. Returns decoded claims or null. */++ async function verify(token) {++ if (!token) return null;++ await ensureKey();++ return client.verifyToken(token);++ }++++ // Warm the key cache at boot (non-fatal if the Network is down)++ ensureKey().catch(() => {});++++ return { client, ensureKey, verify };++}++++/** Token from Authorization header or the ov_token cookie. */++function extractToken(req) {++ const h = req.headers.authorization;++ if (h && h.startsWith('Bearer ')) return h.slice(7);++ return req.cookies?.[ACCESS_COOKIE] || null;++}++++/** Strip registered claims — what the site shows as "the user". */++function claimsToUser(claims) {++ if (!claims) return null;++ const { iat, exp, aud, iss, nbf, jti, ...user } = claims;++ return user;++}++++/**++ * Decode a JWT's payload WITHOUT checking its signature. Only for reading claims++ * we cross-check locally (the FedCM nonce) before the Network — which does verify++ * the signature — sees the assertion. Never use it to trust an identity.++ */++function decodeJwtPayload(token) {++ if (typeof token !== 'string') return null;++ const parts = token.split('.');++ if (parts.length !== 3) return null;++ try {++ const claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));++ return claims && typeof claims === 'object' && !Array.isArray(claims) ? claims : null;++ } catch { return null; }++}++++/** True when the assertion's `nonce` claim is exactly the nonce the page posted. */++function fedcmNonceMatches(token, nonce) {++ if (typeof nonce !== 'string' || !nonce || nonce.length > 256) return false;++ const claims = decodeJwtPayload(token);++ if (!claims || typeof claims.nonce !== 'string') return false;++ const a = Buffer.from(claims.nonce), b = Buffer.from(nonce);++ return a.length === b.length && crypto.timingSafeEqual(a, b);++}++++/**++ * Only allow same-site relative paths, this site's own https origin, or an++ * https://openvibe.network/… URL (sign-in / sign-out-everywhere chains) as post-auth targets.++ */++function sanitizeNext(next, config) {++ if (!next || typeof next !== 'string') return '/';++ if (/^\/(?!\/|\\)/.test(next)) return next; // relative path, not protocol-relative++ try {++ const u = new URL(next);++ if (u.protocol !== 'https:') return '/';++ const allowed = [config.baseUrl, config.networkUrl]++ .map((b) => { try { return new URL(b).hostname; } catch { return null; } })++ .filter(Boolean);++ if (allowed.includes(u.hostname)) return u.toString();++ } catch { /* fall through */ }++ return '/';++}++++/** Append ?key=value to a same-site path or absolute URL, keeping any existing query/hash. */++function withParam(target, key, value) {++ const hashAt = target.indexOf('#');++ const hash = hashAt >= 0 ? target.slice(hashAt) : '';++ const base = hashAt >= 0 ? target.slice(0, hashAt) : target;++ const sep = base.includes('?') ? '&' : '?';++ return `${base}${sep}${encodeURIComponent(key)}=${encodeURIComponent(value)}${hash}`;++}++++/**++ * Build the Network authorize URL for a login. Exported (and pure) so tests can++ * cover the silent-login shape without a browser.++ */++function buildAuthorizeUrl(config, auth, { silent = false } = {}) {++ const { url, state } = auth.client.getAuthorizationUrl(config.oauth.scope);++ if (!silent) return { url, state };++ // ?silent=1: continue as the account openvibe.network already knows (no chooser); if the++ // network has no session either, it bounces back with error=login_required and we stay quiet.++ const u = new URL(url);++ u.searchParams.set('prompt', 'none');++ return { url: u.toString(), state };++}++++function createAuthRoutes(config, auth) {++ const router = express.Router();++++ const accessCookieOpts = () => ({++ sameSite: 'lax',++ secure: config.cookies.secure,++ httpOnly: false, // JS-readable by design — the shared navbar reads it client-side++ path: '/',++ maxAge: 24 * 60 * 60 * 1000, // matches the 24h access JWT++ });++++ const refreshCookieOpts = () => ({++ sameSite: 'lax',++ secure: config.cookies.secure,++ httpOnly: true,++ path: '/auth', // only ever sent back to this session layer++ maxAge: 30 * 24 * 60 * 60 * 1000,++ });++++ const flowCookieOpts = () => ({++ sameSite: 'lax', secure: config.cookies.secure, httpOnly: true,++ path: '/auth', maxAge: 10 * 60 * 1000,++ });++++ const hintCookieOpts = () => ({++ sameSite: 'lax',++ secure: config.cookies.secure,++ httpOnly: false, // navbar.js reads it to decide whether a silent login is worth trying++ path: '/',++ maxAge: 365 * 24 * 60 * 60 * 1000,++ });++++ function setSessionCookies(res, accessToken, refreshToken) {++ res.cookie(ACCESS_COOKIE, accessToken, accessCookieOpts());++ if (refreshToken) res.cookie(REFRESH_COOKIE, refreshToken, refreshCookieOpts());++ res.cookie(HINT_COOKIE, 'account', hintCookieOpts());++ }++++ function clearSessionCookies(res) {++ res.clearCookie(ACCESS_COOKIE, { ...accessCookieOpts(), maxAge: undefined });++ res.clearCookie(REFRESH_COOKIE, { ...refreshCookieOpts(), maxAge: undefined });++ }++++ function clearFlowCookies(res) {++ for (const c of [STATE_COOKIE, NEXT_COOKIE, SILENT_COOKIE]) res.clearCookie(c, { path: '/auth' });++ }++++ /** Exchange at the Network — internal URL first, public as fallback. */++ async function tokenGrant(body) {++ let lastErr = null;++ for (const base of [config.networkInternalUrl, config.networkUrl]) {++ if (!base) continue;++ try {++ const res = await fetch(`${base}/oauth/token`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json' },++ body: JSON.stringify({++ client_id: config.oauth.clientId,++ client_secret: config.oauth.clientSecret,++ ...body,++ }),++ signal: AbortSignal.timeout(10_000),++ });++ const data = await res.json().catch(() => ({}));++ if (!res.ok) {++ const err = new Error(data.error_description || data.error || `token grant failed (${res.status})`);++ err.status = res.status;++ err.error = data.error || 'invalid_grant';++ err.error_description = data.error_description || null;++ throw err;++ }++ return data;++ } catch (err) {++ lastErr = err;++ // 4xx = the grant itself is bad; retrying against another base won't help++ if (err.status && err.status < 500) throw err;++ }++ }++ throw lastErr || new Error('Network unreachable');++ }++++ // ── GET /auth/login ──────────────────────────────────────++ router.get('/login', async (req, res) => {++ const silent = !!req.query.silent && req.query.silent !== '0';++ if (silent) {++ // Already signed in here? Then the silent round trip through the Network would++ // only hand back the session we have: go straight to where the caller wanted.++ const existing = req.cookies?.[ACCESS_COOKIE];++ if (existing && await auth.verify(existing)) {++ clearFlowCookies(res);++ return res.redirect(sanitizeNext(req.query.next, config));++ }++ }++ const { url, state } = buildAuthorizeUrl(config, auth, { silent });++ res.cookie(STATE_COOKIE, state, flowCookieOpts());++ const next = sanitizeNext(req.query.next, config);++ if (next !== '/') res.cookie(NEXT_COOKIE, next, flowCookieOpts());++ else res.clearCookie(NEXT_COOKIE, { path: '/auth' });++ if (silent) res.cookie(SILENT_COOKIE, '1', flowCookieOpts());++ else res.clearCookie(SILENT_COOKIE, { path: '/auth' });++ res.redirect(url);++ });++++ // ── GET /auth/callback ───────────────────────────────────++ router.get('/callback', async (req, res) => {++ const { code, state, error } = req.query;++ const next = sanitizeNext(req.cookies?.[NEXT_COOKIE], config);++ const silent = req.cookies?.[SILENT_COOKIE] === '1';++++ if (error) {++ clearFlowCookies(res);++ // Silent sign-in found no network session — go back quietly as a guest. The++ // ?sso=none marker stops navbar.js from trying again on that page.++ if (silent || error === 'login_required') return res.redirect(withParam(next, 'sso', 'none'));++ return res.redirect(withParam('/', 'auth_error', String(error)));++ }++ if (!code) return res.status(400).send('Missing authorization code');++++ const expectedState = req.cookies?.[STATE_COOKIE];++ clearFlowCookies(res);++ // The state cookie is required, not just compared when present: skipping the check when++ // the cookie is missing would let a crafted link sign a visitor into someone else's account.++ if (!expectedState || !state || !crypto.timingSafeEqual(++ Buffer.from(String(state).padEnd(64).slice(0, 64)),++ Buffer.from(String(expectedState).padEnd(64).slice(0, 64))++ )) {++ return res.status(400).send('OAuth state mismatch — please try signing in again.');++ }++++ try {++ const data = await tokenGrant({++ grant_type: 'authorization_code',++ redirect_uri: config.oauth.redirectUri,++ code,++ });++ setSessionCookies(res, data.access_token, data.refresh_token);++ return res.redirect(next);++ } catch (err) {++ console.error('[Coupons auth] Code exchange failed:', err.message);++ return res.status(502).send('Sign-in failed — could not reach OpenVibe.Network. Please try again.');++ }++ });++++ // ── POST /auth/fedcm ─────────────────────────────────────++ // The shared navbar obtained a FedCM assertion from openvibe.network (the++ // browser's own account chooser) and posts it same-origin with the nonce it++ // put in the FedCM request. We only pre-check the nonce; the Network verifies++ // the assertion's signature when it swaps it for tokens.++ const fedcmBody = express.json({ limit: '16kb', type: 'application/json' });++ const fedcmBodyError = (err, _req, res, next) => (err ? res.status(400).json({ error: 'invalid_request', error_description: 'Malformed JSON body' }) : next());++ router.post('/fedcm', fedcmBody, fedcmBodyError, async (req, res) => {++ if (!req.is('application/json')) return res.status(400).json({ error: 'invalid_request', error_description: 'Expected application/json' });++ const { token, nonce } = req.body || {};++ if (typeof token !== 'string' || !token || typeof nonce !== 'string' || !nonce) {++ return res.status(400).json({ error: 'invalid_request', error_description: 'token and nonce are required' });++ }++ if (!fedcmNonceMatches(token, nonce)) {++ return res.status(400).json({ error: 'invalid_request', error_description: 'nonce mismatch' });++ }++ try {++ const data = await tokenGrant({++ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',++ assertion: token,++ });++ if (!data.access_token) throw Object.assign(new Error('no token'), { status: 401, error: 'invalid_grant' });++ setSessionCookies(res, data.access_token, data.refresh_token);++ const user = data.user || claimsToUser(await auth.verify(data.access_token));++ return res.json({ ok: true, user });++ } catch (err) {++ if (err.status && err.status < 500) {++ return res.status(401).json({ error: err.error || 'invalid_grant', error_description: err.error_description || err.message });++ }++ console.error('[Coupons auth] FedCM exchange failed:', err.message);++ return res.status(502).json({ error: 'server_error', error_description: 'Could not reach OpenVibe.Network' });++ }++ });++++ // ── GET /auth/logout ─────────────────────────────────────++ router.get('/logout', async (req, res) => {++ // Best-effort refresh revocation. The Network's rotating refresh tokens++ // self-invalidate, so a failure here is harmless.++ const refresh = req.cookies?.[REFRESH_COOKIE];++ if (refresh) {++ try {++ await fetch(`${config.networkInternalUrl}/oauth/revoke`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json' },++ body: JSON.stringify({++ client_id: config.oauth.clientId,++ client_secret: config.oauth.clientSecret,++ token: refresh,++ }),++ signal: AbortSignal.timeout(3000),++ });++ } catch { /* optional */ }++ }++ clearSessionCookies(res);++ res.cookie(HINT_COOKIE, 'guest', hintCookieOpts());++ res.redirect(sanitizeNext(req.query.next, config));++ });++++ // ── GET /auth/me ─────────────────────────────────────────++ router.get('/me', async (req, res) => {++ const token = extractToken(req);++ if (!token) return res.status(401).json({ error: 'Not authenticated' });++ const claims = await auth.verify(token);++ if (!claims) return res.status(401).json({ error: 'Invalid or expired token' });++ res.json({ user: claimsToUser(claims), expires_at: claims.exp ? claims.exp * 1000 : null });++ });++++ // ── POST /auth/refresh ───────────────────────────────────++ // Small endpoint the shared navbar can call when ov_token expires.++ router.post('/refresh', async (req, res) => {++ const refresh = req.cookies?.[REFRESH_COOKIE];++ if (!refresh) return res.status(401).json({ error: 'No refresh token' });++ try {++ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: refresh });++ setSessionCookies(res, data.access_token, data.refresh_token);++ const claims = await auth.verify(data.access_token);++ return res.json({ token: data.access_token, user: claimsToUser(claims) });++ } catch (err) {++ if (err.status && err.status < 500) {++ clearSessionCookies(res);++ return res.status(401).json({ error: 'Refresh token rejected — please sign in again' });++ }++ console.error('[Coupons auth] Refresh failed:', err.message);++ return res.status(502).json({ error: 'Could not reach OpenVibe.Network' });++ }++ });++++ return router;++}++++/** Attaches req.user (JWT claims) and req.token when a valid ov_token is presented; never blocks. */++function optionalAuth(auth) {++ return async (req, _res, next) => {++ const token = extractToken(req);++ if (token) {++ const claims = await auth.verify(token);++ if (claims) { req.user = claimsToUser(claims); req.token = token; }++ }++ next();++ };++}++++module.exports = { createAuthClient, createAuthRoutes, extractToken, optionalAuth, sanitizeNext, withParam, buildAuthorizeUrl, claimsToUser, decodeJwtPayload, fedcmNonceMatches };nnew file mode 100644@@@ -0,0 +1,133 @@++'use strict';++++/**++ * Who is calling, resolved once per request into `req.viewer`.++ *++ * Pages (forms, server-rendered HTML) — viewers.pages():++ * { kind: 'anonymous' }++ * { kind: 'user', subject, staff, user } the Network user JWT from the ov_token cookie++ * Anything else (service tokens, extension tokens) is treated as anonymous: pages are for people.++ *++ * API (/api/v1) — viewers.api(): the Authorization header ONLY. Cookies are ignored, so a request a++ * web page makes with the visitor's ambient cookies carries no identity at all (no CSRF, no data):++ * { kind: 'anonymous' } no Authorization header++ * { kind: 'install', install, subject, scopes } Bearer cpx_… (extension install token)++ * { kind: 'service', service, claims, subject, origin } Bearer Network client-credentials token for++ * audience openvibe.coupons; X-OV-Subject names++ * the person it acts for, X-OV-Origin: ai marks++ * model output (OpenVibe.AI coupons.extract_coupon)++ * { kind: 'user', subject, staff, user } Bearer Network user JWT (apps)++ * A presented credential that does not verify is refused (401), never downgraded to anonymous.++ *++ * Identity never comes from a request body or query.++ */++const contracts = require('openvibe-contracts');++const { extractToken, claimsToUser, decodeJwtPayload } = require('./sso');++const { checkCapability } = require('./capabilities');++const { ApiError } = require('../http/errors');++++const { ids, serviceAuth, http } = contracts;++const PRINCIPAL_SUB = /^(svc|app|mod):/;++const AUDIENCE = 'openvibe.coupons';++++const ANONYMOUS = Object.freeze({ kind: 'anonymous', subject: null, staff: false, origin: 'user' });++++function createViewerResolver({ auth, config, installs }) {++ const staffSubjects = new Set(config.staffSubjects || []);++++ function userFromClaims(claims, token) {++ if (!claims || (typeof claims.sub === 'string' && PRINCIPAL_SUB.test(claims.sub))) return null;++ const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;++ const staff = Boolean(subject) && (claims.role === 'admin' || staffSubjects.has(subject));++ return { kind: 'user', subject, staff, origin: 'user', user: claimsToUser(claims), token };++ }++++ async function fromServiceToken(req, token) {++ const publicKey = await auth.ensureKey();++ if (!publicKey) throw new ApiError(503, 'identity.unavailable', 'the Network signing key is not loaded yet');++ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkUrl, audience: AUDIENCE });++ if (!r.ok) throw new ApiError(401, r.code, r.reason);++ const originHeader = req.get('x-ov-origin');++ if (originHeader && originHeader !== 'ai' && originHeader !== 'user') throw new ApiError(400, 'request.invalid_origin', 'X-OV-Origin must be "ai" or "user"');++ const subjectHeader = req.get('x-ov-subject');++ let subject = null;++ if (subjectHeader) {++ if (!ids.isSubjectId('user', subjectHeader)) throw new ApiError(400, 'subject.invalid', 'X-OV-Subject must be a usr_… subject id');++ subject = subjectHeader;++ }++ return { kind: 'service', service: r.claims.sub, claims: r.claims, subject, origin: originHeader === 'ai' ? 'ai' : 'user', staff: false };++ }++++ /** API callers: the Authorization header only. */++ async function resolveApi(req) {++ const header = String(req.headers.authorization || '');++ if (!header) return ANONYMOUS;++ if (!header.startsWith('Bearer ')) throw new ApiError(401, 'auth.unsupported', 'use Authorization: Bearer <token>');++ const token = header.slice(7).trim();++ if (token.startsWith('cpx_')) {++ const v = installs.verify(token);++ return { kind: 'install', install: v.install, subject: v.subject, scopes: v.scopes, staff: false, origin: 'user' };++ }++ const payload = decodeJwtPayload(token);++ if (!payload) throw new ApiError(401, 'token.invalid', 'malformed bearer token');++ if (typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) return fromServiceToken(req, token);++ const claims = await auth.verify(token);++ const user = userFromClaims(claims, token);++ if (!user) throw new ApiError(401, 'token.invalid', 'invalid or expired token');++ return user;++ }++++ /** Pages: the ov_token cookie (or a Bearer user token); services and installs are anonymous here. */++ async function resolvePage(req) {++ const token = extractToken(req);++ if (!token || token.startsWith('cpx_')) return ANONYMOUS;++ const payload = decodeJwtPayload(token);++ if (!payload || (typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub))) return ANONYMOUS;++ return userFromClaims(await auth.verify(token), token) || ANONYMOUS;++ }++++ return {++ resolveApi,++ resolvePage,++ api() {++ return async (req, res, next) => {++ try {++ req.viewer = await resolveApi(req);++ next();++ } catch (err) {++ if (!(err instanceof ApiError)) return next(err);++ res.set('Cache-Control', 'private, no-store');++ http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });++ }++ };++ },++ pages() {++ return async (req, _res, next) => {++ try { req.viewer = await resolvePage(req); next(); } catch (err) { next(err); }++ };++ },++ };++}++++/**++ * Route guard for the API. A service token must hold `cap`; an install token must hold `scope`++ * (when the route accepts installs at all); people and anonymous callers pass here and are judged++ * by the route itself.++ */++function guard(cap, { scope = null } = {}) {++ return (req, res, next) => {++ const v = req.viewer;++ if (v && v.kind === 'service') {++ const c = checkCapability(v.claims, cap);++ if (c.allowed) return next();++ return http.sendProblem(res, 403, c.code, { detail: c.reason, ctx: req.ov });++ }++ if (v && v.kind === 'install') {++ if (scope && v.scopes.includes(scope)) return next();++ return http.sendProblem(res, 403, 'token.scope', { detail: scope ? `this extension token lacks the ${scope} scope` : 'extension tokens cannot use this route', ctx: req.ov });++ }++ return next();++ };++}++++module.exports = { createViewerResolver, guard, ANONYMOUS };nnew file mode 100644@@@ -0,0 +1,135 @@++'use strict';++++/**++ * Import from OpenVibe.Sources, category `coupons` (the seeded source is `staff-coupon-codes`, a++ * manual source: codes a merchant published itself, entered by staff with the evidence URL).++ *++ * GET {OV_SOURCES_INTERNAL_URL}/api/v1/items?category=coupons&after=<cursor>&include_removed=1++ * with a client-credentials token: [coupons, sources.item.read, openvibe.sources]++ *++ * For each item, in change order:++ * removed its evidence is withdrawn; a code left with no evidence is disabled++ * kind 'coupon' + fields.code on a host a merchant covers++ * a code (status unknown — an item is evidence that a code was published,++ * never that it works), or new evidence on the same code; its expiry is++ * fields.expires when the item states one, otherwise unknown++ * anything else HELD in coupon_import_holds with the reason (no_merchant, not_a_coupon,++ * no_code, expired, invalid, not_selected) — never silently dropped;++ * no_merchant holds are retried every run (staff add the merchant)++ * Imported codes wait for staff review unless COUPONS_SOURCES_AUTO_PUBLISH=true.++ * A failed fetch changes nothing and is recorded in import_state.last_error.++ */++const { serviceAuth } = require('openvibe-contracts');++const hosts = require('../domain/hosts');++++const STATE_KEY = 'sources:coupons';++++function createSourcesImporter({ store, config, merchants, coupons, fetchImpl = globalThis.fetch, log = console }) {++ const { db } = store;++ const enabled = Boolean(config.sources.internalUrl && config.oauth.clientSecret);++ const tokens = enabled ? serviceAuth.createTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.sources', scope: 'sources.item.read', fetchImpl,++ }) : null;++ const q = {++ state: db.prepare('SELECT * FROM import_state WHERE key = ?'),++ ensure: db.prepare('INSERT OR IGNORE INTO import_state (key, cursor) VALUES (?, 0)'),++ cursor: db.prepare('UPDATE import_state SET cursor = ?, last_run_at = ?, last_ok_at = ?, last_error = NULL WHERE key = ?'),++ failed: db.prepare('UPDATE import_state SET last_run_at = ?, last_error = ? WHERE key = ?'),++ hold: db.prepare(`INSERT INTO coupon_import_holds (item_id, source_key, reason, detail, item, attempts, created_at, updated_at)++ VALUES (@item_id, @source_key, @reason, @detail, @item, 1, @now, @now)++ ON CONFLICT (item_id) DO UPDATE SET reason = excluded.reason, detail = excluded.detail, item = excluded.item,++ attempts = attempts + 1, updated_at = excluded.updated_at, resolved_at = NULL`),++ resolve: db.prepare('UPDATE coupon_import_holds SET resolved_at = ?, updated_at = ? WHERE item_id = ? AND resolved_at IS NULL'),++ retryable: db.prepare("SELECT item FROM coupon_import_holds WHERE resolved_at IS NULL AND reason = 'no_merchant' ORDER BY updated_at LIMIT 100"),++ open: db.prepare('SELECT item_id, source_key, reason, detail, attempts, created_at, updated_at FROM coupon_import_holds WHERE resolved_at IS NULL ORDER BY updated_at DESC LIMIT 200'),++ openCount: db.prepare('SELECT COUNT(*) AS n FROM coupon_import_holds WHERE resolved_at IS NULL'),++ };++ q.ensure.run(STATE_KEY);++++ function parseItem(item) {++ const f = item.fields && typeof item.fields === 'object' ? item.fields : {};++ const code = typeof f.code === 'string' ? f.code.trim() : '';++ const title = typeof item.title === 'string' && item.title.trim().length >= 3 && item.title.trim().toUpperCase() !== code.toUpperCase()++ ? item.title : `Code ${code}`;++ const restrictions = {};++ if (f.min_spend != null && f.currency) restrictions.min_spend = { amount: String(f.min_spend), currency: f.currency };++ if (f.new_customers_only === true) restrictions.new_customers_only = true;++ if (typeof f.region === 'string') restrictions.regions = f.region;++ if (typeof f.category === 'string') restrictions.categories = f.category;++ return coupons.parseSubmission({ code, title, description: item.summary || null, expires: f.expires || f.expires_at || null, restrictions }, store.now());++ }++++ /** Handle one item inside a transaction. → 'imported' | 'updated' | 'withdrawn' | 'held:<reason>' */++ function handle(item) {++ const now = store.now();++ const held = (reason, detail = null) => {++ q.hold.run({ item_id: item.id, source_key: item.source_key || null, reason, detail: detail ? String(detail).slice(0, 300) : null, item: JSON.stringify(item), now });++ return `held:${reason}`;++ };++ return store.tx(() => {++ if (item.removed) {++ coupons.withdrawSourceItem(item.id, item.removed.reason);++ q.resolve.run(now, now, item.id);++ return 'withdrawn';++ }++ if (config.sources.keys.length && !config.sources.keys.includes(item.source_key)) return held('not_selected', `source ${item.source_key} is not in COUPONS_SOURCES_KEYS`);++ if (item.kind !== 'coupon') return held('not_a_coupon', `kind ${item.kind}`);++ if (!item.fields || typeof item.fields.code !== 'string' || !item.fields.code.trim()) return held('no_code');++ let parsed;++ try { parsed = parseItem(item); } catch (err) {++ return held(err.code === 'coupon.already_expired' ? 'expired' : 'invalid', err.message);++ }++ const at = hosts.hostOfUrl(item.canonical_url);++ const found = at ? merchants.resolve(at.host, { path: at.path, includeStatuses: ['active', 'pending'] }) : null;++ if (!found) return held('no_merchant', at ? at.host : 'no canonical URL');++ const r = coupons.importFromSource(found.merchant, item, parsed, { autoPublish: config.sources.autoPublish });++ q.resolve.run(now, now, item.id);++ return r.created ? 'imported' : 'updated';++ });++ }++++ async function getPage(after) {++ const url = `${config.sources.internalUrl}/api/v1/items?category=coupons&include_removed=1&limit=100&after=${after}`;++ const res = await fetchImpl(url, { headers: { Accept: 'application/json', ...(await tokens.authHeaders()) }, signal: AbortSignal.timeout(10000) });++ if (res.status === 401 && tokens.invalidate) tokens.invalidate();++ const body = await res.json().catch(() => null);++ if (!res.ok || !body || !Array.isArray(body.items)) throw new Error(`Sources answered ${res.status}`);++ return body;++ }++++ /** One import run. → { outcomes: {…}, cursor } or { error } */++ async function run({ maxPages = 10 } = {}) {++ if (!enabled) return { disabled: true };++ const outcomes = {};++ const count = (o) => { outcomes[o] = (outcomes[o] || 0) + 1; };++ let cursor = q.state.get(STATE_KEY).cursor;++ try {++ for (let i = 0; i < maxPages; i++) {++ const page = await getPage(cursor);++ for (const item of page.items) count(handle(item));++ cursor = page.next_after != null ? Number(page.next_after) : cursor;++ q.cursor.run(cursor, store.now(), store.now(), STATE_KEY);++ if (!page.more) break;++ }++ for (const { item } of q.retryable.all()) count(`retry:${handle(JSON.parse(item))}`);++ q.cursor.run(cursor, store.now(), store.now(), STATE_KEY);++ return { outcomes, cursor };++ } catch (err) {++ q.failed.run(store.now(), String(err.message).slice(0, 300), STATE_KEY);++ log.warn('[Coupons] Sources import failed (nothing changed):', err.message);++ return { error: err.message, outcomes, cursor };++ }++ }++++ return {++ enabled,++ run,++ handle,++ state: () => q.state.get(STATE_KEY),++ holds: () => q.open.all(),++ holdCount: () => q.openCount.get().n,++ };++}++++module.exports = { createSourcesImporter };nnew file mode 100644@@@ -0,0 +1,101 @@++'use strict';++++/**++ * OpenVibe.Coupons configuration. Every value comes from the environment (production:++ * /etc/openvibe/coupons.env, see .env.example). Only environment variable NAMES appear in code and++ * docs; secrets are never logged.++ *++ * load(env) is pure so tests can build a config without touching process.env.++ */++require('dotenv').config();++++const trim = (s) => String(s || '').replace(/\/+$/, '');++const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);++const list = (v) => String(v || '').split(',').map((s) => s.trim()).filter(Boolean);++++function load(env = process.env) {++ const nodeEnv = env.NODE_ENV || 'development';++ const isProduction = nodeEnv === 'production';++ const port = int(env.PORT, 4850);++ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.coupons' : `http://localhost:${port}`));++++ return {++ service: 'coupons',++ port,++ host: env.HOST || '127.0.0.1',++ nodeEnv,++ isProduction,++ // Public origin: canonical URLs, feeds, sitemaps and JSON-LD are built from it.++ baseUrl,++ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,++++ dbPath: env.COUPONS_DB_PATH || './data/coupons.db',++++ // OpenVibe.Network: SSO (OAuth2 authorization server), JWKS, client-credentials tokens.++ networkUrl: trim(env.OV_NETWORK_URL || 'https://openvibe.network'),++ networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),++ oauth: {++ clientId: env.OV_OAUTH_CLIENT_ID || 'coupons',++ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',++ redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,++ scope: 'profile theme',++ },++ cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },++ // Signs the per-session form token (CSRF). Unset: a random per-process key.++ formSecret: env.COUPONS_FORM_SECRET || '',++ // Keys the reporter column of coupon_validation_reports (HMAC of the person's subject), so++ // the table never holds who reported what in the clear. Unset: a fixed development key and++ // /api/ready says so. Changing it restarts same-day deduplication.++ reporterKeySecret: env.COUPONS_REPORTER_KEY_SECRET || '',++++ // Staff (merchant approval, moderation): Network admins, plus these subjects (usr_…).++ staffSubjects: list(env.COUPONS_STAFF_SUBJECTS),++++ // Browser-extension origins allowed by CORS on the two lookup routes ONLY (never on++ // reports, submissions or pages). Exact origins (chrome-extension://<id>) or the literal++ // pattern moz-extension://* (Firefox gives every install a random origin). Empty: no CORS++ // at all — the extension still works through its host permission for the API origin.++ extensionOrigins: list(env.COUPONS_EXTENSION_ORIGINS),++++ limits: {++ // Lookup routes, per minute: anonymous callers by IP, extension installs by install.++ lookupAnonPerMin: int(env.COUPONS_LOOKUP_ANON_PER_MIN, 30),++ lookupTokenPerMin: int(env.COUPONS_LOOKUP_TOKEN_PER_MIN, 120),++ lookupServicePerMin: int(env.COUPONS_LOOKUP_SERVICE_PER_MIN, 600),++ // Validity reports per person (every channel together: site, extension, API).++ reportsPerHour: int(env.COUPONS_REPORTS_PER_HOUR, 20),++ reportsPerDay: int(env.COUPONS_REPORTS_PER_DAY, 60),++ // Submissions per person.++ submissionsPerHour: int(env.COUPONS_SUBMISSIONS_PER_HOUR, 10),++ submissionsPerDay: int(env.COUPONS_SUBMISSIONS_PER_DAY, 30),++ // Active extension installs per person.++ installsPerSubject: int(env.COUPONS_INSTALLS_PER_SUBJECT, 10),++ },++ // Extension install tokens expire after this many days (a new one is one click away).++ installTtlDays: int(env.COUPONS_INSTALL_TTL_DAYS, 365),++++ // OpenVibe.Sources: codes from the `coupons` category (e.g. staff-coupon-codes, a manual++ // source) are imported when OV_SOURCES_INTERNAL_URL and the client secret are set.++ sources: {++ internalUrl: trim(env.OV_SOURCES_INTERNAL_URL || ''),++ keys: list(env.COUPONS_SOURCES_KEYS),++ intervalMs: int(env.COUPONS_SOURCES_INTERVAL_MS, 10 * 60 * 1000),++ // false (default): imported codes wait for staff review before they are listed.++ autoPublish: env.COUPONS_SOURCES_AUTO_PUBLISH === 'true',++ },++++ // OpenVibe.Events: the outbox relay runs only when EVENTS_URL and the client secret are set.++ events: {++ url: trim(env.EVENTS_URL || ''),++ intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),++ },++++ // Expiry and confidence-decay sweep.++ worker: {++ enabled: env.COUPONS_WORKER !== 'off',++ intervalMs: int(env.COUPONS_SWEEP_INTERVAL_MS, 60 * 1000),++ },++ };++}++++module.exports = { load };nnew file mode 100644@@@ -0,0 +1,226 @@++'use strict';++++/**++ * Coupons' own SQLite database, created on boot, idempotently. Nothing here is shared with another++ * service.++ *++ * The nine charter tables (roadmap §15.13):++ *++ * coupon_merchants merchants: pending (member-proposed) | active | disabled++ * coupon_merchant_domains host rules: exact host or host + subdomains, optional path prefix++ * coupons codes: status unknown|reported_working|reported_failed|expired|disabled,++ * confidence (NULL = no recent reports), expiry (NULL = unknown)++ * coupon_sources evidence: member submissions, OpenVibe.Sources items, staff entries++ * coupon_restrictions min spend, categories, new customers only, regions, other++ * coupon_validation_reports worked/failed reports, one row per (reporter, coupon, UTC day);++ * the reporter is an HMAC of the person's subject, never returned++ * coupon_status_history every status/confidence change with its reason and actor++ * coupon_application_hints where and how to apply a code, per merchant or per code++ * coupon_watches members watching a merchant++ *++ * Also here: extension_installs (cpx_ install tokens, hashed), import_state and++ * coupon_import_holds (the OpenVibe.Sources importer), the SDK's event_outbox, and++ * <coupons>_index_revisions (openvibe-publishing index sequencer).++ */++const fs = require('fs');++const path = require('path');++const Database = require('better-sqlite3');++const { createIndexSequencer } = require('openvibe-publishing/index-hooks');++++const STATUSES = Object.freeze(['unknown', 'reported_working', 'reported_failed', 'expired', 'disabled']);++++const SCHEMA = `++CREATE TABLE IF NOT EXISTS coupon_merchants (++ id TEXT PRIMARY KEY, -- mer_<ULID>++ slug TEXT NOT NULL UNIQUE, -- /m/:slug++ name TEXT NOT NULL,++ homepage_url TEXT, -- https://<registrable domain>/ unless staff set another++ description TEXT,++ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','active','disabled')),++ created_by TEXT, -- usr_… | svc:<id> | system++ created_at INTEGER NOT NULL,++ updated_at INTEGER NOT NULL++);++++CREATE TABLE IF NOT EXISTS coupon_merchant_domains (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ merchant_id TEXT NOT NULL REFERENCES coupon_merchants(id),++ host TEXT NOT NULL, -- normalized (lowercase, punycode, no port, no trailing dot)++ registrable_domain TEXT NOT NULL, -- eTLD+1 of host (bundled public-suffix subset)++ include_subdomains INTEGER NOT NULL DEFAULT 1,++ path_prefix TEXT NOT NULL DEFAULT '', -- '' = the whole host; '/shop/x' matches only with a path++ created_by TEXT,++ created_at INTEGER NOT NULL,++ UNIQUE (host, path_prefix)++);++CREATE INDEX IF NOT EXISTS coupon_merchant_domains_registrable ON coupon_merchant_domains (registrable_domain);++CREATE INDEX IF NOT EXISTS coupon_merchant_domains_merchant ON coupon_merchant_domains (merchant_id);++++CREATE TABLE IF NOT EXISTS coupons (++ id TEXT PRIMARY KEY, -- cpn_<ULID>++ merchant_id TEXT NOT NULL REFERENCES coupon_merchants(id),++ code TEXT NOT NULL, -- as the merchant writes it++ code_key TEXT NOT NULL, -- uppercase, for duplicate detection++ title TEXT NOT NULL,++ description TEXT,++ status TEXT NOT NULL DEFAULT 'unknown'++ CHECK (status IN ('unknown','reported_working','reported_failed','expired','disabled')),++ status_reason TEXT,++ confidence REAL, -- NULL: no report in the window (validity unknown)++ review_state TEXT NOT NULL DEFAULT 'published' CHECK (review_state IN ('pending','published')),++ origin TEXT NOT NULL CHECK (origin IN ('member','staff','source','ai')),++ expires_at INTEGER, -- NULL: expiry unknown (never guessed)++ expires_precision TEXT CHECK (expires_precision IN ('instant','date')),++ expiry_basis TEXT CHECK (expiry_basis IN ('evidence','submitter','source','staff')),++ expired_at INTEGER, -- when it left active results as expired++ disabled_at INTEGER,++ last_report_at INTEGER,++ last_worked_at INTEGER,++ last_failed_at INTEGER,++ created_by TEXT, -- usr_… | svc:<id>; never returned by the API++ created_at INTEGER NOT NULL,++ updated_at INTEGER NOT NULL,++ CHECK (expires_at IS NULL OR expires_precision IS NOT NULL),++ UNIQUE (merchant_id, code_key)++);++CREATE INDEX IF NOT EXISTS coupons_active ON coupons (merchant_id, status, review_state, expires_at);++CREATE INDEX IF NOT EXISTS coupons_expiry ON coupons (expires_at) WHERE expires_at IS NOT NULL;++CREATE INDEX IF NOT EXISTS coupons_recent ON coupons (created_at);++++CREATE TABLE IF NOT EXISTS coupon_sources (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ coupon_id TEXT NOT NULL REFERENCES coupons(id),++ kind TEXT NOT NULL CHECK (kind IN ('member','staff','source','ai')),++ evidence_url TEXT, -- where the code was published (never fetched by Coupons)++ merchant_evidence INTEGER NOT NULL DEFAULT 0, -- evidence_url is on one of the merchant's own domains++ sources_item_id TEXT, -- itm_… (OpenVibe.Sources)++ sources_item_rev INTEGER,++ source_key TEXT,++ retrieved_at INTEGER, -- Sources' observation time++ ai_run_id TEXT, -- OpenVibe.AI run (coupons.extract_coupon), when origin is ai++ submitted_by TEXT, -- usr_… | svc:<id>; never returned by the API++ removed_at INTEGER,++ removed_reason TEXT,++ created_at INTEGER NOT NULL++);++CREATE INDEX IF NOT EXISTS coupon_sources_coupon ON coupon_sources (coupon_id);++CREATE UNIQUE INDEX IF NOT EXISTS coupon_sources_item ON coupon_sources (coupon_id, sources_item_id) WHERE sources_item_id IS NOT NULL;++CREATE INDEX IF NOT EXISTS coupon_sources_submitter ON coupon_sources (submitted_by, created_at);++++CREATE TABLE IF NOT EXISTS coupon_restrictions (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ coupon_id TEXT NOT NULL REFERENCES coupons(id),++ kind TEXT NOT NULL CHECK (kind IN ('min_spend','category','new_customers_only','region','other')),++ value TEXT, -- category name, ISO 3166-1 alpha-2 region, free text++ amount_minor INTEGER, -- min_spend in minor units++ currency TEXT, -- ISO 4217++ created_at INTEGER NOT NULL,++ CHECK (kind <> 'min_spend' OR (amount_minor IS NOT NULL AND currency IS NOT NULL))++);++CREATE INDEX IF NOT EXISTS coupon_restrictions_coupon ON coupon_restrictions (coupon_id);++++CREATE TABLE IF NOT EXISTS coupon_validation_reports (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ coupon_id TEXT NOT NULL REFERENCES coupons(id),++ reporter_key TEXT NOT NULL, -- HMAC(COUPONS_REPORTER_KEY_SECRET, subject)++ channel TEXT NOT NULL CHECK (channel IN ('site','extension','api')),++ install_id TEXT, -- cpi_… when reported through the extension++ day TEXT NOT NULL, -- UTC YYYY-MM-DD: one report per reporter, coupon and day++ outcome TEXT NOT NULL CHECK (outcome IN ('worked','failed')),++ reason TEXT CHECK (reason IN ('invalid','expired','min_spend_not_met','not_eligible','other')),++ created_at INTEGER NOT NULL,++ updated_at INTEGER NOT NULL,++ UNIQUE (coupon_id, reporter_key, day)++);++CREATE INDEX IF NOT EXISTS coupon_reports_coupon ON coupon_validation_reports (coupon_id, updated_at);++CREATE INDEX IF NOT EXISTS coupon_reports_reporter ON coupon_validation_reports (reporter_key, created_at);++++CREATE TABLE IF NOT EXISTS coupon_status_history (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ coupon_id TEXT NOT NULL REFERENCES coupons(id),++ from_status TEXT,++ to_status TEXT NOT NULL,++ confidence_before REAL,++ confidence_after REAL,++ reason TEXT NOT NULL, -- created | report | decay | expiry | staff:<note> | service:<id>++ actor TEXT NOT NULL, -- system | usr_… | svc:<id>++ created_at INTEGER NOT NULL++);++CREATE INDEX IF NOT EXISTS coupon_status_history_coupon ON coupon_status_history (coupon_id, id);++++CREATE TABLE IF NOT EXISTS coupon_application_hints (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ merchant_id TEXT NOT NULL REFERENCES coupon_merchants(id),++ coupon_id TEXT REFERENCES coupons(id), -- NULL: applies to every code of the merchant++ text TEXT NOT NULL,++ created_by TEXT,++ created_at INTEGER NOT NULL++);++CREATE INDEX IF NOT EXISTS coupon_hints_merchant ON coupon_application_hints (merchant_id);++++CREATE TABLE IF NOT EXISTS coupon_watches (++ subject TEXT NOT NULL, -- usr_…++ merchant_id TEXT NOT NULL REFERENCES coupon_merchants(id),++ created_at INTEGER NOT NULL,++ PRIMARY KEY (subject, merchant_id)++);++++CREATE TABLE IF NOT EXISTS extension_installs (++ id TEXT PRIMARY KEY, -- cpi_<ULID>++ subject TEXT NOT NULL, -- usr_… who connected it++ token_hash TEXT NOT NULL UNIQUE, -- sha256 of the cpx_ token (the token is shown once)++ token_hint TEXT NOT NULL, -- first characters, to recognise it in the list++ label TEXT NOT NULL,++ scopes TEXT NOT NULL, -- JSON array: coupons.lookup, coupons.report++ created_at INTEGER NOT NULL,++ expires_at INTEGER NOT NULL,++ last_used_at INTEGER,++ revoked_at INTEGER++);++CREATE INDEX IF NOT EXISTS extension_installs_subject ON extension_installs (subject, revoked_at);++++CREATE TABLE IF NOT EXISTS import_state (++ key TEXT PRIMARY KEY,++ cursor INTEGER NOT NULL DEFAULT 0,++ last_run_at INTEGER,++ last_ok_at INTEGER,++ last_error TEXT++);++++CREATE TABLE IF NOT EXISTS coupon_import_holds (++ item_id TEXT PRIMARY KEY, -- itm_… held, never silently dropped++ source_key TEXT,++ reason TEXT NOT NULL, -- no_merchant | not_a_coupon | no_code | invalid++ detail TEXT,++ item TEXT NOT NULL, -- the item as Sources returned it (JSON)++ attempts INTEGER NOT NULL DEFAULT 1,++ created_at INTEGER NOT NULL,++ updated_at INTEGER NOT NULL,++ resolved_at INTEGER++);++`;++++/**++ * Open (or create) the database.++ * opts.now — injectable clock (epoch ms), so tests and replays are deterministic.++ */++function openStore(dbPath, { now = () => Date.now() } = {}) {++ if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });++ const db = new Database(dbPath);++ db.pragma('journal_mode = WAL');++ db.pragma('foreign_keys = ON');++ db.pragma('busy_timeout = 5000');++ db.exec(SCHEMA);++ return {++ db,++ now,++ sequencer: createIndexSequencer(db, { prefix: 'coupons', now }),++ tx: (fn) => db.transaction(fn)(),++ close: () => db.close(),++ };++}++++const CHARTER_TABLES = ['coupon_merchants', 'coupon_merchant_domains', 'coupons', 'coupon_sources', 'coupon_restrictions',++ 'coupon_validation_reports', 'coupon_status_history', 'coupon_application_hints', 'coupon_watches'];++++module.exports = { openStore, CHARTER_TABLES, STATUSES };nnew file mode 100644@@@ -0,0 +1,107 @@++'use strict';++++/**++ * Confidence and status from people's reports — a pure function of (reports, evidence, expiry,++ * disabled flag, now). Nothing else can make a code "working": not a submission, not a model, not++ * a source; only reports by people, and only while they are recent.++ *++ * THE FORMULA (documented in the README; tests pin it)++ *++ * Deduplication: one row per (reporter, coupon, UTC day) is stored; of those, only each++ * reporter's MOST RECENT report counts. Reporting the same code every day adds nothing.++ *++ * Recency: a report of age a days weighs w = 0.5 ^ (a / HALF_LIFE_DAYS) (half-life 7 days)++ * and reports older than WINDOW_DAYS (30) weigh 0.++ *++ * W = Σ w over counted "worked" reports, F = Σ w over counted "failed" reports.++ *++ * Evidence: the prior is Beta(α0, β0) with β0 = 1 and α0 = 1, or 1.5 when the code has merchant++ * evidence (it was published on one of the merchant's own domains). Evidence alone never produces++ * a number.++ *++ * confidence = null when W + F = 0 (no counted report in the window)++ * = (α0 + W) / (α0 + β0 + W + F) otherwise, rounded to 2 decimals++ *++ * status = disabled when staff or a service disabled it++ * = expired when a known expiry has passed (or staff marked it expired)++ * = unknown when W + F < MIN_MASS (0.5: roughly one report younger than a week)++ * = reported_working when confidence ≥ 0.6++ * = reported_failed when confidence ≤ 0.4++ * = unknown otherwise (the reports disagree)++ *++ * So one fresh "worked" report gives 0.67 → reported_working; one fresh "failed" gives 0.33 →++ * reported_failed; one of each gives 0.5 → unknown; and a lone report decays back to unknown after++ * a week, and out of the formula (confidence null) after 30 days.++ */++const DAY = 24 * 60 * 60 * 1000;++const HALF_LIFE_DAYS = 7;++const WINDOW_DAYS = 30;++const MIN_MASS = 0.5;++const WORKING_AT = 0.6;++const FAILED_AT = 0.4;++const EVIDENCE_PRIOR = 0.5;++++const round2 = (x) => Math.round(x * 100) / 100;++++function weight(ageMs) {++ if (ageMs < 0) ageMs = 0;++ if (ageMs > WINDOW_DAYS * DAY) return 0;++ return Math.pow(0.5, ageMs / (HALF_LIFE_DAYS * DAY));++}++++/**++ * reports: [{ reporter_key, outcome: 'worked'|'failed', updated_at }] (any order, any number per reporter)++ * → { confidence, W, F, mass, counted, derived: 'unknown'|'reported_working'|'reported_failed' }++ */++function fromReports(reports, { merchantEvidence = false, now }) {++ if (!Number.isFinite(now)) throw new TypeError('now is required');++ const latest = new Map();++ for (const r of reports) {++ const cur = latest.get(r.reporter_key);++ if (!cur || r.updated_at > cur.updated_at) latest.set(r.reporter_key, r);++ }++ let W = 0;++ let F = 0;++ let counted = 0;++ for (const r of latest.values()) {++ const w = weight(now - r.updated_at);++ if (w <= 0) continue;++ counted++;++ if (r.outcome === 'worked') W += w; else F += w;++ }++ const mass = W + F;++ if (mass <= 0) return { confidence: null, W: 0, F: 0, mass: 0, counted: 0, derived: 'unknown' };++ const a0 = 1 + (merchantEvidence ? EVIDENCE_PRIOR : 0);++ const confidence = round2((a0 + W) / (a0 + 1 + W + F));++ let derived = 'unknown';++ if (mass >= MIN_MASS) {++ if (confidence >= WORKING_AT) derived = 'reported_working';++ else if (confidence <= FAILED_AT) derived = 'reported_failed';++ }++ return { confidence, W: round2(W), F: round2(F), mass: round2(mass), counted, derived };++}++++/**++ * The full status of a coupon row at `now`.++ * coupon: { status, expires_at, expired_at, disabled_at }++ * → { status, confidence, reason }++ */++function evaluate(coupon, reports, { merchantEvidence = false, now }) {++ const r = fromReports(reports, { merchantEvidence, now });++ if (coupon.status === 'disabled') return { status: 'disabled', confidence: r.confidence, detail: r };++ const expiredByTime = coupon.expires_at != null && coupon.expires_at <= now;++ if (expiredByTime || (coupon.status === 'expired' && coupon.expired_at != null)) return { status: 'expired', confidence: r.confidence, detail: r };++ return { status: r.derived, confidence: r.confidence, detail: r };++}++++/** Is the coupon in active results at `now`? (Also enforced in SQL: see coupons.ACTIVE_SQL.) */++function isActive(coupon, now) {++ if (coupon.review_state !== 'published') return false;++ if (coupon.status === 'expired' || coupon.status === 'disabled') return false;++ return coupon.expires_at == null || coupon.expires_at > now;++}++++module.exports = {++ fromReports, evaluate, isActive, weight,++ HALF_LIFE_DAYS, WINDOW_DAYS, MIN_MASS, WORKING_AT, FAILED_AT, EVIDENCE_PRIOR, DAY,++};nnew file mode 100644@@@ -0,0 +1,522 @@++'use strict';++++/**++ * Codes: submission, restrictions, hints, evidence, status and confidence, expiry.++ *++ * Status is exactly unknown | reported_working | reported_failed | expired | disabled, and only++ * three things move it:++ * - people's reports (reports.js) through the confidence formula (confidence.js);++ * - time: a known expiry passing (and old reports decaying back to unknown);++ * - staff or a service holding coupons.status.update, and only to disabled / expired / back to++ * active (which recomputes from reports). Nobody can SET reported_working or reported_failed.++ * A submission never carries a status or a confidence: those fields are refused, from people, from++ * services and from AI output alike.++ *++ * Unknown stays unknown: expires_at NULL is "expiry unknown" (never defaulted), confidence NULL is++ * "no recent reports", and "no restrictions stated" is not "no restrictions".++ *++ * Active results = published review state, merchant active, status not expired/disabled, and++ * (expires_at IS NULL OR expires_at > now). The time condition is in the query itself, so a code++ * leaves every active list at the instant it expires, before the sweep records the transition.++ */++const { ids } = require('openvibe-contracts');++const { ApiError } = require('../http/errors');++const confidence = require('./confidence');++const hosts = require('./hosts');++++const ACTIVE_SQL = `c.review_state = 'published' AND c.status NOT IN ('expired','disabled')++ AND (c.expires_at IS NULL OR c.expires_at > @now) AND m.status = 'active'`;++++const CODE_RE = /^[\x21-\x7E]{1,64}$/;++const REGION_RE = /^[A-Z]{2}$/;++const CURRENCY_RE = /^[A-Z]{3}$/;++const ZERO_DECIMAL = new Set(['JPY', 'KRW', 'VND', 'CLP', 'ISK', 'UGX', 'XAF', 'XOF', 'PYG', 'RWF', 'KMF', 'GNF', 'DJF', 'BIF', 'VUV', 'XPF']);++const THREE_DECIMAL = new Set(['BHD', 'KWD', 'OMR', 'JOD', 'TND', 'IQD', 'LYD']);++const FORBIDDEN_FIELDS = ['status', 'confidence', 'verified', 'working', 'valid', 'validity'];++const DAY = 24 * 60 * 60 * 1000;++++const exponent = (cur) => (ZERO_DECIMAL.has(cur) ? 0 : THREE_DECIMAL.has(cur) ? 3 : 2);++const text = (v, max) => (v == null ? '' : String(v).replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '').trim().slice(0, max));++const asList = (v) => (Array.isArray(v) ? v : v == null || v === '' ? [] : String(v).split(','));++++/** '50' | '49.99' | 50 in `currency` → minor units, or throws. */++function toMinor(amount, currency) {++ const s = String(amount).trim();++ const e = exponent(currency);++ const re = e === 0 ? /^\d{1,9}$/ : new RegExp(`^\\d{1,9}(\\.\\d{1,${e}})?$`);++ if (!re.test(s)) throw new ApiError(422, 'coupon.bad_min_spend', `min spend must be a plain amount with at most ${e} decimals`);++ const [whole, frac = ''] = s.split('.');++ return Number(whole) * 10 ** e + Number((frac + '000').slice(0, e) || 0);++}++++function formatMinor(minor, currency) {++ const e = exponent(currency);++ const v = minor / 10 ** e;++ try { return new Intl.NumberFormat('en', { style: 'currency', currency, minimumFractionDigits: e, maximumFractionDigits: e }).format(v); } catch { return `${v.toFixed(e)} ${currency}`; }++}++++/**++ * Expiry input → { expires_at, expires_precision } | null (unknown).++ * 'YYYY-MM-DD' → the end of that day in UTC (the merchant's time zone is not known; documented);++ * a date-time needs an explicit offset or Z (a zone-less time is ambiguous and refused).++ */++function parseExpiry(v, now) {++ if (v == null || v === '') return null;++ const s = String(v).trim();++ let at;++ let precision;++ let m = s.match(/^(\d{4})-(\d{2})-(\d{2})$/);++ if (m) {++ at = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 23, 59, 59, 999);++ const d = new Date(at);++ if (d.getUTCFullYear() !== Number(m[1]) || d.getUTCMonth() !== Number(m[2]) - 1 || d.getUTCDate() !== Number(m[3])) at = NaN;++ precision = 'date';++ } else if ((m = s.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2})$/))) {++ at = Date.parse(s);++ precision = 'instant';++ } else if (/^\d{4}-\d{2}-\d{2}T/.test(s)) {++ throw new ApiError(422, 'coupon.ambiguous_expiry', 'an expiry time needs a time zone (Z or ±hh:mm); or give just the date');++ } else {++ throw new ApiError(422, 'coupon.bad_expiry', 'expires must be YYYY-MM-DD or an ISO 8601 date-time with a time zone');++ }++ if (!Number.isFinite(at)) throw new ApiError(422, 'coupon.bad_expiry', 'expires is not a real date');++ if (at <= now) throw new ApiError(422, 'coupon.already_expired', 'that expiry has already passed');++ if (at > now + 5 * 365 * DAY) throw new ApiError(422, 'coupon.bad_expiry', 'expiry more than five years ahead');++ return { expires_at: at, expires_precision: precision };++}++++/** Restrictions input (API object or form fields) → rows. Nothing stated → []. */++function parseRestrictions(r = {}) {++ const out = [];++ const min = r.min_spend;++ if (min && (min.amount !== undefined && min.amount !== '')) {++ const currency = String(min.currency || '').trim().toUpperCase();++ if (!CURRENCY_RE.test(currency)) throw new ApiError(422, 'coupon.bad_currency', 'min spend needs an ISO 4217 currency (e.g. USD)');++ out.push({ kind: 'min_spend', value: null, amount_minor: toMinor(min.amount, currency), currency });++ }++ const cats = asList(r.categories).map((c) => text(c, 60)).filter(Boolean);++ if (cats.length > 10) throw new ApiError(422, 'coupon.too_many_categories', 'at most 10 categories');++ for (const c of new Set(cats)) out.push({ kind: 'category', value: c });++ if (r.new_customers_only === true || r.new_customers_only === 'true' || r.new_customers_only === 'on' || r.new_customers_only === '1') {++ out.push({ kind: 'new_customers_only', value: null });++ }++ const regions = asList(r.regions).map((x) => String(x).trim().toUpperCase()).filter(Boolean);++ for (const x of regions) if (!REGION_RE.test(x)) throw new ApiError(422, 'coupon.bad_region', `region ${x} is not an ISO 3166-1 alpha-2 code`);++ if (regions.length > 50) throw new ApiError(422, 'coupon.too_many_regions', 'at most 50 regions');++ for (const x of new Set(regions)) out.push({ kind: 'region', value: x });++ const other = text(r.other, 300);++ if (other) out.push({ kind: 'other', value: other });++ return out;++}++++/** Validate a submission body. → normalized input. */++function parseSubmission(body, now) {++ if (!body || typeof body !== 'object' || Array.isArray(body)) throw new ApiError(422, 'request.invalid', 'body must be an object');++ for (const f of FORBIDDEN_FIELDS) {++ if (body[f] !== undefined) throw new ApiError(422, 'coupon.status_not_accepted', `"${f}" cannot be submitted: a code's status comes only from people's reports`);++ }++ const code = String(body.code == null ? '' : body.code).trim();++ if (!CODE_RE.test(code)) throw new ApiError(422, 'coupon.bad_code', 'code must be 1–64 printable characters without spaces');++ const title = text(body.title, 140);++ if (title.length < 3) throw new ApiError(422, 'coupon.title_required', 'title (what the code gives, e.g. "15% off shoes") is required');++ const expiry = parseExpiry(body.expires, now);++ let basis = null;++ if (expiry) basis = body.expiry_basis === 'evidence' ? 'evidence' : 'submitter';++ let evidence = null;++ if (body.evidence_url) {++ const u = String(body.evidence_url).trim();++ if (u.length > 2048 || !hosts.hostOfUrl(u)) throw new ApiError(422, 'coupon.bad_evidence', 'evidence_url must be an http(s) URL of a public site');++ evidence = new URL(u).toString();++ }++ if (basis === 'evidence' && !evidence) throw new ApiError(422, 'coupon.evidence_required', 'an expiry "stated on the evidence page" needs the evidence URL');++ const aiRun = body.ai_run_id == null ? null : text(body.ai_run_id, 64);++ return {++ code, code_key: code.toUpperCase(), title,++ description: text(body.description, 1000) || null,++ expiry, expiry_basis: basis, evidence_url: evidence,++ restrictions: parseRestrictions(body.restrictions || {}),++ hint: text(body.hint, 300) || null,++ ai_run_id: aiRun,++ };++}++++function createCoupons({ store, merchants, publication }) {++ const { db } = store;++ const q = {++ byId: db.prepare('SELECT * FROM coupons WHERE id = ?'),++ byKey: db.prepare('SELECT * FROM coupons WHERE merchant_id = ? AND code_key = ?'),++ insert: db.prepare(`INSERT INTO coupons (id, merchant_id, code, code_key, title, description, status, confidence, review_state, origin,++ expires_at, expires_precision, expiry_basis, created_by, created_at, updated_at)++ VALUES (@id, @merchant_id, @code, @code_key, @title, @description, 'unknown', NULL, @review_state, @origin,++ @expires_at, @expires_precision, @expiry_basis, @created_by, @now, @now)`),++ restrictionInsert: db.prepare('INSERT INTO coupon_restrictions (coupon_id, kind, value, amount_minor, currency, created_at) VALUES (@coupon_id, @kind, @value, @amount_minor, @currency, @now)'),++ restrictions: db.prepare('SELECT kind, value, amount_minor, currency FROM coupon_restrictions WHERE coupon_id = ? ORDER BY id'),++ hintInsert: db.prepare('INSERT INTO coupon_application_hints (merchant_id, coupon_id, text, created_by, created_at) VALUES (?, ?, ?, ?, ?)'),++ hints: db.prepare('SELECT id, coupon_id, text FROM coupon_application_hints WHERE merchant_id = ? AND (coupon_id IS NULL OR coupon_id = ?) ORDER BY coupon_id IS NOT NULL, id'),++ merchantHints: db.prepare('SELECT id, coupon_id, text FROM coupon_application_hints WHERE merchant_id = ? AND coupon_id IS NULL ORDER BY id'),++ sourceInsert: db.prepare(`INSERT INTO coupon_sources (coupon_id, kind, evidence_url, merchant_evidence, sources_item_id, sources_item_rev, source_key, retrieved_at, ai_run_id, submitted_by, created_at)++ VALUES (@coupon_id, @kind, @evidence_url, @merchant_evidence, @sources_item_id, @sources_item_rev, @source_key, @retrieved_at, @ai_run_id, @submitted_by, @now)`),++ sources: db.prepare('SELECT * FROM coupon_sources WHERE coupon_id = ? ORDER BY id'),++ sameEvidence: db.prepare('SELECT 1 FROM coupon_sources WHERE coupon_id = ? AND evidence_url = ? AND removed_at IS NULL'),++ merchantEvidence: db.prepare('SELECT 1 FROM coupon_sources WHERE coupon_id = ? AND merchant_evidence = 1 AND removed_at IS NULL LIMIT 1'),++ submittedSince: db.prepare('SELECT COUNT(*) AS n FROM coupon_sources WHERE submitted_by = ? AND created_at > ?'),++ reportsFor: db.prepare('SELECT reporter_key, outcome, updated_at FROM coupon_validation_reports WHERE coupon_id = ? AND updated_at > ?'),++ update: db.prepare(`UPDATE coupons SET status = @status, confidence = @confidence, status_reason = @status_reason, expired_at = @expired_at,++ disabled_at = @disabled_at, updated_at = @now WHERE id = @id`),++ history: db.prepare(`INSERT INTO coupon_status_history (coupon_id, from_status, to_status, confidence_before, confidence_after, reason, actor, created_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`),++ historyFor: db.prepare('SELECT from_status, to_status, confidence_before, confidence_after, reason, created_at FROM coupon_status_history WHERE coupon_id = ? ORDER BY id DESC LIMIT 50'),++ active: db.prepare(`SELECT c.* FROM coupons c JOIN coupon_merchants m ON m.id = c.merchant_id WHERE c.merchant_id = @merchant AND ${ACTIVE_SQL}++ ORDER BY CASE c.status WHEN 'reported_working' THEN 0 WHEN 'unknown' THEN 1 ELSE 2 END, COALESCE(c.last_worked_at, 0) DESC, c.created_at DESC LIMIT 200`),++ activeCount: db.prepare(`SELECT COUNT(*) AS n FROM coupons c JOIN coupon_merchants m ON m.id = c.merchant_id WHERE c.merchant_id = @merchant AND ${ACTIVE_SQL}`),++ recentlyEnded: db.prepare(`SELECT c.* FROM coupons c JOIN coupon_merchants m ON m.id = c.merchant_id++ WHERE c.merchant_id = @merchant AND c.review_state = 'published' AND m.status = 'active'++ AND (c.status = 'expired' OR (c.expires_at IS NOT NULL AND c.expires_at <= @now)) AND c.status <> 'disabled'++ ORDER BY COALESCE(c.expired_at, c.expires_at) DESC LIMIT 10`),++ recentActive: db.prepare(`SELECT c.* FROM coupons c JOIN coupon_merchants m ON m.id = c.merchant_id WHERE ${ACTIVE_SQL}++ ORDER BY c.created_at DESC LIMIT @limit`),++ allActive: db.prepare(`SELECT c.* FROM coupons c JOIN coupon_merchants m ON m.id = c.merchant_id WHERE ${ACTIVE_SQL} ORDER BY c.created_at DESC LIMIT 50000`),++ dueExpiry: db.prepare(`SELECT id FROM coupons WHERE expires_at IS NOT NULL AND expires_at <= ? AND status NOT IN ('expired','disabled') LIMIT 500`),++ decaying: db.prepare(`SELECT DISTINCT c.id FROM coupons c LEFT JOIN coupon_validation_reports r ON r.coupon_id = c.id++ WHERE c.status NOT IN ('expired','disabled') AND (c.confidence IS NOT NULL OR c.status <> 'unknown' OR r.updated_at > ?) LIMIT 2000`),++ pendingReview: db.prepare(`SELECT c.* FROM coupons c WHERE c.review_state = 'pending' AND c.status <> 'disabled' ORDER BY c.created_at LIMIT 200`),++ setReview: db.prepare("UPDATE coupons SET review_state = 'published', updated_at = ? WHERE id = ? AND review_state = 'pending'"),++ setExpiry: db.prepare('UPDATE coupons SET expires_at = ?, expires_precision = ?, expiry_basis = ?, updated_at = ? WHERE id = ?'),++ reportCounts: db.prepare(`SELECT outcome, COUNT(*) AS n FROM (++ SELECT reporter_key, outcome, MAX(updated_at) AS t FROM coupon_validation_reports WHERE coupon_id = ? AND updated_at > ? GROUP BY reporter_key)++ GROUP BY outcome`),++ byMerchantPending: db.prepare("SELECT id FROM coupons WHERE merchant_id = ? AND review_state = 'pending'"),++ ofMerchant: db.prepare('SELECT id FROM coupons WHERE merchant_id = ?'),++ };++++ const get = (id) => q.byId.get(String(id || ''));++++ function restrictionsOf(id) { return q.restrictions.all(id); }++++ function restrictionsText(rows) {++ return rows.map((r) => {++ if (r.kind === 'min_spend') return `Minimum spend ${formatMinor(r.amount_minor, r.currency)}`;++ if (r.kind === 'category') return `Category: ${r.value}`;++ if (r.kind === 'new_customers_only') return 'New customers only';++ if (r.kind === 'region') return `Region: ${r.value}`;++ return r.value;++ }).join('\n');++ }++++ function sourceRefs(id) {++ return q.sources.all(id).filter((s) => s.sources_item_id && !s.removed_at)++ .map((s) => ({ service: 'sources', type: 'item', id: s.sources_item_id, ...(Number.isInteger(s.sources_item_rev) ? { revision: s.sources_item_rev } : {}), ...(s.evidence_url ? { url: s.evidence_url } : {}), ...(s.retrieved_at ? { retrievedAt: s.retrieved_at } : {}) }));++ }++++ function activeCount(merchantId) { return q.activeCount.get({ merchant: merchantId, now: store.now() }).n; }++++ /** Send the Search documents of a code and its merchant (inside a transaction). */++ function syncIndex(c, { traceparent } = {}) {++ const m = merchants.byId(c.merchant_id);++ publication.sendDocument(publication.couponDocument(c, m, { restrictionsText: restrictionsText(restrictionsOf(c.id)), sourceRefs: sourceRefs(c.id) }), { traceparent });++ if (m) publication.sendDocument(publication.merchantDocument(m, activeCount(m.id)), { traceparent });++ }++++ function syncMerchant(m, { traceparent } = {}) {++ publication.sendDocument(publication.merchantDocument(m, activeCount(m.id)), { traceparent });++ for (const { id } of q.ofMerchant.all(m.id)) {++ const c = get(id);++ publication.sendDocument(publication.couponDocument(c, m, { restrictionsText: restrictionsText(restrictionsOf(c.id)), sourceRefs: sourceRefs(c.id) }), { traceparent });++ }++ }++++ function publiclyListed(c) {++ const m = merchants.byId(c.merchant_id);++ return Boolean(m && m.status === 'active' && confidence.isActive(c, store.now()));++ }++++ function lifecyclePayload(c, extra = {}) {++ return { merchant_id: c.merchant_id, status: c.status, confidence: c.confidence, review_state: c.review_state,++ expires_at: c.expires_at != null ? new Date(c.expires_at).toISOString() : null, canonical_url: publication.couponUrl(c), ...extra };++ }++++ /**++ * Recompute status and confidence from reports and time (inside a transaction), record the++ * change in coupon_status_history, emit events and Search documents.++ * forceStatus: 'disabled' | 'expired' | 'unknown' (re-enable) for staff/service changes.++ */++ function recompute(id, { reason, actor = 'system', forceStatus = null, traceparent } = {}) {++ const before = get(id);++ if (!before) return null;++ const now = store.now();++ let base = before;++ if (forceStatus === 'disabled') base = { ...before, status: 'disabled' };++ else if (forceStatus === 'expired') base = { ...before, status: 'expired', expired_at: now };++ else if (forceStatus === 'unknown') base = { ...before, status: 'unknown', expired_at: null, disabled_at: null };++ const reports = q.reportsFor.all(id, now - confidence.WINDOW_DAYS * DAY);++ const ev = confidence.evaluate(base, reports, { merchantEvidence: Boolean(q.merchantEvidence.get(id)), now });++ const statusChanged = ev.status !== before.status;++ const confChanged = ev.confidence !== before.confidence;++ if (!statusChanged && !confChanged && !forceStatus) return before;++ let expiredAt = null;++ if (ev.status === 'expired') expiredAt = before.status === 'expired' && before.expired_at ? before.expired_at : (before.expires_at != null && before.expires_at <= now ? before.expires_at : now);++ q.update.run({++ id, status: ev.status, confidence: ev.confidence, now,++ status_reason: statusChanged || forceStatus ? reason : before.status_reason,++ expired_at: expiredAt,++ disabled_at: ev.status === 'disabled' ? (before.disabled_at || now) : null,++ });++ const after = get(id);++ if (statusChanged || confChanged) q.history.run(id, before.status, after.status, before.confidence, after.confidence, reason, actor, now);++ if (statusChanged) {++ const type = after.status === 'expired' ? 'coupons.coupon.expired' : after.status === 'disabled' ? 'coupons.coupon.disabled' : 'coupons.coupon.updated';++ publication.emit(type, { type: 'coupon', id }, lifecyclePayload(after, { previous_status: before.status, reason: reason.startsWith('staff:') ? 'staff' : reason }), { isPublic: publiclyListed(after) || publiclyListed(before), traceparent });++ }++ if (confChanged) {++ publication.emit('coupons.confidence.changed', { type: 'coupon', id }, { merchant_id: after.merchant_id, from: before.confidence, to: after.confidence, status: after.status }, { traceparent });++ }++ syncIndex(after, { traceparent });++ return after;++ }++++ function addSource(couponId, merchant, { kind, evidence_url = null, sources_item_id = null, sources_item_rev = null, source_key = null, retrieved_at = null, ai_run_id = null, submitted_by = null }) {++ q.sourceInsert.run({++ coupon_id: couponId, kind, evidence_url, merchant_evidence: evidence_url && merchants.ownsUrl(merchant, evidence_url) ? 1 : 0,++ sources_item_id, sources_item_rev, source_key, retrieved_at, ai_run_id, submitted_by, now: store.now(),++ });++ }++++ function checkSubmissionRate(actorId, limits) {++ const now = store.now();++ if (q.submittedSince.get(actorId, now - 60 * 60 * 1000).n >= limits.submissionsPerHour++ || q.submittedSince.get(actorId, now - DAY).n >= limits.submissionsPerDay) {++ throw new ApiError(429, 'submission.rate_limited', 'too many submissions; try again later');++ }++ }++++ /**++ * Create a code (or add evidence to the same code). Inside one transaction.++ * who: { actor: usr_…|svc:…, kind: 'member'|'staff'|'ai', subject }++ * target: { merchant } (resolved by the caller)++ * → { coupon, merchant, duplicate, created }++ */++ function submit(who, merchant, input, { limits, traceparent } = {}) {++ checkSubmissionRate(who.actor, limits);++ return store.tx(() => {++ const existing = q.byKey.get(merchant.id, input.code_key);++ if (existing) {++ if (existing.status === 'disabled') throw new ApiError(409, 'coupon.disabled', 'this code was taken down and cannot be resubmitted');++ if (existing.status === 'expired' || (existing.expires_at != null && existing.expires_at <= store.now())) {++ throw new ApiError(409, 'coupon.expired', 'this code is recorded as expired');++ }++ if (!input.evidence_url || !q.sameEvidence.get(existing.id, input.evidence_url)) {++ addSource(existing.id, merchant, { kind: who.kind, evidence_url: input.evidence_url, ai_run_id: input.ai_run_id, submitted_by: who.actor });++ recompute(existing.id, { reason: 'evidence', actor: 'system', traceparent });++ }++ return { coupon: get(existing.id), merchant, duplicate: true, created: false };++ }++ const now = store.now();++ const id = `cpn_${ids.ulid(now)}`;++ const review = who.kind === 'ai' || merchant.status !== 'active' ? 'pending' : 'published';++ q.insert.run({++ id, merchant_id: merchant.id, code: input.code, code_key: input.code_key, title: input.title, description: input.description,++ review_state: review, origin: who.kind,++ expires_at: input.expiry ? input.expiry.expires_at : null,++ expires_precision: input.expiry ? input.expiry.expires_precision : null,++ expiry_basis: input.expiry ? (who.kind === 'staff' && input.expiry_basis === 'submitter' ? 'staff' : input.expiry_basis) : null,++ created_by: who.actor, now,++ });++ for (const r of input.restrictions) q.restrictionInsert.run({ coupon_id: id, kind: r.kind, value: r.value ?? null, amount_minor: r.amount_minor ?? null, currency: r.currency ?? null, now });++ if (input.hint) q.hintInsert.run(merchant.id, id, input.hint, who.actor, now);++ addSource(id, merchant, { kind: who.kind, evidence_url: input.evidence_url, ai_run_id: input.ai_run_id, submitted_by: who.actor });++ q.history.run(id, null, 'unknown', null, null, 'created', who.actor, now);++ const c = get(id);++ publication.emit('coupons.coupon.created', { type: 'coupon', id }, lifecyclePayload(c, { origin: c.origin }), { isPublic: publiclyListed(c), traceparent });++ syncIndex(c, { traceparent });++ return { coupon: c, merchant, duplicate: false, created: true };++ });++ }++++ /** Import one OpenVibe.Sources item as a code (inside the importer's transaction). */++ function importFromSource(merchant, item, parsed, { autoPublish }) {++ const existing = q.byKey.get(merchant.id, parsed.code_key);++ const src = {++ kind: 'source', evidence_url: item.canonical_url || null, sources_item_id: item.id, sources_item_rev: item.revision,++ source_key: item.source_key, retrieved_at: Date.parse(item.provenance && item.provenance.retrieved_at) || null, submitted_by: 'svc:sources',++ };++ if (existing) {++ const known = q.sources.all(existing.id).find((s) => s.sources_item_id === item.id);++ if (known) db.prepare('UPDATE coupon_sources SET sources_item_rev = ?, retrieved_at = ?, removed_at = NULL, removed_reason = NULL WHERE id = ?').run(item.revision, src.retrieved_at, known.id);++ else addSource(existing.id, merchant, src);++ if (existing.expires_at == null && parsed.expiry && existing.status !== 'expired') {++ q.setExpiry.run(parsed.expiry.expires_at, parsed.expiry.expires_precision, 'source', store.now(), existing.id);++ }++ recompute(existing.id, { reason: 'evidence', actor: 'svc:sources' });++ return { coupon: get(existing.id), created: false };++ }++ const now = store.now();++ const id = `cpn_${ids.ulid(now)}`;++ q.insert.run({++ id, merchant_id: merchant.id, code: parsed.code, code_key: parsed.code_key, title: parsed.title, description: parsed.description,++ review_state: autoPublish && merchant.status === 'active' ? 'published' : 'pending', origin: 'source',++ expires_at: parsed.expiry ? parsed.expiry.expires_at : null,++ expires_precision: parsed.expiry ? parsed.expiry.expires_precision : null,++ expiry_basis: parsed.expiry ? 'source' : null, created_by: 'svc:sources', now,++ });++ for (const r of parsed.restrictions) q.restrictionInsert.run({ coupon_id: id, kind: r.kind, value: r.value ?? null, amount_minor: r.amount_minor ?? null, currency: r.currency ?? null, now });++ addSource(id, merchant, src);++ q.history.run(id, null, 'unknown', null, null, 'created', 'svc:sources', now);++ const c = get(id);++ publication.emit('coupons.coupon.created', { type: 'coupon', id }, lifecyclePayload(c, { origin: 'source' }), { isPublic: publiclyListed(c) });++ syncIndex(c);++ return { coupon: c, created: true };++ }++++ /** A Sources item was removed: its evidence is withdrawn; a code with no other evidence is disabled. */++ function withdrawSourceItem(itemId, reason) {++ const rows = db.prepare('SELECT * FROM coupon_sources WHERE sources_item_id = ? AND removed_at IS NULL').all(itemId);++ for (const s of rows) {++ db.prepare('UPDATE coupon_sources SET removed_at = ?, removed_reason = ? WHERE id = ?').run(store.now(), String(reason || 'removed at the source').slice(0, 300), s.id);++ const remaining = db.prepare('SELECT COUNT(*) AS n FROM coupon_sources WHERE coupon_id = ? AND removed_at IS NULL').get(s.coupon_id).n;++ if (!remaining) recompute(s.coupon_id, { reason: 'source_removed', actor: 'svc:sources', forceStatus: 'disabled' });++ else recompute(s.coupon_id, { reason: 'evidence', actor: 'svc:sources' });++ }++ return rows.length;++ }++++ /**++ * Staff/service status change: 'disabled' | 'expired' | 'active' (re-enable: recomputed from++ * reports and time). reported_working / reported_failed cannot be set by anyone.++ */++ function setStatus(c, target, { actor, note = '', traceparent } = {}) {++ const why = `${actor.startsWith('svc:') ? `service:${actor.slice(4)}` : 'staff'}${note ? `:${text(note, 200)}` : ''}`;++ if (target === 'disabled') return store.tx(() => recompute(c.id, { reason: why, actor, forceStatus: 'disabled', traceparent }));++ if (target === 'expired') {++ if (c.status === 'disabled') throw new ApiError(409, 'coupon.disabled', 'enable the code before marking it expired');++ return store.tx(() => recompute(c.id, { reason: why, actor, forceStatus: 'expired', traceparent }));++ }++ if (target === 'active') {++ if (c.expires_at != null && c.expires_at <= store.now()) throw new ApiError(409, 'coupon.expiry_passed', 'its known expiry has passed; change the expiry first');++ return store.tx(() => recompute(c.id, { reason: why, actor, forceStatus: 'unknown', traceparent }));++ }++ throw new ApiError(422, 'coupon.status_not_settable', 'status can be set to disabled, expired or active only; working/failed come from people\'s reports');++ }++++ /** Staff: publish a code that waits for review. */++ function approve(c, { actor }) {++ return store.tx(() => {++ if (q.setReview.run(store.now(), c.id).changes !== 1) return get(c.id);++ const after = get(c.id);++ q.history.run(c.id, c.status, after.status, c.confidence, after.confidence, 'staff:approved', actor, store.now());++ publication.emit('coupons.coupon.updated', { type: 'coupon', id: c.id }, lifecyclePayload(after, { previous_status: c.status, reason: 'approved' }), { isPublic: publiclyListed(after) });++ syncIndex(after);++ return after;++ });++ }++++ /** Staff: set or clear a code's expiry (clear = back to unknown). */++ function setExpiry(c, value, { actor }) {++ const expiry = value ? parseExpiry(value, store.now()) : null;++ return store.tx(() => {++ q.setExpiry.run(expiry ? expiry.expires_at : null, expiry ? expiry.expires_precision : null, expiry ? 'staff' : null, store.now(), c.id);++ const updated = get(c.id);++ publication.emit('coupons.coupon.updated', { type: 'coupon', id: c.id }, lifecyclePayload(updated, { reason: 'expiry_changed' }), { isPublic: publiclyListed(updated) });++ recompute(c.id, { reason: 'staff:expiry', actor });++ syncIndex(get(c.id));++ return get(c.id);++ });++ }++++ function addMerchantHint(m, hint, { actor }) {++ const t = text(hint, 300);++ if (!t) throw new ApiError(422, 'hint.empty', 'hint text is required');++ q.hintInsert.run(m.id, null, t, actor, store.now());++ }++++ /**++ * The sweep: record expiries that have passed and let old reports decay. Idempotent.++ * → { expired: [ids], changed: [ids] }++ */++ function sweep() {++ const now = store.now();++ const expired = [];++ const changed = [];++ for (const { id } of q.dueExpiry.all(now)) {++ const after = store.tx(() => recompute(id, { reason: 'expiry', actor: 'system' }));++ if (after && after.status === 'expired') expired.push(id);++ }++ for (const { id } of q.decaying.all(now - (confidence.WINDOW_DAYS + 1) * DAY)) {++ const before = get(id);++ const after = store.tx(() => recompute(id, { reason: 'decay', actor: 'system' }));++ if (after && before && (after.status !== before.status || after.confidence !== before.confidence)) changed.push(id);++ }++ return { expired, changed };++ }++++ /** Counted reports in the window: { worked, failed } (aggregates only, never who). */++ function reportCounts(id) {++ const out = { worked: 0, failed: 0 };++ for (const r of q.reportCounts.all(id, store.now() - confidence.WINDOW_DAYS * DAY)) out[r.outcome] = r.n;++ return out;++ }++++ /**++ * The public view of a code. Never includes who submitted or reported it.++ */++ function view(c, { merchant = null } = {}) {++ const now = store.now();++ const m = merchant || merchants.byId(c.merchant_id);++ const sources = q.sources.all(c.id).filter((s) => !s.removed_at);++ const iso = (t) => (t == null ? null : new Date(t).toISOString());++ // Report times are published to the hour: enough for "last report 3 hours ago", too coarse++ // to tie a report to the moment someone made it.++ const hour = (t) => (t == null ? null : new Date(Math.floor(t / 3600000) * 3600000).toISOString());++ return {++ id: c.id,++ merchant_id: c.merchant_id,++ code: c.code,++ title: c.title,++ description: c.description,++ status: c.status,++ active: Boolean(m && m.status === 'active' && confidence.isActive(c, now)),++ confidence: c.confidence,++ reports: { ...reportCounts(c.id), window_days: confidence.WINDOW_DAYS, last_report_at: hour(c.last_report_at), last_worked_at: hour(c.last_worked_at), last_failed_at: hour(c.last_failed_at) },++ expiry: c.expires_at == null++ ? { known: false, expires_at: null, precision: null, basis: null }++ : { known: true, expires_at: iso(c.expires_at), precision: c.expires_precision, basis: c.expiry_basis },++ restrictions: restrictionsOf(c.id).map((r) => (r.kind === 'min_spend'++ ? { kind: r.kind, amount: formatMinor(r.amount_minor, r.currency), amount_minor: r.amount_minor, currency: r.currency }++ : r.kind === 'new_customers_only' ? { kind: r.kind } : { kind: r.kind, value: r.value })),++ hints: m ? q.hints.all(m.id, c.id).map((h) => ({ text: h.text, scope: h.coupon_id ? 'code' : 'merchant' })) : [],++ evidence: sources.map((s) => ({++ kind: s.kind, url: s.evidence_url, merchant_page: Boolean(s.merchant_evidence),++ ...(s.sources_item_id ? { sources_item: s.sources_item_id, retrieved_at: iso(s.retrieved_at) } : {}),++ })),++ origin: c.origin,++ created_at: iso(c.created_at),++ updated_at: iso(c.updated_at),++ expired_at: iso(c.expired_at),++ url: publication.couponUrl(c),++ };++ }++++ return {++ ACTIVE_SQL, parseSubmission, parseExpiry, parseRestrictions, formatMinor, restrictionsText,++ get, submit, checkSubmissionRate, importFromSource, withdrawSourceItem, recompute, setStatus, approve, setExpiry, addMerchantHint, sweep, view,++ restrictionsOf, reportCounts, activeCount, syncIndex, syncMerchant,++ history: (id) => q.historyFor.all(id),++ active: (merchantId) => q.active.all({ merchant: merchantId, now: store.now() }),++ recentlyEnded: (merchantId) => q.recentlyEnded.all({ merchant: merchantId, now: store.now() }),++ recentActive: (limit = 30) => q.recentActive.all({ now: store.now(), limit }),++ allActive: () => q.allActive.all({ now: store.now() }),++ pendingReview: () => q.pendingReview.all(),++ pendingOfMerchant: (merchantId) => q.byMerchantPending.all(merchantId).map((r) => r.id),++ /** Publish the codes that waited only because their merchant was pending (not AI or source codes). */++ publishWaitingOn(merchant, { actor }) {++ const out = [];++ for (const id of q.byMerchantPending.all(merchant.id).map((r) => r.id)) {++ const c = get(id);++ if (c.origin === 'member' || c.origin === 'staff') out.push(approve(c, { actor }));++ }++ return out;++ },++ merchantHints: (merchantId) => q.merchantHints.all(merchantId),++ };++}++++module.exports = { createCoupons, parseSubmission, parseExpiry, parseRestrictions, ACTIVE_SQL, FORBIDDEN_FIELDS };nnew file mode 100644@@@ -0,0 +1,139 @@++'use strict';++++/**++ * Host normalization and the domain rules merchants are matched by.++ *++ * normalizeHost('WWW.Example-Shop.co.uk.') → 'www.example-shop.co.uk'++ * normalizeHost('bücher.example') → 'xn--bcher-kva.example'++ * registrable('www.example-shop.co.uk') → 'example-shop.co.uk'++ *++ * A host is: lowercase, punycode (IDNA via url.domainToASCII), no trailing dot, no port, at least++ * two labels, each 1–63 of [a-z0-9-] not starting or ending with '-', at most 253 characters. IP++ * literals, localhost and special-use names (.local, .internal, .invalid, .arpa …) are refused:++ * they are never a merchant and must not be looked up.++ *++ * Rule matching (merchant resolution):++ * - a rule names a host at or below a registrable domain, never a public suffix;++ * - include_subdomains: the rule also covers every subdomain of its host, but never crosses the++ * registrable domain of the looked-up host (a rule on myshopify.com cannot claim++ * foo.myshopify.com, which is its own site);++ * - path_prefix: the rule matches only when a path is known and starts with the prefix at a++ * segment boundary; host-only lookups (the browser helper sends only a hostname) never match++ * path rules, so they fall back to the host's own rule;++ * - the most specific rule wins: longest host, then longest path prefix.++ */++const { domainToASCII } = require('url');++const { registrableDomain, publicSuffix } = require('./psl');++++const LABEL_RE = /^(?!-)[a-z0-9-]{1,63}(?<!-)$/;++const SPECIAL = ['localhost', 'local', 'internal', 'invalid', 'arpa', 'test', 'example', 'onion', 'lan', 'home', 'corp'];++++class HostError extends Error {++ constructor(detail) { super(detail); this.name = 'HostError'; this.status = 400; this.code = 'host.invalid'; }++}++++/** → normalized host, or throws HostError. */++function normalizeHost(input) {++ if (typeof input !== 'string') throw new HostError('host must be a string');++ let h = input.trim().toLowerCase();++ if (!h || h.length > 300) throw new HostError('host is empty or too long');++ if (/[\s/\\?#@]/.test(h)) throw new HostError('host must be a hostname only (no scheme, path, query or credentials)');++ if (h.startsWith('[')) throw new HostError('IP addresses are not merchants');++ const colon = h.indexOf(':');++ if (colon !== -1) {++ if (!/^\d{1,5}$/.test(h.slice(colon + 1))) throw new HostError('malformed port');++ h = h.slice(0, colon);++ }++ h = h.replace(/\.$/, '');++ const ascii = domainToASCII(h);++ if (!ascii) throw new HostError('not a valid domain name');++ h = ascii.toLowerCase();++ if (h.length > 253) throw new HostError('host is too long');++ const labels = h.split('.');++ if (labels.length < 2) throw new HostError('host needs at least two labels');++ if (!labels.every((l) => LABEL_RE.test(l))) throw new HostError('host has a malformed label');++ if (labels.every((l) => /^\d+$/.test(l)) || /^\d+$/.test(labels[labels.length - 1])) throw new HostError('IP addresses are not merchants');++ if (SPECIAL.includes(labels[labels.length - 1])) throw new HostError('special-use names are not merchants');++ return h;++}++++/** Normalize, or null. */++function tryHost(input) {++ try { return normalizeHost(input); } catch { return null; }++}++++/** The registrable domain (eTLD+1) of a normalized host, or null when it is a public suffix. */++function registrable(host) { return registrableDomain(host); }++++/** Host and path of an http(s) URL (for evidence and Sources items), or null. */++function hostOfUrl(url) {++ let u;++ try { u = new URL(String(url)); } catch { return null; }++ if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;++ const host = tryHost(u.hostname);++ return host ? { host, path: u.pathname || '/' } : null;++}++++/** '/Shop/x/' → '/Shop/x' ; '' | '/' → '' ; throws on anything that is not a plain path. */++function normalizePathPrefix(p) {++ if (p == null || p === '' || p === '/') return '';++ const s = String(p).trim();++ if (!s.startsWith('/') || s.length > 200 || /[\s?#\\]/.test(s) || s.includes('//') || /(^|\/)\.\.?(\/|$)/.test(s)) {++ throw new HostError('path_prefix must be a plain path like /shop/name');++ }++ return s.replace(/\/+$/, '');++}++++/** Does `path` start with `prefix` at a segment boundary? */++function pathMatches(prefix, path) {++ if (!prefix) return true;++ if (path == null) return false;++ return path === prefix || path.startsWith(`${prefix}/`);++}++++/**++ * Validate a new domain rule. → { host, registrable, path_prefix, include_subdomains }.++ * A rule on a public suffix (co.uk, myshopify.com …) is refused.++ */++function checkRule({ host, include_subdomains = true, path_prefix = '' } = {}) {++ const h = normalizeHost(host);++ const reg = registrable(h);++ if (!reg) throw new HostError(`${h} is a public suffix (${publicSuffix(h)}), not a site`);++ return { host: h, registrable: reg, path_prefix: normalizePathPrefix(path_prefix), include_subdomains: include_subdomains === false || include_subdomains === 0 || include_subdomains === '0' ? 0 : 1 };++}++++/**++ * The best rule for (host, path) among candidate rule rows++ * ({ host, registrable_domain, include_subdomains, path_prefix, … }), or null.++ */++function bestRule(rules, host, path = null) {++ const reg = registrable(host);++ if (!reg) return null;++ let best = null;++ for (const r of rules) {++ const exact = r.host === host;++ const parent = !exact && r.include_subdomains && host.endsWith(`.${r.host}`);++ if (!exact && !parent) continue;++ // Never across registrable domains: the rule's host must be inside the host's own site.++ if (r.host.length < reg.length || !(r.host === reg || r.host.endsWith(`.${reg}`))) continue;++ if (!pathMatches(r.path_prefix, path)) continue;++ if (!best || r.host.length > best.host.length || (r.host.length === best.host.length && r.path_prefix.length > best.path_prefix.length)) best = r;++ }++ return best;++}++++/** Every host a lookup could match a rule on: the host and its parents down to the registrable domain. */++function candidateHosts(host) {++ const reg = registrable(host);++ if (!reg) return [];++ const out = [];++ let h = host;++ for (;;) {++ out.push(h);++ if (h === reg) break;++ h = h.slice(h.indexOf('.') + 1);++ }++ return out;++}++++module.exports = { HostError, normalizeHost, tryHost, registrable, hostOfUrl, normalizePathPrefix, pathMatches, checkRule, bestRule, candidateHosts };nnew file mode 100644@@@ -0,0 +1,98 @@++'use strict';++++/**++ * Extension install tokens — the only credential the OpenVibe browser helper ever holds.++ *++ * cpx_<43 base64url chars> 256 random bits; shown ONCE on /connect-extension; stored as sha256++ *++ * A signed-in person creates one per browser, with the scopes they choose (coupons.lookup is++ * always included; coupons.report is optional). A token is:++ * - scoped: it can look up codes and (optionally) report worked/failed — nothing else. It is not++ * a Network session and is refused everywhere a Network token is expected;++ * - revocable: revoked_at is checked on every request (no cache), so revocation is immediate;++ * - expiring: COUPONS_INSTALL_TTL_DAYS (365) after creation.++ * Revoking or expiring a token never deletes the reports made with it (they are the person's).++ */++const crypto = require('crypto');++const { ids } = require('openvibe-contracts');++const { ApiError } = require('../http/errors');++const { SCOPES, ALL_SCOPES } = require('../auth/capabilities');++++const TOKEN_RE = /^cpx_[A-Za-z0-9_-]{43}$/;++const DAY = 24 * 60 * 60 * 1000;++++const hashToken = (token) => crypto.createHash('sha256').update(String(token)).digest('hex');++++function createInstalls({ store, config }) {++ const { db } = store;++ const q = {++ insert: db.prepare(`INSERT INTO extension_installs (id, subject, token_hash, token_hint, label, scopes, created_at, expires_at)++ VALUES (@id, @subject, @token_hash, @token_hint, @label, @scopes, @created_at, @expires_at)`),++ byHash: db.prepare('SELECT * FROM extension_installs WHERE token_hash = ?'),++ byId: db.prepare('SELECT * FROM extension_installs WHERE id = ?'),++ mine: db.prepare('SELECT * FROM extension_installs WHERE subject = ? ORDER BY created_at DESC LIMIT 50'),++ activeCount: db.prepare('SELECT COUNT(*) AS n FROM extension_installs WHERE subject = ? AND revoked_at IS NULL AND expires_at > ?'),++ revoke: db.prepare('UPDATE extension_installs SET revoked_at = ? WHERE id = ? AND subject = ? AND revoked_at IS NULL'),++ touch: db.prepare('UPDATE extension_installs SET last_used_at = ? WHERE id = ? AND (last_used_at IS NULL OR last_used_at < ?)'),++ };++++ const view = (row) => ({++ id: row.id,++ label: row.label,++ token_hint: row.token_hint,++ scopes: JSON.parse(row.scopes),++ created_at: new Date(row.created_at).toISOString(),++ expires_at: new Date(row.expires_at).toISOString(),++ last_used_at: row.last_used_at ? new Date(row.last_used_at).toISOString() : null,++ revoked_at: row.revoked_at ? new Date(row.revoked_at).toISOString() : null,++ active: !row.revoked_at && row.expires_at > store.now(),++ });++++ return {++ TOKEN_RE,++ view,++++ /** → { install (view), token } — the token exists only in this return value. */++ create(subject, { label, scopes } = {}) {++ if (!ids.isSubjectId('user', subject)) throw new ApiError(403, 'auth.person_required', 'only a signed-in person can connect an extension');++ const name = String(label == null ? '' : label).trim().slice(0, 60) || 'Browser helper';++ const wanted = new Set(Array.isArray(scopes) ? scopes : scopes ? [scopes] : []);++ for (const s of wanted) if (!ALL_SCOPES.includes(s)) throw new ApiError(422, 'install.bad_scope', `unknown scope ${s}`);++ wanted.add(SCOPES.LOOKUP);++ const now = store.now();++ if (q.activeCount.get(subject, now).n >= config.limits.installsPerSubject) {++ throw new ApiError(409, 'install.limit', `at most ${config.limits.installsPerSubject} connected extensions; revoke one first`);++ }++ const token = `cpx_${crypto.randomBytes(32).toString('base64url')}`;++ const row = {++ id: `cpi_${ids.ulid(now)}`, subject, token_hash: hashToken(token), token_hint: token.slice(0, 10),++ label: name, scopes: JSON.stringify(ALL_SCOPES.filter((s) => wanted.has(s))),++ created_at: now, expires_at: now + config.installTtlDays * DAY,++ };++ q.insert.run(row);++ return { install: view(q.byId.get(row.id)), token };++ },++++ list(subject) { return q.mine.all(subject).map(view); },++++ /** Revoke one of `subject`'s installs. → true when it was active. */++ revoke(subject, id) { return q.revoke.run(store.now(), String(id), subject).changes === 1; },++++ /**++ * Verify a presented token. → { install, subject, scopes } or throws 401 (malformed, unknown,++ * revoked, expired). Reads the row every time: revocation takes effect on the next request.++ */++ verify(token) {++ if (!TOKEN_RE.test(String(token))) throw new ApiError(401, 'token.invalid', 'malformed extension token');++ const row = q.byHash.get(hashToken(token));++ if (!row) throw new ApiError(401, 'token.invalid', 'unknown extension token');++ const now = store.now();++ if (row.revoked_at) throw new ApiError(401, 'token.revoked', 'this extension was disconnected; connect it again on openvibe.coupons');++ if (row.expires_at <= now) throw new ApiError(401, 'token.expired', 'this extension token expired; connect it again on openvibe.coupons');++ q.touch.run(now, row.id, now - 60 * 1000);++ return { install: row.id, subject: row.subject, scopes: JSON.parse(row.scopes) };++ },++ };++}++++module.exports = { createInstalls, hashToken, TOKEN_RE };nnew file mode 100644@@@ -0,0 +1,151 @@++'use strict';++++/**++ * Merchants and their domain rules.++ *++ * pending proposed by a member's submission for a site no merchant covers yet; invisible to the++ * public (no page, no lookup) until staff approve it++ * active listed, resolvable, its codes can be shown++ * disabled taken down: no page (410), no lookup, codes out of every list++ *++ * resolve(host, { path }) is the merchant lookup (see hosts.js for the matching rules). Only active++ * merchants resolve publicly.++ */++const { ids } = require('openvibe-contracts');++const hosts = require('./hosts');++const { ApiError } = require('../http/errors');++++const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;++++function slugify(s) {++ return String(s || '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase()++ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64).replace(/-+$/, '');++}++++function createMerchants({ store }) {++ const { db } = store;++ const q = {++ byId: db.prepare('SELECT * FROM coupon_merchants WHERE id = ?'),++ bySlug: db.prepare('SELECT * FROM coupon_merchants WHERE slug = ?'),++ insert: db.prepare(`INSERT INTO coupon_merchants (id, slug, name, homepage_url, description, status, created_by, created_at, updated_at)++ VALUES (@id, @slug, @name, @homepage_url, @description, @status, @created_by, @now, @now)`),++ setStatus: db.prepare('UPDATE coupon_merchants SET status = ?, updated_at = ? WHERE id = ?'),++ domains: db.prepare('SELECT * FROM coupon_merchant_domains WHERE merchant_id = ? ORDER BY host, path_prefix'),++ domainInsert: db.prepare(`INSERT INTO coupon_merchant_domains (merchant_id, host, registrable_domain, include_subdomains, path_prefix, created_by, created_at)++ VALUES (@merchant_id, @host, @registrable, @include_subdomains, @path_prefix, @created_by, @now)`),++ domainByKey: db.prepare('SELECT * FROM coupon_merchant_domains WHERE host = ? AND path_prefix = ?'),++ domainDelete: db.prepare('DELETE FROM coupon_merchant_domains WHERE id = ? AND merchant_id = ?'),++ rulesFor: db.prepare(`SELECT d.*, m.status AS merchant_status FROM coupon_merchant_domains d JOIN coupon_merchants m ON m.id = d.merchant_id++ WHERE d.registrable_domain = ?`),++ listActive: db.prepare(`SELECT * FROM coupon_merchants WHERE status = 'active' ORDER BY name COLLATE NOCASE LIMIT ? OFFSET ?`),++ countActive: db.prepare("SELECT COUNT(*) AS n FROM coupon_merchants WHERE status = 'active'"),++ search: db.prepare(`SELECT DISTINCT m.* FROM coupon_merchants m LEFT JOIN coupon_merchant_domains d ON d.merchant_id = m.id++ WHERE m.status = 'active' AND (m.name LIKE @q ESCAPE '\\' OR d.host LIKE @q ESCAPE '\\')++ ORDER BY m.name COLLATE NOCASE LIMIT 50`),++ pending: db.prepare("SELECT * FROM coupon_merchants WHERE status = 'pending' ORDER BY created_at LIMIT 200"),++ };++++ function get(idOrSlug) {++ const s = String(idOrSlug || '');++ return s.startsWith('mer_') ? q.byId.get(s) : q.bySlug.get(s);++ }++++ function uniqueSlug(base) {++ let slug = SLUG_RE.test(base) ? base : slugify(base) || 'merchant';++ if (!q.bySlug.get(slug)) return slug;++ for (let i = 2; i < 1000; i++) { const s = `${slug.slice(0, 58)}-${i}`; if (!q.bySlug.get(s)) return s; }++ throw new ApiError(409, 'merchant.slug_taken', 'could not find a free slug');++ }++++ /** Add one domain rule (inside the caller's transaction). */++ function addDomain(merchant, rule, actor) {++ const r = hosts.checkRule(rule);++ const clash = q.domainByKey.get(r.host, r.path_prefix);++ if (clash) {++ if (clash.merchant_id === merchant.id) return clash;++ throw new ApiError(409, 'merchant.domain_taken', `${r.host}${r.path_prefix} already belongs to another merchant`);++ }++ q.domainInsert.run({ merchant_id: merchant.id, ...r, created_by: actor, now: store.now() });++ return q.domainByKey.get(r.host, r.path_prefix);++ }++++ /**++ * Create a merchant with at least one domain rule.++ * input: { name, slug?, homepage_url?, description?, domains: [{ host, include_subdomains?, path_prefix? }] }++ */++ function create(input, { status = 'active', actor }) {++ const domains = Array.isArray(input.domains) ? input.domains : [];++ if (!domains.length) throw new ApiError(422, 'merchant.domain_required', 'a merchant needs at least one domain');++ const first = hosts.checkRule(domains[0]);++ const name = String(input.name == null ? '' : input.name).trim().slice(0, 120) || first.registrable;++ let homepage = null;++ if (input.homepage_url) {++ const h = hosts.hostOfUrl(input.homepage_url);++ if (!h || !/^https:\/\//i.test(String(input.homepage_url))) throw new ApiError(422, 'merchant.bad_homepage', 'homepage_url must be an https URL');++ homepage = String(input.homepage_url).slice(0, 500);++ } else {++ homepage = `https://${first.host}/`;++ }++ return store.tx(() => {++ const id = `mer_${ids.ulid(store.now())}`;++ const slug = uniqueSlug(input.slug ? String(input.slug) : slugify(name));++ q.insert.run({++ id, slug, name, homepage_url: homepage,++ description: input.description ? String(input.description).trim().slice(0, 1000) : null,++ status, created_by: actor, now: store.now(),++ });++ const m = q.byId.get(id);++ for (const d of domains) addDomain(m, d, actor);++ return m;++ });++ }++++ /**++ * The merchant for a host (and optionally a path). `includeStatuses` limits which merchants count++ * (default: active only). → { merchant, rule, host, registrable } or null.++ */++ function resolve(rawHost, { path = null, includeStatuses = ['active'] } = {}) {++ const host = hosts.normalizeHost(rawHost);++ const reg = hosts.registrable(host);++ if (!reg) return null;++ const rules = q.rulesFor.all(reg).filter((r) => includeStatuses.includes(r.merchant_status));++ const rule = hosts.bestRule(rules, host, path);++ if (!rule) return null;++ return { merchant: q.byId.get(rule.merchant_id), rule, host, registrable: reg };++ }++++ /** Is `url` on one of the merchant's own domains (merchant evidence)? */++ function ownsUrl(merchant, url) {++ const h = hosts.hostOfUrl(url);++ if (!h) return false;++ const rules = q.domains.all(merchant.id);++ return Boolean(hosts.bestRule(rules, h.host, h.path));++ }++++ return {++ get,++ byId: (id) => q.byId.get(id),++ domains: (m) => q.domains.all(m.id),++ create,++ addDomain: (m, rule, actor) => store.tx(() => addDomain(m, rule, actor)),++ removeDomain: (m, domainId) => q.domainDelete.run(Number(domainId), m.id).changes === 1,++ setStatus(m, status) {++ if (!['pending', 'active', 'disabled'].includes(status)) throw new ApiError(422, 'merchant.bad_status', 'status must be pending, active or disabled');++ q.setStatus.run(status, store.now(), m.id);++ return q.byId.get(m.id);++ },++ resolve,++ ownsUrl,++ listActive: ({ limit = 100, offset = 0 } = {}) => q.listActive.all(limit, offset),++ countActive: () => q.countActive.get().n,++ search(text) {++ const t = String(text || '').trim().toLowerCase().slice(0, 60);++ if (!t) return [];++ return q.search.all({ q: `%${t.replace(/[\\%_]/g, (c) => `\\${c}`)}%` });++ },++ pending: () => q.pending.all(),++ slugify,++ };++}++++module.exports = { createMerchants, slugify };nnew file mode 100644@@@ -0,0 +1,28 @@++'use strict';++++/**++ * Staff actions that span merchants and codes. Every one is recorded: merchant status on the row,++ * code changes in coupon_status_history, and both reach Search (documents or tombstones).++ *++ * approve a pending merchant → active; the codes members submitted for it are published++ * (AI-extracted and imported codes still wait for their own review)++ * disable a merchant → its page is gone (410), lookups stop, every code leaves active++ * results and Search (tombstones); nothing is deleted++ */++const { ApiError } = require('../http/errors');++++function createModeration({ store, merchants, coupons }) {++ return {++ setMerchantStatus(m, status, { actor = 'system' } = {}) {++ if (!['active', 'disabled'].includes(status)) throw new ApiError(422, 'merchant.bad_status', 'status must be active or disabled');++ return store.tx(() => {++ const after = merchants.setStatus(m, status);++ if (status === 'active') coupons.publishWaitingOn(after, { actor });++ coupons.syncMerchant(after);++ return after;++ });++ },++ };++}++++module.exports = { createModeration };nnew file mode 100644@@@ -0,0 +1,100 @@++'use strict';++++/**++ * A small, bundled subset of the Public Suffix List (https://publicsuffix.org/list/, MPL-2.0),++ * enough to find the registrable domain (eTLD+1) of the shops Coupons is likely to see.++ *++ * Rules follow the PSL algorithm: the longest matching rule wins, `*.x` matches any single label++ * under x, `!a.x` is an exception to a wildcard, and when nothing matches the rule is `*` (the top++ * label alone is the suffix). Private-section entries (hosted shops, static hosts) are included++ * because on them every customer's subdomain is a separate site: foo.myshopify.com and++ * bar.myshopify.com must never resolve to the same merchant.++ *++ * This is a subset on purpose (no network fetch, no runtime dependency). The consequence of a++ * missing multi-label suffix is that its registrable domain is computed one label too short; merchant++ * domain rules are created only by staff (or held for staff review), and a rule on a public suffix++ * is refused, so a gap here cannot make an unrelated site resolve to a merchant by itself.++ * Update it from the PSL when a real merchant needs a suffix that is missing.++ */++const RULES = [++ // ICANN: second-level registries under country codes++ 'co.uk', 'org.uk', 'me.uk', 'ltd.uk', 'plc.uk', 'net.uk', 'ac.uk', 'gov.uk', 'sch.uk',++ 'com.au', 'net.au', 'org.au', 'edu.au', 'gov.au', 'asn.au', 'id.au',++ 'co.nz', 'net.nz', 'org.nz', 'ac.nz', 'govt.nz',++ 'co.jp', 'ne.jp', 'or.jp', 'ac.jp', 'go.jp', 'ad.jp', 'ed.jp', 'gr.jp', 'lg.jp',++ 'co.kr', 'or.kr', 'ne.kr', 'go.kr', 'ac.kr',++ 'com.br', 'net.br', 'org.br', 'gov.br', 'edu.br',++ 'com.mx', 'org.mx', 'net.mx', 'gob.mx', 'edu.mx',++ 'com.ar', 'net.ar', 'org.ar', 'gob.ar',++ 'com.co', 'net.co', 'org.co', 'gov.co',++ 'com.pe', 'org.pe', 'net.pe',++ 'com.cn', 'net.cn', 'org.cn', 'gov.cn', 'edu.cn',++ 'com.hk', 'net.hk', 'org.hk', 'edu.hk', 'gov.hk',++ 'com.tw', 'net.tw', 'org.tw', 'edu.tw', 'gov.tw',++ 'com.sg', 'net.sg', 'org.sg', 'edu.sg', 'gov.sg',++ 'com.my', 'net.my', 'org.my', 'edu.my', 'gov.my',++ 'co.id', 'or.id', 'web.id', 'ac.id', 'go.id',++ 'com.ph', 'net.ph', 'org.ph', 'gov.ph',++ 'com.vn', 'net.vn', 'org.vn', 'gov.vn',++ 'co.th', 'in.th', 'or.th', 'ac.th', 'go.th',++ 'co.in', 'net.in', 'org.in', 'firm.in', 'gen.in', 'ind.in', 'ac.in', 'gov.in', 'edu.in',++ 'com.tr', 'net.tr', 'org.tr', 'gov.tr', 'edu.tr',++ 'com.ua', 'net.ua', 'org.ua', 'gov.ua',++ 'com.pl', 'net.pl', 'org.pl',++ 'co.za', 'org.za', 'net.za', 'gov.za', 'ac.za',++ 'co.il', 'org.il', 'net.il', 'ac.il', 'gov.il',++ 'com.sa', 'net.sa', 'org.sa', 'gov.sa',++ 'com.eg', 'net.eg', 'org.eg', 'gov.eg',++ 'co.ke', 'or.ke', 'ne.ke', 'go.ke',++ 'com.ng', 'org.ng', 'net.ng', 'gov.ng',++ // ICANN wildcards and their exceptions++ '*.ck', '!www.ck',++ '*.bd', '*.er', '*.fk', '*.jm', '*.kh', '*.mm', '*.np', '*.pg',++ // Private section: platforms where each customer's subdomain is its own site++ 'myshopify.com', 'bigcartel.com', 'wixsite.com',++ 'github.io', 'gitlab.io', 'blogspot.com', 'herokuapp.com', 'netlify.app', 'vercel.app',++ 'pages.dev', 'workers.dev', 'web.app', 'firebaseapp.com', 'appspot.com', 'azurewebsites.net',++ 'cloudfront.net',++];++++const EXACT = new Set();++const WILDCARD = new Set(); // parent of a '*.' rule++const EXCEPTION = new Set();++for (const r of RULES) {++ if (r.startsWith('!')) EXCEPTION.add(r.slice(1));++ else if (r.startsWith('*.')) WILDCARD.add(r.slice(2));++ else EXACT.add(r);++}++++/**++ * Number of labels of the public suffix of `host` (already normalized, labels joined by '.').++ * PSL algorithm: exceptions first, then the longest matching normal or wildcard rule, else 1.++ */++function suffixLabels(host) {++ const labels = host.split('.');++ let best = 1;++ for (let i = 0; i < labels.length; i++) {++ const candidate = labels.slice(i).join('.');++ const n = labels.length - i;++ if (EXCEPTION.has(candidate)) return n - 1;++ if (EXACT.has(candidate) && n > best) best = n;++ if (i > 0 && WILDCARD.has(candidate) && n + 1 > best) best = n + 1;++ }++ return best;++}++++/** The public suffix of a normalized host (e.g. 'co.uk'). */++function publicSuffix(host) {++ const labels = host.split('.');++ return labels.slice(labels.length - Math.min(suffixLabels(host), labels.length)).join('.');++}++++/** eTLD+1, or null when the host IS a public suffix (e.g. 'co.uk', 'myshopify.com'). */++function registrableDomain(host) {++ const labels = host.split('.');++ const n = suffixLabels(host);++ if (labels.length <= n) return null;++ return labels.slice(labels.length - n - 1).join('.');++}++++module.exports = { RULES, publicSuffix, registrableDomain, suffixLabels };nnew file mode 100644@@@ -0,0 +1,124 @@++'use strict';++++/**++ * What the outside world sees of a merchant or a code: canonical URLs, the indexability gate's++ * decision (openvibe-publishing/seo), the OpenVibe.Search document and the product events.++ *++ * /m/:slug merchant page indexable when the merchant is active and has at least one active++ * code; otherwise noindex ("thin": no active codes) — still served++ * /c/:id code page indexable while the code is active; an expired code is noindex++ * (gate reason `expired`), a disabled one is hidden (`takedown`, 410),++ * a code waiting for review or on a pending merchant is hidden (`draft`)++ *++ * Search documents are coupons (type `coupon`) and merchants (type `merchant`). A code that leaves++ * active results becomes a tombstone; a resource that was never indexed gets no tombstone.++ */++const seo = require('openvibe-publishing/seo');++const hooks = require('openvibe-publishing/index-hooks');++const { isActive } = require('./confidence');++++const ACTOR = Object.freeze({ type: 'service', id: 'coupons' });++const COUPON_POLICY = { minWords: 0 };++const MERCHANT_POLICY = { minWords: 1 };++++function createPublication({ store, config, outbox }) {++ const abs = (p) => seo.canonicalUrl(config.baseUrl, p);++ const merchantPath = (m) => `/m/${m.slug}`;++ const couponPath = (c) => `/c/${c.id}`;++++ /** The gate's decision for a code page. */++ function couponDecision(c, m, now = store.now()) {++ const visible = m && m.status === 'active' && c.review_state === 'published';++ const expiredAt = c.expires_at != null ? c.expires_at : (c.status === 'expired' ? (c.expired_at || now) : null);++ return seo.evaluate({++ state: visible ? 'published' : 'draft',++ visibility: 'public',++ takedown: c.status === 'disabled' || (m && m.status === 'disabled'),++ canonicalUrl: abs(couponPath(c)),++ wordCount: 0,++ ...(expiredAt != null ? { expiresAt: expiredAt } : {}),++ }, { policy: COUPON_POLICY, now });++ }++++ /** The gate's decision for a merchant page; `activeCount` = its active codes right now. */++ function merchantDecision(m, activeCount, now = store.now()) {++ return seo.evaluate({++ state: m.status === 'active' ? 'published' : 'draft',++ visibility: 'public',++ takedown: m.status === 'disabled',++ canonicalUrl: abs(merchantPath(m)),++ // A merchant page's substance is its active codes: none → thin (noindex, still served).++ wordCount: activeCount,++ }, { policy: MERCHANT_POLICY, now });++ }++++ function couponDocument(c, m, { restrictionsText = '', sourceRefs = [] } = {}) {++ const now = store.now();++ const decision = couponDecision(c, m, now);++ const active = m && m.status === 'active' && isActive(c, now);++ const statusLabel = { unknown: 'validity unknown', reported_working: 'reported working', reported_failed: 'reported not working' }[c.status] || c.status;++ return hooks.buildIndexDocument({++ owner: 'coupons', type: 'coupon', id: c.id, revision: 0,++ state: active ? 'published' : 'unpublished', visibility: 'public',++ canonicalUrl: abs(couponPath(c)),++ title: `${c.code} — ${c.title}${m ? ` (${m.name})` : ''}`,++ summary: `${statusLabel}; expiry ${c.expires_at != null ? new Date(c.expires_at).toISOString().slice(0, 10) : 'unknown'}`,++ body: [c.description || '', restrictionsText].filter(Boolean).join('\n'),++ facets: { merchant: m ? m.slug : 'unknown', status: c.status, expiry_known: c.expires_at != null },++ provenance: sourceRefs,++ decision,++ publishedAt: c.created_at,++ updatedAt: c.updated_at,++ language: 'en',++ });++ }++++ function merchantDocument(m, activeCount) {++ const decision = merchantDecision(m, activeCount);++ return hooks.buildIndexDocument({++ owner: 'coupons', type: 'merchant', id: m.id, revision: 0,++ state: m.status === 'active' ? 'published' : 'unpublished', visibility: 'public',++ canonicalUrl: abs(merchantPath(m)),++ title: `${m.name} coupon codes`,++ summary: `${activeCount} active code${activeCount === 1 ? '' : 's'}`,++ body: m.description || '',++ facets: { active_codes: activeCount },++ decision,++ publishedAt: m.created_at,++ updatedAt: m.updated_at,++ language: 'en',++ });++ }++++ /** Stamp and enqueue a Search document (inside the caller's transaction). */++ function sendDocument(doc, { traceparent } = {}) {++ if (doc.deleted && store.sequencer.current(doc.owner, doc.type, doc.id) == null) return null; // never indexed++ const current = store.sequencer.current(doc.owner, doc.type, doc.id);++ const stamped = store.sequencer.stamp(doc);++ if (current === stamped.revision) return null; // the same document again: nothing to send++ return outbox.emit(hooks.indexEvent({ document: stamped, now: store.now() }), { traceparent });++ }++++ /**++ * A product event (inside the caller's transaction). Public only while the code is publicly++ * listed; the actor is always the service and the payload never names a person.++ */++ function emit(eventType, subject, payload, { isPublic = false, traceparent } = {}) {++ return outbox.emit({++ event_type: eventType,++ actor: ACTOR,++ visibility: isPublic ? 'public' : 'internal',++ subject,++ payload,++ }, { traceparent });++ }++++ return {++ abs, merchantPath, couponPath,++ merchantUrl: (m) => abs(merchantPath(m)),++ couponUrl: (c) => abs(couponPath(c)),++ couponDecision, merchantDecision, couponDocument, merchantDocument, sendDocument, emit,++ };++}++++module.exports = { createPublication, ACTOR };nnew file mode 100644@@@ -0,0 +1,98 @@++'use strict';++++/**++ * Worked/failed reports by people.++ *++ * Who may report: a signed-in person (site form or a Bearer Network token), an extension install++ * token with the coupons.report scope (it belongs to the person who connected it), or a service++ * with coupons.report.create acting for a person (X-OV-Subject). Never anonymously, and never as++ * AI output (X-OV-Origin: ai is refused): a model cannot report that a code works.++ *++ * Deduplicated: one row per (reporter, coupon, UTC day). A second report the same day with the++ * same outcome changes nothing; with the other outcome it replaces that day's row (a correction,++ * still one report). Only each reporter's most recent report counts in the confidence formula.++ * The reporter is stored as HMAC(COUPONS_REPORTER_KEY_SECRET, subject): every channel of one++ * person (site, extension installs, API) shares one key, so extra installs add nothing.++ *++ * Rate-limited: COUPONS_REPORTS_PER_HOUR / _PER_DAY new reports per person (plus per-IP limits++ * in Express and nginx).++ *++ * Private: the response is the code's public view (aggregates only). Nothing returned or emitted++ * names a reporter.++ */++const crypto = require('crypto');++const { ids } = require('openvibe-contracts');++const { ApiError } = require('../http/errors');++++const OUTCOMES = ['worked', 'failed'];++const REASONS = ['invalid', 'expired', 'min_spend_not_met', 'not_eligible', 'other'];++const DEV_KEY = 'openvibe-coupons-development-reporter-key';++++function createReports({ store, config, coupons, merchants, publication }) {++ const { db } = store;++ const secret = config.reporterKeySecret || DEV_KEY;++ const q = {++ find: db.prepare('SELECT * FROM coupon_validation_reports WHERE coupon_id = ? AND reporter_key = ? AND day = ?'),++ insert: db.prepare(`INSERT INTO coupon_validation_reports (coupon_id, reporter_key, channel, install_id, day, outcome, reason, created_at, updated_at)++ VALUES (@coupon_id, @reporter_key, @channel, @install_id, @day, @outcome, @reason, @now, @now)`),++ correct: db.prepare('UPDATE coupon_validation_reports SET outcome = @outcome, reason = @reason, channel = @channel, install_id = @install_id, updated_at = @now WHERE id = @id'),++ since: db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports WHERE reporter_key = ? AND created_at > ?'),++ stamp: db.prepare(`UPDATE coupons SET last_report_at = @now,++ last_worked_at = CASE WHEN @outcome = 'worked' THEN @now ELSE last_worked_at END,++ last_failed_at = CASE WHEN @outcome = 'failed' THEN @now ELSE last_failed_at END++ WHERE id = @id`),++ };++++ const reporterKey = (subject) => crypto.createHmac('sha256', secret).update(`coupons-reporter:${subject}`).digest('hex');++++ /** The person a caller reports as, and the channel. Throws when there is none. */++ function reporterOf(viewer, channel) {++ if (!viewer || viewer.kind === 'anonymous') throw new ApiError(401, 'auth.required', 'sign in (or connect the extension) to report');++ if (viewer.origin === 'ai') throw new ApiError(403, 'report.ai_refused', 'reports come from people; model output cannot report a code as working or failed');++ if (!viewer.subject || !ids.isSubjectId('user', viewer.subject)) throw new ApiError(403, 'auth.person_required', 'a report needs the person it is from (X-OV-Subject for services)');++ return { subject: viewer.subject, channel: viewer.kind === 'install' ? 'extension' : channel, install: viewer.kind === 'install' ? viewer.install : null };++ }++++ /**++ * → { accepted, deduplicated, corrected, coupon (public view) }++ */++ function report(viewer, couponId, body, { channel = 'api', traceparent } = {}) {++ const outcome = body && body.outcome;++ if (!OUTCOMES.includes(outcome)) throw new ApiError(422, 'report.bad_outcome', 'outcome must be "worked" or "failed"');++ const reason = body.reason == null || body.reason === '' ? null : body.reason;++ if (reason !== null && !REASONS.includes(reason)) throw new ApiError(422, 'report.bad_reason', `reason must be one of ${REASONS.join(', ')}`);++ if (outcome === 'worked' && reason) throw new ApiError(422, 'report.bad_reason', 'a reason applies to failed reports only');++ const who = reporterOf(viewer, channel);++ const c = coupons.get(couponId);++ if (!c) throw new ApiError(404, 'coupon.not_found', 'no such code');++ const m = merchants.byId(c.merchant_id);++ const now = store.now();++ const isActive = m && m.status === 'active' && c.review_state === 'published' && c.status !== 'expired' && c.status !== 'disabled' && (c.expires_at == null || c.expires_at > now);++ if (!isActive) throw new ApiError(409, 'coupon.not_active', 'this code is not in active results (expired, disabled or not published)');++++ const key = reporterKey(who.subject);++ const day = new Date(now).toISOString().slice(0, 10);++ return store.tx(() => {++ const existing = q.find.get(c.id, key, day);++ if (existing && existing.outcome === outcome && existing.reason === reason) {++ return { accepted: true, deduplicated: true, corrected: false, coupon: coupons.view(coupons.get(c.id), { merchant: m }) };++ }++ if (!existing) {++ if (q.since.get(key, now - 60 * 60 * 1000).n >= config.limits.reportsPerHour || q.since.get(key, now - 24 * 60 * 60 * 1000).n >= config.limits.reportsPerDay) {++ throw new ApiError(429, 'report.rate_limited', 'too many reports; try again later');++ }++ q.insert.run({ coupon_id: c.id, reporter_key: key, channel: who.channel, install_id: who.install, day, outcome, reason, now });++ publication.emit('coupons.report.created', { type: 'coupon', id: c.id }, { merchant_id: c.merchant_id, outcome, ...(reason ? { reason } : {}), channel: who.channel, day }, { traceparent });++ } else {++ q.correct.run({ id: existing.id, outcome, reason, channel: who.channel, install_id: who.install, now });++ }++ q.stamp.run({ id: c.id, now, outcome });++ coupons.recompute(c.id, { reason: 'report', actor: 'system', traceparent });++ return { accepted: true, deduplicated: Boolean(existing), corrected: Boolean(existing), coupon: coupons.view(coupons.get(c.id), { merchant: m }) };++ });++ }++++ return { report, reporterKey, OUTCOMES, REASONS, usingDevKey: !config.reporterKeySecret };++}++++module.exports = { createReports, OUTCOMES, REASONS };nnew file mode 100644@@@ -0,0 +1,42 @@++'use strict';++++/**++ * Members watching a merchant (coupon_watches). A watch is private to its member: it is never++ * shown to anyone else and never leaves Coupons.++ *++ * Delivery is not built yet: Coupons records watches and lists them on /watching, but sends no++ * notification. It will go through the Network's notification store (roadmap W3) when Coupons is++ * granted a notification capability; until then the page says so.++ */++const { ids } = require('openvibe-contracts');++const { ApiError } = require('../http/errors');++++const MAX_WATCHES = 200;++++function createWatches({ store }) {++ const { db } = store;++ const q = {++ add: db.prepare('INSERT OR IGNORE INTO coupon_watches (subject, merchant_id, created_at) VALUES (?, ?, ?)'),++ remove: db.prepare('DELETE FROM coupon_watches WHERE subject = ? AND merchant_id = ?'),++ has: db.prepare('SELECT 1 FROM coupon_watches WHERE subject = ? AND merchant_id = ?'),++ count: db.prepare('SELECT COUNT(*) AS n FROM coupon_watches WHERE subject = ?'),++ mine: db.prepare(`SELECT m.* FROM coupon_watches w JOIN coupon_merchants m ON m.id = w.merchant_id++ WHERE w.subject = ? AND m.status = 'active' ORDER BY m.name COLLATE NOCASE`),++ };++ const person = (subject) => {++ if (!ids.isSubjectId('user', subject)) throw new ApiError(403, 'auth.person_required', 'sign in to watch a merchant');++ return subject;++ };++ return {++ watch(subject, merchant) {++ person(subject);++ if (!q.has.get(subject, merchant.id) && q.count.get(subject).n >= MAX_WATCHES) throw new ApiError(409, 'watch.limit', `at most ${MAX_WATCHES} watches`);++ q.add.run(subject, merchant.id, store.now());++ },++ unwatch(subject, merchant) { q.remove.run(person(subject), merchant.id); },++ isWatching: (subject, merchant) => Boolean(subject && q.has.get(subject, merchant.id)),++ list: (subject) => q.mine.all(person(subject)),++ };++}++++module.exports = { createWatches };nnew file mode 100644@@@ -0,0 +1,68 @@++'use strict';++++/**++ * Coupons → OpenVibe.Events through the openvibe-sdk transactional outbox (ADR-004).++ *++ * coupons.coupon.created|updated|expired|disabled a code's lifecycle (charter coupons.created|…)++ * coupons.report.created a new validity report (never who reported)++ * coupons.confidence.changed a code's confidence moved (reports or decay)++ * coupons.index_document.upserted|deleted OpenVibe.Search documents and tombstones for++ * coupons and merchants (openvibe-publishing++ * index-hooks), consumed by Search's++ * '*.index_document.*' subscription++ *++ * Every envelope's actor is the service itself ({ type: 'service', id: 'coupons' }) and no payload++ * names a person: submitters and reporters stay inside Coupons.++ *++ * emit() runs inside the SQLite transaction that makes the change, so an event exists if and only++ * if its change committed. The relay publishes with Coupons' service token (events.event.publish,++ * audience openvibe.events) only when EVENTS_URL and OV_OAUTH_CLIENT_SECRET are set; otherwise++ * rows wait in event_outbox and /api/ready reports the relay as off.++ */++const { createClient } = require('openvibe-sdk/core');++const { createServiceTokenClient } = require('openvibe-sdk/auth');++const { createEventsClient, createOutbox } = require('openvibe-sdk/events');++++function createCouponsOutbox({ db, config, fetchImpl, now, log = console }) {++ const enabled = Boolean(config.events.url && config.oauth.clientSecret);++ const clientOpts = { baseUrls: { events: config.events.url || 'http://127.0.0.1:4300' }, retries: 0 };++ if (fetchImpl) clientOpts.fetch = fetchImpl;++ if (enabled) {++ clientOpts.tokenProvider = createServiceTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ scope: { 'openvibe.events': 'events.event.publish' }, ...(fetchImpl ? { fetch: fetchImpl } : {}),++ });++ } else {++ clientOpts.getToken = async () => { throw new Error('events relay disabled (EVENTS_URL / OV_OAUTH_CLIENT_SECRET unset)'); };++ }++ const events = createEventsClient(createClient(clientOpts), { source: 'coupons' });++ let lastError = null;++ const outbox = createOutbox(db, {++ events,++ intervalMs: config.events.intervalMs,++ now,++ onError: (err) => {++ const msg = err && err.message;++ if (msg !== lastError) log.warn('[Coupons] event publish failed (will retry):', msg);++ lastError = msg;++ },++ });++ outbox.ensureSchema();++++ /** Inside the caller's transaction. Returns the complete envelope (with its event_id). */++ function emit(envelope, { traceparent } = {}) {++ return outbox.enqueue(envelope, { traceparent });++ }++++ return {++ emit,++ outbox,++ enabled,++ start() { if (enabled) outbox.start(); },++ stop: () => outbox.stop(),++ kick() { if (enabled) outbox.kick(); },++ status: () => ({ enabled, pending: outbox.pending(), rejected: outbox.rejected(), last_error: lastError }),++ };++}++++module.exports = { createCouponsOutbox };nnew file mode 100644@@@ -0,0 +1,249 @@++'use strict';++++/**++ * The JSON API (/api/v1). Identity comes from the Authorization header ONLY (cookies are ignored++ * here), so a web page cannot use a visitor's session against it.++ *++ * Public lookup (the browser helper's two calls; anonymous allowed; CORS only for configured++ * extension origins; rate-limited per IP when anonymous, per install with a cpx_ token):++ * GET /merchants/resolve?host= coupons.merchant.resolve | scope coupons.lookup++ * GET /merchants/:id/coupons coupons.coupon.lookup | scope coupons.lookup++ * Also public (no CORS):++ * GET /merchants/:id coupons.coupon.lookup | scope coupons.lookup++ * GET /coupons/:id coupons.coupon.lookup | scope coupons.lookup++ * People (Bearer Network token), installs and services acting for a person:++ * POST /coupons/:id/report coupons.report.create | scope coupons.report++ * POST /coupons/submit coupons.coupon.submit (installs cannot submit)++ * Staff (Network admins / COUPONS_STAFF_SUBJECTS with a Bearer token) and services:++ * POST /coupons/:id/status coupons.status.update disabled | expired | active++ * POST /merchants coupons.merchant.manage++ * POST /merchants/:id/domains coupons.merchant.manage++ * POST /merchants/:id/status coupons.merchant.manage active | disabled++ *++ * Every lookup response is the same bytes for every caller: it holds no data about the caller or++ * any other person (no submitter, no reporter, no install).++ */++const express = require('express');++const rateLimit = require('express-rate-limit');++const contracts = require('openvibe-contracts');++const { guard } = require('../auth/viewer');++const { CAPABILITIES, SCOPES } = require('../auth/capabilities');++const { ApiError, run, jsonBody } = require('./errors');++const hosts = require('../domain/hosts');++++const MOZ_EXTENSION_RE = /^moz-extension:\/\/[0-9a-f-]{36}$/;++++function createApi(ctx) {++ const { config, viewers, merchants, coupons, reports, publication } = ctx;++ const router = express.Router();++++ // ── Helpers ─────────────────────────────────────────────++ const noStore = (res) => res.set('Cache-Control', 'private, no-store');++++ function extensionOriginAllowed(origin) {++ if (!origin) return false;++ if (config.extensionOrigins.includes(origin)) return true;++ return config.extensionOrigins.includes('moz-extension://*') && MOZ_EXTENSION_RE.test(origin);++ }++++ /** CORS for the lookup routes only: exact configured extension origins, never credentials. */++ function lookupCors(req, res, next) {++ res.vary('Origin');++ const origin = req.get('origin');++ if (extensionOriginAllowed(origin)) {++ res.set('Access-Control-Allow-Origin', origin);++ res.set('Access-Control-Allow-Methods', 'GET, OPTIONS');++ res.set('Access-Control-Allow-Headers', 'Authorization');++ res.set('Access-Control-Max-Age', '600');++ }++ if (req.method === 'OPTIONS') return res.status(204).end();++ next();++ }++++ const tooMany = (code) => (req, res) => {++ noStore(res);++ contracts.http.sendProblem(res, 429, code, { detail: 'Too many requests; slow down and try again shortly.', ctx: req.ov });++ };++++ const lookupLimiter = rateLimit({++ windowMs: 60_000,++ standardHeaders: true,++ legacyHeaders: false,++ limit: (req) => (req.viewer.kind === 'install' ? config.limits.lookupTokenPerMin++ : req.viewer.kind === 'service' ? config.limits.lookupServicePerMin : config.limits.lookupAnonPerMin),++ keyGenerator: (req) => (req.viewer.kind === 'install' ? `install:${req.viewer.install}`++ : req.viewer.kind === 'service' ? `service:${req.viewer.service}` : `ip:${req.ip}`),++ handler: tooMany('lookup.rate_limited'),++ });++ // Per-IP ceiling in front of the per-person limits of reports and submissions.++ const writeLimiter = rateLimit({ windowMs: 60_000, limit: 60, standardHeaders: true, legacyHeaders: false, handler: tooMany('request.rate_limited') });++++ const publicCache = (res) => res.set('Cache-Control', 'public, max-age=60');++++ function merchantView(m) {++ return {++ id: m.id,++ slug: m.slug,++ name: m.name,++ homepage_url: m.homepage_url,++ url: publication.merchantUrl(m),++ domains: merchants.domains(m).map((d) => ({ host: d.host, include_subdomains: Boolean(d.include_subdomains), path_prefix: d.path_prefix || null })),++ active_codes: coupons.activeCount(m.id),++ hints: coupons.merchantHints(m.id).map((h) => h.text),++ };++ }++++ function activeMerchant(idOrSlug) {++ const m = merchants.get(idOrSlug);++ if (!m || m.status !== 'active') throw new ApiError(404, 'merchant.not_found', 'no such merchant');++ return m;++ }++++ function requireStaffOrService(req) {++ const v = req.viewer;++ if (v.kind === 'service') return v.service;++ if (v.kind === 'user' && v.staff) return v.subject;++ if (v.kind === 'anonymous') throw new ApiError(401, 'auth.required', 'authentication required');++ throw new ApiError(403, 'auth.staff_required', 'staff only');++ }++++ router.use(viewers.api());++++ // ── Lookup ──────────────────────────────────────────────++ router.options(['/merchants/resolve', '/merchants/:id/coupons'], lookupCors);++++ router.get('/merchants/resolve', lookupCors, lookupLimiter, guard(CAPABILITIES.MERCHANT_RESOLVE, { scope: SCOPES.LOOKUP }), run((req, res) => {++ if (typeof req.query.host !== 'string') throw new ApiError(400, 'host.required', 'pass ?host=<hostname>');++ const found = merchants.resolve(req.query.host);++ publicCache(res);++ if (!found) {++ const host = hosts.normalizeHost(req.query.host);++ throw new ApiError(404, 'merchant.not_found', `no merchant for ${host}`, { host, registrable_domain: hosts.registrable(host) });++ }++ return {++ host: found.host,++ registrable_domain: found.registrable,++ matched_rule: { host: found.rule.host, include_subdomains: Boolean(found.rule.include_subdomains), path_prefix: found.rule.path_prefix || null },++ merchant: merchantView(found.merchant),++ };++ }));++++ router.get('/merchants/:id/coupons', lookupCors, lookupLimiter, guard(CAPABILITIES.COUPON_LOOKUP, { scope: SCOPES.LOOKUP }), run((req, res) => {++ const m = activeMerchant(req.params.id);++ publicCache(res);++ return { merchant: merchantView(m), coupons: coupons.active(m.id).map((c) => coupons.view(c, { merchant: m })) };++ }));++++ router.get('/merchants/:id', lookupLimiter, guard(CAPABILITIES.COUPON_LOOKUP, { scope: SCOPES.LOOKUP }), run((req, res) => {++ const m = activeMerchant(req.params.id);++ publicCache(res);++ return { merchant: merchantView(m) };++ }));++++ router.get('/coupons/:id', lookupLimiter, guard(CAPABILITIES.COUPON_LOOKUP, { scope: SCOPES.LOOKUP }), run((req, res) => {++ const c = coupons.get(req.params.id);++ const m = c && merchants.byId(c.merchant_id);++ if (!c || !m || m.status !== 'active' || c.review_state !== 'published' || c.status === 'disabled') throw new ApiError(404, 'coupon.not_found', 'no such code');++ publicCache(res);++ return { coupon: coupons.view(c, { merchant: m }), merchant: merchantView(m) };++ }));++++ // ── Reports ─────────────────────────────────────────────++ router.post('/coupons/:id/report', writeLimiter, jsonBody, guard(CAPABILITIES.REPORT_CREATE, { scope: SCOPES.REPORT }), run((req, res) => {++ noStore(res);++ return reports.report(req.viewer, req.params.id, req.body || {}, { channel: 'api', traceparent: req.get('traceparent') });++ }, (out) => (out.deduplicated ? 200 : 201)));++++ // ── Submissions ─────────────────────────────────────────++ router.post('/coupons/submit', writeLimiter, jsonBody, guard(CAPABILITIES.COUPON_SUBMIT), run((req, res) => {++ noStore(res);++ const v = req.viewer;++ let who;++ if (v.kind === 'anonymous') throw new ApiError(401, 'auth.required', 'sign in to submit a code');++ if (v.kind === 'service' && v.origin === 'ai') who = { actor: v.service, kind: 'ai', subject: null };++ else if (v.subject) who = { actor: v.subject, kind: v.kind === 'user' && v.staff ? 'staff' : 'member', subject: v.subject };++ else throw new ApiError(403, 'auth.person_required', 'a submission needs the person it is from (X-OV-Subject for services)');++ const out = submitCode(who, req.body || {}, { traceparent: req.get('traceparent'), limits: v.kind === 'service' ? scaled(config.limits, 10) : config.limits });++ return {++ coupon: coupons.view(out.coupon, { merchant: out.merchant }),++ merchant: { id: out.merchant.id, slug: out.merchant.slug, name: out.merchant.name, status: out.merchant.status },++ duplicate: out.duplicate,++ review_state: out.coupon.review_state,++ };++ }, (out) => (out.duplicate ? 200 : 201)));++++ // ── Staff and services ──────────────────────────────────++ router.post('/coupons/:id/status', writeLimiter, jsonBody, guard(CAPABILITIES.STATUS_UPDATE), run((req, res) => {++ noStore(res);++ const actor = requireStaffOrService(req);++ const c = coupons.get(req.params.id);++ if (!c) throw new ApiError(404, 'coupon.not_found', 'no such code');++ const body = req.body || {};++ const after = coupons.setStatus(c, body.status, { actor, note: body.note, traceparent: req.get('traceparent') });++ return { coupon: coupons.view(after) };++ }));++++ router.post('/merchants', writeLimiter, jsonBody, guard(CAPABILITIES.MERCHANT_MANAGE), run((req, res) => {++ noStore(res);++ const actor = requireStaffOrService(req);++ const body = req.body || {};++ const status = body.status === 'pending' ? 'pending' : 'active';++ const m = merchants.create(body, { status, actor });++ store().tx(() => coupons.syncMerchant(m));++ return { merchant: { ...merchantView(m), status: m.status } };++ }, 201));++++ router.post('/merchants/:id/domains', writeLimiter, jsonBody, guard(CAPABILITIES.MERCHANT_MANAGE), run((req, res) => {++ noStore(res);++ const actor = requireStaffOrService(req);++ const m = merchants.get(req.params.id);++ if (!m) throw new ApiError(404, 'merchant.not_found', 'no such merchant');++ merchants.addDomain(m, req.body || {}, actor);++ return { merchant: { ...merchantView(m), status: m.status } };++ }, 201));++++ router.post('/merchants/:id/status', writeLimiter, jsonBody, guard(CAPABILITIES.MERCHANT_MANAGE), run((req, res) => {++ noStore(res);++ const actor = requireStaffOrService(req);++ const m = merchants.get(req.params.id);++ if (!m) throw new ApiError(404, 'merchant.not_found', 'no such merchant');++ const status = (req.body || {}).status;++ if (!['active', 'disabled'].includes(status)) throw new ApiError(422, 'merchant.bad_status', 'status must be active or disabled');++ const after = ctx.moderation.setMerchantStatus(m, status, { actor });++ return { merchant: { ...merchantView(after), status: after.status } };++ }));++++ // ── The shared submission path (API and the /submit form) ──++ function submitCode(who, body, { traceparent, limits = config.limits } = {}) {++ const input = coupons.parseSubmission(body, ctx.store.now());++ let merchant = null;++ if (body.merchant_id) {++ merchant = merchants.get(String(body.merchant_id));++ if (!merchant) throw new ApiError(404, 'merchant.not_found', 'no such merchant');++ } else if (body.host || body.url) {++ const at = body.url ? hosts.hostOfUrl(body.url) : { host: hosts.normalizeHost(String(body.host)), path: null };++ if (!at) throw new ApiError(422, 'host.invalid', 'url must be an http(s) URL of a public site');++ const any = merchants.resolve(at.host, { path: at.path, includeStatuses: ['active', 'pending', 'disabled'] });++ if (any) merchant = any.merchant;++ else {++ coupons.checkSubmissionRate(who.actor, limits);++ const reg = hosts.registrable(at.host);++ if (!reg) throw new ApiError(422, 'host.invalid', `${at.host} is a public suffix, not a site`);++ // A site no merchant covers yet: proposed as a PENDING merchant, invisible until staff approve it.++ merchant = merchants.create({ name: reg, domains: [{ host: reg, include_subdomains: true }] }, { status: 'pending', actor: who.actor });++ }++ } else {++ throw new ApiError(422, 'merchant.required', 'name the merchant: merchant_id, host or url');++ }++ if (merchant.status === 'disabled') throw new ApiError(409, 'merchant.disabled', 'this merchant was taken down');++ return coupons.submit(who, merchant, input, { limits, traceparent });++ }++++ const store = () => ctx.store;++ const scaled = (limits, k) => ({ ...limits, submissionsPerHour: limits.submissionsPerHour * k, submissionsPerDay: limits.submissionsPerDay * k });++++ ctx.submitCode = submitCode;++ return router;++}++++module.exports = { createApi };nnew file mode 100644@@@ -0,0 +1,131 @@++'use strict';++++/**++ * Crawl and machine-readability artifacts (roadmap §32.4/§32.5):++ *++ * GET /robots.txt sitemap location + explicit automated-consumer policy++ * GET /llms.txt orientation for language models++ * GET /sitemap.xml sitemap index over the two sections below++ * GET /sitemaps/merchants.xml active shops with at least one active code (the gate decides)++ * GET /sitemaps/coupons.xml active codes only; lastmod = the code's real last change++ * GET /feed.xml, /atom.xml, /feed.json newly added active codes++ * GET /m/:slug/feed.xml one shop's active codes++ *++ * Built from the database on every request, never for a viewer. Expired, taken-down, pending and++ * not-yet-reviewed codes, and pending or disabled shops, never appear: the lists start from the++ * active-results query, and every entry also passes through the gate.++ */++const express = require('express');++const seo = require('openvibe-publishing/seo');++const sharedSeo = require('openvibe-shared/seo');++const pages = require('../render/pages');++++function createDiscoveryRoutes({ config, store, merchants, coupons, publication }) {++ const router = express.Router();++ const abs = (p) => seo.canonicalUrl(config.baseUrl, p);++ const xml = (res, body, type = 'application/xml') => res.type(type).set('Cache-Control', 'public, max-age=300').send(body);++++ function activeEntries() {++ const byMerchant = new Map();++ const out = [];++ for (const c of coupons.allActive()) {++ let m = byMerchant.get(c.merchant_id);++ if (!m) { m = merchants.byId(c.merchant_id); byMerchant.set(c.merchant_id, m); }++ out.push({ c, m, decision: publication.couponDecision(c, m) });++ }++ return out;++ }++++ router.get('/robots.txt', (_req, res) => {++ const body = [++ '# openvibe.coupons automated-consumer policy: search engines and AI crawlers are welcome to read',++ '# shop and code pages, their .json twins, feeds and sitemaps. Forms, sign-in, staff pages and the',++ '# API are not for crawling. Pages decide their own indexability (meta robots / X-Robots-Tag):',++ '# an expired code stays readable but is noindex; a Disallow is not a noindex.',++ sharedSeo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: ['/auth/', '/api/', '/submit', '/connect-extension', '/staff', '/watching'] }),++ ].join('\n');++ res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(body);++ });++++ router.get('/llms.txt', (_req, res) => {++ res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(sharedSeo.llmsTxt({++ name: 'OpenVibe.Coupons',++ summary: 'Coupon codes for online shops with merchant/domain matching, restrictions, honest expiry and validity reports by people.',++ details: 'A code\'s status is exactly one of unknown, reported_working, reported_failed, expired or disabled. Only recent reports by people can make a code reported_working or reported_failed; a submission, a source or a model cannot. An expiry that nobody stated is "unknown" (null in JSON), and restrictions that were not stated are absent, not "none". Every shop page has a JSON twin at /m/<slug>.json and every code at /c/<id>.json with the same content. Expired and taken-down codes are not in lists, feeds or sitemaps.',++ sections: [++ { title: 'Start here', links: [++ { title: 'All shops', url: abs('/'), note: 'with recently added codes' },++ { title: 'How codes are labelled', url: abs('/about'), note: 'statuses, the confidence formula, expiry, privacy' },++ { title: 'Sitemap', url: abs('/sitemap.xml') },++ ] },++ { title: 'Feeds', links: [{ title: 'RSS', url: abs('/feed.xml') }, { title: 'Atom', url: abs('/atom.xml') }, { title: 'JSON Feed', url: abs('/feed.json') }] },++ { title: 'Data', links: [++ { title: 'Shop JSON', url: abs('/'), note: 'append .json to a shop URL (/m/<slug>.json)' },++ { title: 'Merchant lookup API', url: abs('/api/v1/merchants/resolve?host=example.com'), note: 'GET /api/v1/merchants/resolve?host= and GET /api/v1/merchants/<id>/coupons; anonymous, rate-limited' },++ ] },++ ],++ }));++ });++++ router.get('/sitemap.xml', (_req, res) => {++ const entries = activeEntries().filter((e) => e.decision.indexable);++ const newest = entries.length ? Math.max(...entries.map((e) => e.c.updated_at)) : null;++ xml(res, seo.sitemapIndex([++ { loc: abs('/sitemaps/merchants.xml'), ...(newest ? { lastmod: new Date(newest).toISOString() } : {}) },++ { loc: abs('/sitemaps/coupons.xml'), ...(newest ? { lastmod: new Date(newest).toISOString() } : {}) },++ ]));++ });++++ router.get('/sitemaps/coupons.xml', (_req, res) => {++ xml(res, seo.sitemap(activeEntries().map((e) => ({ loc: publication.couponUrl(e.c), lastmod: e.c.updated_at, decision: e.decision }))).files[0]);++ });++++ router.get('/sitemaps/merchants.xml', (_req, res) => {++ const byMerchant = new Map();++ for (const e of activeEntries()) {++ const cur = byMerchant.get(e.m.id);++ if (!cur) byMerchant.set(e.m.id, { m: e.m, count: 1, lastmod: e.c.updated_at });++ else { cur.count++; cur.lastmod = Math.max(cur.lastmod, e.c.updated_at); }++ }++ const list = [...byMerchant.values()].map(({ m, count, lastmod }) => ({ loc: publication.merchantUrl(m), lastmod: Math.max(lastmod, m.updated_at), decision: publication.merchantDecision(m, count) }));++ xml(res, seo.sitemap(list).files[0]);++ });++++ // ── Feeds ───────────────────────────────────────────────++ function feedItems(list) {++ return list.map(({ c, m, decision }) => ({++ id: `urn:openvibe:coupons:${c.id}`,++ url: publication.couponUrl(c),++ title: `${c.code} — ${c.title} (${m.name})`,++ summary: `${pages.STATUS_LABEL[c.status]}. ${c.expires_at != null ? `Expires ${new Date(c.expires_at).toISOString().slice(0, 10)}.` : 'Expiry unknown.'}`,++ published: c.created_at,++ updated: c.updated_at,++ tags: [m.name],++ decision,++ }));++ }++ const recent = () => activeEntries().slice(0, 50);++ const channel = { title: 'OpenVibe.Coupons — new codes', link: abs('/'), description: 'Codes added to OpenVibe.Coupons, with their status and expiry.' };++++ router.get('/feed.xml', (_req, res) => xml(res, seo.rssFeed({ ...channel, feedUrl: abs('/feed.xml'), language: 'en' }, feedItems(recent())), 'application/rss+xml'));++ router.get('/atom.xml', (_req, res) => {++ const items = feedItems(recent());++ // Atom needs an <updated>; an empty feed has been empty since this process started, so that++ // instant is its last change (never the request time).++ xml(res, seo.atomFeed({ ...channel, feedUrl: abs('/atom.xml'), ...(items.length ? {} : { updated: bootTime }) }, items), 'application/atom+xml');++ });++ router.get('/feed.json', (_req, res) => {++ res.type('application/feed+json').set('Cache-Control', 'public, max-age=300').send(JSON.stringify(seo.jsonFeed({ ...channel, feedUrl: abs('/feed.json') }, feedItems(recent()))));++ });++ router.get('/m/:slug/feed.xml', (req, res, next) => {++ const m = merchants.get(req.params.slug);++ if (!m || m.status !== 'active' || m.slug !== req.params.slug) return next();++ const list = coupons.active(m.id).map((c) => ({ c, m, decision: publication.couponDecision(c, m) }));++ xml(res, seo.rssFeed({ title: `${m.name} codes — OpenVibe.Coupons`, link: publication.merchantUrl(m), description: `Active codes for ${m.name}.`, feedUrl: abs(`/m/${m.slug}/feed.xml`), language: 'en' }, feedItems(list)), 'application/rss+xml');++ });++++ const bootTime = store.now();++ return router;++}++++module.exports = { createDiscoveryRoutes };nnew file mode 100644@@@ -0,0 +1,58 @@++'use strict';++++/**++ * Errors as RFC 9457 problems (contracts errors.problem@1, which keeps the legacy { error } field),++ * and the small request helpers every router shares.++ */++const express = require('express');++const contracts = require('openvibe-contracts');++++/** A refusal with a stable problem code (e.g. 404 'coupon.not_found'). */++class ApiError extends Error {++ constructor(status, code, detail, extra) {++ super(detail || code);++ this.name = 'ApiError';++ this.status = status;++ this.code = code;++ this.extra = extra || null;++ }++}++++/** Domain errors (HostError, …) carry { status, code } too. */++function asApiError(err) {++ if (err instanceof ApiError) return err;++ if (err && err.name === 'HostError') return new ApiError(err.status, err.code, err.message);++ return null;++}++++/** Wrap a JSON handler: its return value is the body; errors become problems. */++function run(fn, status = 200) {++ return async (req, res) => {++ try {++ const out = await fn(req, res);++ if (out === undefined || res.headersSent) return;++ res.status(typeof status === 'function' ? status(out) : status).json(out);++ } catch (err) {++ if (res.headersSent) return;++ const e = asApiError(err);++ if (e) return contracts.http.sendProblem(res, e.status, e.code, { detail: e.message, ctx: req.ov, extra: e.extra || undefined });++ console.error('[Coupons API]', err && err.stack ? err.stack : err);++ contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });++ }++ };++}++++const jsonParser = express.json({ limit: '32kb' });++/** JSON body parser whose syntax errors are problems too. */++function jsonBody(req, res, next) {++ jsonParser(req, res, (err) => (err ? contracts.http.sendProblem(res, 400, 'request.invalid_json', { detail: 'Malformed JSON body', ctx: req.ov }) : next()));++}++++/** Private, per-viewer responses: never stored by a shared cache, never indexed. */++function privateNoStore(res) {++ res.set('Cache-Control', 'private, no-store');++ res.vary('Cookie');++ res.vary('Authorization');++}++++module.exports = { ApiError, asApiError, run, jsonBody, privateNoStore };nnew file mode 100644@@@ -0,0 +1,347 @@++'use strict';++++/**++ * Public, server-rendered routes — useful without JavaScript:++ *++ * GET / shops, recently added codes, search (?q=)++ * GET /about statuses, the confidence formula, expiry, privacy, the helper++ * GET /m/:slug a shop: active codes with status, confidence, last report time,++ * restrictions, expiry or "unknown"; recently ended codes apart++ * GET /m/:slug.json the same as data++ * GET /c/:id, /c/:id.json one code (an expired code: noindex; taken down: 410)++ * POST /c/:id/report worked / didn't work (signed in, form token)++ * POST /m/:slug/watch watch / stop watching (signed in, form token)++ * GET /submit, POST /submit submit a code (signed in, form token)++ * GET /connect-extension create / revoke browser-helper tokens (signed in, form token)++ * GET /watching your watched shops (private)++ * GET /staff … review and moderation (Network admins, COUPONS_STAFF_SUBJECTS)++ *++ * Caching: only pages rendered for an ANONYMOUS visitor with status 200 are `public, max-age=60`;++ * everything else is `private, no-store`. All pages vary on Cookie and Authorization.++ */++const express = require('express');++const seo = require('openvibe-publishing/seo');++const { renderPage } = require('../render/layout');++const pages = require('../render/pages');++const { csrfToken, checkCsrf } = require('../auth/forms');++const { ApiError } = require('./errors');++const confidence = require('../domain/confidence');++const { SCOPES } = require('../auth/capabilities');++++const COUPON_ID_RE = /^cpn_[0-9A-HJKMNP-TV-Z]{26}$/;++++function createPublicRoutes(ctx) {++ const { config, store, merchants, coupons, reports, watches, installs, publication, viewers, moderation } = ctx;++ const router = express.Router();++ const form = express.urlencoded({ extended: false, limit: '32kb' });++ router.use(viewers.pages());++++ // ── Helpers ─────────────────────────────────────────────++ function cacheHeaders(res, { cacheable, robots }) {++ res.vary('Cookie');++ res.vary('Authorization');++ res.set('Cache-Control', cacheable ? 'public, max-age=60, stale-while-revalidate=60' : 'private, no-store');++ if (robots && robots !== 'index, follow') res.set('X-Robots-Tag', robots);++ }++++ function send(req, res, status, page, { cacheable = false } = {}) {++ cacheHeaders(res, { cacheable: cacheable && req.viewer.kind === 'anonymous' && status === 200, robots: page.decision.robots });++ res.status(status).type('html').send(renderPage({ ...page, viewer: req.viewer, config, path: req.originalUrl }));++ }++++ /** A decision for pages that are not a shop or a code. */++ function pageDecision(path, { indexable = true, query = [] } = {}) {++ return seo.evaluate({++ state: indexable ? 'published' : 'draft', visibility: indexable ? 'public' : 'private',++ canonicalUrl: seo.canonicalUrl(config.baseUrl, path, { query }), wordCount: 0,++ }, { policy: { minWords: 0 }, now: store.now() });++ }++++ function messagePage(req, res, status, heading, text, action) {++ send(req, res, status, { title: heading, decision: pageDecision(req.path, { indexable: false }), body: pages.message({ heading, text, action }) });++ }++ const notFound = (req, res) => messagePage(req, res, 404, 'Not found', 'There is nothing at this address.', { href: '/', label: 'All shops' });++++ const csrf = (req) => csrfToken(config, req.viewer);++ function requireUser(req, res) {++ if (req.viewer.kind === 'user' && req.viewer.subject) return true;++ res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);++ return false;++ }++ function requireForm(req, res) {++ if (!requireUser(req, res)) return false;++ if (!checkCsrf(config, req.viewer, req.body && req.body.csrf)) {++ messagePage(req, res, 403, 'Form expired', 'This form expired or did not come from this site. Go back, reload the page and try again.');++ return false;++ }++ return true;++ }++ const safeBack = (b, fallback) => (typeof b === 'string' && /^\/(?![/\\])[^\s]*$/.test(b) ? b : fallback);++ const withFlash = (path, key) => `${path}${path.includes('?') ? '&' : '?'}done=${key}`;++ const FLASH = {++ reported: 'Thanks — your report was recorded.',++ report_same: 'You already reported that today; nothing changed.',++ report_corrected: 'Your report for today was updated.',++ watching: 'You are watching this shop.',++ unwatched: 'You stopped watching this shop.',++ };++++ function jsonLd(m, v) {++ return [++ seo.structuredData.breadcrumbs([{ name: 'Coupons', url: `${config.baseUrl}/` }, { name: m.name, url: publication.merchantUrl(m) }, ...(v ? [{ name: v.code, url: v.url }] : [])]),++ seo.structuredData.webPage({ url: v ? v.url : publication.merchantUrl(m), name: v ? `${v.code} — ${v.title}` : `${m.name} coupon codes`, dateModified: v ? v.updated_at : null, inLanguage: 'en' }),++ ];++ }++++ // ── Home, about ─────────────────────────────────────────++ router.get('/', (req, res) => {++ const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 60) : '';++ const recent = coupons.recentActive(12).map((c) => ({ coupon: c, merchant: merchants.byId(c.merchant_id) }));++ const list = merchants.listActive({ limit: 500 }).map((m) => ({ merchant: m, active: coupons.activeCount(m.id) }));++ const decision = q ? pageDecision('/', { indexable: false }) : pageDecision('/');++ send(req, res, 200, {++ title: q ? `Search: ${q}` : null, decision, canonical: `${config.baseUrl}/`,++ feeds: [{ type: 'rss', href: '/feed.xml', title: 'New codes (RSS)' }, { type: 'atom', href: '/atom.xml', title: 'New codes (Atom)' }, { type: 'json', href: '/feed.json', title: 'New codes (JSON Feed)' }],++ jsonLd: [seo.structuredData.webPage({ url: `${config.baseUrl}/`, name: 'OpenVibe.Coupons', inLanguage: 'en' })],++ body: pages.home({ merchants: list, recent, q, results: q ? merchants.search(q) : [], total: list.length }),++ }, { cacheable: true });++ });++++ router.get('/about', (req, res) => {++ send(req, res, 200, { title: 'How codes are labelled', decision: pageDecision('/about'), body: pages.about({ confidence }) }, { cacheable: true });++ });++++ // ── Shops ───────────────────────────────────────────────++ function merchantData(m) {++ const active = coupons.active(m.id).map((c) => coupons.view(c, { merchant: m }));++ return {++ merchant: { id: m.id, slug: m.slug, name: m.name, homepage_url: m.homepage_url, url: publication.merchantUrl(m),++ domains: merchants.domains(m).map((d) => ({ host: d.host, include_subdomains: Boolean(d.include_subdomains), path_prefix: d.path_prefix || null })),++ hints: coupons.merchantHints(m.id).map((x) => x.text) },++ coupons: active,++ };++ }++++ function visibleMerchant(req, res) {++ const m = merchants.get(req.params.slug);++ if (!m || m.slug !== req.params.slug || m.status === 'pending') { notFound(req, res); return null; }++ if (m.status === 'disabled') { messagePage(req, res, 410, 'Gone', 'This shop is no longer listed.', { href: '/', label: 'All shops' }); return null; }++ return m;++ }++++ router.get('/m/:slug.json', (req, res) => {++ const m = visibleMerchant(req, res);++ if (!m) return;++ const data = merchantData(m);++ const decision = publication.merchantDecision(m, data.coupons.length);++ res.vary('Cookie'); res.vary('Authorization');++ res.set('Cache-Control', 'public, max-age=60');++ if (!decision.indexable) res.set('X-Robots-Tag', decision.robots);++ res.json({ ...data, indexability: { indexable: decision.indexable, reasons: decision.codes } });++ });++++ router.get('/m/:slug', (req, res) => {++ const m = visibleMerchant(req, res);++ if (!m) return;++ const active = coupons.active(m.id).map((c) => coupons.view(c, { merchant: m }));++ const ended = coupons.recentlyEnded(m.id).map((c) => coupons.view(c, { merchant: m }));++ const decision = publication.merchantDecision(m, active.length);++ const flashMsg = FLASH[req.query.done] || null;++ send(req, res, 200, {++ title: `${m.name} coupon codes`,++ description: active.length ? `${active.length} active code${active.length === 1 ? '' : 's'} for ${m.name}, with restrictions, expiry and people's reports.` : `No active codes for ${m.name} right now.`,++ decision, canonical: publication.merchantUrl(m), jsonLd: jsonLd(m, null),++ feeds: [{ type: 'rss', href: `/m/${m.slug}/feed.xml`, title: `${m.name} codes (RSS)` }],++ body: pages.merchant({++ m, domains: merchants.domains(m), hints: coupons.merchantHints(m.id), active, ended,++ viewer: req.viewer, csrf: csrf(req), back: `/m/${m.slug}`, flashMsg,++ watching: watches.isWatching(req.viewer.subject, m),++ }),++ }, { cacheable: !flashMsg });++ });++++ router.post('/m/:slug/watch', form, (req, res) => {++ if (!requireForm(req, res)) return;++ const m = merchants.get(req.params.slug);++ if (!m || m.status !== 'active') return notFound(req, res);++ if (req.body.action === 'unwatch') watches.unwatch(req.viewer.subject, m); else watches.watch(req.viewer.subject, m);++ res.redirect(303, withFlash(`/m/${m.slug}`, req.body.action === 'unwatch' ? 'unwatched' : 'watching'));++ });++++ // ── Codes ───────────────────────────────────────────────++ function visibleCoupon(req, res) {++ const id = String(req.params.id || '');++ const c = COUPON_ID_RE.test(id) ? coupons.get(id) : null;++ const m = c && merchants.byId(c.merchant_id);++ if (!c || !m || m.status === 'pending' || c.review_state !== 'published') { notFound(req, res); return null; }++ if (c.status === 'disabled' || m.status === 'disabled') { messagePage(req, res, 410, 'Gone', 'This code was taken down.', { href: '/', label: 'All shops' }); return null; }++ return { c, m };++ }++++ router.get('/c/:id.json', (req, res) => {++ const found = visibleCoupon(req, res);++ if (!found) return;++ const decision = publication.couponDecision(found.c, found.m);++ res.vary('Cookie'); res.vary('Authorization');++ res.set('Cache-Control', 'public, max-age=60');++ if (!decision.indexable) res.set('X-Robots-Tag', decision.robots);++ res.json({ coupon: coupons.view(found.c, { merchant: found.m }), merchant: { id: found.m.id, slug: found.m.slug, name: found.m.name, url: publication.merchantUrl(found.m) }, indexability: { indexable: decision.indexable, reasons: decision.codes } });++ });++++ router.get('/c/:id', (req, res) => {++ const found = visibleCoupon(req, res);++ if (!found) return;++ const { c, m } = found;++ const v = coupons.view(c, { merchant: m });++ const flashMsg = FLASH[req.query.done] || null;++ send(req, res, 200, {++ title: `${v.code} — ${v.title} at ${m.name}`,++ description: `${pages.STATUS_LABEL[v.status]}. ${v.expiry.known ? `Expires ${v.expiry.expires_at.slice(0, 10)}.` : 'Expiry unknown.'}`,++ decision: publication.couponDecision(c, m), canonical: v.url, jsonLd: jsonLd(m, v),++ body: pages.couponPage({ v, m, history: coupons.history(c.id), viewer: req.viewer, csrf: csrf(req), back: `/c/${c.id}`, flashMsg }),++ }, { cacheable: !flashMsg });++ });++++ router.post('/c/:id/report', form, (req, res) => {++ if (!requireForm(req, res)) return;++ const back = safeBack(req.body.back, `/c/${req.params.id}`);++ try {++ const out = reports.report(req.viewer, String(req.params.id), { outcome: req.body.outcome, reason: req.body.reason || null }, { channel: 'site' });++ const key = !out.deduplicated ? 'reported' : out.corrected ? 'report_corrected' : 'report_same';++ res.redirect(303, withFlash(back, key));++ } catch (err) {++ if (!(err instanceof ApiError)) throw err;++ messagePage(req, res, err.status, err.status === 429 ? 'Slow down' : 'Could not record that', err.message, { href: back, label: 'Back' });++ }++ });++++ // ── Submit ──────────────────────────────────────────────++ router.get('/submit', (req, res) => {++ const m = typeof req.query.merchant === 'string' ? merchants.get(req.query.merchant) : null;++ const values = m && m.status === 'active' ? { merchant: m.slug } : {};++ send(req, res, 200, {++ title: 'Submit a code', decision: pageDecision('/submit', { indexable: false }),++ body: pages.submitForm({ values, csrf: csrf(req), viewer: req.viewer, merchantName: values.merchant ? m.name : null }),++ });++ });++++ router.post('/submit', form, (req, res) => {++ if (!requireForm(req, res)) return;++ const b = req.body || {};++ const m = b.merchant ? merchants.get(String(b.merchant)) : null;++ const body = {++ ...(m ? { merchant_id: m.id } : { url: /^https?:\/\//i.test(String(b.url || '')) ? b.url : undefined, host: /^https?:\/\//i.test(String(b.url || '')) ? undefined : b.url }),++ code: b.code, title: b.title, description: b.description, evidence_url: b.evidence_url || null,++ expires: b.expires || null, expiry_basis: b.expiry_basis === 'evidence' ? 'evidence' : 'submitter',++ restrictions: {++ min_spend: b.min_spend_amount ? { amount: b.min_spend_amount, currency: b.min_spend_currency } : null,++ categories: b.categories, new_customers_only: b.new_customers_only, regions: b.regions, other: b.restriction_other,++ },++ hint: b.hint,++ };++ const who = { actor: req.viewer.subject, kind: req.viewer.staff ? 'staff' : 'member', subject: req.viewer.subject };++ try {++ const out = ctx.submitCode(who, body, {});++ send(req, res, out.duplicate ? 200 : 201, { title: 'Submitted', decision: pageDecision('/submit', { indexable: false }), body: pages.submitted(out) });++ } catch (err) {++ if (!(err instanceof ApiError) && err.name !== 'HostError') throw err;++ send(req, res, err.status || 422, {++ title: 'Submit a code', decision: pageDecision('/submit', { indexable: false }),++ body: pages.submitForm({ values: b, errs: [err.message], csrf: csrf(req), viewer: req.viewer, merchantName: m ? m.name : null }),++ });++ }++ });++++ // ── Browser helper tokens ───────────────────────────────++ function connectPage(req, res, status, extra = {}) {++ const list = req.viewer.kind === 'user' && req.viewer.subject ? installs.list(req.viewer.subject) : [];++ send(req, res, status, {++ title: 'Connect the browser helper', decision: pageDecision('/connect-extension', { indexable: req.viewer.kind === 'anonymous' }),++ body: pages.connect({ viewer: req.viewer, installs: list, csrf: csrf(req), ...extra }),++ }, { cacheable: req.viewer.kind === 'anonymous' });++ }++ router.get('/connect-extension', (req, res) => connectPage(req, res, 200));++ router.post('/connect-extension', form, (req, res) => {++ if (!requireForm(req, res)) return;++ try {++ const created = installs.create(req.viewer.subject, { label: req.body.label, scopes: req.body.scope_report ? [SCOPES.LOOKUP, SCOPES.REPORT] : [SCOPES.LOOKUP] });++ connectPage(req, res, 201, { created });++ } catch (err) {++ if (!(err instanceof ApiError)) throw err;++ connectPage(req, res, err.status, { errs: [err.message] });++ }++ });++ router.post('/connect-extension/:id/revoke', form, (req, res) => {++ if (!requireForm(req, res)) return;++ installs.revoke(req.viewer.subject, String(req.params.id));++ res.redirect(303, '/connect-extension');++ });++++ router.get('/watching', (req, res) => {++ if (!requireUser(req, res)) return;++ send(req, res, 200, { title: 'Watching', decision: pageDecision('/watching', { indexable: false }), body: pages.watching({ merchants: watches.list(req.viewer.subject) }) });++ });++++ // ── Staff ───────────────────────────────────────────────++ function requireStaff(req, res) {++ if (!requireUser(req, res)) return false;++ if (!req.viewer.staff) { messagePage(req, res, 403, 'Staff only', 'This page is for OpenVibe staff.'); return false; }++ return true;++ }++ function staffPage(req, res, status = 200, extra = {}) {++ const pendingMerchants = merchants.pending().map((m) => ({ ...m, domains: merchants.domains(m).map((d) => d.host + (d.path_prefix || '')), codes: coupons.pendingOfMerchant(m.id).length }));++ const pendingCoupons = coupons.pendingReview().map((c) => {++ const m = merchants.byId(c.merchant_id);++ const v = coupons.view(c, { merchant: m });++ return { id: c.id, code: c.code, title: c.title, origin: c.origin, merchant: m ? m.name : '?', evidence: (v.evidence.find((e) => e.url) || {}).url || null };++ });++ send(req, res, status, {++ title: 'Staff', decision: pageDecision('/staff', { indexable: false }),++ body: pages.staff({ pendingMerchants, pendingCoupons, holds: ctx.importer.holds(), csrf: csrf(req), flashMsg: FLASH[req.query.done] || extra.flashMsg || null, errs: extra.errs }),++ });++ }++ function staffAction(fn) {++ return (req, res) => {++ if (!requireStaff(req, res)) return;++ if (!checkCsrf(config, req.viewer, req.body && req.body.csrf)) return messagePage(req, res, 403, 'Form expired', 'Reload the page and try again.');++ try {++ const msg = fn(req);++ staffPage(req, res, 200, { flashMsg: msg });++ } catch (err) {++ if (!(err instanceof ApiError) && err.name !== 'HostError') throw err;++ staffPage(req, res, err.status || 422, { errs: [err.message] });++ }++ };++ }++ router.get('/staff', (req, res) => { if (requireStaff(req, res)) staffPage(req, res); });++ router.post('/staff/merchants', form, staffAction((req) => {++ const b = req.body;++ const m = merchants.create({ name: b.name, domains: [{ host: b.host, include_subdomains: Boolean(b.include_subdomains), path_prefix: b.path_prefix || '' }] }, { status: 'active', actor: req.viewer.subject });++ store.tx(() => coupons.syncMerchant(m));++ return `Added ${m.name}.`;++ }));++ router.post('/staff/merchants/:id/status', form, staffAction((req) => {++ const m = merchants.get(req.params.id);++ if (!m) throw new ApiError(404, 'merchant.not_found', 'no such shop');++ const after = moderation.setMerchantStatus(m, req.body.status, { actor: req.viewer.subject });++ return `${after.name} is now ${after.status}.`;++ }));++ router.post('/staff/coupons/:id/approve', form, staffAction((req) => {++ const c = coupons.get(req.params.id);++ if (!c) throw new ApiError(404, 'coupon.not_found', 'no such code');++ const m = merchants.byId(c.merchant_id);++ if (m.status !== 'active') throw new ApiError(409, 'merchant.not_active', 'approve the shop first');++ coupons.approve(c, { actor: req.viewer.subject });++ return `Published ${c.code}.`;++ }));++ router.post(['/staff/coupons/status', '/staff/coupons/:id/status'], form, staffAction((req) => {++ const c = coupons.get(req.params.id || req.body.id);++ if (!c) throw new ApiError(404, 'coupon.not_found', 'no such code');++ const after = coupons.setStatus(c, req.body.status, { actor: req.viewer.subject, note: req.body.note });++ return `${c.code} is now ${pages.STATUS_LABEL[after.status].toLowerCase()}.`;++ }));++++ return { router, notFound };++}++++module.exports = { createPublicRoutes };nnew file mode 100644@@@ -0,0 +1,32 @@++'use strict';++++/**++ * OpenVibe.Coupons — process entry. `node server/index.js`++ * Listens on PORT (4850) behind nginx (deploy/). Starts the outbox relay (when EVENTS_URL and the++ * client secret are set) and the worker (expiry/decay sweep; Sources import when configured).++ */++const { createApp } = require('./app');++++const { app, ctx } = createApp();++const { config } = ctx;++++const server = app.listen(config.port, config.host, () => {++ console.log(`[Coupons] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${config.dbPath})`);++ console.log(`[Coupons] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; worker ${config.worker.enabled ? 'on' : 'off'}; Sources import ${ctx.importer.enabled ? 'on' : 'off'}`);++});++server.keepAliveTimeout = 65_000;++ctx.outbox.start();++ctx.worker.start();++++function shutdown(signal) {++ console.log(`[Coupons] ${signal}: closing`);++ ctx.worker.stop();++ server.close(async () => {++ try { await ctx.outbox.stop(); } catch { /* best effort */ }++ try { ctx.store.close(); } catch { /* already closed */ }++ process.exit(0);++ });++ setTimeout(() => process.exit(0), 5000).unref();++}++process.on('SIGTERM', () => shutdown('SIGTERM'));++process.on('SIGINT', () => shutdown('SIGINT'));nnew file mode 100644@@@ -0,0 +1,76 @@++'use strict';++/**++ * Truthful readiness for GET /api/ready (openvibe-shared/ready, Track O).++ *++ * db required a real query on Coupons' SQLite (the nine charter tables answer)++ * network_jwks optional the Network signing key has loaded; without it pages, lookups and++ * feeds still serve, but nobody can sign in and service tokens get 503++ * events_relay optional the outbox relay is configured and has no rejected rows++ * sweep optional the expiry/decay sweep ran without error in the last 5 minutes++ * (active lists do not depend on it: they filter on expiry at query time)++ * sources_import optional the Sources import is configured and its last run succeeded++ * reporter_key optional COUPONS_REPORTER_KEY_SECRET is set (otherwise a development key)++ */++const { createReadiness } = require('openvibe-shared/ready');++const { CHARTER_TABLES } = require('./db');++++function createCouponsReadiness({ store, auth, outbox, worker, importer, reports, config, release = null }) {++ const { db } = store;++ return createReadiness({++ service: 'coupons',++ release,++ checks: [++ {++ name: 'db', required: true,++ check: () => {++ const names = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));++ const missing = CHARTER_TABLES.filter((t) => !names.has(t));++ return missing.length ? `missing ${missing.join(', ')}` : true;++ },++ },++ {++ name: 'network_jwks', required: false,++ check: () => {++ if (auth.client.publicKey) return true;++ auth.ensureKey().catch(() => {});++ return 'Network signing key not loaded yet: sign-in and service calls are unavailable';++ },++ },++ {++ name: 'events_relay', required: false,++ check: () => {++ const s = outbox.status();++ if (!s.enabled) return `relay off (EVENTS_URL or OV_OAUTH_CLIENT_SECRET unset); ${s.pending} events waiting`;++ if (s.rejected) return `${s.rejected} events rejected by OpenVibe.Events`;++ return { ok: true, detail: { pending: s.pending } };++ },++ },++ {++ name: 'sweep', required: false,++ check: () => {++ if (!config.worker.enabled) return 'worker off (COUPONS_WORKER=off): expiries are still enforced at query time, but not recorded';++ const s = worker.lastSweep();++ if (!s) return 'no sweep yet';++ if (s.error) return `last sweep failed: ${s.error}`;++ if (Date.now() - s.at > 5 * 60 * 1000) return 'last sweep is more than 5 minutes old';++ return true;++ },++ },++ {++ name: 'sources_import', required: false,++ check: () => {++ if (!importer.enabled) return 'off (OV_SOURCES_INTERNAL_URL or OV_OAUTH_CLIENT_SECRET unset)';++ const st = importer.state();++ if (st && st.last_error) return `last run failed: ${st.last_error}`;++ return { ok: true, detail: { cursor: st ? st.cursor : 0, held: importer.holdCount() } };++ },++ },++ {++ name: 'reporter_key', required: false,++ check: () => (reports.usingDevKey ? 'COUPONS_REPORTER_KEY_SECRET unset: reporter keys use the development key' : true),++ },++ ],++ });++}++++module.exports = { createCouponsReadiness };nnew file mode 100644@@@ -0,0 +1,100 @@++'use strict';++++/**++ * Page shell. Every page is server-rendered through this and is complete without JavaScript:++ * - <head>: title, description, canonical and robots from the indexability gate's decision++ * (openvibe-publishing/seo metaTags — there is no default that makes a page indexable),++ * Open Graph, JSON-LD built from real fields only, feed links, the shared app icon++ * - the shared chrome: navbar.js and theme-loader.js from the Network (progressive), a++ * <noscript> navigation bar and the server-rendered shared footer (openvibe-shared)++ * - no script of Coupons' own beyond the navbar init: copying a code is plain text selection++ */++const crypto = require('crypto');++const fs = require('fs');++const path = require('path');++const seo = require('openvibe-publishing/seo');++const { escapeHtml: esc } = require('openvibe-publishing/ssr');++const appIcon = require('openvibe-shared/app-icon');++const chrome = require('openvibe-shared/chrome-ssr');++++const NETWORK_URL = 'https://openvibe.network';++const SITE_NAME = 'OpenVibe.Coupons';++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');++const NAV = [++ { label: 'Merchants', href: '/' },++ { label: 'Submit a code', href: '/submit' },++ { label: 'Browser helper', href: '/connect-extension' },++];++++const hashes = new Map();++function assetVersion(rel) {++ if (hashes.has(rel)) return hashes.get(rel);++ let v = 'dev';++ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }++ hashes.set(rel, v);++ return v;++}++const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;++++/**++ * o: title, description, decision (required), canonical, jsonLd [], feeds [{ type, href, title }],++ * body (HTML), viewer, config, path, bodyClass++ */++function renderPage(o) {++ if (!o.decision) throw new TypeError('renderPage needs the gate decision');++ const head = seo.metaTags({++ title: o.title ? `${o.title} · ${SITE_NAME}` : SITE_NAME,++ description: o.description || 'Coupon codes with merchant matching, restrictions, honest expiry and people\'s validity reports.',++ decision: o.decision,++ canonical: o.canonical,++ type: 'website',++ siteName: SITE_NAME,++ jsonLd: (o.jsonLd || []).filter(Boolean),++ });++ const viewer = o.viewer || { kind: 'anonymous' };++ const signedIn = viewer.kind === 'user';++ const loginNext = encodeURIComponent(o.path || '/');++ const nav = {++ service: 'coupons',++ apiBase: NETWORK_URL,++ links: NAV.map((l) => ({ label: l.label, href: l.href })),++ history: { type: 'page', title: o.title || SITE_NAME },++ silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,++ sessionUrl: '/auth/me',++ loginUrl: `/auth/login?next=${loginNext}`,++ };++ const account = signedIn++ ? `<a href="/watching">Watching</a> · <a href="/connect-extension">Browser helper</a>${viewer.staff ? ' · <a href="/staff">Staff</a>' : ''} · <a href="/auth/logout?next=${loginNext}">Sign out</a>`++ : `<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;++ return `<!DOCTYPE html>++<html lang="en">++<head>++<meta charset="utf-8">++<meta name="viewport" content="width=device-width, initial-scale=1">++${head}++${seo.feedLinks(o.feeds || [])}++${appIcon.headTags({ site: 'coupons' })}++<link rel="stylesheet" href="${asset('css/coupons.css')}">++<script src="${NETWORK_URL}/shared/theme-loader.js" defer></script>++<script src="${NETWORK_URL}/shared/navbar.js" defer></script>++</head>++<body class="${esc(o.bodyClass || '')}">++<a class="skip" href="#main">Skip to content</a>++<div id="navbar-mount"></div>++${chrome.noscriptNav({ name: SITE_NAME, home: '/', links: NAV })}++<div class="account-bar" role="navigation" aria-label="Account">${account}</div>++<main id="main" class="page">++${o.body || ''}++</main>++${chrome.footer({ service: 'coupons', variant: 'full' })}++<script>++window.__OV_PAGE = ${JSON.stringify({ navbar: nav }).replace(/</g, '\\u003c')};++document.addEventListener('DOMContentLoaded', function () {++ try { if (window.OpenVibeNavbar) OpenVibeNavbar.init(window.__OV_PAGE.navbar); } catch (e) { /* the chrome is optional */ }++});++</script>++</body>++</html>`;++}++++module.exports = { renderPage, asset, assetVersion, SITE_NAME, NETWORK_URL };nnew file mode 100644@@@ -0,0 +1,279 @@++'use strict';++++/**++ * Page bodies (layout.js wraps them). Everything is in the HTML: codes as selectable text, status,++ * confidence, last report time, restrictions, expiry or "unknown", evidence, and plain forms.++ * Values are escaped by ssr.html unless wrapped in raw().++ *++ * Wording rules: an unknown is shown as unknown ("Expiry unknown", "No restrictions stated",++ * "No reports in the last 30 days"), never as a friendly default.++ */++const ssr = require('openvibe-publishing/ssr');++++const { html: h, raw } = ssr;++++const STATUS_LABEL = {++ unknown: 'Validity unknown',++ reported_working: 'Reported working',++ reported_failed: 'Reported not working',++ expired: 'Expired',++ disabled: 'Taken down',++};++const BASIS_LABEL = {++ evidence: 'as stated on the evidence page',++ submitter: 'according to the person who submitted it',++ source: 'as stated by the imported source',++ staff: 'set by staff',++};++const REASON_LABEL = {++ invalid: 'Code not accepted', expired: 'Said it has expired', min_spend_not_met: 'Minimum spend not met',++ not_eligible: 'Not eligible (account, region or items)', other: 'Other',++};++++const utc = (iso) => (iso ? `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC` : null);++const regionName = (() => {++ let names = null;++ try { names = new Intl.DisplayNames(['en'], { type: 'region' }); } catch { /* no ICU */ }++ return (code) => { try { return names ? `${names.of(code)} (${code})` : code; } catch { return code; } };++})();++++function hidden(csrf) { return h`<input type="hidden" name="csrf" value="${csrf}">`; }++++function statusBadge(status) {++ return h`<span class="status status-${status}">${STATUS_LABEL[status] || status}</span>`;++}++++function expiryText(expiry) {++ if (!expiry.known) return h`<span class="unknown">Expiry unknown</span>`;++ const when = expiry.precision === 'date'++ ? h`${raw(ssr.timeTag(expiry.expires_at, { label: expiry.expires_at.slice(0, 10) }))} (end of that day in UTC; the merchant's time zone is not stated)`++ : h`${raw(ssr.timeTag(expiry.expires_at, { label: utc(expiry.expires_at) }))}`;++ return h`Expires ${when}, ${BASIS_LABEL[expiry.basis] || 'source not recorded'}`;++}++++function confidenceText(v) {++ if (v.confidence == null) return h`<span class="unknown">No reports in the last ${v.reports.window_days} days</span>`;++ return h`Confidence ${Math.round(v.confidence * 100)}% from ${v.reports.worked} “worked” and ${v.reports.failed} “didn't work” report${v.reports.worked + v.reports.failed === 1 ? '' : 's'} in the last ${v.reports.window_days} days (<a href="/about#confidence">how this is computed</a>)`;++}++++function restrictionsList(list) {++ if (!list.length) return h`<p class="unknown">No restrictions stated. That does not mean there are none.</p>`;++ return h`<ul class="restrictions">${list.map((r) => {++ if (r.kind === 'min_spend') return h`<li>Minimum spend ${r.amount}</li>`;++ if (r.kind === 'category') return h`<li>Category: ${r.value}</li>`;++ if (r.kind === 'new_customers_only') return h`<li>New customers only</li>`;++ if (r.kind === 'region') return h`<li>Region: ${regionName(r.value)}</li>`;++ return h`<li>${r.value}</li>`;++ })}</ul>`;++}++++function evidenceList(list) {++ const withUrl = list.filter((e) => e.url);++ if (!withUrl.length) return h`<p class="meta">Evidence: none recorded (submitted without a link).</p>`;++ return h`<p class="meta">Evidence: ${withUrl.map((e, i) => h`${i ? ', ' : ''}<a href="${e.url}" rel="nofollow ugc noopener">${hostOf(e.url)}</a>${e.merchant_page ? ' (the merchant\'s own site)' : ''}${e.retrieved_at ? h` (retrieved ${e.retrieved_at.slice(0, 10)})` : ''}`)}</p>`;++}++function hostOf(u) { try { return new URL(u).hostname; } catch { return u; } }++++function reportForms(v, { viewer, csrf, back }) {++ if (!v.active) return '';++ if (!viewer || viewer.kind !== 'user') return h`<p class="meta"><a href="/auth/login?next=${encodeURIComponent(back)}">Sign in</a> to report whether it worked.</p>`;++ return h`<div class="report">++<form method="post" action="/c/${v.id}/report" class="inline">${hidden(csrf)}<input type="hidden" name="outcome" value="worked"><input type="hidden" name="back" value="${back}"><button type="submit">It worked</button></form>++<form method="post" action="/c/${v.id}/report" class="inline">${hidden(csrf)}<input type="hidden" name="outcome" value="failed"><input type="hidden" name="back" value="${back}">++<label>Why <select name="reason"><option value="">(not sure)</option>${Object.entries(REASON_LABEL).map(([k, l]) => h`<option value="${k}">${l}</option>`)}</select></label>++<button type="submit">It didn't work</button></form>++</div>`;++}++++/** One code. v: coupons.view(); opts: { viewer, csrf, back, link } */++function couponCard(v, opts = {}) {++ return h`<article class="coupon status-${v.status}" id="${v.id}">++<h3>${opts.link ? h`<a href="/c/${v.id}">${v.title}</a>` : v.title}</h3>++<p class="code-line"><span class="label">Code</span> <code class="code">${v.code}</code> ${statusBadge(v.status)}</p>++${v.description ? h`<p>${v.description}</p>` : ''}++<p class="meta">${confidenceText(v)}</p>++<p class="meta">${v.reports.last_report_at ? h`Last report ${v.reports.last_report_at.slice(0, 10)} around ${v.reports.last_report_at.slice(11, 13)}:00 UTC` : 'No reports yet'}</p>++<p class="meta">${expiryText(v.expiry)}</p>++${restrictionsList(v.restrictions)}++${v.hints.length ? h`<ul class="hints">${v.hints.map((x) => h`<li>How to apply: ${x.text}</li>`)}</ul>` : ''}++${evidenceList(v.evidence)}++${v.origin === 'ai' ? h`<p class="disclosure">Extracted by an AI workflow from the evidence page and reviewed by staff before listing. Its validity comes only from people's reports.</p>` : ''}++${reportForms(v, opts)}++</article>`;++}++++function flash(msg) {++ if (!msg) return '';++ return h`<p class="notice" role="status">${msg}</p>`;++}++++function errors(list) {++ if (!list || !list.length) return '';++ return h`<div class="errors" role="alert"><p>Please fix:</p><ul>${list.map((e) => h`<li>${e}</li>`)}</ul></div>`;++}++++function home({ merchants, recent, q, results, total }) {++ return h`<h1>Coupon codes, honestly labelled</h1>++<p class="lede">Codes for online shops with their restrictions, their expiry (or “unknown”) and what people reported when they tried them. A code is only ever called working because people said it worked recently.</p>++<form method="get" action="/" class="search" role="search"><label for="q">Find a shop</label> <input id="q" name="q" value="${q || ''}" maxlength="60" placeholder="name or domain"> <button type="submit">Search</button></form>++${q ? h`<h2>Shops matching “${q}”</h2>${results.length ? h`<ul class="merchant-list">${results.map((m) => h`<li><a href="/m/${m.slug}">${m.name}</a></li>`)}</ul>` : h`<p class="empty">No shop matches. <a href="/submit">Submit a code</a> for a new one.</p>`}` : ''}++<h2>Recently added codes</h2>++${recent.length ? h`<ul class="recent">${recent.map((r) => h`<li><a href="/c/${r.coupon.id}">${r.coupon.title}</a> at <a href="/m/${r.merchant.slug}">${r.merchant.name}</a> ${statusBadge(r.coupon.status)}</li>`)}</ul>` : h`<p class="empty">No codes yet.</p>`}++<h2>Shops (${total})</h2>++${merchants.length ? h`<ul class="merchant-list">${merchants.map((m) => h`<li><a href="/m/${m.merchant.slug}">${m.merchant.name}</a> <span class="meta">${m.active} active code${m.active === 1 ? '' : 's'}</span></li>`)}</ul>` : h`<p class="empty">No shops listed yet.</p>`}++<p class="meta"><a href="/about">How statuses and confidence work</a> · <a href="/feed.xml">RSS</a> · <a href="/atom.xml">Atom</a> · <a href="/feed.json">JSON Feed</a></p>`;++}++++function merchant({ m, domains, hints, active, ended, viewer, csrf, watching, back, flashMsg }) {++ return h`${raw(ssr.breadcrumbsHtml([{ name: 'Coupons', url: '/' }, { name: m.name }]))}++${flash(flashMsg)}++<h1>${m.name} coupon codes</h1>++<p class="meta">${m.homepage_url ? h`<a href="${m.homepage_url}" rel="nofollow noopener">${hostOf(m.homepage_url)}</a> · ` : ''}Covers ${domains.map((d, i) => h`${i ? '; ' : ''}${d.host}${d.path_prefix || ''}${d.include_subdomains ? ' and its subdomains' : ''}`)}</p>++${m.description ? h`<p>${m.description}</p>` : ''}++${hints.length ? h`<ul class="hints">${hints.map((x) => h`<li>How to apply codes here: ${x.text}</li>`)}</ul>` : ''}++<h2>Active codes (${active.length})</h2>++${active.length ? active.map((v) => couponCard(v, { viewer, csrf, back, link: true })) : h`<p class="empty">No active codes for ${m.name} right now.</p>`}++<p><a href="/submit?merchant=${m.slug}">Submit a code for ${m.name}</a></p>++${viewer && viewer.kind === 'user' ? h`<form method="post" action="/m/${m.slug}/watch">${hidden(csrf)}<input type="hidden" name="action" value="${watching ? 'unwatch' : 'watch'}"><button type="submit">${watching ? 'Stop watching' : 'Watch this shop'}</button> <span class="meta">(your watch list is private; notifications are not sent yet)</span></form>` : ''}++${ended.length ? h`<h2>Recently ended (not in active results)</h2><ul class="ended">${ended.map((v) => h`<li><code>${v.code}</code> — ${v.title} · ${v.status === 'expired' ? 'expired' : 'past its expiry'} ${v.expired_at ? v.expired_at.slice(0, 10) : v.expiry.expires_at ? v.expiry.expires_at.slice(0, 10) : ''}</li>`)}</ul>` : ''}++<p class="meta">Follow: <a href="/m/${m.slug}/feed.xml">RSS</a> · <a href="/m/${m.slug}.json">JSON</a></p>`;++}++++function couponPage({ v, m, history, viewer, csrf, back, flashMsg }) {++ return h`${raw(ssr.breadcrumbsHtml([{ name: 'Coupons', url: '/' }, { name: m.name, url: `/m/${m.slug}` }, { name: v.code }]))}++${flash(flashMsg)}++${v.active ? '' : h`<p class="notice">This code is not in active results (${STATUS_LABEL[v.status] ? STATUS_LABEL[v.status].toLowerCase() : v.status}${v.expiry.known && !v.active && v.status !== 'expired' ? ', past its expiry' : ''}).</p>`}++${couponCard(v, { viewer, csrf, back })}++<h2>Status history</h2>++${history.length ? h`<ol class="history">${history.map((x) => h`<li>${utc(new Date(x.created_at).toISOString())}: ${x.from_status ? h`${STATUS_LABEL[x.from_status] || x.from_status} → ` : ''}${STATUS_LABEL[x.to_status] || x.to_status}${x.confidence_after != null ? h`, confidence ${Math.round(x.confidence_after * 100)}%` : ''} <span class="meta">(${x.reason.startsWith('staff') ? 'staff' : x.reason.startsWith('service:') ? 'service' : x.reason})</span></li>`)}</ol>` : h`<p class="empty">No changes yet.</p>`}++<p class="meta"><a href="/c/${v.id}.json">This code as JSON</a></p>`;++}++++function about({ confidence }) {++ return h`<h1>How OpenVibe.Coupons labels codes</h1>++<h2 id="statuses">Statuses</h2>++<ul>++<li><strong>Validity unknown</strong>: nobody reported on it recently, or the reports disagree. New codes always start here, whoever submitted them.</li>++<li><strong>Reported working</strong> / <strong>Reported not working</strong>: what recent reports by people say. Nothing else can set these: not the submitter, not staff, not a source, not an AI model.</li>++<li><strong>Expired</strong>: its known expiry has passed (or staff recorded that it ended). It leaves active results at that moment.</li>++<li><strong>Taken down</strong>: removed by staff.</li>++</ul>++<h2 id="confidence">Confidence</h2>++<p>Only each person's most recent report on a code counts, one per day at most. A report weighs 0.5<sup>age / ${confidence.HALF_LIFE_DAYS} days</sup> and stops counting after ${confidence.WINDOW_DAYS} days. With W the weight of “worked” reports and F of “didn't work” reports:</p>++<p class="formula">confidence = (1 + e + W) / (2 + e + W + F)</p>++<p>where e is ${confidence.EVIDENCE_PRIOR} when the code was published on the merchant's own site, otherwise 0. With no report in the last ${confidence.WINDOW_DAYS} days there is no confidence at all (shown as “no reports”), because evidence alone says nothing about whether a code works today. A code is shown as reported working at ${Math.round(confidence.WORKING_AT * 100)}% or more and reported not working at ${Math.round(confidence.FAILED_AT * 100)}% or less, and only once the reports weigh at least ${confidence.MIN_MASS} (about one report younger than a week).</p>++<h2 id="expiry">Expiry</h2>++<p>An expiry is shown only when someone stated it, with who stated it. A date without a time means the end of that day in UTC, because the merchant's time zone is not known. When nobody stated an expiry it is “unknown”.</p>++<h2 id="privacy">Privacy</h2>++<p>Codes and their aggregates are public. Who submitted or reported a code is never shown, never returned by the API and never sent in events. Reports are stored under a keyed hash of the person, not their account id.</p>++<h2 id="helper">The browser helper</h2>++<p>The OpenVibe browser helper reads only the hostname of the tab you are on, and only when you open it. It asks this site's public API which shop that is and lists its codes. It never reads the page, never sends the page's address beyond the hostname and holds no OpenVibe sign-in: to report from it you <a href="/connect-extension">connect it</a> with a token that can only look up and report, and that you can revoke at any time.</p>`;++}++++function submitForm({ values = {}, errs = [], csrf, viewer, merchantName = null }) {++ if (!viewer || viewer.kind !== 'user') {++ return h`<h1>Submit a code</h1><p><a href="/auth/login?next=%2Fsubmit">Sign in with OpenVibe</a> to submit a code. Submitted codes start as “validity unknown” until people report on them.</p>`;++ }++ const v = (k) => values[k] || '';++ return h`<h1>Submit a code</h1>++<p class="lede">It will be listed as “validity unknown” until people report whether it works. Codes for a shop we do not list yet wait until staff add the shop.</p>++${errors(errs)}++<form method="post" action="/submit" class="form">${hidden(csrf)}++<fieldset><legend>The shop</legend>++${merchantName ? h`<p>Shop: <strong>${merchantName}</strong></p><input type="hidden" name="merchant" value="${v('merchant')}">` : h`<label>Shop website (address or domain) <input name="url" required maxlength="2048" value="${v('url')}" placeholder="https://www.example-shop.com/"></label>`}++</fieldset>++<fieldset><legend>The code</legend>++<label>Code <input name="code" required maxlength="64" value="${v('code')}" autocomplete="off" spellcheck="false"></label>++<label>What it gives <input name="title" required maxlength="140" value="${v('title')}" placeholder="15% off shoes"></label>++<label>Details (optional) <textarea name="description" maxlength="1000" rows="3">${v('description')}</textarea></label>++<label>Where you found it (optional, a link) <input name="evidence_url" type="url" maxlength="2048" value="${v('evidence_url')}"></label>++</fieldset>++<fieldset><legend>Expiry</legend>++<label>Expires on (leave empty if unknown) <input name="expires" type="date" value="${v('expires')}"></label>++<label><input type="checkbox" name="expiry_basis" value="evidence"${values.expiry_basis === 'evidence' ? raw(' checked') : ''}> The linked page states this expiry</label>++</fieldset>++<fieldset><legend>Restrictions (only what is stated)</legend>++<label>Minimum spend <input name="min_spend_amount" inputmode="decimal" maxlength="12" value="${v('min_spend_amount')}" placeholder="50.00"></label>++<label>Currency <input name="min_spend_currency" maxlength="3" value="${v('min_spend_currency')}" placeholder="USD"></label>++<label>Only for categories (comma-separated) <input name="categories" maxlength="400" value="${v('categories')}"></label>++<label><input type="checkbox" name="new_customers_only" value="1"${values.new_customers_only ? raw(' checked') : ''}> New customers only</label>++<label>Only in regions (ISO codes, comma-separated) <input name="regions" maxlength="200" value="${v('regions')}" placeholder="US, CA"></label>++<label>Other conditions <input name="restriction_other" maxlength="300" value="${v('restriction_other')}"></label>++<label>How to apply it (optional) <input name="hint" maxlength="300" value="${v('hint')}" placeholder="Enter it in the Promo code box at checkout"></label>++</fieldset>++<button type="submit">Submit</button>++</form>`;++}++++function submitted({ coupon, merchant, duplicate }) {++ if (coupon.review_state === 'pending') {++ return h`<h1>Thanks — waiting for review</h1><p>${merchant.status === 'pending' ? `${merchant.name} is not listed yet. Staff will review the shop; your code will be listed with it.` : 'This code waits for staff review before it is listed.'}</p><p><a href="/">Back to all shops</a></p>`;++ }++ return h`<h1>${duplicate ? 'Already listed — your evidence was added' : 'Thanks — your code is listed'}</h1><p>It shows as “${STATUS_LABEL[coupon.status]}” until people report on it.</p><p><a href="/m/${merchant.slug}#${coupon.id}">See it on ${merchant.name}'s page</a></p>`;++}++++function connect({ viewer, installs, csrf, created, errs = [] }) {++ const intro = h`<h1>Connect the browser helper</h1>++<p class="lede">The OpenVibe browser helper shows codes for the shop you are on. It works without connecting; connecting lets it report whether a code worked, as you.</p>++<ul>++<li>It reads only the <strong>hostname</strong> of the current tab, and only when you open it.</li>++<li>It never reads page content, never sends full addresses and has no access to your OpenVibe account: the token below can only look up codes and (if you allow it) report on them.</li>++<li>Revoking a token here takes effect on its next request.</li>++</ul>`;++ if (!viewer || viewer.kind !== 'user') return h`${intro}<p><a href="/auth/login?next=%2Fconnect-extension">Sign in with OpenVibe</a> to create a token.</p>`;++ return h`${intro}++${errors(errs)}++${created ? h`<div class="token-once" role="status"><p><strong>Your token for “${created.install.label}”</strong> — copy it into the helper's settings now. It is shown only once.</p><p><code class="token">${created.token}</code></p></div>` : ''}++<h2>Create a token</h2>++<form method="post" action="/connect-extension" class="form">${hidden(csrf)}++<label>Name this browser <input name="label" maxlength="60" placeholder="Firefox on my laptop"></label>++<label><input type="checkbox" name="scope_report" value="1" checked> Allow it to report whether codes worked</label>++<p class="meta">Looking up codes is always allowed. Nothing else is possible with this token.</p>++<button type="submit">Create token</button>++</form>++<h2>Your connected browsers</h2>++${installs.length ? h`<table class="installs"><thead><tr><th>Name</th><th>Token</th><th>Can</th><th>Created</th><th>Last used</th><th></th></tr></thead><tbody>${installs.map((i) => h`<tr>++<td>${i.label}</td><td><code>${i.token_hint}…</code></td><td>${i.scopes.map((s) => (s === 'coupons.report' ? 'look up, report' : '')).filter(Boolean)[0] || 'look up'}</td>++<td>${i.created_at.slice(0, 10)}</td><td>${i.last_used_at ? i.last_used_at.slice(0, 10) : 'never'}</td>++<td>${i.active ? h`<form method="post" action="/connect-extension/${i.id}/revoke">${hidden(csrf)}<button type="submit">Revoke</button></form>` : (i.revoked_at ? 'revoked' : 'expired')}</td></tr>`)}</tbody></table>` : h`<p class="empty">None yet.</p>`}`;++}++++function watching({ merchants }) {++ return h`<h1>Shops you watch</h1>++<p class="meta">Private to you. Notifications are not sent yet; this list is where watched shops are collected until they are.</p>++${merchants.length ? h`<ul class="merchant-list">${merchants.map((m) => h`<li><a href="/m/${m.slug}">${m.name}</a></li>`)}</ul>` : h`<p class="empty">You are not watching any shop. Use “Watch this shop” on a shop's page.</p>`}`;++}++++function staff({ pendingMerchants, pendingCoupons, holds, csrf, flashMsg, errs = [] }) {++ return h`<h1>Staff</h1>++${flash(flashMsg)}${errors(errs)}++<h2>Add a shop</h2>++<form method="post" action="/staff/merchants" class="form">${hidden(csrf)}++<label>Name <input name="name" required maxlength="120"></label>++<label>Domain <input name="host" required maxlength="253" placeholder="example-shop.com"></label>++<label><input type="checkbox" name="include_subdomains" value="1" checked> Include subdomains</label>++<label>Only under this path (optional) <input name="path_prefix" maxlength="200" placeholder="/shop/name"></label>++<button type="submit">Add</button></form>++<h2>Shops waiting for review (${pendingMerchants.length})</h2>++${pendingMerchants.length ? h`<ul>${pendingMerchants.map((m) => h`<li>${m.name} (${m.domains.join(', ')}) · ${m.codes} code(s)++<form method="post" action="/staff/merchants/${m.id}/status" class="inline">${hidden(csrf)}<input type="hidden" name="status" value="active"><button>Approve</button></form>++<form method="post" action="/staff/merchants/${m.id}/status" class="inline">${hidden(csrf)}<input type="hidden" name="status" value="disabled"><button>Reject</button></form></li>`)}</ul>` : h`<p class="empty">None.</p>`}++<h2>Codes waiting for review (${pendingCoupons.length})</h2>++${pendingCoupons.length ? h`<ul>${pendingCoupons.map((c) => h`<li><code>${c.code}</code> — ${c.title} at ${c.merchant} (${c.origin})${c.evidence ? h` · <a href="${c.evidence}" rel="nofollow noopener">evidence</a>` : ''}++<form method="post" action="/staff/coupons/${c.id}/approve" class="inline">${hidden(csrf)}<button>Publish</button></form>++<form method="post" action="/staff/coupons/${c.id}/status" class="inline">${hidden(csrf)}<input type="hidden" name="status" value="disabled"><button>Reject</button></form></li>`)}</ul>` : h`<p class="empty">None.</p>`}++<h2>Change a code</h2>++<form method="post" action="/staff/coupons/status" class="form">${hidden(csrf)}++<label>Code id <input name="id" required pattern="cpn_[0-9A-Z]{26}" placeholder="cpn_…"></label>++<label>Set <select name="status"><option value="disabled">Take down</option><option value="expired">Mark expired</option><option value="active">Back to active (recomputed from reports)</option></select></label>++<label>Note <input name="note" maxlength="200"></label>++<button type="submit">Apply</button></form>++<h2>Imported items held (${holds.length})</h2>++${holds.length ? h`<ul>${holds.map((x) => h`<li>${x.item_id} from ${x.source_key || '?'}: ${x.reason}${x.detail ? h` (${x.detail})` : ''} · ${x.attempts} attempt(s)</li>`)}</ul>` : h`<p class="empty">None.</p>`}`;++}++++function message({ heading, text, action }) {++ return h`<h1>${heading}</h1><p>${text}</p>${action ? h`<p><a href="${action.href}">${action.label}</a></p>` : ''}`;++}++++module.exports = { home, merchant, couponPage, couponCard, about, submitForm, submitted, connect, watching, staff, message, STATUS_LABEL };nnew file mode 100644@@@ -0,0 +1,61 @@++'use strict';++/**++ * Background work in the Coupons process (two timers):++ *++ * sweep every COUPONS_SWEEP_INTERVAL_MS: record codes whose known expiry has passed++ * (status → expired, history, coupons.coupon.expired, Search tombstone) and let++ * old reports decay (status back to unknown, confidence → null after 30 days).++ * Active lists never wait for it: they filter on expires_at at query time.++ * Sources import every COUPONS_SOURCES_INTERVAL_MS when OV_SOURCES_INTERNAL_URL is set.++ */++function createWorker({ config, coupons, importer, outbox, log = console }) {++ let sweepTimer = null;++ let importTimer = null;++ let sweeping = false;++ let importing = false;++ let lastSweep = null;++++ function sweep() {++ if (sweeping) return null;++ sweeping = true;++ try {++ const r = coupons.sweep();++ lastSweep = { at: Date.now(), ...r, error: null };++ if (r.expired.length || r.changed.length) outbox.kick();++ return r;++ } catch (err) {++ lastSweep = { at: Date.now(), error: err.message };++ log.error('[Coupons] sweep failed:', err.message);++ return null;++ } finally { sweeping = false; }++ }++++ async function importTick() {++ if (importing || !importer.enabled) return null;++ importing = true;++ try {++ const r = await importer.run();++ outbox.kick();++ return r;++ } finally { importing = false; }++ }++++ return {++ sweep,++ importTick,++ lastSweep: () => lastSweep,++ start() {++ if (!config.worker.enabled) return;++ sweepTimer = setInterval(sweep, config.worker.intervalMs);++ sweepTimer.unref();++ setTimeout(sweep, 1000).unref();++ if (importer.enabled) {++ importTimer = setInterval(() => importTick().catch((err) => log.warn('[Coupons] import tick failed:', err.message)), config.sources.intervalMs);++ importTimer.unref();++ }++ },++ stop() { clearInterval(sweepTimer); clearInterval(importTimer); },++ };++}++++module.exports = { createWorker };nnew file mode 100644@@@ -0,0 +1,85 @@++'use strict';++/**++ * The confidence formula (server/domain/confidence.js, README "Confidence"), pinned with numbers.++ */++const assert = require('assert');++const conf = require('../server/domain/confidence');++const { check, done } = require('./helpers/boot');++++const NOW = Date.parse('2026-09-22T12:00:00Z');++const DAY = conf.DAY;++const r = (who, outcome, ageDays) => ({ reporter_key: who, outcome, updated_at: NOW - ageDays * DAY });++++(async () => {++ await check('no reports → confidence null and status unknown (unknown stays unknown), with or without evidence', async () => {++ assert.deepStrictEqual(conf.fromReports([], { now: NOW }), { confidence: null, W: 0, F: 0, mass: 0, counted: 0, derived: 'unknown' });++ assert.strictEqual(conf.fromReports([], { now: NOW, merchantEvidence: true }).confidence, null);++ assert.strictEqual(conf.evaluate({ status: 'unknown', expires_at: null }, [], { now: NOW }).status, 'unknown');++ });++++ await check('one fresh report: worked → 0.67 reported_working; failed → 0.33 reported_failed; one of each → 0.5 unknown', async () => {++ assert.strictEqual(conf.fromReports([r('a', 'worked', 0)], { now: NOW }).confidence, 0.67);++ assert.strictEqual(conf.fromReports([r('a', 'worked', 0)], { now: NOW }).derived, 'reported_working');++ assert.strictEqual(conf.fromReports([r('a', 'failed', 0)], { now: NOW }).confidence, 0.33);++ assert.strictEqual(conf.fromReports([r('a', 'failed', 0)], { now: NOW }).derived, 'reported_failed');++ const mixed = conf.fromReports([r('a', 'worked', 0), r('b', 'failed', 0)], { now: NOW });++ assert.strictEqual(mixed.confidence, 0.5);++ assert.strictEqual(mixed.derived, 'unknown');++ });++++ await check('recency: weight halves every 7 days; a lone report decides status for a week; nothing after 30 days', async () => {++ assert.strictEqual(conf.weight(0), 1);++ assert.ok(Math.abs(conf.weight(7 * DAY) - 0.5) < 1e-12);++ assert.ok(Math.abs(conf.weight(14 * DAY) - 0.25) < 1e-12);++ assert.strictEqual(conf.weight(30 * DAY + 1), 0);++ assert.strictEqual(conf.fromReports([r('a', 'worked', 6.9)], { now: NOW }).derived, 'reported_working');++ const week = conf.fromReports([r('a', 'worked', 7.5)], { now: NOW });++ assert.strictEqual(week.derived, 'unknown', 'mass below 0.5 after a week');++ assert.ok(week.confidence > 0.5, 'but the number still leans the way the report said');++ assert.strictEqual(conf.fromReports([r('a', 'worked', 31)], { now: NOW }).confidence, null);++ });++++ await check('deduplication: only each reporter\'s most recent report counts', async () => {++ const spam = Array.from({ length: 20 }, (_, i) => r('same-person', 'worked', i));++ const one = conf.fromReports(spam, { now: NOW });++ assert.strictEqual(one.counted, 1);++ assert.strictEqual(one.confidence, 0.67, 'twenty daily "worked" reports by one person count as one');++ const changed = conf.fromReports([r('p', 'worked', 3), r('p', 'failed', 0)], { now: NOW });++ assert.strictEqual(changed.derived, 'reported_failed', 'a later report replaces the earlier one');++ });++++ await check('evidence: merchant-published code adds half a report to the prior, never a number on its own', async () => {++ assert.strictEqual(conf.fromReports([r('a', 'worked', 0)], { now: NOW, merchantEvidence: true }).confidence, 0.71);++ const failed = conf.fromReports([r('a', 'failed', 0)], { now: NOW, merchantEvidence: true });++ assert.strictEqual(failed.confidence, 0.43);++ assert.strictEqual(failed.derived, 'unknown');++ assert.strictEqual(conf.fromReports([r('a', 'failed', 0), r('b', 'failed', 0)], { now: NOW, merchantEvidence: true }).derived, 'reported_failed');++ });++++ await check('a larger mix: 3 fresh worked, 1 failed a week ago → (1+3+0)/(2+3+0.5) = 0.73', async () => {++ const x = conf.fromReports([r('a', 'worked', 0), r('b', 'worked', 0), r('c', 'worked', 0), r('d', 'failed', 7)], { now: NOW });++ assert.strictEqual(x.W, 3);++ assert.strictEqual(x.F, 0.5);++ assert.strictEqual(x.confidence, 0.73);++ assert.strictEqual(x.derived, 'reported_working');++ });++++ await check('status precedence: disabled > expired (known expiry passed or recorded) > reports', async () => {++ const reports = [r('a', 'worked', 0)];++ assert.strictEqual(conf.evaluate({ status: 'disabled', expires_at: null }, reports, { now: NOW }).status, 'disabled');++ assert.strictEqual(conf.evaluate({ status: 'reported_working', expires_at: NOW }, reports, { now: NOW }).status, 'expired', 'at the instant of expiry');++ assert.strictEqual(conf.evaluate({ status: 'reported_working', expires_at: NOW + 1 }, reports, { now: NOW }).status, 'reported_working');++ assert.strictEqual(conf.evaluate({ status: 'expired', expires_at: null, expired_at: NOW - 1 }, reports, { now: NOW }).status, 'expired');++ assert.strictEqual(conf.isActive({ review_state: 'published', status: 'unknown', expires_at: NOW }, NOW), false);++ assert.strictEqual(conf.isActive({ review_state: 'published', status: 'unknown', expires_at: null }, NOW), true);++ assert.strictEqual(conf.isActive({ review_state: 'pending', status: 'unknown', expires_at: null }, NOW), false);++ });++++ await check('the function is pure: same input, same output; now is required', async () => {++ const input = [r('a', 'worked', 2), r('b', 'failed', 9)];++ assert.deepStrictEqual(conf.fromReports(input, { now: NOW }), conf.fromReports(input, { now: NOW }));++ assert.throws(() => conf.fromReports(input, {}), /now/);++ });++++ done();++})();nnew file mode 100644@@@ -0,0 +1,60 @@++'use strict';++/**++ * The proposals the lead releases in the next openvibe-contracts version are valid against the++ * released schemas, match what the code enforces and emits, and do not collide with released ids.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const contracts = require('openvibe-contracts');++const { check, done } = require('./helpers/boot');++const { PROPOSED, ALL_SCOPES } = require('../server/auth/capabilities');++++const ROOT = path.join(__dirname, '..');++const DIR = path.join(ROOT, 'docs', 'capabilities-proposal');++++(async () => {++ const files = fs.readdirSync(DIR).filter((f) => f.endsWith('.json'));++ const caps = files.map((f) => JSON.parse(fs.readFileSync(path.join(DIR, f), 'utf8')));++ const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'docs', 'service-manifest-proposal.json'), 'utf8'));++++ await check('every capability proposal is a valid capabilities.capability@1 with 3+ segments, owned by coupons', async () => {++ for (const c of caps) {++ const v = contracts.validate('capabilities.capability@1', c);++ assert.ok(v.valid, `${c.id}: ${JSON.stringify(v.errors)}`);++ assert.strictEqual(c.owner, 'coupons');++ assert.ok(c.id.split('.').length >= 3, c.id);++ assert.strictEqual(`${c.id}.json`, files[caps.indexOf(c)]);++ assert.ok(!contracts.capabilities.get(c.id) || contracts.capabilities.get(c.id).owner === 'coupons', `${c.id} collides with a released capability`);++ }++ });++++ await check('the proposals are exactly the capabilities the code enforces; install scopes are not capabilities', async () => {++ assert.deepStrictEqual(caps.map((c) => c.id).sort(), [...PROPOSED].sort());++ assert.deepStrictEqual([...manifest.capabilities].sort(), [...PROPOSED].sort());++ for (const s of ALL_SCOPES) assert.ok(!PROPOSED.has(s), `${s} is an install scope, not a capability`);++ });++++ await check('the service manifest proposal is a valid registry.service-manifest@1 and declares every event capabilities name', async () => {++ const v = contracts.validate('registry.service-manifest@1', manifest);++ assert.ok(v.valid, JSON.stringify(v.errors));++ assert.strictEqual(manifest.id, 'coupons');++ for (const c of caps) for (const e of c.events) assert.ok(manifest.eventsProduced.includes(e), `${c.id} names ${e}, missing from eventsProduced`);++ });++++ await check('every event type the code emits is declared, and every declared one is emitted somewhere', async () => {++ const src = ['server/domain/coupons.js', 'server/domain/reports.js'].map((f) => fs.readFileSync(path.join(ROOT, f), 'utf8')).join('\n');++ const emitted = new Set([...src.matchAll(/'(coupons\.[a-z_]+\.[a-z_]+)'/g)].map((m) => m[1]).filter((x) => !PROPOSED.has(x)));++ emitted.add('coupons.index_document.upserted');++ emitted.add('coupons.index_document.deleted');++ for (const e of emitted) assert.ok(manifest.eventsProduced.includes(e), `${e} emitted but not declared`);++ for (const e of manifest.eventsProduced) assert.ok(emitted.has(e), `${e} declared but never emitted`);++ });++++ await check('the port and service id match the platform table (4850, coupons)', async () => {++ assert.strictEqual(require('../server/config').load({}).port, 4850);++ assert.match(manifest.notes, /Port 4850/);++ });++++ done();++})();nnew file mode 100644@@@ -0,0 +1,104 @@++'use strict';++/**++ * Domain normalization: hosts, the bundled public-suffix subset (eTLD+1, wildcards, exceptions,++ * private-section platforms), rule checks and merchant matching (subdomains, paths, never across++ * registrable domains) — and the same through the resolve API.++ */++const assert = require('assert');++const hosts = require('../server/domain/hosts');++const psl = require('../server/domain/psl');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ await check('normalizeHost: case, trailing dot, port, IDN → punycode', async () => {++ assert.strictEqual(hosts.normalizeHost('WWW.Example-Shop.CO.UK.'), 'www.example-shop.co.uk');++ assert.strictEqual(hosts.normalizeHost('shop.example.com:8443'), 'shop.example.com');++ assert.strictEqual(hosts.normalizeHost('bücher.de'), 'xn--bcher-kva.de');++ assert.strictEqual(hosts.normalizeHost(' Shop.Com '), 'shop.com');++ });++++ await check('normalizeHost refuses URLs, paths, credentials, IPs, localhost and special-use names', async () => {++ for (const bad of ['https://shop.com', 'shop.com/path', '[email protected]', 'shop.com?x=1', 'shop.com#a', '127.0.0.1', '10.0.0.1', '[::1]',++ 'localhost', 'printer.local', 'db.internal', 'x.invalid', 'a.test', 'com', '', '-bad.com', 'bad-.com', 'a..com', 'a'.repeat(64) + '.com', 'shop.com:abc']) {++ assert.throws(() => hosts.normalizeHost(bad), (e) => e.name === 'HostError' && e.status === 400, bad);++ }++ });++++ await check('registrable domain (eTLD+1) from the bundled public-suffix subset', async () => {++ const cases = {++ 'shop.com': 'shop.com',++ 'www.shop.com': 'shop.com',++ 'a.b.c.shop.com': 'shop.com',++ 'www.example-shop.co.uk': 'example-shop.co.uk',++ 'store.example.com.au': 'example.com.au',++ 'm.example.co.jp': 'example.co.jp',++ 'foo.myshopify.com': 'foo.myshopify.com',++ 'checkout.foo.myshopify.com': 'foo.myshopify.com',++ 'someone.github.io': 'someone.github.io',++ 'shop.example.bar.ck': 'example.bar.ck', // *.ck wildcard: bar.ck is a suffix++ 'www.ck': 'www.ck', // !www.ck exception++ 'a.www.ck': 'www.ck',++ 'x.example.kh': 'x.example.kh', // *.kh: example.kh is a suffix++ };++ for (const [h, want] of Object.entries(cases)) assert.strictEqual(hosts.registrable(h), want, h);++ assert.strictEqual(hosts.registrable('co.uk'), null);++ assert.strictEqual(hosts.registrable('myshopify.com'), null);++ assert.strictEqual(hosts.registrable('bar.ck'), null);++ assert.strictEqual(psl.publicSuffix('www.example-shop.co.uk'), 'co.uk');++ });++++ await check('checkRule refuses a rule on a public suffix and normalizes path prefixes', async () => {++ for (const bad of ['co.uk', 'myshopify.com', 'github.io', 'bar.ck']) assert.throws(() => hosts.checkRule({ host: bad }), /public suffix/, bad);++ assert.deepStrictEqual(hosts.checkRule({ host: 'Shop.COM', path_prefix: '/Brand/X/' }), { host: 'shop.com', registrable: 'shop.com', path_prefix: '/Brand/X', include_subdomains: 1 });++ for (const bad of ['brand', '/a/../b', '/a b', '/a?x', '//a']) assert.throws(() => hosts.checkRule({ host: 'shop.com', path_prefix: bad }), /plain path/, bad);++ assert.strictEqual(hosts.checkRule({ host: 'shop.com', include_subdomains: false }).include_subdomains, 0);++ });++++ await check('bestRule: exact host, subdomains, paths at segment boundaries, most specific wins, never across sites', async () => {++ const rule = (host, include_subdomains, path_prefix = '', id = host + path_prefix) => ({ id, host, registrable_domain: hosts.registrable(host), include_subdomains, path_prefix });++ const rules = [rule('shop.com', 1), rule('eu.shop.com', 0), rule('shop.com', 1, '/brands/acme')];++ assert.strictEqual(hosts.bestRule(rules, 'shop.com').id, 'shop.com');++ assert.strictEqual(hosts.bestRule(rules, 'www.shop.com').id, 'shop.com');++ assert.strictEqual(hosts.bestRule(rules, 'eu.shop.com').id, 'eu.shop.com');++ assert.strictEqual(hosts.bestRule(rules, 'x.eu.shop.com').id, 'shop.com', 'eu.shop.com does not include subdomains');++ assert.strictEqual(hosts.bestRule(rules, 'shop.com', '/brands/acme/shoes').id, 'shop.com/brands/acme');++ assert.strictEqual(hosts.bestRule(rules, 'shop.com', '/brands/acmecorp').id, 'shop.com', 'segment boundary');++ assert.strictEqual(hosts.bestRule(rules, 'shop.com', null).id, 'shop.com', 'host-only lookups never match path rules');++ assert.strictEqual(hosts.bestRule(rules, 'othershop.com'), null);++ assert.strictEqual(hosts.bestRule(rules, 'shop.com.evil.net'), null);++ assert.strictEqual(hosts.bestRule(rules, 'evilshop.com'), null, 'suffix match must be at a label boundary');++ // A (hypothetical) rule on a platform domain never claims a customer's own site.++ const platform = [{ id: 'p', host: 'myshopify.com', registrable_domain: 'foo.myshopify.com', include_subdomains: 1, path_prefix: '' }];++ assert.strictEqual(hosts.bestRule(platform, 'foo.myshopify.com'), null);++ });++++ const t = await boot();++ await check('the resolve API: subdomains, unknown hosts, bad input, platform subdomains are separate shops', async () => {++ await t.merchant({ name: 'Example Shop', host: 'example-shop.co.uk' });++ await t.merchant({ name: 'Foo on Shopify', host: 'foo.myshopify.com' });++ const r = await t.get('/api/v1/merchants/resolve?host=WWW.Example-Shop.co.uk');++ assert.strictEqual(r.status, 200, r.text);++ const b = r.json();++ assert.strictEqual(b.merchant.name, 'Example Shop');++ assert.strictEqual(b.host, 'www.example-shop.co.uk');++ assert.strictEqual(b.registrable_domain, 'example-shop.co.uk');++ assert.strictEqual((await t.get('/api/v1/merchants/resolve?host=foo.myshopify.com')).json().merchant.name, 'Foo on Shopify');++ const bar = await t.get('/api/v1/merchants/resolve?host=bar.myshopify.com');++ assert.strictEqual(bar.status, 404);++ assert.strictEqual(bar.json().code, 'merchant.not_found');++ assert.strictEqual((await t.get('/api/v1/merchants/resolve?host=127.0.0.1')).status, 400);++ assert.strictEqual((await t.get('/api/v1/merchants/resolve?host=https://example-shop.co.uk/cart')).status, 400);++ assert.strictEqual((await t.get('/api/v1/merchants/resolve')).status, 400);++ });++++ await check('a merchant cannot be created on a public suffix, and a domain rule belongs to one merchant', async () => {++ const r = await t.get('/api/v1/merchants', { as: t.staffToken(), json: { name: 'All UK', domains: [{ host: 'co.uk' }] } });++ assert.strictEqual(r.status, 400);++ const dup = await t.get('/api/v1/merchants', { as: t.staffToken(), json: { name: 'Impostor', domains: [{ host: 'example-shop.co.uk' }] } });++ assert.strictEqual(dup.status, 409);++ assert.strictEqual(dup.json().code, 'merchant.domain_taken');++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,161 @@++'use strict';++/**++ * The browser-helper surface: install tokens (scoped, hashed at rest, revocation immediate,++ * expiring), the lookup API (same bytes for every caller, no personal data), CORS only for the++ * extension origin and only on the lookup routes, per-caller rate limits — and what a malicious++ * merchant page can do with a visitor's browser (nothing that reveals anyone's data).++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++const EXT = 'chrome-extension://abcdefghijklmnopabcdefghijklmnop';++const DAY = 24 * 3600 * 1000;++++(async () => {++ const t = await boot({ env: { COUPONS_EXTENSION_ORIGINS: `${EXT},moz-extension://*`, COUPONS_LOOKUP_ANON_PER_MIN: '8', COUPONS_LOOKUP_TOKEN_PER_MIN: '40' } });++ const alice = t.network.addUser('alice');++ const bob = t.network.addUser('bob');++ const shop = await t.merchant({ name: 'Paper Lantern', host: 'paperlantern.store' });++ const code = (await t.submit(alice, { host: 'paperlantern.store', code: 'GLOW', title: 'Ten off lamps', evidence_url: 'https://paperlantern.store/sale' })).json().coupon;++ await t.get(`/api/v1/coupons/${code.id}/report`, { as: t.network.userToken(alice), json: { outcome: 'worked' } });++++ let aliceToken;++ let bobToken;++ await check('connect page: signed out → sign-in link; signed in → token shown once, stored only as a hash', async () => {++ const anon = await t.get('/connect-extension');++ assert.strictEqual(anon.status, 200);++ assert.match(anon.text, /Sign in with OpenVibe/);++ assert.doesNotMatch(anon.text, /cpx_/);++ aliceToken = await t.connect(alice, { label: 'Firefox' });++ bobToken = await t.connect(bob, { label: 'Chrome', report: false });++ assert.match(aliceToken, /^cpx_[A-Za-z0-9_-]{43}$/);++ const rows = t.ctx.store.db.prepare('SELECT * FROM extension_installs').all();++ assert.strictEqual(rows.length, 2);++ const dump = JSON.stringify(rows);++ assert.ok(!dump.includes(aliceToken) && !dump.includes(bobToken), 'the token itself is never stored');++ const again = await t.get('/connect-extension', { as: alice });++ assert.doesNotMatch(again.text, new RegExp(aliceToken), 'shown once only');++ assert.match(again.text, /Firefox/);++ assert.match(again.headers.get('cache-control'), /no-store/);++ });++++ await check('connect and revoke forms need the form token (CSRF)', async () => {++ const r = await t.get('/connect-extension', { as: alice, form: { label: 'x', scope_report: '1' } });++ assert.strictEqual(r.status, 403);++ const install = t.ctx.installs.list(alice.subject)[0];++ const rv = await t.get(`/connect-extension/${install.id}/revoke`, { as: alice, form: { csrf: 'forged' } });++ assert.strictEqual(rv.status, 403);++ assert.strictEqual(t.ctx.installs.list(alice.subject)[0].active, true);++ });++++ await check('scopes: lookup always; report only when granted; nothing else (no submit, no staff)', async () => {++ assert.strictEqual((await t.get(`/api/v1/merchants/${shop.id}/coupons`, { as: bobToken })).status, 200);++ const noReport = await t.get(`/api/v1/coupons/${code.id}/report`, { as: bobToken, json: { outcome: 'worked' } });++ assert.strictEqual(noReport.status, 403);++ assert.strictEqual(noReport.json().code, 'token.scope');++ assert.strictEqual((await t.get(`/api/v1/coupons/${code.id}/report`, { as: aliceToken, json: { outcome: 'worked' } })).status, 200, 'alice already reported today via the API: deduplicated');++ assert.strictEqual((await t.get('/api/v1/coupons/submit', { as: aliceToken, json: { host: 'paperlantern.store', code: 'NEW', title: 'New one' } })).status, 403);++ assert.strictEqual((await t.get(`/api/v1/coupons/${code.id}/status`, { as: aliceToken, json: { status: 'disabled' } })).status, 403);++ // An install token is not a session: pages treat it as anonymous.++ const page = await t.get('/connect-extension', { as: aliceToken });++ assert.match(page.text, /Sign in with OpenVibe/);++ });++++ await check('revocation takes effect on the very next request', async () => {++ assert.strictEqual((await t.get('/api/v1/merchants/resolve?host=paperlantern.store', { as: aliceToken })).status, 200);++ const install = t.ctx.installs.list(alice.subject).find((i) => i.label === 'Firefox');++ const rv = await t.get(`/connect-extension/${install.id}/revoke`, { as: alice, form: { csrf: t.csrf(alice) } });++ assert.strictEqual(rv.status, 303);++ const r = await t.get('/api/v1/merchants/resolve?host=paperlantern.store', { as: aliceToken });++ assert.strictEqual(r.status, 401);++ assert.strictEqual(r.json().code, 'token.revoked');++ assert.strictEqual((await t.get(`/api/v1/coupons/${code.id}/report`, { as: aliceToken, json: { outcome: 'failed' } })).status, 401);++ // Someone else cannot revoke bob's install.++ const bobInstall = t.ctx.installs.list(bob.subject)[0];++ await t.get(`/connect-extension/${bobInstall.id}/revoke`, { as: alice, form: { csrf: t.csrf(alice) } });++ assert.strictEqual((await t.get('/api/v1/merchants/resolve?host=paperlantern.store', { as: bobToken })).status, 200);++ });++++ await check('malformed, unknown and expired tokens are refused, never downgraded to anonymous', async () => {++ assert.strictEqual((await t.get('/api/v1/merchants/resolve?host=paperlantern.store', { as: 'cpx_short' })).json().code, 'token.invalid');++ assert.strictEqual((await t.get('/api/v1/merchants/resolve?host=paperlantern.store', { as: `cpx_${'A'.repeat(43)}` })).json().code, 'token.invalid');++ const temp = await t.connect(alice, { label: 'temp' });++ t.clock.advance(366 * DAY);++ const r = await t.get('/api/v1/merchants/resolve?host=paperlantern.store', { as: temp });++ assert.strictEqual(r.status, 401);++ assert.strictEqual(r.json().code, 'token.expired');++ t.clock.advance(-366 * DAY);++ });++++ await check('a lookup response is the same bytes for anonymous callers and for any two people, and holds no personal data', async () => {++ const carolToken = await t.connect(t.network.addUser('carol'));++ const paths = ['/api/v1/merchants/resolve?host=www.paperlantern.store', `/api/v1/merchants/${shop.id}/coupons`];++ for (const p of paths) {++ const anon = await t.get(p);++ const b = await t.get(p, { as: bobToken });++ const c = await t.get(p, { as: carolToken });++ const signed = await t.get(p, { as: t.network.userToken(alice) });++ assert.strictEqual(anon.status, 200, anon.text);++ assert.strictEqual(b.text, anon.text, p);++ assert.strictEqual(c.text, anon.text, p);++ assert.strictEqual(signed.text, anon.text, p);++ assert.doesNotMatch(anon.text, /usr_|gst_|cpi_|cpx_|reporter|submitted_by|created_by|install/);++ assert.strictEqual(anon.headers.get('cache-control'), 'public, max-age=60');++ }++ });++++ await check('CORS: the extension origin (and moz-extension://*) on the two lookup routes only; never a web origin, never credentials', async () => {++ const lookup = `/api/v1/merchants/${shop.id}/coupons`;++ const ok = await t.get(lookup, { headers: { origin: EXT } });++ assert.strictEqual(ok.headers.get('access-control-allow-origin'), EXT);++ assert.strictEqual(ok.headers.get('access-control-allow-credentials'), null);++ assert.match(ok.headers.get('vary'), /Origin/);++ const moz = await t.get('/api/v1/merchants/resolve?host=paperlantern.store', { headers: { origin: 'moz-extension://0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0' } });++ assert.strictEqual(moz.headers.get('access-control-allow-origin'), 'moz-extension://0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0');++ const pre = await t.get(lookup, { method: 'OPTIONS', headers: { origin: EXT, 'access-control-request-method': 'GET', 'access-control-request-headers': 'authorization' } });++ assert.strictEqual(pre.status, 204);++ assert.match(pre.headers.get('access-control-allow-headers'), /Authorization/);++ for (const origin of ['https://paperlantern.store', 'https://evil.example-shop.com', 'null', 'chrome-extension://someotherextensionidxxxxxxxxxxxx']) {++ const r = await t.get(lookup, { headers: { origin } });++ assert.strictEqual(r.headers.get('access-control-allow-origin'), null, origin);++ }++ const report = await t.get(`/api/v1/coupons/${code.id}/report`, { method: 'OPTIONS', headers: { origin: EXT, 'access-control-request-method': 'POST' } });++ assert.strictEqual(report.headers.get('access-control-allow-origin'), null, 'no CORS on reports');++ const single = await t.get(`/api/v1/coupons/${code.id}`, { headers: { origin: EXT } });++ assert.strictEqual(single.headers.get('access-control-allow-origin'), null, 'no CORS beyond the two lookup routes');++ });++++ await check('a malicious merchant page riding the visitor\'s cookies gets nothing: API ignores cookies, forms need a token only this site can make', async () => {++ const cookie = `ov_token=${t.network.userToken(bob)}`;++ const evil = { origin: 'https://paperlantern.store', cookie };++ const rep = await t.get(`/api/v1/coupons/${code.id}/report`, { method: 'POST', headers: { ...evil, 'content-type': 'application/json' }, body: JSON.stringify({ outcome: 'failed' }) });++ assert.strictEqual(rep.status, 401);++ const formRep = await t.get(`/c/${code.id}/report`, { method: 'POST', headers: { ...evil, 'content-type': 'application/x-www-form-urlencoded' }, body: 'outcome=failed' });++ assert.strictEqual(formRep.status, 403, 'no form token → refused');++ const connect = await t.get('/connect-extension', { method: 'POST', headers: { ...evil, 'content-type': 'application/x-www-form-urlencoded' }, body: 'label=stolen' });++ assert.strictEqual(connect.status, 403, 'cannot mint a token for the visitor');++ const lookup = await t.get('/api/v1/merchants/resolve?host=paperlantern.store', { headers: evil });++ assert.strictEqual(lookup.headers.get('access-control-allow-origin'), null, 'and its script cannot read even the public answer');++ assert.doesNotMatch(lookup.text, /bob|usr_/);++ const watching = await t.get('/watching', { headers: { origin: 'https://paperlantern.store' } });++ assert.strictEqual(watching.status, 303, 'private pages need the visitor\'s own session');++ const framed = await t.get('/connect-extension', { as: bob });++ assert.strictEqual(framed.headers.get('x-frame-options'), 'DENY', 'the connect page cannot be framed (clickjacking)');++ assert.match(framed.headers.get('content-security-policy'), /frame-ancestors 'none'/);++ });++++ await check('lookup rate limits: anonymous callers per IP, installs per install (higher), 429 as problem+json', async () => {++ await t.restart();++ const p = '/api/v1/merchants/resolve?host=paperlantern.store';++ let last;++ for (let i = 0; i < 9; i++) last = await t.get(p);++ assert.strictEqual(last.status, 429);++ assert.strictEqual(last.json().code, 'lookup.rate_limited');++ assert.match(last.headers.get('content-type'), /application\/problem\+json/);++ const withToken = await t.get(p, { as: bobToken });++ assert.strictEqual(withToken.status, 200, 'an install has its own, larger budget');++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,116 @@++'use strict';++/**++ * Boots Coupons on a temp database with a controllable clock and mocks of its neighbours, and++ * returns a small HTTP client. Every test file gets its own instance.++ *++ * const t = await boot({ env });++ * t.get(path, { as: user | 'Bearer token string', json, form, headers, method })++ * t.clock.advance(ms) the app's clock (expiry, reports, decay, tokens)++ * t.merchant({ name, host }) an active merchant created through the API as staff++ * t.submit(user, body) POST /api/v1/coupons/submit as that user++ * t.connect(user, { report }) a cpx_ token created through the /connect-extension form++ */++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const { startNetwork, startSources } = require('./mocks');++++function makeClock(start = Date.parse('2026-09-22T12:00:00Z')) {++ let t = start;++ return { now: () => t, advance: (ms) => { t += ms; return t; }, set: (v) => { t = v; } };++}++++async function boot(opts = {}) {++ const network = await startNetwork();++ const sources = await startSources({ network });++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-coupons-test-'));++ const dbPath = path.join(dir, 'coupons.db');++ const clock = opts.clock || makeClock();++ const staff = network.addUser('staffer', { role: 'admin' });++ const env = {++ NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.coupons', TRUST_PROXY: '1',++ COUPONS_DB_PATH: dbPath,++ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,++ OV_OAUTH_CLIENT_ID: 'coupons', OV_OAUTH_CLIENT_SECRET: 'shh', COOKIE_SECURE: 'false',++ COUPONS_WORKER: 'off', COUPONS_FORM_SECRET: 'test-form-secret', COUPONS_REPORTER_KEY_SECRET: 'test-reporter-secret',++ OV_SOURCES_INTERNAL_URL: sources.url,++ ...(opts.env || {}),++ };++ const configLib = require('../../server/config');++ const { createApp } = require('../../server/app');++ const quiet = { log() {}, warn() {}, error: (...a) => { if (process.env.VERBOSE) console.error(...a); } };++++ let server = null;++ let built = null;++ async function start() {++ const config = configLib.load(env);++ built = createApp({ config, now: clock.now, log: quiet });++ await built.ctx.auth.ensureKey();++ server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });++ t.base = `http://127.0.0.1:${server.address().port}`;++ t.app = built.app;++ t.ctx = built.ctx;++ }++ async function stop() {++ if (server) await new Promise((r) => server.close(r));++ if (built) { built.ctx.worker.stop(); await built.ctx.outbox.stop(); built.ctx.store.close(); }++ server = null; built = null;++ }++++ /** as: a network user ({ subject, … }) → ov_token cookie; a string → Authorization: Bearer <string>. */++ async function get(p, o = {}) {++ const headers = { ...(o.headers || {}) };++ if (o.as && typeof o.as === 'object') headers.cookie = `ov_token=${network.userToken(o.as)}`;++ if (typeof o.as === 'string') headers.authorization = `Bearer ${o.as}`;++ let body = o.body;++ if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }++ if (o.form) { body = new URLSearchParams(o.form).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }++ const res = await fetch(t.base + p, { method: o.method || (body ? 'POST' : 'GET'), headers, body, redirect: 'manual' });++ const text = await res.text();++ return { status: res.status, headers: res.headers, text, json() { return JSON.parse(text); } };++ }++++ /** Rows of event_outbox as parsed envelopes. */++ function events(type = null) {++ return t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope))++ .filter((e) => !type || e.event_type === type || (type instanceof RegExp && type.test(e.event_type)));++ }++++ const csrf = (user) => require('../../server/auth/forms').csrfToken({ formSecret: env.COUPONS_FORM_SECRET }, user);++++ async function merchant({ name, host, include_subdomains = true, path_prefix = '' }) {++ const r = await get('/api/v1/merchants', { as: network.userToken(staff), json: { name, domains: [{ host, include_subdomains, path_prefix }] } });++ if (r.status !== 201) throw new Error(`merchant: ${r.status} ${r.text}`);++ return r.json().merchant;++ }++++ async function submit(user, body) {++ return get('/api/v1/coupons/submit', { as: network.userToken(user), json: body });++ }++++ async function connect(user, { report = true, label = 'test browser' } = {}) {++ const r = await get('/connect-extension', { as: user, form: { csrf: csrf(user), label, ...(report ? { scope_report: '1' } : {}) } });++ if (r.status !== 201) throw new Error(`connect: ${r.status} ${r.text.slice(0, 300)}`);++ const m = r.text.match(/cpx_[A-Za-z0-9_-]{43}/);++ if (!m) throw new Error('no token on the page');++ return m[0];++ }++++ const t = {++ network, sources, clock, dbPath, staff, get, events, csrf, merchant, submit, connect,++ staffToken: () => network.userToken(staff),++ async restart() { await stop(); await start(); },++ async close() { await stop(); await network.close(); await sources.close(); fs.rmSync(dir, { recursive: true, force: true }); },++ };++ await start();++ return t;++}++++let failures = 0;++async function check(name, fn) {++ try { await fn(); console.log(' ✓', name); } catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 8).join('\n ')); }++}++function done() { console.log(failures ? `\n${failures} failed` : '\nall passed'); process.exit(failures ? 1 : 0); }++++module.exports = { boot, check, done, makeClock };nnew file mode 100644@@@ -0,0 +1,100 @@++'use strict';++/**++ * In-process stand-ins for Coupons' neighbours, with a real RS256 key pair:++ * Network JWKS, /oauth/token (client_credentials → service tokens with the requested scope as++ * capabilities)++ * Sources GET /api/v1/items?category=coupons&after= (needs sources.item.read), items the test++ * controls in change order; setDown(true) answers 503++ * userToken()/serviceToken() mint the tokens browsers and services present to Coupons.++ */++const http = require('http');++const crypto = require('crypto');++const jwt = require('jsonwebtoken');++const { serviceAuth, ids } = require('openvibe-contracts');++++function listen(handler) {++ return new Promise((resolve) => {++ const server = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const raw = Buffer.concat(chunks).toString('utf8');++ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };++ handler(req, raw, json, res);++ });++ });++ server.listen(0, '127.0.0.1', () => resolve({ server, url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((r) => server.close(r)) }));++ });++}++++async function startNetwork() {++ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });++ const publicPem = publicKey.export({ type: 'spki', format: 'pem' });++ const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });++ const grants = [];++ let issuer = null;++ const srv = await listen((req, raw, json) => {++ if (req.url === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });++ if (req.url === '/oauth/token' && req.method === 'POST') {++ let body = {};++ if (String(req.headers['content-type'] || '').includes('application/x-www-form-urlencoded')) body = Object.fromEntries(new URLSearchParams(raw));++ else { try { body = JSON.parse(raw); } catch { /* */ } }++ grants.push(body);++ if (body.client_secret !== 'shh') return json(401, { error: 'invalid_client' });++ if (body.grant_type === 'client_credentials') {++ let scope = body.scope;++ if (scope && typeof scope === 'object') scope = Object.values(scope).join(' ');++ const cap = String(scope || '').split(/\s+/).filter(Boolean);++ return json(200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience || 'openvibe.events'], cap }), token_type: 'Bearer', expires_in: 300 });++ }++ return json(400, { error: 'unsupported_grant_type' });++ }++ return json(404, { error: 'not found' });++ });++ issuer = srv.url;++ function signService({ sub, aud, cap }) {++ return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type: 'service', aud, cap, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: crypto.randomUUID() }, privatePem);++ }++ function addUser(username, extra = {}) {++ return { subject: ids.newId('user'), username, display_name: extra.display_name || username, role: extra.role || 'user' };++ }++ function userToken(u) {++ return jwt.sign({ sub: String(Math.floor(Math.random() * 1e6)), subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role || 'user' }, privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h' });++ }++ function serviceToken(client, cap) {++ return signService({ sub: `svc:${client}`, aud: ['openvibe.coupons'], cap });++ }++ return { ...srv, publicPem, grants, addUser, userToken, serviceToken, signService };++}++++async function startSources({ network }) {++ const items = []; // in change order; each gets change_seq++ let seq = 0;++ let down = false;++ const calls = [];++ const srv = await listen((req, raw, json) => {++ calls.push(req.url);++ if (down) return json(503, { code: 'down' });++ const token = String(req.headers.authorization || '').slice(7);++ const v = serviceAuth.verifyServiceToken(token, { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.sources' });++ if (!v.ok || !(v.claims.cap || []).includes('sources.item.read')) return json(403, { code: 'capability.denied' });++ const u = new URL(req.url, 'http://x');++ if (u.pathname !== '/api/v1/items') return json(404, { code: 'route.not_found' });++ const after = Number(u.searchParams.get('after') || 0);++ const limit = Number(u.searchParams.get('limit') || 100);++ const list = items.filter((i) => i.change_seq > after && i.category === u.searchParams.get('category')).slice(0, limit);++ return json(200, { items: list, next_after: list.length ? list[list.length - 1].change_seq : after, more: false, sources: {} });++ });++ function put(item) {++ const idx = items.findIndex((i) => i.id === item.id);++ const row = { category: 'coupons', kind: 'coupon', revision: 1, removed: null, authors: [], summary: null, published_at: null, source_updated_at: null,++ provenance: { retrieved_at: '2026-09-22T10:00:00.000Z', first_seen_at: '2026-09-22T10:00:00.000Z', content_hash: 'a'.repeat(64), raw_body_hash: null, parser_version: 'manual@1', fetch_run_id: null, license_note: null, terms_note: 'merchant-published codes', entered_by: 'svc:network' },++ ...item, change_seq: ++seq };++ if (idx >= 0) items.splice(idx, 1);++ items.push(row);++ return row;++ }++ return { ...srv, items, calls, put, setDown: (v) => { down = v; } };++}++++module.exports = { startNetwork, startSources, listen };nnew file mode 100644@@@ -0,0 +1,237 @@++'use strict';++/**++ * A code's life: submitted as unknown (expiry and validity unknown stay unknown, everywhere),++ * nothing but people's reports can make it "working", a known expiry takes it out of active++ * results at that instant (API, pages, feeds, sitemaps, Search), staff and services can only++ * disable / expire / re-enable, and every change is in the history, the events and Search.++ */++const assert = require('assert');++const contracts = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++++const HOUR = 3600 * 1000;++const DAY = 24 * HOUR;++++(async () => {++ const t = await boot();++ const alice = t.network.addUser('alice');++ const bob = t.network.addUser('bob');++ const shop = await t.merchant({ name: 'Acme Outdoor', host: 'acme-outdoor.com' });++++ let unknownCode;++ await check('a submission with no expiry and no restrictions: status unknown, confidence null, expiry unknown — in the API, the page and the JSON', async () => {++ const r = await t.submit(alice, { host: 'www.acme-outdoor.com', code: 'TRAIL15', title: '15% off tents' });++ assert.strictEqual(r.status, 201, r.text);++ const c = r.json().coupon;++ unknownCode = c;++ assert.strictEqual(c.status, 'unknown');++ assert.strictEqual(c.confidence, null);++ assert.deepStrictEqual(c.expiry, { known: false, expires_at: null, precision: null, basis: null });++ assert.deepStrictEqual(c.restrictions, []);++ assert.strictEqual(c.active, true);++ const page = await t.get(`/m/${shop.slug}`);++ assert.strictEqual(page.status, 200);++ assert.match(page.text, /TRAIL15/);++ assert.match(page.text, /Validity unknown/);++ assert.match(page.text, /Expiry unknown/);++ assert.match(page.text, /No restrictions stated. That does not mean there are none./);++ assert.match(page.text, /No reports in the last 30 days/);++ assert.doesNotMatch(page.text, /Expires /);++ const j = (await t.get(`/c/${c.id}.json`)).json();++ assert.strictEqual(j.coupon.expiry.expires_at, null);++ assert.strictEqual(j.coupon.confidence, null);++ const jsonld = page.text.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g).join('\n');++ assert.doesNotMatch(jsonld, /validThrough|priceValidUntil|aggregateRating|ratingValue|"Offer"/, 'no fabricated offer or validity in structured data');++ });++++ await check('the unknown code stays unknown across time and sweeps while nobody reports', async () => {++ t.clock.advance(40 * DAY);++ t.ctx.worker.sweep();++ const c = (await t.get(`/api/v1/coupons/${unknownCode.id}`)).json().coupon;++ assert.strictEqual(c.status, 'unknown');++ assert.strictEqual(c.confidence, null);++ assert.strictEqual(c.expiry.known, false);++ assert.strictEqual(c.active, true, 'unknown expiry never expires by itself');++ });++++ await check('nobody can submit a status, a confidence or "verified": people, services and AI output alike', async () => {++ for (const extra of [{ status: 'reported_working' }, { confidence: 0.99 }, { verified: true }, { working: true }]) {++ const r = await t.submit(alice, { host: 'acme-outdoor.com', code: 'X1', title: 'Some code', ...extra });++ assert.strictEqual(r.status, 422, JSON.stringify(extra));++ assert.strictEqual(r.json().code, 'coupon.status_not_accepted');++ }++ const ai = t.network.serviceToken('ai', ['coupons.coupon.submit']);++ const r = await t.get('/api/v1/coupons/submit', { as: ai, headers: { 'x-ov-origin': 'ai' }, json: { host: 'acme-outdoor.com', code: 'AIGUESS', title: 'Model guess', status: 'reported_working' } });++ assert.strictEqual(r.status, 422);++ });++++ let aiCode;++ await check('AI-extracted codes are drafts for staff review, start unknown, and a model cannot report', async () => {++ const ai = t.network.serviceToken('ai', ['coupons.coupon.submit', 'coupons.report.create']);++ const r = await t.get('/api/v1/coupons/submit', { as: ai, headers: { 'x-ov-origin': 'ai' }, json: { host: 'acme-outdoor.com', code: 'AIFOUND', title: '10% off boots', evidence_url: 'https://acme-outdoor.com/promos', ai_run_id: 'run_123' } });++ assert.strictEqual(r.status, 201, r.text);++ aiCode = r.json().coupon;++ assert.strictEqual(r.json().review_state, 'pending');++ assert.strictEqual(aiCode.status, 'unknown');++ assert.strictEqual(aiCode.active, false);++ assert.strictEqual((await t.get(`/c/${aiCode.id}`)).status, 404, 'not public before review');++ assert.doesNotMatch((await t.get(`/m/${shop.slug}`)).text, /AIFOUND/);++ const rep = await t.get(`/api/v1/coupons/${unknownCode.id}/report`, { as: ai, headers: { 'x-ov-origin': 'ai', 'x-ov-subject': alice.subject }, json: { outcome: 'worked' } });++ assert.strictEqual(rep.status, 403);++ assert.strictEqual(rep.json().code, 'report.ai_refused');++ // Staff review publishes it — still unknown until people report.++ const approve = await t.get(`/staff/coupons/${aiCode.id}/approve`, { as: t.staff, form: { csrf: t.csrf(t.staff) } });++ assert.strictEqual(approve.status, 200, approve.text.slice(0, 200));++ const after = (await t.get(`/api/v1/coupons/${aiCode.id}`)).json().coupon;++ assert.strictEqual(after.status, 'unknown');++ assert.strictEqual(after.active, true);++ assert.match((await t.get(`/c/${aiCode.id}`)).text, /Extracted by an AI workflow/);++ });++++ await check('only people\'s reports make a code "reported working"', async () => {++ const r = await t.get(`/api/v1/coupons/${unknownCode.id}/report`, { as: t.network.userToken(bob), json: { outcome: 'worked' } });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.json().coupon.status, 'reported_working');++ assert.strictEqual(r.json().coupon.confidence, 0.67);++ });++++ await check('staff and services can disable, expire or re-enable — never set working/failed', async () => {++ const svc = t.network.serviceToken('moderation', ['coupons.status.update']);++ for (const s of ['reported_working', 'reported_failed', 'unknown']) {++ const r = await t.get(`/api/v1/coupons/${unknownCode.id}/status`, { as: svc, json: { status: s } });++ assert.strictEqual(r.status, 422, s);++ assert.strictEqual(r.json().code, 'coupon.status_not_settable');++ }++ const noCap = await t.get(`/api/v1/coupons/${unknownCode.id}/status`, { as: t.network.serviceToken('other', []), json: { status: 'disabled' } });++ assert.strictEqual(noCap.status, 403);++ const member = await t.get(`/api/v1/coupons/${unknownCode.id}/status`, { as: t.network.userToken(alice), json: { status: 'disabled' } });++ assert.strictEqual(member.status, 403);++ const off = await t.get(`/api/v1/coupons/${unknownCode.id}/status`, { as: svc, json: { status: 'disabled', note: 'merchant asked' } });++ assert.strictEqual(off.status, 200, off.text);++ assert.strictEqual(off.json().coupon.status, 'disabled');++ assert.strictEqual((await t.get(`/c/${unknownCode.id}`)).status, 410);++ assert.strictEqual((await t.get(`/api/v1/coupons/${unknownCode.id}`)).status, 404);++ const on = await t.get(`/api/v1/coupons/${unknownCode.id}/status`, { as: svc, json: { status: 'active' } });++ assert.strictEqual(on.json().coupon.status, 'reported_working', 're-enabled: recomputed from the reports, not set');++ });++++ let dated;++ const expiresAt = Date.parse('2026-11-05T09:30:00Z');++ await check('a code with a known expiry leaves every active result at that instant, before any sweep', async () => {++ t.clock.set(Date.parse('2026-11-01T00:00:00Z'));++ const r = await t.submit(alice, { host: 'acme-outdoor.com', code: 'FALL20', title: '20% off jackets', expires: '2026-11-05T09:30:00Z', evidence_url: 'https://acme-outdoor.com/fall', expiry_basis: 'evidence',++ restrictions: { min_spend: { amount: '50', currency: 'USD' }, new_customers_only: true, regions: 'US, CA', categories: ['Jackets'] } });++ assert.strictEqual(r.status, 201, r.text);++ dated = r.json().coupon;++ assert.deepStrictEqual(dated.expiry, { known: true, expires_at: '2026-11-05T09:30:00.000Z', precision: 'instant', basis: 'evidence' });++ assert.strictEqual(dated.evidence[0].merchant_page, true);++ assert.deepStrictEqual(dated.restrictions.map((x) => x.kind).sort(), ['category', 'min_spend', 'new_customers_only', 'region', 'region']);++ const page = await t.get(`/m/${shop.slug}`);++ assert.match(page.text, /Minimum spend \$50\.00/);++ assert.match(page.text, /New customers only/);++ assert.match(page.text, /Canada \(CA\)/);++ assert.match(page.text, /as stated on the evidence page/);++++ const listed = async () => {++ const api = (await t.get(`/api/v1/merchants/${shop.id}/coupons`)).json().coupons.map((c) => c.code);++ const html = (await t.get(`/m/${shop.slug}`)).text;++ const feed = (await t.get('/feed.xml')).text;++ const map = (await t.get('/sitemaps/coupons.xml')).text;++ return { api: api.includes('FALL20'), page: /id="cpn_[^"]+"[\s\S]*?FALL20/.test(html.split('Recently ended')[0]), feed: feed.includes('FALL20'), sitemap: map.includes(dated.id) };++ };++ t.clock.set(expiresAt - 1);++ assert.deepStrictEqual(await listed(), { api: true, page: true, feed: true, sitemap: true });++ t.clock.set(expiresAt);++ assert.deepStrictEqual(await listed(), { api: false, page: false, feed: false, sitemap: false });++ const cpage = await t.get(`/c/${dated.id}`);++ assert.strictEqual(cpage.status, 200, 'still readable');++ assert.match(cpage.text, /<meta name="robots" content="noindex, follow">/);++ assert.match(cpage.headers.get('x-robots-tag'), /noindex/);++ assert.match((await t.get(`/m/${shop.slug}`)).text, /Recently ended[\s\S]*FALL20/);++ const rep = await t.get(`/api/v1/coupons/${dated.id}/report`, { as: t.network.userToken(bob), json: { outcome: 'worked' } });++ assert.strictEqual(rep.status, 409, 'no reports on a code out of active results');++ });++++ await check('the sweep records the expiry: status, history, coupons.coupon.expired, Search tombstone — once', async () => {++ const before = t.events().length;++ const r = t.ctx.worker.sweep();++ assert.deepStrictEqual(r.expired, [dated.id]);++ const row = t.ctx.coupons.get(dated.id);++ assert.strictEqual(row.status, 'expired');++ assert.strictEqual(row.expired_at, expiresAt, 'expired at the stated instant, not at sweep time');++ const hist = t.ctx.coupons.history(dated.id);++ assert.strictEqual(hist[0].to_status, 'expired');++ assert.strictEqual(hist[0].reason, 'expiry');++ const evs = t.events().slice(before);++ const mine = evs.filter((e) => e.subject.id === dated.id).map((e) => e.event_type).sort();++ assert.deepStrictEqual(mine, ['coupons.coupon.expired', 'coupons.index_document.deleted']);++ assert.ok(evs.some((e) => e.event_type === 'coupons.index_document.upserted' && e.subject.id === shop.id), 'the shop document is re-sent with one active code fewer');++ assert.deepStrictEqual(t.ctx.worker.sweep().expired, [], 'idempotent');++ assert.strictEqual(t.events().length, before + evs.length);++ });++++ await check('a date-only expiry means the end of that day in UTC, and the page says the time zone is not stated', async () => {++ const r = await t.submit(alice, { host: 'acme-outdoor.com', code: 'DATEONLY', title: 'Five off socks', expires: '2026-11-10' });++ assert.strictEqual(r.json().coupon.expiry.expires_at, '2026-11-10T23:59:59.999Z');++ assert.strictEqual(r.json().coupon.expiry.basis, 'submitter');++ assert.match((await t.get(`/c/${r.json().coupon.id}`)).text, /end of that day in UTC; the merchant's time zone is not stated/);++ assert.strictEqual((await t.submit(alice, { host: 'acme-outdoor.com', code: 'AMBIG', title: 'Ambiguous time', expires: '2026-11-10T10:00' })).json().code, 'coupon.ambiguous_expiry');++ assert.strictEqual((await t.submit(alice, { host: 'acme-outdoor.com', code: 'PAST', title: 'Already over', expires: '2026-01-01' })).json().code, 'coupon.already_expired');++ });++++ await check('a shop with no active codes is served but noindex (thin), and leaves the merchant sitemap', async () => {++ const empty = await t.merchant({ name: 'Quiet Shop', host: 'quiet-shop.com' });++ const page = await t.get(`/m/${empty.slug}`);++ assert.strictEqual(page.status, 200);++ assert.match(page.text, /No active codes for Quiet Shop right now/);++ assert.match(page.text, /<meta name="robots" content="noindex, follow">/);++ assert.doesNotMatch((await t.get('/sitemaps/merchants.xml')).text, /quiet-shop/);++ assert.match((await t.get('/sitemaps/merchants.xml')).text, new RegExp(`/m/${shop.slug}<`));++ });++++ await check('members\' codes for a shop nobody lists wait for staff; approving the shop publishes them', async () => {++ const r = await t.submit(bob, { url: 'https://www.new-gadgets.net/cart', code: 'GADGET5', title: 'Five off gadgets' });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.json().merchant.status, 'pending');++ assert.strictEqual(r.json().review_state, 'pending');++ assert.strictEqual((await t.get('/api/v1/merchants/resolve?host=new-gadgets.net')).status, 404, 'pending shops do not resolve');++ assert.strictEqual((await t.get(`/m/${r.json().merchant.slug}`)).status, 404);++ const staffPage = await t.get('/staff', { as: t.staff });++ assert.match(staffPage.text, /new-gadgets\.net/);++ const ok = await t.get(`/staff/merchants/${r.json().merchant.id}/status`, { as: t.staff, form: { csrf: t.csrf(t.staff), status: 'active' } });++ assert.strictEqual(ok.status, 200);++ const list = (await t.get(`/api/v1/merchants/${r.json().merchant.id}/coupons`)).json().coupons;++ assert.deepStrictEqual(list.map((c) => [c.code, c.status]), [['GADGET5', 'unknown']]);++ assert.strictEqual((await t.get('/api/v1/merchants/resolve?host=shop.new-gadgets.net')).status, 200);++ });++++ await check('duplicates add evidence to the same code; a taken-down code cannot be resubmitted', async () => {++ const a = await t.submit(bob, { host: 'acme-outdoor.com', code: 'trail15', title: 'Same code, other case', evidence_url: 'https://forum.example-deals.org/t/1' });++ assert.strictEqual(a.status, 200);++ assert.strictEqual(a.json().duplicate, true);++ assert.strictEqual(a.json().coupon.id, unknownCode.id);++ assert.ok(a.json().coupon.evidence.some((e) => e.url === 'https://forum.example-deals.org/t/1'));++ await t.get(`/api/v1/coupons/${unknownCode.id}/status`, { as: t.staffToken(), json: { status: 'disabled' } });++ assert.strictEqual((await t.submit(bob, { host: 'acme-outdoor.com', code: 'TRAIL15', title: 'Again' })).json().code, 'coupon.disabled');++ });++++ await check('every event is a valid envelope from the service, and none names a person', async () => {++ const all = t.events();++ assert.ok(all.length > 10);++ for (const e of all) {++ const v = contracts.validate('events.event-envelope@1', e);++ assert.ok(v.valid, `${e.event_type}: ${JSON.stringify(v.errors)}`);++ assert.deepStrictEqual(e.actor, { type: 'service', id: 'coupons' });++ assert.doesNotMatch(JSON.stringify(e), /usr_[0-9A-Z]{26}/, `${e.event_type} names a person`);++ if (e.event_type === 'coupons.index_document.upserted') {++ const d = contracts.validate('search.index-document@1', e.payload);++ assert.ok(d.valid, JSON.stringify(d.errors));++ }++ }++ const types = new Set(all.map((e) => e.event_type));++ for (const want of ['coupons.coupon.created', 'coupons.coupon.updated', 'coupons.coupon.expired', 'coupons.coupon.disabled', 'coupons.report.created', 'coupons.confidence.changed']) assert.ok(types.has(want), want);++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,136 @@++'use strict';++/**++ * Useful without JavaScript (every flow is a plain form), discoverable (robots, llms.txt, sitemaps,++ * feeds, JSON twins, JSON-LD from real fields only), cache-safe, and honest about readiness.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ const alice = t.network.addUser('alice');++ const shop = await t.merchant({ name: 'Green Grocer', host: 'greengrocer.co.uk' });++++ let codeId;++ await check('submit a code with the plain form (no JavaScript): listed at once as unknown', async () => {++ const anon = await t.get('/submit');++ assert.match(anon.text, /Sign in with OpenVibe/);++ const page = await t.get(`/submit?merchant=${shop.slug}`, { as: alice });++ assert.match(page.text, /<form method="post" action="\/submit"/);++ assert.match(page.text, /Shop: <strong>Green Grocer<\/strong>/);++ assert.doesNotMatch(page.text, /<script src="\/[^"]+\.js"/, 'no page script of our own');++ const r = await t.get('/submit', { as: alice, form: { csrf: t.csrf(alice), merchant: shop.slug, code: 'VEG5', title: 'Five pounds off veg boxes', expires: '', min_spend_amount: '30', min_spend_currency: 'gbp', regions: 'GB' } });++ assert.strictEqual(r.status, 201, r.text.slice(0, 300));++ assert.match(r.text, /your code is listed/);++ codeId = t.ctx.store.db.prepare("SELECT id FROM coupons WHERE code = 'VEG5'").get().id;++ const m = await t.get(`/m/${shop.slug}`);++ assert.match(m.text, /VEG5/);++ assert.match(m.text, /Minimum spend £30\.00/);++ assert.match(m.text, /United Kingdom \(GB\)/);++ const bad = await t.get('/submit', { as: alice, form: { csrf: t.csrf(alice), merchant: shop.slug, code: 'has space', title: 'x' } });++ assert.strictEqual(bad.status, 422);++ assert.match(bad.text, /Please fix/);++ assert.match(bad.text, /value="x"/, 'the form keeps what was typed');++ assert.strictEqual((await t.get('/submit', { as: alice, form: { merchant: shop.slug, code: 'A1', title: 'abc' } })).status, 403, 'no form token');++ });++++ await check('report and watch with plain forms; the watch list is private', async () => {++ const r = await t.get(`/c/${codeId}/report`, { as: alice, form: { csrf: t.csrf(alice), outcome: 'worked', back: `/m/${shop.slug}` } });++ assert.strictEqual(r.status, 303);++ assert.strictEqual(r.headers.get('location'), `/m/${shop.slug}?done=reported`);++ const after = await t.get(`/m/${shop.slug}?done=reported`, { as: alice });++ assert.match(after.text, /your report was recorded/);++ assert.match(after.text, /Reported working/);++ assert.match(after.text, /Confidence 67%/);++ const offsite = await t.get(`/c/${codeId}/report`, { as: alice, form: { csrf: t.csrf(alice), outcome: 'worked', back: 'https://evil.example-shop.com/' } });++ assert.strictEqual(offsite.headers.get('location'), `/c/${codeId}?done=report_same`, 'no open redirect');++ const w = await t.get(`/m/${shop.slug}/watch`, { as: alice, form: { csrf: t.csrf(alice), action: 'watch' } });++ assert.strictEqual(w.status, 303);++ assert.match((await t.get('/watching', { as: alice })).text, /Green Grocer/);++ assert.strictEqual((await t.get('/watching')).status, 303, 'anonymous → sign in');++ await t.get(`/m/${shop.slug}/watch`, { as: alice, form: { csrf: t.csrf(alice), action: 'unwatch' } });++ assert.doesNotMatch((await t.get('/watching', { as: alice })).text, /Green Grocer/);++ });++++ await check('caching: anonymous pages are public for 60 s, signed-in pages are private; both vary on Cookie', async () => {++ const anon = await t.get(`/m/${shop.slug}`);++ assert.match(anon.headers.get('cache-control'), /^public, max-age=60/);++ assert.match(anon.headers.get('vary'), /Cookie/);++ const signed = await t.get(`/m/${shop.slug}`, { as: alice });++ assert.strictEqual(signed.headers.get('cache-control'), 'private, no-store');++ assert.match(signed.text, /name="csrf"/);++ assert.doesNotMatch(anon.text, /name="csrf"/, 'no form tokens in a shared cache');++ });++++ await check('JSON-LD only from real fields (breadcrumbs, web page); the JSON twin has the page\'s content', async () => {++ const page = (await t.get(`/c/${codeId}`)).text;++ const blocks = [...page.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));++ assert.deepStrictEqual(blocks.map((b) => b['@type']).sort(), ['BreadcrumbList', 'WebPage']);++ const json = (await t.get(`/c/${codeId}.json`)).json();++ assert.strictEqual(json.coupon.code, 'VEG5');++ assert.strictEqual(json.indexability.indexable, true);++ const mj = (await t.get(`/m/${shop.slug}.json`)).json();++ assert.deepStrictEqual(mj.coupons.map((c) => c.code), ['VEG5']);++ });++++ await check('robots.txt, llms.txt, the sitemap index and sections, and the feeds', async () => {++ const robots = await t.get('/robots.txt');++ assert.match(robots.text, /Sitemap: https:\/\/openvibe\.coupons\/sitemap\.xml/);++ assert.match(robots.text, /Disallow: \/api\//);++ assert.match(robots.text, /automated-consumer policy/);++ const llms = await t.get('/llms.txt');++ assert.match(llms.text, /reported_working/);++ assert.match(llms.text, /\/m\/<slug>\.json/);++ assert.match((await t.get('/sitemap.xml')).text, /sitemaps\/coupons\.xml[\s\S]*sitemaps\/merchants\.xml|sitemaps\/merchants\.xml[\s\S]*sitemaps\/coupons\.xml/);++ assert.match((await t.get('/sitemaps/coupons.xml')).text, new RegExp(`https://openvibe\\.coupons/c/${codeId}`));++ assert.match((await t.get('/sitemaps/merchants.xml')).text, new RegExp(`https://openvibe\\.coupons/m/${shop.slug}`));++ const rss = await t.get('/feed.xml');++ assert.match(rss.headers.get('content-type'), /rss/);++ assert.match(rss.text, /VEG5/);++ assert.match(rss.text, /Expiry unknown/);++ assert.match((await t.get('/atom.xml')).text, /<entry>/);++ assert.strictEqual((await t.get('/feed.json')).json().items.length, 1);++ assert.match((await t.get(`/m/${shop.slug}/feed.xml`)).text, /VEG5/);++ });++++ await check('home, search and the about page (with the formula) render without JavaScript', async () => {++ const home = await t.get('/');++ assert.match(home.text, /Green Grocer/);++ assert.match(home.text, /1 active code</);++ const s = await t.get('/?q=grocer');++ assert.match(s.text, /Shops matching “grocer”/);++ assert.match(s.text, /<meta name="robots" content="noindex, nofollow">/, 'search results are not indexed');++ const about = await t.get('/about');++ assert.match(about.text, /confidence = \(1 \+ e \+ W\) \/ \(2 \+ e \+ W \+ F\)/);++ assert.match(about.text, /reads only the hostname/);++ });++++ await check('health, readiness, release, 404s', async () => {++ assert.strictEqual((await t.get('/api/health')).json().service, 'openvibe-coupons');++ const ready = await t.get('/api/ready');++ assert.strictEqual(ready.status, 200);++ const body = ready.json();++ assert.match(JSON.stringify(body), /relay off/);++ assert.match(JSON.stringify(body), /worker off/);++ assert.strictEqual((await t.get('/release.json')).status, 200);++ assert.strictEqual((await t.get('/nope')).status, 404);++ assert.strictEqual((await t.get('/m/nope')).status, 404);++ assert.strictEqual((await t.get('/c/cpn_00000000000000000000000000')).status, 404);++ const api404 = await t.get('/api/v1/nope');++ assert.strictEqual(api404.status, 404);++ assert.match(api404.headers.get('content-type'), /problem\+json/);++ });++++ await check('staff pages are for staff only', async () => {++ assert.strictEqual((await t.get('/staff')).status, 303);++ assert.strictEqual((await t.get('/staff', { as: alice })).status, 403);++ assert.strictEqual((await t.get('/staff', { as: t.staff })).status, 200);++ const add = await t.get('/staff/merchants', { as: t.staff, form: { csrf: t.csrf(t.staff), name: 'Cycle Hub', host: 'cyclehub.de', include_subdomains: '1' } });++ assert.match(add.text, /Added Cycle Hub/);++ const noCsrf = await t.get('/staff/merchants', { as: t.staff, form: { name: 'X', host: 'x-shop.de' } });++ assert.strictEqual(noCsrf.status, 403);++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,130 @@++'use strict';++/**++ * Validity reports: deduplicated per (person, code, UTC day) across every channel, only the latest++ * per person counts, rate-limited per person, never anonymous, never from a cookie on the API,++ * never returned or emitted with the reporter, and decaying back to unknown with time.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++const HOUR = 3600 * 1000;++const DAY = 24 * HOUR;++++(async () => {++ const t = await boot({ env: { COUPONS_REPORTS_PER_HOUR: '5', COUPONS_REPORTS_PER_DAY: '8' } });++ const alice = t.network.addUser('alice');++ const bob = t.network.addUser('bob');++ const carol = t.network.addUser('carol');++ const shop = await t.merchant({ name: 'Blue Kettle', host: 'bluekettle.shop' });++ const mk = async (code) => (await t.submit(alice, { host: 'bluekettle.shop', code, title: `Code ${code}` })).json().coupon;++ const code = await mk('KETTLE10');++ const report = (who, id, body) => t.get(`/api/v1/coupons/${id}/report`, { as: typeof who === 'string' ? who : t.network.userToken(who), json: body });++++ await check('anonymous reports are refused, and the API ignores cookies (no ambient session)', async () => {++ assert.strictEqual((await t.get(`/api/v1/coupons/${code.id}/report`, { json: { outcome: 'worked' } })).status, 401);++ const cookieOnly = await t.get(`/api/v1/coupons/${code.id}/report`, { as: bob, json: { outcome: 'worked' } });++ assert.strictEqual(cookieOnly.status, 401, 'a signed-in cookie does not authenticate the API');++ assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports').get().n, 0);++ });++++ await check('input is validated: outcome worked|failed, reasons only for failed ones', async () => {++ assert.strictEqual((await report(bob, code.id, { outcome: 'great' })).status, 422);++ assert.strictEqual((await report(bob, code.id, { outcome: 'worked', reason: 'invalid' })).status, 422);++ assert.strictEqual((await report(bob, code.id, { outcome: 'failed', reason: 'because' })).status, 422);++ assert.strictEqual((await report(bob, 'cpn_nope', { outcome: 'worked' })).status, 404);++ });++++ await check('same person, same code, same day: one report — a repeat is a no-op, the other outcome corrects it', async () => {++ const a = await report(bob, code.id, { outcome: 'worked' });++ assert.strictEqual(a.status, 201);++ assert.strictEqual(a.json().deduplicated, false);++ const b = await report(bob, code.id, { outcome: 'worked' });++ assert.strictEqual(b.status, 200);++ assert.strictEqual(b.json().deduplicated, true);++ assert.strictEqual(b.json().corrected, false);++ // Through the site form and the extension too — the same person is one reporter.++ const form = await t.get(`/c/${code.id}/report`, { as: bob, form: { csrf: t.csrf(bob), outcome: 'worked', back: `/m/${shop.slug}` } });++ assert.strictEqual(form.status, 303);++ assert.match(form.headers.get('location'), /done=report_same/);++ const tokenA = await t.connect(bob, { label: 'laptop' });++ const tokenB = await t.connect(bob, { label: 'phone' });++ assert.strictEqual((await report(tokenA, code.id, { outcome: 'worked' })).json().deduplicated, true);++ assert.strictEqual((await report(tokenB, code.id, { outcome: 'worked' })).json().deduplicated, true);++ assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports').get().n, 1);++ const c = await report(bob, code.id, { outcome: 'failed', reason: 'min_spend_not_met' });++ assert.strictEqual(c.json().corrected, true);++ assert.strictEqual(c.json().coupon.status, 'reported_failed');++ assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports').get().n, 1);++ assert.strictEqual(t.events('coupons.report.created').length, 1, 'one report, one event');++ });++++ await check('the next day is a new report, but only the latest per person counts', async () => {++ t.clock.advance(DAY);++ const r = await report(bob, code.id, { outcome: 'worked' });++ assert.strictEqual(r.status, 201);++ const v = r.json().coupon;++ assert.deepStrictEqual([v.reports.worked, v.reports.failed], [1, 0], 'bob counts once, with his latest');++ assert.strictEqual(v.status, 'reported_working');++ assert.strictEqual(v.confidence, 0.67);++ await report(carol, code.id, { outcome: 'failed', reason: 'invalid' });++ const after = (await t.get(`/api/v1/coupons/${code.id}`)).json().coupon;++ assert.strictEqual(after.status, 'unknown', 'one worked and one failed: the reports disagree');++ assert.strictEqual(after.confidence, 0.5);++ });++++ await check('the response and the events never name a reporter; report times are published to the hour', async () => {++ const r = await report(alice, code.id, { outcome: 'worked' });++ const body = r.text;++ assert.doesNotMatch(body, /usr_[0-9A-Z]{26}/);++ assert.doesNotMatch(body, /reporter|install_id|cpi_/);++ const last = r.json().coupon.reports.last_report_at;++ assert.match(last, /T\d{2}:00:00\.000Z$/);++ for (const e of t.events('coupons.report.created')) {++ assert.deepStrictEqual(Object.keys(e.payload).sort(), ['channel', 'day', 'merchant_id', 'outcome', ...(e.payload.reason ? ['reason'] : [])].sort());++ assert.deepStrictEqual(e.actor, { type: 'service', id: 'coupons' });++ }++ const keys = t.ctx.store.db.prepare('SELECT reporter_key FROM coupon_validation_reports').all().map((x) => x.reporter_key);++ for (const k of keys) { assert.match(k, /^[0-9a-f]{64}$/); assert.ok(!k.includes('usr_')); }++ });++++ await check('rate limit: new reports per person per hour and per day (dedupe hits do not count)', async () => {++ const codes = [];++ for (let i = 0; i < 8; i++) codes.push(await mk(`RL${i}`));++ t.clock.advance(2 * HOUR);++ const dave = t.network.addUser('dave');++ for (let i = 0; i < 5; i++) assert.strictEqual((await report(dave, codes[i].id, { outcome: 'worked' })).status, 201, `report ${i}`);++ assert.strictEqual((await report(dave, codes[0].id, { outcome: 'worked' })).status, 200, 'a dedupe hit is not a new report');++ const limited = await report(dave, codes[5].id, { outcome: 'worked' });++ assert.strictEqual(limited.status, 429);++ assert.strictEqual(limited.json().code, 'report.rate_limited');++ t.clock.advance(HOUR + 1);++ for (let i = 5; i < 8; i++) assert.strictEqual((await report(dave, codes[i].id, { outcome: 'worked' })).status, 201);++ const daily = await report(dave, code.id, { outcome: 'worked' });++ assert.strictEqual(daily.status, 429, 'eight in a day is the daily ceiling here');++ // Another person is unaffected.++ assert.strictEqual((await report(t.network.addUser('erin'), codes[5].id, { outcome: 'worked' })).status, 201);++ });++++ await check('decay: a lone "worked" is reported_working for a week, unknown after, and no confidence after 30 days', async () => {++ const fresh = await mk('DECAY1');++ const frank = t.network.addUser('frank');++ await report(frank, fresh.id, { outcome: 'worked' });++ assert.strictEqual(t.ctx.coupons.get(fresh.id).status, 'reported_working');++ t.clock.advance(6 * DAY);++ t.ctx.worker.sweep();++ assert.strictEqual(t.ctx.coupons.get(fresh.id).status, 'reported_working');++ t.clock.advance(2 * DAY);++ t.ctx.worker.sweep();++ assert.strictEqual(t.ctx.coupons.get(fresh.id).status, 'unknown');++ assert.ok(t.ctx.coupons.get(fresh.id).confidence > 0.5);++ t.clock.advance(23 * DAY);++ t.ctx.worker.sweep();++ assert.strictEqual(t.ctx.coupons.get(fresh.id).confidence, null);++ const hist = t.ctx.coupons.history(fresh.id).map((h) => h.reason);++ assert.ok(hist.includes('decay'));++ assert.ok(t.events('coupons.confidence.changed').some((e) => e.subject.id === fresh.id && e.payload.to === null));++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,44 @@++#!/usr/bin/env node++/**++ * Runs every test/*.test.js in its own process and fails if any fails. They use temp SQLite++ * databases and in-process mocks of OpenVibe.Network and OpenVibe.Sources; none needs the++ * network or a running site.++ *++ * npm test # everything++ * npm test -- schedule # only files whose name contains one of the words++ */++'use strict';++const { spawn } = require('child_process');++const fs = require('fs');++const path = require('path');++++const filters = process.argv.slice(2).filter((a) => !a.startsWith('-'));++const files = fs.readdirSync(__dirname).filter((f) => f.endsWith('.test.js')).filter((f) => !filters.length || filters.some((w) => f.includes(w))).sort();++const TIMEOUT_MS = 60000;++++function runOne(file) {++ return new Promise((resolve) => {++ const started = Date.now();++ const child = spawn(process.execPath, [path.join(__dirname, file)], { cwd: path.join(__dirname, '..'), env: { ...process.env, NODE_ENV: 'test' }, stdio: ['ignore', 'pipe', 'pipe'] });++ let output = '';++ child.stdout.on('data', (c) => { output += c; });++ child.stderr.on('data', (c) => { output += c; });++ const timer = setTimeout(() => { output += `\n[run] timed out after ${TIMEOUT_MS}ms`; child.kill('SIGKILL'); }, TIMEOUT_MS);++ child.on('close', (code, signal) => { clearTimeout(timer); resolve({ file, ok: code === 0, code: code ?? signal, ms: Date.now() - started, output }); });++ });++}++++(async () => {++ if (!files.length) { console.error('no test files matched'); process.exit(1); }++ const results = [];++ for (const f of files) {++ const r = await runOne(f);++ results.push(r);++ console.log(`${r.ok ? '✓' : '✗'} ${r.file.padEnd(34)} ${String(r.ms).padStart(6)}ms`);++ if (process.env.VERBOSE) console.log(r.output);++ }++ const failed = results.filter((r) => !r.ok);++ for (const r of failed) { console.log(`\n── ${r.file} (exit ${r.code}) ──`); console.log(r.output.split('\n').slice(-80).join('\n')); }++ console.log(`\n${results.length - failed.length}/${results.length} test files passed`);++ process.exit(failed.length ? 1 : 0);++})();nnew file mode 100644@@@ -0,0 +1,111 @@++'use strict';++/**++ * Import from OpenVibe.Sources (category coupons): items become codes with evidence and status++ * unknown, wait for staff review by default, anything that cannot be imported is HELD with a++ * reason (never dropped), no_merchant holds resolve once staff add the shop, a removed item++ * withdraws its evidence, and a failed fetch changes nothing.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ const shop = await t.merchant({ name: 'River Books', host: 'riverbooks.com' });++ const item = (id, extra = {}) => ({++ id, source_key: 'staff-coupon-codes', identity: `https://riverbooks.com/promo#${id}`, canonical_url: 'https://riverbooks.com/promo',++ title: 'Autumn reading sale', fields: { code: 'READ20', expires: null }, ...extra,++ });++++ await check('an item becomes a code: status unknown, expiry unknown, evidence with the Sources item and its retrieval time, pending review', async () => {++ t.sources.put(item('itm_01K0000000000000000000000A'));++ const r = await t.ctx.importer.run();++ assert.deepStrictEqual(r.outcomes, { imported: 1 });++ const c = t.ctx.store.db.prepare("SELECT * FROM coupons WHERE code = 'READ20'").get();++ assert.strictEqual(c.status, 'unknown');++ assert.strictEqual(c.confidence, null);++ assert.strictEqual(c.expires_at, null);++ assert.strictEqual(c.origin, 'source');++ assert.strictEqual(c.review_state, 'pending');++ const v = t.ctx.coupons.view(c);++ assert.deepStrictEqual(v.evidence, [{ kind: 'source', url: 'https://riverbooks.com/promo', merchant_page: true, sources_item: 'itm_01K0000000000000000000000A', retrieved_at: '2026-09-22T10:00:00.000Z' }]);++ assert.strictEqual((await t.get(`/api/v1/merchants/${shop.id}/coupons`)).json().coupons.length, 0, 'not listed before review');++ assert.strictEqual(t.sources.calls.length, 1);++ assert.match(t.network.grants.find((g) => g.audience === 'openvibe.sources').scope, /sources\.item\.read/);++ });++++ await check('the cursor advances: a second run fetches from where the first stopped and imports nothing new', async () => {++ const r = await t.ctx.importer.run();++ assert.deepStrictEqual(r.outcomes, {});++ assert.match(t.sources.calls[t.sources.calls.length - 1], /after=1/);++ });++++ await check('items that cannot become a code are held with a reason, and listed for staff', async () => {++ t.sources.put(item('itm_01K0000000000000000000000B', { canonical_url: 'https://unknown-shop.com/x', fields: { code: 'NOPE' } }));++ t.sources.put(item('itm_01K0000000000000000000000C', { kind: 'record' }));++ t.sources.put(item('itm_01K0000000000000000000000D', { fields: { expires: '2026-12-01' } }));++ t.sources.put(item('itm_01K0000000000000000000000E', { fields: { code: 'OLD', expires: '2026-01-01' } }));++ const r = await t.ctx.importer.run();++ assert.deepStrictEqual(r.outcomes, { 'held:no_merchant': 1, 'held:not_a_coupon': 1, 'held:no_code': 1, 'held:expired': 1, 'retry:held:no_merchant': 1 });++ const holds = t.ctx.importer.holds().map((h) => h.reason).sort();++ assert.deepStrictEqual(holds, ['expired', 'no_code', 'no_merchant', 'not_a_coupon']);++ const staff = await t.get('/staff', { as: t.staff });++ assert.match(staff.text, /itm_01K0000000000000000000000B from staff-coupon-codes: no_merchant/);++ });++++ await check('a no_merchant hold is imported once staff add the shop', async () => {++ await t.merchant({ name: 'Unknown Shop', host: 'unknown-shop.com' });++ const r = await t.ctx.importer.run();++ assert.deepStrictEqual(r.outcomes, { 'retry:imported': 1 });++ assert.ok(t.ctx.store.db.prepare("SELECT 1 FROM coupons WHERE code = 'NOPE'").get());++ assert.ok(!t.ctx.importer.holds().some((h) => h.reason === 'no_merchant'));++ });++++ await check('a stated expiry is kept with basis "source"; a new revision of the same item updates the evidence, not a second code', async () => {++ t.sources.put(item('itm_01K0000000000000000000000A', { revision: 2, fields: { code: 'READ20', expires: '2026-12-31', min_spend: 25, currency: 'EUR' } }));++ await t.ctx.importer.run();++ const rows = t.ctx.store.db.prepare("SELECT * FROM coupons WHERE code = 'READ20'").all();++ assert.strictEqual(rows.length, 1);++ assert.strictEqual(rows[0].expiry_basis, 'source');++ assert.strictEqual(new Date(rows[0].expires_at).toISOString(), '2026-12-31T23:59:59.999Z');++ const src = t.ctx.store.db.prepare('SELECT * FROM coupon_sources WHERE coupon_id = ?').all(rows[0].id);++ assert.strictEqual(src.length, 1);++ assert.strictEqual(src[0].sources_item_rev, 2);++ });++++ await check('with COUPONS_SOURCES_AUTO_PUBLISH=true imported codes are listed at once — still unknown', async () => {++ const t2 = await boot({ env: { COUPONS_SOURCES_AUTO_PUBLISH: 'true' } });++ const s2 = await t2.merchant({ name: 'River Books', host: 'riverbooks.com' });++ t2.sources.put(item('itm_01K0000000000000000000000F'));++ await t2.ctx.importer.run();++ const list = (await t2.get(`/api/v1/merchants/${s2.id}/coupons`)).json().coupons;++ assert.deepStrictEqual(list.map((c) => [c.code, c.status, c.confidence]), [['READ20', 'unknown', null]]);++ await t2.close();++ });++++ await check('a removed item withdraws its evidence; a code with no evidence left is taken down', async () => {++ const id = t.ctx.store.db.prepare("SELECT id FROM coupons WHERE code = 'READ20'").get().id;++ t.sources.put(item('itm_01K0000000000000000000000A', { revision: 3, removed: { at: '2026-09-23T00:00:00.000Z', reason: 'merchant asked' } }));++ const r = await t.ctx.importer.run();++ assert.strictEqual(r.outcomes.withdrawn, 1);++ const c = t.ctx.coupons.get(id);++ assert.strictEqual(c.status, 'disabled');++ assert.strictEqual(c.status_reason, 'source_removed');++ });++++ await check('a failed fetch changes nothing and is recorded', async () => {++ const before = t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupons').get().n;++ const cursor = t.ctx.importer.state().cursor;++ t.sources.setDown(true);++ const r = await t.ctx.importer.run();++ assert.match(r.error, /503/);++ assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupons').get().n, before);++ assert.strictEqual(t.ctx.importer.state().cursor, cursor);++ assert.match(t.ctx.importer.state().last_error, /503/);++ const ready = (await t.get('/api/ready')).json();++ assert.match(JSON.stringify(ready), /last run failed/);++ t.sources.setDown(false);++ });++++ await t.close();++ done();++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,73 @@
@@ -0,0 +1,73 @@
+# ═══════════════════════════════════════════════════════════════
+# openvibe.coupons — environment configuration (names only; never commit values)
+# Copy to .env for development. Production: /etc/openvibe/coupons.env (0600).
+# ═══════════════════════════════════════════════════════════════
+
+# Server
+PORT=4850
+HOST=127.0.0.1
+NODE_ENV=development
+# Public origin: canonical URLs, feeds, sitemaps and JSON-LD are built from it
+BASE_URL=http://localhost:4850
+# Proxy hops that set X-Forwarded-For (Cloudflare → nginx → Node = 2). Per-IP rate limits use it.
+TRUST_PROXY=2
+
+# SQLite database (production unit: /var/lib/openvibe-coupons/coupons.db)
+COUPONS_DB_PATH=./data/coupons.db
+
+# OpenVibe.Network — SSO (OAuth2), JWKS, client-credentials tokens
+OV_NETWORK_URL=https://openvibe.network
+OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
+# OAuth client `coupons` (redirect https://openvibe.coupons/auth/callback). The same client is the
+# service principal svc:coupons for client-credentials grants (see README, "Grants").
+OV_OAUTH_CLIENT_ID=coupons
+OV_OAUTH_CLIENT_SECRET=
+# Production: https://openvibe.coupons/auth/callback (default: BASE_URL + /auth/callback)
+#OV_OAUTH_REDIRECT_URI=
+# Cookies: Secure by default in production; set false for plain-http local development
+#COOKIE_SECURE=false
+# Signs the form tokens (CSRF). Unset: random per process.
+#COUPONS_FORM_SECRET=
+# Keys the reporter column (HMAC of the person). Set it in production; changing it restarts
+# same-day deduplication. Unset: a development key (and /api/ready says so).
+#COUPONS_REPORTER_KEY_SECRET=
+
+# Staff (review, moderation): Network admins, plus these subjects (usr_…, comma-separated)
+#COUPONS_STAFF_SUBJECTS=
+
+# Browser helper: extension origins allowed by CORS on the two lookup routes only.
+# Exact origins (chrome-extension://<id>) or the literal moz-extension://* (Firefox install origins
+# are random). Empty: no CORS; the helper still works through its host permission.
+#COUPONS_EXTENSION_ORIGINS=
+# Extension install tokens expire after this many days
+#COUPONS_INSTALL_TTL_DAYS=365
+
+# Rate limits
+#COUPONS_LOOKUP_ANON_PER_MIN=30
+#COUPONS_LOOKUP_TOKEN_PER_MIN=120
+#COUPONS_LOOKUP_SERVICE_PER_MIN=600
+#COUPONS_REPORTS_PER_HOUR=20
+#COUPONS_REPORTS_PER_DAY=60
+#COUPONS_SUBMISSIONS_PER_HOUR=10
+#COUPONS_SUBMISSIONS_PER_DAY=30
+#COUPONS_INSTALLS_PER_SUBJECT=10
+
+# OpenVibe.Sources — import the `coupons` category (runs when this and OV_OAUTH_CLIENT_SECRET are set)
+#OV_SOURCES_INTERNAL_URL=http://127.0.0.1:4720
+# Only these source keys (comma-separated); empty: every coupons source. Others are held as not_selected.
+#COUPONS_SOURCES_KEYS=staff-coupon-codes
+#COUPONS_SOURCES_INTERVAL_MS=600000
+# true: imported codes are listed without staff review (still status unknown)
+#COUPONS_SOURCES_AUTO_PUBLISH=false
+
+# OpenVibe.Events — the outbox relay runs only when EVENTS_URL and OV_OAUTH_CLIENT_SECRET are set
+#EVENTS_URL=http://127.0.0.1:4300
+#EVENTS_RELAY_INTERVAL_MS=2000
+
+# Expiry / confidence-decay sweep (in-process); off: COUPONS_WORKER=off
+#COUPONS_WORKER=on
+#COUPONS_SWEEP_INTERVAL_MS=60000
+
+# release.json (openvibe-shared/release): the deployed commit when git is not available
+#RELEASE_COMMIT=
+#MIN_CLIENT_RELEASE=
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+name: ci
+on:
+ push:
+ branches: [main]
+ pull_request:
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22.22.1 # production runs Node 22
+ cache: npm
+ - run: npm ci --no-audit --no-fund
+ - run: npm test
+ # Coupons' service manifest and capabilities are proposals (docs/) until the lead releases them
+ # in openvibe-contracts; until then the check reports the placeholder manifest's range.
+ - run: npx openvibe-contracts-check --service coupons --src server
+ continue-on-error: true
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+node_modules/
+data/
+dist/
+.env
+.env.*
+!.env.example
+*.db
+*.db-wal
+*.db-shm
+*.log
+*.pem
+*.key
@@ -2,76 +2,424 @@
@@ -2,76 +2,424 @@
> Coupon codes with merchant matching, restrictions, expiry and real-people validity reports.
> Coupon codes with merchant matching, restrictions, expiry and real-people validity reports.
-**Status:** placeholder — planning only, no runnable code yet.
-**Domain:** `openvibe.coupons`
-**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §12.10.
+**Status:** alpha (roadmap Wave 18, Coupons part). The service runs and its tests pass. It is **not
+deployed**, `openvibe.coupons` still shows its placeholder from OpenVibe.Sites, it holds **no codes**
+(nothing is seeded), and its capabilities and service manifest are proposals that the next
+openvibe-contracts release has to include.
+**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).
**License:** AGPL-3.0 (same as every OpenVibe service).
## Purpose
## Purpose
-Merchants, normalised domains, codes, restrictions, validity reports, expiry/confidence and the API a future browser helper will use. Distinct from Deals even where they cross-link.
+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](https://github.com/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" (`null` in 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
## Owns
-- `coupon_merchants`, `coupon_merchant_domains`, `coupons`, `coupon_sources`, `coupon_restrictions`, `coupon_validation_reports`, `coupon_status_history`, `coupon_application_hints`, `coupon_watches`
-- status model `unknown|reported_working|reported_failed|expired|disabled`
+The nine charter tables live in Coupons' own SQLite (`COUPONS_DB_PATH`):
+
+| 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_state` and `coupon_import_holds`: the Sources importer.
+- `event_outbox`: the SDK transactional outbox.
+- `coupons_index_revisions`: the openvibe-publishing index sequencer.
+
+Nothing is seeded, and no code, merchant or report is invented for a demo.
## Does not own
## Does not own
-- deal offers (Deals)
-- discussion (Community)
+- **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 `coupons` category.
+- **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:**
+ - `disabled` if staff or a service took the code down.
+ - Otherwise `expired` if a known expiry has passed, or staff recorded that it ended.
+ - Otherwise `unknown` if W + F < 0.5.
+ - Otherwise `reported_working` if confidence ≥ 0.6, `reported_failed` if confidence ≤ 0.4, and
+ `unknown` in 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 still `unknown` for 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 > now` in 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
+ records `expired` at the stated instant, with history, `coupons.coupon.expired` and a Search
+ tombstone. Running it again changes nothing.
+- **Nobody sets working or failed:**
+ - A submission carrying `status`, `confidence`, `verified`, `working`, `valid` or `validity` is
+ refused (422 `coupon.status_not_accepted`). That applies to people, services and AI output
+ alike.
+ - Staff and services with `coupons.status.update` can 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: ai` are refused.
+ - AI-extracted codes (`coupons.extract_coupon` output) are held for staff review. They start
+ `unknown` and carry an on-page disclosure.
+
+## 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, `localhost` and 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 as `myshopify.com`, `github.io` and `netlify.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.
+- **Rules:**
+ - A rule names a host at or below a registrable domain. A rule on a public suffix is refused.
+ - `include_subdomains` covers every subdomain, but never across the looked-up host's registrable
+ domain.
+ - A `path_prefix` matches 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 |
+|---|---|
+| `/` | Shops with their active-code counts, recently added codes, and search (`?q=`, 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. |
+| `/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. |
+
+### Discoverability (roadmap §32)
-## Planned surfaces
+- Every page gets its robots meta, canonical and `X-Robots-Tag` from the `openvibe-publishing/seo`
+ gate:
+ - 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.
+- JSON-LD is `BreadcrumbList` and `WebPage` only, built from real fields. There is no `Offer`,
+ `validThrough` or 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.
-- ingest/submit, merchant/domain normalisation, restriction parsing, community validity reports with abuse controls, confidence from evidence only, expiry cleanup
-- `GET /api/v1/merchants/resolve?host=`, `GET /api/v1/merchants/:id/coupons`, `POST /api/v1/coupons/:id/report`, `POST /api/v1/coupons/submit`; scoped extension credentials
+### Caching
-## Data (authority tables / families)
+- Pages rendered for anonymous visitors are `public, max-age=60`. Signed-in pages (which carry
+ form tokens) are `private, 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 is `private, no-store`.
-- see above
+## API (`/api/v1`)
-## Capabilities and events
+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.
-- `coupons.submit`, `coupons.report`, `coupons.merchant.resolve`, `coupons.lookup`, `coupons.status.update`
+| 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` |
+| `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` |
-Events: ``coupons.created|updated|expired|disabled``, ``coupons.report.created``, ``coupons.confidence.changed``
+- **CORS:**
+ - Only the two lookup routes send CORS headers, and only to origins in
+ `COUPONS_EXTENSION_ORIGINS`. Those are exact `chrome-extension://<id>` origins, and optionally
+ the literal `moz-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.
+- **Rate limits:**
+ - Lookups: `COUPONS_LOOKUP_ANON_PER_MIN` (30) per IP, `COUPONS_LOOKUP_TOKEN_PER_MIN` (120) per
+ install, and `COUPONS_LOOKUP_SERVICE_PER_MIN` (600) per service.
+ - Reports: `COUPONS_REPORTS_PER_HOUR` (20) and `COUPONS_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.
+- **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-extension` and 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) and `coupons.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 most
+ `COUPONS_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.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}`.
+
+## Capabilities (proposed: `docs/capabilities-proposal/`)
+
+Service tokens use audience `openvibe.coupons`, with one capability per route:
+
+- `coupons.coupon.submit` (the charter's `coupons.submit`)
+- `coupons.report.create` (the charter's `coupons.report`)
+- `coupons.merchant.resolve`
+- `coupons.coupon.lookup` (the charter's `coupons.lookup`)
+- `coupons.status.update`
+- `coupons.merchant.manage` (new: the staff and service merchant administration the lookups
+ depend on)
+
+Until the proposals are released, grants for these ids are decided locally with the contracts
+library's matching rule (`server/auth/capabilities.js`). Nothing changes when they are released.
+Install scopes (`coupons.lookup`, `coupons.report`) are Coupons-local and aren't capabilities. The
+service manifest proposal is `docs/service-manifest-proposal.json`.
## Depends on
## Depends on
-- source registry
-- OpenVibe.Community
-- Notifications
-- Search
-- OpenVibe.Events
+- **Packages** (all pinned by release tarball): `openvibe-publishing` v0.2.0 (seo gate, ssr,
+ index-hooks), `openvibe-contracts` v0.19.0, `openvibe-shared` v1.3.0 (chrome, app icon, footer,
+ legal, release, metrics, ready, seo), `openvibe-sdk` v0.2.2 (events outbox, service tokens).
+- **OpenVibe.Network:**
+ - SSO: an OAuth client `coupons` with redirect `https://openvibe.coupons/auth/callback`. It is
+ **not seeded yet**.
+ - JWKS.
+- **OpenVibe.Sources:** `sources.item.read`, category `coupons`. The seeded `staff-coupon-codes`
+ source is disabled until a person enables it.
+- **OpenVibe.Events:** `events.event.publish`.
+- **OpenVibe.Search:** consumes `coupons.index_document.*`. `coupons` has to be in Search's
+ `SEARCH_EVENT_OWNERS`.
+
+### Grants the Network must hold
-## Acceptance (must be true before "done")
+Each grant is `[client, capability, audience]`:
-- expired/dead codes leave active results predictably
-- reports are deduplicated and rate-limited
-- a malicious merchant page cannot obtain another user's account data
-- no code is labelled working because a model guessed it
+- `[coupons, events.event.publish, openvibe.events]`
+- `[coupons, sources.item.read, openvibe.sources]` (only if the Sources import is turned on)
+- For OpenVibe.AI to deliver `coupons.extract_coupon` drafts:
+ `[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]`
-## Bootstrap / extraction source
+## Acceptance (automated: `npm test`)
-No current implementation; Wave 16.
+| 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
+
+- **Identity:**
+ - Only verified Network JWTs (offline RS256 against JWKS), service tokens for audience
+ `openvibe.coupons`, and `cpx_` install tokens.
+ - A presented credential that fails is refused, never downgraded to anonymous.
+ - Identity never comes from a body or query.
+- **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'` and `X-Frame-Options: DENY`.
+ - The browser helper never exposes an interface to pages: it has no content scripts, no
+ `externally_connectable` and 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.
+- **XSS:** every value goes through `openvibe-publishing/ssr` auto-escaping. There's no page script
+ of Coupons' own beyond the shared navbar init, and CSP comes from helmet. Evidence links are
+ `rel="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: there's no Network notification grant 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
## Launch rule
-This repository does not make the product real, and the domain keeps its placeholder page on
-[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of the
-following exist here (plan §12.12):
-
-1. an owning runtime with health/readiness endpoints and observability;
-2. canonical identity/auth integration (OpenVibe.Network subjects, scoped service principals);
-3. server-rendered or static public routes that are useful without JavaScript;
-4. real persistence and end-to-end workflows;
-5. capability and event registration against `OpenVibe.Contracts`;
-6. a migration/seed strategy, a security/threat review, and sitemap/robots/feed behaviour;
-7. acceptance tests proving the advertised functionality.
-
-The launch release removes the domain from `OpenVibe.Sites/sites.json`, switches routing and
-registers maturity in the ecosystem registry atomically. A placeholder is never counted as an
-implemented service.
+This repository alone doesn't make the product live. `openvibe.coupons` keeps its placeholder on
+[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of plan §12.12
+holds. Status against each point:
+
+1. **Runtime, health, readiness, observability:** done.
+2. **Canonical identity and auth:** done. The OAuth client still has to be seeded in the Network.
+3. **SSR public routes useful without JS:** done.
+4. **Persistence and end-to-end workflows:** done.
+5. **Capability and event registration against OpenVibe.Contracts:** proposals are in `docs/`,
+ waiting on the release.
+6. **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 `coupons` category.
+ - The threat review and the sitemap, robots and feed behaviour are done.
+7. **Acceptance tests:** done.
+
+**The launch release does all of these in one release:**
+
+- Removes `openvibe.coupons` from `OpenVibe.Sites/sites.json`.
+- Switches routing: nginx vhost, DNS and TLS.
+- Flips the Network hub entry (`server/chrome/sites.js`, `status: 'soon'`).
+- Registers maturity in the ecosystem registry.
+
+A placeholder never counts as an implemented service, and this README doesn't call the service
+live.
+
+## Development
+
+```bash
+fnm exec --using=22.22.1 npm install
+fnm exec --using=22.22.1 npm test # temp databases 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)
+
+1. **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/callback` to the Network's seeded OAuth
+ clients.
+ - Add the grants above.
+2. **Code and config:**
+ - Put the code at `/opt/openvibe.coupons` and run `npm ci --omit=dev` on Node 22.
+ - Create `/etc/openvibe/coupons.env` (0600) from `.env.example`. Set these values:
+ - `OV_OAUTH_CLIENT_SECRET`
+ - `COUPONS_FORM_SECRET`
+ - `COUPONS_REPORTER_KEY_SECRET`
+ - `COUPONS_STAFF_SUBJECTS` (optional)
+ - `EVENTS_URL=http://127.0.0.1:4300`
+ - `BASE_URL=https://openvibe.coupons`
+ - Optionally set `OV_SOURCES_INTERNAL_URL=http://127.0.0.1:4720`.
+ - Once the extension has a store id, set `COUPONS_EXTENSION_ORIGINS`.
+3. **systemd:** install `deploy/systemd/openvibe-coupons.service` (port 4850,
+ `StateDirectory=openvibe-coupons`).
+4. **nginx:** install `deploy/nginx/openvibe.coupons.conf`. `/metrics` is never proxied.
+5. **Search:** add `coupons` to `SEARCH_EVENT_OWNERS`.
+6. **Contracts:** release the capability and manifest proposals in openvibe-contracts. Then CI's
+ contracts check can drop `continue-on-error`.
+7. **Launch:** in the same release, remove `openvibe.coupons` from OpenVibe.Sites and flip the
+ Network hub entry (see the launch rule above).
---
---
@@ -1,7 +1,33 @@
@@ -1,7 +1,33 @@
{
{
"repository": "OpenVibers/OpenVibe.Coupons",
"repository": "OpenVibers/OpenVibe.Coupons",
- "stage": "placeholder",
- "code": false,
+ "stage": "alpha",
+ "code": true,
+ "wave": "W18",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
- "updated": "2026-09-21"
+ "runtime": "node 22, express 4, better-sqlite3 11",
+ "port": 4850,
+ "domain": "openvibe.coupons",
+ "packages": [
+ "openvibe-publishing v0.2.0",
+ "openvibe-contracts v0.19.0",
+ "openvibe-shared v1.3.0",
+ "openvibe-sdk v0.2.2"
+ ],
+ "capabilities": [
+ "coupons.coupon.submit",
+ "coupons.report.create",
+ "coupons.merchant.resolve",
+ "coupons.coupon.lookup",
+ "coupons.status.update",
+ "coupons.merchant.manage"
+ ],
+ "installScopes": ["coupons.lookup", "coupons.report"],
+ "capabilitiesRegistered": false,
+ "serviceManifestRegistered": false,
+ "seedCoupons": "none",
+ "notifications": "watches are recorded; no notification is sent yet",
+ "client": "OpenVibers/OpenVibe.Extensions browser/ (not published to any store)",
+ "deployed": false,
+ "placeholderInSites": true,
+ "updated": "2026-09-22"
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,109 @@
@@ -0,0 +1,109 @@
+## ═══════════════════════════════════════════════════════════════
+## openvibe.coupons — Nginx configuration (REFERENCE)
+##
+## Deployed to /etc/nginx/sites-available/openvibe.coupons.conf — keep them in sync and reload nginx
+## after changing it. The Node app (port 4850, systemd unit openvibe-coupons) renders every page;
+## nginx terminates TLS, redirects www → apex, rate-limits auth, forms and API, and compresses text.
+##
+## No proxy_cache here on purpose: the app decides cacheability per response (public only for pages
+## rendered for anonymous visitors and for the lookup API, whose answers are the same for everyone;
+## private, no-store for everything else). CORS headers come from the app (extension origins on the
+## two lookup routes only) — do not add any here.
+## ═══════════════════════════════════════════════════════════════
+
+limit_req_zone $binary_remote_addr zone=ovcoupons_lookup:10m rate=5r/s;
+limit_req_zone $binary_remote_addr zone=ovcoupons_api:10m rate=5r/s;
+limit_req_zone $binary_remote_addr zone=ovcoupons_auth:10m rate=10r/m;
+limit_req_zone $binary_remote_addr zone=ovcoupons_forms:10m rate=2r/s;
+
+server {
+ listen 80;
+ listen [::]:80;
+ server_name openvibe.coupons www.openvibe.coupons;
+
+ location /.well-known/acme-challenge/ { root /var/www/certbot; }
+ location / { return 301 https://openvibe.coupons$request_uri; }
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name www.openvibe.coupons;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.coupons/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.coupons/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ return 301 https://openvibe.coupons$request_uri;
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name openvibe.coupons;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.coupons/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.coupons/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ access_log /var/log/nginx/openvibe.coupons.access.log;
+ error_log /var/log/nginx/openvibe.coupons.error.log;
+
+ # Forms and JSON bodies are small (no uploads).
+ client_max_body_size 64k;
+
+ add_header X-Content-Type-Options nosniff always;
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+
+ gzip on;
+ gzip_proxied any;
+ gzip_vary on;
+ gzip_comp_level 5;
+ gzip_min_length 1024;
+ gzip_types text/css application/javascript text/javascript application/json application/problem+json application/feed+json image/svg+xml text/plain application/xml text/xml application/rss+xml application/atom+xml;
+
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # ── Network SSO session endpoints (strict) ──────────────
+ location /auth/ {
+ limit_req zone=ovcoupons_auth burst=10 nodelay;
+ proxy_pass http://127.0.0.1:4850;
+ }
+
+ # ── The browser helper's lookups (the app also limits per install) ──
+ location ~ ^/api/v1/merchants/(resolve|[^/]+/coupons)$ {
+ limit_req zone=ovcoupons_lookup burst=20 nodelay;
+ proxy_pass http://127.0.0.1:4850;
+ }
+
+ # ── API ─────────────────────────────────────────────────
+ location /api/ {
+ limit_req zone=ovcoupons_api burst=20 nodelay;
+ proxy_pass http://127.0.0.1:4850;
+ }
+
+ # ── Forms (submit, reports, connect-extension, staff) ───
+ location ~ ^/(submit|connect-extension|staff|c/[^/]+/report|m/[^/]+/watch) {
+ limit_req zone=ovcoupons_forms burst=10 nodelay;
+ proxy_pass http://127.0.0.1:4850;
+ }
+
+ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4850/metrics) ──
+ location = /metrics { return 404; }
+
+ # ── Pages, feeds, sitemaps, robots.txt, llms.txt, static assets ──
+ location / {
+ proxy_pass http://127.0.0.1:4850;
+ }
+
+ location ~ /\.(?!well-known) {
+ deny all;
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+[Unit]
+Description=OpenVibe.Coupons — coupon codes with merchant matching, honest expiry and people's validity reports
+# Network (sign-in, JWKS, service tokens), Events (the outbox relay) and Sources (imports) should be
+# up first, but Coupons degrades gracefully without them: pages, lookups and feeds serve; sign-in,
+# events and imports wait (see /api/ready).
+After=network.target
+Wants=openvibe-network.service
+
+[Service]
+Type=simple
+User=ubuntu
+Group=ubuntu
+WorkingDirectory=/opt/openvibe.coupons
+EnvironmentFile=/etc/openvibe/coupons.env
+Environment=NODE_ENV=production
+Environment=PORT=4850
+Environment=PATH=/usr/local/bin:/usr/bin:/bin
+ExecStart=/usr/bin/env node /opt/openvibe.coupons/server/index.js
+Restart=always
+RestartSec=5
+KillSignal=SIGTERM
+TimeoutStopSec=10
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+# Coupons' own database lives in /var/lib/openvibe-coupons; the code tree stays read-only.
+StateDirectory=openvibe-coupons
+Environment=COUPONS_DB_PATH=/var/lib/openvibe-coupons/coupons.db
+
+[Install]
+WantedBy=multi-user.target
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+{
+ "id": "coupons.coupon.lookup",
+ "version": "1.0",
+ "owner": "coupons",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Read a merchant's active codes (status, confidence, report aggregates, restrictions, expiry or unknown, evidence) or one code. Public data only, the same response for every caller; anonymous callers may use the routes with tighter rate limits. The charter's coupons.lookup.",
+ "permissions": [
+ "coupons:read"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "coupons-lookup",
+ "events": [],
+ "implementedBy": [
+ "GET /api/v1/merchants/:id/coupons",
+ "GET /api/v1/merchants/:id",
+ "GET /api/v1/coupons/:id"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
+{
+ "id": "coupons.coupon.submit",
+ "version": "1.0",
+ "owner": "coupons",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Submit a coupon code for a merchant (by merchant id, host or URL) on behalf of a person (X-OV-Subject) or as AI output (X-OV-Origin: ai, held for staff review). The code starts with status unknown; a status, confidence or 'verified' field is refused. A site no merchant covers becomes a pending merchant for staff review. The charter's coupons.submit.",
+ "permissions": [
+ "coupons:write"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "coupons-write",
+ "events": [
+ "coupons.coupon.created",
+ "coupons.coupon.updated",
+ "coupons.confidence.changed",
+ "coupons.index_document.upserted",
+ "coupons.index_document.deleted"
+ ],
+ "implementedBy": [
+ "POST /api/v1/coupons/submit"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
+{
+ "id": "coupons.merchant.manage",
+ "version": "1.0",
+ "owner": "coupons",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Create merchants with their domain rules (never on a public suffix), add domain rules, and approve or disable merchants. Approving a pending merchant publishes the codes members submitted for it; disabling one takes its codes out of active results and Search.",
+ "permissions": [
+ "coupons:moderate"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "coupons-write",
+ "events": [
+ "coupons.coupon.updated",
+ "coupons.index_document.upserted",
+ "coupons.index_document.deleted"
+ ],
+ "implementedBy": [
+ "POST /api/v1/merchants",
+ "POST /api/v1/merchants/:id/domains",
+ "POST /api/v1/merchants/:id/status"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "id": "coupons.merchant.resolve",
+ "version": "1.0",
+ "owner": "coupons",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Resolve a hostname to an active merchant (normalized host, eTLD+1 from a bundled public-suffix subset, subdomain rules; path rules never match host-only lookups). Public data only; anonymous callers may use the route with tighter rate limits.",
+ "permissions": [
+ "coupons:read"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "coupons-lookup",
+ "events": [],
+ "implementedBy": [
+ "GET /api/v1/merchants/resolve?host="
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+{
+ "id": "coupons.report.create",
+ "version": "1.0",
+ "owner": "coupons",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Report that a code worked or failed, on behalf of a person (X-OV-Subject). One report per person, code and UTC day; only the latest per person counts; rate-limited per person; AI output (X-OV-Origin: ai) is refused. The response carries aggregates only, never a reporter. The charter's coupons.report.",
+ "permissions": [
+ "coupons:report"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "coupons-report",
+ "events": [
+ "coupons.report.created",
+ "coupons.coupon.updated",
+ "coupons.confidence.changed",
+ "coupons.index_document.upserted"
+ ],
+ "implementedBy": [
+ "POST /api/v1/coupons/:id/report"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
+{
+ "id": "coupons.status.update",
+ "version": "1.0",
+ "owner": "coupons",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Moderate a code: disable it, mark it expired, or return it to active results (its status is then recomputed from people's reports and time). reported_working and reported_failed cannot be set by anyone.",
+ "permissions": [
+ "coupons:moderate"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "coupons-write",
+ "events": [
+ "coupons.coupon.disabled",
+ "coupons.coupon.expired",
+ "coupons.coupon.updated",
+ "coupons.confidence.changed",
+ "coupons.index_document.upserted",
+ "coupons.index_document.deleted"
+ ],
+ "implementedBy": [
+ "POST /api/v1/coupons/:id/status"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -0,0 +1,39 @@
+{
+ "version": "0.1.0",
+ "contractRanges": {
+ "openvibe-contracts": ">=0.19.0 <1.0.0"
+ },
+ "id": "coupons",
+ "name": "OpenVibe.Coupons",
+ "status": "alpha",
+ "repository": "OpenVibers/OpenVibe.Coupons",
+ "domains": [
+ "openvibe.coupons"
+ ],
+ "publicOrigin": "https://openvibe.coupons",
+ "health": "/api/health",
+ "ready": "/api/ready",
+ "capabilities": [
+ "coupons.coupon.submit",
+ "coupons.report.create",
+ "coupons.merchant.resolve",
+ "coupons.coupon.lookup",
+ "coupons.status.update",
+ "coupons.merchant.manage"
+ ],
+ "eventsProduced": [
+ "coupons.coupon.created",
+ "coupons.coupon.updated",
+ "coupons.coupon.expired",
+ "coupons.coupon.disabled",
+ "coupons.report.created",
+ "coupons.confidence.changed",
+ "coupons.index_document.upserted",
+ "coupons.index_document.deleted"
+ ],
+ "eventsConsumed": [],
+ "namespacesOwned": [
+ "coupons.*"
+ ],
+ "notes": "Port 4850. Owns coupon_merchants, coupon_merchant_domains, coupons, coupon_sources, coupon_restrictions, coupon_validation_reports, coupon_status_history, coupon_application_hints, coupon_watches in its own SQLite, plus extension_installs (cpx_ browser-helper tokens, scopes coupons.lookup and coupons.report, hashed, revocable). Consumes OpenVibe.Network (SSO, JWKS) and OpenVibe.Sources (sources.item.read, category coupons); publishes through OpenVibe.Events (events.event.publish); OpenVibe.Search reads coupons.index_document.* from Events. The browser helper lives in OpenVibers/OpenVibe.Extensions (ADR-023)."
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,1562 @@
@@ -0,0 +1,1562 @@
+{
+ "name": "openvibe-coupons",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "openvibe-coupons",
+ "version": "0.1.0",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "better-sqlite3": "^11.10.0",
+ "cookie-parser": "^1.4.7",
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "express-rate-limit": "^7.5.0",
+ "helmet": "^8.1.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.19.0",
+ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.2.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
+ },
+ "devDependencies": {
+ "jsonwebtoken": "^9.0.2"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-formats": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/better-sqlite3": {
+ "version": "11.10.0",
+ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
+ "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "bindings": "^1.5.0",
+ "prebuild-install": "^7.1.1"
+ }
+ },
+ "node_modules/bindings": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+ "license": "MIT",
+ "dependencies": {
+ "file-uri-to-path": "1.0.0"
+ }
+ },
+ "node_modules/bl": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer": "^5.5.0",
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.4.0"
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.8",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
+ "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.16.0",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/buffer": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.1.13"
+ }
+ },
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "license": "ISC"
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-parser": {
+ "version": "1.4.7",
+ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
+ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "0.7.2",
+ "cookie-signature": "1.0.6"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/decompress-response": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mimic-response": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/deep-extend": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/expand-template": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
+ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+ "license": "(MIT OR WTFPL)",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.22.3",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
+ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.5",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.13",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.16.0",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express-rate-limit": {
+ "version": "7.5.1",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
+ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
+ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/file-uri-to-path": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+ "license": "MIT"
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fs-constants": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
+ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
+ "license": "MIT"
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/github-from-package": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
+ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
+ "license": "MIT"
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/helmet": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
+ "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/EvanHahn"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
+ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^4.0.1",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/jsonwebtoken/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/jwa": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mimic-response": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/mkdirp-classic": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
+ "license": "MIT"
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/napi-build-utils": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
+ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/node-abi": {
+ "version": "3.96.0",
+ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",
+ "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/openvibe-contracts": {
+ "version": "0.19.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.19.0",
+ "integrity": "sha512-5DjPvXIfc68wZhFXgKlCzDarbbFD5dUV4yYQIKEK1qD+VIuR+4twbON1brdyM9rYZyRyYClPigAqReocmRGytg==",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1"
+ },
+ "bin": {
+ "openvibe-contracts-check": "bin/check-service.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/openvibe-publishing": {
+ "version": "0.2.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.2.0",
+ "integrity": "sha512-ZIvDLEIzOk+7A20FTywuiJsvUNVEfkpsa7qbm1PXxezDzIBFcEMJFXxGsm7Ran6rXQ1+Uio+LoBzp3YCC4kHiQ==",
+ "license": "MIT",
+ "dependencies": {
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "better-sqlite3": ">=11"
+ },
+ "peerDependenciesMeta": {
+ "better-sqlite3": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/openvibe-publishing/node_modules/openvibe-shared": {
+ "version": "1.0.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0",
+ "integrity": "sha512-JOSH6LbIVzE9oiVWZmUnjZ+Ab8dRQ/SPk9aqBjbJGXp6kBoJA+tj7x1mp6vCusaxGHp17b2r9gmJQXk1TRL/JA==",
+ "license": "MIT",
+ "dependencies": {
+ "jsonwebtoken": "^9.0.0"
+ }
+ },
+ "node_modules/openvibe-sdk": {
+ "version": "0.2.2",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
+ "integrity": "sha512-kDifUp3EcmnTWzElAmaCZnX659WErcnm4+qVEoAiYQWX/w/1p4TWvoHbUPsrX65TSROVR3tiyVkl801n/0yyCw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/openvibe-shared": {
+ "version": "1.3.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
+ "integrity": "sha512-w8yl/5+9eend8I2O/DzAJnEAL2N3GTUY7c7pzK+MLJuLAGD8FmJJQ9gwbgyrq70Snjt3ebrg3TlpORhRAp2kgA==",
+ "license": "MIT",
+ "dependencies": {
+ "jsonwebtoken": "^9.0.0"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+ "license": "MIT"
+ },
+ "node_modules/prebuild-install": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
+ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
+ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
+ "license": "MIT",
+ "dependencies": {
+ "detect-libc": "^2.0.0",
+ "expand-template": "^2.0.3",
+ "github-from-package": "0.0.0",
+ "minimist": "^1.2.3",
+ "mkdirp-classic": "^0.5.3",
+ "napi-build-utils": "^2.0.0",
+ "node-abi": "^3.3.0",
+ "pump": "^3.0.0",
+ "rc": "^1.2.7",
+ "simple-get": "^4.0.0",
+ "tar-fs": "^2.0.0",
+ "tunnel-agent": "^0.6.0"
+ },
+ "bin": {
+ "prebuild-install": "bin.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
+ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/pump": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
+ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.16.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
+ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/rc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+ "dependencies": {
+ "deep-extend": "^0.6.0",
+ "ini": "~1.3.0",
+ "minimist": "^1.2.0",
+ "strip-json-comments": "~2.0.1"
+ },
+ "bin": {
+ "rc": "cli.js"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/simple-concat": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/simple-get": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
+ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decompress-response": "^6.0.0",
+ "once": "^1.3.1",
+ "simple-concat": "^1.0.0"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tar-fs": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
+ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
+ "license": "MIT",
+ "dependencies": {
+ "chownr": "^1.1.1",
+ "mkdirp-classic": "^0.5.2",
+ "pump": "^3.0.0",
+ "tar-stream": "^2.1.4"
+ }
+ },
+ "node_modules/tar-stream": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "bl": "^4.0.3",
+ "end-of-stream": "^1.4.1",
+ "fs-constants": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/tunnel-agent": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
+{
+ "name": "openvibe-coupons",
+ "version": "0.1.0",
+ "private": true,
+ "description": "OpenVibe.Coupons — coupon codes with merchant and domain resolution, restrictions, honest expiry, deduplicated and rate-limited people's validity reports, and the public lookup API the OpenVibe browser helper uses. Server-rendered and useful without JavaScript.",
+ "main": "server/index.js",
+ "scripts": {
+ "start": "node server/index.js",
+ "dev": "NODE_ENV=development node server/index.js",
+ "test": "node test/run.js"
+ },
+ "keywords": ["openvibe", "coupons", "merchants", "browser-extension"],
+ "author": "OpenVibers",
+ "license": "AGPL-3.0-only",
+ "repository": "github:OpenVibers/OpenVibe.Coupons",
+ "dependencies": {
+ "better-sqlite3": "^11.10.0",
+ "cookie-parser": "^1.4.7",
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "express-rate-limit": "^7.5.0",
+ "helmet": "^8.1.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.19.0",
+ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.2.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
+ },
+ "devDependencies": {
+ "jsonwebtoken": "^9.0.2"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
+/* OpenVibe.Coupons — theme-token driven (openvibe-shared presets). No JavaScript needed. */
+:root { color-scheme: dark; }
+* { box-sizing: border-box; }
+body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
+a { color: var(--accent-light, #60a5fa); }
+a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 3px solid var(--accent, #3b82f6); outline-offset: 2px; }
+.skip { position: absolute; left: -9999px; }
+.skip:focus { left: 16px; top: 8px; background: var(--bg-card, #131c2e); padding: 8px 12px; z-index: 10; }
+.account-bar { max-width: 820px; margin: 0 auto; padding: 6px 16px 0; text-align: right; font-size: 14px; color: var(--text-secondary, #96a7c2); }
+.page { max-width: 820px; margin: 0 auto; padding: 16px 16px 64px; min-height: 60vh; }
+h1 { font-size: 1.9rem; line-height: 1.2; margin: 1.1rem 0 .6rem; }
+h2 { font-size: 1.3rem; margin: 1.6rem 0 .5rem; }
+h3 { font-size: 1.1rem; margin: 0 0 .4rem; }
+.lede { color: var(--text-secondary, #96a7c2); font-size: 1.05rem; }
+.meta, .empty { color: var(--text-muted, #7386a3); font-size: .9rem; margin: .25rem 0; }
+.unknown { font-style: italic; color: var(--text-secondary, #96a7c2); }
+.coupon { border: 1px solid var(--border, #1f2d47); border-radius: 10px; padding: 12px 16px; margin: 12px 0; background: var(--bg-card, #131c2e); }
+.code-line { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: .3rem 0; }
+.code-line .label { color: var(--text-muted, #7386a3); font-size: .85rem; }
+.code { font: 600 1.15rem/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; padding: 4px 10px; border: 1px dashed var(--accent, #3b82f6); border-radius: 6px; user-select: all; -webkit-user-select: all; word-break: break-all; }
+.status { display: inline-block; font-size: .78rem; padding: 1px 9px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); }
+.status-reported_working { border-color: var(--success, #22c55e); color: var(--success, #22c55e); }
+.status-reported_failed { border-color: var(--error, #ef4444); color: var(--error, #ef4444); }
+.status-expired, .status-disabled { opacity: .8; }
+.restrictions, .hints { margin: .4rem 0; padding-left: 1.2rem; font-size: .95rem; }
+.report { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 8px; }
+form.inline { display: inline-flex; gap: 6px; align-items: center; flex-wrap: wrap; }
+button { font: inherit; padding: 5px 12px; border-radius: 6px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-tertiary, #172238); color: var(--text-primary, #e6edf7); cursor: pointer; }
+button:hover { border-color: var(--accent, #3b82f6); }
+input, select, textarea { font: inherit; padding: 5px 8px; border-radius: 6px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); max-width: 100%; }
+.form label { display: block; margin: 8px 0; }
+.form fieldset { border: 1px solid var(--border, #1f2d47); border-radius: 8px; margin: 12px 0; }
+.search { margin: 12px 0; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
+.merchant-list, .recent, .ended, .history { padding-left: 1.2rem; }
+.notice { border-left: 3px solid var(--info, #38bdf8); padding: 6px 12px; background: var(--bg-card, #131c2e); }
+.errors { border-left: 3px solid var(--error, #ef4444); padding: 6px 12px; background: var(--bg-card, #131c2e); }
+.disclosure { border: 1px solid var(--warning, #f59e0b); border-radius: 8px; padding: 6px 10px; font-size: .9rem; }
+.token-once { border: 1px solid var(--warning, #f59e0b); border-radius: 8px; padding: 8px 14px; }
+.token { user-select: all; -webkit-user-select: all; word-break: break-all; font-size: 1rem; }
+.installs { border-collapse: collapse; width: 100%; font-size: .92rem; }
+.installs th, .installs td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border, #1f2d47); }
+.formula { font-family: ui-monospace, monospace; }
+.ov-breadcrumbs ol { list-style: none; padding: 0; margin: 8px 0; display: flex; flex-wrap: wrap; gap: 6px; font-size: .9rem; }
+.ov-breadcrumbs li + li::before { content: "›"; margin-right: 6px; color: var(--text-muted); }
+@media (max-width: 600px) { .installs thead { display: none; } .installs td { display: block; border: 0; padding: 2px 0; } .installs tr { display: block; border-bottom: 1px solid var(--border, #1f2d47); padding: 6px 0; } }
new file mode 100644
new file mode 100644
@@ -0,0 +1,140 @@
@@ -0,0 +1,140 @@
+'use strict';
+
+/**
+ * OpenVibe.Coupons — Express app factory. server/index.js listens and starts the worker; tests build
+ * their own instance with a temp database, an injectable clock and mock neighbours.
+ *
+ * Pages (server-rendered, http/public.js) API (/api/v1, http/api.js; Authorization header only)
+ * Discovery (robots, llms, sitemaps, feeds) /auth/* (Network SSO)
+ * /api/health, /api/ready, /release.json, /metrics
+ */
+const path = require('path');
+const express = require('express');
+const helmet = require('helmet');
+const cookieParser = require('cookie-parser');
+const rateLimit = require('express-rate-limit');
+const contracts = require('openvibe-contracts');
+
+const configLib = require('./config');
+const { openStore } = require('./db');
+const { createAuthClient, createAuthRoutes } = require('./auth/sso');
+const { createViewerResolver } = require('./auth/viewer');
+const { createCouponsOutbox } = require('./events/outbox');
+const { createPublication } = require('./domain/publication');
+const { createMerchants } = require('./domain/merchants');
+const { createCoupons } = require('./domain/coupons');
+const { createReports } = require('./domain/reports');
+const { createInstalls } = require('./domain/installs');
+const { createWatches } = require('./domain/watches');
+const { createModeration } = require('./domain/moderation');
+const { createSourcesImporter } = require('./clients/sources');
+const { createApi } = require('./http/api');
+const { createPublicRoutes } = require('./http/public');
+const { createDiscoveryRoutes } = require('./http/discovery');
+const { createCouponsReadiness } = require('./observability');
+const { createWorker } = require('./worker');
+const { assetVersion } = require('./render/layout');
+
+const PUBLIC_DIR = path.join(__dirname, '..', 'public');
+const VERSION = require('../package.json').version;
+
+/** opts: config, store | dbPath, now (clock), fetchImpl, auth (a createAuthClient-like object), log */
+function createApp(opts = {}) {
+ const config = opts.config || configLib.load();
+ const log = opts.log || console;
+ const fetchImpl = opts.fetchImpl || globalThis.fetch;
+ const store = opts.store || openStore(opts.dbPath || config.dbPath, { now: opts.now });
+
+ const outbox = createCouponsOutbox({ db: store.db, config, fetchImpl, now: store.now, log });
+ const publication = createPublication({ store, config, outbox });
+ const merchants = createMerchants({ store });
+ const coupons = createCoupons({ store, merchants, publication });
+ const reports = createReports({ store, config, coupons, merchants, publication });
+ const installs = createInstalls({ store, config });
+ const watches = createWatches({ store });
+ const moderation = createModeration({ store, merchants, coupons });
+ const importer = createSourcesImporter({ store, config, merchants, coupons, fetchImpl, log });
+ const auth = opts.auth || createAuthClient(config);
+ const viewers = createViewerResolver({ auth, config, installs });
+ const worker = createWorker({ config, coupons, importer, outbox, log });
+
+ const ctx = { config, store, outbox, publication, merchants, coupons, reports, installs, watches, moderation, importer, auth, viewers, worker };
+
+ const app = express();
+ app.disable('x-powered-by');
+ app.set('trust proxy', config.trustProxy);
+ const release = require('openvibe-shared/release').createRelease({ service: 'coupons', root: path.join(__dirname, '..') });
+ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'coupons', release: release.release });
+ app.locals.metrics = metrics.registry;
+ app.locals.ctx = ctx;
+
+ app.use(contracts.http.middleware());
+ app.use(helmet({
+ contentSecurityPolicy: {
+ directives: {
+ defaultSrc: ["'self'"],
+ // The shared chrome (theme-loader, navbar) comes from the Network; the inline init is ours.
+ scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network'],
+ styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
+ fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
+ imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media'],
+ connectSrc: ["'self'", 'https://openvibe.network'],
+ frameSrc: ["'self'", 'https://openvibe.network'],
+ frameAncestors: ["'none'"],
+ objectSrc: ["'none'"],
+ baseUri: ["'self'"],
+ formAction: ["'self'", 'https://openvibe.network'],
+ },
+ },
+ frameguard: { action: 'deny' },
+ crossOriginEmbedderPolicy: false,
+ crossOriginResourcePolicy: { policy: 'same-site' },
+ referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
+ }));
+ app.use(cookieParser());
+
+ // ── Machine endpoints ───────────────────────────────────
+ app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-coupons', version: VERSION }));
+ app.get('/release.json', release.handler);
+ const readiness = createCouponsReadiness({ store, auth, outbox, worker, importer, reports, config, release: release.release });
+ app.get('/api/ready', readiness.handler);
+
+ // ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────
+ app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, standardHeaders: true, legacyHeaders: false }));
+ app.use('/auth', createAuthRoutes(config, auth));
+ { const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'coupons', service: 'coupons', host: 'openvibe.coupons', name: 'OpenVibe.Coupons', profile: 'ugc' })); }
+
+ // ── Static assets (content-hashed ?v= → immutable) ──────
+ app.use(express.static(PUBLIC_DIR, {
+ index: false, redirect: false,
+ setHeaders(res, filePath) {
+ const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
+ const v = res.req && res.req.query && res.req.query.v;
+ res.setHeader('Cache-Control', v && v === assetVersion(rel) ? 'public, max-age=31536000, immutable' : 'public, max-age=300');
+ },
+ }));
+
+ // ── API ─────────────────────────────────────────────────
+ app.use('/api/v1', createApi(ctx));
+ app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
+
+ // ── Discovery and pages ─────────────────────────────────
+ app.use(createDiscoveryRoutes(ctx));
+ const pages = rateLimit({ windowMs: 60_000, limit: 300, standardHeaders: true, legacyHeaders: false });
+ const publicRoutes = createPublicRoutes(ctx);
+ app.use(pages, publicRoutes.router);
+ app.use((req, res) => publicRoutes.notFound(req, res));
+
+ // eslint-disable-next-line no-unused-vars
+ app.use((err, req, res, _next) => {
+ log.error('[Coupons]', err && err.stack ? err.stack : err);
+ if (res.headersSent) return;
+ res.set('Cache-Control', 'private, no-store');
+ if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
+ res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');
+ });
+
+ return { app, ctx };
+}
+
+module.exports = { createApp };
new file mode 100644
new file mode 100644
@@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
+'use strict';
+
+/**
+ * Capability checks for service tokens (audience openvibe.coupons), and the scopes of extension
+ * install tokens.
+ *
+ * Capabilities (roadmap §15.13, as 3-segment ids) are for OpenVibe services holding a Network
+ * client-credentials token. Coupons' ids are proposed in docs/capabilities-proposal/ for the next
+ * contracts release; until then a grant is decided locally with the library's own matching rule
+ * (the exact id, or a `prefix.*` grant covering it). An id the library knows always goes through
+ * the library, so the day the release lands nothing changes here.
+ *
+ * Install scopes (coupons.lookup, coupons.report) are NOT Network capabilities: they are the two
+ * things a browser-helper install token may do, issued and revoked on this site
+ * (/connect-extension). They map onto the capabilities of the same routes.
+ */
+const { capabilities } = require('openvibe-contracts');
+
+const CAPABILITIES = Object.freeze({
+ COUPON_SUBMIT: 'coupons.coupon.submit', // charter coupons.submit
+ REPORT_CREATE: 'coupons.report.create', // charter coupons.report
+ MERCHANT_RESOLVE: 'coupons.merchant.resolve',
+ COUPON_LOOKUP: 'coupons.coupon.lookup', // charter coupons.lookup
+ STATUS_UPDATE: 'coupons.status.update',
+ MERCHANT_MANAGE: 'coupons.merchant.manage',
+});
+const PROPOSED = new Set(Object.values(CAPABILITIES));
+
+const SCOPES = Object.freeze({ LOOKUP: 'coupons.lookup', REPORT: 'coupons.report' });
+const ALL_SCOPES = Object.freeze([SCOPES.LOOKUP, SCOPES.REPORT]);
+
+/** → { allowed, code, reason } like capabilities.check(). */
+function checkCapability(claims, capabilityId) {
+ if (!capabilities.get(capabilityId) && PROPOSED.has(capabilityId)) {
+ return capabilities.grants(claims && claims.cap, capabilityId)
+ ? { allowed: true, code: null, reason: null }
+ : { allowed: false, code: 'capability.denied', reason: `${capabilityId} not granted` };
+ }
+ return capabilities.check(claims, capabilityId);
+}
+
+module.exports = { CAPABILITIES, PROPOSED, SCOPES, ALL_SCOPES, checkCapability };
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+'use strict';
+
+/**
+ * Form tokens for the no-JavaScript forms (submit, report, watch, connect-extension, staff) (CSRF). The session cookie is
+ * SameSite=Lax already; the token is the second lock: an HMAC of the signed-in subject under
+ * COUPONS_FORM_SECRET (a random per-process key when unset, so forms opened before a restart must be
+ * reloaded). Never derived from anything a cross-site page can read.
+ */
+const crypto = require('crypto');
+
+const fallback = crypto.randomBytes(32).toString('hex');
+
+function csrfToken(config, viewer) {
+ if (!viewer || !viewer.subject) return '';
+ return crypto.createHmac('sha256', config.formSecret || fallback).update(`coupons-form:${viewer.subject}`).digest('base64url').slice(0, 32);
+}
+
+function checkCsrf(config, viewer, token) {
+ const expected = csrfToken(config, viewer);
+ if (!expected || typeof token !== 'string' || token.length !== expected.length) return false;
+ return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected));
+}
+
+module.exports = { csrfToken, checkCsrf };
new file mode 100644
new file mode 100644
@@ -0,0 +1,420 @@
@@ -0,0 +1,420 @@
+'use strict';
+
+// ═══════════════════════════════════════════════════════════════
+// openvibe.coupons — OAuth2 CLIENT session layer (sign-in via OpenVibe.Network SSO)
+//
+// The same session layer OpenVibe.Community uses (server/auth/routes.js there, MIT), with
+// Coupons' client id: registered in the Network as OAuth client `coupons`, redirect
+// https://openvibe.coupons/auth/callback.
+//
+// GET /auth/login → redirect to Network /oauth/authorize (?silent=1: prompt=none;
+// ?next=… same-site path or an https://openvibe.network/… URL)
+// GET /auth/callback → server-side code exchange, set cookies
+// POST /auth/fedcm → the shared navbar's FedCM assertion → tokens (jwt-bearer grant)
+// GET /auth/logout → clear cookies (+ best-effort refresh revoke), hint=guest
+// GET /auth/me → offline-verify ov_token, return profile
+// POST /auth/refresh → rotate tokens via refresh_token grant
+//
+// Cookies (host-only for openvibe.coupons):
+// ov_token access JWT, SameSite=Lax, Secure, JS-readable (navbar.js reads it)
+// ov_refresh opaque refresh token, httpOnly, Path=/auth
+// ov_sso_hint 'account' after a sign-in, 'guest' after a sign-out (navbar silent-login hint)
+// Pages never trust a body or query for identity: only the verified ov_token (or a Bearer token).
+// ═══════════════════════════════════════════════════════════════
+
+const express = require('express');
+const crypto = require('crypto');
+const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');
+
+const ACCESS_COOKIE = 'ov_token';
+const REFRESH_COOKIE = 'ov_refresh';
+const HINT_COOKIE = 'ov_sso_hint';
+const STATE_COOKIE = 'ov_oauth_state';
+const NEXT_COOKIE = 'ov_oauth_next';
+const SILENT_COOKIE = 'ov_oauth_silent';
+
+/**
+ * Create the auth client + JWKS fetcher shared by the whole app.
+ * Verification is OFFLINE: we cache the Network's RS256 public key from
+ * GET /api/.well-known/jwks and verify JWTs locally on every request.
+ */
+function createAuthClient(config) {
+ const client = new OpenVibeAuthClient({
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ redirectUri: config.oauth.redirectUri,
+ publicKey: null, // filled by ensureKey()
+ authBase: config.networkUrl,
+ internalBase: config.networkInternalUrl,
+ });
+
+ let lastFetch = 0;
+ let inflight = null;
+ async function ensureKey() {
+ if (client.publicKey) return client.publicKey;
+ // A request that arrives while the key is being fetched waits for that fetch rather than
+ // being treated as signed out.
+ if (inflight) return inflight;
+ // Don't hammer the Network if it's down — retry at most every 30s
+ if (Date.now() - lastFetch < 30_000) return null;
+ lastFetch = Date.now();
+ inflight = fetchKey().finally(() => { inflight = null; });
+ return inflight;
+ }
+ async function fetchKey() {
+ for (const base of [config.networkInternalUrl, config.networkUrl]) {
+ if (!base) continue;
+ try {
+ const res = await fetch(`${base}/api/.well-known/jwks`, { signal: AbortSignal.timeout(5000) });
+ if (!res.ok) continue;
+ const jwks = await res.json();
+ if (jwks.public_key) {
+ client.publicKey = jwks.public_key;
+ console.log(`[Coupons] Network public key loaded from ${base} (${jwks.algorithm || 'RS256'})`);
+ return client.publicKey;
+ }
+ } catch (err) {
+ console.warn(`[Coupons auth] JWKS fetch failed from ${base}: ${err.message}`);
+ }
+ }
+ return null;
+ }
+
+ /** Offline JWT verification. Returns decoded claims or null. */
+ async function verify(token) {
+ if (!token) return null;
+ await ensureKey();
+ return client.verifyToken(token);
+ }
+
+ // Warm the key cache at boot (non-fatal if the Network is down)
+ ensureKey().catch(() => {});
+
+ return { client, ensureKey, verify };
+}
+
+/** Token from Authorization header or the ov_token cookie. */
+function extractToken(req) {
+ const h = req.headers.authorization;
+ if (h && h.startsWith('Bearer ')) return h.slice(7);
+ return req.cookies?.[ACCESS_COOKIE] || null;
+}
+
+/** Strip registered claims — what the site shows as "the user". */
+function claimsToUser(claims) {
+ if (!claims) return null;
+ const { iat, exp, aud, iss, nbf, jti, ...user } = claims;
+ return user;
+}
+
+/**
+ * Decode a JWT's payload WITHOUT checking its signature. Only for reading claims
+ * we cross-check locally (the FedCM nonce) before the Network — which does verify
+ * the signature — sees the assertion. Never use it to trust an identity.
+ */
+function decodeJwtPayload(token) {
+ if (typeof token !== 'string') return null;
+ const parts = token.split('.');
+ if (parts.length !== 3) return null;
+ try {
+ const claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
+ return claims && typeof claims === 'object' && !Array.isArray(claims) ? claims : null;
+ } catch { return null; }
+}
+
+/** True when the assertion's `nonce` claim is exactly the nonce the page posted. */
+function fedcmNonceMatches(token, nonce) {
+ if (typeof nonce !== 'string' || !nonce || nonce.length > 256) return false;
+ const claims = decodeJwtPayload(token);
+ if (!claims || typeof claims.nonce !== 'string') return false;
+ const a = Buffer.from(claims.nonce), b = Buffer.from(nonce);
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
+}
+
+/**
+ * Only allow same-site relative paths, this site's own https origin, or an
+ * https://openvibe.network/… URL (sign-in / sign-out-everywhere chains) as post-auth targets.
+ */
+function sanitizeNext(next, config) {
+ if (!next || typeof next !== 'string') return '/';
+ if (/^\/(?!\/|\\)/.test(next)) return next; // relative path, not protocol-relative
+ try {
+ const u = new URL(next);
+ if (u.protocol !== 'https:') return '/';
+ const allowed = [config.baseUrl, config.networkUrl]
+ .map((b) => { try { return new URL(b).hostname; } catch { return null; } })
+ .filter(Boolean);
+ if (allowed.includes(u.hostname)) return u.toString();
+ } catch { /* fall through */ }
+ return '/';
+}
+
+/** Append ?key=value to a same-site path or absolute URL, keeping any existing query/hash. */
+function withParam(target, key, value) {
+ const hashAt = target.indexOf('#');
+ const hash = hashAt >= 0 ? target.slice(hashAt) : '';
+ const base = hashAt >= 0 ? target.slice(0, hashAt) : target;
+ const sep = base.includes('?') ? '&' : '?';
+ return `${base}${sep}${encodeURIComponent(key)}=${encodeURIComponent(value)}${hash}`;
+}
+
+/**
+ * Build the Network authorize URL for a login. Exported (and pure) so tests can
+ * cover the silent-login shape without a browser.
+ */
+function buildAuthorizeUrl(config, auth, { silent = false } = {}) {
+ const { url, state } = auth.client.getAuthorizationUrl(config.oauth.scope);
+ if (!silent) return { url, state };
+ // ?silent=1: continue as the account openvibe.network already knows (no chooser); if the
+ // network has no session either, it bounces back with error=login_required and we stay quiet.
+ const u = new URL(url);
+ u.searchParams.set('prompt', 'none');
+ return { url: u.toString(), state };
+}
+
+function createAuthRoutes(config, auth) {
+ const router = express.Router();
+
+ const accessCookieOpts = () => ({
+ sameSite: 'lax',
+ secure: config.cookies.secure,
+ httpOnly: false, // JS-readable by design — the shared navbar reads it client-side
+ path: '/',
+ maxAge: 24 * 60 * 60 * 1000, // matches the 24h access JWT
+ });
+
+ const refreshCookieOpts = () => ({
+ sameSite: 'lax',
+ secure: config.cookies.secure,
+ httpOnly: true,
+ path: '/auth', // only ever sent back to this session layer
+ maxAge: 30 * 24 * 60 * 60 * 1000,
+ });
+
+ const flowCookieOpts = () => ({
+ sameSite: 'lax', secure: config.cookies.secure, httpOnly: true,
+ path: '/auth', maxAge: 10 * 60 * 1000,
+ });
+
+ const hintCookieOpts = () => ({
+ sameSite: 'lax',
+ secure: config.cookies.secure,
+ httpOnly: false, // navbar.js reads it to decide whether a silent login is worth trying
+ path: '/',
+ maxAge: 365 * 24 * 60 * 60 * 1000,
+ });
+
+ function setSessionCookies(res, accessToken, refreshToken) {
+ res.cookie(ACCESS_COOKIE, accessToken, accessCookieOpts());
+ if (refreshToken) res.cookie(REFRESH_COOKIE, refreshToken, refreshCookieOpts());
+ res.cookie(HINT_COOKIE, 'account', hintCookieOpts());
+ }
+
+ function clearSessionCookies(res) {
+ res.clearCookie(ACCESS_COOKIE, { ...accessCookieOpts(), maxAge: undefined });
+ res.clearCookie(REFRESH_COOKIE, { ...refreshCookieOpts(), maxAge: undefined });
+ }
+
+ function clearFlowCookies(res) {
+ for (const c of [STATE_COOKIE, NEXT_COOKIE, SILENT_COOKIE]) res.clearCookie(c, { path: '/auth' });
+ }
+
+ /** Exchange at the Network — internal URL first, public as fallback. */
+ async function tokenGrant(body) {
+ let lastErr = null;
+ for (const base of [config.networkInternalUrl, config.networkUrl]) {
+ if (!base) continue;
+ try {
+ const res = await fetch(`${base}/oauth/token`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ client_id: config.oauth.clientId,
+ client_secret: config.oauth.clientSecret,
+ ...body,
+ }),
+ signal: AbortSignal.timeout(10_000),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ const err = new Error(data.error_description || data.error || `token grant failed (${res.status})`);
+ err.status = res.status;
+ err.error = data.error || 'invalid_grant';
+ err.error_description = data.error_description || null;
+ throw err;
+ }
+ return data;
+ } catch (err) {
+ lastErr = err;
+ // 4xx = the grant itself is bad; retrying against another base won't help
+ if (err.status && err.status < 500) throw err;
+ }
+ }
+ throw lastErr || new Error('Network unreachable');
+ }
+
+ // ── GET /auth/login ──────────────────────────────────────
+ router.get('/login', async (req, res) => {
+ const silent = !!req.query.silent && req.query.silent !== '0';
+ if (silent) {
+ // Already signed in here? Then the silent round trip through the Network would
+ // only hand back the session we have: go straight to where the caller wanted.
+ const existing = req.cookies?.[ACCESS_COOKIE];
+ if (existing && await auth.verify(existing)) {
+ clearFlowCookies(res);
+ return res.redirect(sanitizeNext(req.query.next, config));
+ }
+ }
+ const { url, state } = buildAuthorizeUrl(config, auth, { silent });
+ res.cookie(STATE_COOKIE, state, flowCookieOpts());
+ const next = sanitizeNext(req.query.next, config);
+ if (next !== '/') res.cookie(NEXT_COOKIE, next, flowCookieOpts());
+ else res.clearCookie(NEXT_COOKIE, { path: '/auth' });
+ if (silent) res.cookie(SILENT_COOKIE, '1', flowCookieOpts());
+ else res.clearCookie(SILENT_COOKIE, { path: '/auth' });
+ res.redirect(url);
+ });
+
+ // ── GET /auth/callback ───────────────────────────────────
+ router.get('/callback', async (req, res) => {
+ const { code, state, error } = req.query;
+ const next = sanitizeNext(req.cookies?.[NEXT_COOKIE], config);
+ const silent = req.cookies?.[SILENT_COOKIE] === '1';
+
+ if (error) {
+ clearFlowCookies(res);
+ // Silent sign-in found no network session — go back quietly as a guest. The
+ // ?sso=none marker stops navbar.js from trying again on that page.
+ if (silent || error === 'login_required') return res.redirect(withParam(next, 'sso', 'none'));
+ return res.redirect(withParam('/', 'auth_error', String(error)));
+ }
+ if (!code) return res.status(400).send('Missing authorization code');
+
+ const expectedState = req.cookies?.[STATE_COOKIE];
+ clearFlowCookies(res);
+ // The state cookie is required, not just compared when present: skipping the check when
+ // the cookie is missing would let a crafted link sign a visitor into someone else's account.
+ if (!expectedState || !state || !crypto.timingSafeEqual(
+ Buffer.from(String(state).padEnd(64).slice(0, 64)),
+ Buffer.from(String(expectedState).padEnd(64).slice(0, 64))
+ )) {
+ return res.status(400).send('OAuth state mismatch — please try signing in again.');
+ }
+
+ try {
+ const data = await tokenGrant({
+ grant_type: 'authorization_code',
+ redirect_uri: config.oauth.redirectUri,
+ code,
+ });
+ setSessionCookies(res, data.access_token, data.refresh_token);
+ return res.redirect(next);
+ } catch (err) {
+ console.error('[Coupons auth] Code exchange failed:', err.message);
+ return res.status(502).send('Sign-in failed — could not reach OpenVibe.Network. Please try again.');
+ }
+ });
+
+ // ── POST /auth/fedcm ─────────────────────────────────────
+ // The shared navbar obtained a FedCM assertion from openvibe.network (the
+ // browser's own account chooser) and posts it same-origin with the nonce it
+ // put in the FedCM request. We only pre-check the nonce; the Network verifies
+ // the assertion's signature when it swaps it for tokens.
+ const fedcmBody = express.json({ limit: '16kb', type: 'application/json' });
+ const fedcmBodyError = (err, _req, res, next) => (err ? res.status(400).json({ error: 'invalid_request', error_description: 'Malformed JSON body' }) : next());
+ router.post('/fedcm', fedcmBody, fedcmBodyError, async (req, res) => {
+ if (!req.is('application/json')) return res.status(400).json({ error: 'invalid_request', error_description: 'Expected application/json' });
+ const { token, nonce } = req.body || {};
+ if (typeof token !== 'string' || !token || typeof nonce !== 'string' || !nonce) {
+ return res.status(400).json({ error: 'invalid_request', error_description: 'token and nonce are required' });
+ }
+ if (!fedcmNonceMatches(token, nonce)) {
+ return res.status(400).json({ error: 'invalid_request', error_description: 'nonce mismatch' });
+ }
+ try {
+ const data = await tokenGrant({
+ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
+ assertion: token,
+ });
+ if (!data.access_token) throw Object.assign(new Error('no token'), { status: 401, error: 'invalid_grant' });
+ setSessionCookies(res, data.access_token, data.refresh_token);
+ const user = data.user || claimsToUser(await auth.verify(data.access_token));
+ return res.json({ ok: true, user });
+ } catch (err) {
+ if (err.status && err.status < 500) {
+ return res.status(401).json({ error: err.error || 'invalid_grant', error_description: err.error_description || err.message });
+ }
+ console.error('[Coupons auth] FedCM exchange failed:', err.message);
+ return res.status(502).json({ error: 'server_error', error_description: 'Could not reach OpenVibe.Network' });
+ }
+ });
+
+ // ── GET /auth/logout ─────────────────────────────────────
+ router.get('/logout', async (req, res) => {
+ // Best-effort refresh revocation. The Network's rotating refresh tokens
+ // self-invalidate, so a failure here is harmless.
+ const refresh = req.cookies?.[REFRESH_COOKIE];
+ if (refresh) {
+ try {
+ await fetch(`${config.networkInternalUrl}/oauth/revoke`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ client_id: config.oauth.clientId,
+ client_secret: config.oauth.clientSecret,
+ token: refresh,
+ }),
+ signal: AbortSignal.timeout(3000),
+ });
+ } catch { /* optional */ }
+ }
+ clearSessionCookies(res);
+ res.cookie(HINT_COOKIE, 'guest', hintCookieOpts());
+ res.redirect(sanitizeNext(req.query.next, config));
+ });
+
+ // ── GET /auth/me ─────────────────────────────────────────
+ router.get('/me', async (req, res) => {
+ const token = extractToken(req);
+ if (!token) return res.status(401).json({ error: 'Not authenticated' });
+ const claims = await auth.verify(token);
+ if (!claims) return res.status(401).json({ error: 'Invalid or expired token' });
+ res.json({ user: claimsToUser(claims), expires_at: claims.exp ? claims.exp * 1000 : null });
+ });
+
+ // ── POST /auth/refresh ───────────────────────────────────
+ // Small endpoint the shared navbar can call when ov_token expires.
+ router.post('/refresh', async (req, res) => {
+ const refresh = req.cookies?.[REFRESH_COOKIE];
+ if (!refresh) return res.status(401).json({ error: 'No refresh token' });
+ try {
+ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: refresh });
+ setSessionCookies(res, data.access_token, data.refresh_token);
+ const claims = await auth.verify(data.access_token);
+ return res.json({ token: data.access_token, user: claimsToUser(claims) });
+ } catch (err) {
+ if (err.status && err.status < 500) {
+ clearSessionCookies(res);
+ return res.status(401).json({ error: 'Refresh token rejected — please sign in again' });
+ }
+ console.error('[Coupons auth] Refresh failed:', err.message);
+ return res.status(502).json({ error: 'Could not reach OpenVibe.Network' });
+ }
+ });
+
+ return router;
+}
+
+/** Attaches req.user (JWT claims) and req.token when a valid ov_token is presented; never blocks. */
+function optionalAuth(auth) {
+ return async (req, _res, next) => {
+ const token = extractToken(req);
+ if (token) {
+ const claims = await auth.verify(token);
+ if (claims) { req.user = claimsToUser(claims); req.token = token; }
+ }
+ next();
+ };
+}
+
+module.exports = { createAuthClient, createAuthRoutes, extractToken, optionalAuth, sanitizeNext, withParam, buildAuthorizeUrl, claimsToUser, decodeJwtPayload, fedcmNonceMatches };
new file mode 100644
new file mode 100644
@@ -0,0 +1,133 @@
@@ -0,0 +1,133 @@
+'use strict';
+
+/**
+ * Who is calling, resolved once per request into `req.viewer`.
+ *
+ * Pages (forms, server-rendered HTML) — viewers.pages():
+ * { kind: 'anonymous' }
+ * { kind: 'user', subject, staff, user } the Network user JWT from the ov_token cookie
+ * Anything else (service tokens, extension tokens) is treated as anonymous: pages are for people.
+ *
+ * API (/api/v1) — viewers.api(): the Authorization header ONLY. Cookies are ignored, so a request a
+ * web page makes with the visitor's ambient cookies carries no identity at all (no CSRF, no data):
+ * { kind: 'anonymous' } no Authorization header
+ * { kind: 'install', install, subject, scopes } Bearer cpx_… (extension install token)
+ * { kind: 'service', service, claims, subject, origin } Bearer Network client-credentials token for
+ * audience openvibe.coupons; X-OV-Subject names
+ * the person it acts for, X-OV-Origin: ai marks
+ * model output (OpenVibe.AI coupons.extract_coupon)
+ * { kind: 'user', subject, staff, user } Bearer Network user JWT (apps)
+ * A presented credential that does not verify is refused (401), never downgraded to anonymous.
+ *
+ * Identity never comes from a request body or query.
+ */
+const contracts = require('openvibe-contracts');
+const { extractToken, claimsToUser, decodeJwtPayload } = require('./sso');
+const { checkCapability } = require('./capabilities');
+const { ApiError } = require('../http/errors');
+
+const { ids, serviceAuth, http } = contracts;
+const PRINCIPAL_SUB = /^(svc|app|mod):/;
+const AUDIENCE = 'openvibe.coupons';
+
+const ANONYMOUS = Object.freeze({ kind: 'anonymous', subject: null, staff: false, origin: 'user' });
+
+function createViewerResolver({ auth, config, installs }) {
+ const staffSubjects = new Set(config.staffSubjects || []);
+
+ function userFromClaims(claims, token) {
+ if (!claims || (typeof claims.sub === 'string' && PRINCIPAL_SUB.test(claims.sub))) return null;
+ const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
+ const staff = Boolean(subject) && (claims.role === 'admin' || staffSubjects.has(subject));
+ return { kind: 'user', subject, staff, origin: 'user', user: claimsToUser(claims), token };
+ }
+
+ async function fromServiceToken(req, token) {
+ const publicKey = await auth.ensureKey();
+ if (!publicKey) throw new ApiError(503, 'identity.unavailable', 'the Network signing key is not loaded yet');
+ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkUrl, audience: AUDIENCE });
+ if (!r.ok) throw new ApiError(401, r.code, r.reason);
+ const originHeader = req.get('x-ov-origin');
+ if (originHeader && originHeader !== 'ai' && originHeader !== 'user') throw new ApiError(400, 'request.invalid_origin', 'X-OV-Origin must be "ai" or "user"');
+ const subjectHeader = req.get('x-ov-subject');
+ let subject = null;
+ if (subjectHeader) {
+ if (!ids.isSubjectId('user', subjectHeader)) throw new ApiError(400, 'subject.invalid', 'X-OV-Subject must be a usr_… subject id');
+ subject = subjectHeader;
+ }
+ return { kind: 'service', service: r.claims.sub, claims: r.claims, subject, origin: originHeader === 'ai' ? 'ai' : 'user', staff: false };
+ }
+
+ /** API callers: the Authorization header only. */
+ async function resolveApi(req) {
+ const header = String(req.headers.authorization || '');
+ if (!header) return ANONYMOUS;
+ if (!header.startsWith('Bearer ')) throw new ApiError(401, 'auth.unsupported', 'use Authorization: Bearer <token>');
+ const token = header.slice(7).trim();
+ if (token.startsWith('cpx_')) {
+ const v = installs.verify(token);
+ return { kind: 'install', install: v.install, subject: v.subject, scopes: v.scopes, staff: false, origin: 'user' };
+ }
+ const payload = decodeJwtPayload(token);
+ if (!payload) throw new ApiError(401, 'token.invalid', 'malformed bearer token');
+ if (typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) return fromServiceToken(req, token);
+ const claims = await auth.verify(token);
+ const user = userFromClaims(claims, token);
+ if (!user) throw new ApiError(401, 'token.invalid', 'invalid or expired token');
+ return user;
+ }
+
+ /** Pages: the ov_token cookie (or a Bearer user token); services and installs are anonymous here. */
+ async function resolvePage(req) {
+ const token = extractToken(req);
+ if (!token || token.startsWith('cpx_')) return ANONYMOUS;
+ const payload = decodeJwtPayload(token);
+ if (!payload || (typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub))) return ANONYMOUS;
+ return userFromClaims(await auth.verify(token), token) || ANONYMOUS;
+ }
+
+ return {
+ resolveApi,
+ resolvePage,
+ api() {
+ return async (req, res, next) => {
+ try {
+ req.viewer = await resolveApi(req);
+ next();
+ } catch (err) {
+ if (!(err instanceof ApiError)) return next(err);
+ res.set('Cache-Control', 'private, no-store');
+ http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });
+ }
+ };
+ },
+ pages() {
+ return async (req, _res, next) => {
+ try { req.viewer = await resolvePage(req); next(); } catch (err) { next(err); }
+ };
+ },
+ };
+}
+
+/**
+ * Route guard for the API. A service token must hold `cap`; an install token must hold `scope`
+ * (when the route accepts installs at all); people and anonymous callers pass here and are judged
+ * by the route itself.
+ */
+function guard(cap, { scope = null } = {}) {
+ return (req, res, next) => {
+ const v = req.viewer;
+ if (v && v.kind === 'service') {
+ const c = checkCapability(v.claims, cap);
+ if (c.allowed) return next();
+ return http.sendProblem(res, 403, c.code, { detail: c.reason, ctx: req.ov });
+ }
+ if (v && v.kind === 'install') {
+ if (scope && v.scopes.includes(scope)) return next();
+ return http.sendProblem(res, 403, 'token.scope', { detail: scope ? `this extension token lacks the ${scope} scope` : 'extension tokens cannot use this route', ctx: req.ov });
+ }
+ return next();
+ };
+}
+
+module.exports = { createViewerResolver, guard, ANONYMOUS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,135 @@
@@ -0,0 +1,135 @@
+'use strict';
+
+/**
+ * Import from OpenVibe.Sources, category `coupons` (the seeded source is `staff-coupon-codes`, a
+ * manual source: codes a merchant published itself, entered by staff with the evidence URL).
+ *
+ * GET {OV_SOURCES_INTERNAL_URL}/api/v1/items?category=coupons&after=<cursor>&include_removed=1
+ * with a client-credentials token: [coupons, sources.item.read, openvibe.sources]
+ *
+ * For each item, in change order:
+ * removed its evidence is withdrawn; a code left with no evidence is disabled
+ * kind 'coupon' + fields.code on a host a merchant covers
+ * a code (status unknown — an item is evidence that a code was published,
+ * never that it works), or new evidence on the same code; its expiry is
+ * fields.expires when the item states one, otherwise unknown
+ * anything else HELD in coupon_import_holds with the reason (no_merchant, not_a_coupon,
+ * no_code, expired, invalid, not_selected) — never silently dropped;
+ * no_merchant holds are retried every run (staff add the merchant)
+ * Imported codes wait for staff review unless COUPONS_SOURCES_AUTO_PUBLISH=true.
+ * A failed fetch changes nothing and is recorded in import_state.last_error.
+ */
+const { serviceAuth } = require('openvibe-contracts');
+const hosts = require('../domain/hosts');
+
+const STATE_KEY = 'sources:coupons';
+
+function createSourcesImporter({ store, config, merchants, coupons, fetchImpl = globalThis.fetch, log = console }) {
+ const { db } = store;
+ const enabled = Boolean(config.sources.internalUrl && config.oauth.clientSecret);
+ const tokens = enabled ? serviceAuth.createTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.sources', scope: 'sources.item.read', fetchImpl,
+ }) : null;
+ const q = {
+ state: db.prepare('SELECT * FROM import_state WHERE key = ?'),
+ ensure: db.prepare('INSERT OR IGNORE INTO import_state (key, cursor) VALUES (?, 0)'),
+ cursor: db.prepare('UPDATE import_state SET cursor = ?, last_run_at = ?, last_ok_at = ?, last_error = NULL WHERE key = ?'),
+ failed: db.prepare('UPDATE import_state SET last_run_at = ?, last_error = ? WHERE key = ?'),
+ hold: db.prepare(`INSERT INTO coupon_import_holds (item_id, source_key, reason, detail, item, attempts, created_at, updated_at)
+ VALUES (@item_id, @source_key, @reason, @detail, @item, 1, @now, @now)
+ ON CONFLICT (item_id) DO UPDATE SET reason = excluded.reason, detail = excluded.detail, item = excluded.item,
+ attempts = attempts + 1, updated_at = excluded.updated_at, resolved_at = NULL`),
+ resolve: db.prepare('UPDATE coupon_import_holds SET resolved_at = ?, updated_at = ? WHERE item_id = ? AND resolved_at IS NULL'),
+ retryable: db.prepare("SELECT item FROM coupon_import_holds WHERE resolved_at IS NULL AND reason = 'no_merchant' ORDER BY updated_at LIMIT 100"),
+ open: db.prepare('SELECT item_id, source_key, reason, detail, attempts, created_at, updated_at FROM coupon_import_holds WHERE resolved_at IS NULL ORDER BY updated_at DESC LIMIT 200'),
+ openCount: db.prepare('SELECT COUNT(*) AS n FROM coupon_import_holds WHERE resolved_at IS NULL'),
+ };
+ q.ensure.run(STATE_KEY);
+
+ function parseItem(item) {
+ const f = item.fields && typeof item.fields === 'object' ? item.fields : {};
+ const code = typeof f.code === 'string' ? f.code.trim() : '';
+ const title = typeof item.title === 'string' && item.title.trim().length >= 3 && item.title.trim().toUpperCase() !== code.toUpperCase()
+ ? item.title : `Code ${code}`;
+ const restrictions = {};
+ if (f.min_spend != null && f.currency) restrictions.min_spend = { amount: String(f.min_spend), currency: f.currency };
+ if (f.new_customers_only === true) restrictions.new_customers_only = true;
+ if (typeof f.region === 'string') restrictions.regions = f.region;
+ if (typeof f.category === 'string') restrictions.categories = f.category;
+ return coupons.parseSubmission({ code, title, description: item.summary || null, expires: f.expires || f.expires_at || null, restrictions }, store.now());
+ }
+
+ /** Handle one item inside a transaction. → 'imported' | 'updated' | 'withdrawn' | 'held:<reason>' */
+ function handle(item) {
+ const now = store.now();
+ const held = (reason, detail = null) => {
+ q.hold.run({ item_id: item.id, source_key: item.source_key || null, reason, detail: detail ? String(detail).slice(0, 300) : null, item: JSON.stringify(item), now });
+ return `held:${reason}`;
+ };
+ return store.tx(() => {
+ if (item.removed) {
+ coupons.withdrawSourceItem(item.id, item.removed.reason);
+ q.resolve.run(now, now, item.id);
+ return 'withdrawn';
+ }
+ if (config.sources.keys.length && !config.sources.keys.includes(item.source_key)) return held('not_selected', `source ${item.source_key} is not in COUPONS_SOURCES_KEYS`);
+ if (item.kind !== 'coupon') return held('not_a_coupon', `kind ${item.kind}`);
+ if (!item.fields || typeof item.fields.code !== 'string' || !item.fields.code.trim()) return held('no_code');
+ let parsed;
+ try { parsed = parseItem(item); } catch (err) {
+ return held(err.code === 'coupon.already_expired' ? 'expired' : 'invalid', err.message);
+ }
+ const at = hosts.hostOfUrl(item.canonical_url);
+ const found = at ? merchants.resolve(at.host, { path: at.path, includeStatuses: ['active', 'pending'] }) : null;
+ if (!found) return held('no_merchant', at ? at.host : 'no canonical URL');
+ const r = coupons.importFromSource(found.merchant, item, parsed, { autoPublish: config.sources.autoPublish });
+ q.resolve.run(now, now, item.id);
+ return r.created ? 'imported' : 'updated';
+ });
+ }
+
+ async function getPage(after) {
+ const url = `${config.sources.internalUrl}/api/v1/items?category=coupons&include_removed=1&limit=100&after=${after}`;
+ const res = await fetchImpl(url, { headers: { Accept: 'application/json', ...(await tokens.authHeaders()) }, signal: AbortSignal.timeout(10000) });
+ if (res.status === 401 && tokens.invalidate) tokens.invalidate();
+ const body = await res.json().catch(() => null);
+ if (!res.ok || !body || !Array.isArray(body.items)) throw new Error(`Sources answered ${res.status}`);
+ return body;
+ }
+
+ /** One import run. → { outcomes: {…}, cursor } or { error } */
+ async function run({ maxPages = 10 } = {}) {
+ if (!enabled) return { disabled: true };
+ const outcomes = {};
+ const count = (o) => { outcomes[o] = (outcomes[o] || 0) + 1; };
+ let cursor = q.state.get(STATE_KEY).cursor;
+ try {
+ for (let i = 0; i < maxPages; i++) {
+ const page = await getPage(cursor);
+ for (const item of page.items) count(handle(item));
+ cursor = page.next_after != null ? Number(page.next_after) : cursor;
+ q.cursor.run(cursor, store.now(), store.now(), STATE_KEY);
+ if (!page.more) break;
+ }
+ for (const { item } of q.retryable.all()) count(`retry:${handle(JSON.parse(item))}`);
+ q.cursor.run(cursor, store.now(), store.now(), STATE_KEY);
+ return { outcomes, cursor };
+ } catch (err) {
+ q.failed.run(store.now(), String(err.message).slice(0, 300), STATE_KEY);
+ log.warn('[Coupons] Sources import failed (nothing changed):', err.message);
+ return { error: err.message, outcomes, cursor };
+ }
+ }
+
+ return {
+ enabled,
+ run,
+ handle,
+ state: () => q.state.get(STATE_KEY),
+ holds: () => q.open.all(),
+ holdCount: () => q.openCount.get().n,
+ };
+}
+
+module.exports = { createSourcesImporter };
new file mode 100644
new file mode 100644
@@ -0,0 +1,101 @@
@@ -0,0 +1,101 @@
+'use strict';
+
+/**
+ * OpenVibe.Coupons configuration. Every value comes from the environment (production:
+ * /etc/openvibe/coupons.env, see .env.example). Only environment variable NAMES appear in code and
+ * docs; secrets are never logged.
+ *
+ * load(env) is pure so tests can build a config without touching process.env.
+ */
+require('dotenv').config();
+
+const trim = (s) => String(s || '').replace(/\/+$/, '');
+const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);
+const list = (v) => String(v || '').split(',').map((s) => s.trim()).filter(Boolean);
+
+function load(env = process.env) {
+ const nodeEnv = env.NODE_ENV || 'development';
+ const isProduction = nodeEnv === 'production';
+ const port = int(env.PORT, 4850);
+ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.coupons' : `http://localhost:${port}`));
+
+ return {
+ service: 'coupons',
+ port,
+ host: env.HOST || '127.0.0.1',
+ nodeEnv,
+ isProduction,
+ // Public origin: canonical URLs, feeds, sitemaps and JSON-LD are built from it.
+ baseUrl,
+ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
+
+ dbPath: env.COUPONS_DB_PATH || './data/coupons.db',
+
+ // OpenVibe.Network: SSO (OAuth2 authorization server), JWKS, client-credentials tokens.
+ networkUrl: trim(env.OV_NETWORK_URL || 'https://openvibe.network'),
+ networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
+ oauth: {
+ clientId: env.OV_OAUTH_CLIENT_ID || 'coupons',
+ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
+ redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,
+ scope: 'profile theme',
+ },
+ cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
+ // Signs the per-session form token (CSRF). Unset: a random per-process key.
+ formSecret: env.COUPONS_FORM_SECRET || '',
+ // Keys the reporter column of coupon_validation_reports (HMAC of the person's subject), so
+ // the table never holds who reported what in the clear. Unset: a fixed development key and
+ // /api/ready says so. Changing it restarts same-day deduplication.
+ reporterKeySecret: env.COUPONS_REPORTER_KEY_SECRET || '',
+
+ // Staff (merchant approval, moderation): Network admins, plus these subjects (usr_…).
+ staffSubjects: list(env.COUPONS_STAFF_SUBJECTS),
+
+ // Browser-extension origins allowed by CORS on the two lookup routes ONLY (never on
+ // reports, submissions or pages). Exact origins (chrome-extension://<id>) or the literal
+ // pattern moz-extension://* (Firefox gives every install a random origin). Empty: no CORS
+ // at all — the extension still works through its host permission for the API origin.
+ extensionOrigins: list(env.COUPONS_EXTENSION_ORIGINS),
+
+ limits: {
+ // Lookup routes, per minute: anonymous callers by IP, extension installs by install.
+ lookupAnonPerMin: int(env.COUPONS_LOOKUP_ANON_PER_MIN, 30),
+ lookupTokenPerMin: int(env.COUPONS_LOOKUP_TOKEN_PER_MIN, 120),
+ lookupServicePerMin: int(env.COUPONS_LOOKUP_SERVICE_PER_MIN, 600),
+ // Validity reports per person (every channel together: site, extension, API).
+ reportsPerHour: int(env.COUPONS_REPORTS_PER_HOUR, 20),
+ reportsPerDay: int(env.COUPONS_REPORTS_PER_DAY, 60),
+ // Submissions per person.
+ submissionsPerHour: int(env.COUPONS_SUBMISSIONS_PER_HOUR, 10),
+ submissionsPerDay: int(env.COUPONS_SUBMISSIONS_PER_DAY, 30),
+ // Active extension installs per person.
+ installsPerSubject: int(env.COUPONS_INSTALLS_PER_SUBJECT, 10),
+ },
+ // Extension install tokens expire after this many days (a new one is one click away).
+ installTtlDays: int(env.COUPONS_INSTALL_TTL_DAYS, 365),
+
+ // OpenVibe.Sources: codes from the `coupons` category (e.g. staff-coupon-codes, a manual
+ // source) are imported when OV_SOURCES_INTERNAL_URL and the client secret are set.
+ sources: {
+ internalUrl: trim(env.OV_SOURCES_INTERNAL_URL || ''),
+ keys: list(env.COUPONS_SOURCES_KEYS),
+ intervalMs: int(env.COUPONS_SOURCES_INTERVAL_MS, 10 * 60 * 1000),
+ // false (default): imported codes wait for staff review before they are listed.
+ autoPublish: env.COUPONS_SOURCES_AUTO_PUBLISH === 'true',
+ },
+
+ // OpenVibe.Events: the outbox relay runs only when EVENTS_URL and the client secret are set.
+ events: {
+ url: trim(env.EVENTS_URL || ''),
+ intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
+ },
+
+ // Expiry and confidence-decay sweep.
+ worker: {
+ enabled: env.COUPONS_WORKER !== 'off',
+ intervalMs: int(env.COUPONS_SWEEP_INTERVAL_MS, 60 * 1000),
+ },
+ };
+}
+
+module.exports = { load };
new file mode 100644
new file mode 100644
@@ -0,0 +1,226 @@
@@ -0,0 +1,226 @@
+'use strict';
+
+/**
+ * Coupons' own SQLite database, created on boot, idempotently. Nothing here is shared with another
+ * service.
+ *
+ * The nine charter tables (roadmap §15.13):
+ *
+ * coupon_merchants merchants: pending (member-proposed) | active | disabled
+ * coupon_merchant_domains host rules: exact host or host + subdomains, optional path prefix
+ * coupons codes: status unknown|reported_working|reported_failed|expired|disabled,
+ * confidence (NULL = no recent reports), expiry (NULL = unknown)
+ * coupon_sources evidence: member submissions, OpenVibe.Sources items, staff entries
+ * coupon_restrictions min spend, categories, new customers only, regions, other
+ * coupon_validation_reports worked/failed reports, one row per (reporter, coupon, UTC day);
+ * the reporter is an HMAC of the person's subject, never returned
+ * coupon_status_history every status/confidence change with its reason and actor
+ * coupon_application_hints where and how to apply a code, per merchant or per code
+ * coupon_watches members watching a merchant
+ *
+ * Also here: extension_installs (cpx_ install tokens, hashed), import_state and
+ * coupon_import_holds (the OpenVibe.Sources importer), the SDK's event_outbox, and
+ * <coupons>_index_revisions (openvibe-publishing index sequencer).
+ */
+const fs = require('fs');
+const path = require('path');
+const Database = require('better-sqlite3');
+const { createIndexSequencer } = require('openvibe-publishing/index-hooks');
+
+const STATUSES = Object.freeze(['unknown', 'reported_working', 'reported_failed', 'expired', 'disabled']);
+
+const SCHEMA = `
+CREATE TABLE IF NOT EXISTS coupon_merchants (
+ id TEXT PRIMARY KEY, -- mer_<ULID>
+ slug TEXT NOT NULL UNIQUE, -- /m/:slug
+ name TEXT NOT NULL,
+ homepage_url TEXT, -- https://<registrable domain>/ unless staff set another
+ description TEXT,
+ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','active','disabled')),
+ created_by TEXT, -- usr_… | svc:<id> | system
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS coupon_merchant_domains (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ merchant_id TEXT NOT NULL REFERENCES coupon_merchants(id),
+ host TEXT NOT NULL, -- normalized (lowercase, punycode, no port, no trailing dot)
+ registrable_domain TEXT NOT NULL, -- eTLD+1 of host (bundled public-suffix subset)
+ include_subdomains INTEGER NOT NULL DEFAULT 1,
+ path_prefix TEXT NOT NULL DEFAULT '', -- '' = the whole host; '/shop/x' matches only with a path
+ created_by TEXT,
+ created_at INTEGER NOT NULL,
+ UNIQUE (host, path_prefix)
+);
+CREATE INDEX IF NOT EXISTS coupon_merchant_domains_registrable ON coupon_merchant_domains (registrable_domain);
+CREATE INDEX IF NOT EXISTS coupon_merchant_domains_merchant ON coupon_merchant_domains (merchant_id);
+
+CREATE TABLE IF NOT EXISTS coupons (
+ id TEXT PRIMARY KEY, -- cpn_<ULID>
+ merchant_id TEXT NOT NULL REFERENCES coupon_merchants(id),
+ code TEXT NOT NULL, -- as the merchant writes it
+ code_key TEXT NOT NULL, -- uppercase, for duplicate detection
+ title TEXT NOT NULL,
+ description TEXT,
+ status TEXT NOT NULL DEFAULT 'unknown'
+ CHECK (status IN ('unknown','reported_working','reported_failed','expired','disabled')),
+ status_reason TEXT,
+ confidence REAL, -- NULL: no report in the window (validity unknown)
+ review_state TEXT NOT NULL DEFAULT 'published' CHECK (review_state IN ('pending','published')),
+ origin TEXT NOT NULL CHECK (origin IN ('member','staff','source','ai')),
+ expires_at INTEGER, -- NULL: expiry unknown (never guessed)
+ expires_precision TEXT CHECK (expires_precision IN ('instant','date')),
+ expiry_basis TEXT CHECK (expiry_basis IN ('evidence','submitter','source','staff')),
+ expired_at INTEGER, -- when it left active results as expired
+ disabled_at INTEGER,
+ last_report_at INTEGER,
+ last_worked_at INTEGER,
+ last_failed_at INTEGER,
+ created_by TEXT, -- usr_… | svc:<id>; never returned by the API
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL,
+ CHECK (expires_at IS NULL OR expires_precision IS NOT NULL),
+ UNIQUE (merchant_id, code_key)
+);
+CREATE INDEX IF NOT EXISTS coupons_active ON coupons (merchant_id, status, review_state, expires_at);
+CREATE INDEX IF NOT EXISTS coupons_expiry ON coupons (expires_at) WHERE expires_at IS NOT NULL;
+CREATE INDEX IF NOT EXISTS coupons_recent ON coupons (created_at);
+
+CREATE TABLE IF NOT EXISTS coupon_sources (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ coupon_id TEXT NOT NULL REFERENCES coupons(id),
+ kind TEXT NOT NULL CHECK (kind IN ('member','staff','source','ai')),
+ evidence_url TEXT, -- where the code was published (never fetched by Coupons)
+ merchant_evidence INTEGER NOT NULL DEFAULT 0, -- evidence_url is on one of the merchant's own domains
+ sources_item_id TEXT, -- itm_… (OpenVibe.Sources)
+ sources_item_rev INTEGER,
+ source_key TEXT,
+ retrieved_at INTEGER, -- Sources' observation time
+ ai_run_id TEXT, -- OpenVibe.AI run (coupons.extract_coupon), when origin is ai
+ submitted_by TEXT, -- usr_… | svc:<id>; never returned by the API
+ removed_at INTEGER,
+ removed_reason TEXT,
+ created_at INTEGER NOT NULL
+);
+CREATE INDEX IF NOT EXISTS coupon_sources_coupon ON coupon_sources (coupon_id);
+CREATE UNIQUE INDEX IF NOT EXISTS coupon_sources_item ON coupon_sources (coupon_id, sources_item_id) WHERE sources_item_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS coupon_sources_submitter ON coupon_sources (submitted_by, created_at);
+
+CREATE TABLE IF NOT EXISTS coupon_restrictions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ coupon_id TEXT NOT NULL REFERENCES coupons(id),
+ kind TEXT NOT NULL CHECK (kind IN ('min_spend','category','new_customers_only','region','other')),
+ value TEXT, -- category name, ISO 3166-1 alpha-2 region, free text
+ amount_minor INTEGER, -- min_spend in minor units
+ currency TEXT, -- ISO 4217
+ created_at INTEGER NOT NULL,
+ CHECK (kind <> 'min_spend' OR (amount_minor IS NOT NULL AND currency IS NOT NULL))
+);
+CREATE INDEX IF NOT EXISTS coupon_restrictions_coupon ON coupon_restrictions (coupon_id);
+
+CREATE TABLE IF NOT EXISTS coupon_validation_reports (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ coupon_id TEXT NOT NULL REFERENCES coupons(id),
+ reporter_key TEXT NOT NULL, -- HMAC(COUPONS_REPORTER_KEY_SECRET, subject)
+ channel TEXT NOT NULL CHECK (channel IN ('site','extension','api')),
+ install_id TEXT, -- cpi_… when reported through the extension
+ day TEXT NOT NULL, -- UTC YYYY-MM-DD: one report per reporter, coupon and day
+ outcome TEXT NOT NULL CHECK (outcome IN ('worked','failed')),
+ reason TEXT CHECK (reason IN ('invalid','expired','min_spend_not_met','not_eligible','other')),
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL,
+ UNIQUE (coupon_id, reporter_key, day)
+);
+CREATE INDEX IF NOT EXISTS coupon_reports_coupon ON coupon_validation_reports (coupon_id, updated_at);
+CREATE INDEX IF NOT EXISTS coupon_reports_reporter ON coupon_validation_reports (reporter_key, created_at);
+
+CREATE TABLE IF NOT EXISTS coupon_status_history (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ coupon_id TEXT NOT NULL REFERENCES coupons(id),
+ from_status TEXT,
+ to_status TEXT NOT NULL,
+ confidence_before REAL,
+ confidence_after REAL,
+ reason TEXT NOT NULL, -- created | report | decay | expiry | staff:<note> | service:<id>
+ actor TEXT NOT NULL, -- system | usr_… | svc:<id>
+ created_at INTEGER NOT NULL
+);
+CREATE INDEX IF NOT EXISTS coupon_status_history_coupon ON coupon_status_history (coupon_id, id);
+
+CREATE TABLE IF NOT EXISTS coupon_application_hints (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ merchant_id TEXT NOT NULL REFERENCES coupon_merchants(id),
+ coupon_id TEXT REFERENCES coupons(id), -- NULL: applies to every code of the merchant
+ text TEXT NOT NULL,
+ created_by TEXT,
+ created_at INTEGER NOT NULL
+);
+CREATE INDEX IF NOT EXISTS coupon_hints_merchant ON coupon_application_hints (merchant_id);
+
+CREATE TABLE IF NOT EXISTS coupon_watches (
+ subject TEXT NOT NULL, -- usr_…
+ merchant_id TEXT NOT NULL REFERENCES coupon_merchants(id),
+ created_at INTEGER NOT NULL,
+ PRIMARY KEY (subject, merchant_id)
+);
+
+CREATE TABLE IF NOT EXISTS extension_installs (
+ id TEXT PRIMARY KEY, -- cpi_<ULID>
+ subject TEXT NOT NULL, -- usr_… who connected it
+ token_hash TEXT NOT NULL UNIQUE, -- sha256 of the cpx_ token (the token is shown once)
+ token_hint TEXT NOT NULL, -- first characters, to recognise it in the list
+ label TEXT NOT NULL,
+ scopes TEXT NOT NULL, -- JSON array: coupons.lookup, coupons.report
+ created_at INTEGER NOT NULL,
+ expires_at INTEGER NOT NULL,
+ last_used_at INTEGER,
+ revoked_at INTEGER
+);
+CREATE INDEX IF NOT EXISTS extension_installs_subject ON extension_installs (subject, revoked_at);
+
+CREATE TABLE IF NOT EXISTS import_state (
+ key TEXT PRIMARY KEY,
+ cursor INTEGER NOT NULL DEFAULT 0,
+ last_run_at INTEGER,
+ last_ok_at INTEGER,
+ last_error TEXT
+);
+
+CREATE TABLE IF NOT EXISTS coupon_import_holds (
+ item_id TEXT PRIMARY KEY, -- itm_… held, never silently dropped
+ source_key TEXT,
+ reason TEXT NOT NULL, -- no_merchant | not_a_coupon | no_code | invalid
+ detail TEXT,
+ item TEXT NOT NULL, -- the item as Sources returned it (JSON)
+ attempts INTEGER NOT NULL DEFAULT 1,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL,
+ resolved_at INTEGER
+);
+`;
+
+/**
+ * Open (or create) the database.
+ * opts.now — injectable clock (epoch ms), so tests and replays are deterministic.
+ */
+function openStore(dbPath, { now = () => Date.now() } = {}) {
+ if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });
+ const db = new Database(dbPath);
+ db.pragma('journal_mode = WAL');
+ db.pragma('foreign_keys = ON');
+ db.pragma('busy_timeout = 5000');
+ db.exec(SCHEMA);
+ return {
+ db,
+ now,
+ sequencer: createIndexSequencer(db, { prefix: 'coupons', now }),
+ tx: (fn) => db.transaction(fn)(),
+ close: () => db.close(),
+ };
+}
+
+const CHARTER_TABLES = ['coupon_merchants', 'coupon_merchant_domains', 'coupons', 'coupon_sources', 'coupon_restrictions',
+ 'coupon_validation_reports', 'coupon_status_history', 'coupon_application_hints', 'coupon_watches'];
+
+module.exports = { openStore, CHARTER_TABLES, STATUSES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -0,0 +1,107 @@
+'use strict';
+
+/**
+ * Confidence and status from people's reports — a pure function of (reports, evidence, expiry,
+ * disabled flag, now). Nothing else can make a code "working": not a submission, not a model, not
+ * a source; only reports by people, and only while they are recent.
+ *
+ * THE FORMULA (documented in the README; tests pin it)
+ *
+ * Deduplication: one row per (reporter, coupon, UTC day) is stored; of those, only each
+ * reporter's MOST RECENT report counts. Reporting the same code every day adds nothing.
+ *
+ * Recency: a report of age a days weighs w = 0.5 ^ (a / HALF_LIFE_DAYS) (half-life 7 days)
+ * and reports older than WINDOW_DAYS (30) weigh 0.
+ *
+ * W = Σ w over counted "worked" reports, F = Σ w over counted "failed" reports.
+ *
+ * Evidence: the prior is Beta(α0, β0) with β0 = 1 and α0 = 1, or 1.5 when the code has merchant
+ * evidence (it was published on one of the merchant's own domains). Evidence alone never produces
+ * a number.
+ *
+ * confidence = null when W + F = 0 (no counted report in the window)
+ * = (α0 + W) / (α0 + β0 + W + F) otherwise, rounded to 2 decimals
+ *
+ * status = disabled when staff or a service disabled it
+ * = expired when a known expiry has passed (or staff marked it expired)
+ * = unknown when W + F < MIN_MASS (0.5: roughly one report younger than a week)
+ * = reported_working when confidence ≥ 0.6
+ * = reported_failed when confidence ≤ 0.4
+ * = unknown otherwise (the reports disagree)
+ *
+ * So one fresh "worked" report gives 0.67 → reported_working; one fresh "failed" gives 0.33 →
+ * reported_failed; one of each gives 0.5 → unknown; and a lone report decays back to unknown after
+ * a week, and out of the formula (confidence null) after 30 days.
+ */
+const DAY = 24 * 60 * 60 * 1000;
+const HALF_LIFE_DAYS = 7;
+const WINDOW_DAYS = 30;
+const MIN_MASS = 0.5;
+const WORKING_AT = 0.6;
+const FAILED_AT = 0.4;
+const EVIDENCE_PRIOR = 0.5;
+
+const round2 = (x) => Math.round(x * 100) / 100;
+
+function weight(ageMs) {
+ if (ageMs < 0) ageMs = 0;
+ if (ageMs > WINDOW_DAYS * DAY) return 0;
+ return Math.pow(0.5, ageMs / (HALF_LIFE_DAYS * DAY));
+}
+
+/**
+ * reports: [{ reporter_key, outcome: 'worked'|'failed', updated_at }] (any order, any number per reporter)
+ * → { confidence, W, F, mass, counted, derived: 'unknown'|'reported_working'|'reported_failed' }
+ */
+function fromReports(reports, { merchantEvidence = false, now }) {
+ if (!Number.isFinite(now)) throw new TypeError('now is required');
+ const latest = new Map();
+ for (const r of reports) {
+ const cur = latest.get(r.reporter_key);
+ if (!cur || r.updated_at > cur.updated_at) latest.set(r.reporter_key, r);
+ }
+ let W = 0;
+ let F = 0;
+ let counted = 0;
+ for (const r of latest.values()) {
+ const w = weight(now - r.updated_at);
+ if (w <= 0) continue;
+ counted++;
+ if (r.outcome === 'worked') W += w; else F += w;
+ }
+ const mass = W + F;
+ if (mass <= 0) return { confidence: null, W: 0, F: 0, mass: 0, counted: 0, derived: 'unknown' };
+ const a0 = 1 + (merchantEvidence ? EVIDENCE_PRIOR : 0);
+ const confidence = round2((a0 + W) / (a0 + 1 + W + F));
+ let derived = 'unknown';
+ if (mass >= MIN_MASS) {
+ if (confidence >= WORKING_AT) derived = 'reported_working';
+ else if (confidence <= FAILED_AT) derived = 'reported_failed';
+ }
+ return { confidence, W: round2(W), F: round2(F), mass: round2(mass), counted, derived };
+}
+
+/**
+ * The full status of a coupon row at `now`.
+ * coupon: { status, expires_at, expired_at, disabled_at }
+ * → { status, confidence, reason }
+ */
+function evaluate(coupon, reports, { merchantEvidence = false, now }) {
+ const r = fromReports(reports, { merchantEvidence, now });
+ if (coupon.status === 'disabled') return { status: 'disabled', confidence: r.confidence, detail: r };
+ const expiredByTime = coupon.expires_at != null && coupon.expires_at <= now;
+ if (expiredByTime || (coupon.status === 'expired' && coupon.expired_at != null)) return { status: 'expired', confidence: r.confidence, detail: r };
+ return { status: r.derived, confidence: r.confidence, detail: r };
+}
+
+/** Is the coupon in active results at `now`? (Also enforced in SQL: see coupons.ACTIVE_SQL.) */
+function isActive(coupon, now) {
+ if (coupon.review_state !== 'published') return false;
+ if (coupon.status === 'expired' || coupon.status === 'disabled') return false;
+ return coupon.expires_at == null || coupon.expires_at > now;
+}
+
+module.exports = {
+ fromReports, evaluate, isActive, weight,
+ HALF_LIFE_DAYS, WINDOW_DAYS, MIN_MASS, WORKING_AT, FAILED_AT, EVIDENCE_PRIOR, DAY,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,522 @@
@@ -0,0 +1,522 @@
+'use strict';
+
+/**
+ * Codes: submission, restrictions, hints, evidence, status and confidence, expiry.
+ *
+ * Status is exactly unknown | reported_working | reported_failed | expired | disabled, and only
+ * three things move it:
+ * - people's reports (reports.js) through the confidence formula (confidence.js);
+ * - time: a known expiry passing (and old reports decaying back to unknown);
+ * - staff or a service holding coupons.status.update, and only to disabled / expired / back to
+ * active (which recomputes from reports). Nobody can SET reported_working or reported_failed.
+ * A submission never carries a status or a confidence: those fields are refused, from people, from
+ * services and from AI output alike.
+ *
+ * Unknown stays unknown: expires_at NULL is "expiry unknown" (never defaulted), confidence NULL is
+ * "no recent reports", and "no restrictions stated" is not "no restrictions".
+ *
+ * Active results = published review state, merchant active, status not expired/disabled, and
+ * (expires_at IS NULL OR expires_at > now). The time condition is in the query itself, so a code
+ * leaves every active list at the instant it expires, before the sweep records the transition.
+ */
+const { ids } = require('openvibe-contracts');
+const { ApiError } = require('../http/errors');
+const confidence = require('./confidence');
+const hosts = require('./hosts');
+
+const ACTIVE_SQL = `c.review_state = 'published' AND c.status NOT IN ('expired','disabled')
+ AND (c.expires_at IS NULL OR c.expires_at > @now) AND m.status = 'active'`;
+
+const CODE_RE = /^[\x21-\x7E]{1,64}$/;
+const REGION_RE = /^[A-Z]{2}$/;
+const CURRENCY_RE = /^[A-Z]{3}$/;
+const ZERO_DECIMAL = new Set(['JPY', 'KRW', 'VND', 'CLP', 'ISK', 'UGX', 'XAF', 'XOF', 'PYG', 'RWF', 'KMF', 'GNF', 'DJF', 'BIF', 'VUV', 'XPF']);
+const THREE_DECIMAL = new Set(['BHD', 'KWD', 'OMR', 'JOD', 'TND', 'IQD', 'LYD']);
+const FORBIDDEN_FIELDS = ['status', 'confidence', 'verified', 'working', 'valid', 'validity'];
+const DAY = 24 * 60 * 60 * 1000;
+
+const exponent = (cur) => (ZERO_DECIMAL.has(cur) ? 0 : THREE_DECIMAL.has(cur) ? 3 : 2);
+const text = (v, max) => (v == null ? '' : String(v).replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '').trim().slice(0, max));
+const asList = (v) => (Array.isArray(v) ? v : v == null || v === '' ? [] : String(v).split(','));
+
+/** '50' | '49.99' | 50 in `currency` → minor units, or throws. */
+function toMinor(amount, currency) {
+ const s = String(amount).trim();
+ const e = exponent(currency);
+ const re = e === 0 ? /^\d{1,9}$/ : new RegExp(`^\\d{1,9}(\\.\\d{1,${e}})?$`);
+ if (!re.test(s)) throw new ApiError(422, 'coupon.bad_min_spend', `min spend must be a plain amount with at most ${e} decimals`);
+ const [whole, frac = ''] = s.split('.');
+ return Number(whole) * 10 ** e + Number((frac + '000').slice(0, e) || 0);
+}
+
+function formatMinor(minor, currency) {
+ const e = exponent(currency);
+ const v = minor / 10 ** e;
+ try { return new Intl.NumberFormat('en', { style: 'currency', currency, minimumFractionDigits: e, maximumFractionDigits: e }).format(v); } catch { return `${v.toFixed(e)} ${currency}`; }
+}
+
+/**
+ * Expiry input → { expires_at, expires_precision } | null (unknown).
+ * 'YYYY-MM-DD' → the end of that day in UTC (the merchant's time zone is not known; documented);
+ * a date-time needs an explicit offset or Z (a zone-less time is ambiguous and refused).
+ */
+function parseExpiry(v, now) {
+ if (v == null || v === '') return null;
+ const s = String(v).trim();
+ let at;
+ let precision;
+ let m = s.match(/^(\d{4})-(\d{2})-(\d{2})$/);
+ if (m) {
+ at = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 23, 59, 59, 999);
+ const d = new Date(at);
+ if (d.getUTCFullYear() !== Number(m[1]) || d.getUTCMonth() !== Number(m[2]) - 1 || d.getUTCDate() !== Number(m[3])) at = NaN;
+ precision = 'date';
+ } else if ((m = s.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2})$/))) {
+ at = Date.parse(s);
+ precision = 'instant';
+ } else if (/^\d{4}-\d{2}-\d{2}T/.test(s)) {
+ throw new ApiError(422, 'coupon.ambiguous_expiry', 'an expiry time needs a time zone (Z or ±hh:mm); or give just the date');
+ } else {
+ throw new ApiError(422, 'coupon.bad_expiry', 'expires must be YYYY-MM-DD or an ISO 8601 date-time with a time zone');
+ }
+ if (!Number.isFinite(at)) throw new ApiError(422, 'coupon.bad_expiry', 'expires is not a real date');
+ if (at <= now) throw new ApiError(422, 'coupon.already_expired', 'that expiry has already passed');
+ if (at > now + 5 * 365 * DAY) throw new ApiError(422, 'coupon.bad_expiry', 'expiry more than five years ahead');
+ return { expires_at: at, expires_precision: precision };
+}
+
+/** Restrictions input (API object or form fields) → rows. Nothing stated → []. */
+function parseRestrictions(r = {}) {
+ const out = [];
+ const min = r.min_spend;
+ if (min && (min.amount !== undefined && min.amount !== '')) {
+ const currency = String(min.currency || '').trim().toUpperCase();
+ if (!CURRENCY_RE.test(currency)) throw new ApiError(422, 'coupon.bad_currency', 'min spend needs an ISO 4217 currency (e.g. USD)');
+ out.push({ kind: 'min_spend', value: null, amount_minor: toMinor(min.amount, currency), currency });
+ }
+ const cats = asList(r.categories).map((c) => text(c, 60)).filter(Boolean);
+ if (cats.length > 10) throw new ApiError(422, 'coupon.too_many_categories', 'at most 10 categories');
+ for (const c of new Set(cats)) out.push({ kind: 'category', value: c });
+ if (r.new_customers_only === true || r.new_customers_only === 'true' || r.new_customers_only === 'on' || r.new_customers_only === '1') {
+ out.push({ kind: 'new_customers_only', value: null });
+ }
+ const regions = asList(r.regions).map((x) => String(x).trim().toUpperCase()).filter(Boolean);
+ for (const x of regions) if (!REGION_RE.test(x)) throw new ApiError(422, 'coupon.bad_region', `region ${x} is not an ISO 3166-1 alpha-2 code`);
+ if (regions.length > 50) throw new ApiError(422, 'coupon.too_many_regions', 'at most 50 regions');
+ for (const x of new Set(regions)) out.push({ kind: 'region', value: x });
+ const other = text(r.other, 300);
+ if (other) out.push({ kind: 'other', value: other });
+ return out;
+}
+
+/** Validate a submission body. → normalized input. */
+function parseSubmission(body, now) {
+ if (!body || typeof body !== 'object' || Array.isArray(body)) throw new ApiError(422, 'request.invalid', 'body must be an object');
+ for (const f of FORBIDDEN_FIELDS) {
+ if (body[f] !== undefined) throw new ApiError(422, 'coupon.status_not_accepted', `"${f}" cannot be submitted: a code's status comes only from people's reports`);
+ }
+ const code = String(body.code == null ? '' : body.code).trim();
+ if (!CODE_RE.test(code)) throw new ApiError(422, 'coupon.bad_code', 'code must be 1–64 printable characters without spaces');
+ const title = text(body.title, 140);
+ if (title.length < 3) throw new ApiError(422, 'coupon.title_required', 'title (what the code gives, e.g. "15% off shoes") is required');
+ const expiry = parseExpiry(body.expires, now);
+ let basis = null;
+ if (expiry) basis = body.expiry_basis === 'evidence' ? 'evidence' : 'submitter';
+ let evidence = null;
+ if (body.evidence_url) {
+ const u = String(body.evidence_url).trim();
+ if (u.length > 2048 || !hosts.hostOfUrl(u)) throw new ApiError(422, 'coupon.bad_evidence', 'evidence_url must be an http(s) URL of a public site');
+ evidence = new URL(u).toString();
+ }
+ if (basis === 'evidence' && !evidence) throw new ApiError(422, 'coupon.evidence_required', 'an expiry "stated on the evidence page" needs the evidence URL');
+ const aiRun = body.ai_run_id == null ? null : text(body.ai_run_id, 64);
+ return {
+ code, code_key: code.toUpperCase(), title,
+ description: text(body.description, 1000) || null,
+ expiry, expiry_basis: basis, evidence_url: evidence,
+ restrictions: parseRestrictions(body.restrictions || {}),
+ hint: text(body.hint, 300) || null,
+ ai_run_id: aiRun,
+ };
+}
+
+function createCoupons({ store, merchants, publication }) {
+ const { db } = store;
+ const q = {
+ byId: db.prepare('SELECT * FROM coupons WHERE id = ?'),
+ byKey: db.prepare('SELECT * FROM coupons WHERE merchant_id = ? AND code_key = ?'),
+ insert: db.prepare(`INSERT INTO coupons (id, merchant_id, code, code_key, title, description, status, confidence, review_state, origin,
+ expires_at, expires_precision, expiry_basis, created_by, created_at, updated_at)
+ VALUES (@id, @merchant_id, @code, @code_key, @title, @description, 'unknown', NULL, @review_state, @origin,
+ @expires_at, @expires_precision, @expiry_basis, @created_by, @now, @now)`),
+ restrictionInsert: db.prepare('INSERT INTO coupon_restrictions (coupon_id, kind, value, amount_minor, currency, created_at) VALUES (@coupon_id, @kind, @value, @amount_minor, @currency, @now)'),
+ restrictions: db.prepare('SELECT kind, value, amount_minor, currency FROM coupon_restrictions WHERE coupon_id = ? ORDER BY id'),
+ hintInsert: db.prepare('INSERT INTO coupon_application_hints (merchant_id, coupon_id, text, created_by, created_at) VALUES (?, ?, ?, ?, ?)'),
+ hints: db.prepare('SELECT id, coupon_id, text FROM coupon_application_hints WHERE merchant_id = ? AND (coupon_id IS NULL OR coupon_id = ?) ORDER BY coupon_id IS NOT NULL, id'),
+ merchantHints: db.prepare('SELECT id, coupon_id, text FROM coupon_application_hints WHERE merchant_id = ? AND coupon_id IS NULL ORDER BY id'),
+ sourceInsert: db.prepare(`INSERT INTO coupon_sources (coupon_id, kind, evidence_url, merchant_evidence, sources_item_id, sources_item_rev, source_key, retrieved_at, ai_run_id, submitted_by, created_at)
+ VALUES (@coupon_id, @kind, @evidence_url, @merchant_evidence, @sources_item_id, @sources_item_rev, @source_key, @retrieved_at, @ai_run_id, @submitted_by, @now)`),
+ sources: db.prepare('SELECT * FROM coupon_sources WHERE coupon_id = ? ORDER BY id'),
+ sameEvidence: db.prepare('SELECT 1 FROM coupon_sources WHERE coupon_id = ? AND evidence_url = ? AND removed_at IS NULL'),
+ merchantEvidence: db.prepare('SELECT 1 FROM coupon_sources WHERE coupon_id = ? AND merchant_evidence = 1 AND removed_at IS NULL LIMIT 1'),
+ submittedSince: db.prepare('SELECT COUNT(*) AS n FROM coupon_sources WHERE submitted_by = ? AND created_at > ?'),
+ reportsFor: db.prepare('SELECT reporter_key, outcome, updated_at FROM coupon_validation_reports WHERE coupon_id = ? AND updated_at > ?'),
+ update: db.prepare(`UPDATE coupons SET status = @status, confidence = @confidence, status_reason = @status_reason, expired_at = @expired_at,
+ disabled_at = @disabled_at, updated_at = @now WHERE id = @id`),
+ history: db.prepare(`INSERT INTO coupon_status_history (coupon_id, from_status, to_status, confidence_before, confidence_after, reason, actor, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`),
+ historyFor: db.prepare('SELECT from_status, to_status, confidence_before, confidence_after, reason, created_at FROM coupon_status_history WHERE coupon_id = ? ORDER BY id DESC LIMIT 50'),
+ active: db.prepare(`SELECT c.* FROM coupons c JOIN coupon_merchants m ON m.id = c.merchant_id WHERE c.merchant_id = @merchant AND ${ACTIVE_SQL}
+ ORDER BY CASE c.status WHEN 'reported_working' THEN 0 WHEN 'unknown' THEN 1 ELSE 2 END, COALESCE(c.last_worked_at, 0) DESC, c.created_at DESC LIMIT 200`),
+ activeCount: db.prepare(`SELECT COUNT(*) AS n FROM coupons c JOIN coupon_merchants m ON m.id = c.merchant_id WHERE c.merchant_id = @merchant AND ${ACTIVE_SQL}`),
+ recentlyEnded: db.prepare(`SELECT c.* FROM coupons c JOIN coupon_merchants m ON m.id = c.merchant_id
+ WHERE c.merchant_id = @merchant AND c.review_state = 'published' AND m.status = 'active'
+ AND (c.status = 'expired' OR (c.expires_at IS NOT NULL AND c.expires_at <= @now)) AND c.status <> 'disabled'
+ ORDER BY COALESCE(c.expired_at, c.expires_at) DESC LIMIT 10`),
+ recentActive: db.prepare(`SELECT c.* FROM coupons c JOIN coupon_merchants m ON m.id = c.merchant_id WHERE ${ACTIVE_SQL}
+ ORDER BY c.created_at DESC LIMIT @limit`),
+ allActive: db.prepare(`SELECT c.* FROM coupons c JOIN coupon_merchants m ON m.id = c.merchant_id WHERE ${ACTIVE_SQL} ORDER BY c.created_at DESC LIMIT 50000`),
+ dueExpiry: db.prepare(`SELECT id FROM coupons WHERE expires_at IS NOT NULL AND expires_at <= ? AND status NOT IN ('expired','disabled') LIMIT 500`),
+ decaying: db.prepare(`SELECT DISTINCT c.id FROM coupons c LEFT JOIN coupon_validation_reports r ON r.coupon_id = c.id
+ WHERE c.status NOT IN ('expired','disabled') AND (c.confidence IS NOT NULL OR c.status <> 'unknown' OR r.updated_at > ?) LIMIT 2000`),
+ pendingReview: db.prepare(`SELECT c.* FROM coupons c WHERE c.review_state = 'pending' AND c.status <> 'disabled' ORDER BY c.created_at LIMIT 200`),
+ setReview: db.prepare("UPDATE coupons SET review_state = 'published', updated_at = ? WHERE id = ? AND review_state = 'pending'"),
+ setExpiry: db.prepare('UPDATE coupons SET expires_at = ?, expires_precision = ?, expiry_basis = ?, updated_at = ? WHERE id = ?'),
+ reportCounts: db.prepare(`SELECT outcome, COUNT(*) AS n FROM (
+ SELECT reporter_key, outcome, MAX(updated_at) AS t FROM coupon_validation_reports WHERE coupon_id = ? AND updated_at > ? GROUP BY reporter_key)
+ GROUP BY outcome`),
+ byMerchantPending: db.prepare("SELECT id FROM coupons WHERE merchant_id = ? AND review_state = 'pending'"),
+ ofMerchant: db.prepare('SELECT id FROM coupons WHERE merchant_id = ?'),
+ };
+
+ const get = (id) => q.byId.get(String(id || ''));
+
+ function restrictionsOf(id) { return q.restrictions.all(id); }
+
+ function restrictionsText(rows) {
+ return rows.map((r) => {
+ if (r.kind === 'min_spend') return `Minimum spend ${formatMinor(r.amount_minor, r.currency)}`;
+ if (r.kind === 'category') return `Category: ${r.value}`;
+ if (r.kind === 'new_customers_only') return 'New customers only';
+ if (r.kind === 'region') return `Region: ${r.value}`;
+ return r.value;
+ }).join('\n');
+ }
+
+ function sourceRefs(id) {
+ return q.sources.all(id).filter((s) => s.sources_item_id && !s.removed_at)
+ .map((s) => ({ service: 'sources', type: 'item', id: s.sources_item_id, ...(Number.isInteger(s.sources_item_rev) ? { revision: s.sources_item_rev } : {}), ...(s.evidence_url ? { url: s.evidence_url } : {}), ...(s.retrieved_at ? { retrievedAt: s.retrieved_at } : {}) }));
+ }
+
+ function activeCount(merchantId) { return q.activeCount.get({ merchant: merchantId, now: store.now() }).n; }
+
+ /** Send the Search documents of a code and its merchant (inside a transaction). */
+ function syncIndex(c, { traceparent } = {}) {
+ const m = merchants.byId(c.merchant_id);
+ publication.sendDocument(publication.couponDocument(c, m, { restrictionsText: restrictionsText(restrictionsOf(c.id)), sourceRefs: sourceRefs(c.id) }), { traceparent });
+ if (m) publication.sendDocument(publication.merchantDocument(m, activeCount(m.id)), { traceparent });
+ }
+
+ function syncMerchant(m, { traceparent } = {}) {
+ publication.sendDocument(publication.merchantDocument(m, activeCount(m.id)), { traceparent });
+ for (const { id } of q.ofMerchant.all(m.id)) {
+ const c = get(id);
+ publication.sendDocument(publication.couponDocument(c, m, { restrictionsText: restrictionsText(restrictionsOf(c.id)), sourceRefs: sourceRefs(c.id) }), { traceparent });
+ }
+ }
+
+ function publiclyListed(c) {
+ const m = merchants.byId(c.merchant_id);
+ return Boolean(m && m.status === 'active' && confidence.isActive(c, store.now()));
+ }
+
+ function lifecyclePayload(c, extra = {}) {
+ return { merchant_id: c.merchant_id, status: c.status, confidence: c.confidence, review_state: c.review_state,
+ expires_at: c.expires_at != null ? new Date(c.expires_at).toISOString() : null, canonical_url: publication.couponUrl(c), ...extra };
+ }
+
+ /**
+ * Recompute status and confidence from reports and time (inside a transaction), record the
+ * change in coupon_status_history, emit events and Search documents.
+ * forceStatus: 'disabled' | 'expired' | 'unknown' (re-enable) for staff/service changes.
+ */
+ function recompute(id, { reason, actor = 'system', forceStatus = null, traceparent } = {}) {
+ const before = get(id);
+ if (!before) return null;
+ const now = store.now();
+ let base = before;
+ if (forceStatus === 'disabled') base = { ...before, status: 'disabled' };
+ else if (forceStatus === 'expired') base = { ...before, status: 'expired', expired_at: now };
+ else if (forceStatus === 'unknown') base = { ...before, status: 'unknown', expired_at: null, disabled_at: null };
+ const reports = q.reportsFor.all(id, now - confidence.WINDOW_DAYS * DAY);
+ const ev = confidence.evaluate(base, reports, { merchantEvidence: Boolean(q.merchantEvidence.get(id)), now });
+ const statusChanged = ev.status !== before.status;
+ const confChanged = ev.confidence !== before.confidence;
+ if (!statusChanged && !confChanged && !forceStatus) return before;
+ let expiredAt = null;
+ if (ev.status === 'expired') expiredAt = before.status === 'expired' && before.expired_at ? before.expired_at : (before.expires_at != null && before.expires_at <= now ? before.expires_at : now);
+ q.update.run({
+ id, status: ev.status, confidence: ev.confidence, now,
+ status_reason: statusChanged || forceStatus ? reason : before.status_reason,
+ expired_at: expiredAt,
+ disabled_at: ev.status === 'disabled' ? (before.disabled_at || now) : null,
+ });
+ const after = get(id);
+ if (statusChanged || confChanged) q.history.run(id, before.status, after.status, before.confidence, after.confidence, reason, actor, now);
+ if (statusChanged) {
+ const type = after.status === 'expired' ? 'coupons.coupon.expired' : after.status === 'disabled' ? 'coupons.coupon.disabled' : 'coupons.coupon.updated';
+ publication.emit(type, { type: 'coupon', id }, lifecyclePayload(after, { previous_status: before.status, reason: reason.startsWith('staff:') ? 'staff' : reason }), { isPublic: publiclyListed(after) || publiclyListed(before), traceparent });
+ }
+ if (confChanged) {
+ publication.emit('coupons.confidence.changed', { type: 'coupon', id }, { merchant_id: after.merchant_id, from: before.confidence, to: after.confidence, status: after.status }, { traceparent });
+ }
+ syncIndex(after, { traceparent });
+ return after;
+ }
+
+ function addSource(couponId, merchant, { kind, evidence_url = null, sources_item_id = null, sources_item_rev = null, source_key = null, retrieved_at = null, ai_run_id = null, submitted_by = null }) {
+ q.sourceInsert.run({
+ coupon_id: couponId, kind, evidence_url, merchant_evidence: evidence_url && merchants.ownsUrl(merchant, evidence_url) ? 1 : 0,
+ sources_item_id, sources_item_rev, source_key, retrieved_at, ai_run_id, submitted_by, now: store.now(),
+ });
+ }
+
+ function checkSubmissionRate(actorId, limits) {
+ const now = store.now();
+ if (q.submittedSince.get(actorId, now - 60 * 60 * 1000).n >= limits.submissionsPerHour
+ || q.submittedSince.get(actorId, now - DAY).n >= limits.submissionsPerDay) {
+ throw new ApiError(429, 'submission.rate_limited', 'too many submissions; try again later');
+ }
+ }
+
+ /**
+ * Create a code (or add evidence to the same code). Inside one transaction.
+ * who: { actor: usr_…|svc:…, kind: 'member'|'staff'|'ai', subject }
+ * target: { merchant } (resolved by the caller)
+ * → { coupon, merchant, duplicate, created }
+ */
+ function submit(who, merchant, input, { limits, traceparent } = {}) {
+ checkSubmissionRate(who.actor, limits);
+ return store.tx(() => {
+ const existing = q.byKey.get(merchant.id, input.code_key);
+ if (existing) {
+ if (existing.status === 'disabled') throw new ApiError(409, 'coupon.disabled', 'this code was taken down and cannot be resubmitted');
+ if (existing.status === 'expired' || (existing.expires_at != null && existing.expires_at <= store.now())) {
+ throw new ApiError(409, 'coupon.expired', 'this code is recorded as expired');
+ }
+ if (!input.evidence_url || !q.sameEvidence.get(existing.id, input.evidence_url)) {
+ addSource(existing.id, merchant, { kind: who.kind, evidence_url: input.evidence_url, ai_run_id: input.ai_run_id, submitted_by: who.actor });
+ recompute(existing.id, { reason: 'evidence', actor: 'system', traceparent });
+ }
+ return { coupon: get(existing.id), merchant, duplicate: true, created: false };
+ }
+ const now = store.now();
+ const id = `cpn_${ids.ulid(now)}`;
+ const review = who.kind === 'ai' || merchant.status !== 'active' ? 'pending' : 'published';
+ q.insert.run({
+ id, merchant_id: merchant.id, code: input.code, code_key: input.code_key, title: input.title, description: input.description,
+ review_state: review, origin: who.kind,
+ expires_at: input.expiry ? input.expiry.expires_at : null,
+ expires_precision: input.expiry ? input.expiry.expires_precision : null,
+ expiry_basis: input.expiry ? (who.kind === 'staff' && input.expiry_basis === 'submitter' ? 'staff' : input.expiry_basis) : null,
+ created_by: who.actor, now,
+ });
+ for (const r of input.restrictions) q.restrictionInsert.run({ coupon_id: id, kind: r.kind, value: r.value ?? null, amount_minor: r.amount_minor ?? null, currency: r.currency ?? null, now });
+ if (input.hint) q.hintInsert.run(merchant.id, id, input.hint, who.actor, now);
+ addSource(id, merchant, { kind: who.kind, evidence_url: input.evidence_url, ai_run_id: input.ai_run_id, submitted_by: who.actor });
+ q.history.run(id, null, 'unknown', null, null, 'created', who.actor, now);
+ const c = get(id);
+ publication.emit('coupons.coupon.created', { type: 'coupon', id }, lifecyclePayload(c, { origin: c.origin }), { isPublic: publiclyListed(c), traceparent });
+ syncIndex(c, { traceparent });
+ return { coupon: c, merchant, duplicate: false, created: true };
+ });
+ }
+
+ /** Import one OpenVibe.Sources item as a code (inside the importer's transaction). */
+ function importFromSource(merchant, item, parsed, { autoPublish }) {
+ const existing = q.byKey.get(merchant.id, parsed.code_key);
+ const src = {
+ kind: 'source', evidence_url: item.canonical_url || null, sources_item_id: item.id, sources_item_rev: item.revision,
+ source_key: item.source_key, retrieved_at: Date.parse(item.provenance && item.provenance.retrieved_at) || null, submitted_by: 'svc:sources',
+ };
+ if (existing) {
+ const known = q.sources.all(existing.id).find((s) => s.sources_item_id === item.id);
+ if (known) db.prepare('UPDATE coupon_sources SET sources_item_rev = ?, retrieved_at = ?, removed_at = NULL, removed_reason = NULL WHERE id = ?').run(item.revision, src.retrieved_at, known.id);
+ else addSource(existing.id, merchant, src);
+ if (existing.expires_at == null && parsed.expiry && existing.status !== 'expired') {
+ q.setExpiry.run(parsed.expiry.expires_at, parsed.expiry.expires_precision, 'source', store.now(), existing.id);
+ }
+ recompute(existing.id, { reason: 'evidence', actor: 'svc:sources' });
+ return { coupon: get(existing.id), created: false };
+ }
+ const now = store.now();
+ const id = `cpn_${ids.ulid(now)}`;
+ q.insert.run({
+ id, merchant_id: merchant.id, code: parsed.code, code_key: parsed.code_key, title: parsed.title, description: parsed.description,
+ review_state: autoPublish && merchant.status === 'active' ? 'published' : 'pending', origin: 'source',
+ expires_at: parsed.expiry ? parsed.expiry.expires_at : null,
+ expires_precision: parsed.expiry ? parsed.expiry.expires_precision : null,
+ expiry_basis: parsed.expiry ? 'source' : null, created_by: 'svc:sources', now,
+ });
+ for (const r of parsed.restrictions) q.restrictionInsert.run({ coupon_id: id, kind: r.kind, value: r.value ?? null, amount_minor: r.amount_minor ?? null, currency: r.currency ?? null, now });
+ addSource(id, merchant, src);
+ q.history.run(id, null, 'unknown', null, null, 'created', 'svc:sources', now);
+ const c = get(id);
+ publication.emit('coupons.coupon.created', { type: 'coupon', id }, lifecyclePayload(c, { origin: 'source' }), { isPublic: publiclyListed(c) });
+ syncIndex(c);
+ return { coupon: c, created: true };
+ }
+
+ /** A Sources item was removed: its evidence is withdrawn; a code with no other evidence is disabled. */
+ function withdrawSourceItem(itemId, reason) {
+ const rows = db.prepare('SELECT * FROM coupon_sources WHERE sources_item_id = ? AND removed_at IS NULL').all(itemId);
+ for (const s of rows) {
+ db.prepare('UPDATE coupon_sources SET removed_at = ?, removed_reason = ? WHERE id = ?').run(store.now(), String(reason || 'removed at the source').slice(0, 300), s.id);
+ const remaining = db.prepare('SELECT COUNT(*) AS n FROM coupon_sources WHERE coupon_id = ? AND removed_at IS NULL').get(s.coupon_id).n;
+ if (!remaining) recompute(s.coupon_id, { reason: 'source_removed', actor: 'svc:sources', forceStatus: 'disabled' });
+ else recompute(s.coupon_id, { reason: 'evidence', actor: 'svc:sources' });
+ }
+ return rows.length;
+ }
+
+ /**
+ * Staff/service status change: 'disabled' | 'expired' | 'active' (re-enable: recomputed from
+ * reports and time). reported_working / reported_failed cannot be set by anyone.
+ */
+ function setStatus(c, target, { actor, note = '', traceparent } = {}) {
+ const why = `${actor.startsWith('svc:') ? `service:${actor.slice(4)}` : 'staff'}${note ? `:${text(note, 200)}` : ''}`;
+ if (target === 'disabled') return store.tx(() => recompute(c.id, { reason: why, actor, forceStatus: 'disabled', traceparent }));
+ if (target === 'expired') {
+ if (c.status === 'disabled') throw new ApiError(409, 'coupon.disabled', 'enable the code before marking it expired');
+ return store.tx(() => recompute(c.id, { reason: why, actor, forceStatus: 'expired', traceparent }));
+ }
+ if (target === 'active') {
+ if (c.expires_at != null && c.expires_at <= store.now()) throw new ApiError(409, 'coupon.expiry_passed', 'its known expiry has passed; change the expiry first');
+ return store.tx(() => recompute(c.id, { reason: why, actor, forceStatus: 'unknown', traceparent }));
+ }
+ throw new ApiError(422, 'coupon.status_not_settable', 'status can be set to disabled, expired or active only; working/failed come from people\'s reports');
+ }
+
+ /** Staff: publish a code that waits for review. */
+ function approve(c, { actor }) {
+ return store.tx(() => {
+ if (q.setReview.run(store.now(), c.id).changes !== 1) return get(c.id);
+ const after = get(c.id);
+ q.history.run(c.id, c.status, after.status, c.confidence, after.confidence, 'staff:approved', actor, store.now());
+ publication.emit('coupons.coupon.updated', { type: 'coupon', id: c.id }, lifecyclePayload(after, { previous_status: c.status, reason: 'approved' }), { isPublic: publiclyListed(after) });
+ syncIndex(after);
+ return after;
+ });
+ }
+
+ /** Staff: set or clear a code's expiry (clear = back to unknown). */
+ function setExpiry(c, value, { actor }) {
+ const expiry = value ? parseExpiry(value, store.now()) : null;
+ return store.tx(() => {
+ q.setExpiry.run(expiry ? expiry.expires_at : null, expiry ? expiry.expires_precision : null, expiry ? 'staff' : null, store.now(), c.id);
+ const updated = get(c.id);
+ publication.emit('coupons.coupon.updated', { type: 'coupon', id: c.id }, lifecyclePayload(updated, { reason: 'expiry_changed' }), { isPublic: publiclyListed(updated) });
+ recompute(c.id, { reason: 'staff:expiry', actor });
+ syncIndex(get(c.id));
+ return get(c.id);
+ });
+ }
+
+ function addMerchantHint(m, hint, { actor }) {
+ const t = text(hint, 300);
+ if (!t) throw new ApiError(422, 'hint.empty', 'hint text is required');
+ q.hintInsert.run(m.id, null, t, actor, store.now());
+ }
+
+ /**
+ * The sweep: record expiries that have passed and let old reports decay. Idempotent.
+ * → { expired: [ids], changed: [ids] }
+ */
+ function sweep() {
+ const now = store.now();
+ const expired = [];
+ const changed = [];
+ for (const { id } of q.dueExpiry.all(now)) {
+ const after = store.tx(() => recompute(id, { reason: 'expiry', actor: 'system' }));
+ if (after && after.status === 'expired') expired.push(id);
+ }
+ for (const { id } of q.decaying.all(now - (confidence.WINDOW_DAYS + 1) * DAY)) {
+ const before = get(id);
+ const after = store.tx(() => recompute(id, { reason: 'decay', actor: 'system' }));
+ if (after && before && (after.status !== before.status || after.confidence !== before.confidence)) changed.push(id);
+ }
+ return { expired, changed };
+ }
+
+ /** Counted reports in the window: { worked, failed } (aggregates only, never who). */
+ function reportCounts(id) {
+ const out = { worked: 0, failed: 0 };
+ for (const r of q.reportCounts.all(id, store.now() - confidence.WINDOW_DAYS * DAY)) out[r.outcome] = r.n;
+ return out;
+ }
+
+ /**
+ * The public view of a code. Never includes who submitted or reported it.
+ */
+ function view(c, { merchant = null } = {}) {
+ const now = store.now();
+ const m = merchant || merchants.byId(c.merchant_id);
+ const sources = q.sources.all(c.id).filter((s) => !s.removed_at);
+ const iso = (t) => (t == null ? null : new Date(t).toISOString());
+ // Report times are published to the hour: enough for "last report 3 hours ago", too coarse
+ // to tie a report to the moment someone made it.
+ const hour = (t) => (t == null ? null : new Date(Math.floor(t / 3600000) * 3600000).toISOString());
+ return {
+ id: c.id,
+ merchant_id: c.merchant_id,
+ code: c.code,
+ title: c.title,
+ description: c.description,
+ status: c.status,
+ active: Boolean(m && m.status === 'active' && confidence.isActive(c, now)),
+ confidence: c.confidence,
+ reports: { ...reportCounts(c.id), window_days: confidence.WINDOW_DAYS, last_report_at: hour(c.last_report_at), last_worked_at: hour(c.last_worked_at), last_failed_at: hour(c.last_failed_at) },
+ expiry: c.expires_at == null
+ ? { known: false, expires_at: null, precision: null, basis: null }
+ : { known: true, expires_at: iso(c.expires_at), precision: c.expires_precision, basis: c.expiry_basis },
+ restrictions: restrictionsOf(c.id).map((r) => (r.kind === 'min_spend'
+ ? { kind: r.kind, amount: formatMinor(r.amount_minor, r.currency), amount_minor: r.amount_minor, currency: r.currency }
+ : r.kind === 'new_customers_only' ? { kind: r.kind } : { kind: r.kind, value: r.value })),
+ hints: m ? q.hints.all(m.id, c.id).map((h) => ({ text: h.text, scope: h.coupon_id ? 'code' : 'merchant' })) : [],
+ evidence: sources.map((s) => ({
+ kind: s.kind, url: s.evidence_url, merchant_page: Boolean(s.merchant_evidence),
+ ...(s.sources_item_id ? { sources_item: s.sources_item_id, retrieved_at: iso(s.retrieved_at) } : {}),
+ })),
+ origin: c.origin,
+ created_at: iso(c.created_at),
+ updated_at: iso(c.updated_at),
+ expired_at: iso(c.expired_at),
+ url: publication.couponUrl(c),
+ };
+ }
+
+ return {
+ ACTIVE_SQL, parseSubmission, parseExpiry, parseRestrictions, formatMinor, restrictionsText,
+ get, submit, checkSubmissionRate, importFromSource, withdrawSourceItem, recompute, setStatus, approve, setExpiry, addMerchantHint, sweep, view,
+ restrictionsOf, reportCounts, activeCount, syncIndex, syncMerchant,
+ history: (id) => q.historyFor.all(id),
+ active: (merchantId) => q.active.all({ merchant: merchantId, now: store.now() }),
+ recentlyEnded: (merchantId) => q.recentlyEnded.all({ merchant: merchantId, now: store.now() }),
+ recentActive: (limit = 30) => q.recentActive.all({ now: store.now(), limit }),
+ allActive: () => q.allActive.all({ now: store.now() }),
+ pendingReview: () => q.pendingReview.all(),
+ pendingOfMerchant: (merchantId) => q.byMerchantPending.all(merchantId).map((r) => r.id),
+ /** Publish the codes that waited only because their merchant was pending (not AI or source codes). */
+ publishWaitingOn(merchant, { actor }) {
+ const out = [];
+ for (const id of q.byMerchantPending.all(merchant.id).map((r) => r.id)) {
+ const c = get(id);
+ if (c.origin === 'member' || c.origin === 'staff') out.push(approve(c, { actor }));
+ }
+ return out;
+ },
+ merchantHints: (merchantId) => q.merchantHints.all(merchantId),
+ };
+}
+
+module.exports = { createCoupons, parseSubmission, parseExpiry, parseRestrictions, ACTIVE_SQL, FORBIDDEN_FIELDS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,139 @@
@@ -0,0 +1,139 @@
+'use strict';
+
+/**
+ * Host normalization and the domain rules merchants are matched by.
+ *
+ * normalizeHost('WWW.Example-Shop.co.uk.') → 'www.example-shop.co.uk'
+ * normalizeHost('bücher.example') → 'xn--bcher-kva.example'
+ * registrable('www.example-shop.co.uk') → 'example-shop.co.uk'
+ *
+ * A host is: lowercase, punycode (IDNA via url.domainToASCII), no trailing dot, no port, at least
+ * two labels, each 1–63 of [a-z0-9-] not starting or ending with '-', at most 253 characters. IP
+ * literals, localhost and special-use names (.local, .internal, .invalid, .arpa …) are refused:
+ * they are never a merchant and must not be looked up.
+ *
+ * Rule matching (merchant resolution):
+ * - a rule names a host at or below a registrable domain, never a public suffix;
+ * - include_subdomains: the rule also covers every subdomain of its host, but never crosses the
+ * registrable domain of the looked-up host (a rule on myshopify.com cannot claim
+ * foo.myshopify.com, which is its own site);
+ * - path_prefix: the rule matches only when a path is known and starts with the prefix at a
+ * segment boundary; host-only lookups (the browser helper sends only a hostname) never match
+ * path rules, so they fall back to the host's own rule;
+ * - the most specific rule wins: longest host, then longest path prefix.
+ */
+const { domainToASCII } = require('url');
+const { registrableDomain, publicSuffix } = require('./psl');
+
+const LABEL_RE = /^(?!-)[a-z0-9-]{1,63}(?<!-)$/;
+const SPECIAL = ['localhost', 'local', 'internal', 'invalid', 'arpa', 'test', 'example', 'onion', 'lan', 'home', 'corp'];
+
+class HostError extends Error {
+ constructor(detail) { super(detail); this.name = 'HostError'; this.status = 400; this.code = 'host.invalid'; }
+}
+
+/** → normalized host, or throws HostError. */
+function normalizeHost(input) {
+ if (typeof input !== 'string') throw new HostError('host must be a string');
+ let h = input.trim().toLowerCase();
+ if (!h || h.length > 300) throw new HostError('host is empty or too long');
+ if (/[\s/\\?#@]/.test(h)) throw new HostError('host must be a hostname only (no scheme, path, query or credentials)');
+ if (h.startsWith('[')) throw new HostError('IP addresses are not merchants');
+ const colon = h.indexOf(':');
+ if (colon !== -1) {
+ if (!/^\d{1,5}$/.test(h.slice(colon + 1))) throw new HostError('malformed port');
+ h = h.slice(0, colon);
+ }
+ h = h.replace(/\.$/, '');
+ const ascii = domainToASCII(h);
+ if (!ascii) throw new HostError('not a valid domain name');
+ h = ascii.toLowerCase();
+ if (h.length > 253) throw new HostError('host is too long');
+ const labels = h.split('.');
+ if (labels.length < 2) throw new HostError('host needs at least two labels');
+ if (!labels.every((l) => LABEL_RE.test(l))) throw new HostError('host has a malformed label');
+ if (labels.every((l) => /^\d+$/.test(l)) || /^\d+$/.test(labels[labels.length - 1])) throw new HostError('IP addresses are not merchants');
+ if (SPECIAL.includes(labels[labels.length - 1])) throw new HostError('special-use names are not merchants');
+ return h;
+}
+
+/** Normalize, or null. */
+function tryHost(input) {
+ try { return normalizeHost(input); } catch { return null; }
+}
+
+/** The registrable domain (eTLD+1) of a normalized host, or null when it is a public suffix. */
+function registrable(host) { return registrableDomain(host); }
+
+/** Host and path of an http(s) URL (for evidence and Sources items), or null. */
+function hostOfUrl(url) {
+ let u;
+ try { u = new URL(String(url)); } catch { return null; }
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
+ const host = tryHost(u.hostname);
+ return host ? { host, path: u.pathname || '/' } : null;
+}
+
+/** '/Shop/x/' → '/Shop/x' ; '' | '/' → '' ; throws on anything that is not a plain path. */
+function normalizePathPrefix(p) {
+ if (p == null || p === '' || p === '/') return '';
+ const s = String(p).trim();
+ if (!s.startsWith('/') || s.length > 200 || /[\s?#\\]/.test(s) || s.includes('//') || /(^|\/)\.\.?(\/|$)/.test(s)) {
+ throw new HostError('path_prefix must be a plain path like /shop/name');
+ }
+ return s.replace(/\/+$/, '');
+}
+
+/** Does `path` start with `prefix` at a segment boundary? */
+function pathMatches(prefix, path) {
+ if (!prefix) return true;
+ if (path == null) return false;
+ return path === prefix || path.startsWith(`${prefix}/`);
+}
+
+/**
+ * Validate a new domain rule. → { host, registrable, path_prefix, include_subdomains }.
+ * A rule on a public suffix (co.uk, myshopify.com …) is refused.
+ */
+function checkRule({ host, include_subdomains = true, path_prefix = '' } = {}) {
+ const h = normalizeHost(host);
+ const reg = registrable(h);
+ if (!reg) throw new HostError(`${h} is a public suffix (${publicSuffix(h)}), not a site`);
+ return { host: h, registrable: reg, path_prefix: normalizePathPrefix(path_prefix), include_subdomains: include_subdomains === false || include_subdomains === 0 || include_subdomains === '0' ? 0 : 1 };
+}
+
+/**
+ * The best rule for (host, path) among candidate rule rows
+ * ({ host, registrable_domain, include_subdomains, path_prefix, … }), or null.
+ */
+function bestRule(rules, host, path = null) {
+ const reg = registrable(host);
+ if (!reg) return null;
+ let best = null;
+ for (const r of rules) {
+ const exact = r.host === host;
+ const parent = !exact && r.include_subdomains && host.endsWith(`.${r.host}`);
+ if (!exact && !parent) continue;
+ // Never across registrable domains: the rule's host must be inside the host's own site.
+ if (r.host.length < reg.length || !(r.host === reg || r.host.endsWith(`.${reg}`))) continue;
+ if (!pathMatches(r.path_prefix, path)) continue;
+ if (!best || r.host.length > best.host.length || (r.host.length === best.host.length && r.path_prefix.length > best.path_prefix.length)) best = r;
+ }
+ return best;
+}
+
+/** Every host a lookup could match a rule on: the host and its parents down to the registrable domain. */
+function candidateHosts(host) {
+ const reg = registrable(host);
+ if (!reg) return [];
+ const out = [];
+ let h = host;
+ for (;;) {
+ out.push(h);
+ if (h === reg) break;
+ h = h.slice(h.indexOf('.') + 1);
+ }
+ return out;
+}
+
+module.exports = { HostError, normalizeHost, tryHost, registrable, hostOfUrl, normalizePathPrefix, pathMatches, checkRule, bestRule, candidateHosts };
new file mode 100644
new file mode 100644
@@ -0,0 +1,98 @@
@@ -0,0 +1,98 @@
+'use strict';
+
+/**
+ * Extension install tokens — the only credential the OpenVibe browser helper ever holds.
+ *
+ * cpx_<43 base64url chars> 256 random bits; shown ONCE on /connect-extension; stored as sha256
+ *
+ * A signed-in person creates one per browser, with the scopes they choose (coupons.lookup is
+ * always included; coupons.report is optional). A token is:
+ * - scoped: it can look up codes and (optionally) report worked/failed — nothing else. It is not
+ * a Network session and is refused everywhere a Network token is expected;
+ * - revocable: revoked_at is checked on every request (no cache), so revocation is immediate;
+ * - expiring: COUPONS_INSTALL_TTL_DAYS (365) after creation.
+ * Revoking or expiring a token never deletes the reports made with it (they are the person's).
+ */
+const crypto = require('crypto');
+const { ids } = require('openvibe-contracts');
+const { ApiError } = require('../http/errors');
+const { SCOPES, ALL_SCOPES } = require('../auth/capabilities');
+
+const TOKEN_RE = /^cpx_[A-Za-z0-9_-]{43}$/;
+const DAY = 24 * 60 * 60 * 1000;
+
+const hashToken = (token) => crypto.createHash('sha256').update(String(token)).digest('hex');
+
+function createInstalls({ store, config }) {
+ const { db } = store;
+ const q = {
+ insert: db.prepare(`INSERT INTO extension_installs (id, subject, token_hash, token_hint, label, scopes, created_at, expires_at)
+ VALUES (@id, @subject, @token_hash, @token_hint, @label, @scopes, @created_at, @expires_at)`),
+ byHash: db.prepare('SELECT * FROM extension_installs WHERE token_hash = ?'),
+ byId: db.prepare('SELECT * FROM extension_installs WHERE id = ?'),
+ mine: db.prepare('SELECT * FROM extension_installs WHERE subject = ? ORDER BY created_at DESC LIMIT 50'),
+ activeCount: db.prepare('SELECT COUNT(*) AS n FROM extension_installs WHERE subject = ? AND revoked_at IS NULL AND expires_at > ?'),
+ revoke: db.prepare('UPDATE extension_installs SET revoked_at = ? WHERE id = ? AND subject = ? AND revoked_at IS NULL'),
+ touch: db.prepare('UPDATE extension_installs SET last_used_at = ? WHERE id = ? AND (last_used_at IS NULL OR last_used_at < ?)'),
+ };
+
+ const view = (row) => ({
+ id: row.id,
+ label: row.label,
+ token_hint: row.token_hint,
+ scopes: JSON.parse(row.scopes),
+ created_at: new Date(row.created_at).toISOString(),
+ expires_at: new Date(row.expires_at).toISOString(),
+ last_used_at: row.last_used_at ? new Date(row.last_used_at).toISOString() : null,
+ revoked_at: row.revoked_at ? new Date(row.revoked_at).toISOString() : null,
+ active: !row.revoked_at && row.expires_at > store.now(),
+ });
+
+ return {
+ TOKEN_RE,
+ view,
+
+ /** → { install (view), token } — the token exists only in this return value. */
+ create(subject, { label, scopes } = {}) {
+ if (!ids.isSubjectId('user', subject)) throw new ApiError(403, 'auth.person_required', 'only a signed-in person can connect an extension');
+ const name = String(label == null ? '' : label).trim().slice(0, 60) || 'Browser helper';
+ const wanted = new Set(Array.isArray(scopes) ? scopes : scopes ? [scopes] : []);
+ for (const s of wanted) if (!ALL_SCOPES.includes(s)) throw new ApiError(422, 'install.bad_scope', `unknown scope ${s}`);
+ wanted.add(SCOPES.LOOKUP);
+ const now = store.now();
+ if (q.activeCount.get(subject, now).n >= config.limits.installsPerSubject) {
+ throw new ApiError(409, 'install.limit', `at most ${config.limits.installsPerSubject} connected extensions; revoke one first`);
+ }
+ const token = `cpx_${crypto.randomBytes(32).toString('base64url')}`;
+ const row = {
+ id: `cpi_${ids.ulid(now)}`, subject, token_hash: hashToken(token), token_hint: token.slice(0, 10),
+ label: name, scopes: JSON.stringify(ALL_SCOPES.filter((s) => wanted.has(s))),
+ created_at: now, expires_at: now + config.installTtlDays * DAY,
+ };
+ q.insert.run(row);
+ return { install: view(q.byId.get(row.id)), token };
+ },
+
+ list(subject) { return q.mine.all(subject).map(view); },
+
+ /** Revoke one of `subject`'s installs. → true when it was active. */
+ revoke(subject, id) { return q.revoke.run(store.now(), String(id), subject).changes === 1; },
+
+ /**
+ * Verify a presented token. → { install, subject, scopes } or throws 401 (malformed, unknown,
+ * revoked, expired). Reads the row every time: revocation takes effect on the next request.
+ */
+ verify(token) {
+ if (!TOKEN_RE.test(String(token))) throw new ApiError(401, 'token.invalid', 'malformed extension token');
+ const row = q.byHash.get(hashToken(token));
+ if (!row) throw new ApiError(401, 'token.invalid', 'unknown extension token');
+ const now = store.now();
+ if (row.revoked_at) throw new ApiError(401, 'token.revoked', 'this extension was disconnected; connect it again on openvibe.coupons');
+ if (row.expires_at <= now) throw new ApiError(401, 'token.expired', 'this extension token expired; connect it again on openvibe.coupons');
+ q.touch.run(now, row.id, now - 60 * 1000);
+ return { install: row.id, subject: row.subject, scopes: JSON.parse(row.scopes) };
+ },
+ };
+}
+
+module.exports = { createInstalls, hashToken, TOKEN_RE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,151 @@
@@ -0,0 +1,151 @@
+'use strict';
+
+/**
+ * Merchants and their domain rules.
+ *
+ * pending proposed by a member's submission for a site no merchant covers yet; invisible to the
+ * public (no page, no lookup) until staff approve it
+ * active listed, resolvable, its codes can be shown
+ * disabled taken down: no page (410), no lookup, codes out of every list
+ *
+ * resolve(host, { path }) is the merchant lookup (see hosts.js for the matching rules). Only active
+ * merchants resolve publicly.
+ */
+const { ids } = require('openvibe-contracts');
+const hosts = require('./hosts');
+const { ApiError } = require('../http/errors');
+
+const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
+
+function slugify(s) {
+ return String(s || '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64).replace(/-+$/, '');
+}
+
+function createMerchants({ store }) {
+ const { db } = store;
+ const q = {
+ byId: db.prepare('SELECT * FROM coupon_merchants WHERE id = ?'),
+ bySlug: db.prepare('SELECT * FROM coupon_merchants WHERE slug = ?'),
+ insert: db.prepare(`INSERT INTO coupon_merchants (id, slug, name, homepage_url, description, status, created_by, created_at, updated_at)
+ VALUES (@id, @slug, @name, @homepage_url, @description, @status, @created_by, @now, @now)`),
+ setStatus: db.prepare('UPDATE coupon_merchants SET status = ?, updated_at = ? WHERE id = ?'),
+ domains: db.prepare('SELECT * FROM coupon_merchant_domains WHERE merchant_id = ? ORDER BY host, path_prefix'),
+ domainInsert: db.prepare(`INSERT INTO coupon_merchant_domains (merchant_id, host, registrable_domain, include_subdomains, path_prefix, created_by, created_at)
+ VALUES (@merchant_id, @host, @registrable, @include_subdomains, @path_prefix, @created_by, @now)`),
+ domainByKey: db.prepare('SELECT * FROM coupon_merchant_domains WHERE host = ? AND path_prefix = ?'),
+ domainDelete: db.prepare('DELETE FROM coupon_merchant_domains WHERE id = ? AND merchant_id = ?'),
+ rulesFor: db.prepare(`SELECT d.*, m.status AS merchant_status FROM coupon_merchant_domains d JOIN coupon_merchants m ON m.id = d.merchant_id
+ WHERE d.registrable_domain = ?`),
+ listActive: db.prepare(`SELECT * FROM coupon_merchants WHERE status = 'active' ORDER BY name COLLATE NOCASE LIMIT ? OFFSET ?`),
+ countActive: db.prepare("SELECT COUNT(*) AS n FROM coupon_merchants WHERE status = 'active'"),
+ search: db.prepare(`SELECT DISTINCT m.* FROM coupon_merchants m LEFT JOIN coupon_merchant_domains d ON d.merchant_id = m.id
+ WHERE m.status = 'active' AND (m.name LIKE @q ESCAPE '\\' OR d.host LIKE @q ESCAPE '\\')
+ ORDER BY m.name COLLATE NOCASE LIMIT 50`),
+ pending: db.prepare("SELECT * FROM coupon_merchants WHERE status = 'pending' ORDER BY created_at LIMIT 200"),
+ };
+
+ function get(idOrSlug) {
+ const s = String(idOrSlug || '');
+ return s.startsWith('mer_') ? q.byId.get(s) : q.bySlug.get(s);
+ }
+
+ function uniqueSlug(base) {
+ let slug = SLUG_RE.test(base) ? base : slugify(base) || 'merchant';
+ if (!q.bySlug.get(slug)) return slug;
+ for (let i = 2; i < 1000; i++) { const s = `${slug.slice(0, 58)}-${i}`; if (!q.bySlug.get(s)) return s; }
+ throw new ApiError(409, 'merchant.slug_taken', 'could not find a free slug');
+ }
+
+ /** Add one domain rule (inside the caller's transaction). */
+ function addDomain(merchant, rule, actor) {
+ const r = hosts.checkRule(rule);
+ const clash = q.domainByKey.get(r.host, r.path_prefix);
+ if (clash) {
+ if (clash.merchant_id === merchant.id) return clash;
+ throw new ApiError(409, 'merchant.domain_taken', `${r.host}${r.path_prefix} already belongs to another merchant`);
+ }
+ q.domainInsert.run({ merchant_id: merchant.id, ...r, created_by: actor, now: store.now() });
+ return q.domainByKey.get(r.host, r.path_prefix);
+ }
+
+ /**
+ * Create a merchant with at least one domain rule.
+ * input: { name, slug?, homepage_url?, description?, domains: [{ host, include_subdomains?, path_prefix? }] }
+ */
+ function create(input, { status = 'active', actor }) {
+ const domains = Array.isArray(input.domains) ? input.domains : [];
+ if (!domains.length) throw new ApiError(422, 'merchant.domain_required', 'a merchant needs at least one domain');
+ const first = hosts.checkRule(domains[0]);
+ const name = String(input.name == null ? '' : input.name).trim().slice(0, 120) || first.registrable;
+ let homepage = null;
+ if (input.homepage_url) {
+ const h = hosts.hostOfUrl(input.homepage_url);
+ if (!h || !/^https:\/\//i.test(String(input.homepage_url))) throw new ApiError(422, 'merchant.bad_homepage', 'homepage_url must be an https URL');
+ homepage = String(input.homepage_url).slice(0, 500);
+ } else {
+ homepage = `https://${first.host}/`;
+ }
+ return store.tx(() => {
+ const id = `mer_${ids.ulid(store.now())}`;
+ const slug = uniqueSlug(input.slug ? String(input.slug) : slugify(name));
+ q.insert.run({
+ id, slug, name, homepage_url: homepage,
+ description: input.description ? String(input.description).trim().slice(0, 1000) : null,
+ status, created_by: actor, now: store.now(),
+ });
+ const m = q.byId.get(id);
+ for (const d of domains) addDomain(m, d, actor);
+ return m;
+ });
+ }
+
+ /**
+ * The merchant for a host (and optionally a path). `includeStatuses` limits which merchants count
+ * (default: active only). → { merchant, rule, host, registrable } or null.
+ */
+ function resolve(rawHost, { path = null, includeStatuses = ['active'] } = {}) {
+ const host = hosts.normalizeHost(rawHost);
+ const reg = hosts.registrable(host);
+ if (!reg) return null;
+ const rules = q.rulesFor.all(reg).filter((r) => includeStatuses.includes(r.merchant_status));
+ const rule = hosts.bestRule(rules, host, path);
+ if (!rule) return null;
+ return { merchant: q.byId.get(rule.merchant_id), rule, host, registrable: reg };
+ }
+
+ /** Is `url` on one of the merchant's own domains (merchant evidence)? */
+ function ownsUrl(merchant, url) {
+ const h = hosts.hostOfUrl(url);
+ if (!h) return false;
+ const rules = q.domains.all(merchant.id);
+ return Boolean(hosts.bestRule(rules, h.host, h.path));
+ }
+
+ return {
+ get,
+ byId: (id) => q.byId.get(id),
+ domains: (m) => q.domains.all(m.id),
+ create,
+ addDomain: (m, rule, actor) => store.tx(() => addDomain(m, rule, actor)),
+ removeDomain: (m, domainId) => q.domainDelete.run(Number(domainId), m.id).changes === 1,
+ setStatus(m, status) {
+ if (!['pending', 'active', 'disabled'].includes(status)) throw new ApiError(422, 'merchant.bad_status', 'status must be pending, active or disabled');
+ q.setStatus.run(status, store.now(), m.id);
+ return q.byId.get(m.id);
+ },
+ resolve,
+ ownsUrl,
+ listActive: ({ limit = 100, offset = 0 } = {}) => q.listActive.all(limit, offset),
+ countActive: () => q.countActive.get().n,
+ search(text) {
+ const t = String(text || '').trim().toLowerCase().slice(0, 60);
+ if (!t) return [];
+ return q.search.all({ q: `%${t.replace(/[\\%_]/g, (c) => `\\${c}`)}%` });
+ },
+ pending: () => q.pending.all(),
+ slugify,
+ };
+}
+
+module.exports = { createMerchants, slugify };
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
+'use strict';
+
+/**
+ * Staff actions that span merchants and codes. Every one is recorded: merchant status on the row,
+ * code changes in coupon_status_history, and both reach Search (documents or tombstones).
+ *
+ * approve a pending merchant → active; the codes members submitted for it are published
+ * (AI-extracted and imported codes still wait for their own review)
+ * disable a merchant → its page is gone (410), lookups stop, every code leaves active
+ * results and Search (tombstones); nothing is deleted
+ */
+const { ApiError } = require('../http/errors');
+
+function createModeration({ store, merchants, coupons }) {
+ return {
+ setMerchantStatus(m, status, { actor = 'system' } = {}) {
+ if (!['active', 'disabled'].includes(status)) throw new ApiError(422, 'merchant.bad_status', 'status must be active or disabled');
+ return store.tx(() => {
+ const after = merchants.setStatus(m, status);
+ if (status === 'active') coupons.publishWaitingOn(after, { actor });
+ coupons.syncMerchant(after);
+ return after;
+ });
+ },
+ };
+}
+
+module.exports = { createModeration };
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -0,0 +1,100 @@
+'use strict';
+
+/**
+ * A small, bundled subset of the Public Suffix List (https://publicsuffix.org/list/, MPL-2.0),
+ * enough to find the registrable domain (eTLD+1) of the shops Coupons is likely to see.
+ *
+ * Rules follow the PSL algorithm: the longest matching rule wins, `*.x` matches any single label
+ * under x, `!a.x` is an exception to a wildcard, and when nothing matches the rule is `*` (the top
+ * label alone is the suffix). Private-section entries (hosted shops, static hosts) are included
+ * because on them every customer's subdomain is a separate site: foo.myshopify.com and
+ * bar.myshopify.com must never resolve to the same merchant.
+ *
+ * This is a subset on purpose (no network fetch, no runtime dependency). The consequence of a
+ * missing multi-label suffix is that its registrable domain is computed one label too short; merchant
+ * domain rules are created only by staff (or held for staff review), and a rule on a public suffix
+ * is refused, so a gap here cannot make an unrelated site resolve to a merchant by itself.
+ * Update it from the PSL when a real merchant needs a suffix that is missing.
+ */
+const RULES = [
+ // ICANN: second-level registries under country codes
+ 'co.uk', 'org.uk', 'me.uk', 'ltd.uk', 'plc.uk', 'net.uk', 'ac.uk', 'gov.uk', 'sch.uk',
+ 'com.au', 'net.au', 'org.au', 'edu.au', 'gov.au', 'asn.au', 'id.au',
+ 'co.nz', 'net.nz', 'org.nz', 'ac.nz', 'govt.nz',
+ 'co.jp', 'ne.jp', 'or.jp', 'ac.jp', 'go.jp', 'ad.jp', 'ed.jp', 'gr.jp', 'lg.jp',
+ 'co.kr', 'or.kr', 'ne.kr', 'go.kr', 'ac.kr',
+ 'com.br', 'net.br', 'org.br', 'gov.br', 'edu.br',
+ 'com.mx', 'org.mx', 'net.mx', 'gob.mx', 'edu.mx',
+ 'com.ar', 'net.ar', 'org.ar', 'gob.ar',
+ 'com.co', 'net.co', 'org.co', 'gov.co',
+ 'com.pe', 'org.pe', 'net.pe',
+ 'com.cn', 'net.cn', 'org.cn', 'gov.cn', 'edu.cn',
+ 'com.hk', 'net.hk', 'org.hk', 'edu.hk', 'gov.hk',
+ 'com.tw', 'net.tw', 'org.tw', 'edu.tw', 'gov.tw',
+ 'com.sg', 'net.sg', 'org.sg', 'edu.sg', 'gov.sg',
+ 'com.my', 'net.my', 'org.my', 'edu.my', 'gov.my',
+ 'co.id', 'or.id', 'web.id', 'ac.id', 'go.id',
+ 'com.ph', 'net.ph', 'org.ph', 'gov.ph',
+ 'com.vn', 'net.vn', 'org.vn', 'gov.vn',
+ 'co.th', 'in.th', 'or.th', 'ac.th', 'go.th',
+ 'co.in', 'net.in', 'org.in', 'firm.in', 'gen.in', 'ind.in', 'ac.in', 'gov.in', 'edu.in',
+ 'com.tr', 'net.tr', 'org.tr', 'gov.tr', 'edu.tr',
+ 'com.ua', 'net.ua', 'org.ua', 'gov.ua',
+ 'com.pl', 'net.pl', 'org.pl',
+ 'co.za', 'org.za', 'net.za', 'gov.za', 'ac.za',
+ 'co.il', 'org.il', 'net.il', 'ac.il', 'gov.il',
+ 'com.sa', 'net.sa', 'org.sa', 'gov.sa',
+ 'com.eg', 'net.eg', 'org.eg', 'gov.eg',
+ 'co.ke', 'or.ke', 'ne.ke', 'go.ke',
+ 'com.ng', 'org.ng', 'net.ng', 'gov.ng',
+ // ICANN wildcards and their exceptions
+ '*.ck', '!www.ck',
+ '*.bd', '*.er', '*.fk', '*.jm', '*.kh', '*.mm', '*.np', '*.pg',
+ // Private section: platforms where each customer's subdomain is its own site
+ 'myshopify.com', 'bigcartel.com', 'wixsite.com',
+ 'github.io', 'gitlab.io', 'blogspot.com', 'herokuapp.com', 'netlify.app', 'vercel.app',
+ 'pages.dev', 'workers.dev', 'web.app', 'firebaseapp.com', 'appspot.com', 'azurewebsites.net',
+ 'cloudfront.net',
+];
+
+const EXACT = new Set();
+const WILDCARD = new Set(); // parent of a '*.' rule
+const EXCEPTION = new Set();
+for (const r of RULES) {
+ if (r.startsWith('!')) EXCEPTION.add(r.slice(1));
+ else if (r.startsWith('*.')) WILDCARD.add(r.slice(2));
+ else EXACT.add(r);
+}
+
+/**
+ * Number of labels of the public suffix of `host` (already normalized, labels joined by '.').
+ * PSL algorithm: exceptions first, then the longest matching normal or wildcard rule, else 1.
+ */
+function suffixLabels(host) {
+ const labels = host.split('.');
+ let best = 1;
+ for (let i = 0; i < labels.length; i++) {
+ const candidate = labels.slice(i).join('.');
+ const n = labels.length - i;
+ if (EXCEPTION.has(candidate)) return n - 1;
+ if (EXACT.has(candidate) && n > best) best = n;
+ if (i > 0 && WILDCARD.has(candidate) && n + 1 > best) best = n + 1;
+ }
+ return best;
+}
+
+/** The public suffix of a normalized host (e.g. 'co.uk'). */
+function publicSuffix(host) {
+ const labels = host.split('.');
+ return labels.slice(labels.length - Math.min(suffixLabels(host), labels.length)).join('.');
+}
+
+/** eTLD+1, or null when the host IS a public suffix (e.g. 'co.uk', 'myshopify.com'). */
+function registrableDomain(host) {
+ const labels = host.split('.');
+ const n = suffixLabels(host);
+ if (labels.length <= n) return null;
+ return labels.slice(labels.length - n - 1).join('.');
+}
+
+module.exports = { RULES, publicSuffix, registrableDomain, suffixLabels };
new file mode 100644
new file mode 100644
@@ -0,0 +1,124 @@
@@ -0,0 +1,124 @@
+'use strict';
+
+/**
+ * What the outside world sees of a merchant or a code: canonical URLs, the indexability gate's
+ * decision (openvibe-publishing/seo), the OpenVibe.Search document and the product events.
+ *
+ * /m/:slug merchant page indexable when the merchant is active and has at least one active
+ * code; otherwise noindex ("thin": no active codes) — still served
+ * /c/:id code page indexable while the code is active; an expired code is noindex
+ * (gate reason `expired`), a disabled one is hidden (`takedown`, 410),
+ * a code waiting for review or on a pending merchant is hidden (`draft`)
+ *
+ * Search documents are coupons (type `coupon`) and merchants (type `merchant`). A code that leaves
+ * active results becomes a tombstone; a resource that was never indexed gets no tombstone.
+ */
+const seo = require('openvibe-publishing/seo');
+const hooks = require('openvibe-publishing/index-hooks');
+const { isActive } = require('./confidence');
+
+const ACTOR = Object.freeze({ type: 'service', id: 'coupons' });
+const COUPON_POLICY = { minWords: 0 };
+const MERCHANT_POLICY = { minWords: 1 };
+
+function createPublication({ store, config, outbox }) {
+ const abs = (p) => seo.canonicalUrl(config.baseUrl, p);
+ const merchantPath = (m) => `/m/${m.slug}`;
+ const couponPath = (c) => `/c/${c.id}`;
+
+ /** The gate's decision for a code page. */
+ function couponDecision(c, m, now = store.now()) {
+ const visible = m && m.status === 'active' && c.review_state === 'published';
+ const expiredAt = c.expires_at != null ? c.expires_at : (c.status === 'expired' ? (c.expired_at || now) : null);
+ return seo.evaluate({
+ state: visible ? 'published' : 'draft',
+ visibility: 'public',
+ takedown: c.status === 'disabled' || (m && m.status === 'disabled'),
+ canonicalUrl: abs(couponPath(c)),
+ wordCount: 0,
+ ...(expiredAt != null ? { expiresAt: expiredAt } : {}),
+ }, { policy: COUPON_POLICY, now });
+ }
+
+ /** The gate's decision for a merchant page; `activeCount` = its active codes right now. */
+ function merchantDecision(m, activeCount, now = store.now()) {
+ return seo.evaluate({
+ state: m.status === 'active' ? 'published' : 'draft',
+ visibility: 'public',
+ takedown: m.status === 'disabled',
+ canonicalUrl: abs(merchantPath(m)),
+ // A merchant page's substance is its active codes: none → thin (noindex, still served).
+ wordCount: activeCount,
+ }, { policy: MERCHANT_POLICY, now });
+ }
+
+ function couponDocument(c, m, { restrictionsText = '', sourceRefs = [] } = {}) {
+ const now = store.now();
+ const decision = couponDecision(c, m, now);
+ const active = m && m.status === 'active' && isActive(c, now);
+ const statusLabel = { unknown: 'validity unknown', reported_working: 'reported working', reported_failed: 'reported not working' }[c.status] || c.status;
+ return hooks.buildIndexDocument({
+ owner: 'coupons', type: 'coupon', id: c.id, revision: 0,
+ state: active ? 'published' : 'unpublished', visibility: 'public',
+ canonicalUrl: abs(couponPath(c)),
+ title: `${c.code} — ${c.title}${m ? ` (${m.name})` : ''}`,
+ summary: `${statusLabel}; expiry ${c.expires_at != null ? new Date(c.expires_at).toISOString().slice(0, 10) : 'unknown'}`,
+ body: [c.description || '', restrictionsText].filter(Boolean).join('\n'),
+ facets: { merchant: m ? m.slug : 'unknown', status: c.status, expiry_known: c.expires_at != null },
+ provenance: sourceRefs,
+ decision,
+ publishedAt: c.created_at,
+ updatedAt: c.updated_at,
+ language: 'en',
+ });
+ }
+
+ function merchantDocument(m, activeCount) {
+ const decision = merchantDecision(m, activeCount);
+ return hooks.buildIndexDocument({
+ owner: 'coupons', type: 'merchant', id: m.id, revision: 0,
+ state: m.status === 'active' ? 'published' : 'unpublished', visibility: 'public',
+ canonicalUrl: abs(merchantPath(m)),
+ title: `${m.name} coupon codes`,
+ summary: `${activeCount} active code${activeCount === 1 ? '' : 's'}`,
+ body: m.description || '',
+ facets: { active_codes: activeCount },
+ decision,
+ publishedAt: m.created_at,
+ updatedAt: m.updated_at,
+ language: 'en',
+ });
+ }
+
+ /** Stamp and enqueue a Search document (inside the caller's transaction). */
+ function sendDocument(doc, { traceparent } = {}) {
+ if (doc.deleted && store.sequencer.current(doc.owner, doc.type, doc.id) == null) return null; // never indexed
+ const current = store.sequencer.current(doc.owner, doc.type, doc.id);
+ const stamped = store.sequencer.stamp(doc);
+ if (current === stamped.revision) return null; // the same document again: nothing to send
+ return outbox.emit(hooks.indexEvent({ document: stamped, now: store.now() }), { traceparent });
+ }
+
+ /**
+ * A product event (inside the caller's transaction). Public only while the code is publicly
+ * listed; the actor is always the service and the payload never names a person.
+ */
+ function emit(eventType, subject, payload, { isPublic = false, traceparent } = {}) {
+ return outbox.emit({
+ event_type: eventType,
+ actor: ACTOR,
+ visibility: isPublic ? 'public' : 'internal',
+ subject,
+ payload,
+ }, { traceparent });
+ }
+
+ return {
+ abs, merchantPath, couponPath,
+ merchantUrl: (m) => abs(merchantPath(m)),
+ couponUrl: (c) => abs(couponPath(c)),
+ couponDecision, merchantDecision, couponDocument, merchantDocument, sendDocument, emit,
+ };
+}
+
+module.exports = { createPublication, ACTOR };
new file mode 100644
new file mode 100644
@@ -0,0 +1,98 @@
@@ -0,0 +1,98 @@
+'use strict';
+
+/**
+ * Worked/failed reports by people.
+ *
+ * Who may report: a signed-in person (site form or a Bearer Network token), an extension install
+ * token with the coupons.report scope (it belongs to the person who connected it), or a service
+ * with coupons.report.create acting for a person (X-OV-Subject). Never anonymously, and never as
+ * AI output (X-OV-Origin: ai is refused): a model cannot report that a code works.
+ *
+ * Deduplicated: one row per (reporter, coupon, UTC day). A second report the same day with the
+ * same outcome changes nothing; with the other outcome it replaces that day's row (a correction,
+ * still one report). Only each reporter's most recent report counts in the confidence formula.
+ * The reporter is stored as HMAC(COUPONS_REPORTER_KEY_SECRET, subject): every channel of one
+ * person (site, extension installs, API) shares one key, so extra installs add nothing.
+ *
+ * Rate-limited: COUPONS_REPORTS_PER_HOUR / _PER_DAY new reports per person (plus per-IP limits
+ * in Express and nginx).
+ *
+ * Private: the response is the code's public view (aggregates only). Nothing returned or emitted
+ * names a reporter.
+ */
+const crypto = require('crypto');
+const { ids } = require('openvibe-contracts');
+const { ApiError } = require('../http/errors');
+
+const OUTCOMES = ['worked', 'failed'];
+const REASONS = ['invalid', 'expired', 'min_spend_not_met', 'not_eligible', 'other'];
+const DEV_KEY = 'openvibe-coupons-development-reporter-key';
+
+function createReports({ store, config, coupons, merchants, publication }) {
+ const { db } = store;
+ const secret = config.reporterKeySecret || DEV_KEY;
+ const q = {
+ find: db.prepare('SELECT * FROM coupon_validation_reports WHERE coupon_id = ? AND reporter_key = ? AND day = ?'),
+ insert: db.prepare(`INSERT INTO coupon_validation_reports (coupon_id, reporter_key, channel, install_id, day, outcome, reason, created_at, updated_at)
+ VALUES (@coupon_id, @reporter_key, @channel, @install_id, @day, @outcome, @reason, @now, @now)`),
+ correct: db.prepare('UPDATE coupon_validation_reports SET outcome = @outcome, reason = @reason, channel = @channel, install_id = @install_id, updated_at = @now WHERE id = @id'),
+ since: db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports WHERE reporter_key = ? AND created_at > ?'),
+ stamp: db.prepare(`UPDATE coupons SET last_report_at = @now,
+ last_worked_at = CASE WHEN @outcome = 'worked' THEN @now ELSE last_worked_at END,
+ last_failed_at = CASE WHEN @outcome = 'failed' THEN @now ELSE last_failed_at END
+ WHERE id = @id`),
+ };
+
+ const reporterKey = (subject) => crypto.createHmac('sha256', secret).update(`coupons-reporter:${subject}`).digest('hex');
+
+ /** The person a caller reports as, and the channel. Throws when there is none. */
+ function reporterOf(viewer, channel) {
+ if (!viewer || viewer.kind === 'anonymous') throw new ApiError(401, 'auth.required', 'sign in (or connect the extension) to report');
+ if (viewer.origin === 'ai') throw new ApiError(403, 'report.ai_refused', 'reports come from people; model output cannot report a code as working or failed');
+ if (!viewer.subject || !ids.isSubjectId('user', viewer.subject)) throw new ApiError(403, 'auth.person_required', 'a report needs the person it is from (X-OV-Subject for services)');
+ return { subject: viewer.subject, channel: viewer.kind === 'install' ? 'extension' : channel, install: viewer.kind === 'install' ? viewer.install : null };
+ }
+
+ /**
+ * → { accepted, deduplicated, corrected, coupon (public view) }
+ */
+ function report(viewer, couponId, body, { channel = 'api', traceparent } = {}) {
+ const outcome = body && body.outcome;
+ if (!OUTCOMES.includes(outcome)) throw new ApiError(422, 'report.bad_outcome', 'outcome must be "worked" or "failed"');
+ const reason = body.reason == null || body.reason === '' ? null : body.reason;
+ if (reason !== null && !REASONS.includes(reason)) throw new ApiError(422, 'report.bad_reason', `reason must be one of ${REASONS.join(', ')}`);
+ if (outcome === 'worked' && reason) throw new ApiError(422, 'report.bad_reason', 'a reason applies to failed reports only');
+ const who = reporterOf(viewer, channel);
+ const c = coupons.get(couponId);
+ if (!c) throw new ApiError(404, 'coupon.not_found', 'no such code');
+ const m = merchants.byId(c.merchant_id);
+ const now = store.now();
+ const isActive = m && m.status === 'active' && c.review_state === 'published' && c.status !== 'expired' && c.status !== 'disabled' && (c.expires_at == null || c.expires_at > now);
+ if (!isActive) throw new ApiError(409, 'coupon.not_active', 'this code is not in active results (expired, disabled or not published)');
+
+ const key = reporterKey(who.subject);
+ const day = new Date(now).toISOString().slice(0, 10);
+ return store.tx(() => {
+ const existing = q.find.get(c.id, key, day);
+ if (existing && existing.outcome === outcome && existing.reason === reason) {
+ return { accepted: true, deduplicated: true, corrected: false, coupon: coupons.view(coupons.get(c.id), { merchant: m }) };
+ }
+ if (!existing) {
+ if (q.since.get(key, now - 60 * 60 * 1000).n >= config.limits.reportsPerHour || q.since.get(key, now - 24 * 60 * 60 * 1000).n >= config.limits.reportsPerDay) {
+ throw new ApiError(429, 'report.rate_limited', 'too many reports; try again later');
+ }
+ q.insert.run({ coupon_id: c.id, reporter_key: key, channel: who.channel, install_id: who.install, day, outcome, reason, now });
+ publication.emit('coupons.report.created', { type: 'coupon', id: c.id }, { merchant_id: c.merchant_id, outcome, ...(reason ? { reason } : {}), channel: who.channel, day }, { traceparent });
+ } else {
+ q.correct.run({ id: existing.id, outcome, reason, channel: who.channel, install_id: who.install, now });
+ }
+ q.stamp.run({ id: c.id, now, outcome });
+ coupons.recompute(c.id, { reason: 'report', actor: 'system', traceparent });
+ return { accepted: true, deduplicated: Boolean(existing), corrected: Boolean(existing), coupon: coupons.view(coupons.get(c.id), { merchant: m }) };
+ });
+ }
+
+ return { report, reporterKey, OUTCOMES, REASONS, usingDevKey: !config.reporterKeySecret };
+}
+
+module.exports = { createReports, OUTCOMES, REASONS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
+'use strict';
+
+/**
+ * Members watching a merchant (coupon_watches). A watch is private to its member: it is never
+ * shown to anyone else and never leaves Coupons.
+ *
+ * Delivery is not built yet: Coupons records watches and lists them on /watching, but sends no
+ * notification. It will go through the Network's notification store (roadmap W3) when Coupons is
+ * granted a notification capability; until then the page says so.
+ */
+const { ids } = require('openvibe-contracts');
+const { ApiError } = require('../http/errors');
+
+const MAX_WATCHES = 200;
+
+function createWatches({ store }) {
+ const { db } = store;
+ const q = {
+ add: db.prepare('INSERT OR IGNORE INTO coupon_watches (subject, merchant_id, created_at) VALUES (?, ?, ?)'),
+ remove: db.prepare('DELETE FROM coupon_watches WHERE subject = ? AND merchant_id = ?'),
+ has: db.prepare('SELECT 1 FROM coupon_watches WHERE subject = ? AND merchant_id = ?'),
+ count: db.prepare('SELECT COUNT(*) AS n FROM coupon_watches WHERE subject = ?'),
+ mine: db.prepare(`SELECT m.* FROM coupon_watches w JOIN coupon_merchants m ON m.id = w.merchant_id
+ WHERE w.subject = ? AND m.status = 'active' ORDER BY m.name COLLATE NOCASE`),
+ };
+ const person = (subject) => {
+ if (!ids.isSubjectId('user', subject)) throw new ApiError(403, 'auth.person_required', 'sign in to watch a merchant');
+ return subject;
+ };
+ return {
+ watch(subject, merchant) {
+ person(subject);
+ if (!q.has.get(subject, merchant.id) && q.count.get(subject).n >= MAX_WATCHES) throw new ApiError(409, 'watch.limit', `at most ${MAX_WATCHES} watches`);
+ q.add.run(subject, merchant.id, store.now());
+ },
+ unwatch(subject, merchant) { q.remove.run(person(subject), merchant.id); },
+ isWatching: (subject, merchant) => Boolean(subject && q.has.get(subject, merchant.id)),
+ list: (subject) => q.mine.all(person(subject)),
+ };
+}
+
+module.exports = { createWatches };
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+'use strict';
+
+/**
+ * Coupons → OpenVibe.Events through the openvibe-sdk transactional outbox (ADR-004).
+ *
+ * coupons.coupon.created|updated|expired|disabled a code's lifecycle (charter coupons.created|…)
+ * coupons.report.created a new validity report (never who reported)
+ * coupons.confidence.changed a code's confidence moved (reports or decay)
+ * coupons.index_document.upserted|deleted OpenVibe.Search documents and tombstones for
+ * coupons and merchants (openvibe-publishing
+ * index-hooks), consumed by Search's
+ * '*.index_document.*' subscription
+ *
+ * Every envelope's actor is the service itself ({ type: 'service', id: 'coupons' }) and no payload
+ * names a person: submitters and reporters stay inside Coupons.
+ *
+ * emit() runs inside the SQLite transaction that makes the change, so an event exists if and only
+ * if its change committed. The relay publishes with Coupons' service token (events.event.publish,
+ * audience openvibe.events) only when EVENTS_URL and OV_OAUTH_CLIENT_SECRET are set; otherwise
+ * rows wait in event_outbox and /api/ready reports the relay as off.
+ */
+const { createClient } = require('openvibe-sdk/core');
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
+const { createEventsClient, createOutbox } = require('openvibe-sdk/events');
+
+function createCouponsOutbox({ db, config, fetchImpl, now, log = console }) {
+ const enabled = Boolean(config.events.url && config.oauth.clientSecret);
+ const clientOpts = { baseUrls: { events: config.events.url || 'http://127.0.0.1:4300' }, retries: 0 };
+ if (fetchImpl) clientOpts.fetch = fetchImpl;
+ if (enabled) {
+ clientOpts.tokenProvider = createServiceTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ scope: { 'openvibe.events': 'events.event.publish' }, ...(fetchImpl ? { fetch: fetchImpl } : {}),
+ });
+ } else {
+ clientOpts.getToken = async () => { throw new Error('events relay disabled (EVENTS_URL / OV_OAUTH_CLIENT_SECRET unset)'); };
+ }
+ const events = createEventsClient(createClient(clientOpts), { source: 'coupons' });
+ let lastError = null;
+ const outbox = createOutbox(db, {
+ events,
+ intervalMs: config.events.intervalMs,
+ now,
+ onError: (err) => {
+ const msg = err && err.message;
+ if (msg !== lastError) log.warn('[Coupons] event publish failed (will retry):', msg);
+ lastError = msg;
+ },
+ });
+ outbox.ensureSchema();
+
+ /** Inside the caller's transaction. Returns the complete envelope (with its event_id). */
+ function emit(envelope, { traceparent } = {}) {
+ return outbox.enqueue(envelope, { traceparent });
+ }
+
+ return {
+ emit,
+ outbox,
+ enabled,
+ start() { if (enabled) outbox.start(); },
+ stop: () => outbox.stop(),
+ kick() { if (enabled) outbox.kick(); },
+ status: () => ({ enabled, pending: outbox.pending(), rejected: outbox.rejected(), last_error: lastError }),
+ };
+}
+
+module.exports = { createCouponsOutbox };
new file mode 100644
new file mode 100644
@@ -0,0 +1,249 @@
@@ -0,0 +1,249 @@
+'use strict';
+
+/**
+ * The JSON API (/api/v1). Identity comes from the Authorization header ONLY (cookies are ignored
+ * here), so a web page cannot use a visitor's session against it.
+ *
+ * Public lookup (the browser helper's two calls; anonymous allowed; CORS only for configured
+ * extension origins; rate-limited per IP when anonymous, per install with a cpx_ token):
+ * GET /merchants/resolve?host= coupons.merchant.resolve | scope coupons.lookup
+ * GET /merchants/:id/coupons coupons.coupon.lookup | scope coupons.lookup
+ * Also public (no CORS):
+ * GET /merchants/:id coupons.coupon.lookup | scope coupons.lookup
+ * GET /coupons/:id coupons.coupon.lookup | scope coupons.lookup
+ * People (Bearer Network token), installs and services acting for a person:
+ * POST /coupons/:id/report coupons.report.create | scope coupons.report
+ * POST /coupons/submit coupons.coupon.submit (installs cannot submit)
+ * Staff (Network admins / COUPONS_STAFF_SUBJECTS with a Bearer token) and services:
+ * POST /coupons/:id/status coupons.status.update disabled | expired | active
+ * POST /merchants coupons.merchant.manage
+ * POST /merchants/:id/domains coupons.merchant.manage
+ * POST /merchants/:id/status coupons.merchant.manage active | disabled
+ *
+ * Every lookup response is the same bytes for every caller: it holds no data about the caller or
+ * any other person (no submitter, no reporter, no install).
+ */
+const express = require('express');
+const rateLimit = require('express-rate-limit');
+const contracts = require('openvibe-contracts');
+const { guard } = require('../auth/viewer');
+const { CAPABILITIES, SCOPES } = require('../auth/capabilities');
+const { ApiError, run, jsonBody } = require('./errors');
+const hosts = require('../domain/hosts');
+
+const MOZ_EXTENSION_RE = /^moz-extension:\/\/[0-9a-f-]{36}$/;
+
+function createApi(ctx) {
+ const { config, viewers, merchants, coupons, reports, publication } = ctx;
+ const router = express.Router();
+
+ // ── Helpers ─────────────────────────────────────────────
+ const noStore = (res) => res.set('Cache-Control', 'private, no-store');
+
+ function extensionOriginAllowed(origin) {
+ if (!origin) return false;
+ if (config.extensionOrigins.includes(origin)) return true;
+ return config.extensionOrigins.includes('moz-extension://*') && MOZ_EXTENSION_RE.test(origin);
+ }
+
+ /** CORS for the lookup routes only: exact configured extension origins, never credentials. */
+ function lookupCors(req, res, next) {
+ res.vary('Origin');
+ const origin = req.get('origin');
+ if (extensionOriginAllowed(origin)) {
+ res.set('Access-Control-Allow-Origin', origin);
+ res.set('Access-Control-Allow-Methods', 'GET, OPTIONS');
+ res.set('Access-Control-Allow-Headers', 'Authorization');
+ res.set('Access-Control-Max-Age', '600');
+ }
+ if (req.method === 'OPTIONS') return res.status(204).end();
+ next();
+ }
+
+ const tooMany = (code) => (req, res) => {
+ noStore(res);
+ contracts.http.sendProblem(res, 429, code, { detail: 'Too many requests; slow down and try again shortly.', ctx: req.ov });
+ };
+
+ const lookupLimiter = rateLimit({
+ windowMs: 60_000,
+ standardHeaders: true,
+ legacyHeaders: false,
+ limit: (req) => (req.viewer.kind === 'install' ? config.limits.lookupTokenPerMin
+ : req.viewer.kind === 'service' ? config.limits.lookupServicePerMin : config.limits.lookupAnonPerMin),
+ keyGenerator: (req) => (req.viewer.kind === 'install' ? `install:${req.viewer.install}`
+ : req.viewer.kind === 'service' ? `service:${req.viewer.service}` : `ip:${req.ip}`),
+ handler: tooMany('lookup.rate_limited'),
+ });
+ // Per-IP ceiling in front of the per-person limits of reports and submissions.
+ const writeLimiter = rateLimit({ windowMs: 60_000, limit: 60, standardHeaders: true, legacyHeaders: false, handler: tooMany('request.rate_limited') });
+
+ const publicCache = (res) => res.set('Cache-Control', 'public, max-age=60');
+
+ function merchantView(m) {
+ return {
+ id: m.id,
+ slug: m.slug,
+ name: m.name,
+ homepage_url: m.homepage_url,
+ url: publication.merchantUrl(m),
+ domains: merchants.domains(m).map((d) => ({ host: d.host, include_subdomains: Boolean(d.include_subdomains), path_prefix: d.path_prefix || null })),
+ active_codes: coupons.activeCount(m.id),
+ hints: coupons.merchantHints(m.id).map((h) => h.text),
+ };
+ }
+
+ function activeMerchant(idOrSlug) {
+ const m = merchants.get(idOrSlug);
+ if (!m || m.status !== 'active') throw new ApiError(404, 'merchant.not_found', 'no such merchant');
+ return m;
+ }
+
+ function requireStaffOrService(req) {
+ const v = req.viewer;
+ if (v.kind === 'service') return v.service;
+ if (v.kind === 'user' && v.staff) return v.subject;
+ if (v.kind === 'anonymous') throw new ApiError(401, 'auth.required', 'authentication required');
+ throw new ApiError(403, 'auth.staff_required', 'staff only');
+ }
+
+ router.use(viewers.api());
+
+ // ── Lookup ──────────────────────────────────────────────
+ router.options(['/merchants/resolve', '/merchants/:id/coupons'], lookupCors);
+
+ router.get('/merchants/resolve', lookupCors, lookupLimiter, guard(CAPABILITIES.MERCHANT_RESOLVE, { scope: SCOPES.LOOKUP }), run((req, res) => {
+ if (typeof req.query.host !== 'string') throw new ApiError(400, 'host.required', 'pass ?host=<hostname>');
+ const found = merchants.resolve(req.query.host);
+ publicCache(res);
+ if (!found) {
+ const host = hosts.normalizeHost(req.query.host);
+ throw new ApiError(404, 'merchant.not_found', `no merchant for ${host}`, { host, registrable_domain: hosts.registrable(host) });
+ }
+ return {
+ host: found.host,
+ registrable_domain: found.registrable,
+ matched_rule: { host: found.rule.host, include_subdomains: Boolean(found.rule.include_subdomains), path_prefix: found.rule.path_prefix || null },
+ merchant: merchantView(found.merchant),
+ };
+ }));
+
+ router.get('/merchants/:id/coupons', lookupCors, lookupLimiter, guard(CAPABILITIES.COUPON_LOOKUP, { scope: SCOPES.LOOKUP }), run((req, res) => {
+ const m = activeMerchant(req.params.id);
+ publicCache(res);
+ return { merchant: merchantView(m), coupons: coupons.active(m.id).map((c) => coupons.view(c, { merchant: m })) };
+ }));
+
+ router.get('/merchants/:id', lookupLimiter, guard(CAPABILITIES.COUPON_LOOKUP, { scope: SCOPES.LOOKUP }), run((req, res) => {
+ const m = activeMerchant(req.params.id);
+ publicCache(res);
+ return { merchant: merchantView(m) };
+ }));
+
+ router.get('/coupons/:id', lookupLimiter, guard(CAPABILITIES.COUPON_LOOKUP, { scope: SCOPES.LOOKUP }), run((req, res) => {
+ const c = coupons.get(req.params.id);
+ const m = c && merchants.byId(c.merchant_id);
+ if (!c || !m || m.status !== 'active' || c.review_state !== 'published' || c.status === 'disabled') throw new ApiError(404, 'coupon.not_found', 'no such code');
+ publicCache(res);
+ return { coupon: coupons.view(c, { merchant: m }), merchant: merchantView(m) };
+ }));
+
+ // ── Reports ─────────────────────────────────────────────
+ router.post('/coupons/:id/report', writeLimiter, jsonBody, guard(CAPABILITIES.REPORT_CREATE, { scope: SCOPES.REPORT }), run((req, res) => {
+ noStore(res);
+ return reports.report(req.viewer, req.params.id, req.body || {}, { channel: 'api', traceparent: req.get('traceparent') });
+ }, (out) => (out.deduplicated ? 200 : 201)));
+
+ // ── Submissions ─────────────────────────────────────────
+ router.post('/coupons/submit', writeLimiter, jsonBody, guard(CAPABILITIES.COUPON_SUBMIT), run((req, res) => {
+ noStore(res);
+ const v = req.viewer;
+ let who;
+ if (v.kind === 'anonymous') throw new ApiError(401, 'auth.required', 'sign in to submit a code');
+ if (v.kind === 'service' && v.origin === 'ai') who = { actor: v.service, kind: 'ai', subject: null };
+ else if (v.subject) who = { actor: v.subject, kind: v.kind === 'user' && v.staff ? 'staff' : 'member', subject: v.subject };
+ else throw new ApiError(403, 'auth.person_required', 'a submission needs the person it is from (X-OV-Subject for services)');
+ const out = submitCode(who, req.body || {}, { traceparent: req.get('traceparent'), limits: v.kind === 'service' ? scaled(config.limits, 10) : config.limits });
+ return {
+ coupon: coupons.view(out.coupon, { merchant: out.merchant }),
+ merchant: { id: out.merchant.id, slug: out.merchant.slug, name: out.merchant.name, status: out.merchant.status },
+ duplicate: out.duplicate,
+ review_state: out.coupon.review_state,
+ };
+ }, (out) => (out.duplicate ? 200 : 201)));
+
+ // ── Staff and services ──────────────────────────────────
+ router.post('/coupons/:id/status', writeLimiter, jsonBody, guard(CAPABILITIES.STATUS_UPDATE), run((req, res) => {
+ noStore(res);
+ const actor = requireStaffOrService(req);
+ const c = coupons.get(req.params.id);
+ if (!c) throw new ApiError(404, 'coupon.not_found', 'no such code');
+ const body = req.body || {};
+ const after = coupons.setStatus(c, body.status, { actor, note: body.note, traceparent: req.get('traceparent') });
+ return { coupon: coupons.view(after) };
+ }));
+
+ router.post('/merchants', writeLimiter, jsonBody, guard(CAPABILITIES.MERCHANT_MANAGE), run((req, res) => {
+ noStore(res);
+ const actor = requireStaffOrService(req);
+ const body = req.body || {};
+ const status = body.status === 'pending' ? 'pending' : 'active';
+ const m = merchants.create(body, { status, actor });
+ store().tx(() => coupons.syncMerchant(m));
+ return { merchant: { ...merchantView(m), status: m.status } };
+ }, 201));
+
+ router.post('/merchants/:id/domains', writeLimiter, jsonBody, guard(CAPABILITIES.MERCHANT_MANAGE), run((req, res) => {
+ noStore(res);
+ const actor = requireStaffOrService(req);
+ const m = merchants.get(req.params.id);
+ if (!m) throw new ApiError(404, 'merchant.not_found', 'no such merchant');
+ merchants.addDomain(m, req.body || {}, actor);
+ return { merchant: { ...merchantView(m), status: m.status } };
+ }, 201));
+
+ router.post('/merchants/:id/status', writeLimiter, jsonBody, guard(CAPABILITIES.MERCHANT_MANAGE), run((req, res) => {
+ noStore(res);
+ const actor = requireStaffOrService(req);
+ const m = merchants.get(req.params.id);
+ if (!m) throw new ApiError(404, 'merchant.not_found', 'no such merchant');
+ const status = (req.body || {}).status;
+ if (!['active', 'disabled'].includes(status)) throw new ApiError(422, 'merchant.bad_status', 'status must be active or disabled');
+ const after = ctx.moderation.setMerchantStatus(m, status, { actor });
+ return { merchant: { ...merchantView(after), status: after.status } };
+ }));
+
+ // ── The shared submission path (API and the /submit form) ──
+ function submitCode(who, body, { traceparent, limits = config.limits } = {}) {
+ const input = coupons.parseSubmission(body, ctx.store.now());
+ let merchant = null;
+ if (body.merchant_id) {
+ merchant = merchants.get(String(body.merchant_id));
+ if (!merchant) throw new ApiError(404, 'merchant.not_found', 'no such merchant');
+ } else if (body.host || body.url) {
+ const at = body.url ? hosts.hostOfUrl(body.url) : { host: hosts.normalizeHost(String(body.host)), path: null };
+ if (!at) throw new ApiError(422, 'host.invalid', 'url must be an http(s) URL of a public site');
+ const any = merchants.resolve(at.host, { path: at.path, includeStatuses: ['active', 'pending', 'disabled'] });
+ if (any) merchant = any.merchant;
+ else {
+ coupons.checkSubmissionRate(who.actor, limits);
+ const reg = hosts.registrable(at.host);
+ if (!reg) throw new ApiError(422, 'host.invalid', `${at.host} is a public suffix, not a site`);
+ // A site no merchant covers yet: proposed as a PENDING merchant, invisible until staff approve it.
+ merchant = merchants.create({ name: reg, domains: [{ host: reg, include_subdomains: true }] }, { status: 'pending', actor: who.actor });
+ }
+ } else {
+ throw new ApiError(422, 'merchant.required', 'name the merchant: merchant_id, host or url');
+ }
+ if (merchant.status === 'disabled') throw new ApiError(409, 'merchant.disabled', 'this merchant was taken down');
+ return coupons.submit(who, merchant, input, { limits, traceparent });
+ }
+
+ const store = () => ctx.store;
+ const scaled = (limits, k) => ({ ...limits, submissionsPerHour: limits.submissionsPerHour * k, submissionsPerDay: limits.submissionsPerDay * k });
+
+ ctx.submitCode = submitCode;
+ return router;
+}
+
+module.exports = { createApi };
new file mode 100644
new file mode 100644
@@ -0,0 +1,131 @@
@@ -0,0 +1,131 @@
+'use strict';
+
+/**
+ * Crawl and machine-readability artifacts (roadmap §32.4/§32.5):
+ *
+ * GET /robots.txt sitemap location + explicit automated-consumer policy
+ * GET /llms.txt orientation for language models
+ * GET /sitemap.xml sitemap index over the two sections below
+ * GET /sitemaps/merchants.xml active shops with at least one active code (the gate decides)
+ * GET /sitemaps/coupons.xml active codes only; lastmod = the code's real last change
+ * GET /feed.xml, /atom.xml, /feed.json newly added active codes
+ * GET /m/:slug/feed.xml one shop's active codes
+ *
+ * Built from the database on every request, never for a viewer. Expired, taken-down, pending and
+ * not-yet-reviewed codes, and pending or disabled shops, never appear: the lists start from the
+ * active-results query, and every entry also passes through the gate.
+ */
+const express = require('express');
+const seo = require('openvibe-publishing/seo');
+const sharedSeo = require('openvibe-shared/seo');
+const pages = require('../render/pages');
+
+function createDiscoveryRoutes({ config, store, merchants, coupons, publication }) {
+ const router = express.Router();
+ const abs = (p) => seo.canonicalUrl(config.baseUrl, p);
+ const xml = (res, body, type = 'application/xml') => res.type(type).set('Cache-Control', 'public, max-age=300').send(body);
+
+ function activeEntries() {
+ const byMerchant = new Map();
+ const out = [];
+ for (const c of coupons.allActive()) {
+ let m = byMerchant.get(c.merchant_id);
+ if (!m) { m = merchants.byId(c.merchant_id); byMerchant.set(c.merchant_id, m); }
+ out.push({ c, m, decision: publication.couponDecision(c, m) });
+ }
+ return out;
+ }
+
+ router.get('/robots.txt', (_req, res) => {
+ const body = [
+ '# openvibe.coupons automated-consumer policy: search engines and AI crawlers are welcome to read',
+ '# shop and code pages, their .json twins, feeds and sitemaps. Forms, sign-in, staff pages and the',
+ '# API are not for crawling. Pages decide their own indexability (meta robots / X-Robots-Tag):',
+ '# an expired code stays readable but is noindex; a Disallow is not a noindex.',
+ sharedSeo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: ['/auth/', '/api/', '/submit', '/connect-extension', '/staff', '/watching'] }),
+ ].join('\n');
+ res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(body);
+ });
+
+ router.get('/llms.txt', (_req, res) => {
+ res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(sharedSeo.llmsTxt({
+ name: 'OpenVibe.Coupons',
+ summary: 'Coupon codes for online shops with merchant/domain matching, restrictions, honest expiry and validity reports by people.',
+ details: 'A code\'s status is exactly one of unknown, reported_working, reported_failed, expired or disabled. Only recent reports by people can make a code reported_working or reported_failed; a submission, a source or a model cannot. An expiry that nobody stated is "unknown" (null in JSON), and restrictions that were not stated are absent, not "none". Every shop page has a JSON twin at /m/<slug>.json and every code at /c/<id>.json with the same content. Expired and taken-down codes are not in lists, feeds or sitemaps.',
+ sections: [
+ { title: 'Start here', links: [
+ { title: 'All shops', url: abs('/'), note: 'with recently added codes' },
+ { title: 'How codes are labelled', url: abs('/about'), note: 'statuses, the confidence formula, expiry, privacy' },
+ { title: 'Sitemap', url: abs('/sitemap.xml') },
+ ] },
+ { title: 'Feeds', links: [{ title: 'RSS', url: abs('/feed.xml') }, { title: 'Atom', url: abs('/atom.xml') }, { title: 'JSON Feed', url: abs('/feed.json') }] },
+ { title: 'Data', links: [
+ { title: 'Shop JSON', url: abs('/'), note: 'append .json to a shop URL (/m/<slug>.json)' },
+ { title: 'Merchant lookup API', url: abs('/api/v1/merchants/resolve?host=example.com'), note: 'GET /api/v1/merchants/resolve?host= and GET /api/v1/merchants/<id>/coupons; anonymous, rate-limited' },
+ ] },
+ ],
+ }));
+ });
+
+ router.get('/sitemap.xml', (_req, res) => {
+ const entries = activeEntries().filter((e) => e.decision.indexable);
+ const newest = entries.length ? Math.max(...entries.map((e) => e.c.updated_at)) : null;
+ xml(res, seo.sitemapIndex([
+ { loc: abs('/sitemaps/merchants.xml'), ...(newest ? { lastmod: new Date(newest).toISOString() } : {}) },
+ { loc: abs('/sitemaps/coupons.xml'), ...(newest ? { lastmod: new Date(newest).toISOString() } : {}) },
+ ]));
+ });
+
+ router.get('/sitemaps/coupons.xml', (_req, res) => {
+ xml(res, seo.sitemap(activeEntries().map((e) => ({ loc: publication.couponUrl(e.c), lastmod: e.c.updated_at, decision: e.decision }))).files[0]);
+ });
+
+ router.get('/sitemaps/merchants.xml', (_req, res) => {
+ const byMerchant = new Map();
+ for (const e of activeEntries()) {
+ const cur = byMerchant.get(e.m.id);
+ if (!cur) byMerchant.set(e.m.id, { m: e.m, count: 1, lastmod: e.c.updated_at });
+ else { cur.count++; cur.lastmod = Math.max(cur.lastmod, e.c.updated_at); }
+ }
+ const list = [...byMerchant.values()].map(({ m, count, lastmod }) => ({ loc: publication.merchantUrl(m), lastmod: Math.max(lastmod, m.updated_at), decision: publication.merchantDecision(m, count) }));
+ xml(res, seo.sitemap(list).files[0]);
+ });
+
+ // ── Feeds ───────────────────────────────────────────────
+ function feedItems(list) {
+ return list.map(({ c, m, decision }) => ({
+ id: `urn:openvibe:coupons:${c.id}`,
+ url: publication.couponUrl(c),
+ title: `${c.code} — ${c.title} (${m.name})`,
+ summary: `${pages.STATUS_LABEL[c.status]}. ${c.expires_at != null ? `Expires ${new Date(c.expires_at).toISOString().slice(0, 10)}.` : 'Expiry unknown.'}`,
+ published: c.created_at,
+ updated: c.updated_at,
+ tags: [m.name],
+ decision,
+ }));
+ }
+ const recent = () => activeEntries().slice(0, 50);
+ const channel = { title: 'OpenVibe.Coupons — new codes', link: abs('/'), description: 'Codes added to OpenVibe.Coupons, with their status and expiry.' };
+
+ router.get('/feed.xml', (_req, res) => xml(res, seo.rssFeed({ ...channel, feedUrl: abs('/feed.xml'), language: 'en' }, feedItems(recent())), 'application/rss+xml'));
+ router.get('/atom.xml', (_req, res) => {
+ const items = feedItems(recent());
+ // Atom needs an <updated>; an empty feed has been empty since this process started, so that
+ // instant is its last change (never the request time).
+ xml(res, seo.atomFeed({ ...channel, feedUrl: abs('/atom.xml'), ...(items.length ? {} : { updated: bootTime }) }, items), 'application/atom+xml');
+ });
+ router.get('/feed.json', (_req, res) => {
+ res.type('application/feed+json').set('Cache-Control', 'public, max-age=300').send(JSON.stringify(seo.jsonFeed({ ...channel, feedUrl: abs('/feed.json') }, feedItems(recent()))));
+ });
+ router.get('/m/:slug/feed.xml', (req, res, next) => {
+ const m = merchants.get(req.params.slug);
+ if (!m || m.status !== 'active' || m.slug !== req.params.slug) return next();
+ const list = coupons.active(m.id).map((c) => ({ c, m, decision: publication.couponDecision(c, m) }));
+ xml(res, seo.rssFeed({ title: `${m.name} codes — OpenVibe.Coupons`, link: publication.merchantUrl(m), description: `Active codes for ${m.name}.`, feedUrl: abs(`/m/${m.slug}/feed.xml`), language: 'en' }, feedItems(list)), 'application/rss+xml');
+ });
+
+ const bootTime = store.now();
+ return router;
+}
+
+module.exports = { createDiscoveryRoutes };
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
+'use strict';
+
+/**
+ * Errors as RFC 9457 problems (contracts errors.problem@1, which keeps the legacy { error } field),
+ * and the small request helpers every router shares.
+ */
+const express = require('express');
+const contracts = require('openvibe-contracts');
+
+/** A refusal with a stable problem code (e.g. 404 'coupon.not_found'). */
+class ApiError extends Error {
+ constructor(status, code, detail, extra) {
+ super(detail || code);
+ this.name = 'ApiError';
+ this.status = status;
+ this.code = code;
+ this.extra = extra || null;
+ }
+}
+
+/** Domain errors (HostError, …) carry { status, code } too. */
+function asApiError(err) {
+ if (err instanceof ApiError) return err;
+ if (err && err.name === 'HostError') return new ApiError(err.status, err.code, err.message);
+ return null;
+}
+
+/** Wrap a JSON handler: its return value is the body; errors become problems. */
+function run(fn, status = 200) {
+ return async (req, res) => {
+ try {
+ const out = await fn(req, res);
+ if (out === undefined || res.headersSent) return;
+ res.status(typeof status === 'function' ? status(out) : status).json(out);
+ } catch (err) {
+ if (res.headersSent) return;
+ const e = asApiError(err);
+ if (e) return contracts.http.sendProblem(res, e.status, e.code, { detail: e.message, ctx: req.ov, extra: e.extra || undefined });
+ console.error('[Coupons API]', err && err.stack ? err.stack : err);
+ contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
+ }
+ };
+}
+
+const jsonParser = express.json({ limit: '32kb' });
+/** JSON body parser whose syntax errors are problems too. */
+function jsonBody(req, res, next) {
+ jsonParser(req, res, (err) => (err ? contracts.http.sendProblem(res, 400, 'request.invalid_json', { detail: 'Malformed JSON body', ctx: req.ov }) : next()));
+}
+
+/** Private, per-viewer responses: never stored by a shared cache, never indexed. */
+function privateNoStore(res) {
+ res.set('Cache-Control', 'private, no-store');
+ res.vary('Cookie');
+ res.vary('Authorization');
+}
+
+module.exports = { ApiError, asApiError, run, jsonBody, privateNoStore };
new file mode 100644
new file mode 100644
@@ -0,0 +1,347 @@
@@ -0,0 +1,347 @@
+'use strict';
+
+/**
+ * Public, server-rendered routes — useful without JavaScript:
+ *
+ * GET / shops, recently added codes, search (?q=)
+ * GET /about statuses, the confidence formula, expiry, privacy, the helper
+ * GET /m/:slug a shop: active codes with status, confidence, last report time,
+ * restrictions, expiry or "unknown"; recently ended codes apart
+ * GET /m/:slug.json the same as data
+ * GET /c/:id, /c/:id.json one code (an expired code: noindex; taken down: 410)
+ * POST /c/:id/report worked / didn't work (signed in, form token)
+ * POST /m/:slug/watch watch / stop watching (signed in, form token)
+ * GET /submit, POST /submit submit a code (signed in, form token)
+ * GET /connect-extension create / revoke browser-helper tokens (signed in, form token)
+ * GET /watching your watched shops (private)
+ * GET /staff … review and moderation (Network admins, COUPONS_STAFF_SUBJECTS)
+ *
+ * Caching: only pages rendered for an ANONYMOUS visitor with status 200 are `public, max-age=60`;
+ * everything else is `private, no-store`. All pages vary on Cookie and Authorization.
+ */
+const express = require('express');
+const seo = require('openvibe-publishing/seo');
+const { renderPage } = require('../render/layout');
+const pages = require('../render/pages');
+const { csrfToken, checkCsrf } = require('../auth/forms');
+const { ApiError } = require('./errors');
+const confidence = require('../domain/confidence');
+const { SCOPES } = require('../auth/capabilities');
+
+const COUPON_ID_RE = /^cpn_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+function createPublicRoutes(ctx) {
+ const { config, store, merchants, coupons, reports, watches, installs, publication, viewers, moderation } = ctx;
+ const router = express.Router();
+ const form = express.urlencoded({ extended: false, limit: '32kb' });
+ router.use(viewers.pages());
+
+ // ── Helpers ─────────────────────────────────────────────
+ function cacheHeaders(res, { cacheable, robots }) {
+ res.vary('Cookie');
+ res.vary('Authorization');
+ res.set('Cache-Control', cacheable ? 'public, max-age=60, stale-while-revalidate=60' : 'private, no-store');
+ if (robots && robots !== 'index, follow') res.set('X-Robots-Tag', robots);
+ }
+
+ function send(req, res, status, page, { cacheable = false } = {}) {
+ cacheHeaders(res, { cacheable: cacheable && req.viewer.kind === 'anonymous' && status === 200, robots: page.decision.robots });
+ res.status(status).type('html').send(renderPage({ ...page, viewer: req.viewer, config, path: req.originalUrl }));
+ }
+
+ /** A decision for pages that are not a shop or a code. */
+ function pageDecision(path, { indexable = true, query = [] } = {}) {
+ return seo.evaluate({
+ state: indexable ? 'published' : 'draft', visibility: indexable ? 'public' : 'private',
+ canonicalUrl: seo.canonicalUrl(config.baseUrl, path, { query }), wordCount: 0,
+ }, { policy: { minWords: 0 }, now: store.now() });
+ }
+
+ function messagePage(req, res, status, heading, text, action) {
+ send(req, res, status, { title: heading, decision: pageDecision(req.path, { indexable: false }), body: pages.message({ heading, text, action }) });
+ }
+ const notFound = (req, res) => messagePage(req, res, 404, 'Not found', 'There is nothing at this address.', { href: '/', label: 'All shops' });
+
+ const csrf = (req) => csrfToken(config, req.viewer);
+ function requireUser(req, res) {
+ if (req.viewer.kind === 'user' && req.viewer.subject) return true;
+ res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
+ return false;
+ }
+ function requireForm(req, res) {
+ if (!requireUser(req, res)) return false;
+ if (!checkCsrf(config, req.viewer, req.body && req.body.csrf)) {
+ messagePage(req, res, 403, 'Form expired', 'This form expired or did not come from this site. Go back, reload the page and try again.');
+ return false;
+ }
+ return true;
+ }
+ const safeBack = (b, fallback) => (typeof b === 'string' && /^\/(?![/\\])[^\s]*$/.test(b) ? b : fallback);
+ const withFlash = (path, key) => `${path}${path.includes('?') ? '&' : '?'}done=${key}`;
+ const FLASH = {
+ reported: 'Thanks — your report was recorded.',
+ report_same: 'You already reported that today; nothing changed.',
+ report_corrected: 'Your report for today was updated.',
+ watching: 'You are watching this shop.',
+ unwatched: 'You stopped watching this shop.',
+ };
+
+ function jsonLd(m, v) {
+ return [
+ seo.structuredData.breadcrumbs([{ name: 'Coupons', url: `${config.baseUrl}/` }, { name: m.name, url: publication.merchantUrl(m) }, ...(v ? [{ name: v.code, url: v.url }] : [])]),
+ seo.structuredData.webPage({ url: v ? v.url : publication.merchantUrl(m), name: v ? `${v.code} — ${v.title}` : `${m.name} coupon codes`, dateModified: v ? v.updated_at : null, inLanguage: 'en' }),
+ ];
+ }
+
+ // ── Home, about ─────────────────────────────────────────
+ router.get('/', (req, res) => {
+ const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 60) : '';
+ const recent = coupons.recentActive(12).map((c) => ({ coupon: c, merchant: merchants.byId(c.merchant_id) }));
+ const list = merchants.listActive({ limit: 500 }).map((m) => ({ merchant: m, active: coupons.activeCount(m.id) }));
+ const decision = q ? pageDecision('/', { indexable: false }) : pageDecision('/');
+ send(req, res, 200, {
+ title: q ? `Search: ${q}` : null, decision, canonical: `${config.baseUrl}/`,
+ feeds: [{ type: 'rss', href: '/feed.xml', title: 'New codes (RSS)' }, { type: 'atom', href: '/atom.xml', title: 'New codes (Atom)' }, { type: 'json', href: '/feed.json', title: 'New codes (JSON Feed)' }],
+ jsonLd: [seo.structuredData.webPage({ url: `${config.baseUrl}/`, name: 'OpenVibe.Coupons', inLanguage: 'en' })],
+ body: pages.home({ merchants: list, recent, q, results: q ? merchants.search(q) : [], total: list.length }),
+ }, { cacheable: true });
+ });
+
+ router.get('/about', (req, res) => {
+ send(req, res, 200, { title: 'How codes are labelled', decision: pageDecision('/about'), body: pages.about({ confidence }) }, { cacheable: true });
+ });
+
+ // ── Shops ───────────────────────────────────────────────
+ function merchantData(m) {
+ const active = coupons.active(m.id).map((c) => coupons.view(c, { merchant: m }));
+ return {
+ merchant: { id: m.id, slug: m.slug, name: m.name, homepage_url: m.homepage_url, url: publication.merchantUrl(m),
+ domains: merchants.domains(m).map((d) => ({ host: d.host, include_subdomains: Boolean(d.include_subdomains), path_prefix: d.path_prefix || null })),
+ hints: coupons.merchantHints(m.id).map((x) => x.text) },
+ coupons: active,
+ };
+ }
+
+ function visibleMerchant(req, res) {
+ const m = merchants.get(req.params.slug);
+ if (!m || m.slug !== req.params.slug || m.status === 'pending') { notFound(req, res); return null; }
+ if (m.status === 'disabled') { messagePage(req, res, 410, 'Gone', 'This shop is no longer listed.', { href: '/', label: 'All shops' }); return null; }
+ return m;
+ }
+
+ router.get('/m/:slug.json', (req, res) => {
+ const m = visibleMerchant(req, res);
+ if (!m) return;
+ const data = merchantData(m);
+ const decision = publication.merchantDecision(m, data.coupons.length);
+ res.vary('Cookie'); res.vary('Authorization');
+ res.set('Cache-Control', 'public, max-age=60');
+ if (!decision.indexable) res.set('X-Robots-Tag', decision.robots);
+ res.json({ ...data, indexability: { indexable: decision.indexable, reasons: decision.codes } });
+ });
+
+ router.get('/m/:slug', (req, res) => {
+ const m = visibleMerchant(req, res);
+ if (!m) return;
+ const active = coupons.active(m.id).map((c) => coupons.view(c, { merchant: m }));
+ const ended = coupons.recentlyEnded(m.id).map((c) => coupons.view(c, { merchant: m }));
+ const decision = publication.merchantDecision(m, active.length);
+ const flashMsg = FLASH[req.query.done] || null;
+ send(req, res, 200, {
+ title: `${m.name} coupon codes`,
+ description: active.length ? `${active.length} active code${active.length === 1 ? '' : 's'} for ${m.name}, with restrictions, expiry and people's reports.` : `No active codes for ${m.name} right now.`,
+ decision, canonical: publication.merchantUrl(m), jsonLd: jsonLd(m, null),
+ feeds: [{ type: 'rss', href: `/m/${m.slug}/feed.xml`, title: `${m.name} codes (RSS)` }],
+ body: pages.merchant({
+ m, domains: merchants.domains(m), hints: coupons.merchantHints(m.id), active, ended,
+ viewer: req.viewer, csrf: csrf(req), back: `/m/${m.slug}`, flashMsg,
+ watching: watches.isWatching(req.viewer.subject, m),
+ }),
+ }, { cacheable: !flashMsg });
+ });
+
+ router.post('/m/:slug/watch', form, (req, res) => {
+ if (!requireForm(req, res)) return;
+ const m = merchants.get(req.params.slug);
+ if (!m || m.status !== 'active') return notFound(req, res);
+ if (req.body.action === 'unwatch') watches.unwatch(req.viewer.subject, m); else watches.watch(req.viewer.subject, m);
+ res.redirect(303, withFlash(`/m/${m.slug}`, req.body.action === 'unwatch' ? 'unwatched' : 'watching'));
+ });
+
+ // ── Codes ───────────────────────────────────────────────
+ function visibleCoupon(req, res) {
+ const id = String(req.params.id || '');
+ const c = COUPON_ID_RE.test(id) ? coupons.get(id) : null;
+ const m = c && merchants.byId(c.merchant_id);
+ if (!c || !m || m.status === 'pending' || c.review_state !== 'published') { notFound(req, res); return null; }
+ if (c.status === 'disabled' || m.status === 'disabled') { messagePage(req, res, 410, 'Gone', 'This code was taken down.', { href: '/', label: 'All shops' }); return null; }
+ return { c, m };
+ }
+
+ router.get('/c/:id.json', (req, res) => {
+ const found = visibleCoupon(req, res);
+ if (!found) return;
+ const decision = publication.couponDecision(found.c, found.m);
+ res.vary('Cookie'); res.vary('Authorization');
+ res.set('Cache-Control', 'public, max-age=60');
+ if (!decision.indexable) res.set('X-Robots-Tag', decision.robots);
+ res.json({ coupon: coupons.view(found.c, { merchant: found.m }), merchant: { id: found.m.id, slug: found.m.slug, name: found.m.name, url: publication.merchantUrl(found.m) }, indexability: { indexable: decision.indexable, reasons: decision.codes } });
+ });
+
+ router.get('/c/:id', (req, res) => {
+ const found = visibleCoupon(req, res);
+ if (!found) return;
+ const { c, m } = found;
+ const v = coupons.view(c, { merchant: m });
+ const flashMsg = FLASH[req.query.done] || null;
+ send(req, res, 200, {
+ title: `${v.code} — ${v.title} at ${m.name}`,
+ description: `${pages.STATUS_LABEL[v.status]}. ${v.expiry.known ? `Expires ${v.expiry.expires_at.slice(0, 10)}.` : 'Expiry unknown.'}`,
+ decision: publication.couponDecision(c, m), canonical: v.url, jsonLd: jsonLd(m, v),
+ body: pages.couponPage({ v, m, history: coupons.history(c.id), viewer: req.viewer, csrf: csrf(req), back: `/c/${c.id}`, flashMsg }),
+ }, { cacheable: !flashMsg });
+ });
+
+ router.post('/c/:id/report', form, (req, res) => {
+ if (!requireForm(req, res)) return;
+ const back = safeBack(req.body.back, `/c/${req.params.id}`);
+ try {
+ const out = reports.report(req.viewer, String(req.params.id), { outcome: req.body.outcome, reason: req.body.reason || null }, { channel: 'site' });
+ const key = !out.deduplicated ? 'reported' : out.corrected ? 'report_corrected' : 'report_same';
+ res.redirect(303, withFlash(back, key));
+ } catch (err) {
+ if (!(err instanceof ApiError)) throw err;
+ messagePage(req, res, err.status, err.status === 429 ? 'Slow down' : 'Could not record that', err.message, { href: back, label: 'Back' });
+ }
+ });
+
+ // ── Submit ──────────────────────────────────────────────
+ router.get('/submit', (req, res) => {
+ const m = typeof req.query.merchant === 'string' ? merchants.get(req.query.merchant) : null;
+ const values = m && m.status === 'active' ? { merchant: m.slug } : {};
+ send(req, res, 200, {
+ title: 'Submit a code', decision: pageDecision('/submit', { indexable: false }),
+ body: pages.submitForm({ values, csrf: csrf(req), viewer: req.viewer, merchantName: values.merchant ? m.name : null }),
+ });
+ });
+
+ router.post('/submit', form, (req, res) => {
+ if (!requireForm(req, res)) return;
+ const b = req.body || {};
+ const m = b.merchant ? merchants.get(String(b.merchant)) : null;
+ const body = {
+ ...(m ? { merchant_id: m.id } : { url: /^https?:\/\//i.test(String(b.url || '')) ? b.url : undefined, host: /^https?:\/\//i.test(String(b.url || '')) ? undefined : b.url }),
+ code: b.code, title: b.title, description: b.description, evidence_url: b.evidence_url || null,
+ expires: b.expires || null, expiry_basis: b.expiry_basis === 'evidence' ? 'evidence' : 'submitter',
+ restrictions: {
+ min_spend: b.min_spend_amount ? { amount: b.min_spend_amount, currency: b.min_spend_currency } : null,
+ categories: b.categories, new_customers_only: b.new_customers_only, regions: b.regions, other: b.restriction_other,
+ },
+ hint: b.hint,
+ };
+ const who = { actor: req.viewer.subject, kind: req.viewer.staff ? 'staff' : 'member', subject: req.viewer.subject };
+ try {
+ const out = ctx.submitCode(who, body, {});
+ send(req, res, out.duplicate ? 200 : 201, { title: 'Submitted', decision: pageDecision('/submit', { indexable: false }), body: pages.submitted(out) });
+ } catch (err) {
+ if (!(err instanceof ApiError) && err.name !== 'HostError') throw err;
+ send(req, res, err.status || 422, {
+ title: 'Submit a code', decision: pageDecision('/submit', { indexable: false }),
+ body: pages.submitForm({ values: b, errs: [err.message], csrf: csrf(req), viewer: req.viewer, merchantName: m ? m.name : null }),
+ });
+ }
+ });
+
+ // ── Browser helper tokens ───────────────────────────────
+ function connectPage(req, res, status, extra = {}) {
+ const list = req.viewer.kind === 'user' && req.viewer.subject ? installs.list(req.viewer.subject) : [];
+ send(req, res, status, {
+ title: 'Connect the browser helper', decision: pageDecision('/connect-extension', { indexable: req.viewer.kind === 'anonymous' }),
+ body: pages.connect({ viewer: req.viewer, installs: list, csrf: csrf(req), ...extra }),
+ }, { cacheable: req.viewer.kind === 'anonymous' });
+ }
+ router.get('/connect-extension', (req, res) => connectPage(req, res, 200));
+ router.post('/connect-extension', form, (req, res) => {
+ if (!requireForm(req, res)) return;
+ try {
+ const created = installs.create(req.viewer.subject, { label: req.body.label, scopes: req.body.scope_report ? [SCOPES.LOOKUP, SCOPES.REPORT] : [SCOPES.LOOKUP] });
+ connectPage(req, res, 201, { created });
+ } catch (err) {
+ if (!(err instanceof ApiError)) throw err;
+ connectPage(req, res, err.status, { errs: [err.message] });
+ }
+ });
+ router.post('/connect-extension/:id/revoke', form, (req, res) => {
+ if (!requireForm(req, res)) return;
+ installs.revoke(req.viewer.subject, String(req.params.id));
+ res.redirect(303, '/connect-extension');
+ });
+
+ router.get('/watching', (req, res) => {
+ if (!requireUser(req, res)) return;
+ send(req, res, 200, { title: 'Watching', decision: pageDecision('/watching', { indexable: false }), body: pages.watching({ merchants: watches.list(req.viewer.subject) }) });
+ });
+
+ // ── Staff ───────────────────────────────────────────────
+ function requireStaff(req, res) {
+ if (!requireUser(req, res)) return false;
+ if (!req.viewer.staff) { messagePage(req, res, 403, 'Staff only', 'This page is for OpenVibe staff.'); return false; }
+ return true;
+ }
+ function staffPage(req, res, status = 200, extra = {}) {
+ const pendingMerchants = merchants.pending().map((m) => ({ ...m, domains: merchants.domains(m).map((d) => d.host + (d.path_prefix || '')), codes: coupons.pendingOfMerchant(m.id).length }));
+ const pendingCoupons = coupons.pendingReview().map((c) => {
+ const m = merchants.byId(c.merchant_id);
+ const v = coupons.view(c, { merchant: m });
+ return { id: c.id, code: c.code, title: c.title, origin: c.origin, merchant: m ? m.name : '?', evidence: (v.evidence.find((e) => e.url) || {}).url || null };
+ });
+ send(req, res, status, {
+ title: 'Staff', decision: pageDecision('/staff', { indexable: false }),
+ body: pages.staff({ pendingMerchants, pendingCoupons, holds: ctx.importer.holds(), csrf: csrf(req), flashMsg: FLASH[req.query.done] || extra.flashMsg || null, errs: extra.errs }),
+ });
+ }
+ function staffAction(fn) {
+ return (req, res) => {
+ if (!requireStaff(req, res)) return;
+ if (!checkCsrf(config, req.viewer, req.body && req.body.csrf)) return messagePage(req, res, 403, 'Form expired', 'Reload the page and try again.');
+ try {
+ const msg = fn(req);
+ staffPage(req, res, 200, { flashMsg: msg });
+ } catch (err) {
+ if (!(err instanceof ApiError) && err.name !== 'HostError') throw err;
+ staffPage(req, res, err.status || 422, { errs: [err.message] });
+ }
+ };
+ }
+ router.get('/staff', (req, res) => { if (requireStaff(req, res)) staffPage(req, res); });
+ router.post('/staff/merchants', form, staffAction((req) => {
+ const b = req.body;
+ const m = merchants.create({ name: b.name, domains: [{ host: b.host, include_subdomains: Boolean(b.include_subdomains), path_prefix: b.path_prefix || '' }] }, { status: 'active', actor: req.viewer.subject });
+ store.tx(() => coupons.syncMerchant(m));
+ return `Added ${m.name}.`;
+ }));
+ router.post('/staff/merchants/:id/status', form, staffAction((req) => {
+ const m = merchants.get(req.params.id);
+ if (!m) throw new ApiError(404, 'merchant.not_found', 'no such shop');
+ const after = moderation.setMerchantStatus(m, req.body.status, { actor: req.viewer.subject });
+ return `${after.name} is now ${after.status}.`;
+ }));
+ router.post('/staff/coupons/:id/approve', form, staffAction((req) => {
+ const c = coupons.get(req.params.id);
+ if (!c) throw new ApiError(404, 'coupon.not_found', 'no such code');
+ const m = merchants.byId(c.merchant_id);
+ if (m.status !== 'active') throw new ApiError(409, 'merchant.not_active', 'approve the shop first');
+ coupons.approve(c, { actor: req.viewer.subject });
+ return `Published ${c.code}.`;
+ }));
+ router.post(['/staff/coupons/status', '/staff/coupons/:id/status'], form, staffAction((req) => {
+ const c = coupons.get(req.params.id || req.body.id);
+ if (!c) throw new ApiError(404, 'coupon.not_found', 'no such code');
+ const after = coupons.setStatus(c, req.body.status, { actor: req.viewer.subject, note: req.body.note });
+ return `${c.code} is now ${pages.STATUS_LABEL[after.status].toLowerCase()}.`;
+ }));
+
+ return { router, notFound };
+}
+
+module.exports = { createPublicRoutes };
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+'use strict';
+
+/**
+ * OpenVibe.Coupons — process entry. `node server/index.js`
+ * Listens on PORT (4850) behind nginx (deploy/). Starts the outbox relay (when EVENTS_URL and the
+ * client secret are set) and the worker (expiry/decay sweep; Sources import when configured).
+ */
+const { createApp } = require('./app');
+
+const { app, ctx } = createApp();
+const { config } = ctx;
+
+const server = app.listen(config.port, config.host, () => {
+ console.log(`[Coupons] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${config.dbPath})`);
+ console.log(`[Coupons] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; worker ${config.worker.enabled ? 'on' : 'off'}; Sources import ${ctx.importer.enabled ? 'on' : 'off'}`);
+});
+server.keepAliveTimeout = 65_000;
+ctx.outbox.start();
+ctx.worker.start();
+
+function shutdown(signal) {
+ console.log(`[Coupons] ${signal}: closing`);
+ ctx.worker.stop();
+ server.close(async () => {
+ try { await ctx.outbox.stop(); } catch { /* best effort */ }
+ try { ctx.store.close(); } catch { /* already closed */ }
+ process.exit(0);
+ });
+ setTimeout(() => process.exit(0), 5000).unref();
+}
+process.on('SIGTERM', () => shutdown('SIGTERM'));
+process.on('SIGINT', () => shutdown('SIGINT'));
new file mode 100644
new file mode 100644
@@ -0,0 +1,76 @@
@@ -0,0 +1,76 @@
+'use strict';
+/**
+ * Truthful readiness for GET /api/ready (openvibe-shared/ready, Track O).
+ *
+ * db required a real query on Coupons' SQLite (the nine charter tables answer)
+ * network_jwks optional the Network signing key has loaded; without it pages, lookups and
+ * feeds still serve, but nobody can sign in and service tokens get 503
+ * events_relay optional the outbox relay is configured and has no rejected rows
+ * sweep optional the expiry/decay sweep ran without error in the last 5 minutes
+ * (active lists do not depend on it: they filter on expiry at query time)
+ * sources_import optional the Sources import is configured and its last run succeeded
+ * reporter_key optional COUPONS_REPORTER_KEY_SECRET is set (otherwise a development key)
+ */
+const { createReadiness } = require('openvibe-shared/ready');
+const { CHARTER_TABLES } = require('./db');
+
+function createCouponsReadiness({ store, auth, outbox, worker, importer, reports, config, release = null }) {
+ const { db } = store;
+ return createReadiness({
+ service: 'coupons',
+ release,
+ checks: [
+ {
+ name: 'db', required: true,
+ check: () => {
+ const names = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));
+ const missing = CHARTER_TABLES.filter((t) => !names.has(t));
+ return missing.length ? `missing ${missing.join(', ')}` : true;
+ },
+ },
+ {
+ name: 'network_jwks', required: false,
+ check: () => {
+ if (auth.client.publicKey) return true;
+ auth.ensureKey().catch(() => {});
+ return 'Network signing key not loaded yet: sign-in and service calls are unavailable';
+ },
+ },
+ {
+ name: 'events_relay', required: false,
+ check: () => {
+ const s = outbox.status();
+ if (!s.enabled) return `relay off (EVENTS_URL or OV_OAUTH_CLIENT_SECRET unset); ${s.pending} events waiting`;
+ if (s.rejected) return `${s.rejected} events rejected by OpenVibe.Events`;
+ return { ok: true, detail: { pending: s.pending } };
+ },
+ },
+ {
+ name: 'sweep', required: false,
+ check: () => {
+ if (!config.worker.enabled) return 'worker off (COUPONS_WORKER=off): expiries are still enforced at query time, but not recorded';
+ const s = worker.lastSweep();
+ if (!s) return 'no sweep yet';
+ if (s.error) return `last sweep failed: ${s.error}`;
+ if (Date.now() - s.at > 5 * 60 * 1000) return 'last sweep is more than 5 minutes old';
+ return true;
+ },
+ },
+ {
+ name: 'sources_import', required: false,
+ check: () => {
+ if (!importer.enabled) return 'off (OV_SOURCES_INTERNAL_URL or OV_OAUTH_CLIENT_SECRET unset)';
+ const st = importer.state();
+ if (st && st.last_error) return `last run failed: ${st.last_error}`;
+ return { ok: true, detail: { cursor: st ? st.cursor : 0, held: importer.holdCount() } };
+ },
+ },
+ {
+ name: 'reporter_key', required: false,
+ check: () => (reports.usingDevKey ? 'COUPONS_REPORTER_KEY_SECRET unset: reporter keys use the development key' : true),
+ },
+ ],
+ });
+}
+
+module.exports = { createCouponsReadiness };
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -0,0 +1,100 @@
+'use strict';
+
+/**
+ * Page shell. Every page is server-rendered through this and is complete without JavaScript:
+ * - <head>: title, description, canonical and robots from the indexability gate's decision
+ * (openvibe-publishing/seo metaTags — there is no default that makes a page indexable),
+ * Open Graph, JSON-LD built from real fields only, feed links, the shared app icon
+ * - the shared chrome: navbar.js and theme-loader.js from the Network (progressive), a
+ * <noscript> navigation bar and the server-rendered shared footer (openvibe-shared)
+ * - no script of Coupons' own beyond the navbar init: copying a code is plain text selection
+ */
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const seo = require('openvibe-publishing/seo');
+const { escapeHtml: esc } = require('openvibe-publishing/ssr');
+const appIcon = require('openvibe-shared/app-icon');
+const chrome = require('openvibe-shared/chrome-ssr');
+
+const NETWORK_URL = 'https://openvibe.network';
+const SITE_NAME = 'OpenVibe.Coupons';
+const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
+const NAV = [
+ { label: 'Merchants', href: '/' },
+ { label: 'Submit a code', href: '/submit' },
+ { label: 'Browser helper', href: '/connect-extension' },
+];
+
+const hashes = new Map();
+function assetVersion(rel) {
+ if (hashes.has(rel)) return hashes.get(rel);
+ let v = 'dev';
+ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }
+ hashes.set(rel, v);
+ return v;
+}
+const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;
+
+/**
+ * o: title, description, decision (required), canonical, jsonLd [], feeds [{ type, href, title }],
+ * body (HTML), viewer, config, path, bodyClass
+ */
+function renderPage(o) {
+ if (!o.decision) throw new TypeError('renderPage needs the gate decision');
+ const head = seo.metaTags({
+ title: o.title ? `${o.title} · ${SITE_NAME}` : SITE_NAME,
+ description: o.description || 'Coupon codes with merchant matching, restrictions, honest expiry and people\'s validity reports.',
+ decision: o.decision,
+ canonical: o.canonical,
+ type: 'website',
+ siteName: SITE_NAME,
+ jsonLd: (o.jsonLd || []).filter(Boolean),
+ });
+ const viewer = o.viewer || { kind: 'anonymous' };
+ const signedIn = viewer.kind === 'user';
+ const loginNext = encodeURIComponent(o.path || '/');
+ const nav = {
+ service: 'coupons',
+ apiBase: NETWORK_URL,
+ links: NAV.map((l) => ({ label: l.label, href: l.href })),
+ history: { type: 'page', title: o.title || SITE_NAME },
+ silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
+ sessionUrl: '/auth/me',
+ loginUrl: `/auth/login?next=${loginNext}`,
+ };
+ const account = signedIn
+ ? `<a href="/watching">Watching</a> · <a href="/connect-extension">Browser helper</a>${viewer.staff ? ' · <a href="/staff">Staff</a>' : ''} · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
+ : `<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;
+ return `<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+${head}
+${seo.feedLinks(o.feeds || [])}
+${appIcon.headTags({ site: 'coupons' })}
+<link rel="stylesheet" href="${asset('css/coupons.css')}">
+<script src="${NETWORK_URL}/shared/theme-loader.js" defer></script>
+<script src="${NETWORK_URL}/shared/navbar.js" defer></script>
+</head>
+<body class="${esc(o.bodyClass || '')}">
+<a class="skip" href="#main">Skip to content</a>
+<div id="navbar-mount"></div>
+${chrome.noscriptNav({ name: SITE_NAME, home: '/', links: NAV })}
+<div class="account-bar" role="navigation" aria-label="Account">${account}</div>
+<main id="main" class="page">
+${o.body || ''}
+</main>
+${chrome.footer({ service: 'coupons', variant: 'full' })}
+<script>
+window.__OV_PAGE = ${JSON.stringify({ navbar: nav }).replace(/</g, '\\u003c')};
+document.addEventListener('DOMContentLoaded', function () {
+ try { if (window.OpenVibeNavbar) OpenVibeNavbar.init(window.__OV_PAGE.navbar); } catch (e) { /* the chrome is optional */ }
+});
+</script>
+</body>
+</html>`;
+}
+
+module.exports = { renderPage, asset, assetVersion, SITE_NAME, NETWORK_URL };
new file mode 100644
new file mode 100644
@@ -0,0 +1,279 @@
@@ -0,0 +1,279 @@
+'use strict';
+
+/**
+ * Page bodies (layout.js wraps them). Everything is in the HTML: codes as selectable text, status,
+ * confidence, last report time, restrictions, expiry or "unknown", evidence, and plain forms.
+ * Values are escaped by ssr.html unless wrapped in raw().
+ *
+ * Wording rules: an unknown is shown as unknown ("Expiry unknown", "No restrictions stated",
+ * "No reports in the last 30 days"), never as a friendly default.
+ */
+const ssr = require('openvibe-publishing/ssr');
+
+const { html: h, raw } = ssr;
+
+const STATUS_LABEL = {
+ unknown: 'Validity unknown',
+ reported_working: 'Reported working',
+ reported_failed: 'Reported not working',
+ expired: 'Expired',
+ disabled: 'Taken down',
+};
+const BASIS_LABEL = {
+ evidence: 'as stated on the evidence page',
+ submitter: 'according to the person who submitted it',
+ source: 'as stated by the imported source',
+ staff: 'set by staff',
+};
+const REASON_LABEL = {
+ invalid: 'Code not accepted', expired: 'Said it has expired', min_spend_not_met: 'Minimum spend not met',
+ not_eligible: 'Not eligible (account, region or items)', other: 'Other',
+};
+
+const utc = (iso) => (iso ? `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC` : null);
+const regionName = (() => {
+ let names = null;
+ try { names = new Intl.DisplayNames(['en'], { type: 'region' }); } catch { /* no ICU */ }
+ return (code) => { try { return names ? `${names.of(code)} (${code})` : code; } catch { return code; } };
+})();
+
+function hidden(csrf) { return h`<input type="hidden" name="csrf" value="${csrf}">`; }
+
+function statusBadge(status) {
+ return h`<span class="status status-${status}">${STATUS_LABEL[status] || status}</span>`;
+}
+
+function expiryText(expiry) {
+ if (!expiry.known) return h`<span class="unknown">Expiry unknown</span>`;
+ const when = expiry.precision === 'date'
+ ? h`${raw(ssr.timeTag(expiry.expires_at, { label: expiry.expires_at.slice(0, 10) }))} (end of that day in UTC; the merchant's time zone is not stated)`
+ : h`${raw(ssr.timeTag(expiry.expires_at, { label: utc(expiry.expires_at) }))}`;
+ return h`Expires ${when}, ${BASIS_LABEL[expiry.basis] || 'source not recorded'}`;
+}
+
+function confidenceText(v) {
+ if (v.confidence == null) return h`<span class="unknown">No reports in the last ${v.reports.window_days} days</span>`;
+ return h`Confidence ${Math.round(v.confidence * 100)}% from ${v.reports.worked} “worked” and ${v.reports.failed} “didn't work” report${v.reports.worked + v.reports.failed === 1 ? '' : 's'} in the last ${v.reports.window_days} days (<a href="/about#confidence">how this is computed</a>)`;
+}
+
+function restrictionsList(list) {
+ if (!list.length) return h`<p class="unknown">No restrictions stated. That does not mean there are none.</p>`;
+ return h`<ul class="restrictions">${list.map((r) => {
+ if (r.kind === 'min_spend') return h`<li>Minimum spend ${r.amount}</li>`;
+ if (r.kind === 'category') return h`<li>Category: ${r.value}</li>`;
+ if (r.kind === 'new_customers_only') return h`<li>New customers only</li>`;
+ if (r.kind === 'region') return h`<li>Region: ${regionName(r.value)}</li>`;
+ return h`<li>${r.value}</li>`;
+ })}</ul>`;
+}
+
+function evidenceList(list) {
+ const withUrl = list.filter((e) => e.url);
+ if (!withUrl.length) return h`<p class="meta">Evidence: none recorded (submitted without a link).</p>`;
+ return h`<p class="meta">Evidence: ${withUrl.map((e, i) => h`${i ? ', ' : ''}<a href="${e.url}" rel="nofollow ugc noopener">${hostOf(e.url)}</a>${e.merchant_page ? ' (the merchant\'s own site)' : ''}${e.retrieved_at ? h` (retrieved ${e.retrieved_at.slice(0, 10)})` : ''}`)}</p>`;
+}
+function hostOf(u) { try { return new URL(u).hostname; } catch { return u; } }
+
+function reportForms(v, { viewer, csrf, back }) {
+ if (!v.active) return '';
+ if (!viewer || viewer.kind !== 'user') return h`<p class="meta"><a href="/auth/login?next=${encodeURIComponent(back)}">Sign in</a> to report whether it worked.</p>`;
+ return h`<div class="report">
+<form method="post" action="/c/${v.id}/report" class="inline">${hidden(csrf)}<input type="hidden" name="outcome" value="worked"><input type="hidden" name="back" value="${back}"><button type="submit">It worked</button></form>
+<form method="post" action="/c/${v.id}/report" class="inline">${hidden(csrf)}<input type="hidden" name="outcome" value="failed"><input type="hidden" name="back" value="${back}">
+<label>Why <select name="reason"><option value="">(not sure)</option>${Object.entries(REASON_LABEL).map(([k, l]) => h`<option value="${k}">${l}</option>`)}</select></label>
+<button type="submit">It didn't work</button></form>
+</div>`;
+}
+
+/** One code. v: coupons.view(); opts: { viewer, csrf, back, link } */
+function couponCard(v, opts = {}) {
+ return h`<article class="coupon status-${v.status}" id="${v.id}">
+<h3>${opts.link ? h`<a href="/c/${v.id}">${v.title}</a>` : v.title}</h3>
+<p class="code-line"><span class="label">Code</span> <code class="code">${v.code}</code> ${statusBadge(v.status)}</p>
+${v.description ? h`<p>${v.description}</p>` : ''}
+<p class="meta">${confidenceText(v)}</p>
+<p class="meta">${v.reports.last_report_at ? h`Last report ${v.reports.last_report_at.slice(0, 10)} around ${v.reports.last_report_at.slice(11, 13)}:00 UTC` : 'No reports yet'}</p>
+<p class="meta">${expiryText(v.expiry)}</p>
+${restrictionsList(v.restrictions)}
+${v.hints.length ? h`<ul class="hints">${v.hints.map((x) => h`<li>How to apply: ${x.text}</li>`)}</ul>` : ''}
+${evidenceList(v.evidence)}
+${v.origin === 'ai' ? h`<p class="disclosure">Extracted by an AI workflow from the evidence page and reviewed by staff before listing. Its validity comes only from people's reports.</p>` : ''}
+${reportForms(v, opts)}
+</article>`;
+}
+
+function flash(msg) {
+ if (!msg) return '';
+ return h`<p class="notice" role="status">${msg}</p>`;
+}
+
+function errors(list) {
+ if (!list || !list.length) return '';
+ return h`<div class="errors" role="alert"><p>Please fix:</p><ul>${list.map((e) => h`<li>${e}</li>`)}</ul></div>`;
+}
+
+function home({ merchants, recent, q, results, total }) {
+ return h`<h1>Coupon codes, honestly labelled</h1>
+<p class="lede">Codes for online shops with their restrictions, their expiry (or “unknown”) and what people reported when they tried them. A code is only ever called working because people said it worked recently.</p>
+<form method="get" action="/" class="search" role="search"><label for="q">Find a shop</label> <input id="q" name="q" value="${q || ''}" maxlength="60" placeholder="name or domain"> <button type="submit">Search</button></form>
+${q ? h`<h2>Shops matching “${q}”</h2>${results.length ? h`<ul class="merchant-list">${results.map((m) => h`<li><a href="/m/${m.slug}">${m.name}</a></li>`)}</ul>` : h`<p class="empty">No shop matches. <a href="/submit">Submit a code</a> for a new one.</p>`}` : ''}
+<h2>Recently added codes</h2>
+${recent.length ? h`<ul class="recent">${recent.map((r) => h`<li><a href="/c/${r.coupon.id}">${r.coupon.title}</a> at <a href="/m/${r.merchant.slug}">${r.merchant.name}</a> ${statusBadge(r.coupon.status)}</li>`)}</ul>` : h`<p class="empty">No codes yet.</p>`}
+<h2>Shops (${total})</h2>
+${merchants.length ? h`<ul class="merchant-list">${merchants.map((m) => h`<li><a href="/m/${m.merchant.slug}">${m.merchant.name}</a> <span class="meta">${m.active} active code${m.active === 1 ? '' : 's'}</span></li>`)}</ul>` : h`<p class="empty">No shops listed yet.</p>`}
+<p class="meta"><a href="/about">How statuses and confidence work</a> · <a href="/feed.xml">RSS</a> · <a href="/atom.xml">Atom</a> · <a href="/feed.json">JSON Feed</a></p>`;
+}
+
+function merchant({ m, domains, hints, active, ended, viewer, csrf, watching, back, flashMsg }) {
+ return h`${raw(ssr.breadcrumbsHtml([{ name: 'Coupons', url: '/' }, { name: m.name }]))}
+${flash(flashMsg)}
+<h1>${m.name} coupon codes</h1>
+<p class="meta">${m.homepage_url ? h`<a href="${m.homepage_url}" rel="nofollow noopener">${hostOf(m.homepage_url)}</a> · ` : ''}Covers ${domains.map((d, i) => h`${i ? '; ' : ''}${d.host}${d.path_prefix || ''}${d.include_subdomains ? ' and its subdomains' : ''}`)}</p>
+${m.description ? h`<p>${m.description}</p>` : ''}
+${hints.length ? h`<ul class="hints">${hints.map((x) => h`<li>How to apply codes here: ${x.text}</li>`)}</ul>` : ''}
+<h2>Active codes (${active.length})</h2>
+${active.length ? active.map((v) => couponCard(v, { viewer, csrf, back, link: true })) : h`<p class="empty">No active codes for ${m.name} right now.</p>`}
+<p><a href="/submit?merchant=${m.slug}">Submit a code for ${m.name}</a></p>
+${viewer && viewer.kind === 'user' ? h`<form method="post" action="/m/${m.slug}/watch">${hidden(csrf)}<input type="hidden" name="action" value="${watching ? 'unwatch' : 'watch'}"><button type="submit">${watching ? 'Stop watching' : 'Watch this shop'}</button> <span class="meta">(your watch list is private; notifications are not sent yet)</span></form>` : ''}
+${ended.length ? h`<h2>Recently ended (not in active results)</h2><ul class="ended">${ended.map((v) => h`<li><code>${v.code}</code> — ${v.title} · ${v.status === 'expired' ? 'expired' : 'past its expiry'} ${v.expired_at ? v.expired_at.slice(0, 10) : v.expiry.expires_at ? v.expiry.expires_at.slice(0, 10) : ''}</li>`)}</ul>` : ''}
+<p class="meta">Follow: <a href="/m/${m.slug}/feed.xml">RSS</a> · <a href="/m/${m.slug}.json">JSON</a></p>`;
+}
+
+function couponPage({ v, m, history, viewer, csrf, back, flashMsg }) {
+ return h`${raw(ssr.breadcrumbsHtml([{ name: 'Coupons', url: '/' }, { name: m.name, url: `/m/${m.slug}` }, { name: v.code }]))}
+${flash(flashMsg)}
+${v.active ? '' : h`<p class="notice">This code is not in active results (${STATUS_LABEL[v.status] ? STATUS_LABEL[v.status].toLowerCase() : v.status}${v.expiry.known && !v.active && v.status !== 'expired' ? ', past its expiry' : ''}).</p>`}
+${couponCard(v, { viewer, csrf, back })}
+<h2>Status history</h2>
+${history.length ? h`<ol class="history">${history.map((x) => h`<li>${utc(new Date(x.created_at).toISOString())}: ${x.from_status ? h`${STATUS_LABEL[x.from_status] || x.from_status} → ` : ''}${STATUS_LABEL[x.to_status] || x.to_status}${x.confidence_after != null ? h`, confidence ${Math.round(x.confidence_after * 100)}%` : ''} <span class="meta">(${x.reason.startsWith('staff') ? 'staff' : x.reason.startsWith('service:') ? 'service' : x.reason})</span></li>`)}</ol>` : h`<p class="empty">No changes yet.</p>`}
+<p class="meta"><a href="/c/${v.id}.json">This code as JSON</a></p>`;
+}
+
+function about({ confidence }) {
+ return h`<h1>How OpenVibe.Coupons labels codes</h1>
+<h2 id="statuses">Statuses</h2>
+<ul>
+<li><strong>Validity unknown</strong>: nobody reported on it recently, or the reports disagree. New codes always start here, whoever submitted them.</li>
+<li><strong>Reported working</strong> / <strong>Reported not working</strong>: what recent reports by people say. Nothing else can set these: not the submitter, not staff, not a source, not an AI model.</li>
+<li><strong>Expired</strong>: its known expiry has passed (or staff recorded that it ended). It leaves active results at that moment.</li>
+<li><strong>Taken down</strong>: removed by staff.</li>
+</ul>
+<h2 id="confidence">Confidence</h2>
+<p>Only each person's most recent report on a code counts, one per day at most. A report weighs 0.5<sup>age / ${confidence.HALF_LIFE_DAYS} days</sup> and stops counting after ${confidence.WINDOW_DAYS} days. With W the weight of “worked” reports and F of “didn't work” reports:</p>
+<p class="formula">confidence = (1 + e + W) / (2 + e + W + F)</p>
+<p>where e is ${confidence.EVIDENCE_PRIOR} when the code was published on the merchant's own site, otherwise 0. With no report in the last ${confidence.WINDOW_DAYS} days there is no confidence at all (shown as “no reports”), because evidence alone says nothing about whether a code works today. A code is shown as reported working at ${Math.round(confidence.WORKING_AT * 100)}% or more and reported not working at ${Math.round(confidence.FAILED_AT * 100)}% or less, and only once the reports weigh at least ${confidence.MIN_MASS} (about one report younger than a week).</p>
+<h2 id="expiry">Expiry</h2>
+<p>An expiry is shown only when someone stated it, with who stated it. A date without a time means the end of that day in UTC, because the merchant's time zone is not known. When nobody stated an expiry it is “unknown”.</p>
+<h2 id="privacy">Privacy</h2>
+<p>Codes and their aggregates are public. Who submitted or reported a code is never shown, never returned by the API and never sent in events. Reports are stored under a keyed hash of the person, not their account id.</p>
+<h2 id="helper">The browser helper</h2>
+<p>The OpenVibe browser helper reads only the hostname of the tab you are on, and only when you open it. It asks this site's public API which shop that is and lists its codes. It never reads the page, never sends the page's address beyond the hostname and holds no OpenVibe sign-in: to report from it you <a href="/connect-extension">connect it</a> with a token that can only look up and report, and that you can revoke at any time.</p>`;
+}
+
+function submitForm({ values = {}, errs = [], csrf, viewer, merchantName = null }) {
+ if (!viewer || viewer.kind !== 'user') {
+ return h`<h1>Submit a code</h1><p><a href="/auth/login?next=%2Fsubmit">Sign in with OpenVibe</a> to submit a code. Submitted codes start as “validity unknown” until people report on them.</p>`;
+ }
+ const v = (k) => values[k] || '';
+ return h`<h1>Submit a code</h1>
+<p class="lede">It will be listed as “validity unknown” until people report whether it works. Codes for a shop we do not list yet wait until staff add the shop.</p>
+${errors(errs)}
+<form method="post" action="/submit" class="form">${hidden(csrf)}
+<fieldset><legend>The shop</legend>
+${merchantName ? h`<p>Shop: <strong>${merchantName}</strong></p><input type="hidden" name="merchant" value="${v('merchant')}">` : h`<label>Shop website (address or domain) <input name="url" required maxlength="2048" value="${v('url')}" placeholder="https://www.example-shop.com/"></label>`}
+</fieldset>
+<fieldset><legend>The code</legend>
+<label>Code <input name="code" required maxlength="64" value="${v('code')}" autocomplete="off" spellcheck="false"></label>
+<label>What it gives <input name="title" required maxlength="140" value="${v('title')}" placeholder="15% off shoes"></label>
+<label>Details (optional) <textarea name="description" maxlength="1000" rows="3">${v('description')}</textarea></label>
+<label>Where you found it (optional, a link) <input name="evidence_url" type="url" maxlength="2048" value="${v('evidence_url')}"></label>
+</fieldset>
+<fieldset><legend>Expiry</legend>
+<label>Expires on (leave empty if unknown) <input name="expires" type="date" value="${v('expires')}"></label>
+<label><input type="checkbox" name="expiry_basis" value="evidence"${values.expiry_basis === 'evidence' ? raw(' checked') : ''}> The linked page states this expiry</label>
+</fieldset>
+<fieldset><legend>Restrictions (only what is stated)</legend>
+<label>Minimum spend <input name="min_spend_amount" inputmode="decimal" maxlength="12" value="${v('min_spend_amount')}" placeholder="50.00"></label>
+<label>Currency <input name="min_spend_currency" maxlength="3" value="${v('min_spend_currency')}" placeholder="USD"></label>
+<label>Only for categories (comma-separated) <input name="categories" maxlength="400" value="${v('categories')}"></label>
+<label><input type="checkbox" name="new_customers_only" value="1"${values.new_customers_only ? raw(' checked') : ''}> New customers only</label>
+<label>Only in regions (ISO codes, comma-separated) <input name="regions" maxlength="200" value="${v('regions')}" placeholder="US, CA"></label>
+<label>Other conditions <input name="restriction_other" maxlength="300" value="${v('restriction_other')}"></label>
+<label>How to apply it (optional) <input name="hint" maxlength="300" value="${v('hint')}" placeholder="Enter it in the Promo code box at checkout"></label>
+</fieldset>
+<button type="submit">Submit</button>
+</form>`;
+}
+
+function submitted({ coupon, merchant, duplicate }) {
+ if (coupon.review_state === 'pending') {
+ return h`<h1>Thanks — waiting for review</h1><p>${merchant.status === 'pending' ? `${merchant.name} is not listed yet. Staff will review the shop; your code will be listed with it.` : 'This code waits for staff review before it is listed.'}</p><p><a href="/">Back to all shops</a></p>`;
+ }
+ return h`<h1>${duplicate ? 'Already listed — your evidence was added' : 'Thanks — your code is listed'}</h1><p>It shows as “${STATUS_LABEL[coupon.status]}” until people report on it.</p><p><a href="/m/${merchant.slug}#${coupon.id}">See it on ${merchant.name}'s page</a></p>`;
+}
+
+function connect({ viewer, installs, csrf, created, errs = [] }) {
+ const intro = h`<h1>Connect the browser helper</h1>
+<p class="lede">The OpenVibe browser helper shows codes for the shop you are on. It works without connecting; connecting lets it report whether a code worked, as you.</p>
+<ul>
+<li>It reads only the <strong>hostname</strong> of the current tab, and only when you open it.</li>
+<li>It never reads page content, never sends full addresses and has no access to your OpenVibe account: the token below can only look up codes and (if you allow it) report on them.</li>
+<li>Revoking a token here takes effect on its next request.</li>
+</ul>`;
+ if (!viewer || viewer.kind !== 'user') return h`${intro}<p><a href="/auth/login?next=%2Fconnect-extension">Sign in with OpenVibe</a> to create a token.</p>`;
+ return h`${intro}
+${errors(errs)}
+${created ? h`<div class="token-once" role="status"><p><strong>Your token for “${created.install.label}”</strong> — copy it into the helper's settings now. It is shown only once.</p><p><code class="token">${created.token}</code></p></div>` : ''}
+<h2>Create a token</h2>
+<form method="post" action="/connect-extension" class="form">${hidden(csrf)}
+<label>Name this browser <input name="label" maxlength="60" placeholder="Firefox on my laptop"></label>
+<label><input type="checkbox" name="scope_report" value="1" checked> Allow it to report whether codes worked</label>
+<p class="meta">Looking up codes is always allowed. Nothing else is possible with this token.</p>
+<button type="submit">Create token</button>
+</form>
+<h2>Your connected browsers</h2>
+${installs.length ? h`<table class="installs"><thead><tr><th>Name</th><th>Token</th><th>Can</th><th>Created</th><th>Last used</th><th></th></tr></thead><tbody>${installs.map((i) => h`<tr>
+<td>${i.label}</td><td><code>${i.token_hint}…</code></td><td>${i.scopes.map((s) => (s === 'coupons.report' ? 'look up, report' : '')).filter(Boolean)[0] || 'look up'}</td>
+<td>${i.created_at.slice(0, 10)}</td><td>${i.last_used_at ? i.last_used_at.slice(0, 10) : 'never'}</td>
+<td>${i.active ? h`<form method="post" action="/connect-extension/${i.id}/revoke">${hidden(csrf)}<button type="submit">Revoke</button></form>` : (i.revoked_at ? 'revoked' : 'expired')}</td></tr>`)}</tbody></table>` : h`<p class="empty">None yet.</p>`}`;
+}
+
+function watching({ merchants }) {
+ return h`<h1>Shops you watch</h1>
+<p class="meta">Private to you. Notifications are not sent yet; this list is where watched shops are collected until they are.</p>
+${merchants.length ? h`<ul class="merchant-list">${merchants.map((m) => h`<li><a href="/m/${m.slug}">${m.name}</a></li>`)}</ul>` : h`<p class="empty">You are not watching any shop. Use “Watch this shop” on a shop's page.</p>`}`;
+}
+
+function staff({ pendingMerchants, pendingCoupons, holds, csrf, flashMsg, errs = [] }) {
+ return h`<h1>Staff</h1>
+${flash(flashMsg)}${errors(errs)}
+<h2>Add a shop</h2>
+<form method="post" action="/staff/merchants" class="form">${hidden(csrf)}
+<label>Name <input name="name" required maxlength="120"></label>
+<label>Domain <input name="host" required maxlength="253" placeholder="example-shop.com"></label>
+<label><input type="checkbox" name="include_subdomains" value="1" checked> Include subdomains</label>
+<label>Only under this path (optional) <input name="path_prefix" maxlength="200" placeholder="/shop/name"></label>
+<button type="submit">Add</button></form>
+<h2>Shops waiting for review (${pendingMerchants.length})</h2>
+${pendingMerchants.length ? h`<ul>${pendingMerchants.map((m) => h`<li>${m.name} (${m.domains.join(', ')}) · ${m.codes} code(s)
+<form method="post" action="/staff/merchants/${m.id}/status" class="inline">${hidden(csrf)}<input type="hidden" name="status" value="active"><button>Approve</button></form>
+<form method="post" action="/staff/merchants/${m.id}/status" class="inline">${hidden(csrf)}<input type="hidden" name="status" value="disabled"><button>Reject</button></form></li>`)}</ul>` : h`<p class="empty">None.</p>`}
+<h2>Codes waiting for review (${pendingCoupons.length})</h2>
+${pendingCoupons.length ? h`<ul>${pendingCoupons.map((c) => h`<li><code>${c.code}</code> — ${c.title} at ${c.merchant} (${c.origin})${c.evidence ? h` · <a href="${c.evidence}" rel="nofollow noopener">evidence</a>` : ''}
+<form method="post" action="/staff/coupons/${c.id}/approve" class="inline">${hidden(csrf)}<button>Publish</button></form>
+<form method="post" action="/staff/coupons/${c.id}/status" class="inline">${hidden(csrf)}<input type="hidden" name="status" value="disabled"><button>Reject</button></form></li>`)}</ul>` : h`<p class="empty">None.</p>`}
+<h2>Change a code</h2>
+<form method="post" action="/staff/coupons/status" class="form">${hidden(csrf)}
+<label>Code id <input name="id" required pattern="cpn_[0-9A-Z]{26}" placeholder="cpn_…"></label>
+<label>Set <select name="status"><option value="disabled">Take down</option><option value="expired">Mark expired</option><option value="active">Back to active (recomputed from reports)</option></select></label>
+<label>Note <input name="note" maxlength="200"></label>
+<button type="submit">Apply</button></form>
+<h2>Imported items held (${holds.length})</h2>
+${holds.length ? h`<ul>${holds.map((x) => h`<li>${x.item_id} from ${x.source_key || '?'}: ${x.reason}${x.detail ? h` (${x.detail})` : ''} · ${x.attempts} attempt(s)</li>`)}</ul>` : h`<p class="empty">None.</p>`}`;
+}
+
+function message({ heading, text, action }) {
+ return h`<h1>${heading}</h1><p>${text}</p>${action ? h`<p><a href="${action.href}">${action.label}</a></p>` : ''}`;
+}
+
+module.exports = { home, merchant, couponPage, couponCard, about, submitForm, submitted, connect, watching, staff, message, STATUS_LABEL };
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -0,0 +1,61 @@
+'use strict';
+/**
+ * Background work in the Coupons process (two timers):
+ *
+ * sweep every COUPONS_SWEEP_INTERVAL_MS: record codes whose known expiry has passed
+ * (status → expired, history, coupons.coupon.expired, Search tombstone) and let
+ * old reports decay (status back to unknown, confidence → null after 30 days).
+ * Active lists never wait for it: they filter on expires_at at query time.
+ * Sources import every COUPONS_SOURCES_INTERVAL_MS when OV_SOURCES_INTERNAL_URL is set.
+ */
+function createWorker({ config, coupons, importer, outbox, log = console }) {
+ let sweepTimer = null;
+ let importTimer = null;
+ let sweeping = false;
+ let importing = false;
+ let lastSweep = null;
+
+ function sweep() {
+ if (sweeping) return null;
+ sweeping = true;
+ try {
+ const r = coupons.sweep();
+ lastSweep = { at: Date.now(), ...r, error: null };
+ if (r.expired.length || r.changed.length) outbox.kick();
+ return r;
+ } catch (err) {
+ lastSweep = { at: Date.now(), error: err.message };
+ log.error('[Coupons] sweep failed:', err.message);
+ return null;
+ } finally { sweeping = false; }
+ }
+
+ async function importTick() {
+ if (importing || !importer.enabled) return null;
+ importing = true;
+ try {
+ const r = await importer.run();
+ outbox.kick();
+ return r;
+ } finally { importing = false; }
+ }
+
+ return {
+ sweep,
+ importTick,
+ lastSweep: () => lastSweep,
+ start() {
+ if (!config.worker.enabled) return;
+ sweepTimer = setInterval(sweep, config.worker.intervalMs);
+ sweepTimer.unref();
+ setTimeout(sweep, 1000).unref();
+ if (importer.enabled) {
+ importTimer = setInterval(() => importTick().catch((err) => log.warn('[Coupons] import tick failed:', err.message)), config.sources.intervalMs);
+ importTimer.unref();
+ }
+ },
+ stop() { clearInterval(sweepTimer); clearInterval(importTimer); },
+ };
+}
+
+module.exports = { createWorker };
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -0,0 +1,85 @@
+'use strict';
+/**
+ * The confidence formula (server/domain/confidence.js, README "Confidence"), pinned with numbers.
+ */
+const assert = require('assert');
+const conf = require('../server/domain/confidence');
+const { check, done } = require('./helpers/boot');
+
+const NOW = Date.parse('2026-09-22T12:00:00Z');
+const DAY = conf.DAY;
+const r = (who, outcome, ageDays) => ({ reporter_key: who, outcome, updated_at: NOW - ageDays * DAY });
+
+(async () => {
+ await check('no reports → confidence null and status unknown (unknown stays unknown), with or without evidence', async () => {
+ assert.deepStrictEqual(conf.fromReports([], { now: NOW }), { confidence: null, W: 0, F: 0, mass: 0, counted: 0, derived: 'unknown' });
+ assert.strictEqual(conf.fromReports([], { now: NOW, merchantEvidence: true }).confidence, null);
+ assert.strictEqual(conf.evaluate({ status: 'unknown', expires_at: null }, [], { now: NOW }).status, 'unknown');
+ });
+
+ await check('one fresh report: worked → 0.67 reported_working; failed → 0.33 reported_failed; one of each → 0.5 unknown', async () => {
+ assert.strictEqual(conf.fromReports([r('a', 'worked', 0)], { now: NOW }).confidence, 0.67);
+ assert.strictEqual(conf.fromReports([r('a', 'worked', 0)], { now: NOW }).derived, 'reported_working');
+ assert.strictEqual(conf.fromReports([r('a', 'failed', 0)], { now: NOW }).confidence, 0.33);
+ assert.strictEqual(conf.fromReports([r('a', 'failed', 0)], { now: NOW }).derived, 'reported_failed');
+ const mixed = conf.fromReports([r('a', 'worked', 0), r('b', 'failed', 0)], { now: NOW });
+ assert.strictEqual(mixed.confidence, 0.5);
+ assert.strictEqual(mixed.derived, 'unknown');
+ });
+
+ await check('recency: weight halves every 7 days; a lone report decides status for a week; nothing after 30 days', async () => {
+ assert.strictEqual(conf.weight(0), 1);
+ assert.ok(Math.abs(conf.weight(7 * DAY) - 0.5) < 1e-12);
+ assert.ok(Math.abs(conf.weight(14 * DAY) - 0.25) < 1e-12);
+ assert.strictEqual(conf.weight(30 * DAY + 1), 0);
+ assert.strictEqual(conf.fromReports([r('a', 'worked', 6.9)], { now: NOW }).derived, 'reported_working');
+ const week = conf.fromReports([r('a', 'worked', 7.5)], { now: NOW });
+ assert.strictEqual(week.derived, 'unknown', 'mass below 0.5 after a week');
+ assert.ok(week.confidence > 0.5, 'but the number still leans the way the report said');
+ assert.strictEqual(conf.fromReports([r('a', 'worked', 31)], { now: NOW }).confidence, null);
+ });
+
+ await check('deduplication: only each reporter\'s most recent report counts', async () => {
+ const spam = Array.from({ length: 20 }, (_, i) => r('same-person', 'worked', i));
+ const one = conf.fromReports(spam, { now: NOW });
+ assert.strictEqual(one.counted, 1);
+ assert.strictEqual(one.confidence, 0.67, 'twenty daily "worked" reports by one person count as one');
+ const changed = conf.fromReports([r('p', 'worked', 3), r('p', 'failed', 0)], { now: NOW });
+ assert.strictEqual(changed.derived, 'reported_failed', 'a later report replaces the earlier one');
+ });
+
+ await check('evidence: merchant-published code adds half a report to the prior, never a number on its own', async () => {
+ assert.strictEqual(conf.fromReports([r('a', 'worked', 0)], { now: NOW, merchantEvidence: true }).confidence, 0.71);
+ const failed = conf.fromReports([r('a', 'failed', 0)], { now: NOW, merchantEvidence: true });
+ assert.strictEqual(failed.confidence, 0.43);
+ assert.strictEqual(failed.derived, 'unknown');
+ assert.strictEqual(conf.fromReports([r('a', 'failed', 0), r('b', 'failed', 0)], { now: NOW, merchantEvidence: true }).derived, 'reported_failed');
+ });
+
+ await check('a larger mix: 3 fresh worked, 1 failed a week ago → (1+3+0)/(2+3+0.5) = 0.73', async () => {
+ const x = conf.fromReports([r('a', 'worked', 0), r('b', 'worked', 0), r('c', 'worked', 0), r('d', 'failed', 7)], { now: NOW });
+ assert.strictEqual(x.W, 3);
+ assert.strictEqual(x.F, 0.5);
+ assert.strictEqual(x.confidence, 0.73);
+ assert.strictEqual(x.derived, 'reported_working');
+ });
+
+ await check('status precedence: disabled > expired (known expiry passed or recorded) > reports', async () => {
+ const reports = [r('a', 'worked', 0)];
+ assert.strictEqual(conf.evaluate({ status: 'disabled', expires_at: null }, reports, { now: NOW }).status, 'disabled');
+ assert.strictEqual(conf.evaluate({ status: 'reported_working', expires_at: NOW }, reports, { now: NOW }).status, 'expired', 'at the instant of expiry');
+ assert.strictEqual(conf.evaluate({ status: 'reported_working', expires_at: NOW + 1 }, reports, { now: NOW }).status, 'reported_working');
+ assert.strictEqual(conf.evaluate({ status: 'expired', expires_at: null, expired_at: NOW - 1 }, reports, { now: NOW }).status, 'expired');
+ assert.strictEqual(conf.isActive({ review_state: 'published', status: 'unknown', expires_at: NOW }, NOW), false);
+ assert.strictEqual(conf.isActive({ review_state: 'published', status: 'unknown', expires_at: null }, NOW), true);
+ assert.strictEqual(conf.isActive({ review_state: 'pending', status: 'unknown', expires_at: null }, NOW), false);
+ });
+
+ await check('the function is pure: same input, same output; now is required', async () => {
+ const input = [r('a', 'worked', 2), r('b', 'failed', 9)];
+ assert.deepStrictEqual(conf.fromReports(input, { now: NOW }), conf.fromReports(input, { now: NOW }));
+ assert.throws(() => conf.fromReports(input, {}), /now/);
+ });
+
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
+'use strict';
+/**
+ * The proposals the lead releases in the next openvibe-contracts version are valid against the
+ * released schemas, match what the code enforces and emits, and do not collide with released ids.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const contracts = require('openvibe-contracts');
+const { check, done } = require('./helpers/boot');
+const { PROPOSED, ALL_SCOPES } = require('../server/auth/capabilities');
+
+const ROOT = path.join(__dirname, '..');
+const DIR = path.join(ROOT, 'docs', 'capabilities-proposal');
+
+(async () => {
+ const files = fs.readdirSync(DIR).filter((f) => f.endsWith('.json'));
+ const caps = files.map((f) => JSON.parse(fs.readFileSync(path.join(DIR, f), 'utf8')));
+ const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'docs', 'service-manifest-proposal.json'), 'utf8'));
+
+ await check('every capability proposal is a valid capabilities.capability@1 with 3+ segments, owned by coupons', async () => {
+ for (const c of caps) {
+ const v = contracts.validate('capabilities.capability@1', c);
+ assert.ok(v.valid, `${c.id}: ${JSON.stringify(v.errors)}`);
+ assert.strictEqual(c.owner, 'coupons');
+ assert.ok(c.id.split('.').length >= 3, c.id);
+ assert.strictEqual(`${c.id}.json`, files[caps.indexOf(c)]);
+ assert.ok(!contracts.capabilities.get(c.id) || contracts.capabilities.get(c.id).owner === 'coupons', `${c.id} collides with a released capability`);
+ }
+ });
+
+ await check('the proposals are exactly the capabilities the code enforces; install scopes are not capabilities', async () => {
+ assert.deepStrictEqual(caps.map((c) => c.id).sort(), [...PROPOSED].sort());
+ assert.deepStrictEqual([...manifest.capabilities].sort(), [...PROPOSED].sort());
+ for (const s of ALL_SCOPES) assert.ok(!PROPOSED.has(s), `${s} is an install scope, not a capability`);
+ });
+
+ await check('the service manifest proposal is a valid registry.service-manifest@1 and declares every event capabilities name', async () => {
+ const v = contracts.validate('registry.service-manifest@1', manifest);
+ assert.ok(v.valid, JSON.stringify(v.errors));
+ assert.strictEqual(manifest.id, 'coupons');
+ for (const c of caps) for (const e of c.events) assert.ok(manifest.eventsProduced.includes(e), `${c.id} names ${e}, missing from eventsProduced`);
+ });
+
+ await check('every event type the code emits is declared, and every declared one is emitted somewhere', async () => {
+ const src = ['server/domain/coupons.js', 'server/domain/reports.js'].map((f) => fs.readFileSync(path.join(ROOT, f), 'utf8')).join('\n');
+ const emitted = new Set([...src.matchAll(/'(coupons\.[a-z_]+\.[a-z_]+)'/g)].map((m) => m[1]).filter((x) => !PROPOSED.has(x)));
+ emitted.add('coupons.index_document.upserted');
+ emitted.add('coupons.index_document.deleted');
+ for (const e of emitted) assert.ok(manifest.eventsProduced.includes(e), `${e} emitted but not declared`);
+ for (const e of manifest.eventsProduced) assert.ok(emitted.has(e), `${e} declared but never emitted`);
+ });
+
+ await check('the port and service id match the platform table (4850, coupons)', async () => {
+ assert.strictEqual(require('../server/config').load({}).port, 4850);
+ assert.match(manifest.notes, /Port 4850/);
+ });
+
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
+'use strict';
+/**
+ * Domain normalization: hosts, the bundled public-suffix subset (eTLD+1, wildcards, exceptions,
+ * private-section platforms), rule checks and merchant matching (subdomains, paths, never across
+ * registrable domains) — and the same through the resolve API.
+ */
+const assert = require('assert');
+const hosts = require('../server/domain/hosts');
+const psl = require('../server/domain/psl');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ await check('normalizeHost: case, trailing dot, port, IDN → punycode', async () => {
+ assert.strictEqual(hosts.normalizeHost('WWW.Example-Shop.CO.UK.'), 'www.example-shop.co.uk');
+ assert.strictEqual(hosts.normalizeHost('shop.example.com:8443'), 'shop.example.com');
+ assert.strictEqual(hosts.normalizeHost('bücher.de'), 'xn--bcher-kva.de');
+ assert.strictEqual(hosts.normalizeHost(' Shop.Com '), 'shop.com');
+ });
+
+ await check('normalizeHost refuses URLs, paths, credentials, IPs, localhost and special-use names', async () => {
+ for (const bad of ['https://shop.com', 'shop.com/path', '[email protected]', 'shop.com?x=1', 'shop.com#a', '127.0.0.1', '10.0.0.1', '[::1]',
+ 'localhost', 'printer.local', 'db.internal', 'x.invalid', 'a.test', 'com', '', '-bad.com', 'bad-.com', 'a..com', 'a'.repeat(64) + '.com', 'shop.com:abc']) {
+ assert.throws(() => hosts.normalizeHost(bad), (e) => e.name === 'HostError' && e.status === 400, bad);
+ }
+ });
+
+ await check('registrable domain (eTLD+1) from the bundled public-suffix subset', async () => {
+ const cases = {
+ 'shop.com': 'shop.com',
+ 'www.shop.com': 'shop.com',
+ 'a.b.c.shop.com': 'shop.com',
+ 'www.example-shop.co.uk': 'example-shop.co.uk',
+ 'store.example.com.au': 'example.com.au',
+ 'm.example.co.jp': 'example.co.jp',
+ 'foo.myshopify.com': 'foo.myshopify.com',
+ 'checkout.foo.myshopify.com': 'foo.myshopify.com',
+ 'someone.github.io': 'someone.github.io',
+ 'shop.example.bar.ck': 'example.bar.ck', // *.ck wildcard: bar.ck is a suffix
+ 'www.ck': 'www.ck', // !www.ck exception
+ 'a.www.ck': 'www.ck',
+ 'x.example.kh': 'x.example.kh', // *.kh: example.kh is a suffix
+ };
+ for (const [h, want] of Object.entries(cases)) assert.strictEqual(hosts.registrable(h), want, h);
+ assert.strictEqual(hosts.registrable('co.uk'), null);
+ assert.strictEqual(hosts.registrable('myshopify.com'), null);
+ assert.strictEqual(hosts.registrable('bar.ck'), null);
+ assert.strictEqual(psl.publicSuffix('www.example-shop.co.uk'), 'co.uk');
+ });
+
+ await check('checkRule refuses a rule on a public suffix and normalizes path prefixes', async () => {
+ for (const bad of ['co.uk', 'myshopify.com', 'github.io', 'bar.ck']) assert.throws(() => hosts.checkRule({ host: bad }), /public suffix/, bad);
+ assert.deepStrictEqual(hosts.checkRule({ host: 'Shop.COM', path_prefix: '/Brand/X/' }), { host: 'shop.com', registrable: 'shop.com', path_prefix: '/Brand/X', include_subdomains: 1 });
+ for (const bad of ['brand', '/a/../b', '/a b', '/a?x', '//a']) assert.throws(() => hosts.checkRule({ host: 'shop.com', path_prefix: bad }), /plain path/, bad);
+ assert.strictEqual(hosts.checkRule({ host: 'shop.com', include_subdomains: false }).include_subdomains, 0);
+ });
+
+ await check('bestRule: exact host, subdomains, paths at segment boundaries, most specific wins, never across sites', async () => {
+ const rule = (host, include_subdomains, path_prefix = '', id = host + path_prefix) => ({ id, host, registrable_domain: hosts.registrable(host), include_subdomains, path_prefix });
+ const rules = [rule('shop.com', 1), rule('eu.shop.com', 0), rule('shop.com', 1, '/brands/acme')];
+ assert.strictEqual(hosts.bestRule(rules, 'shop.com').id, 'shop.com');
+ assert.strictEqual(hosts.bestRule(rules, 'www.shop.com').id, 'shop.com');
+ assert.strictEqual(hosts.bestRule(rules, 'eu.shop.com').id, 'eu.shop.com');
+ assert.strictEqual(hosts.bestRule(rules, 'x.eu.shop.com').id, 'shop.com', 'eu.shop.com does not include subdomains');
+ assert.strictEqual(hosts.bestRule(rules, 'shop.com', '/brands/acme/shoes').id, 'shop.com/brands/acme');
+ assert.strictEqual(hosts.bestRule(rules, 'shop.com', '/brands/acmecorp').id, 'shop.com', 'segment boundary');
+ assert.strictEqual(hosts.bestRule(rules, 'shop.com', null).id, 'shop.com', 'host-only lookups never match path rules');
+ assert.strictEqual(hosts.bestRule(rules, 'othershop.com'), null);
+ assert.strictEqual(hosts.bestRule(rules, 'shop.com.evil.net'), null);
+ assert.strictEqual(hosts.bestRule(rules, 'evilshop.com'), null, 'suffix match must be at a label boundary');
+ // A (hypothetical) rule on a platform domain never claims a customer's own site.
+ const platform = [{ id: 'p', host: 'myshopify.com', registrable_domain: 'foo.myshopify.com', include_subdomains: 1, path_prefix: '' }];
+ assert.strictEqual(hosts.bestRule(platform, 'foo.myshopify.com'), null);
+ });
+
+ const t = await boot();
+ await check('the resolve API: subdomains, unknown hosts, bad input, platform subdomains are separate shops', async () => {
+ await t.merchant({ name: 'Example Shop', host: 'example-shop.co.uk' });
+ await t.merchant({ name: 'Foo on Shopify', host: 'foo.myshopify.com' });
+ const r = await t.get('/api/v1/merchants/resolve?host=WWW.Example-Shop.co.uk');
+ assert.strictEqual(r.status, 200, r.text);
+ const b = r.json();
+ assert.strictEqual(b.merchant.name, 'Example Shop');
+ assert.strictEqual(b.host, 'www.example-shop.co.uk');
+ assert.strictEqual(b.registrable_domain, 'example-shop.co.uk');
+ assert.strictEqual((await t.get('/api/v1/merchants/resolve?host=foo.myshopify.com')).json().merchant.name, 'Foo on Shopify');
+ const bar = await t.get('/api/v1/merchants/resolve?host=bar.myshopify.com');
+ assert.strictEqual(bar.status, 404);
+ assert.strictEqual(bar.json().code, 'merchant.not_found');
+ assert.strictEqual((await t.get('/api/v1/merchants/resolve?host=127.0.0.1')).status, 400);
+ assert.strictEqual((await t.get('/api/v1/merchants/resolve?host=https://example-shop.co.uk/cart')).status, 400);
+ assert.strictEqual((await t.get('/api/v1/merchants/resolve')).status, 400);
+ });
+
+ await check('a merchant cannot be created on a public suffix, and a domain rule belongs to one merchant', async () => {
+ const r = await t.get('/api/v1/merchants', { as: t.staffToken(), json: { name: 'All UK', domains: [{ host: 'co.uk' }] } });
+ assert.strictEqual(r.status, 400);
+ const dup = await t.get('/api/v1/merchants', { as: t.staffToken(), json: { name: 'Impostor', domains: [{ host: 'example-shop.co.uk' }] } });
+ assert.strictEqual(dup.status, 409);
+ assert.strictEqual(dup.json().code, 'merchant.domain_taken');
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,161 @@
@@ -0,0 +1,161 @@
+'use strict';
+/**
+ * The browser-helper surface: install tokens (scoped, hashed at rest, revocation immediate,
+ * expiring), the lookup API (same bytes for every caller, no personal data), CORS only for the
+ * extension origin and only on the lookup routes, per-caller rate limits — and what a malicious
+ * merchant page can do with a visitor's browser (nothing that reveals anyone's data).
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+const EXT = 'chrome-extension://abcdefghijklmnopabcdefghijklmnop';
+const DAY = 24 * 3600 * 1000;
+
+(async () => {
+ const t = await boot({ env: { COUPONS_EXTENSION_ORIGINS: `${EXT},moz-extension://*`, COUPONS_LOOKUP_ANON_PER_MIN: '8', COUPONS_LOOKUP_TOKEN_PER_MIN: '40' } });
+ const alice = t.network.addUser('alice');
+ const bob = t.network.addUser('bob');
+ const shop = await t.merchant({ name: 'Paper Lantern', host: 'paperlantern.store' });
+ const code = (await t.submit(alice, { host: 'paperlantern.store', code: 'GLOW', title: 'Ten off lamps', evidence_url: 'https://paperlantern.store/sale' })).json().coupon;
+ await t.get(`/api/v1/coupons/${code.id}/report`, { as: t.network.userToken(alice), json: { outcome: 'worked' } });
+
+ let aliceToken;
+ let bobToken;
+ await check('connect page: signed out → sign-in link; signed in → token shown once, stored only as a hash', async () => {
+ const anon = await t.get('/connect-extension');
+ assert.strictEqual(anon.status, 200);
+ assert.match(anon.text, /Sign in with OpenVibe/);
+ assert.doesNotMatch(anon.text, /cpx_/);
+ aliceToken = await t.connect(alice, { label: 'Firefox' });
+ bobToken = await t.connect(bob, { label: 'Chrome', report: false });
+ assert.match(aliceToken, /^cpx_[A-Za-z0-9_-]{43}$/);
+ const rows = t.ctx.store.db.prepare('SELECT * FROM extension_installs').all();
+ assert.strictEqual(rows.length, 2);
+ const dump = JSON.stringify(rows);
+ assert.ok(!dump.includes(aliceToken) && !dump.includes(bobToken), 'the token itself is never stored');
+ const again = await t.get('/connect-extension', { as: alice });
+ assert.doesNotMatch(again.text, new RegExp(aliceToken), 'shown once only');
+ assert.match(again.text, /Firefox/);
+ assert.match(again.headers.get('cache-control'), /no-store/);
+ });
+
+ await check('connect and revoke forms need the form token (CSRF)', async () => {
+ const r = await t.get('/connect-extension', { as: alice, form: { label: 'x', scope_report: '1' } });
+ assert.strictEqual(r.status, 403);
+ const install = t.ctx.installs.list(alice.subject)[0];
+ const rv = await t.get(`/connect-extension/${install.id}/revoke`, { as: alice, form: { csrf: 'forged' } });
+ assert.strictEqual(rv.status, 403);
+ assert.strictEqual(t.ctx.installs.list(alice.subject)[0].active, true);
+ });
+
+ await check('scopes: lookup always; report only when granted; nothing else (no submit, no staff)', async () => {
+ assert.strictEqual((await t.get(`/api/v1/merchants/${shop.id}/coupons`, { as: bobToken })).status, 200);
+ const noReport = await t.get(`/api/v1/coupons/${code.id}/report`, { as: bobToken, json: { outcome: 'worked' } });
+ assert.strictEqual(noReport.status, 403);
+ assert.strictEqual(noReport.json().code, 'token.scope');
+ assert.strictEqual((await t.get(`/api/v1/coupons/${code.id}/report`, { as: aliceToken, json: { outcome: 'worked' } })).status, 200, 'alice already reported today via the API: deduplicated');
+ assert.strictEqual((await t.get('/api/v1/coupons/submit', { as: aliceToken, json: { host: 'paperlantern.store', code: 'NEW', title: 'New one' } })).status, 403);
+ assert.strictEqual((await t.get(`/api/v1/coupons/${code.id}/status`, { as: aliceToken, json: { status: 'disabled' } })).status, 403);
+ // An install token is not a session: pages treat it as anonymous.
+ const page = await t.get('/connect-extension', { as: aliceToken });
+ assert.match(page.text, /Sign in with OpenVibe/);
+ });
+
+ await check('revocation takes effect on the very next request', async () => {
+ assert.strictEqual((await t.get('/api/v1/merchants/resolve?host=paperlantern.store', { as: aliceToken })).status, 200);
+ const install = t.ctx.installs.list(alice.subject).find((i) => i.label === 'Firefox');
+ const rv = await t.get(`/connect-extension/${install.id}/revoke`, { as: alice, form: { csrf: t.csrf(alice) } });
+ assert.strictEqual(rv.status, 303);
+ const r = await t.get('/api/v1/merchants/resolve?host=paperlantern.store', { as: aliceToken });
+ assert.strictEqual(r.status, 401);
+ assert.strictEqual(r.json().code, 'token.revoked');
+ assert.strictEqual((await t.get(`/api/v1/coupons/${code.id}/report`, { as: aliceToken, json: { outcome: 'failed' } })).status, 401);
+ // Someone else cannot revoke bob's install.
+ const bobInstall = t.ctx.installs.list(bob.subject)[0];
+ await t.get(`/connect-extension/${bobInstall.id}/revoke`, { as: alice, form: { csrf: t.csrf(alice) } });
+ assert.strictEqual((await t.get('/api/v1/merchants/resolve?host=paperlantern.store', { as: bobToken })).status, 200);
+ });
+
+ await check('malformed, unknown and expired tokens are refused, never downgraded to anonymous', async () => {
+ assert.strictEqual((await t.get('/api/v1/merchants/resolve?host=paperlantern.store', { as: 'cpx_short' })).json().code, 'token.invalid');
+ assert.strictEqual((await t.get('/api/v1/merchants/resolve?host=paperlantern.store', { as: `cpx_${'A'.repeat(43)}` })).json().code, 'token.invalid');
+ const temp = await t.connect(alice, { label: 'temp' });
+ t.clock.advance(366 * DAY);
+ const r = await t.get('/api/v1/merchants/resolve?host=paperlantern.store', { as: temp });
+ assert.strictEqual(r.status, 401);
+ assert.strictEqual(r.json().code, 'token.expired');
+ t.clock.advance(-366 * DAY);
+ });
+
+ await check('a lookup response is the same bytes for anonymous callers and for any two people, and holds no personal data', async () => {
+ const carolToken = await t.connect(t.network.addUser('carol'));
+ const paths = ['/api/v1/merchants/resolve?host=www.paperlantern.store', `/api/v1/merchants/${shop.id}/coupons`];
+ for (const p of paths) {
+ const anon = await t.get(p);
+ const b = await t.get(p, { as: bobToken });
+ const c = await t.get(p, { as: carolToken });
+ const signed = await t.get(p, { as: t.network.userToken(alice) });
+ assert.strictEqual(anon.status, 200, anon.text);
+ assert.strictEqual(b.text, anon.text, p);
+ assert.strictEqual(c.text, anon.text, p);
+ assert.strictEqual(signed.text, anon.text, p);
+ assert.doesNotMatch(anon.text, /usr_|gst_|cpi_|cpx_|reporter|submitted_by|created_by|install/);
+ assert.strictEqual(anon.headers.get('cache-control'), 'public, max-age=60');
+ }
+ });
+
+ await check('CORS: the extension origin (and moz-extension://*) on the two lookup routes only; never a web origin, never credentials', async () => {
+ const lookup = `/api/v1/merchants/${shop.id}/coupons`;
+ const ok = await t.get(lookup, { headers: { origin: EXT } });
+ assert.strictEqual(ok.headers.get('access-control-allow-origin'), EXT);
+ assert.strictEqual(ok.headers.get('access-control-allow-credentials'), null);
+ assert.match(ok.headers.get('vary'), /Origin/);
+ const moz = await t.get('/api/v1/merchants/resolve?host=paperlantern.store', { headers: { origin: 'moz-extension://0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0' } });
+ assert.strictEqual(moz.headers.get('access-control-allow-origin'), 'moz-extension://0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0');
+ const pre = await t.get(lookup, { method: 'OPTIONS', headers: { origin: EXT, 'access-control-request-method': 'GET', 'access-control-request-headers': 'authorization' } });
+ assert.strictEqual(pre.status, 204);
+ assert.match(pre.headers.get('access-control-allow-headers'), /Authorization/);
+ for (const origin of ['https://paperlantern.store', 'https://evil.example-shop.com', 'null', 'chrome-extension://someotherextensionidxxxxxxxxxxxx']) {
+ const r = await t.get(lookup, { headers: { origin } });
+ assert.strictEqual(r.headers.get('access-control-allow-origin'), null, origin);
+ }
+ const report = await t.get(`/api/v1/coupons/${code.id}/report`, { method: 'OPTIONS', headers: { origin: EXT, 'access-control-request-method': 'POST' } });
+ assert.strictEqual(report.headers.get('access-control-allow-origin'), null, 'no CORS on reports');
+ const single = await t.get(`/api/v1/coupons/${code.id}`, { headers: { origin: EXT } });
+ assert.strictEqual(single.headers.get('access-control-allow-origin'), null, 'no CORS beyond the two lookup routes');
+ });
+
+ await check('a malicious merchant page riding the visitor\'s cookies gets nothing: API ignores cookies, forms need a token only this site can make', async () => {
+ const cookie = `ov_token=${t.network.userToken(bob)}`;
+ const evil = { origin: 'https://paperlantern.store', cookie };
+ const rep = await t.get(`/api/v1/coupons/${code.id}/report`, { method: 'POST', headers: { ...evil, 'content-type': 'application/json' }, body: JSON.stringify({ outcome: 'failed' }) });
+ assert.strictEqual(rep.status, 401);
+ const formRep = await t.get(`/c/${code.id}/report`, { method: 'POST', headers: { ...evil, 'content-type': 'application/x-www-form-urlencoded' }, body: 'outcome=failed' });
+ assert.strictEqual(formRep.status, 403, 'no form token → refused');
+ const connect = await t.get('/connect-extension', { method: 'POST', headers: { ...evil, 'content-type': 'application/x-www-form-urlencoded' }, body: 'label=stolen' });
+ assert.strictEqual(connect.status, 403, 'cannot mint a token for the visitor');
+ const lookup = await t.get('/api/v1/merchants/resolve?host=paperlantern.store', { headers: evil });
+ assert.strictEqual(lookup.headers.get('access-control-allow-origin'), null, 'and its script cannot read even the public answer');
+ assert.doesNotMatch(lookup.text, /bob|usr_/);
+ const watching = await t.get('/watching', { headers: { origin: 'https://paperlantern.store' } });
+ assert.strictEqual(watching.status, 303, 'private pages need the visitor\'s own session');
+ const framed = await t.get('/connect-extension', { as: bob });
+ assert.strictEqual(framed.headers.get('x-frame-options'), 'DENY', 'the connect page cannot be framed (clickjacking)');
+ assert.match(framed.headers.get('content-security-policy'), /frame-ancestors 'none'/);
+ });
+
+ await check('lookup rate limits: anonymous callers per IP, installs per install (higher), 429 as problem+json', async () => {
+ await t.restart();
+ const p = '/api/v1/merchants/resolve?host=paperlantern.store';
+ let last;
+ for (let i = 0; i < 9; i++) last = await t.get(p);
+ assert.strictEqual(last.status, 429);
+ assert.strictEqual(last.json().code, 'lookup.rate_limited');
+ assert.match(last.headers.get('content-type'), /application\/problem\+json/);
+ const withToken = await t.get(p, { as: bobToken });
+ assert.strictEqual(withToken.status, 200, 'an install has its own, larger budget');
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,116 @@
@@ -0,0 +1,116 @@
+'use strict';
+/**
+ * Boots Coupons on a temp database with a controllable clock and mocks of its neighbours, and
+ * returns a small HTTP client. Every test file gets its own instance.
+ *
+ * const t = await boot({ env });
+ * t.get(path, { as: user | 'Bearer token string', json, form, headers, method })
+ * t.clock.advance(ms) the app's clock (expiry, reports, decay, tokens)
+ * t.merchant({ name, host }) an active merchant created through the API as staff
+ * t.submit(user, body) POST /api/v1/coupons/submit as that user
+ * t.connect(user, { report }) a cpx_ token created through the /connect-extension form
+ */
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const { startNetwork, startSources } = require('./mocks');
+
+function makeClock(start = Date.parse('2026-09-22T12:00:00Z')) {
+ let t = start;
+ return { now: () => t, advance: (ms) => { t += ms; return t; }, set: (v) => { t = v; } };
+}
+
+async function boot(opts = {}) {
+ const network = await startNetwork();
+ const sources = await startSources({ network });
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-coupons-test-'));
+ const dbPath = path.join(dir, 'coupons.db');
+ const clock = opts.clock || makeClock();
+ const staff = network.addUser('staffer', { role: 'admin' });
+ const env = {
+ NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.coupons', TRUST_PROXY: '1',
+ COUPONS_DB_PATH: dbPath,
+ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
+ OV_OAUTH_CLIENT_ID: 'coupons', OV_OAUTH_CLIENT_SECRET: 'shh', COOKIE_SECURE: 'false',
+ COUPONS_WORKER: 'off', COUPONS_FORM_SECRET: 'test-form-secret', COUPONS_REPORTER_KEY_SECRET: 'test-reporter-secret',
+ OV_SOURCES_INTERNAL_URL: sources.url,
+ ...(opts.env || {}),
+ };
+ const configLib = require('../../server/config');
+ const { createApp } = require('../../server/app');
+ const quiet = { log() {}, warn() {}, error: (...a) => { if (process.env.VERBOSE) console.error(...a); } };
+
+ let server = null;
+ let built = null;
+ async function start() {
+ const config = configLib.load(env);
+ built = createApp({ config, now: clock.now, log: quiet });
+ await built.ctx.auth.ensureKey();
+ server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
+ t.base = `http://127.0.0.1:${server.address().port}`;
+ t.app = built.app;
+ t.ctx = built.ctx;
+ }
+ async function stop() {
+ if (server) await new Promise((r) => server.close(r));
+ if (built) { built.ctx.worker.stop(); await built.ctx.outbox.stop(); built.ctx.store.close(); }
+ server = null; built = null;
+ }
+
+ /** as: a network user ({ subject, … }) → ov_token cookie; a string → Authorization: Bearer <string>. */
+ async function get(p, o = {}) {
+ const headers = { ...(o.headers || {}) };
+ if (o.as && typeof o.as === 'object') headers.cookie = `ov_token=${network.userToken(o.as)}`;
+ if (typeof o.as === 'string') headers.authorization = `Bearer ${o.as}`;
+ let body = o.body;
+ if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
+ if (o.form) { body = new URLSearchParams(o.form).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }
+ const res = await fetch(t.base + p, { method: o.method || (body ? 'POST' : 'GET'), headers, body, redirect: 'manual' });
+ const text = await res.text();
+ return { status: res.status, headers: res.headers, text, json() { return JSON.parse(text); } };
+ }
+
+ /** Rows of event_outbox as parsed envelopes. */
+ function events(type = null) {
+ return t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope))
+ .filter((e) => !type || e.event_type === type || (type instanceof RegExp && type.test(e.event_type)));
+ }
+
+ const csrf = (user) => require('../../server/auth/forms').csrfToken({ formSecret: env.COUPONS_FORM_SECRET }, user);
+
+ async function merchant({ name, host, include_subdomains = true, path_prefix = '' }) {
+ const r = await get('/api/v1/merchants', { as: network.userToken(staff), json: { name, domains: [{ host, include_subdomains, path_prefix }] } });
+ if (r.status !== 201) throw new Error(`merchant: ${r.status} ${r.text}`);
+ return r.json().merchant;
+ }
+
+ async function submit(user, body) {
+ return get('/api/v1/coupons/submit', { as: network.userToken(user), json: body });
+ }
+
+ async function connect(user, { report = true, label = 'test browser' } = {}) {
+ const r = await get('/connect-extension', { as: user, form: { csrf: csrf(user), label, ...(report ? { scope_report: '1' } : {}) } });
+ if (r.status !== 201) throw new Error(`connect: ${r.status} ${r.text.slice(0, 300)}`);
+ const m = r.text.match(/cpx_[A-Za-z0-9_-]{43}/);
+ if (!m) throw new Error('no token on the page');
+ return m[0];
+ }
+
+ const t = {
+ network, sources, clock, dbPath, staff, get, events, csrf, merchant, submit, connect,
+ staffToken: () => network.userToken(staff),
+ async restart() { await stop(); await start(); },
+ async close() { await stop(); await network.close(); await sources.close(); fs.rmSync(dir, { recursive: true, force: true }); },
+ };
+ await start();
+ return t;
+}
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); console.log(' ✓', name); } catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 8).join('\n ')); }
+}
+function done() { console.log(failures ? `\n${failures} failed` : '\nall passed'); process.exit(failures ? 1 : 0); }
+
+module.exports = { boot, check, done, makeClock };
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -0,0 +1,100 @@
+'use strict';
+/**
+ * In-process stand-ins for Coupons' neighbours, with a real RS256 key pair:
+ * Network JWKS, /oauth/token (client_credentials → service tokens with the requested scope as
+ * capabilities)
+ * Sources GET /api/v1/items?category=coupons&after= (needs sources.item.read), items the test
+ * controls in change order; setDown(true) answers 503
+ * userToken()/serviceToken() mint the tokens browsers and services present to Coupons.
+ */
+const http = require('http');
+const crypto = require('crypto');
+const jwt = require('jsonwebtoken');
+const { serviceAuth, ids } = require('openvibe-contracts');
+
+function listen(handler) {
+ return new Promise((resolve) => {
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const raw = Buffer.concat(chunks).toString('utf8');
+ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
+ handler(req, raw, json, res);
+ });
+ });
+ server.listen(0, '127.0.0.1', () => resolve({ server, url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((r) => server.close(r)) }));
+ });
+}
+
+async function startNetwork() {
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+ const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
+ const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
+ const grants = [];
+ let issuer = null;
+ const srv = await listen((req, raw, json) => {
+ if (req.url === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });
+ if (req.url === '/oauth/token' && req.method === 'POST') {
+ let body = {};
+ if (String(req.headers['content-type'] || '').includes('application/x-www-form-urlencoded')) body = Object.fromEntries(new URLSearchParams(raw));
+ else { try { body = JSON.parse(raw); } catch { /* */ } }
+ grants.push(body);
+ if (body.client_secret !== 'shh') return json(401, { error: 'invalid_client' });
+ if (body.grant_type === 'client_credentials') {
+ let scope = body.scope;
+ if (scope && typeof scope === 'object') scope = Object.values(scope).join(' ');
+ const cap = String(scope || '').split(/\s+/).filter(Boolean);
+ return json(200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience || 'openvibe.events'], cap }), token_type: 'Bearer', expires_in: 300 });
+ }
+ return json(400, { error: 'unsupported_grant_type' });
+ }
+ return json(404, { error: 'not found' });
+ });
+ issuer = srv.url;
+ function signService({ sub, aud, cap }) {
+ return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type: 'service', aud, cap, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: crypto.randomUUID() }, privatePem);
+ }
+ function addUser(username, extra = {}) {
+ return { subject: ids.newId('user'), username, display_name: extra.display_name || username, role: extra.role || 'user' };
+ }
+ function userToken(u) {
+ return jwt.sign({ sub: String(Math.floor(Math.random() * 1e6)), subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role || 'user' }, privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h' });
+ }
+ function serviceToken(client, cap) {
+ return signService({ sub: `svc:${client}`, aud: ['openvibe.coupons'], cap });
+ }
+ return { ...srv, publicPem, grants, addUser, userToken, serviceToken, signService };
+}
+
+async function startSources({ network }) {
+ const items = []; // in change order; each gets change_seq
+ let seq = 0;
+ let down = false;
+ const calls = [];
+ const srv = await listen((req, raw, json) => {
+ calls.push(req.url);
+ if (down) return json(503, { code: 'down' });
+ const token = String(req.headers.authorization || '').slice(7);
+ const v = serviceAuth.verifyServiceToken(token, { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.sources' });
+ if (!v.ok || !(v.claims.cap || []).includes('sources.item.read')) return json(403, { code: 'capability.denied' });
+ const u = new URL(req.url, 'http://x');
+ if (u.pathname !== '/api/v1/items') return json(404, { code: 'route.not_found' });
+ const after = Number(u.searchParams.get('after') || 0);
+ const limit = Number(u.searchParams.get('limit') || 100);
+ const list = items.filter((i) => i.change_seq > after && i.category === u.searchParams.get('category')).slice(0, limit);
+ return json(200, { items: list, next_after: list.length ? list[list.length - 1].change_seq : after, more: false, sources: {} });
+ });
+ function put(item) {
+ const idx = items.findIndex((i) => i.id === item.id);
+ const row = { category: 'coupons', kind: 'coupon', revision: 1, removed: null, authors: [], summary: null, published_at: null, source_updated_at: null,
+ provenance: { retrieved_at: '2026-09-22T10:00:00.000Z', first_seen_at: '2026-09-22T10:00:00.000Z', content_hash: 'a'.repeat(64), raw_body_hash: null, parser_version: 'manual@1', fetch_run_id: null, license_note: null, terms_note: 'merchant-published codes', entered_by: 'svc:network' },
+ ...item, change_seq: ++seq };
+ if (idx >= 0) items.splice(idx, 1);
+ items.push(row);
+ return row;
+ }
+ return { ...srv, items, calls, put, setDown: (v) => { down = v; } };
+}
+
+module.exports = { startNetwork, startSources, listen };
new file mode 100644
new file mode 100644
@@ -0,0 +1,237 @@
@@ -0,0 +1,237 @@
+'use strict';
+/**
+ * A code's life: submitted as unknown (expiry and validity unknown stay unknown, everywhere),
+ * nothing but people's reports can make it "working", a known expiry takes it out of active
+ * results at that instant (API, pages, feeds, sitemaps, Search), staff and services can only
+ * disable / expire / re-enable, and every change is in the history, the events and Search.
+ */
+const assert = require('assert');
+const contracts = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+
+const HOUR = 3600 * 1000;
+const DAY = 24 * HOUR;
+
+(async () => {
+ const t = await boot();
+ const alice = t.network.addUser('alice');
+ const bob = t.network.addUser('bob');
+ const shop = await t.merchant({ name: 'Acme Outdoor', host: 'acme-outdoor.com' });
+
+ let unknownCode;
+ await check('a submission with no expiry and no restrictions: status unknown, confidence null, expiry unknown — in the API, the page and the JSON', async () => {
+ const r = await t.submit(alice, { host: 'www.acme-outdoor.com', code: 'TRAIL15', title: '15% off tents' });
+ assert.strictEqual(r.status, 201, r.text);
+ const c = r.json().coupon;
+ unknownCode = c;
+ assert.strictEqual(c.status, 'unknown');
+ assert.strictEqual(c.confidence, null);
+ assert.deepStrictEqual(c.expiry, { known: false, expires_at: null, precision: null, basis: null });
+ assert.deepStrictEqual(c.restrictions, []);
+ assert.strictEqual(c.active, true);
+ const page = await t.get(`/m/${shop.slug}`);
+ assert.strictEqual(page.status, 200);
+ assert.match(page.text, /TRAIL15/);
+ assert.match(page.text, /Validity unknown/);
+ assert.match(page.text, /Expiry unknown/);
+ assert.match(page.text, /No restrictions stated. That does not mean there are none./);
+ assert.match(page.text, /No reports in the last 30 days/);
+ assert.doesNotMatch(page.text, /Expires /);
+ const j = (await t.get(`/c/${c.id}.json`)).json();
+ assert.strictEqual(j.coupon.expiry.expires_at, null);
+ assert.strictEqual(j.coupon.confidence, null);
+ const jsonld = page.text.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g).join('\n');
+ assert.doesNotMatch(jsonld, /validThrough|priceValidUntil|aggregateRating|ratingValue|"Offer"/, 'no fabricated offer or validity in structured data');
+ });
+
+ await check('the unknown code stays unknown across time and sweeps while nobody reports', async () => {
+ t.clock.advance(40 * DAY);
+ t.ctx.worker.sweep();
+ const c = (await t.get(`/api/v1/coupons/${unknownCode.id}`)).json().coupon;
+ assert.strictEqual(c.status, 'unknown');
+ assert.strictEqual(c.confidence, null);
+ assert.strictEqual(c.expiry.known, false);
+ assert.strictEqual(c.active, true, 'unknown expiry never expires by itself');
+ });
+
+ await check('nobody can submit a status, a confidence or "verified": people, services and AI output alike', async () => {
+ for (const extra of [{ status: 'reported_working' }, { confidence: 0.99 }, { verified: true }, { working: true }]) {
+ const r = await t.submit(alice, { host: 'acme-outdoor.com', code: 'X1', title: 'Some code', ...extra });
+ assert.strictEqual(r.status, 422, JSON.stringify(extra));
+ assert.strictEqual(r.json().code, 'coupon.status_not_accepted');
+ }
+ const ai = t.network.serviceToken('ai', ['coupons.coupon.submit']);
+ const r = await t.get('/api/v1/coupons/submit', { as: ai, headers: { 'x-ov-origin': 'ai' }, json: { host: 'acme-outdoor.com', code: 'AIGUESS', title: 'Model guess', status: 'reported_working' } });
+ assert.strictEqual(r.status, 422);
+ });
+
+ let aiCode;
+ await check('AI-extracted codes are drafts for staff review, start unknown, and a model cannot report', async () => {
+ const ai = t.network.serviceToken('ai', ['coupons.coupon.submit', 'coupons.report.create']);
+ const r = await t.get('/api/v1/coupons/submit', { as: ai, headers: { 'x-ov-origin': 'ai' }, json: { host: 'acme-outdoor.com', code: 'AIFOUND', title: '10% off boots', evidence_url: 'https://acme-outdoor.com/promos', ai_run_id: 'run_123' } });
+ assert.strictEqual(r.status, 201, r.text);
+ aiCode = r.json().coupon;
+ assert.strictEqual(r.json().review_state, 'pending');
+ assert.strictEqual(aiCode.status, 'unknown');
+ assert.strictEqual(aiCode.active, false);
+ assert.strictEqual((await t.get(`/c/${aiCode.id}`)).status, 404, 'not public before review');
+ assert.doesNotMatch((await t.get(`/m/${shop.slug}`)).text, /AIFOUND/);
+ const rep = await t.get(`/api/v1/coupons/${unknownCode.id}/report`, { as: ai, headers: { 'x-ov-origin': 'ai', 'x-ov-subject': alice.subject }, json: { outcome: 'worked' } });
+ assert.strictEqual(rep.status, 403);
+ assert.strictEqual(rep.json().code, 'report.ai_refused');
+ // Staff review publishes it — still unknown until people report.
+ const approve = await t.get(`/staff/coupons/${aiCode.id}/approve`, { as: t.staff, form: { csrf: t.csrf(t.staff) } });
+ assert.strictEqual(approve.status, 200, approve.text.slice(0, 200));
+ const after = (await t.get(`/api/v1/coupons/${aiCode.id}`)).json().coupon;
+ assert.strictEqual(after.status, 'unknown');
+ assert.strictEqual(after.active, true);
+ assert.match((await t.get(`/c/${aiCode.id}`)).text, /Extracted by an AI workflow/);
+ });
+
+ await check('only people\'s reports make a code "reported working"', async () => {
+ const r = await t.get(`/api/v1/coupons/${unknownCode.id}/report`, { as: t.network.userToken(bob), json: { outcome: 'worked' } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.json().coupon.status, 'reported_working');
+ assert.strictEqual(r.json().coupon.confidence, 0.67);
+ });
+
+ await check('staff and services can disable, expire or re-enable — never set working/failed', async () => {
+ const svc = t.network.serviceToken('moderation', ['coupons.status.update']);
+ for (const s of ['reported_working', 'reported_failed', 'unknown']) {
+ const r = await t.get(`/api/v1/coupons/${unknownCode.id}/status`, { as: svc, json: { status: s } });
+ assert.strictEqual(r.status, 422, s);
+ assert.strictEqual(r.json().code, 'coupon.status_not_settable');
+ }
+ const noCap = await t.get(`/api/v1/coupons/${unknownCode.id}/status`, { as: t.network.serviceToken('other', []), json: { status: 'disabled' } });
+ assert.strictEqual(noCap.status, 403);
+ const member = await t.get(`/api/v1/coupons/${unknownCode.id}/status`, { as: t.network.userToken(alice), json: { status: 'disabled' } });
+ assert.strictEqual(member.status, 403);
+ const off = await t.get(`/api/v1/coupons/${unknownCode.id}/status`, { as: svc, json: { status: 'disabled', note: 'merchant asked' } });
+ assert.strictEqual(off.status, 200, off.text);
+ assert.strictEqual(off.json().coupon.status, 'disabled');
+ assert.strictEqual((await t.get(`/c/${unknownCode.id}`)).status, 410);
+ assert.strictEqual((await t.get(`/api/v1/coupons/${unknownCode.id}`)).status, 404);
+ const on = await t.get(`/api/v1/coupons/${unknownCode.id}/status`, { as: svc, json: { status: 'active' } });
+ assert.strictEqual(on.json().coupon.status, 'reported_working', 're-enabled: recomputed from the reports, not set');
+ });
+
+ let dated;
+ const expiresAt = Date.parse('2026-11-05T09:30:00Z');
+ await check('a code with a known expiry leaves every active result at that instant, before any sweep', async () => {
+ t.clock.set(Date.parse('2026-11-01T00:00:00Z'));
+ const r = await t.submit(alice, { host: 'acme-outdoor.com', code: 'FALL20', title: '20% off jackets', expires: '2026-11-05T09:30:00Z', evidence_url: 'https://acme-outdoor.com/fall', expiry_basis: 'evidence',
+ restrictions: { min_spend: { amount: '50', currency: 'USD' }, new_customers_only: true, regions: 'US, CA', categories: ['Jackets'] } });
+ assert.strictEqual(r.status, 201, r.text);
+ dated = r.json().coupon;
+ assert.deepStrictEqual(dated.expiry, { known: true, expires_at: '2026-11-05T09:30:00.000Z', precision: 'instant', basis: 'evidence' });
+ assert.strictEqual(dated.evidence[0].merchant_page, true);
+ assert.deepStrictEqual(dated.restrictions.map((x) => x.kind).sort(), ['category', 'min_spend', 'new_customers_only', 'region', 'region']);
+ const page = await t.get(`/m/${shop.slug}`);
+ assert.match(page.text, /Minimum spend \$50\.00/);
+ assert.match(page.text, /New customers only/);
+ assert.match(page.text, /Canada \(CA\)/);
+ assert.match(page.text, /as stated on the evidence page/);
+
+ const listed = async () => {
+ const api = (await t.get(`/api/v1/merchants/${shop.id}/coupons`)).json().coupons.map((c) => c.code);
+ const html = (await t.get(`/m/${shop.slug}`)).text;
+ const feed = (await t.get('/feed.xml')).text;
+ const map = (await t.get('/sitemaps/coupons.xml')).text;
+ return { api: api.includes('FALL20'), page: /id="cpn_[^"]+"[\s\S]*?FALL20/.test(html.split('Recently ended')[0]), feed: feed.includes('FALL20'), sitemap: map.includes(dated.id) };
+ };
+ t.clock.set(expiresAt - 1);
+ assert.deepStrictEqual(await listed(), { api: true, page: true, feed: true, sitemap: true });
+ t.clock.set(expiresAt);
+ assert.deepStrictEqual(await listed(), { api: false, page: false, feed: false, sitemap: false });
+ const cpage = await t.get(`/c/${dated.id}`);
+ assert.strictEqual(cpage.status, 200, 'still readable');
+ assert.match(cpage.text, /<meta name="robots" content="noindex, follow">/);
+ assert.match(cpage.headers.get('x-robots-tag'), /noindex/);
+ assert.match((await t.get(`/m/${shop.slug}`)).text, /Recently ended[\s\S]*FALL20/);
+ const rep = await t.get(`/api/v1/coupons/${dated.id}/report`, { as: t.network.userToken(bob), json: { outcome: 'worked' } });
+ assert.strictEqual(rep.status, 409, 'no reports on a code out of active results');
+ });
+
+ await check('the sweep records the expiry: status, history, coupons.coupon.expired, Search tombstone — once', async () => {
+ const before = t.events().length;
+ const r = t.ctx.worker.sweep();
+ assert.deepStrictEqual(r.expired, [dated.id]);
+ const row = t.ctx.coupons.get(dated.id);
+ assert.strictEqual(row.status, 'expired');
+ assert.strictEqual(row.expired_at, expiresAt, 'expired at the stated instant, not at sweep time');
+ const hist = t.ctx.coupons.history(dated.id);
+ assert.strictEqual(hist[0].to_status, 'expired');
+ assert.strictEqual(hist[0].reason, 'expiry');
+ const evs = t.events().slice(before);
+ const mine = evs.filter((e) => e.subject.id === dated.id).map((e) => e.event_type).sort();
+ assert.deepStrictEqual(mine, ['coupons.coupon.expired', 'coupons.index_document.deleted']);
+ assert.ok(evs.some((e) => e.event_type === 'coupons.index_document.upserted' && e.subject.id === shop.id), 'the shop document is re-sent with one active code fewer');
+ assert.deepStrictEqual(t.ctx.worker.sweep().expired, [], 'idempotent');
+ assert.strictEqual(t.events().length, before + evs.length);
+ });
+
+ await check('a date-only expiry means the end of that day in UTC, and the page says the time zone is not stated', async () => {
+ const r = await t.submit(alice, { host: 'acme-outdoor.com', code: 'DATEONLY', title: 'Five off socks', expires: '2026-11-10' });
+ assert.strictEqual(r.json().coupon.expiry.expires_at, '2026-11-10T23:59:59.999Z');
+ assert.strictEqual(r.json().coupon.expiry.basis, 'submitter');
+ assert.match((await t.get(`/c/${r.json().coupon.id}`)).text, /end of that day in UTC; the merchant's time zone is not stated/);
+ assert.strictEqual((await t.submit(alice, { host: 'acme-outdoor.com', code: 'AMBIG', title: 'Ambiguous time', expires: '2026-11-10T10:00' })).json().code, 'coupon.ambiguous_expiry');
+ assert.strictEqual((await t.submit(alice, { host: 'acme-outdoor.com', code: 'PAST', title: 'Already over', expires: '2026-01-01' })).json().code, 'coupon.already_expired');
+ });
+
+ await check('a shop with no active codes is served but noindex (thin), and leaves the merchant sitemap', async () => {
+ const empty = await t.merchant({ name: 'Quiet Shop', host: 'quiet-shop.com' });
+ const page = await t.get(`/m/${empty.slug}`);
+ assert.strictEqual(page.status, 200);
+ assert.match(page.text, /No active codes for Quiet Shop right now/);
+ assert.match(page.text, /<meta name="robots" content="noindex, follow">/);
+ assert.doesNotMatch((await t.get('/sitemaps/merchants.xml')).text, /quiet-shop/);
+ assert.match((await t.get('/sitemaps/merchants.xml')).text, new RegExp(`/m/${shop.slug}<`));
+ });
+
+ await check('members\' codes for a shop nobody lists wait for staff; approving the shop publishes them', async () => {
+ const r = await t.submit(bob, { url: 'https://www.new-gadgets.net/cart', code: 'GADGET5', title: 'Five off gadgets' });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.json().merchant.status, 'pending');
+ assert.strictEqual(r.json().review_state, 'pending');
+ assert.strictEqual((await t.get('/api/v1/merchants/resolve?host=new-gadgets.net')).status, 404, 'pending shops do not resolve');
+ assert.strictEqual((await t.get(`/m/${r.json().merchant.slug}`)).status, 404);
+ const staffPage = await t.get('/staff', { as: t.staff });
+ assert.match(staffPage.text, /new-gadgets\.net/);
+ const ok = await t.get(`/staff/merchants/${r.json().merchant.id}/status`, { as: t.staff, form: { csrf: t.csrf(t.staff), status: 'active' } });
+ assert.strictEqual(ok.status, 200);
+ const list = (await t.get(`/api/v1/merchants/${r.json().merchant.id}/coupons`)).json().coupons;
+ assert.deepStrictEqual(list.map((c) => [c.code, c.status]), [['GADGET5', 'unknown']]);
+ assert.strictEqual((await t.get('/api/v1/merchants/resolve?host=shop.new-gadgets.net')).status, 200);
+ });
+
+ await check('duplicates add evidence to the same code; a taken-down code cannot be resubmitted', async () => {
+ const a = await t.submit(bob, { host: 'acme-outdoor.com', code: 'trail15', title: 'Same code, other case', evidence_url: 'https://forum.example-deals.org/t/1' });
+ assert.strictEqual(a.status, 200);
+ assert.strictEqual(a.json().duplicate, true);
+ assert.strictEqual(a.json().coupon.id, unknownCode.id);
+ assert.ok(a.json().coupon.evidence.some((e) => e.url === 'https://forum.example-deals.org/t/1'));
+ await t.get(`/api/v1/coupons/${unknownCode.id}/status`, { as: t.staffToken(), json: { status: 'disabled' } });
+ assert.strictEqual((await t.submit(bob, { host: 'acme-outdoor.com', code: 'TRAIL15', title: 'Again' })).json().code, 'coupon.disabled');
+ });
+
+ await check('every event is a valid envelope from the service, and none names a person', async () => {
+ const all = t.events();
+ assert.ok(all.length > 10);
+ for (const e of all) {
+ const v = contracts.validate('events.event-envelope@1', e);
+ assert.ok(v.valid, `${e.event_type}: ${JSON.stringify(v.errors)}`);
+ assert.deepStrictEqual(e.actor, { type: 'service', id: 'coupons' });
+ assert.doesNotMatch(JSON.stringify(e), /usr_[0-9A-Z]{26}/, `${e.event_type} names a person`);
+ if (e.event_type === 'coupons.index_document.upserted') {
+ const d = contracts.validate('search.index-document@1', e.payload);
+ assert.ok(d.valid, JSON.stringify(d.errors));
+ }
+ }
+ const types = new Set(all.map((e) => e.event_type));
+ for (const want of ['coupons.coupon.created', 'coupons.coupon.updated', 'coupons.coupon.expired', 'coupons.coupon.disabled', 'coupons.report.created', 'coupons.confidence.changed']) assert.ok(types.has(want), want);
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,136 @@
@@ -0,0 +1,136 @@
+'use strict';
+/**
+ * Useful without JavaScript (every flow is a plain form), discoverable (robots, llms.txt, sitemaps,
+ * feeds, JSON twins, JSON-LD from real fields only), cache-safe, and honest about readiness.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ const alice = t.network.addUser('alice');
+ const shop = await t.merchant({ name: 'Green Grocer', host: 'greengrocer.co.uk' });
+
+ let codeId;
+ await check('submit a code with the plain form (no JavaScript): listed at once as unknown', async () => {
+ const anon = await t.get('/submit');
+ assert.match(anon.text, /Sign in with OpenVibe/);
+ const page = await t.get(`/submit?merchant=${shop.slug}`, { as: alice });
+ assert.match(page.text, /<form method="post" action="\/submit"/);
+ assert.match(page.text, /Shop: <strong>Green Grocer<\/strong>/);
+ assert.doesNotMatch(page.text, /<script src="\/[^"]+\.js"/, 'no page script of our own');
+ const r = await t.get('/submit', { as: alice, form: { csrf: t.csrf(alice), merchant: shop.slug, code: 'VEG5', title: 'Five pounds off veg boxes', expires: '', min_spend_amount: '30', min_spend_currency: 'gbp', regions: 'GB' } });
+ assert.strictEqual(r.status, 201, r.text.slice(0, 300));
+ assert.match(r.text, /your code is listed/);
+ codeId = t.ctx.store.db.prepare("SELECT id FROM coupons WHERE code = 'VEG5'").get().id;
+ const m = await t.get(`/m/${shop.slug}`);
+ assert.match(m.text, /VEG5/);
+ assert.match(m.text, /Minimum spend £30\.00/);
+ assert.match(m.text, /United Kingdom \(GB\)/);
+ const bad = await t.get('/submit', { as: alice, form: { csrf: t.csrf(alice), merchant: shop.slug, code: 'has space', title: 'x' } });
+ assert.strictEqual(bad.status, 422);
+ assert.match(bad.text, /Please fix/);
+ assert.match(bad.text, /value="x"/, 'the form keeps what was typed');
+ assert.strictEqual((await t.get('/submit', { as: alice, form: { merchant: shop.slug, code: 'A1', title: 'abc' } })).status, 403, 'no form token');
+ });
+
+ await check('report and watch with plain forms; the watch list is private', async () => {
+ const r = await t.get(`/c/${codeId}/report`, { as: alice, form: { csrf: t.csrf(alice), outcome: 'worked', back: `/m/${shop.slug}` } });
+ assert.strictEqual(r.status, 303);
+ assert.strictEqual(r.headers.get('location'), `/m/${shop.slug}?done=reported`);
+ const after = await t.get(`/m/${shop.slug}?done=reported`, { as: alice });
+ assert.match(after.text, /your report was recorded/);
+ assert.match(after.text, /Reported working/);
+ assert.match(after.text, /Confidence 67%/);
+ const offsite = await t.get(`/c/${codeId}/report`, { as: alice, form: { csrf: t.csrf(alice), outcome: 'worked', back: 'https://evil.example-shop.com/' } });
+ assert.strictEqual(offsite.headers.get('location'), `/c/${codeId}?done=report_same`, 'no open redirect');
+ const w = await t.get(`/m/${shop.slug}/watch`, { as: alice, form: { csrf: t.csrf(alice), action: 'watch' } });
+ assert.strictEqual(w.status, 303);
+ assert.match((await t.get('/watching', { as: alice })).text, /Green Grocer/);
+ assert.strictEqual((await t.get('/watching')).status, 303, 'anonymous → sign in');
+ await t.get(`/m/${shop.slug}/watch`, { as: alice, form: { csrf: t.csrf(alice), action: 'unwatch' } });
+ assert.doesNotMatch((await t.get('/watching', { as: alice })).text, /Green Grocer/);
+ });
+
+ await check('caching: anonymous pages are public for 60 s, signed-in pages are private; both vary on Cookie', async () => {
+ const anon = await t.get(`/m/${shop.slug}`);
+ assert.match(anon.headers.get('cache-control'), /^public, max-age=60/);
+ assert.match(anon.headers.get('vary'), /Cookie/);
+ const signed = await t.get(`/m/${shop.slug}`, { as: alice });
+ assert.strictEqual(signed.headers.get('cache-control'), 'private, no-store');
+ assert.match(signed.text, /name="csrf"/);
+ assert.doesNotMatch(anon.text, /name="csrf"/, 'no form tokens in a shared cache');
+ });
+
+ await check('JSON-LD only from real fields (breadcrumbs, web page); the JSON twin has the page\'s content', async () => {
+ const page = (await t.get(`/c/${codeId}`)).text;
+ const blocks = [...page.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
+ assert.deepStrictEqual(blocks.map((b) => b['@type']).sort(), ['BreadcrumbList', 'WebPage']);
+ const json = (await t.get(`/c/${codeId}.json`)).json();
+ assert.strictEqual(json.coupon.code, 'VEG5');
+ assert.strictEqual(json.indexability.indexable, true);
+ const mj = (await t.get(`/m/${shop.slug}.json`)).json();
+ assert.deepStrictEqual(mj.coupons.map((c) => c.code), ['VEG5']);
+ });
+
+ await check('robots.txt, llms.txt, the sitemap index and sections, and the feeds', async () => {
+ const robots = await t.get('/robots.txt');
+ assert.match(robots.text, /Sitemap: https:\/\/openvibe\.coupons\/sitemap\.xml/);
+ assert.match(robots.text, /Disallow: \/api\//);
+ assert.match(robots.text, /automated-consumer policy/);
+ const llms = await t.get('/llms.txt');
+ assert.match(llms.text, /reported_working/);
+ assert.match(llms.text, /\/m\/<slug>\.json/);
+ assert.match((await t.get('/sitemap.xml')).text, /sitemaps\/coupons\.xml[\s\S]*sitemaps\/merchants\.xml|sitemaps\/merchants\.xml[\s\S]*sitemaps\/coupons\.xml/);
+ assert.match((await t.get('/sitemaps/coupons.xml')).text, new RegExp(`https://openvibe\\.coupons/c/${codeId}`));
+ assert.match((await t.get('/sitemaps/merchants.xml')).text, new RegExp(`https://openvibe\\.coupons/m/${shop.slug}`));
+ const rss = await t.get('/feed.xml');
+ assert.match(rss.headers.get('content-type'), /rss/);
+ assert.match(rss.text, /VEG5/);
+ assert.match(rss.text, /Expiry unknown/);
+ assert.match((await t.get('/atom.xml')).text, /<entry>/);
+ assert.strictEqual((await t.get('/feed.json')).json().items.length, 1);
+ assert.match((await t.get(`/m/${shop.slug}/feed.xml`)).text, /VEG5/);
+ });
+
+ await check('home, search and the about page (with the formula) render without JavaScript', async () => {
+ const home = await t.get('/');
+ assert.match(home.text, /Green Grocer/);
+ assert.match(home.text, /1 active code</);
+ const s = await t.get('/?q=grocer');
+ assert.match(s.text, /Shops matching “grocer”/);
+ assert.match(s.text, /<meta name="robots" content="noindex, nofollow">/, 'search results are not indexed');
+ const about = await t.get('/about');
+ assert.match(about.text, /confidence = \(1 \+ e \+ W\) \/ \(2 \+ e \+ W \+ F\)/);
+ assert.match(about.text, /reads only the hostname/);
+ });
+
+ await check('health, readiness, release, 404s', async () => {
+ assert.strictEqual((await t.get('/api/health')).json().service, 'openvibe-coupons');
+ const ready = await t.get('/api/ready');
+ assert.strictEqual(ready.status, 200);
+ const body = ready.json();
+ assert.match(JSON.stringify(body), /relay off/);
+ assert.match(JSON.stringify(body), /worker off/);
+ assert.strictEqual((await t.get('/release.json')).status, 200);
+ assert.strictEqual((await t.get('/nope')).status, 404);
+ assert.strictEqual((await t.get('/m/nope')).status, 404);
+ assert.strictEqual((await t.get('/c/cpn_00000000000000000000000000')).status, 404);
+ const api404 = await t.get('/api/v1/nope');
+ assert.strictEqual(api404.status, 404);
+ assert.match(api404.headers.get('content-type'), /problem\+json/);
+ });
+
+ await check('staff pages are for staff only', async () => {
+ assert.strictEqual((await t.get('/staff')).status, 303);
+ assert.strictEqual((await t.get('/staff', { as: alice })).status, 403);
+ assert.strictEqual((await t.get('/staff', { as: t.staff })).status, 200);
+ const add = await t.get('/staff/merchants', { as: t.staff, form: { csrf: t.csrf(t.staff), name: 'Cycle Hub', host: 'cyclehub.de', include_subdomains: '1' } });
+ assert.match(add.text, /Added Cycle Hub/);
+ const noCsrf = await t.get('/staff/merchants', { as: t.staff, form: { name: 'X', host: 'x-shop.de' } });
+ assert.strictEqual(noCsrf.status, 403);
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,130 @@
@@ -0,0 +1,130 @@
+'use strict';
+/**
+ * Validity reports: deduplicated per (person, code, UTC day) across every channel, only the latest
+ * per person counts, rate-limited per person, never anonymous, never from a cookie on the API,
+ * never returned or emitted with the reporter, and decaying back to unknown with time.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+const HOUR = 3600 * 1000;
+const DAY = 24 * HOUR;
+
+(async () => {
+ const t = await boot({ env: { COUPONS_REPORTS_PER_HOUR: '5', COUPONS_REPORTS_PER_DAY: '8' } });
+ const alice = t.network.addUser('alice');
+ const bob = t.network.addUser('bob');
+ const carol = t.network.addUser('carol');
+ const shop = await t.merchant({ name: 'Blue Kettle', host: 'bluekettle.shop' });
+ const mk = async (code) => (await t.submit(alice, { host: 'bluekettle.shop', code, title: `Code ${code}` })).json().coupon;
+ const code = await mk('KETTLE10');
+ const report = (who, id, body) => t.get(`/api/v1/coupons/${id}/report`, { as: typeof who === 'string' ? who : t.network.userToken(who), json: body });
+
+ await check('anonymous reports are refused, and the API ignores cookies (no ambient session)', async () => {
+ assert.strictEqual((await t.get(`/api/v1/coupons/${code.id}/report`, { json: { outcome: 'worked' } })).status, 401);
+ const cookieOnly = await t.get(`/api/v1/coupons/${code.id}/report`, { as: bob, json: { outcome: 'worked' } });
+ assert.strictEqual(cookieOnly.status, 401, 'a signed-in cookie does not authenticate the API');
+ assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports').get().n, 0);
+ });
+
+ await check('input is validated: outcome worked|failed, reasons only for failed ones', async () => {
+ assert.strictEqual((await report(bob, code.id, { outcome: 'great' })).status, 422);
+ assert.strictEqual((await report(bob, code.id, { outcome: 'worked', reason: 'invalid' })).status, 422);
+ assert.strictEqual((await report(bob, code.id, { outcome: 'failed', reason: 'because' })).status, 422);
+ assert.strictEqual((await report(bob, 'cpn_nope', { outcome: 'worked' })).status, 404);
+ });
+
+ await check('same person, same code, same day: one report — a repeat is a no-op, the other outcome corrects it', async () => {
+ const a = await report(bob, code.id, { outcome: 'worked' });
+ assert.strictEqual(a.status, 201);
+ assert.strictEqual(a.json().deduplicated, false);
+ const b = await report(bob, code.id, { outcome: 'worked' });
+ assert.strictEqual(b.status, 200);
+ assert.strictEqual(b.json().deduplicated, true);
+ assert.strictEqual(b.json().corrected, false);
+ // Through the site form and the extension too — the same person is one reporter.
+ const form = await t.get(`/c/${code.id}/report`, { as: bob, form: { csrf: t.csrf(bob), outcome: 'worked', back: `/m/${shop.slug}` } });
+ assert.strictEqual(form.status, 303);
+ assert.match(form.headers.get('location'), /done=report_same/);
+ const tokenA = await t.connect(bob, { label: 'laptop' });
+ const tokenB = await t.connect(bob, { label: 'phone' });
+ assert.strictEqual((await report(tokenA, code.id, { outcome: 'worked' })).json().deduplicated, true);
+ assert.strictEqual((await report(tokenB, code.id, { outcome: 'worked' })).json().deduplicated, true);
+ assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports').get().n, 1);
+ const c = await report(bob, code.id, { outcome: 'failed', reason: 'min_spend_not_met' });
+ assert.strictEqual(c.json().corrected, true);
+ assert.strictEqual(c.json().coupon.status, 'reported_failed');
+ assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports').get().n, 1);
+ assert.strictEqual(t.events('coupons.report.created').length, 1, 'one report, one event');
+ });
+
+ await check('the next day is a new report, but only the latest per person counts', async () => {
+ t.clock.advance(DAY);
+ const r = await report(bob, code.id, { outcome: 'worked' });
+ assert.strictEqual(r.status, 201);
+ const v = r.json().coupon;
+ assert.deepStrictEqual([v.reports.worked, v.reports.failed], [1, 0], 'bob counts once, with his latest');
+ assert.strictEqual(v.status, 'reported_working');
+ assert.strictEqual(v.confidence, 0.67);
+ await report(carol, code.id, { outcome: 'failed', reason: 'invalid' });
+ const after = (await t.get(`/api/v1/coupons/${code.id}`)).json().coupon;
+ assert.strictEqual(after.status, 'unknown', 'one worked and one failed: the reports disagree');
+ assert.strictEqual(after.confidence, 0.5);
+ });
+
+ await check('the response and the events never name a reporter; report times are published to the hour', async () => {
+ const r = await report(alice, code.id, { outcome: 'worked' });
+ const body = r.text;
+ assert.doesNotMatch(body, /usr_[0-9A-Z]{26}/);
+ assert.doesNotMatch(body, /reporter|install_id|cpi_/);
+ const last = r.json().coupon.reports.last_report_at;
+ assert.match(last, /T\d{2}:00:00\.000Z$/);
+ for (const e of t.events('coupons.report.created')) {
+ assert.deepStrictEqual(Object.keys(e.payload).sort(), ['channel', 'day', 'merchant_id', 'outcome', ...(e.payload.reason ? ['reason'] : [])].sort());
+ assert.deepStrictEqual(e.actor, { type: 'service', id: 'coupons' });
+ }
+ const keys = t.ctx.store.db.prepare('SELECT reporter_key FROM coupon_validation_reports').all().map((x) => x.reporter_key);
+ for (const k of keys) { assert.match(k, /^[0-9a-f]{64}$/); assert.ok(!k.includes('usr_')); }
+ });
+
+ await check('rate limit: new reports per person per hour and per day (dedupe hits do not count)', async () => {
+ const codes = [];
+ for (let i = 0; i < 8; i++) codes.push(await mk(`RL${i}`));
+ t.clock.advance(2 * HOUR);
+ const dave = t.network.addUser('dave');
+ for (let i = 0; i < 5; i++) assert.strictEqual((await report(dave, codes[i].id, { outcome: 'worked' })).status, 201, `report ${i}`);
+ assert.strictEqual((await report(dave, codes[0].id, { outcome: 'worked' })).status, 200, 'a dedupe hit is not a new report');
+ const limited = await report(dave, codes[5].id, { outcome: 'worked' });
+ assert.strictEqual(limited.status, 429);
+ assert.strictEqual(limited.json().code, 'report.rate_limited');
+ t.clock.advance(HOUR + 1);
+ for (let i = 5; i < 8; i++) assert.strictEqual((await report(dave, codes[i].id, { outcome: 'worked' })).status, 201);
+ const daily = await report(dave, code.id, { outcome: 'worked' });
+ assert.strictEqual(daily.status, 429, 'eight in a day is the daily ceiling here');
+ // Another person is unaffected.
+ assert.strictEqual((await report(t.network.addUser('erin'), codes[5].id, { outcome: 'worked' })).status, 201);
+ });
+
+ await check('decay: a lone "worked" is reported_working for a week, unknown after, and no confidence after 30 days', async () => {
+ const fresh = await mk('DECAY1');
+ const frank = t.network.addUser('frank');
+ await report(frank, fresh.id, { outcome: 'worked' });
+ assert.strictEqual(t.ctx.coupons.get(fresh.id).status, 'reported_working');
+ t.clock.advance(6 * DAY);
+ t.ctx.worker.sweep();
+ assert.strictEqual(t.ctx.coupons.get(fresh.id).status, 'reported_working');
+ t.clock.advance(2 * DAY);
+ t.ctx.worker.sweep();
+ assert.strictEqual(t.ctx.coupons.get(fresh.id).status, 'unknown');
+ assert.ok(t.ctx.coupons.get(fresh.id).confidence > 0.5);
+ t.clock.advance(23 * DAY);
+ t.ctx.worker.sweep();
+ assert.strictEqual(t.ctx.coupons.get(fresh.id).confidence, null);
+ const hist = t.ctx.coupons.history(fresh.id).map((h) => h.reason);
+ assert.ok(hist.includes('decay'));
+ assert.ok(t.events('coupons.confidence.changed').some((e) => e.subject.id === fresh.id && e.payload.to === null));
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
+#!/usr/bin/env node
+/**
+ * Runs every test/*.test.js in its own process and fails if any fails. They use temp SQLite
+ * databases and in-process mocks of OpenVibe.Network and OpenVibe.Sources; none needs the
+ * network or a running site.
+ *
+ * npm test # everything
+ * npm test -- schedule # only files whose name contains one of the words
+ */
+'use strict';
+const { spawn } = require('child_process');
+const fs = require('fs');
+const path = require('path');
+
+const filters = process.argv.slice(2).filter((a) => !a.startsWith('-'));
+const files = fs.readdirSync(__dirname).filter((f) => f.endsWith('.test.js')).filter((f) => !filters.length || filters.some((w) => f.includes(w))).sort();
+const TIMEOUT_MS = 60000;
+
+function runOne(file) {
+ return new Promise((resolve) => {
+ const started = Date.now();
+ const child = spawn(process.execPath, [path.join(__dirname, file)], { cwd: path.join(__dirname, '..'), env: { ...process.env, NODE_ENV: 'test' }, stdio: ['ignore', 'pipe', 'pipe'] });
+ let output = '';
+ child.stdout.on('data', (c) => { output += c; });
+ child.stderr.on('data', (c) => { output += c; });
+ const timer = setTimeout(() => { output += `\n[run] timed out after ${TIMEOUT_MS}ms`; child.kill('SIGKILL'); }, TIMEOUT_MS);
+ child.on('close', (code, signal) => { clearTimeout(timer); resolve({ file, ok: code === 0, code: code ?? signal, ms: Date.now() - started, output }); });
+ });
+}
+
+(async () => {
+ if (!files.length) { console.error('no test files matched'); process.exit(1); }
+ const results = [];
+ for (const f of files) {
+ const r = await runOne(f);
+ results.push(r);
+ console.log(`${r.ok ? '✓' : '✗'} ${r.file.padEnd(34)} ${String(r.ms).padStart(6)}ms`);
+ if (process.env.VERBOSE) console.log(r.output);
+ }
+ const failed = results.filter((r) => !r.ok);
+ for (const r of failed) { console.log(`\n── ${r.file} (exit ${r.code}) ──`); console.log(r.output.split('\n').slice(-80).join('\n')); }
+ console.log(`\n${results.length - failed.length}/${results.length} test files passed`);
+ process.exit(failed.length ? 1 : 0);
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -0,0 +1,111 @@
+'use strict';
+/**
+ * Import from OpenVibe.Sources (category coupons): items become codes with evidence and status
+ * unknown, wait for staff review by default, anything that cannot be imported is HELD with a
+ * reason (never dropped), no_merchant holds resolve once staff add the shop, a removed item
+ * withdraws its evidence, and a failed fetch changes nothing.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ const shop = await t.merchant({ name: 'River Books', host: 'riverbooks.com' });
+ const item = (id, extra = {}) => ({
+ id, source_key: 'staff-coupon-codes', identity: `https://riverbooks.com/promo#${id}`, canonical_url: 'https://riverbooks.com/promo',
+ title: 'Autumn reading sale', fields: { code: 'READ20', expires: null }, ...extra,
+ });
+
+ await check('an item becomes a code: status unknown, expiry unknown, evidence with the Sources item and its retrieval time, pending review', async () => {
+ t.sources.put(item('itm_01K0000000000000000000000A'));
+ const r = await t.ctx.importer.run();
+ assert.deepStrictEqual(r.outcomes, { imported: 1 });
+ const c = t.ctx.store.db.prepare("SELECT * FROM coupons WHERE code = 'READ20'").get();
+ assert.strictEqual(c.status, 'unknown');
+ assert.strictEqual(c.confidence, null);
+ assert.strictEqual(c.expires_at, null);
+ assert.strictEqual(c.origin, 'source');
+ assert.strictEqual(c.review_state, 'pending');
+ const v = t.ctx.coupons.view(c);
+ assert.deepStrictEqual(v.evidence, [{ kind: 'source', url: 'https://riverbooks.com/promo', merchant_page: true, sources_item: 'itm_01K0000000000000000000000A', retrieved_at: '2026-09-22T10:00:00.000Z' }]);
+ assert.strictEqual((await t.get(`/api/v1/merchants/${shop.id}/coupons`)).json().coupons.length, 0, 'not listed before review');
+ assert.strictEqual(t.sources.calls.length, 1);
+ assert.match(t.network.grants.find((g) => g.audience === 'openvibe.sources').scope, /sources\.item\.read/);
+ });
+
+ await check('the cursor advances: a second run fetches from where the first stopped and imports nothing new', async () => {
+ const r = await t.ctx.importer.run();
+ assert.deepStrictEqual(r.outcomes, {});
+ assert.match(t.sources.calls[t.sources.calls.length - 1], /after=1/);
+ });
+
+ await check('items that cannot become a code are held with a reason, and listed for staff', async () => {
+ t.sources.put(item('itm_01K0000000000000000000000B', { canonical_url: 'https://unknown-shop.com/x', fields: { code: 'NOPE' } }));
+ t.sources.put(item('itm_01K0000000000000000000000C', { kind: 'record' }));
+ t.sources.put(item('itm_01K0000000000000000000000D', { fields: { expires: '2026-12-01' } }));
+ t.sources.put(item('itm_01K0000000000000000000000E', { fields: { code: 'OLD', expires: '2026-01-01' } }));
+ const r = await t.ctx.importer.run();
+ assert.deepStrictEqual(r.outcomes, { 'held:no_merchant': 1, 'held:not_a_coupon': 1, 'held:no_code': 1, 'held:expired': 1, 'retry:held:no_merchant': 1 });
+ const holds = t.ctx.importer.holds().map((h) => h.reason).sort();
+ assert.deepStrictEqual(holds, ['expired', 'no_code', 'no_merchant', 'not_a_coupon']);
+ const staff = await t.get('/staff', { as: t.staff });
+ assert.match(staff.text, /itm_01K0000000000000000000000B from staff-coupon-codes: no_merchant/);
+ });
+
+ await check('a no_merchant hold is imported once staff add the shop', async () => {
+ await t.merchant({ name: 'Unknown Shop', host: 'unknown-shop.com' });
+ const r = await t.ctx.importer.run();
+ assert.deepStrictEqual(r.outcomes, { 'retry:imported': 1 });
+ assert.ok(t.ctx.store.db.prepare("SELECT 1 FROM coupons WHERE code = 'NOPE'").get());
+ assert.ok(!t.ctx.importer.holds().some((h) => h.reason === 'no_merchant'));
+ });
+
+ await check('a stated expiry is kept with basis "source"; a new revision of the same item updates the evidence, not a second code', async () => {
+ t.sources.put(item('itm_01K0000000000000000000000A', { revision: 2, fields: { code: 'READ20', expires: '2026-12-31', min_spend: 25, currency: 'EUR' } }));
+ await t.ctx.importer.run();
+ const rows = t.ctx.store.db.prepare("SELECT * FROM coupons WHERE code = 'READ20'").all();
+ assert.strictEqual(rows.length, 1);
+ assert.strictEqual(rows[0].expiry_basis, 'source');
+ assert.strictEqual(new Date(rows[0].expires_at).toISOString(), '2026-12-31T23:59:59.999Z');
+ const src = t.ctx.store.db.prepare('SELECT * FROM coupon_sources WHERE coupon_id = ?').all(rows[0].id);
+ assert.strictEqual(src.length, 1);
+ assert.strictEqual(src[0].sources_item_rev, 2);
+ });
+
+ await check('with COUPONS_SOURCES_AUTO_PUBLISH=true imported codes are listed at once — still unknown', async () => {
+ const t2 = await boot({ env: { COUPONS_SOURCES_AUTO_PUBLISH: 'true' } });
+ const s2 = await t2.merchant({ name: 'River Books', host: 'riverbooks.com' });
+ t2.sources.put(item('itm_01K0000000000000000000000F'));
+ await t2.ctx.importer.run();
+ const list = (await t2.get(`/api/v1/merchants/${s2.id}/coupons`)).json().coupons;
+ assert.deepStrictEqual(list.map((c) => [c.code, c.status, c.confidence]), [['READ20', 'unknown', null]]);
+ await t2.close();
+ });
+
+ await check('a removed item withdraws its evidence; a code with no evidence left is taken down', async () => {
+ const id = t.ctx.store.db.prepare("SELECT id FROM coupons WHERE code = 'READ20'").get().id;
+ t.sources.put(item('itm_01K0000000000000000000000A', { revision: 3, removed: { at: '2026-09-23T00:00:00.000Z', reason: 'merchant asked' } }));
+ const r = await t.ctx.importer.run();
+ assert.strictEqual(r.outcomes.withdrawn, 1);
+ const c = t.ctx.coupons.get(id);
+ assert.strictEqual(c.status, 'disabled');
+ assert.strictEqual(c.status_reason, 'source_removed');
+ });
+
+ await check('a failed fetch changes nothing and is recorded', async () => {
+ const before = t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupons').get().n;
+ const cursor = t.ctx.importer.state().cursor;
+ t.sources.setDown(true);
+ const r = await t.ctx.importer.run();
+ assert.match(r.error, /503/);
+ assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupons').get().n, before);
+ assert.strictEqual(t.ctx.importer.state().cursor, cursor);
+ assert.match(t.ctx.importer.state().last_error, /503/);
+ const ready = (await t.get('/api/ready')).json();
+ assert.match(JSON.stringify(ready), /last run failed/);
+ t.sources.setDown(false);
+ });
+
+ await t.close();
+ done();
+})();