openvibers/openvibe.reviews Commits
Reviews 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 review 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.
Changes
@@@ -119,7 +119,9 @@ share is expressed as `ratingValue` 0–100 with `bestRating: 100` and the real(no live signal → `unsourced`, summary under the word minimum → `thin`, unsupported points →`unsupported_claims`, unreviewed AI → hidden); sitemaps list indexable entities only, Atom/JSON feedslist published summaries, `robots.txt` and `llms.txt` are served, and Search receives--`reviews.index_document.upserted|deleted` with Sources items as provenance.++`reviews.index_document.upserted|deleted` with Sources items as provenance. With `INDEXNOW_KEY` set,++publishing, editing, unpublishing or deleting an indexable entity page tells IndexNow (the key file is++served at `/<key>.txt`; unset, nothing is sent, and drafts and noindex pages never ping).**Operations.** `/api/health`, `/api/ready` (DB required; Network key, Sources, Events relay, webhooksecret, Community and editors reported), `/release.json`, `/metrics` (outbox and backlog gauges).@@@ -41,7 +41,7 @@ function lagged(read, initial) {};}--function createApp({ config, svc, viewers, platform, sync, keys, db, log = console, rateLimits = true, fetchImpl = globalThis.fetch, limitsNow = null }) {++function createApp({ config, svc, viewers, platform, sync, keys, db, log = console, rateLimits = true, fetchImpl = globalThis.fetch, limitsNow = null, indexnow = null }) {const app = express();app.disable('x-powered-by');app.set('trust proxy', config.trustProxy);@@@ -96,6 +96,9 @@ function createApp({ config, svc, viewers, platform, sync, keys, db, log = consoapp.use('/auth', createSessionRoutes({ config, viewers, log, fetchImpl }));{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'reviews', service: 'reviews', host: 'openvibe.reviews', name: 'OpenVibe.Reviews', profile: 'ugc' })); }++ // GET /<key>.txt — the IndexNow key file (mounted only when a key is configured; it serves itself).++ if (indexnow && indexnow.enabled) app.use(indexnow.keyFile);++// Signed deliveries from OpenVibe.Events. Loopback/internal only in nginx (not proxied publicly).const consumer = consumerRouter({ db, svc, sync, config, log });app.use('/internal', http.middleware(), consumer.router);@@@ -32,7 +32,9 @@ function load(env = process.env) {hour: Math.max(1, int(env.REVIEWS_LIMITS_HOUR, 3000)),},// PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role).-- db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '' },++ // Non-production without DATABASE_URL: the embedded PGlite database in data/pglite++ // (REVIEWS_PGLITE_DIR, tests: an isolated directory of their own).++ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.REVIEWS_PGLITE_DIR || '' },valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:reviews:' },// Identity: OpenVibe.Network signs user JWTs (SSO) and service tokens (client credentials).@@@ -55,6 +57,11 @@ function load(env = process.env) {},cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },++ // IndexNow (openvibe-shared/indexnow): a key makes search engines recrawl an entity page the++ // moment it appears, changes or leaves the index (the key file is served at /<key>.txt). Unset →++ // off: no key file, nothing sent. Tests and drills never set it.++ indexnow: { key: String(env.INDEXNOW_KEY || '').trim() },++// Other services (all optional: each integration degrades to an explicit failure state).eventsUrl: trim(env.EVENTS_URL || ''),eventsRelayIntervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),@@@ -47,14 +47,16 @@ const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');/*** 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 (REVIEWS_PGLITE_DIR overrides the directory — how a test gets 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 reviews)');-- log.warn(`[Reviews] 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: 'reviews', registry, log });++ const dir = config.db.pgliteDir || DEV_PGLITE;++ log.warn(`[Reviews] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);++ fs.mkdirSync(dir, { recursive: true });++ const db = createDb({ pglite: dir, service: 'reviews', registry, log });await db.migrate({ dir: MIGRATIONS, log });return db;}@@@ -16,19 +16,23 @@ const { createSourcesSync } = require('./reviews/sync');const { createKeyStore } = require('./auth/keys');const { createViewerResolver } = require('./auth/viewer');const { createApp } = require('./app');++const { createIndexNow } = require('openvibe-shared/indexnow');--async function start({ config, db: givenDb = null, now = () => Date.now(), fetchImpl = globalThis.fetch, tokens = null, publicKey = null, log = console, listen = true, workers = listen, rateLimits = true, limitsNow = null } = {}) {++async function start({ config, db: givenDb = null, now = () => Date.now(), fetchImpl = globalThis.fetch, tokens = null, publicKey = null, log = console, listen = true, workers = listen, rateLimits = true, limitsNow = null, indexnow: givenIndexnow = undefined } = {}) {config = config || load();// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.const db = givenDb || await openDb(config, { log });const stores = createStores(db, { now });const platform = createPlatform({ config, db, fetchImpl, tokens, now, log });-- const svc = createReviewsService({ db, stores, outbox: platform.outbox, config, now, log });++ // IndexNow (openvibe-shared/indexnow): created once at boot from INDEXNOW_KEY. Unset → off, nothing++ // mounted, nothing sent; tests and drills never set it (nor hand one in).++ const indexnow = givenIndexnow !== undefined ? givenIndexnow : createIndexNow({ host: config.baseUrl, key: config.indexnow.key, fetch: fetchImpl, log });++ const svc = createReviewsService({ db, stores, outbox: platform.outbox, config, now, log, indexnow });const sync = createSourcesSync({ db, svc, platform, config, now, log });const keys = createKeyStore({ config, fetchImpl, log, publicKey });keys.ensure().catch(() => {});const viewers = createViewerResolver({ keys, config });-- const app = createApp({ config, svc, viewers, platform, sync, keys, db, log, rateLimits, fetchImpl, limitsNow });++ const app = createApp({ config, svc, viewers, platform, sync, keys, db, log, rateLimits, fetchImpl, limitsNow, indexnow });// Search holds whatever the current rules say (idempotent: unchanged documents are not re-sent).try { await svc.reconcileIndex(); } catch (err) { log.warn(`[Reviews] index reconcile: ${err.message}`); }@@@ -58,7 +62,7 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetchif (server) await new Promise((resolve) => server.close(resolve));if (!givenDb) await db.close();}-- return { app, db, svc, sync, stores, platform, keys, viewers, server, config, stop };++ return { app, db, svc, sync, stores, platform, keys, viewers, indexnow, server, config, stop };}module.exports = { start };@@@ -55,7 +55,7 @@ const RATING_TEXT_RE = new RegExp([const toIso = (ms) => (ms == null ? null : new Date(ms).toISOString());--function createReviewsService({ db, stores, outbox, config, now = () => Date.now(), log = console }) {++function createReviewsService({ db, stores, outbox, config, now = () => Date.now(), log = console, indexnow = null }) {const { revisions, reviews, redirects, discussions, sequencer } = stores;const access = createAccess(config);const origin = config.baseUrl;@@@ -158,6 +158,10 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.nowconst tx = async (fn) => await db.tx(async () => await fn()); // ambient: plain db calls inside join itconst entityPath = (e) => `/e/${encodeURIComponent(e.slug)}`;const entityUrl = (e) => seo.canonicalUrl(origin, entityPath(e));++ const sitemapUrl = seo.canonicalUrl(origin, '/sitemap.xml');++ // IndexNow (openvibe-shared/indexnow): tell the engines when a public, indexable entity page++ // appears, changes or disappears. Never for drafts, private or noindex pages; off without a key.++ const pinger = indexnow && indexnow.enabled ? indexnow : null;const parse = (s, d) => { try { return s == null ? d : JSON.parse(s); } catch { return d; } };function actorId(actor) {@@@ -1108,6 +1112,7 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.nowreturn await tx(async () => {const e = await entityOrFail(ref);if (e.state !== 'active') fail(409, 'entity.not_active', `This entity is ${e.state}`);++ const wasIndexable = pinger ? (await decide(e)).indexable : false;const next = { ...e };const changes = {};if (name != null && name !== e.name) { next.name = checkName(name); changes.name = [e.name, next.name]; await insertAlias(e.id, 'name', next.name, actor); }@@@ -1126,7 +1131,13 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now.run(next.name, next.kind, next.description, next.slug, next.noindex, now(), e.id);if (Object.keys(changes).length) await audit(actor, 'entity.updated', e.id, null, changes);await syncEntity(e.id);-- return await q.entity.get(e.id);++ const fresh = await q.entity.get(e.id);++ // IndexNow: the old URL changed or left the index; the new one appeared or changed.++ if (pinger) {++ if (wasIndexable) pinger.pingSoon([entityUrl(e), sitemapUrl]);++ if ((await decide(fresh)).indexable) pinger.pingSoon([entityUrl(fresh), sitemapUrl]);++ }++ return fresh;});},@@@ -1135,12 +1146,15 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.nowreturn await tx(async () => {const e = await entityOrFail(ref);if (e.state !== 'active') fail(409, 'entity.not_active', `This entity is ${e.state}`);++ const wasIndexable = pinger ? (await decide(e)).indexable : false;if ((await q.mergedInto.all(e.id)).length) fail(409, 'entity.has_merges', 'Split the entities merged into this one first');const active = await q.activeSignalsOfEntity.all(e.id);if (active.length) fail(409, 'entity.has_signals', 'This entity has live signals: reattribute or ignore their items first');await db.prepare("UPDATE review_entities SET state = 'deleted', deleted_at = ?, updated_at = ? WHERE id = ?").run(now(), now(), e.id);await audit(actor, 'entity.deleted', e.id, null, { note });await syncEntity(e.id);++ // IndexNow: the page left the index (only if it was a public, indexable page).++ if (pinger && wasIndexable) pinger.pingSoon([entityUrl(e), sitemapUrl]);return await q.entity.get(e.id);});},@@@ -1468,6 +1482,7 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.nowif (rev.meta && rev.meta.system && !(review && review.decision === 'approved')) fail(409, 'summary.review_required', 'A revision prepared after a source change is published only after an editor approves it');const bad = (await citationState(e.id, rev)).filter((p) => !p.supported);if (bad.length) fail(409, 'summary.unsupported_points', `${bad.length} point(s) cite no live signal of this entity`, { points: bad.map((p) => p.key) });++ const wasIndexable = pinger ? (await decide(e)).indexable : false;const wasPublished = summary.state === 'published';const before = summary.published_revision;await q.publishSummary.run({ id: summary.id, n, now: now() });@@@ -1485,6 +1500,8 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.nowextra: { entity_id: e.id, entity_slug: fresh.slug, authorship: rec ? hooks.AUTHORSHIP[rec.mode] : null, ...(rev.meta && rev.meta.correction ? { correction: { note: rev.meta.correction.note, corrects: rev.meta.correction.corrects } } : {}) },}));}++ // IndexNow: the entity page appeared or changed; never a draft or a non-indexable page.++ if (pinger && (decision.indexable || wasIndexable)) pinger.pingSoon([entityUrl(fresh), sitemapUrl]);return await q.summary.get(e.id);});},@@@ -1495,13 +1512,17 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.nowconst e = await entityOrFail(ref);const summary = await q.summary.get(e.id);if (!summary || summary.state !== 'published') fail(409, 'summary.not_published', 'Nothing is published');++ const wasIndexable = pinger ? (await decide(e)).indexable : false;await q.unpublishSummary.run(now(), summary.id);await audit(actor, 'summary.unpublished', e.id, summary.id, { revision: summary.published_revision });const doc = await syncEntity(e.id);const fresh = await q.entity.get(e.id);++ const decision = await decide(fresh);++ // IndexNow: the page changed or left the index; only when it was or is a public, indexable page.++ if (pinger && (wasIndexable || decision.indexable)) pinger.pingSoon([entityUrl(fresh), sitemapUrl]);await emit(hooks.publicationEvent({product: 'reviews', type: 'summary', action: 'unpublished', id: summary.id, revision: summary.published_revision,-- actor: hooks.subjectRef(actorId(actor)), decision: await decide(fresh), now: now(),++ actor: hooks.subjectRef(actorId(actor)), decision, now: now(),document: { owner: 'reviews', type: 'summary', id: summary.id, revision: doc ? doc.revision : 0, deleted: true },extra: { entity_id: e.id, entity_slug: fresh.slug },}));@@@ -147,7 +147,7 @@ function fakeCommunity() {* (default false) turns on the per-address and per-actor limits; limitsNow is the per-actor limiter's* clock; log is Reviews' logger (default quiet).*/--async function boot({ env = {}, sources = fakeSources(), community = null, now, workers = false, rateLimits = false, limitsNow = null, log = quiet } = {}) {++async function boot({ env = {}, sources = fakeSources(), community = null, now, workers = false, rateLimits = false, limitsNow = null, log = quiet, indexnow } = {}) {const config = load({NODE_ENV: 'test', PORT: '0', HOST: '127.0.0.1', BASE_URL: 'http://reviews.test',OV_NETWORK_URL: ISSUER, OV_NETWORK_INTERNAL_URL: '', REVIEWS_GATE_MIN_WORDS: '20',@@@ -164,7 +164,7 @@ async function boot({ env = {}, sources = fakeSources(), community = null, now,// One database per boot (PGlite, or REVIEWS_TEST_STORE=pg: the containers), dropped when the boot stops.const testdb = await testDb();const h = await start({-- config, db: testdb.db, publicKey, log, listen: true, workers, rateLimits, limitsNow, now, fetchImpl,++ config, db: testdb.db, publicKey, log, listen: true, workers, rateLimits, limitsNow, now, fetchImpl, indexnow,tokens: { getToken: async () => 'stub-token', authHeaders: async () => ({ Authorization: 'Bearer stub-token' }), invalidate() {} },});const base = `http://127.0.0.1:${h.server.address().port}`;nnew file mode 100644@@@ -0,0 +1,71 @@++'use strict';++/**++ * IndexNow (openvibe-shared/indexnow): 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++ * entity page pings the engines with the entity path and the sitemap. Drafts never ping.++ */++const assert = require('assert');++const { boot, req, suite, editorToken, steamItem, importItem, createEntity } = require('./helpers');++++const t = suite('indexnow');++const KEY = 'k'.repeat(32);++const OVERVIEW = 'Most sampled Steam reviewers recommend this game, and the sample here is broad enough to clear the thin-content gate for the entity page in this test without difficulty.';++++/** An entity with three live signals and their ids, resolvable through the steam source binding. */++async function entityWithSignals(h) {++ const e = await createEntity(h, { name: 'Portal 2', kind: 'game', aliases: [{ type: 'source', value: 'steam-reviews-portal-2' }] });++ for (let i = 0; i < 3; i++) await importItem(h, steamItem({ votedUp: i < 2 }));++ const sigs = (await h.db.prepare("SELECT id FROM review_signals WHERE entity_id = ? AND status = 'active' ORDER BY id").all(e.id)).map((s) => s.id);++ return { e, sigs };++}++++t('without a key IndexNow is off: no key route and nothing sent', async () => {++ const h = await boot();++ try {++ assert.strictEqual(h.indexnow.enabled, false, 'IndexNow is off without a key');++ const res = await req(h, 'GET', `/${KEY}.txt`);++ assert.strictEqual(res.status, 404, res.text);++ } finally { await h.stop(); }++});++++t('with a key the key file answers text/plain with the key', async () => {++ const h = await boot({ env: { INDEXNOW_KEY: KEY } });++ try {++ assert.strictEqual(h.indexnow.enabled, true);++ const res = await req(h, 'GET', `/${KEY}.txt`);++ assert.strictEqual(res.status, 200, res.text);++ assert.match(res.headers.get('content-type'), /text\/plain/);++ assert.strictEqual(res.text, KEY);++ } finally { await h.stop(); }++});++++t('a draft never pings; a publish pings the page path and the sitemap; an unpublish pings again', async () => {++ // A spy in place of the module's HTTP send: records every pingSoon batch.++ const pings = [];++ const spy = {++ enabled: true,++ keyFile: (_req, _res, next) => next(),++ pingSoon: (urls) => { const a = Array.isArray(urls) ? urls : [urls]; pings.push(...a); return a.length; },++ ping: async () => ({ sent: 0, status: 0 }),++ flush: async () => ({ sent: 0, status: 0 }),++ };++ const h = await boot({ indexnow: spy });++ try {++ const { e, sigs } = await entityWithSignals(h);++++ const draft = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/revisions`, { token: editorToken(), body: { overview: OVERVIEW, overview_signals: sigs } });++ assert.strictEqual(draft.status, 201, draft.text);++ assert.deepStrictEqual(pings, [], 'a draft must not ping');++++ const pub = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/revisions`, { token: editorToken(), body: { overview: OVERVIEW, overview_signals: sigs, publish: true } });++ assert.strictEqual(pub.status, 201, pub.text);++ assert.ok(pings.includes('http://reviews.test/e/portal-2'), JSON.stringify(pings));++ assert.ok(pings.includes('http://reviews.test/sitemap.xml'), JSON.stringify(pings));++++ pings.length = 0;++ const unpub = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/unpublish`, { token: editorToken() });++ assert.strictEqual(unpub.status, 200, unpub.text);++ assert.ok(pings.includes('http://reviews.test/e/portal-2'), JSON.stringify(pings));++ assert.ok(pings.includes('http://reviews.test/sitemap.xml'), JSON.stringify(pings));++ } finally { await h.stop(); }++});nnew file mode 100644@@@ -0,0 +1,62 @@++'use strict';++// Size budgets for openvibe.reviews' 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: 20, // measured 17.8 (fresh database)++ htmlBrotliKB: 5.5, // 4.6++ jsFiles: 5, // 4++ jsRawKB: 230, // 212.2++ jsBrotliKB: 55, // 49.9++ cssFiles: 2, // 1++ cssRawKB: 7.5, // 6.7++ cssBrotliKB: 2, // 1.7++ 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: '', REVIEWS_PGLITE_DIR: pgliteDir,++ },++ 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 (REVIEWS_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; });
@@ -119,7 +119,9 @@ share is expressed as `ratingValue` 0–100 with `bestRating: 100` and the real
@@ -119,7 +119,9 @@ share is expressed as `ratingValue` 0–100 with `bestRating: 100` and the real
(no live signal → `unsourced`, summary under the word minimum → `thin`, unsupported points →
(no live signal → `unsourced`, summary under the word minimum → `thin`, unsupported points →
`unsupported_claims`, unreviewed AI → hidden); sitemaps list indexable entities only, Atom/JSON feeds
`unsupported_claims`, unreviewed AI → hidden); sitemaps list indexable entities only, Atom/JSON feeds
list published summaries, `robots.txt` and `llms.txt` are served, and Search receives
list published summaries, `robots.txt` and `llms.txt` are served, and Search receives
-`reviews.index_document.upserted|deleted` with Sources items as provenance.
+`reviews.index_document.upserted|deleted` with Sources items as provenance. With `INDEXNOW_KEY` set,
+publishing, editing, unpublishing or deleting an indexable entity page tells IndexNow (the key file is
+served at `/<key>.txt`; unset, nothing is sent, and drafts and noindex pages never ping).
**Operations.** `/api/health`, `/api/ready` (DB required; Network key, Sources, Events relay, webhook
**Operations.** `/api/health`, `/api/ready` (DB required; Network key, Sources, Events relay, webhook
secret, Community and editors reported), `/release.json`, `/metrics` (outbox and backlog gauges).
secret, Community and editors reported), `/release.json`, `/metrics` (outbox and backlog gauges).
@@ -41,7 +41,7 @@ function lagged(read, initial) {
@@ -41,7 +41,7 @@ function lagged(read, initial) {
};
};
}
}
-function createApp({ config, svc, viewers, platform, sync, keys, db, log = console, rateLimits = true, fetchImpl = globalThis.fetch, limitsNow = null }) {
+function createApp({ config, svc, viewers, platform, sync, keys, db, log = console, rateLimits = true, fetchImpl = globalThis.fetch, limitsNow = null, indexnow = null }) {
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
app.set('trust proxy', config.trustProxy);
app.set('trust proxy', config.trustProxy);
@@ -96,6 +96,9 @@ function createApp({ config, svc, viewers, platform, sync, keys, db, log = conso
@@ -96,6 +96,9 @@ function createApp({ config, svc, viewers, platform, sync, keys, db, log = conso
app.use('/auth', createSessionRoutes({ config, viewers, log, fetchImpl }));
app.use('/auth', createSessionRoutes({ config, viewers, log, fetchImpl }));
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'reviews', service: 'reviews', host: 'openvibe.reviews', name: 'OpenVibe.Reviews', profile: 'ugc' })); }
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'reviews', service: 'reviews', host: 'openvibe.reviews', name: 'OpenVibe.Reviews', profile: 'ugc' })); }
+ // GET /<key>.txt — the IndexNow key file (mounted only when a key is configured; it serves itself).
+ if (indexnow && indexnow.enabled) app.use(indexnow.keyFile);
+
// Signed deliveries from OpenVibe.Events. Loopback/internal only in nginx (not proxied publicly).
// Signed deliveries from OpenVibe.Events. Loopback/internal only in nginx (not proxied publicly).
const consumer = consumerRouter({ db, svc, sync, config, log });
const consumer = consumerRouter({ db, svc, sync, config, log });
app.use('/internal', http.middleware(), consumer.router);
app.use('/internal', http.middleware(), consumer.router);
@@ -32,7 +32,9 @@ function load(env = process.env) {
@@ -32,7 +32,9 @@ function load(env = process.env) {
hour: Math.max(1, int(env.REVIEWS_LIMITS_HOUR, 3000)),
hour: Math.max(1, int(env.REVIEWS_LIMITS_HOUR, 3000)),
},
},
// 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 || '' },
+ // Non-production without DATABASE_URL: the embedded PGlite database in data/pglite
+ // (REVIEWS_PGLITE_DIR, tests: an isolated directory of their own).
+ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.REVIEWS_PGLITE_DIR || '' },
valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:reviews:' },
valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:reviews:' },
// Identity: OpenVibe.Network signs user JWTs (SSO) and service tokens (client credentials).
// Identity: OpenVibe.Network signs user JWTs (SSO) and service tokens (client credentials).
@@ -55,6 +57,11 @@ function load(env = process.env) {
@@ -55,6 +57,11 @@ function load(env = process.env) {
},
},
cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
+ // IndexNow (openvibe-shared/indexnow): a key makes search engines recrawl an entity page the
+ // moment it appears, changes or leaves the index (the key file is served at /<key>.txt). Unset →
+ // off: no key file, nothing sent. Tests and drills never set it.
+ indexnow: { key: String(env.INDEXNOW_KEY || '').trim() },
+
// Other services (all optional: each integration degrades to an explicit failure state).
// Other services (all optional: each integration degrades to an explicit failure state).
eventsUrl: trim(env.EVENTS_URL || ''),
eventsUrl: trim(env.EVENTS_URL || ''),
eventsRelayIntervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
eventsRelayIntervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
@@ -47,14 +47,16 @@ const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
@@ -47,14 +47,16 @@ const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
/**
/**
* The serving handle (ADR-035): DATABASE_URL through PgBouncer; in development without it, an embedded PGlite database
* 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 (REVIEWS_PGLITE_DIR overrides the directory — how a test gets its own). Migrations run first, as the
+ * owner (DATABASE_DIRECT_URL), or on the embedded handle.
*/
*/
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 reviews)');
if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh reviews)');
- log.warn(`[Reviews] 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: 'reviews', registry, log });
+ const dir = config.db.pgliteDir || DEV_PGLITE;
+ log.warn(`[Reviews] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);
+ fs.mkdirSync(dir, { recursive: true });
+ const db = createDb({ pglite: dir, service: 'reviews', registry, log });
await db.migrate({ dir: MIGRATIONS, log });
await db.migrate({ dir: MIGRATIONS, log });
return db;
return db;
}
}
@@ -16,19 +16,23 @@ const { createSourcesSync } = require('./reviews/sync');
@@ -16,19 +16,23 @@ const { createSourcesSync } = require('./reviews/sync');
const { createKeyStore } = require('./auth/keys');
const { createKeyStore } = require('./auth/keys');
const { createViewerResolver } = require('./auth/viewer');
const { createViewerResolver } = require('./auth/viewer');
const { createApp } = require('./app');
const { createApp } = require('./app');
+const { createIndexNow } = require('openvibe-shared/indexnow');
-async function start({ config, db: givenDb = null, now = () => Date.now(), fetchImpl = globalThis.fetch, tokens = null, publicKey = null, log = console, listen = true, workers = listen, rateLimits = true, limitsNow = null } = {}) {
+async function start({ config, db: givenDb = null, now = () => Date.now(), fetchImpl = globalThis.fetch, tokens = null, publicKey = null, log = console, listen = true, workers = listen, rateLimits = true, limitsNow = null, indexnow: givenIndexnow = undefined } = {}) {
config = config || load();
config = config || load();
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.
const db = givenDb || await openDb(config, { log });
const db = givenDb || await openDb(config, { log });
const stores = createStores(db, { now });
const stores = createStores(db, { now });
const platform = createPlatform({ config, db, fetchImpl, tokens, now, log });
const platform = createPlatform({ config, db, fetchImpl, tokens, now, log });
- const svc = createReviewsService({ db, stores, outbox: platform.outbox, config, now, log });
+ // IndexNow (openvibe-shared/indexnow): created once at boot from INDEXNOW_KEY. Unset → off, nothing
+ // mounted, nothing sent; tests and drills never set it (nor hand one in).
+ const indexnow = givenIndexnow !== undefined ? givenIndexnow : createIndexNow({ host: config.baseUrl, key: config.indexnow.key, fetch: fetchImpl, log });
+ const svc = createReviewsService({ db, stores, outbox: platform.outbox, config, now, log, indexnow });
const sync = createSourcesSync({ db, svc, platform, config, now, log });
const sync = createSourcesSync({ db, svc, platform, config, now, log });
const keys = createKeyStore({ config, fetchImpl, log, publicKey });
const keys = createKeyStore({ config, fetchImpl, log, publicKey });
keys.ensure().catch(() => {});
keys.ensure().catch(() => {});
const viewers = createViewerResolver({ keys, config });
const viewers = createViewerResolver({ keys, config });
- const app = createApp({ config, svc, viewers, platform, sync, keys, db, log, rateLimits, fetchImpl, limitsNow });
+ const app = createApp({ config, svc, viewers, platform, sync, keys, db, log, rateLimits, fetchImpl, limitsNow, indexnow });
// Search holds whatever the current rules say (idempotent: unchanged documents are not re-sent).
// Search holds whatever the current rules say (idempotent: unchanged documents are not re-sent).
try { await svc.reconcileIndex(); } catch (err) { log.warn(`[Reviews] index reconcile: ${err.message}`); }
try { await svc.reconcileIndex(); } catch (err) { log.warn(`[Reviews] index reconcile: ${err.message}`); }
@@ -58,7 +62,7 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
@@ -58,7 +62,7 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
if (server) await new Promise((resolve) => server.close(resolve));
if (server) await new Promise((resolve) => server.close(resolve));
if (!givenDb) await db.close();
if (!givenDb) await db.close();
}
}
- return { app, db, svc, sync, stores, platform, keys, viewers, server, config, stop };
+ return { app, db, svc, sync, stores, platform, keys, viewers, indexnow, server, config, stop };
}
}
module.exports = { start };
module.exports = { start };
@@ -55,7 +55,7 @@ const RATING_TEXT_RE = new RegExp([
@@ -55,7 +55,7 @@ const RATING_TEXT_RE = new RegExp([
const toIso = (ms) => (ms == null ? null : new Date(ms).toISOString());
const toIso = (ms) => (ms == null ? null : new Date(ms).toISOString());
-function createReviewsService({ db, stores, outbox, config, now = () => Date.now(), log = console }) {
+function createReviewsService({ db, stores, outbox, config, now = () => Date.now(), log = console, indexnow = null }) {
const { revisions, reviews, redirects, discussions, sequencer } = stores;
const { revisions, reviews, redirects, discussions, sequencer } = stores;
const access = createAccess(config);
const access = createAccess(config);
const origin = config.baseUrl;
const origin = config.baseUrl;
@@ -158,6 +158,10 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -158,6 +158,10 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
const tx = async (fn) => await db.tx(async () => await fn()); // ambient: plain db calls inside join it
const tx = async (fn) => await db.tx(async () => await fn()); // ambient: plain db calls inside join it
const entityPath = (e) => `/e/${encodeURIComponent(e.slug)}`;
const entityPath = (e) => `/e/${encodeURIComponent(e.slug)}`;
const entityUrl = (e) => seo.canonicalUrl(origin, entityPath(e));
const entityUrl = (e) => seo.canonicalUrl(origin, entityPath(e));
+ const sitemapUrl = seo.canonicalUrl(origin, '/sitemap.xml');
+ // IndexNow (openvibe-shared/indexnow): tell the engines when a public, indexable entity page
+ // appears, changes or disappears. Never for drafts, private or noindex pages; off without a key.
+ const pinger = indexnow && indexnow.enabled ? indexnow : null;
const parse = (s, d) => { try { return s == null ? d : JSON.parse(s); } catch { return d; } };
const parse = (s, d) => { try { return s == null ? d : JSON.parse(s); } catch { return d; } };
function actorId(actor) {
function actorId(actor) {
@@ -1108,6 +1112,7 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -1108,6 +1112,7 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
return await tx(async () => {
return await tx(async () => {
const e = await entityOrFail(ref);
const e = await entityOrFail(ref);
if (e.state !== 'active') fail(409, 'entity.not_active', `This entity is ${e.state}`);
if (e.state !== 'active') fail(409, 'entity.not_active', `This entity is ${e.state}`);
+ const wasIndexable = pinger ? (await decide(e)).indexable : false;
const next = { ...e };
const next = { ...e };
const changes = {};
const changes = {};
if (name != null && name !== e.name) { next.name = checkName(name); changes.name = [e.name, next.name]; await insertAlias(e.id, 'name', next.name, actor); }
if (name != null && name !== e.name) { next.name = checkName(name); changes.name = [e.name, next.name]; await insertAlias(e.id, 'name', next.name, actor); }
@@ -1126,7 +1131,13 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -1126,7 +1131,13 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
.run(next.name, next.kind, next.description, next.slug, next.noindex, now(), e.id);
.run(next.name, next.kind, next.description, next.slug, next.noindex, now(), e.id);
if (Object.keys(changes).length) await audit(actor, 'entity.updated', e.id, null, changes);
if (Object.keys(changes).length) await audit(actor, 'entity.updated', e.id, null, changes);
await syncEntity(e.id);
await syncEntity(e.id);
- return await q.entity.get(e.id);
+ const fresh = await q.entity.get(e.id);
+ // IndexNow: the old URL changed or left the index; the new one appeared or changed.
+ if (pinger) {
+ if (wasIndexable) pinger.pingSoon([entityUrl(e), sitemapUrl]);
+ if ((await decide(fresh)).indexable) pinger.pingSoon([entityUrl(fresh), sitemapUrl]);
+ }
+ return fresh;
});
});
},
},
@@ -1135,12 +1146,15 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -1135,12 +1146,15 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
return await tx(async () => {
return await tx(async () => {
const e = await entityOrFail(ref);
const e = await entityOrFail(ref);
if (e.state !== 'active') fail(409, 'entity.not_active', `This entity is ${e.state}`);
if (e.state !== 'active') fail(409, 'entity.not_active', `This entity is ${e.state}`);
+ const wasIndexable = pinger ? (await decide(e)).indexable : false;
if ((await q.mergedInto.all(e.id)).length) fail(409, 'entity.has_merges', 'Split the entities merged into this one first');
if ((await q.mergedInto.all(e.id)).length) fail(409, 'entity.has_merges', 'Split the entities merged into this one first');
const active = await q.activeSignalsOfEntity.all(e.id);
const active = await q.activeSignalsOfEntity.all(e.id);
if (active.length) fail(409, 'entity.has_signals', 'This entity has live signals: reattribute or ignore their items first');
if (active.length) fail(409, 'entity.has_signals', 'This entity has live signals: reattribute or ignore their items first');
await db.prepare("UPDATE review_entities SET state = 'deleted', deleted_at = ?, updated_at = ? WHERE id = ?").run(now(), now(), e.id);
await db.prepare("UPDATE review_entities SET state = 'deleted', deleted_at = ?, updated_at = ? WHERE id = ?").run(now(), now(), e.id);
await audit(actor, 'entity.deleted', e.id, null, { note });
await audit(actor, 'entity.deleted', e.id, null, { note });
await syncEntity(e.id);
await syncEntity(e.id);
+ // IndexNow: the page left the index (only if it was a public, indexable page).
+ if (pinger && wasIndexable) pinger.pingSoon([entityUrl(e), sitemapUrl]);
return await q.entity.get(e.id);
return await q.entity.get(e.id);
});
});
},
},
@@ -1468,6 +1482,7 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -1468,6 +1482,7 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
if (rev.meta && rev.meta.system && !(review && review.decision === 'approved')) fail(409, 'summary.review_required', 'A revision prepared after a source change is published only after an editor approves it');
if (rev.meta && rev.meta.system && !(review && review.decision === 'approved')) fail(409, 'summary.review_required', 'A revision prepared after a source change is published only after an editor approves it');
const bad = (await citationState(e.id, rev)).filter((p) => !p.supported);
const bad = (await citationState(e.id, rev)).filter((p) => !p.supported);
if (bad.length) fail(409, 'summary.unsupported_points', `${bad.length} point(s) cite no live signal of this entity`, { points: bad.map((p) => p.key) });
if (bad.length) fail(409, 'summary.unsupported_points', `${bad.length} point(s) cite no live signal of this entity`, { points: bad.map((p) => p.key) });
+ const wasIndexable = pinger ? (await decide(e)).indexable : false;
const wasPublished = summary.state === 'published';
const wasPublished = summary.state === 'published';
const before = summary.published_revision;
const before = summary.published_revision;
await q.publishSummary.run({ id: summary.id, n, now: now() });
await q.publishSummary.run({ id: summary.id, n, now: now() });
@@ -1485,6 +1500,8 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -1485,6 +1500,8 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
extra: { entity_id: e.id, entity_slug: fresh.slug, authorship: rec ? hooks.AUTHORSHIP[rec.mode] : null, ...(rev.meta && rev.meta.correction ? { correction: { note: rev.meta.correction.note, corrects: rev.meta.correction.corrects } } : {}) },
extra: { entity_id: e.id, entity_slug: fresh.slug, authorship: rec ? hooks.AUTHORSHIP[rec.mode] : null, ...(rev.meta && rev.meta.correction ? { correction: { note: rev.meta.correction.note, corrects: rev.meta.correction.corrects } } : {}) },
}));
}));
}
}
+ // IndexNow: the entity page appeared or changed; never a draft or a non-indexable page.
+ if (pinger && (decision.indexable || wasIndexable)) pinger.pingSoon([entityUrl(fresh), sitemapUrl]);
return await q.summary.get(e.id);
return await q.summary.get(e.id);
});
});
},
},
@@ -1495,13 +1512,17 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -1495,13 +1512,17 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
const e = await entityOrFail(ref);
const e = await entityOrFail(ref);
const summary = await q.summary.get(e.id);
const summary = await q.summary.get(e.id);
if (!summary || summary.state !== 'published') fail(409, 'summary.not_published', 'Nothing is published');
if (!summary || summary.state !== 'published') fail(409, 'summary.not_published', 'Nothing is published');
+ const wasIndexable = pinger ? (await decide(e)).indexable : false;
await q.unpublishSummary.run(now(), summary.id);
await q.unpublishSummary.run(now(), summary.id);
await audit(actor, 'summary.unpublished', e.id, summary.id, { revision: summary.published_revision });
await audit(actor, 'summary.unpublished', e.id, summary.id, { revision: summary.published_revision });
const doc = await syncEntity(e.id);
const doc = await syncEntity(e.id);
const fresh = await q.entity.get(e.id);
const fresh = await q.entity.get(e.id);
+ const decision = await decide(fresh);
+ // IndexNow: the page changed or left the index; only when it was or is a public, indexable page.
+ if (pinger && (wasIndexable || decision.indexable)) pinger.pingSoon([entityUrl(fresh), sitemapUrl]);
await emit(hooks.publicationEvent({
await emit(hooks.publicationEvent({
product: 'reviews', type: 'summary', action: 'unpublished', id: summary.id, revision: summary.published_revision,
product: 'reviews', type: 'summary', action: 'unpublished', id: summary.id, revision: summary.published_revision,
- actor: hooks.subjectRef(actorId(actor)), decision: await decide(fresh), now: now(),
+ actor: hooks.subjectRef(actorId(actor)), decision, now: now(),
document: { owner: 'reviews', type: 'summary', id: summary.id, revision: doc ? doc.revision : 0, deleted: true },
document: { owner: 'reviews', type: 'summary', id: summary.id, revision: doc ? doc.revision : 0, deleted: true },
extra: { entity_id: e.id, entity_slug: fresh.slug },
extra: { entity_id: e.id, entity_slug: fresh.slug },
}));
}));
@@ -147,7 +147,7 @@ function fakeCommunity() {
@@ -147,7 +147,7 @@ function fakeCommunity() {
* (default false) turns on the per-address and per-actor limits; limitsNow is the per-actor limiter's
* (default false) turns on the per-address and per-actor limits; limitsNow is the per-actor limiter's
* clock; log is Reviews' logger (default quiet).
* clock; log is Reviews' logger (default quiet).
*/
*/
-async function boot({ env = {}, sources = fakeSources(), community = null, now, workers = false, rateLimits = false, limitsNow = null, log = quiet } = {}) {
+async function boot({ env = {}, sources = fakeSources(), community = null, now, workers = false, rateLimits = false, limitsNow = null, log = quiet, indexnow } = {}) {
const config = load({
const config = load({
NODE_ENV: 'test', PORT: '0', HOST: '127.0.0.1', BASE_URL: 'http://reviews.test',
NODE_ENV: 'test', PORT: '0', HOST: '127.0.0.1', BASE_URL: 'http://reviews.test',
OV_NETWORK_URL: ISSUER, OV_NETWORK_INTERNAL_URL: '', REVIEWS_GATE_MIN_WORDS: '20',
OV_NETWORK_URL: ISSUER, OV_NETWORK_INTERNAL_URL: '', REVIEWS_GATE_MIN_WORDS: '20',
@@ -164,7 +164,7 @@ async function boot({ env = {}, sources = fakeSources(), community = null, now,
@@ -164,7 +164,7 @@ async function boot({ env = {}, sources = fakeSources(), community = null, now,
// One database per boot (PGlite, or REVIEWS_TEST_STORE=pg: the containers), dropped when the boot stops.
// One database per boot (PGlite, or REVIEWS_TEST_STORE=pg: the containers), dropped when the boot stops.
const testdb = await testDb();
const testdb = await testDb();
const h = await start({
const h = await start({
- config, db: testdb.db, publicKey, log, listen: true, workers, rateLimits, limitsNow, now, fetchImpl,
+ config, db: testdb.db, publicKey, log, listen: true, workers, rateLimits, limitsNow, now, fetchImpl, indexnow,
tokens: { getToken: async () => 'stub-token', authHeaders: async () => ({ Authorization: 'Bearer stub-token' }), invalidate() {} },
tokens: { getToken: async () => 'stub-token', authHeaders: async () => ({ Authorization: 'Bearer stub-token' }), invalidate() {} },
});
});
const base = `http://127.0.0.1:${h.server.address().port}`;
const base = `http://127.0.0.1:${h.server.address().port}`;
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
+'use strict';
+/**
+ * IndexNow (openvibe-shared/indexnow): 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
+ * entity page pings the engines with the entity path and the sitemap. Drafts never ping.
+ */
+const assert = require('assert');
+const { boot, req, suite, editorToken, steamItem, importItem, createEntity } = require('./helpers');
+
+const t = suite('indexnow');
+const KEY = 'k'.repeat(32);
+const OVERVIEW = 'Most sampled Steam reviewers recommend this game, and the sample here is broad enough to clear the thin-content gate for the entity page in this test without difficulty.';
+
+/** An entity with three live signals and their ids, resolvable through the steam source binding. */
+async function entityWithSignals(h) {
+ const e = await createEntity(h, { name: 'Portal 2', kind: 'game', aliases: [{ type: 'source', value: 'steam-reviews-portal-2' }] });
+ for (let i = 0; i < 3; i++) await importItem(h, steamItem({ votedUp: i < 2 }));
+ const sigs = (await h.db.prepare("SELECT id FROM review_signals WHERE entity_id = ? AND status = 'active' ORDER BY id").all(e.id)).map((s) => s.id);
+ return { e, sigs };
+}
+
+t('without a key IndexNow is off: no key route and nothing sent', async () => {
+ const h = await boot();
+ try {
+ assert.strictEqual(h.indexnow.enabled, false, 'IndexNow is off without a key');
+ const res = await req(h, 'GET', `/${KEY}.txt`);
+ assert.strictEqual(res.status, 404, res.text);
+ } finally { await h.stop(); }
+});
+
+t('with a key the key file answers text/plain with the key', async () => {
+ const h = await boot({ env: { INDEXNOW_KEY: KEY } });
+ try {
+ assert.strictEqual(h.indexnow.enabled, true);
+ const res = await req(h, 'GET', `/${KEY}.txt`);
+ assert.strictEqual(res.status, 200, res.text);
+ assert.match(res.headers.get('content-type'), /text\/plain/);
+ assert.strictEqual(res.text, KEY);
+ } finally { await h.stop(); }
+});
+
+t('a draft never pings; a publish pings the page path and the sitemap; an unpublish pings again', async () => {
+ // A spy in place of the module's HTTP send: records every pingSoon batch.
+ const pings = [];
+ const spy = {
+ enabled: true,
+ keyFile: (_req, _res, next) => next(),
+ pingSoon: (urls) => { const a = Array.isArray(urls) ? urls : [urls]; pings.push(...a); return a.length; },
+ ping: async () => ({ sent: 0, status: 0 }),
+ flush: async () => ({ sent: 0, status: 0 }),
+ };
+ const h = await boot({ indexnow: spy });
+ try {
+ const { e, sigs } = await entityWithSignals(h);
+
+ const draft = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/revisions`, { token: editorToken(), body: { overview: OVERVIEW, overview_signals: sigs } });
+ assert.strictEqual(draft.status, 201, draft.text);
+ assert.deepStrictEqual(pings, [], 'a draft must not ping');
+
+ const pub = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/revisions`, { token: editorToken(), body: { overview: OVERVIEW, overview_signals: sigs, publish: true } });
+ assert.strictEqual(pub.status, 201, pub.text);
+ assert.ok(pings.includes('http://reviews.test/e/portal-2'), JSON.stringify(pings));
+ assert.ok(pings.includes('http://reviews.test/sitemap.xml'), JSON.stringify(pings));
+
+ pings.length = 0;
+ const unpub = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/unpublish`, { token: editorToken() });
+ assert.strictEqual(unpub.status, 200, unpub.text);
+ assert.ok(pings.includes('http://reviews.test/e/portal-2'), JSON.stringify(pings));
+ assert.ok(pings.includes('http://reviews.test/sitemap.xml'), JSON.stringify(pings));
+ } finally { await h.stop(); }
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
+'use strict';
+// Size budgets for openvibe.reviews' 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: 20, // measured 17.8 (fresh database)
+ htmlBrotliKB: 5.5, // 4.6
+ jsFiles: 5, // 4
+ jsRawKB: 230, // 212.2
+ jsBrotliKB: 55, // 49.9
+ cssFiles: 2, // 1
+ cssRawKB: 7.5, // 6.7
+ cssBrotliKB: 2, // 1.7
+ 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: '', REVIEWS_PGLITE_DIR: pgliteDir,
+ },
+ 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 (REVIEWS_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; });