openvibers/openvibe.deals Commits

Deals 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 deal or product 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 (test/perf-budget.test.js, its own PGlite directory). Tests green (verified).

OP OpenVibers committed 29 Sept 2026 · 3ed88be2d2 · 9 files, +208 −12


Changes

ddiff --git a/.env.example b/.env.example
iindex 491c1ee..47090e0 100644
---- a/.env.example
++++ b/.env.example
@@@ -90,6 +90,11 @@ OV_SOURCES_INTERNAL_URL=http://127.0.0.1:4720
# Browser origins that may call /api/v1 with a Bearer Network JWT
#API_CORS_ORIGINS=https://openvibe.network,https://openvibe.live,https://openvibe.community,https://openvibe.coupons
++# IndexNow (openvibe-shared/indexnow): 8–128 hex/alphanumeric characters. Set: the key file is served
++# at /<key>.txt and an indexable deal or product page appearing, changing or disappearing pings the
++# engines. Unset: off, nothing is mounted and nothing is sent.
++#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 1152fd2..459b318 100644
---- a/README.md
++++ b/README.md
@@@ -237,6 +237,10 @@ moderator records a review (a `usr_` subject; a service cannot review).
explains observations and the JSON twins.
- AI-assisted text (`X-OV-Origin: ai`, e.g. OpenVibe.AI's `deals.enrich_deal`) is stored separately
(`ai_summary`), disclosed on the page and `noindex` until a person reviews it.
++- IndexNow (`openvibe-shared/indexnow`): with `INDEXNOW_KEY` set, the key file is served at
++ `/<key>.txt` and an indexable deal or product page appearing, changing or disappearing pings the
++ engines with the page's path and `/sitemap.xml`. Unset, the feature is off — nothing is mounted and
++ nothing is sent. Drafts, private and noindex pages are never pinged (`test/indexnow.test.js`).
### Caching
ddiff --git a/server/app.js b/server/app.js
iindex 2629834..58d7181 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -23,6 +23,7 @@ const { createPeople } = require('./clients/network');
const { createCommunity } = require('./clients/community');
const { createSources } = require('./clients/sources');
const { createServiceOutbox } = require('openvibe-sdk/events');
++const { createIndexNow } = require('openvibe-shared/indexnow');
const { createReads } = require('./domain/reads');
const { createCatalog } = require('./domain/catalog');
const { createPublication } = require('./domain/publication');
@@@ -68,7 +69,13 @@ async function createApp(opts = {}) {
const reads = createReads({ store });
const catalog = createCatalog({ store });
const publication = createPublication({ config, store, reads, catalog });
-- const indexing = createIndexing({ store, publication, outbox, catalog });
++ // IndexNow (openvibe-shared/indexnow): created once at boot from INDEXNOW_KEY; unset → off
++ // (nothing mounted, nothing sent). The key file is served at /<key>.txt and indexing.js pings the
++ // engines when an indexable deal or product page appears, changes or goes away.
++ const indexnow = opts.indexnow !== undefined ? opts.indexnow : createIndexNow({
++ host: config.baseUrl, key: config.indexnowKey, fetch: fetchImpl, log,
++ });
++ const indexing = createIndexing({ store, publication, outbox, catalog, indexnow });
const hotness = createHotness({ store, reads });
const watches = createWatches({ config, store, reads, catalog, publication, outbox });
const limits = createLimits({ config, store });
@@@ -90,7 +97,7 @@ async function createApp(opts = {}) {
const viewers = createViewerResolver({ auth, config, people });
const worker = createWorker({ config, store, offers, hotness, indexing, listings, importer, limits, log });
-- const ctx = { config, store, outbox, people, community, sources, reads, catalog, publication, indexing, hotness, watches, limits, access, offers, flags, votes, listings, importer, auth, viewers, worker };
++ const ctx = { config, store, outbox, people, community, sources, reads, catalog, publication, indexing, hotness, watches, limits, access, offers, flags, votes, listings, importer, auth, viewers, worker, indexnow };
const app = express();
app.disable('x-powered-by');
@@@ -139,6 +146,9 @@ async function createApp(opts = {}) {
const readiness = createDealsReadiness({ store, jwksUrl, outbox, importer, worker, limits, release: release.release, valkey: ctx.valkey, log });
app.get('/api/ready', readiness.handler);
++ // GET /<key>.txt — the IndexNow key file (only when a key is configured; it serves itself).
++ if (indexnow.enabled) app.use(indexnow.keyFile);
++
// ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────
app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, standardHeaders: true, legacyHeaders: false }));
app.use('/auth', sso.router(express));
ddiff --git a/server/config.js b/server/config.js
iindex a287691..90cc117 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -38,7 +38,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 || '' },
++ // pgliteDir is the dev/test embedded-database directory (a test gives the spawned server its own).
++ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.DEALS_PGLITE_DIR || '' },
valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:deals:' },
// OpenVibe.Network: SSO (OAuth2 authorization server), JWKS, client-credentials tokens.
@@@ -110,6 +111,11 @@ function load(env = process.env) {
hotWindowDays: int(env.DEALS_HOT_WINDOW_DAYS, 14),
},
++ // IndexNow (openvibe-shared/indexnow): when INDEXNOW_KEY is set the key file is served at
++ // /<key>.txt and an indexable deal or product page appearing, changing or disappearing pings
++ // the engines. Unset (or empty) → off: nothing is mounted and nothing is sent.
++ indexnowKey: String(env.INDEXNOW_KEY || '').trim(),
++
// Browser origins that may call /api/v1 with a Bearer Network JWT (no cookies cross origins).
apiCorsOrigins: list(env.API_CORS_ORIGINS || 'https://openvibe.network,https://openvibe.live,https://openvibe.community,https://openvibe.coupons'),
};
ddiff --git a/server/db.js b/server/db.js
iindex f8f85ba..96fe162 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -44,14 +44,16 @@ const CHARTER_TABLES = ['deal_products', 'deal_product_aliases', 'deal_offers',
/**
* The serving handle (ADR-035): DATABASE_URL through PgBouncer; in development without it, an embedded PGlite database
-- * in data/pglite. Migrations run first, as the owner (DATABASE_DIRECT_URL), or on the embedded handle.
++ * in data/pglite (DEALS_PGLITE_DIR overrides the directory — how a test gives a spawned server its own).
++ * Migrations run first, as the owner (DATABASE_DIRECT_URL), or on the embedded handle.
*/
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 deals)');
-- log.warn(`[Deals] 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: 'deals', registry, log });
++ const dir = config.db.pgliteDir || DEV_PGLITE;
++ log.warn(`[Deals] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);
++ fs.mkdirSync(dir, { recursive: true });
++ const db = createDb({ pglite: dir, service: 'deals', registry, log });
await db.migrate({ dir: MIGRATIONS, log });
return db;
}
ddiff --git a/server/domain/indexing.js b/server/domain/indexing.js
iindex b90699f..72fb25e 100644
---- a/server/domain/indexing.js
++++ b/server/domain/indexing.js
@@@ -11,6 +11,9 @@
* nothing; a document that changed (text, status, freshness, indexability)
* gets revision + 1 and one event.
* indexProduct(id) the same for product pages.
++ *
++ * When an indexable page appears, changes or disappears it also tells IndexNow (openvibe-shared/indexnow,
++ * injected at boot): the page's path and the sitemap. Off without a key.
*/
const hooks = require('openvibe-publishing/index-hooks');
const { iso } = require('./util');
@@@ -22,9 +25,18 @@ function actorRef(actor) {
return { type: 'service', id: 'deals' };
}
--function createIndexing({ store, publication, outbox, catalog }) {
++function createIndexing({ store, publication, outbox, catalog, indexnow = null }) {
const { sequencer, db } = store;
++ /**
++ * IndexNow: a public, indexable page appeared, changed or went away — tell the engines its path
++ * (and the sitemap, which lists it). pingSoon never throws and is a no-op without a key, so it can
++ * never take a write down. A draft, private or noindex page is never announced.
++ */
++ function announce(path) {
++ if (indexnow && indexnow.enabled) indexnow.pingSoon([publication.abs(path), publication.abs('/sitemap.xml')]);
++ }
++
function latestPayload(v) {
return v.latest ? { price: v.latest.price, currency: v.latest.currency, availability: v.latest.availability, observed_at: iso(v.latest.observed_at) } : null;
}
@@@ -85,11 +97,23 @@ function createIndexing({ store, publication, outbox, catalog }) {
if (!offer) return null;
if (offer.merged_into) {
const before = await sequencer.current('deals', 'offer', offer.id);
-- return await emitIfNew(await sequencer.stamp(db, hooks.tombstone({ owner: 'deals', type: 'offer', id: offer.id, revision: 0 })), before);
++ const sent = await emitIfNew(await sequencer.stamp(db, hooks.tombstone({ owner: 'deals', type: 'offer', id: offer.id, revision: 0 })), before);
++ // A merge is a delete: the duplicate's page goes away. Announce it only if Search had it.
++ if (sent && before != null) announce(publication.offerPath(offer));
++ return sent;
}
const v = await publication.offerView(offer);
const before = await sequencer.current('deals', 'offer', v.root.id);
-- return await emitIfNew(await sequencer.stamp(db, offerDocument(v)), before);
++ const sent = await emitIfNew(await sequencer.stamp(db, offerDocument(v)), before);
++ if (sent) {
++ // An indexable page appears or changes; a page Search already had that is now explicitly
++ // removed (a takedown) disappeared. An expired offer stays published-but-noindex, so it is
++ // not announced — a noindex page is never pinged.
++ const indexable = v.root.status === 'active' && v.decision.indexable;
++ const removed = v.root.status === 'disabled' && before != null;
++ if (indexable || removed) announce(publication.offerPath(v.root));
++ }
++ return sent;
}
async function indexProduct(product) {
@@@ -108,7 +132,11 @@ function createIndexing({ store, publication, outbox, catalog }) {
publishedAt: product.created_at,
updatedAt: pv.freshestObservedAt || product.updated_at,
});
-- return await emitIfNew(await sequencer.stamp(db, doc), before);
++ const sent = await emitIfNew(await sequencer.stamp(db, doc), before);
++ // A product page appears or changes while indexable; one Search had that is now no longer
++ // indexable (its last fresh offer went) disappeared.
++ if (sent && (pv.decision.indexable || before != null)) announce(publication.productPath(product));
++ return sent;
}
/** After any change to an offer: its index document and its product's. */
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 28ffbbc..252e664 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -45,7 +45,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 });
await require('openvibe-sdk/auth').jwksClient(`${config.networkInternalUrl}/api/.well-known/jwks`).keys();
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..4ba1fed
---- /dev/null
++++ b/test/indexnow.test.js
@@@ -0,0 +1,77 @@
++'use strict';
++/**
++ * IndexNow (openvibe-shared/indexnow, mounted in server/app.js, pinged from server/domain/indexing.js):
++ * INDEXNOW_KEY unset → the feature is off (no key route, nothing sent). With a key the key file is
++ * served at /<key>.txt as text/plain and publishing an indexable deal pings the engines with the
++ * deal's path and the sitemap. Drafts and noindex pages never ping.
++ */
++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.deals', 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, a submission still works', 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.deals/d/x']), 0, 'nothing is queued');
++ const alice = t.network.addUser('alice');
++ const offer = await t.submit(alice, { url: 'https://off.example/p/1', title: 'A deal that publishes', price: '9.99', currency: 'usd' });
++ assert.ok(offer && offer.id, 'a submission still publishes');
++ } 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, r.text);
++ 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 {
++ const alice = t.network.addUser('alice');
++ const offer = await t.submit(alice, { url: 'https://off.example/p/2', title: 'A published deal', price: '9.99', currency: 'usd' });
++ const flat = inw.calls.flat();
++ assert.ok(flat.includes(`https://openvibe.deals/d/${offer.slug}`), `pinged the deal path: ${JSON.stringify(inw.calls)}`);
++ assert.ok(flat.includes('https://openvibe.deals/sitemap.xml'), 'pinged the sitemap');
++
++ // A draft: AI-assisted text is held for review (noindex), so it never pings.
++ inw.calls.length = 0;
++ const r = await t.get('/api/v1/offers', {
++ as: t.network.serviceToken('ai', ['deals.offer.submit']),
++ headers: { 'X-OV-Subject': alice.subject, 'X-OV-Origin': 'ai' },
++ json: { url: 'https://off.example/p/3', title: 'An AI draft deal', price: '4.99', currency: 'usd' },
++ });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.json().offer.review_state, 'pending');
++ assert.deepStrictEqual(inw.calls, [], 'a noindex draft is never pinged');
++
++ // A takedown removes the page: the path is pinged again (it left the index).
++ inw.calls.length = 0;
++ const off = await t.get(`/mod/offers/${offer.slug}/disable`, { as: t.mod, form: { reason: 'spam listing' } });
++ assert.strictEqual(off.status, 303, off.text.slice(0, 300));
++ assert.ok(inw.calls.flat().includes(`https://openvibe.deals/d/${offer.slug}`), 'a page that leaves the index pings');
++ } finally { await t.close(); }
++ });
++
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
nnew file mode 100644
iindex 0000000..3a71032
---- /dev/null
++++ b/test/perf-budget.test.js
@@@ -0,0 +1,64 @@
++'use strict';
++// Size budgets for openvibe.deals' home page (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.2 (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: 7, // 5.6
++ cssBrotliKB: 2, // 1.5
++ 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: '',
++ DEALS_PGLITE_DIR: pgliteDir, DEALS_WORKER: 'off', DEALS_IMPORT: '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 (DEALS_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; });