openvibers/openvibe.coupons Commits
Coupons tells search engines when a page changes (plan T11, IndexNow): with INDEXNOW_KEY set the key file is served at /<key>.txt and an indexable page appearing, changing or going away pings the engines (its path and the sitemap); drafts, private and noindex pages never; unset, nothing is mounted or sent. A home-page performance budget runs in CI where there was none. Tests green (verified).
Changes
@@@ -15,6 +15,7 @@ TRUST_PROXY=2# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh coupons: the runtime role through PgBouncer,# the owner on a direct connection (boot migrations), and Valkey (shared limit counters).# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.++# COUPONS_PGLITE_DIR (tests only) puts that embedded database in a directory of its own.DATABASE_URL=DATABASE_DIRECT_URL=VALKEY_URL=@@@ -79,6 +80,10 @@ OV_OAUTH_CLIENT_SECRET=#COUPONS_WORKER=on#COUPONS_SWEEP_INTERVAL_MS=60000++# IndexNow: key file served at /<key>.txt; a publish/update/removal pings api.indexnow.org.++# Unset: off — no key file, no requests. Under a restore drill leave it unset/empty.++#INDEXNOW_KEY=++# release.json (openvibe-shared/release): the deployed commit when git is not available#RELEASE_COMMIT=#MIN_CLIENT_RELEASE=@@@ -448,6 +448,7 @@ First install (done once; kept for a rebuild):- `COUPONS_STAFF_SUBJECTS` (optional)- `EVENTS_URL=http://127.0.0.1:4300`- `BASE_URL=https://openvibe.coupons`++ - `INDEXNOW_KEY` (optional; enables IndexNow — the key file at `/<key>.txt` and a ping when an indexable page changes. Unset: off, and drills leave it unset)- Optionally set `OV_SOURCES_INTERNAL_URL=http://127.0.0.1:4720`.- Once the extension has a store id, set `COUPONS_EXTENSION_ORIGINS`.3. **systemd:** install `deploy/systemd/openvibe-coupons.service` (port 4850).@@@ -17,6 +17,7 @@ const contracts = require('openvibe-contracts');const { createSsoClient } = require('openvibe-sdk/sso');const { jwksClient } = require('openvibe-sdk/auth');++const { createIndexNow } = require('openvibe-shared/indexnow');const configLib = require('./config');const { openStore } = require('./db');@@@ -49,11 +50,19 @@ async function createApp(opts = {}) {const config = opts.config || configLib.load();const log = opts.log || console;const fetchImpl = opts.fetchImpl || globalThis.fetch;++ // IndexNow (openvibe-shared/indexnow): created once at boot. No INDEXNOW_KEY → off, nothing mounted,++ // nothing sent. Tests inject a spy; the pings themselves are queued and never fatal.++ const indexnow = opts.indexnow !== undefined ? opts.indexnow : createIndexNow({++ host: config.baseUrl,++ key: config.indexnowKey,++ fetch: fetchImpl,++ log: (...args) => log.warn(...args),++ });// 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 });-- const publication = createPublication({ store, config, outbox });++ const publication = createPublication({ store, config, outbox, indexnow });const merchants = createMerchants({ store });const coupons = createCoupons({ store, merchants, publication, outbox });const reports = createReports({ store, config, coupons, merchants, publication });@@@ -82,7 +91,7 @@ async function createApp(opts = {}) {const viewers = createViewerResolver({ auth, jwks, config, installs });const worker = createWorker({ config, coupons, importer, outbox, log });-- const ctx = { config, store, outbox, publication, merchants, coupons, reports, installs, watches, moderation, importer, auth, jwks, viewers, worker };++ const ctx = { config, store, outbox, publication, merchants, coupons, reports, installs, watches, moderation, importer, auth, jwks, viewers, worker, indexnow };const app = express();app.disable('x-powered-by');@@@ -131,6 +140,8 @@ async function createApp(opts = {}) {release.mount(app, { registry: metrics.registry });const readiness = createCouponsReadiness({ store, jwks, outbox, worker, importer, reports, config, release: release.release, valkey: ctx.valkey });app.get('/api/ready', readiness.handler);++ // GET /<key>.txt — the IndexNow key file, mounted only when a key is configured (nothing else).++ if (indexnow.enabled) app.get(`/${config.indexnowKey}.txt`, indexnow.keyFile);// ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, standardHeaders: true, legacyHeaders: false }));@@@ -36,7 +36,8 @@ function load(env = process.env) {},// PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role).-- db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '' },++ // COUPONS_PGLITE_DIR (tests only) puts the embedded development database in its own directory.++ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.COUPONS_PGLITE_DIR || '' },valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:coupons:' },// OpenVibe.Network: SSO (OAuth2 authorization server), JWKS, client-credentials tokens.@@@ -103,6 +104,10 @@ function load(env = process.env) {enabled: env.COUPONS_WORKER !== 'off',intervalMs: int(env.COUPONS_SWEEP_INTERVAL_MS, 60 * 1000),},++++ // IndexNow (openvibe-shared/indexnow): a key makes search engines recrawl a page the moment it++ // appears, changes or leaves the index. Unset: IndexNow is off — no key file, no requests.++ indexnowKey: String(env.INDEXNOW_KEY || '').trim(),};}@@@ -40,9 +40,10 @@ const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');async function openDb(config, { log = console, registry } = {}) {if (!config.db.url) {if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh coupons)');-- log.warn(`[Coupons] DATABASE_URL unset: embedded PGlite database in ${DEV_PGLITE} (development only, one process)`);-- fs.mkdirSync(DEV_PGLITE, { recursive: true });-- const db = createDb({ pglite: DEV_PGLITE, service: 'coupons', registry, log });++ const dir = config.db.pgliteDir || DEV_PGLITE;++ log.warn(`[Coupons] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);++ fs.mkdirSync(dir, { recursive: true });++ const db = createDb({ pglite: dir, service: 'coupons', registry, log });await db.migrate({ dir: MIGRATIONS, log });return db;}@@@ -228,15 +228,15 @@ function createCoupons({ store, merchants, publication, outbox = null }) {/** Send the Search documents of a code and its merchant (inside a transaction). */async function syncIndex(c, { traceparent } = {}) {const m = await merchants.byId(c.merchant_id);-- await publication.sendDocument(publication.couponDocument(c, m, { restrictionsText: restrictionsText(await restrictionsOf(c.id)), sourceRefs: await sourceRefs(c.id) }), { traceparent });-- if (m) await publication.sendDocument(publication.merchantDocument(m, await activeCount(m.id)), { traceparent });++ await publication.sendDocument(publication.couponDocument(c, m, { restrictionsText: restrictionsText(await restrictionsOf(c.id)), sourceRefs: await sourceRefs(c.id) }), { traceparent, page: publication.couponPath(c) });++ if (m) await publication.sendDocument(publication.merchantDocument(m, await activeCount(m.id)), { traceparent, page: publication.merchantPath(m) });}async function syncMerchant(m, { traceparent } = {}) {-- await publication.sendDocument(publication.merchantDocument(m, await activeCount(m.id)), { traceparent });++ await publication.sendDocument(publication.merchantDocument(m, await activeCount(m.id)), { traceparent, page: publication.merchantPath(m) });for (const { id } of await q.ofMerchant.all(m.id)) {const c = await get(id);-- await publication.sendDocument(publication.couponDocument(c, m, { restrictionsText: restrictionsText(await restrictionsOf(c.id)), sourceRefs: await sourceRefs(c.id) }), { traceparent });++ await publication.sendDocument(publication.couponDocument(c, m, { restrictionsText: restrictionsText(await restrictionsOf(c.id)), sourceRefs: await sourceRefs(c.id) }), { traceparent, page: publication.couponPath(c) });}}@@@ -21,7 +21,7 @@ const ACTOR = Object.freeze({ type: 'service', id: 'coupons' });const COUPON_POLICY = { minWords: 0 };const MERCHANT_POLICY = { minWords: 1 };--function createPublication({ store, config, outbox }) {++function createPublication({ store, config, outbox, indexnow = null }) {const abs = (p) => seo.canonicalUrl(config.baseUrl, p);const merchantPath = (m) => `/m/${m.slug}`;const couponPath = (c) => `/c/${c.id}`;@@@ -90,13 +90,21 @@ function createPublication({ store, config, outbox }) {});}-- /** Stamp and enqueue a Search document (inside the caller's transaction). */-- async function sendDocument(doc, { traceparent } = {}) {++ /** Stamp and enqueue a Search document (inside the caller's transaction). `page` is the page's path. */++ async function sendDocument(doc, { traceparent, page } = {}) {if (doc.deleted && await store.sequencer.current(doc.owner, doc.type, doc.id) == null) return null; // never indexedconst 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-- return await outbox.emit(hooks.indexEvent({ document: stamped, now: store.now() }), { traceparent });++ 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;}/**@@@ -42,7 +42,7 @@ async function boot(opts = {}) {let built = null;async function start() {const config = configLib.load(env);-- built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, limitsNow: opts.limitsNow });++ built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, limitsNow: opts.limitsNow, indexnow: opts.indexnow });try { await built.ctx.jwks.keys(); } catch { /* started without the key: readiness reports it */ }server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });t.base = `http://127.0.0.1:${server.address().port}`;nnew file mode 100644@@@ -0,0 +1,85 @@++'use strict';++/**++ * IndexNow (openvibe-shared/indexnow, mounted in server/app.js, pinged from++ * server/domain/publication.js): off without a key — no key file, nothing sent; with a key the file++ * answers text/plain and an indexable publish pings the page path and the sitemap. A draft never pings.++ */++const assert = require('assert');++const { createIndexNow } = require('openvibe-shared/indexnow');++const { boot, check, done } = require('./helpers/boot');++++const KEY = 'k'.repeat(32); // 8–128 hex/alphanumeric, what IndexNow's own tools generate++++/** A real key file, but pingSoon records its calls instead of debouncing and POSTing. */++function spy() {++ const calls = [];++ const real = createIndexNow({ host: 'https://openvibe.coupons', key: KEY, fetch: async () => ({ status: 200 }), log() {} });++ return { ...real, calls, pingSoon: (urls) => { calls.push(urls); return real.pingSoon(urls); } };++}++++(async () => {++ await check('without a key: no key route, nothing sent', async () => {++ const t = await boot();++ try {++ assert.strictEqual(t.ctx.indexnow.enabled, false, 'IndexNow is off without INDEXNOW_KEY');++ const probe = await t.get(`/${KEY}.txt`);++ assert.strictEqual(probe.status, 404, 'no key file is mounted without a key');++ assert.strictEqual(t.ctx.indexnow.pingSoon(['https://openvibe.coupons/c/cpn_x']), 0, 'nothing is queued');++ // A member publish still works (nothing is pinged, and nothing throws).++ const shop = await t.merchant({ name: 'Off Shop', host: 'offshop.co.uk' });++ const alice = t.network.addUser('alice');++ const r = await t.submit(alice, { merchant_id: shop.id, code: 'OFF10', title: 'Ten off' });++ assert.strictEqual(r.status, 201, r.text.slice(0, 300));++ } finally { await t.close(); }++ });++++ await check('with a key: the key file answers text/plain with the key', async () => {++ const t = await boot({ env: { INDEXNOW_KEY: KEY }, indexnow: spy() });++ try {++ assert.strictEqual(t.ctx.indexnow.enabled, true);++ const r = await t.get(`/${KEY}.txt`);++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('content-type'), /text\/plain/);++ assert.strictEqual(r.text, KEY);++ assert.strictEqual((await t.get('/not-the-key.txt')).status, 404);++ } finally { await t.close(); }++ });++++ await check('a publish pings the page path and the sitemap; a draft does not', async () => {++ const inw = spy();++ const t = await boot({ env: { INDEXNOW_KEY: KEY }, indexnow: inw });++ try {++ // A shop with no codes is thin (noindex): creating it pings nothing.++ const shop = await t.merchant({ name: 'On Shop', host: 'onshop.co.uk' });++ assert.deepStrictEqual(inw.calls, [], 'a merchant with no active codes never pings');++++ // A member submission is published at once → its code page and its shop page are indexable.++ const alice = t.network.addUser('alice');++ const r = await t.submit(alice, { merchant_id: shop.id, code: 'ON5', title: 'Five off' });++ assert.strictEqual(r.status, 201, r.text.slice(0, 300));++ const id = (await t.ctx.store.db.prepare("SELECT id FROM coupons WHERE code = 'ON5'").get()).id;++ const flat = inw.calls.flat();++ assert.ok(flat.includes(`https://openvibe.coupons/c/${id}`), `pinged the code path: ${JSON.stringify(inw.calls)}`);++ assert.ok(flat.includes(`https://openvibe.coupons/m/${shop.slug}`), 'pinged the shop path (its first active code)');++ assert.ok(flat.includes('https://openvibe.coupons/sitemap.xml'), 'pinged the sitemap');++++ // The code is taken down: its page leaves the index, so the path is pinged again.++ const beforeDisable = inw.calls.length;++ const off = await t.get(`/api/v1/coupons/${id}/status`, { as: t.staffToken(), json: { status: 'disabled' } });++ assert.strictEqual(off.status, 200, off.text.slice(0, 300));++ assert.ok(inw.calls.length > beforeDisable, 'a page that leaves the index pings');++ assert.ok(inw.calls.slice(beforeDisable).flat().includes(`https://openvibe.coupons/c/${id}`));++++ // A code waiting for review (a pending shop) is a draft: no ping.++ const before = inw.calls.length;++ const pending = await t.get('/api/v1/merchants', { as: t.staffToken(), json: { name: 'Pending Shop', domains: [{ host: 'pending-shop.co.uk' }], status: 'pending' } });++ assert.strictEqual(pending.status, 201, pending.text.slice(0, 300));++ const shop2 = pending.json().merchant;++ const draft = await t.submit(alice, { merchant_id: shop2.id, code: 'SOON', title: 'Waiting for review' });++ assert.strictEqual(draft.status, 201);++ assert.strictEqual(inw.calls.length, before, 'a draft never pings');++ } finally { await t.close(); }++ });++++ done();++})();nnew file mode 100644@@@ -0,0 +1,64 @@++'use strict';++// Size budgets for openvibe.coupons' home page (roadmap WS-T task 1, openvibe-shared/perf-budget): the++// server as it runs (a fresh database), measured without a browser. Budgets sit a little above today's++// measurement; raising one is a decision to state in the commit.++// node test/perf-budget.test.js++const assert = require('assert');++const fs = require('fs');++const net = require('net');++const os = require('os');++const path = require('path');++const { spawn } = require('child_process');++const { measure, check, format } = require('openvibe-shared/perf-budget');++++const BUDGETS = {++ htmlRawKB: 22, // measured 17.4 (fresh database, 2026-09-29)++ htmlBrotliKB: 5.5, // 4.5++ jsFiles: 5, // 4++ jsRawKB: 240, // 212.2++ jsBrotliKB: 58, // 49.9++ cssFiles: 2, // 1++ cssRawKB: 6.5, // 4.4++ cssBrotliKB: 2, // 1.2++ externalFiles: 1, // 0++};++++const freePort = () => new Promise((resolve) => { const s = net.createServer(); s.listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => resolve(port)); }); });++++(async () => {++ // A database of its own (not the shared dev PGlite in data/pglite, and not whatever the caller's++ // DATABASE_URL names), so the measurement is the server on a fresh database and the run leaves nothing.++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-budget-'));++ const pgliteDir = path.join(dir, 'pglite');++ const port = await freePort();++ const child = spawn(process.execPath, [path.join(__dirname, '..', 'server', 'index.js')], {++ cwd: path.join(__dirname, '..'),++ env: {++ ...process.env, PORT: String(port), HOST: '127.0.0.1', NODE_ENV: 'test',++ DATABASE_URL: '', DATABASE_DIRECT_URL: '', VALKEY_URL: '',++ COUPONS_PGLITE_DIR: pgliteDir, COUPONS_WORKER: 'off',++ OV_SOURCES_INTERNAL_URL: '', EVENTS_URL: '', INDEXNOW_KEY: '',++ },++ stdio: ['ignore', 'ignore', 'pipe'],++ });++ let stderr = '';++ child.stderr.on('data', (d) => { stderr = (stderr + d).slice(-2000); });++ const base = `http://127.0.0.1:${port}`;++ try {++ let up = false;++ for (let i = 0; i < 150 && !up; i++) {++ up = await fetch(`${base}/api/health`).then((r) => r.ok).catch(() => false);++ if (!up) await new Promise((r) => setTimeout(r, 100));++ }++ assert.ok(up, `the server did not start:\n${stderr}`);++ assert.ok(fs.existsSync(pgliteDir), 'the server did not use the isolated database (COUPONS_PGLITE_DIR)');++ const m = await measure({ base });++ const over = check(m, BUDGETS);++ assert.deepStrictEqual(over, [], format(m, over));++ console.log(format(m));++ console.log('perf budget: all checks passed');++ } finally {++ child.kill();++ fs.rmSync(dir, { recursive: true, force: true });++ }++})().catch((err) => { console.error(err); process.exitCode = 1; });
@@ -15,6 +15,7 @@ TRUST_PROXY=2
@@ -15,6 +15,7 @@ TRUST_PROXY=2
# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh coupons: the runtime role through PgBouncer,
# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh coupons: the runtime role through PgBouncer,
# the owner on a direct connection (boot migrations), and Valkey (shared limit counters).
# the owner on a direct connection (boot migrations), and Valkey (shared limit counters).
# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.
# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.
+# COUPONS_PGLITE_DIR (tests only) puts that embedded database in a directory of its own.
DATABASE_URL=
DATABASE_URL=
DATABASE_DIRECT_URL=
DATABASE_DIRECT_URL=
VALKEY_URL=
VALKEY_URL=
@@ -79,6 +80,10 @@ OV_OAUTH_CLIENT_SECRET=
@@ -79,6 +80,10 @@ OV_OAUTH_CLIENT_SECRET=
#COUPONS_WORKER=on
#COUPONS_WORKER=on
#COUPONS_SWEEP_INTERVAL_MS=60000
#COUPONS_SWEEP_INTERVAL_MS=60000
+# IndexNow: key file served at /<key>.txt; a publish/update/removal pings api.indexnow.org.
+# Unset: off — no key file, no requests. Under a restore drill leave it unset/empty.
+#INDEXNOW_KEY=
+
# release.json (openvibe-shared/release): the deployed commit when git is not available
# release.json (openvibe-shared/release): the deployed commit when git is not available
#RELEASE_COMMIT=
#RELEASE_COMMIT=
#MIN_CLIENT_RELEASE=
#MIN_CLIENT_RELEASE=
@@ -448,6 +448,7 @@ First install (done once; kept for a rebuild):
@@ -448,6 +448,7 @@ First install (done once; kept for a rebuild):
- `COUPONS_STAFF_SUBJECTS` (optional)
- `COUPONS_STAFF_SUBJECTS` (optional)
- `EVENTS_URL=http://127.0.0.1:4300`
- `EVENTS_URL=http://127.0.0.1:4300`
- `BASE_URL=https://openvibe.coupons`
- `BASE_URL=https://openvibe.coupons`
+ - `INDEXNOW_KEY` (optional; enables IndexNow — the key file at `/<key>.txt` and a ping when an indexable page changes. Unset: off, and drills leave it unset)
- Optionally set `OV_SOURCES_INTERNAL_URL=http://127.0.0.1:4720`.
- Optionally set `OV_SOURCES_INTERNAL_URL=http://127.0.0.1:4720`.
- Once the extension has a store id, set `COUPONS_EXTENSION_ORIGINS`.
- Once the extension has a store id, set `COUPONS_EXTENSION_ORIGINS`.
3. **systemd:** install `deploy/systemd/openvibe-coupons.service` (port 4850).
3. **systemd:** install `deploy/systemd/openvibe-coupons.service` (port 4850).
@@ -17,6 +17,7 @@ const contracts = require('openvibe-contracts');
@@ -17,6 +17,7 @@ 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 { createIndexNow } = require('openvibe-shared/indexnow');
const configLib = require('./config');
const configLib = require('./config');
const { openStore } = require('./db');
const { openStore } = require('./db');
@@ -49,11 +50,19 @@ async function createApp(opts = {}) {
@@ -49,11 +50,19 @@ async function createApp(opts = {}) {
const config = opts.config || configLib.load();
const config = opts.config || configLib.load();
const log = opts.log || console;
const log = opts.log || console;
const fetchImpl = opts.fetchImpl || globalThis.fetch;
const fetchImpl = opts.fetchImpl || globalThis.fetch;
+ // IndexNow (openvibe-shared/indexnow): created once at boot. No INDEXNOW_KEY → off, nothing mounted,
+ // nothing sent. Tests inject a spy; the pings themselves are queued and never fatal.
+ const indexnow = opts.indexnow !== undefined ? opts.indexnow : createIndexNow({
+ host: config.baseUrl,
+ key: config.indexnowKey,
+ fetch: fetchImpl,
+ log: (...args) => log.warn(...args),
+ });
// 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 });
const outbox = createCouponsOutbox({ db: store.db, config, fetchImpl, now: store.now, log });
- const publication = createPublication({ store, config, outbox });
+ 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 });
const reports = createReports({ store, config, coupons, merchants, publication });
const reports = createReports({ store, config, coupons, merchants, publication });
@@ -82,7 +91,7 @@ async function createApp(opts = {}) {
@@ -82,7 +91,7 @@ async function createApp(opts = {}) {
const viewers = createViewerResolver({ auth, jwks, config, installs });
const viewers = createViewerResolver({ auth, jwks, config, installs });
const worker = createWorker({ config, coupons, importer, outbox, log });
const worker = createWorker({ config, coupons, importer, outbox, log });
- const ctx = { config, store, outbox, publication, merchants, coupons, reports, installs, watches, moderation, importer, auth, jwks, viewers, worker };
+ const ctx = { config, store, outbox, publication, merchants, coupons, reports, installs, watches, moderation, importer, auth, jwks, viewers, worker, indexnow };
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -131,6 +140,8 @@ async function createApp(opts = {}) {
@@ -131,6 +140,8 @@ async function createApp(opts = {}) {
release.mount(app, { registry: metrics.registry });
release.mount(app, { registry: metrics.registry });
const readiness = createCouponsReadiness({ store, jwks, outbox, worker, importer, reports, config, release: release.release, valkey: ctx.valkey });
const readiness = createCouponsReadiness({ store, jwks, outbox, worker, importer, reports, config, release: release.release, valkey: ctx.valkey });
app.get('/api/ready', readiness.handler);
app.get('/api/ready', readiness.handler);
+ // GET /<key>.txt — the IndexNow key file, mounted only when a key is configured (nothing else).
+ if (indexnow.enabled) app.get(`/${config.indexnowKey}.txt`, indexnow.keyFile);
// ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────
// ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────
app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, standardHeaders: true, legacyHeaders: false }));
app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, standardHeaders: true, legacyHeaders: false }));
@@ -36,7 +36,8 @@ function load(env = process.env) {
@@ -36,7 +36,8 @@ function load(env = process.env) {
},
},
// PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role).
// PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role).
- db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '' },
+ // COUPONS_PGLITE_DIR (tests only) puts the embedded development database in its own directory.
+ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.COUPONS_PGLITE_DIR || '' },
valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:coupons:' },
valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:coupons:' },
// OpenVibe.Network: SSO (OAuth2 authorization server), JWKS, client-credentials tokens.
// OpenVibe.Network: SSO (OAuth2 authorization server), JWKS, client-credentials tokens.
@@ -103,6 +104,10 @@ function load(env = process.env) {
@@ -103,6 +104,10 @@ function load(env = process.env) {
enabled: env.COUPONS_WORKER !== 'off',
enabled: env.COUPONS_WORKER !== 'off',
intervalMs: int(env.COUPONS_SWEEP_INTERVAL_MS, 60 * 1000),
intervalMs: int(env.COUPONS_SWEEP_INTERVAL_MS, 60 * 1000),
},
},
+
+ // IndexNow (openvibe-shared/indexnow): a key makes search engines recrawl a page the moment it
+ // appears, changes or leaves the index. Unset: IndexNow is off — no key file, no requests.
+ indexnowKey: String(env.INDEXNOW_KEY || '').trim(),
};
};
}
}
@@ -40,9 +40,10 @@ const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
@@ -40,9 +40,10 @@ const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
async function openDb(config, { log = console, registry } = {}) {
async function openDb(config, { log = console, registry } = {}) {
if (!config.db.url) {
if (!config.db.url) {
if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh coupons)');
if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh coupons)');
- log.warn(`[Coupons] DATABASE_URL unset: embedded PGlite database in ${DEV_PGLITE} (development only, one process)`);
- fs.mkdirSync(DEV_PGLITE, { recursive: true });
- const db = createDb({ pglite: DEV_PGLITE, service: 'coupons', registry, log });
+ const dir = config.db.pgliteDir || DEV_PGLITE;
+ log.warn(`[Coupons] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);
+ fs.mkdirSync(dir, { recursive: true });
+ const db = createDb({ pglite: dir, service: 'coupons', registry, log });
await db.migrate({ dir: MIGRATIONS, log });
await db.migrate({ dir: MIGRATIONS, log });
return db;
return db;
}
}
@@ -228,15 +228,15 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
@@ -228,15 +228,15 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
/** Send the Search documents of a code and its merchant (inside a transaction). */
/** Send the Search documents of a code and its merchant (inside a transaction). */
async function syncIndex(c, { traceparent } = {}) {
async function syncIndex(c, { traceparent } = {}) {
const m = await merchants.byId(c.merchant_id);
const m = await merchants.byId(c.merchant_id);
- await publication.sendDocument(publication.couponDocument(c, m, { restrictionsText: restrictionsText(await restrictionsOf(c.id)), sourceRefs: await sourceRefs(c.id) }), { traceparent });
- if (m) await publication.sendDocument(publication.merchantDocument(m, await activeCount(m.id)), { traceparent });
+ await publication.sendDocument(publication.couponDocument(c, m, { restrictionsText: restrictionsText(await restrictionsOf(c.id)), sourceRefs: await sourceRefs(c.id) }), { traceparent, page: publication.couponPath(c) });
+ if (m) await publication.sendDocument(publication.merchantDocument(m, await activeCount(m.id)), { traceparent, page: publication.merchantPath(m) });
}
}
async function syncMerchant(m, { traceparent } = {}) {
async function syncMerchant(m, { traceparent } = {}) {
- await publication.sendDocument(publication.merchantDocument(m, await activeCount(m.id)), { traceparent });
+ await publication.sendDocument(publication.merchantDocument(m, await activeCount(m.id)), { traceparent, page: publication.merchantPath(m) });
for (const { id } of await q.ofMerchant.all(m.id)) {
for (const { id } of await q.ofMerchant.all(m.id)) {
const c = await get(id);
const c = await get(id);
- await publication.sendDocument(publication.couponDocument(c, m, { restrictionsText: restrictionsText(await restrictionsOf(c.id)), sourceRefs: await sourceRefs(c.id) }), { traceparent });
+ await publication.sendDocument(publication.couponDocument(c, m, { restrictionsText: restrictionsText(await restrictionsOf(c.id)), sourceRefs: await sourceRefs(c.id) }), { traceparent, page: publication.couponPath(c) });
}
}
}
}
@@ -21,7 +21,7 @@ const ACTOR = Object.freeze({ type: 'service', id: 'coupons' });
@@ -21,7 +21,7 @@ const ACTOR = Object.freeze({ type: 'service', id: 'coupons' });
const COUPON_POLICY = { minWords: 0 };
const COUPON_POLICY = { minWords: 0 };
const MERCHANT_POLICY = { minWords: 1 };
const MERCHANT_POLICY = { minWords: 1 };
-function createPublication({ store, config, outbox }) {
+function createPublication({ store, config, outbox, indexnow = null }) {
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,13 +90,21 @@ function createPublication({ store, config, outbox }) {
@@ -90,13 +90,21 @@ function createPublication({ store, config, outbox }) {
});
});
}
}
- /** Stamp and enqueue a Search document (inside the caller's transaction). */
- async function sendDocument(doc, { traceparent } = {}) {
+ /** Stamp and enqueue a Search document (inside the caller's transaction). `page` is the page's path. */
+ async function sendDocument(doc, { traceparent, page } = {}) {
if (doc.deleted && await store.sequencer.current(doc.owner, doc.type, doc.id) == null) return null; // never indexed
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 current = await store.sequencer.current(doc.owner, doc.type, doc.id);
const stamped = await store.sequencer.stamp(store.db, doc);
const stamped = await store.sequencer.stamp(store.db, doc);
if (current === stamped.revision) return null; // the same document again: nothing to send
if (current === stamped.revision) return null; // the same document again: nothing to send
- return await outbox.emit(hooks.indexEvent({ document: stamped, now: store.now() }), { traceparent });
+ 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;
}
}
/**
/**
@@ -42,7 +42,7 @@ async function boot(opts = {}) {
@@ -42,7 +42,7 @@ async function boot(opts = {}) {
let built = null;
let built = null;
async function start() {
async function start() {
const config = configLib.load(env);
const config = configLib.load(env);
- built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, limitsNow: opts.limitsNow });
+ built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, limitsNow: opts.limitsNow, indexnow: opts.indexnow });
try { await built.ctx.jwks.keys(); } catch { /* started without the key: readiness reports it */ }
try { await built.ctx.jwks.keys(); } catch { /* started without the key: readiness reports it */ }
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
t.base = `http://127.0.0.1:${server.address().port}`;
t.base = `http://127.0.0.1:${server.address().port}`;
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -0,0 +1,85 @@
+'use strict';
+/**
+ * IndexNow (openvibe-shared/indexnow, mounted in server/app.js, pinged from
+ * server/domain/publication.js): off without a key — no key file, nothing sent; with a key the file
+ * answers text/plain and an indexable publish pings the page path and the sitemap. A draft never pings.
+ */
+const assert = require('assert');
+const { createIndexNow } = require('openvibe-shared/indexnow');
+const { boot, check, done } = require('./helpers/boot');
+
+const KEY = 'k'.repeat(32); // 8–128 hex/alphanumeric, what IndexNow's own tools generate
+
+/** A real key file, but pingSoon records its calls instead of debouncing and POSTing. */
+function spy() {
+ const calls = [];
+ const real = createIndexNow({ host: 'https://openvibe.coupons', key: KEY, fetch: async () => ({ status: 200 }), log() {} });
+ return { ...real, calls, pingSoon: (urls) => { calls.push(urls); return real.pingSoon(urls); } };
+}
+
+(async () => {
+ await check('without a key: no key route, nothing sent', async () => {
+ const t = await boot();
+ try {
+ assert.strictEqual(t.ctx.indexnow.enabled, false, 'IndexNow is off without INDEXNOW_KEY');
+ const probe = await t.get(`/${KEY}.txt`);
+ assert.strictEqual(probe.status, 404, 'no key file is mounted without a key');
+ assert.strictEqual(t.ctx.indexnow.pingSoon(['https://openvibe.coupons/c/cpn_x']), 0, 'nothing is queued');
+ // A member publish still works (nothing is pinged, and nothing throws).
+ const shop = await t.merchant({ name: 'Off Shop', host: 'offshop.co.uk' });
+ const alice = t.network.addUser('alice');
+ const r = await t.submit(alice, { merchant_id: shop.id, code: 'OFF10', title: 'Ten off' });
+ assert.strictEqual(r.status, 201, r.text.slice(0, 300));
+ } finally { await t.close(); }
+ });
+
+ await check('with a key: the key file answers text/plain with the key', async () => {
+ const t = await boot({ env: { INDEXNOW_KEY: KEY }, indexnow: spy() });
+ try {
+ assert.strictEqual(t.ctx.indexnow.enabled, true);
+ const r = await t.get(`/${KEY}.txt`);
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('content-type'), /text\/plain/);
+ assert.strictEqual(r.text, KEY);
+ assert.strictEqual((await t.get('/not-the-key.txt')).status, 404);
+ } finally { await t.close(); }
+ });
+
+ await check('a publish pings the page path and the sitemap; a draft does not', async () => {
+ const inw = spy();
+ const t = await boot({ env: { INDEXNOW_KEY: KEY }, indexnow: inw });
+ try {
+ // A shop with no codes is thin (noindex): creating it pings nothing.
+ const shop = await t.merchant({ name: 'On Shop', host: 'onshop.co.uk' });
+ assert.deepStrictEqual(inw.calls, [], 'a merchant with no active codes never pings');
+
+ // A member submission is published at once → its code page and its shop page are indexable.
+ const alice = t.network.addUser('alice');
+ const r = await t.submit(alice, { merchant_id: shop.id, code: 'ON5', title: 'Five off' });
+ assert.strictEqual(r.status, 201, r.text.slice(0, 300));
+ const id = (await t.ctx.store.db.prepare("SELECT id FROM coupons WHERE code = 'ON5'").get()).id;
+ const flat = inw.calls.flat();
+ assert.ok(flat.includes(`https://openvibe.coupons/c/${id}`), `pinged the code path: ${JSON.stringify(inw.calls)}`);
+ assert.ok(flat.includes(`https://openvibe.coupons/m/${shop.slug}`), 'pinged the shop path (its first active code)');
+ assert.ok(flat.includes('https://openvibe.coupons/sitemap.xml'), 'pinged the sitemap');
+
+ // The code is taken down: its page leaves the index, so the path is pinged again.
+ const beforeDisable = inw.calls.length;
+ const off = await t.get(`/api/v1/coupons/${id}/status`, { as: t.staffToken(), json: { status: 'disabled' } });
+ assert.strictEqual(off.status, 200, off.text.slice(0, 300));
+ assert.ok(inw.calls.length > beforeDisable, 'a page that leaves the index pings');
+ assert.ok(inw.calls.slice(beforeDisable).flat().includes(`https://openvibe.coupons/c/${id}`));
+
+ // A code waiting for review (a pending shop) is a draft: no ping.
+ const before = inw.calls.length;
+ const pending = await t.get('/api/v1/merchants', { as: t.staffToken(), json: { name: 'Pending Shop', domains: [{ host: 'pending-shop.co.uk' }], status: 'pending' } });
+ assert.strictEqual(pending.status, 201, pending.text.slice(0, 300));
+ const shop2 = pending.json().merchant;
+ const draft = await t.submit(alice, { merchant_id: shop2.id, code: 'SOON', title: 'Waiting for review' });
+ assert.strictEqual(draft.status, 201);
+ assert.strictEqual(inw.calls.length, before, 'a draft never pings');
+ } finally { await t.close(); }
+ });
+
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
+'use strict';
+// Size budgets for openvibe.coupons' home page (roadmap WS-T task 1, openvibe-shared/perf-budget): the
+// server as it runs (a fresh database), measured without a browser. Budgets sit a little above today's
+// measurement; raising one is a decision to state in the commit.
+// node test/perf-budget.test.js
+const assert = require('assert');
+const fs = require('fs');
+const net = require('net');
+const os = require('os');
+const path = require('path');
+const { spawn } = require('child_process');
+const { measure, check, format } = require('openvibe-shared/perf-budget');
+
+const BUDGETS = {
+ htmlRawKB: 22, // measured 17.4 (fresh database, 2026-09-29)
+ htmlBrotliKB: 5.5, // 4.5
+ jsFiles: 5, // 4
+ jsRawKB: 240, // 212.2
+ jsBrotliKB: 58, // 49.9
+ cssFiles: 2, // 1
+ cssRawKB: 6.5, // 4.4
+ cssBrotliKB: 2, // 1.2
+ externalFiles: 1, // 0
+};
+
+const freePort = () => new Promise((resolve) => { const s = net.createServer(); s.listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => resolve(port)); }); });
+
+(async () => {
+ // A database of its own (not the shared dev PGlite in data/pglite, and not whatever the caller's
+ // DATABASE_URL names), so the measurement is the server on a fresh database and the run leaves nothing.
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-budget-'));
+ const pgliteDir = path.join(dir, 'pglite');
+ const port = await freePort();
+ const child = spawn(process.execPath, [path.join(__dirname, '..', 'server', 'index.js')], {
+ cwd: path.join(__dirname, '..'),
+ env: {
+ ...process.env, PORT: String(port), HOST: '127.0.0.1', NODE_ENV: 'test',
+ DATABASE_URL: '', DATABASE_DIRECT_URL: '', VALKEY_URL: '',
+ COUPONS_PGLITE_DIR: pgliteDir, COUPONS_WORKER: 'off',
+ OV_SOURCES_INTERNAL_URL: '', EVENTS_URL: '', INDEXNOW_KEY: '',
+ },
+ stdio: ['ignore', 'ignore', 'pipe'],
+ });
+ let stderr = '';
+ child.stderr.on('data', (d) => { stderr = (stderr + d).slice(-2000); });
+ const base = `http://127.0.0.1:${port}`;
+ try {
+ let up = false;
+ for (let i = 0; i < 150 && !up; i++) {
+ up = await fetch(`${base}/api/health`).then((r) => r.ok).catch(() => false);
+ if (!up) await new Promise((r) => setTimeout(r, 100));
+ }
+ assert.ok(up, `the server did not start:\n${stderr}`);
+ assert.ok(fs.existsSync(pgliteDir), 'the server did not use the isolated database (COUPONS_PGLITE_DIR)');
+ const m = await measure({ base });
+ const over = check(m, BUDGETS);
+ assert.deepStrictEqual(over, [], format(m, over));
+ console.log(format(m));
+ console.log('perf budget: all checks passed');
+ } finally {
+ child.kill();
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+})().catch((err) => { console.error(err); process.exitCode = 1; });