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).

OP OpenVibers committed 29 Sept 2026 · 36f2f0868c · 10 files, +195 −15


Changes

ddiff --git a/.env.example b/.env.example
iindex 5cb6817..2383d8e 100644
---- a/.env.example
++++ b/.env.example
@@@ -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=
ddiff --git a/README.md b/README.md
iindex 2a329b5..09659ba 100644
---- a/README.md
++++ b/README.md
@@@ -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).
ddiff --git a/server/app.js b/server/app.js
iindex 1d88f10..d38ce6b 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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 }));
ddiff --git a/server/config.js b/server/config.js
iindex 25ba61f..e9f06e0 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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(),
};
}
ddiff --git a/server/db.js b/server/db.js
iindex 0de88b5..5748c98 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -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;
}
ddiff --git a/server/domain/coupons.js b/server/domain/coupons.js
iindex 064bca5..8f7ded3 100644
---- a/server/domain/coupons.js
++++ b/server/domain/coupons.js
@@@ -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) });
}
}
ddiff --git a/server/domain/publication.js b/server/domain/publication.js
iindex daf2afb..49216e2 100644
---- a/server/domain/publication.js
++++ b/server/domain/publication.js
@@@ -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 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
-- 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;
}
/**
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex b7d2d18..697982c 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -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}`;
ddiff --git a/test/indexnow.test.js b/test/indexnow.test.js
nnew file mode 100644
iindex 0000000..412d8aa
---- /dev/null
++++ b/test/indexnow.test.js
@@@ -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();
++})();
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
nnew file mode 100644
iindex 0000000..e08ecc7
---- /dev/null
++++ b/test/perf-budget.test.js
@@@ -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; });