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).
Changes
@@@ -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)"]nnew file mode 100644@@@ -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;@@@ -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"@@@ -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"@@@ -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 });@@@ -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,};@@@ -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');@@@ -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;ddeleted file mode 100644@@@ -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 };@@@ -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])?$/;ddeleted file mode 100644@@@ -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 };@@@ -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 });}/**ddeleted file mode 100644@@@ -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 };@@@ -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}$/;nnew file mode 100644@@@ -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();++})();@@@ -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 () => {nnew file mode 100644@@@ -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 }++ ]++ }++ ]++ }++}
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
"domain": "openvibe.coupons",
"domain": "openvibe.coupons",
"contracts": "openvibe-contracts v0.79.0",
"contracts": "openvibe-contracts v0.79.0",
"packages": [
"packages": [
- "openvibe-publishing v1.0.0",
+ "openvibe-publishing v1.1.0",
"openvibe-contracts v0.79.0",
"openvibe-contracts v0.79.0",
"openvibe-shared v2.2.0",
"openvibe-shared v2.2.0",
"openvibe-sdk v0.25.1"
"openvibe-sdk v0.25.1"
@@ -42,7 +42,7 @@
@@ -42,7 +42,7 @@
"expired codes leave API, pages, feeds, sitemaps and Search at the instant of expiry",
"expired codes leave API, pages, feeds, sitemaps and Search at the instant of expiry",
"server-rendered pages useful without JavaScript",
"server-rendered pages useful without JavaScript",
"/api/v1 by capability and browser-helper install tokens",
"/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",
"SDK outbox events and Search index documents",
"per-actor limits (openvibe-sdk/limits)"
"per-actor limits (openvibe-sdk/limits)"
]
]
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -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;
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1163,9 +1163,9 @@
@@ -1163,9 +1163,9 @@
}
}
},
},
"node_modules/openvibe-publishing": {
"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",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -27,7 +27,7 @@
@@ -27,7 +27,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -17,12 +17,12 @@ const contracts = require('openvibe-contracts');
@@ -17,12 +17,12 @@ const contracts = require('openvibe-contracts');
const { createSsoClient } = require('openvibe-sdk/sso');
const { createSsoClient } = require('openvibe-sdk/sso');
const { jwksClient } = require('openvibe-sdk/auth');
const { jwksClient } = require('openvibe-sdk/auth');
+const { createServiceOutbox } = require('openvibe-sdk/events');
const { createIndexNow } = require('openvibe-shared/indexnow');
const { createIndexNow } = require('openvibe-shared/indexnow');
const configLib = require('./config');
const configLib = require('./config');
const { openStore } = require('./db');
const { openStore } = require('./db');
const { createViewerResolver } = require('./auth/viewer');
const { createViewerResolver } = require('./auth/viewer');
-const { createCouponsOutbox } = require('./events/outbox');
const { createPublication } = require('./domain/publication');
const { createPublication } = require('./domain/publication');
const { createMerchants } = require('./domain/merchants');
const { createMerchants } = require('./domain/merchants');
const { createCoupons } = require('./domain/coupons');
const { createCoupons } = require('./domain/coupons');
@@ -61,7 +61,13 @@ async function createApp(opts = {}) {
@@ -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.
// 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 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 publication = createPublication({ store, config, outbox, indexnow });
const merchants = createMerchants({ store });
const merchants = createMerchants({ store });
const coupons = createCoupons({ store, merchants, publication, outbox });
const coupons = createCoupons({ store, merchants, publication, outbox });
@@ -4,8 +4,12 @@
@@ -4,8 +4,12 @@
* Import from OpenVibe.Sources, category `coupons` (the seeded source is `staff-coupon-codes`, a
* 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).
* 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:
* For each item, in change order:
* removed its evidence is withdrawn; a code left with no evidence is disabled
* removed its evidence is withdrawn; a code left with no evidence is disabled
@@ -19,22 +23,27 @@
@@ -19,22 +23,27 @@
* Imported codes wait for staff review unless COUPONS_SOURCES_AUTO_PUBLISH=true.
* 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.
* 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 STATE_KEY = 'sources:coupons';
+const CURSOR_NAME = 'sources';
function createSourcesImporter({ store, config, merchants, coupons, fetchImpl = globalThis.fetch, log = console }) {
function createSourcesImporter({ store, config, merchants, coupons, fetchImpl = globalThis.fetch, log = console }) {
const { db } = store;
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 = {
const q = {
state: db.prepare('SELECT * FROM import_state WHERE key = ?'),
state: db.prepare('SELECT * FROM import_state WHERE key = ?'),
ensure: db.prepare('INSERT INTO import_state (key, cursor) VALUES (?, 0) ON CONFLICT DO NOTHING'),
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 = ?'),
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)
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)
VALUES (@item_id, @source_key, @reason, @detail, @item, 1, @now, @now)
@@ -61,43 +70,35 @@ function createSourcesImporter({ store, config, merchants, coupons, fetchImpl =
@@ -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());
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 now = store.now();
const held = async (reason, detail = null) => {
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 });
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 `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);
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 } */
/** One import run. → { outcomes: {…}, cursor } or { error } */
async function run({ maxPages = 10 } = {}) {
async function run({ maxPages = 10 } = {}) {
@@ -105,30 +106,25 @@ function createSourcesImporter({ store, config, merchants, coupons, fetchImpl =
@@ -105,30 +106,25 @@ function createSourcesImporter({ store, config, merchants, coupons, fetchImpl =
if (!enabled) return { disabled: true };
if (!enabled) return { disabled: true };
const outcomes = {};
const outcomes = {};
const count = (o) => { outcomes[o] = (outcomes[o] || 0) + 1; };
const count = (o) => { outcomes[o] = (outcomes[o] || 0) + 1; };
- let cursor = (await q.state.get(STATE_KEY)).cursor;
try {
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) {
} catch (err) {
await q.failed.run(store.now(), String(err.message).slice(0, 300), STATE_KEY);
await q.failed.run(store.now(), String(err.message).slice(0, 300), STATE_KEY);
log.warn('[Coupons] Sources import failed (nothing changed):', err.message);
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 {
return {
enabled,
enabled,
run,
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(),
holds: async () => await q.open.all(),
holdCount: async () => (await q.openCount.get()).n,
holdCount: async () => (await q.openCount.get()).n,
};
};
@@ -19,9 +19,9 @@
@@ -19,9 +19,9 @@
* coupon_application_hints where and how to apply a code, per merchant or per code
* coupon_application_hints where and how to apply a code, per merchant or per code
* coupon_watches members watching a merchant
* 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 fs = require('fs');
const path = require('path');
const path = require('path');
@@ -29,7 +29,7 @@ function publicReason(reason) {
@@ -29,7 +29,7 @@ function publicReason(reason) {
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
const { ApiError } = require('../http/errors');
const { ApiError } = require('../http/errors');
const confidence = require('./confidence');
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')
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'`;
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 }) {
@@ -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 } = {}) {
async function moderated(action, c, actor, { reason = null, details = {}, traceparent } = {}) {
if (!outbox) return null;
if (!outbox) return null;
const person = /^usr_/.test(String(actor || '')) ? actor : null;
const person = /^usr_/.test(String(actor || '')) ? actor : null;
deleted file mode 100644
deleted file mode 100644
@@ -1,139 +0,0 @@
@@ -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 };
@@ -12,7 +12,7 @@
@@ -12,7 +12,7 @@
* merchants resolve publicly.
* merchants resolve publicly.
*/
*/
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
-const hosts = require('./hosts');
+const { hosts } = require('openvibe-publishing/ingest');
const { ApiError } = require('../http/errors');
const { ApiError } = require('../http/errors');
const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
deleted file mode 100644
deleted file mode 100644
@@ -1,100 +0,0 @@
@@ -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 };
@@ -4,6 +4,13 @@
@@ -4,6 +4,13 @@
* What the outside world sees of a merchant or a code: canonical URLs, the indexability gate's
* 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.
* 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
* /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
* code; otherwise noindex ("thin": no active codes) — still served
* /c/:id code page indexable while the code is active; an expired code is noindex
* /c/:id code page indexable while the code is active; an expired code is noindex
@@ -15,6 +22,7 @@
@@ -15,6 +22,7 @@
*/
*/
const seo = require('openvibe-publishing/seo');
const seo = require('openvibe-publishing/seo');
const hooks = require('openvibe-publishing/index-hooks');
const hooks = require('openvibe-publishing/index-hooks');
+const { createPublication: createChassisPublication } = require('openvibe-publishing/publication');
const { isActive } = require('./confidence');
const { isActive } = require('./confidence');
const ACTOR = Object.freeze({ type: 'service', id: 'coupons' });
const ACTOR = Object.freeze({ type: 'service', id: 'coupons' });
@@ -22,6 +30,9 @@ const COUPON_POLICY = { minWords: 0 };
@@ -22,6 +30,9 @@ const COUPON_POLICY = { minWords: 0 };
const MERCHANT_POLICY = { minWords: 1 };
const MERCHANT_POLICY = { minWords: 1 };
function createPublication({ store, config, outbox, indexnow = null }) {
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 abs = (p) => seo.canonicalUrl(config.baseUrl, p);
const merchantPath = (m) => `/m/${m.slug}`;
const merchantPath = (m) => `/m/${m.slug}`;
const couponPath = (c) => `/c/${c.id}`;
const couponPath = (c) => `/c/${c.id}`;
@@ -90,21 +101,9 @@ function createPublication({ store, config, outbox, indexnow = null }) {
@@ -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 } = {}) {
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 });
}
}
/**
/**
deleted file mode 100644
deleted file mode 100644
@@ -1,28 +0,0 @@
@@ -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 };
@@ -29,7 +29,7 @@ const contracts = require('openvibe-contracts');
@@ -29,7 +29,7 @@ const contracts = require('openvibe-contracts');
const { guard } = require('../auth/viewer');
const { guard } = require('../auth/viewer');
const { CAPABILITIES, SCOPES } = require('../auth/capabilities');
const { CAPABILITIES, SCOPES } = require('../auth/capabilities');
const { ApiError, run, jsonBody } = require('./errors');
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}$/;
const MOZ_EXTENSION_RE = /^moz-extension:\/\/[0-9a-f-]{36}$/;
new file mode 100644
new file mode 100644
@@ -0,0 +1,127 @@
@@ -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();
+})();
@@ -5,8 +5,7 @@
@@ -5,8 +5,7 @@
* registrable domains) — and the same through the resolve API.
* registrable domains) — and the same through the resolve API.
*/
*/
const assert = require('assert');
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');
const { boot, check, done } = require('./helpers/boot');
(async () => {
(async () => {
@@ -44,7 +43,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -44,7 +43,7 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual(hosts.registrable('co.uk'), null);
assert.strictEqual(hosts.registrable('co.uk'), null);
assert.strictEqual(hosts.registrable('myshopify.com'), null);
assert.strictEqual(hosts.registrable('myshopify.com'), null);
assert.strictEqual(hosts.registrable('bar.ck'), 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 () => {
await check('checkRule refuses a rule on a public suffix and normalizes path prefixes', async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -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 }
+ ]
+ }
+ ]
+ }
+}