openvibers/openvibe.coupons Commits

Coupons on the Publishing 1.1.0 chassis (plan T9 J5): the Sources client, change cursor and pull loop come from openvibe-publishing/ingest (clients/sources.js keeps only the coupons policy per item, on the caller's transaction), Search publication and the IndexNow ping from /publication (domain/publication.js keeps the gate facts, document bodies and product events), and the registrable-host helpers from the chassis' hosts. Migration 0002 seeds coupons_ingest_cursor once (continuity, never a reset to zero). The replaced domain/hosts.js, domain/psl.js and events/outbox.js are deleted; test/chassis.test.js proves nothing requires them and replays the Sources fixture against a golden captured from the pre-change code; test/domains.test.js runs the same host cases against the chassis. npm test and test:pg green (verified).

OP OpenVibers committed 29 Sept 2026 · 0b72fa1e93 · 17 files, +330 −353


Changes

ddiff --git a/STATUS.json b/STATUS.json
iindex 8a1e632..9d41a10 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -9,7 +9,7 @@
"domain": "openvibe.coupons",
"contracts": "openvibe-contracts v0.79.0",
"packages": [
-- "openvibe-publishing v1.0.0",
++ "openvibe-publishing v1.1.0",
"openvibe-contracts v0.79.0",
"openvibe-shared v2.2.0",
"openvibe-sdk v0.25.1"
@@@ -42,7 +42,7 @@
"expired codes leave API, pages, feeds, sitemaps and Search at the instant of expiry",
"server-rendered pages useful without JavaScript",
"/api/v1 by capability and browser-helper install tokens",
-- "OpenVibe.Sources coupons import with holds",
++ "OpenVibe.Sources coupons import with holds, on the openvibe-publishing/ingest chassis",
"SDK outbox events and Search index documents",
"per-actor limits (openvibe-sdk/limits)"
]
ddiff --git a/migrations/0002_ingest_cursor.sql b/migrations/0002_ingest_cursor.sql
nnew file mode 100644
iindex 0000000..89cd303
---- /dev/null
++++ b/migrations/0002_ingest_cursor.sql
@@@ -0,0 +1,17 @@
++-- phase: expand
++-- OpenVibe.Coupons on the openvibe-publishing/ingest chassis (plan T9): the Sources importer's change
++-- cursor moves from import_state to the chassis' <prefix>_ingest_cursor (lib/ingest.js schema()).
++-- The old cursor (import_state key 'sources:coupons') is copied once here, never reset to zero.
++
++CREATE TABLE IF NOT EXISTS coupons_ingest_cursor (
++ name text COLLATE "C" NOT NULL,
++ cursor bigint NOT NULL,
++ updated_at bigint NOT NULL,
++ PRIMARY KEY (name)
++);
++
++INSERT INTO coupons_ingest_cursor (name, cursor, updated_at)
++SELECT 'sources', cursor, COALESCE(last_ok_at, last_run_at, 0)
++FROM import_state
++WHERE key = 'sources:coupons'
++ON CONFLICT (name) DO NOTHING;
ddiff --git a/package-lock.json b/package-lock.json
iindex 9976d99..904b0c3 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -16,7 +16,7 @@
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
-- "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.0.0",
++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.1.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
"pg": "^8.23.0"
@@@ -1163,9 +1163,9 @@
}
},
"node_modules/openvibe-publishing": {
-- "version": "1.0.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.0.0",
-- "integrity": "sha512-g4mU0PPtI4y38NcsdppEbrXTrCYp0rPmIYWyxLxh4GpEYhn48jMWq2+cMQA2uwZCeeZlB3UiXVrQNQyQLiliwg==",
++ "version": "1.1.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.1.0",
++ "integrity": "sha512-bJsVl82hDivazoiM/np9ckKJE4V5F4OxyIiDwB6xuhK8M6P42sGU+V88rFCkyiI4BVQiKv9xs4GVx8Fj3pyjsg==",
"license": "MIT",
"engines": {
"node": ">=20"
ddiff --git a/package.json b/package.json
iindex f18b886..85bb230 100644
---- a/package.json
++++ b/package.json
@@@ -27,7 +27,7 @@
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
-- "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.0.0",
++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.1.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
"pg": "^8.23.0"
ddiff --git a/server/app.js b/server/app.js
iindex d38ce6b..1b801ac 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -17,12 +17,12 @@ const contracts = require('openvibe-contracts');
const { createSsoClient } = require('openvibe-sdk/sso');
const { jwksClient } = require('openvibe-sdk/auth');
++const { createServiceOutbox } = require('openvibe-sdk/events');
const { createIndexNow } = require('openvibe-shared/indexnow');
const configLib = require('./config');
const { openStore } = require('./db');
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');
@@@ -61,7 +61,13 @@ async function createApp(opts = {}) {
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test, a script) hands in a store.
const store = opts.store || await openStore(config, { now: opts.now, log });
-- const outbox = createCouponsOutbox({ db: store.db, config, fetchImpl, now: store.now, log });
++ // OpenVibe.Events through the shared openvibe-sdk transactional outbox (ADR-004, plan T1/T9): rows are
++ // written inside the change's own transaction; moderationAction never names an owner_subject.
++ const outbox = createServiceOutbox({
++ db: store.db, source: 'coupons', eventsUrl: config.events.url, networkInternalUrl: config.networkInternalUrl,
++ clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, intervalMs: config.events.intervalMs,
++ now: store.now, fetch: fetchImpl, log, moderationOwnerSubject: false,
++ });
const publication = createPublication({ store, config, outbox, indexnow });
const merchants = createMerchants({ store });
const coupons = createCoupons({ store, merchants, publication, outbox });
ddiff --git a/server/clients/sources.js b/server/clients/sources.js
iindex 6a564d2..cf7cd0f 100644
---- a/server/clients/sources.js
++++ b/server/clients/sources.js
@@@ -4,8 +4,12 @@
* 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]
++ * The generic half — the Sources HTTP client, the change cursor and the page/savepoint pull loop — is
++ * openvibe-publishing/ingest (plan T9): the client is created with the client-credentials token
++ * (audience openvibe.sources, scope sources.item.read), pullChanges reads the feed in change order
++ * from coupons_ingest_cursor (the old import_state cursor was copied there by migration 0002) and runs
++ * one transaction per page with one savepoint per item. What stays here is the coupons policy: what an
++ * item means for a code, the holds, and the run bookkeeping.
*
* For each item, in change order:
* removed its evidence is withdrawn; a code left with no evidence is disabled
@@@ -19,22 +23,27 @@
* 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 { createSourcesClient, createChangeCursor, pullChanges, hosts } = require('openvibe-publishing/ingest');
const STATE_KEY = 'sources:coupons';
++const CURSOR_NAME = 'sources';
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 source = createSourcesClient({
++ config: {
++ networkInternalUrl: config.networkInternalUrl,
++ oauth: { clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret },
++ sources: { internalUrl: config.sources.internalUrl, category: 'coupons' },
++ },
++ fetchImpl, now: store.now,
++ });
++ const enabled = source.enabled;
++ const cursor = createChangeCursor(db, { prefix: 'coupons', now: store.now });
const q = {
state: db.prepare('SELECT * FROM import_state WHERE key = ?'),
ensure: db.prepare('INSERT INTO import_state (key, cursor) VALUES (?, 0) ON CONFLICT DO NOTHING'),
-- cursor: db.prepare('UPDATE import_state SET cursor = ?, last_run_at = ?, last_ok_at = ?, last_error = NULL WHERE key = ?'),
++ ok: db.prepare('UPDATE import_state SET 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)
@@@ -61,43 +70,35 @@ function createSourcesImporter({ store, config, merchants, coupons, fetchImpl =
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>' */
-- async function handle(item) {
++ /** Handle one item on the caller's transaction/savepoint handle `t`. → 'imported' | 'updated' | 'withdrawn' | 'held:<reason>' */
++ async function handle(item, t) {
const now = store.now();
const held = async (reason, detail = null) => {
await 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 await store.tx(async () => {
-- if (item.removed) {
-- await coupons.withdrawSourceItem(item.id, item.removed.reason);
-- await q.resolve.run(now, now, item.id);
-- return 'withdrawn';
-- }
-- if (config.sources.keys.length && !config.sources.keys.includes(item.source_key)) return await held('not_selected', `source ${item.source_key} is not in COUPONS_SOURCES_KEYS`);
-- if (item.kind !== 'coupon') return await held('not_a_coupon', `kind ${item.kind}`);
-- if (!item.fields || typeof item.fields.code !== 'string' || !item.fields.code.trim()) return await held('no_code');
-- let parsed;
-- try { parsed = parseItem(item); } catch (err) {
-- return await held(err.code === 'coupon.already_expired' ? 'expired' : 'invalid', err.message);
-- }
-- const at = hosts.hostOfUrl(item.canonical_url);
-- const found = at ? await merchants.resolve(at.host, { path: at.path, includeStatuses: ['active', 'pending'] }) : null;
-- if (!found) return await held('no_merchant', at ? at.host : 'no canonical URL');
-- const r = await coupons.importFromSource(found.merchant, item, parsed, { autoPublish: config.sources.autoPublish });
++ if (item.removed) {
++ await coupons.withdrawSourceItem(item.id, item.removed.reason);
await q.resolve.run(now, now, item.id);
-- return r.created ? 'imported' : 'updated';
-- });
++ return 'withdrawn';
++ }
++ if (config.sources.keys.length && !config.sources.keys.includes(item.source_key)) return await held('not_selected', `source ${item.source_key} is not in COUPONS_SOURCES_KEYS`);
++ if (item.kind !== 'coupon') return await held('not_a_coupon', `kind ${item.kind}`);
++ if (!item.fields || typeof item.fields.code !== 'string' || !item.fields.code.trim()) return await held('no_code');
++ let parsed;
++ try { parsed = parseItem(item); } catch (err) {
++ return await held(err.code === 'coupon.already_expired' ? 'expired' : 'invalid', err.message);
++ }
++ const at = hosts.hostOfUrl(item.canonical_url);
++ const found = at ? await merchants.resolve(at.host, { path: at.path, includeStatuses: ['active', 'pending'] }) : null;
++ if (!found) return await held('no_merchant', at ? at.host : 'no canonical URL');
++ const r = await coupons.importFromSource(found.merchant, item, parsed, { autoPublish: config.sources.autoPublish });
++ await 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;
-- }
++ /** Coupons' outcome → the chassis' per-item contract. */
++ const chassisOutcome = (o) => (o === 'withdrawn' ? 'removed' : o.startsWith('held:') ? 'hold' : 'applied');
/** One import run. → { outcomes: {…}, cursor } or { error } */
async function run({ maxPages = 10 } = {}) {
@@@ -105,30 +106,25 @@ function createSourcesImporter({ store, config, merchants, coupons, fetchImpl =
if (!enabled) return { disabled: true };
const outcomes = {};
const count = (o) => { outcomes[o] = (outcomes[o] || 0) + 1; };
-- let cursor = (await 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(await handle(item));
-- cursor = page.next_after != null ? Number(page.next_after) : cursor;
-- await q.cursor.run(cursor, store.now(), store.now(), STATE_KEY);
-- if (!page.more) break;
-- }
-- for (const { item } of await q.retryable.all()) count(`retry:${await handle(JSON.parse(item))}`);
-- await q.cursor.run(cursor, store.now(), store.now(), STATE_KEY);
-- return { outcomes, cursor };
++ const r = await pullChanges({
++ db, cursor, source, name: CURSOR_NAME, maxPages, pageSize: 100,
++ apply: async (item, t) => { const o = await handle(item, t); count(o); return chassisOutcome(o); },
++ });
++ for (const { item } of await q.retryable.all()) count(`retry:${await store.tx(async (t) => await handle(JSON.parse(item), t))}`);
++ await q.ok.run(store.now(), store.now(), STATE_KEY);
++ return { outcomes, cursor: r.after };
} catch (err) {
await 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 { error: err.message, outcomes, cursor: await cursor.get(CURSOR_NAME) };
}
}
return {
enabled,
run,
-- handle,
-- state: async () => await q.state.get(STATE_KEY),
++ state: async () => ({ ...(await q.state.get(STATE_KEY) || { key: STATE_KEY }), cursor: await cursor.get(CURSOR_NAME) }),
holds: async () => await q.open.all(),
holdCount: async () => (await q.openCount.get()).n,
};
ddiff --git a/server/db.js b/server/db.js
iindex 5748c98..d8489e9 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -19,9 +19,9 @@
* 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).
++ * Also here: extension_installs (cpx_ install tokens, hashed), import_state (the importer's run
++ * bookkeeping) and coupon_import_holds, coupons_ingest_cursor (the openvibe-publishing/ingest change
++ * cursor, migration 0002), the SDK's event_outbox, and coupons_index_revisions (index sequencer).
*/
const fs = require('fs');
const path = require('path');
ddiff --git a/server/domain/coupons.js b/server/domain/coupons.js
iindex 8f7ded3..34054e9 100644
---- a/server/domain/coupons.js
++++ b/server/domain/coupons.js
@@@ -29,7 +29,7 @@ function publicReason(reason) {
const { ids } = require('openvibe-contracts');
const { ApiError } = require('../http/errors');
const confidence = require('./confidence');
--const hosts = require('./hosts');
++const { hosts } = require('openvibe-publishing/ingest');
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'`;
@@@ -412,7 +412,7 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
});
}
-- /** coupons.moderation.action for a staff or service action (never the submitter; see events/outbox.js). */
++ /** coupons.moderation.action for a staff or service action (never the submitter; see the outbox in server/app.js). */
async function moderated(action, c, actor, { reason = null, details = {}, traceparent } = {}) {
if (!outbox) return null;
const person = /^usr_/.test(String(actor || '')) ? actor : null;
ddiff --git a/server/domain/hosts.js b/server/domain/hosts.js
ddeleted file mode 100644
iindex 1e3c84c..0000000
---- a/server/domain/hosts.js
++++ /dev/null
@@@ -1,139 +0,0 @@
--'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 };
ddiff --git a/server/domain/merchants.js b/server/domain/merchants.js
iindex 081261d..06c51fb 100644
---- a/server/domain/merchants.js
++++ b/server/domain/merchants.js
@@@ -12,7 +12,7 @@
* merchants resolve publicly.
*/
const { ids } = require('openvibe-contracts');
--const hosts = require('./hosts');
++const { hosts } = require('openvibe-publishing/ingest');
const { ApiError } = require('../http/errors');
const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
ddiff --git a/server/domain/psl.js b/server/domain/psl.js
ddeleted file mode 100644
iindex da5ea16..0000000
---- a/server/domain/psl.js
++++ /dev/null
@@@ -1,100 +0,0 @@
--'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 };
ddiff --git a/server/domain/publication.js b/server/domain/publication.js
iindex 49216e2..c5bee13 100644
---- a/server/domain/publication.js
++++ b/server/domain/publication.js
@@@ -4,6 +4,13 @@
* 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.
*
++ * The mechanics — the sequencer revision, the Search event, the outbox write and the IndexNow ping —
++ * are openvibe-publishing/publication (plan T9): createPublication stamps and enqueues on the caller's
++ * transaction, sends nothing for an unchanged document, sends no tombstone for a resource that was
++ * never indexed, and pings IndexNow only for an indexable page that appeared or changed, or a page
++ * Search already had that went away. What stays here is the coupons policy: the gate facts, the
++ * document bodies and the product's own event payloads.
++ *
* /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
@@@ -15,6 +22,7 @@
*/
const seo = require('openvibe-publishing/seo');
const hooks = require('openvibe-publishing/index-hooks');
++const { createPublication: createChassisPublication } = require('openvibe-publishing/publication');
const { isActive } = require('./confidence');
const ACTOR = Object.freeze({ type: 'service', id: 'coupons' });
@@@ -22,6 +30,9 @@ const COUPON_POLICY = { minWords: 0 };
const MERCHANT_POLICY = { minWords: 1 };
function createPublication({ store, config, outbox, indexnow = null }) {
++ const chassis = createChassisPublication({
++ owner: 'coupons', sequencer: store.sequencer, outbox, baseUrl: config.baseUrl, indexnow, now: store.now,
++ });
const abs = (p) => seo.canonicalUrl(config.baseUrl, p);
const merchantPath = (m) => `/m/${m.slug}`;
const couponPath = (c) => `/c/${c.id}`;
@@@ -90,21 +101,9 @@ function createPublication({ store, config, outbox, indexnow = null }) {
});
}
-- /** Stamp and enqueue a Search document (inside the caller's transaction). `page` is the page's path. */
++ /** Stamp and enqueue a Search document on the caller's transaction (inside the change's transaction). */
async function sendDocument(doc, { traceparent, page } = {}) {
-- if (doc.deleted && await store.sequencer.current(doc.owner, doc.type, doc.id) == null) return null; // never indexed
-- const current = await store.sequencer.current(doc.owner, doc.type, doc.id);
-- const stamped = await store.sequencer.stamp(store.db, doc);
-- if (current === stamped.revision) return null; // the same document again: nothing to send
-- const sent = await outbox.emit(hooks.indexEvent({ document: stamped, now: store.now() }), { traceparent });
-- // IndexNow: an indexable page appeared or changed, or a page Search already had disappeared
-- // (a tombstone). A draft, private or noindex page never pings. pingSoon never throws and is a
-- // no-op without a key, so it can never take a publish down.
-- if (indexnow && indexnow.enabled && page) {
-- const indexable = !stamped.deleted && stamped.indexability && stamped.indexability.decision === 'index';
-- if (indexable || stamped.deleted) indexnow.pingSoon([abs(page), abs('/sitemap.xml')]);
-- }
-- return sent;
++ return await chassis.index(store.db, { document: doc, page, traceparent });
}
/**
ddiff --git a/server/events/outbox.js b/server/events/outbox.js
ddeleted file mode 100644
iindex dd7273b..0000000
---- a/server/events/outbox.js
++++ /dev/null
@@@ -1,28 +0,0 @@
--'use strict';
--
--/**
-- * Coupons → OpenVibe.Events through the shared openvibe-sdk transactional outbox (ADR-004,
-- * openvibe-sdk/events createServiceOutbox; plan T1). The table is event_outbox, the relay is off
-- * without EVENTS_URL or OV_OAUTH_CLIENT_SECRET, and status() reports pending/rejected rows for
-- * /api/ready. moderationAction never names an owner_subject (moderationOwnerSubject: false), as
-- * Coupons' audit event never did.
-- */
--const { createServiceOutbox } = require('openvibe-sdk/events');
--
--function createCouponsOutbox({ db, config, fetchImpl, now, log = console }) {
-- return createServiceOutbox({
-- db,
-- source: 'coupons',
-- eventsUrl: config.events.url,
-- networkInternalUrl: config.networkInternalUrl,
-- clientId: config.oauth.clientId,
-- clientSecret: config.oauth.clientSecret,
-- intervalMs: config.events.intervalMs,
-- now,
-- fetch: fetchImpl,
-- log,
-- moderationOwnerSubject: false,
-- });
--}
--
--module.exports = { createCouponsOutbox };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 29a5354..0ddd84d 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -29,7 +29,7 @@ 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 { hosts } = require('openvibe-publishing/ingest');
const MOZ_EXTENSION_RE = /^moz-extension:\/\/[0-9a-f-]{36}$/;
ddiff --git a/test/chassis.test.js b/test/chassis.test.js
nnew file mode 100644
iindex 0000000..5aff28a
---- /dev/null
++++ b/test/chassis.test.js
@@@ -0,0 +1,127 @@
++'use strict';
++/**
++ * Plan T9 (J5) — Coupons on the openvibe-publishing 1.1.0 chassis.
++ *
++ * - the local copies the brief deletes are unrequired: nothing under server/ requires ./hosts, ./psl
++ * or ./events/outbox any more (the files themselves cannot be removed in this harness);
++ * - a dry-run ingest from the captured Sources fixture (test/fixtures/sources-coupons.json, captured
++ * from the pre-chassis importer before the change) reproduces the same domain rows;
++ * - the Search document is a valid search.index-document@1;
++ * - the outbox row and the state change share the caller's transaction (a rollback leaves neither);
++ * - a cursor already in the old import_state key is copied once into the chassis cursor.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const contracts = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/boot');
++
++const ROOT = path.join(__dirname, '..');
++const FIXTURE = JSON.parse(fs.readFileSync(path.join(__dirname, 'fixtures', 'sources-coupons.json'), 'utf8'));
++const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
++
++/** The domain rows, projected the same way the golden was captured (ids and ULIDs excluded). */
++async function rowsOf(t) {
++ const merchants = await t.ctx.store.db.prepare('SELECT id, slug, name, status FROM coupon_merchants ORDER BY slug').all();
++ const slugOf = new Map(merchants.map((m) => [m.id, m.slug]));
++ const coupons = await t.ctx.store.db.prepare('SELECT * FROM coupons ORDER BY code').all();
++ const out = { merchants: merchants.map((m) => ({ slug: m.slug, name: m.name, status: m.status })), coupons: [] };
++ for (const c of coupons) {
++ const evidence = await t.ctx.store.db.prepare('SELECT kind, evidence_url, merchant_evidence, sources_item_id, sources_item_rev, source_key, retrieved_at, removed_at, removed_reason FROM coupon_sources WHERE coupon_id = ? ORDER BY id').all(c.id);
++ const restrictions = await t.ctx.store.db.prepare('SELECT kind, value, amount_minor, currency FROM coupon_restrictions WHERE coupon_id = ? ORDER BY id').all(c.id);
++ out.coupons.push({
++ merchant: slugOf.get(c.merchant_id), code: c.code, code_key: c.code_key, title: c.title, description: c.description,
++ status: c.status, status_reason: c.status_reason, confidence: c.confidence, review_state: c.review_state, origin: c.origin,
++ expires_at: c.expires_at, expires_precision: c.expires_precision, expiry_basis: c.expiry_basis, expired_at: c.expired_at,
++ disabled_at: c.disabled_at, created_by: c.created_by, created_at: c.created_at, updated_at: c.updated_at, evidence, restrictions,
++ });
++ }
++ return out;
++}
++
++(async () => {
++ await check('the local chassis copies are unrequired; the chassis provides hosts and publication', async () => {
++ const files = [];
++ (function walk(dir) { for (const e of fs.readdirSync(dir, { withFileTypes: true })) { const p = path.join(dir, e.name); if (e.isDirectory()) walk(p); else if (e.name.endsWith('.js')) files.push(p); } })(path.join(ROOT, 'server'));
++ const dead = new Set([path.join(ROOT, 'server/domain/hosts.js'), path.join(ROOT, 'server/domain/psl.js')]);
++ const needles = ["'./hosts'", "'../domain/hosts'", "'./psl'", "'./events/outbox'", "'../events/outbox'"];
++ const hits = [];
++ for (const f of files) {
++ if (dead.has(f)) continue;
++ const src = fs.readFileSync(f, 'utf8');
++ if (needles.some((n) => src.includes(n))) hits.push(path.relative(ROOT, f));
++ }
++ assert.deepStrictEqual(hits, [], `still required by: ${hits.join(', ')}`);
++ assert.strictEqual(typeof require('openvibe-publishing/ingest').hosts.normalizeHost, 'function');
++ assert.strictEqual(typeof require('openvibe-publishing/publication').createPublication, 'function');
++ });
++
++ await check('a dry-run ingest from the captured fixture reproduces the pre-chassis domain rows', async () => {
++ const t = await boot();
++ try {
++ await t.merchant({ name: 'River Books', host: 'riverbooks.com' });
++ for (const item of FIXTURE.items) t.sources.put({ ...item });
++ const r = await t.ctx.importer.run();
++ assert.deepStrictEqual(r.outcomes, FIXTURE.golden.outcomes);
++ assert.deepStrictEqual(await rowsOf(t), { merchants: FIXTURE.golden.merchants, coupons: FIXTURE.golden.coupons });
++ const row = await t.ctx.store.db.prepare("SELECT cursor FROM coupons_ingest_cursor WHERE name = 'sources'").get();
++ assert.ok(row && row.cursor >= 1, 'the chassis cursor advanced');
++ } finally { await t.close(); }
++ });
++
++ await check('the Search document is a valid search.index-document@1 (coupon and merchant)', async () => {
++ const t = await boot();
++ try {
++ const shop = await t.merchant({ name: 'Doc Shop', host: 'docshop.com' });
++ const alice = t.network.addUser('alice');
++ const r = await t.submit(alice, { merchant_id: shop.id, code: 'DOC10', title: 'Ten off' });
++ assert.strictEqual(r.status, 201, r.text.slice(0, 300));
++ const upserts = await t.events('coupons.index_document.upserted');
++ const types = new Set();
++ for (const env of upserts) {
++ const v = contracts.validate('search.index-document@1', env.payload);
++ assert.ok(v.valid, `${env.event_type}: ${JSON.stringify(v.errors)}`);
++ assert.strictEqual(env.payload.owner, 'coupons');
++ types.add(env.payload.type);
++ }
++ assert.ok(types.has('coupon') && types.has('merchant'), `indexed types: ${[...types]}`);
++ } finally { await t.close(); }
++ });
++
++ await check('the outbox row and the index revision share the change\'s transaction (a rollback leaves neither)', async () => {
++ const t = await boot();
++ try {
++ const shop = await t.merchant({ name: 'Tx Shop', host: 'txshop.com' });
++ const alice = t.network.addUser('alice');
++ await t.submit(alice, { merchant_id: shop.id, code: 'TX10', title: 'Ten off' });
++ const coupon = await t.ctx.store.db.prepare("SELECT * FROM coupons WHERE code = 'TX10'").get();
++ const merchant = await t.ctx.store.db.prepare('SELECT * FROM coupon_merchants WHERE id = ?').get(shop.id);
++ const outboxBefore = (await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM event_outbox').get()).n;
++ const revBefore = (await t.ctx.store.db.prepare("SELECT revision FROM coupons_index_revisions WHERE owner = 'coupons' AND type = 'coupon' AND id = ?").get(coupon.id)).revision;
++ const doc = { ...t.ctx.publication.couponDocument(coupon, merchant), title: 'changed for the rollback test' };
++ await assert.rejects(t.ctx.store.tx(async () => {
++ await t.ctx.publication.sendDocument(doc, { page: t.ctx.publication.couponPath(coupon) });
++ throw new Error('change failed');
++ }), /change failed/);
++ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM event_outbox').get()).n, outboxBefore, 'no outbox row survives the rollback');
++ assert.strictEqual((await t.ctx.store.db.prepare("SELECT revision FROM coupons_index_revisions WHERE owner = 'coupons' AND type = 'coupon' AND id = ?").get(coupon.id)).revision, revBefore, 'no index revision survives the rollback');
++ } finally { await t.close(); }
++ });
++
++ await check('a cursor already stored under the old key is copied once into the chassis cursor (migration 0002)', async () => {
++ const t = await boot();
++ try {
++ await t.ctx.store.db.prepare("INSERT INTO import_state (key, cursor, last_run_at) VALUES ('sources:coupons', 42, 7) ON CONFLICT (key) DO UPDATE SET cursor = 42").run();
++ const sql = read('migrations/0002_ingest_cursor.sql');
++ assert.match(sql, /CREATE TABLE IF NOT EXISTS coupons_ingest_cursor/);
++ // The runtime role may not create schema objects, so run only the copy statement (the DDL
++ // already ran with the migrations); this is the continuity read, exactly as written.
++ const copy = sql.slice(sql.indexOf('INSERT INTO coupons_ingest_cursor')).split(';')[0];
++ await t.ctx.store.db.query(copy);
++ const row = await t.ctx.store.db.prepare("SELECT cursor FROM coupons_ingest_cursor WHERE name = 'sources'").get();
++ assert.strictEqual(row.cursor, 42);
++ } finally { await t.close(); }
++ });
++
++ done();
++})();
ddiff --git a/test/domains.test.js b/test/domains.test.js
iindex a476d3e..d5e5e40 100644
---- a/test/domains.test.js
++++ b/test/domains.test.js
@@@ -5,8 +5,7 @@
* 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 { hosts } = require('openvibe-publishing/ingest');
const { boot, check, done } = require('./helpers/boot');
(async () => {
@@@ -44,7 +43,7 @@ const { boot, check, done } = require('./helpers/boot');
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');
++ assert.strictEqual(hosts.publicSuffix('www.example-shop.co.uk'), 'co.uk');
});
await check('checkRule refuses a rule on a public suffix and normalizes path prefixes', async () => {
ddiff --git a/test/fixtures/sources-coupons.json b/test/fixtures/sources-coupons.json
nnew file mode 100644
iindex 0000000..561232b
---- /dev/null
++++ b/test/fixtures/sources-coupons.json
@@@ -0,0 +1,100 @@
++{
++ "note": "Captured from the pre-chassis importer (server/clients/sources.js, before plan T9): the Sources items a dry run ingested and the domain rows it produced. test/chassis.test.js replays the items through the chassis importer and must reproduce `golden` exactly. Ids (ULID) are excluded; rows are keyed by code/slug.",
++ "items": [
++ { "id": "itm_01K0000000000000000000000A", "source_key": "staff-coupon-codes", "identity": "https://riverbooks.com/promo#itm_01K0000000000000000000000A", "canonical_url": "https://riverbooks.com/promo", "title": "Autumn reading sale", "fields": { "code": "READ20", "expires": null } },
++ { "id": "itm_01K0000000000000000000000B", "source_key": "staff-coupon-codes", "identity": "https://riverbooks.com/promo#itm_01K0000000000000000000000B", "canonical_url": "https://riverbooks.com/ship", "title": "Free shipping", "fields": { "code": "SHIP10", "expires": "2026-12-31", "min_spend": 25, "currency": "EUR", "new_customers_only": true, "region": "DE", "category": "books" } },
++ { "id": "itm_01K0000000000000000000000H", "source_key": "staff-coupon-codes", "identity": "https://riverbooks.com/promo#itm_01K0000000000000000000000H", "canonical_url": "https://unknown-shop.com/x", "title": "Autumn reading sale", "fields": { "code": "NOPE" } },
++ { "id": "itm_01K0000000000000000000000R", "source_key": "staff-coupon-codes", "identity": "https://riverbooks.com/promo#itm_01K0000000000000000000000R", "canonical_url": "https://riverbooks.com/promo", "title": "Autumn reading sale", "kind": "record" },
++ { "id": "itm_01K0000000000000000000000N", "source_key": "staff-coupon-codes", "identity": "https://riverbooks.com/promo#itm_01K0000000000000000000000N", "canonical_url": "https://riverbooks.com/promo", "title": "Autumn reading sale", "fields": { "expires": "2026-12-01" } },
++ { "id": "itm_01K0000000000000000000000E", "source_key": "staff-coupon-codes", "identity": "https://riverbooks.com/promo#itm_01K0000000000000000000000E", "canonical_url": "https://riverbooks.com/promo", "title": "Autumn reading sale", "fields": { "code": "OLD", "expires": "2026-01-01" } },
++ { "id": "itm_01K0000000000000000000000A", "source_key": "staff-coupon-codes", "identity": "https://riverbooks.com/promo#itm_01K0000000000000000000000A", "canonical_url": "https://riverbooks.com/promo", "title": "Autumn reading sale", "revision": 2, "fields": { "code": "READ20", "expires": "2026-12-31" } }
++ ],
++ "golden": {
++ "outcomes": {
++ "imported": 2,
++ "held:no_merchant": 1,
++ "held:not_a_coupon": 1,
++ "held:no_code": 1,
++ "held:expired": 1,
++ "retry:held:no_merchant": 1
++ },
++ "merchants": [
++ { "slug": "river-books", "name": "River Books", "status": "active" }
++ ],
++ "coupons": [
++ {
++ "merchant": "river-books",
++ "code": "READ20",
++ "code_key": "READ20",
++ "title": "Autumn reading sale",
++ "description": null,
++ "status": "unknown",
++ "status_reason": null,
++ "confidence": null,
++ "review_state": "pending",
++ "origin": "source",
++ "expires_at": 1798761599999,
++ "expires_precision": "date",
++ "expiry_basis": "source",
++ "expired_at": null,
++ "disabled_at": null,
++ "created_by": "svc:sources",
++ "created_at": 1790078400000,
++ "updated_at": 1790078400000,
++ "evidence": [
++ {
++ "kind": "source",
++ "evidence_url": "https://riverbooks.com/promo",
++ "merchant_evidence": 1,
++ "sources_item_id": "itm_01K0000000000000000000000A",
++ "sources_item_rev": 2,
++ "source_key": "staff-coupon-codes",
++ "retrieved_at": 1790071200000,
++ "removed_at": null,
++ "removed_reason": null
++ }
++ ],
++ "restrictions": []
++ },
++ {
++ "merchant": "river-books",
++ "code": "SHIP10",
++ "code_key": "SHIP10",
++ "title": "Free shipping",
++ "description": null,
++ "status": "unknown",
++ "status_reason": null,
++ "confidence": null,
++ "review_state": "pending",
++ "origin": "source",
++ "expires_at": 1798761599999,
++ "expires_precision": "date",
++ "expiry_basis": "source",
++ "expired_at": null,
++ "disabled_at": null,
++ "created_by": "svc:sources",
++ "created_at": 1790078400000,
++ "updated_at": 1790078400000,
++ "evidence": [
++ {
++ "kind": "source",
++ "evidence_url": "https://riverbooks.com/ship",
++ "merchant_evidence": 1,
++ "sources_item_id": "itm_01K0000000000000000000000B",
++ "sources_item_rev": 1,
++ "source_key": "staff-coupon-codes",
++ "retrieved_at": 1790071200000,
++ "removed_at": null,
++ "removed_reason": null
++ }
++ ],
++ "restrictions": [
++ { "kind": "min_spend", "value": null, "amount_minor": 2500, "currency": "EUR" },
++ { "kind": "category", "value": "books", "amount_minor": null, "currency": null },
++ { "kind": "new_customers_only", "value": null, "amount_minor": null, "currency": null },
++ { "kind": "region", "value": "DE", "amount_minor": null, "currency": null }
++ ]
++ }
++ ]
++ }
++}