openvibers/openvibe.codes Commits

Codes tells search engines when a page changes (plan T11, IndexNow): with INDEXNOW_KEY set the key file is served at /<key>.txt and a release or app page appearing, changing or going away pings the engines (its path and the sitemap); drafts and private pages never; unset, nothing is mounted or sent. A home-page performance budget runs in CI. Tests green (verified).

OP OpenVibers committed 29 Sept 2026 · 98bb8f9a27 · 8 files, +200 −10


Changes

ddiff --git a/README.md b/README.md
iindex d65652a..05e8010 100644
---- a/README.md
++++ b/README.md
@@@ -75,7 +75,18 @@ For the playgrounds to succeed end to end (not Codes' code; configuration elsewh
## Configuration
--See [.env.example](.env.example). Required in production: `OV_OAUTH_CLIENT_SECRET`, `CODES_FORM_SECRET`, `BASE_URL`, `DATABASE_URL` (and `DATABASE_DIRECT_URL` for the boot migrations, run as the owner). Optional: `EVENTS_URL` (relay), `CODES_STAFF_SUBJECTS`, `CODES_PLAYGROUND_*`, `CODES_REGISTRY_TTL_MS`, `CODES_LIMITS_MINUTE` / `CODES_LIMITS_HOUR`.
++See [.env.example](.env.example). Required in production: `OV_OAUTH_CLIENT_SECRET`, `CODES_FORM_SECRET`, `BASE_URL`, `DATABASE_URL` (and `DATABASE_DIRECT_URL` for the boot migrations, run as the owner). Optional: `EVENTS_URL` (relay), `INDEXNOW_KEY` (see below), `CODES_STAFF_SUBJECTS`, `CODES_PLAYGROUND_*`, `CODES_REGISTRY_TTL_MS`, `CODES_LIMITS_MINUTE` / `CODES_LIMITS_HOUR`.
++
++### IndexNow (openvibe-shared/indexnow)
++
++With `INDEXNOW_KEY` set (8–128 hex or alphanumeric characters, what IndexNow's own tools
++generate), the key file is served at `/<key>.txt` as `text/plain` and every public release
++transition tells the engines: publish, deprecate and revoke of a published or deprecated release
++ping `api.indexnow.org` with the release page (while it is published), the app page, `/updates`
++and `/sitemap.xml`; the module batches and debounces these. Nothing pings for a draft, and a
++release that was never public stays silent. Unset: the feature is off — no key file, nothing sent
++(what tests and drills do). The key is not a secret in the credential sense: engines fetch it by
++design. `test/indexnow.test.js`.
### Per-actor limits
@@@ -137,7 +148,7 @@ fnm exec --using=22.22.1 npm run dev # http://localhost:4900
npx openvibe-contracts-check --service codes --src server
```
--Tests: ADR-013 trust-tier migration; secrets never persisted or re-displayed; Network errors surfaced honestly (404/403/409/422/503, unreachable, expired session refresh); scope editor offers only grantable capabilities and refuses forged requests before Network sees them; generated docs match the pinned versions (every contract, capability, event type, SDK module); webhook verification, v2 only (tampering, lengths, malformed headers, the ±300 s window both ways, timestamp header mismatch, a v1-only delivery refused, samples without v1, constant-time, the in-browser verifier agreeing); playgrounds refuse without the grant and call nothing; the usage page (owner, admins and staff only, Network not asked for anyone else, filters forwarded as offered, a Network failure shown as it answered); project export (complete, no secrets, audit for admin+ only, never partial) and delete (owner only, Network first, drafts and runs removed, public releases revoked with an event); manifest validation; release lifecycle and events; no internal key anywhere; PKCE sign-in; readiness, release.json, loopback metrics; the released codes manifest matching the code.
++Tests: ADR-013 trust-tier migration; secrets never persisted or re-displayed; Network errors surfaced honestly (404/403/409/422/503, unreachable, expired session refresh); scope editor offers only grantable capabilities and refuses forged requests before Network sees them; generated docs match the pinned versions (every contract, capability, event type, SDK module); webhook verification, v2 only (tampering, lengths, malformed headers, the ±300 s window both ways, timestamp header mismatch, a v1-only delivery refused, samples without v1, constant-time, the in-browser verifier agreeing); playgrounds refuse without the grant and call nothing; the usage page (owner, admins and staff only, Network not asked for anyone else, filters forwarded as offered, a Network failure shown as it answered); project export (complete, no secrets, audit for admin+ only, never partial) and delete (owner only, Network first, drafts and runs removed, public releases revoked with an event); manifest validation; release lifecycle and events; no internal key anywhere; PKCE sign-in; readiness, release.json, loopback metrics; the released codes manifest matching the code; IndexNow (off without a key, the key file served with a key, a publish pinging the page and the sitemap, a draft never pinging); and the home page's size budgets (`test/perf-budget.test.js`, openvibe-shared/perf-budget).
## Acceptance (must be true before "done")
ddiff --git a/server/app.js b/server/app.js
iindex 9505c41..bafd3b6 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -39,6 +39,7 @@ const { createPortalRoutes, createReleaseActionRoutes } = require('./http/portal
const { createApi } = require('./http/api');
const { createCodesReadiness } = require('./observability');
const { createActorLimits } = require('./http/actor-limits');
++const { createIndexNow } = require('openvibe-shared/indexnow');
const { assetVersion, send } = require('./render/layout');
const { html } = require('./render/html');
@@@ -62,13 +63,18 @@ async function createApp(opts = {}) {
const network = createNetworkClient({ config, fetchImpl, log });
const outbox = createCodesOutbox({ db: store.db, config, fetchImpl, now: store.now, log });
const trust = createTrust({ store, outbox });
-- const releases = createReleases({ store, outbox, trust });
++ // IndexNow (openvibe-shared/indexnow): created once at boot from INDEXNOW_KEY. Unset → off, nothing
++ // mounted, nothing sent; tests and drills never set it.
++ const indexnow = opts.indexnow !== undefined ? opts.indexnow : createIndexNow({
++ host: config.baseUrl, key: config.indexnow.key, ...(fetchImpl ? { fetch: fetchImpl } : {}), log,
++ });
++ const releases = createReleases({ store, outbox, trust, config, indexnow });
const playground = createPlayground({ store, config, network, keys, fetchImpl, log });
const archiver = createArchiver({ config, fetchImpl, log });
// Each enforcing service's /limits.json, for /docs/limits and the project usage page.
const limits = createLimitsReader();
-- const ctx = { config, store, docs, keys, sso, network, outbox, trust, releases, playground, archiver, limits, log };
++ const ctx = { config, store, docs, keys, sso, network, outbox, trust, releases, playground, archiver, limits, indexnow, log };
const app = express();
app.disable('x-powered-by');
@@@ -128,6 +134,9 @@ async function createApp(opts = {}) {
app.use('/auth', sso.routes());
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'codes', service: 'codes', host: 'openvibe.codes', name: 'OpenVibe.Codes', profile: 'ugc' })); }
++ // GET /<key>.txt — the IndexNow key file (mounted only when a key is configured; it serves itself).
++ if (indexnow.enabled) app.use(indexnow.keyFile);
++
// ── Static assets (content-hashed ?v= → immutable) ──────
// This site's own pinned copy of the OpenVibe Frame's browser files (openvibe-shared/serve).
app.use('/shared', require('openvibe-shared/serve').handler());
ddiff --git a/server/config.js b/server/config.js
iindex 845e1b6..266fb82 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -41,7 +41,9 @@ 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 || '' },
++ // In development without DATABASE_URL an embedded PGlite database in data/pglite is used
++ // (CODES_PGLITE_DIR overrides the directory — how a test gets an isolated database of its own).
++ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.CODES_PGLITE_DIR || '' },
valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:codes:' },
// OpenVibe.Network: SSO (OAuth2 authorization server with PKCE), JWKS, the developer
@@@ -95,6 +97,11 @@ function load(env = process.env) {
url: trim(env.EVENTS_URL || ''),
intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
},
++
++ // IndexNow (openvibe-shared/indexnow): a key makes search engines recrawl a page the moment a
++ // public release appears, changes or goes away (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() },
};
}
ddiff --git a/server/db.js b/server/db.js
iindex 804e26c..0c2ad06 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -27,9 +27,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 codes)');
-- log.warn(`[Codes] 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: 'codes', registry, log });
++ const dir = config.db.pgliteDir || DEV_PGLITE;
++ log.warn(`[Codes] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);
++ fs.mkdirSync(dir, { recursive: true });
++ const db = createDb({ pglite: dir, service: 'codes', registry, log });
await db.migrate({ dir: MIGRATIONS, log });
return db;
}
ddiff --git a/server/domain/releases.js b/server/domain/releases.js
iindex b771c18..4c43ac2 100644
---- a/server/domain/releases.js
++++ b/server/domain/releases.js
@@@ -26,8 +26,28 @@ const fail = (status, code, detail, extra) => { throw new ReleaseError(status, c
/** Role needed to manage releases of an app in this environment (Network's rule for apps). */
const manageRole = (environment) => (environment === 'production' ? 'admin' : 'developer');
--function createReleases({ store, outbox, trust }) {
++function createReleases({ store, outbox, trust, config, indexnow }) {
const { db } = store;
++ const site = String((config && config.baseUrl) || '').replace(/\/+$/, '');
++ const abs = (p) => `${site}${p}`;
++
++ /**
++ * IndexNow (openvibe-shared/indexnow): tell the engines when a public, indexable page appears,
++ * changes or goes away. Called after the row moved, so beforeStatus/afterStatus are real. Only
++ * public releases (published, deprecated) count; a draft's own change is never public, and a
++ * revoked release that was never public stays silent. The sitemap and /updates change with them.
++ */
++ function pingChange(beforeStatus, afterStatus, rel) {
++ if (!indexnow || !indexnow.enabled) return;
++ const wasPublic = beforeStatus === 'published' || beforeStatus === 'deprecated';
++ const isPublic = afterStatus === 'published' || afterStatus === 'deprecated';
++ if (!wasPublic && !isPublic) return;
++ const paths = [abs(`/apps/${rel.app_id}`), abs('/updates'), abs('/sitemap.xml')];
++ // The release page itself is indexable only while published; ping it when it became or
++ // stopped being published, so an engine picks it up or drops it.
++ if (beforeStatus === 'published' || afterStatus === 'published') paths.unshift(abs(`/releases/${rel.id}`));
++ indexnow.pingSoon(paths);
++ }
async function view(r) {
if (!r) return null;
@@@ -137,6 +157,7 @@ function createReleases({ store, outbox, trust }) {
await writeLog(r.id, 'published', actor.label);
env = await event('codes.app.published', r, actor, { compatibility: JSON.parse(r.compatibility || '{}') });
});
++ pingChange(r.status, 'published', r);
return { release: await get(r.id), event_id: env.event_id };
}
@@@ -160,6 +181,7 @@ function createReleases({ store, outbox, trust }) {
await writeLog(r.id, 'deprecated', actor.label, { reason: why, replacement: repl });
env = await event('codes.app.deprecated', r, actor, { reason: why, replacement: repl });
});
++ pingChange(r.status, 'deprecated', r);
return { release: await get(r.id), event_id: env.event_id };
}
@@@ -188,6 +210,7 @@ function createReleases({ store, outbox, trust }) {
}, { traceparent: actor.traceparent });
}
});
++ if (wasPublic) pingChange(r.status, 'revoked', r);
return { release: await get(r.id), event_id: env ? env.event_id : null };
}
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex b0db253..221488b 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -44,7 +44,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
// One database per boot (PGlite, or CODES_TEST_STORE=pg: the containers), dropped when the boot closes.
const testdb = await require('./db').testDb();
-- const built = await createApp({ config, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true });
++ const built = await createApp({ config, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true, indexnow: opts.indexnow });
await built.ctx.keys.ensure();
if (opts.relay) built.ctx.outbox.start();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
ddiff --git a/test/indexnow.test.js b/test/indexnow.test.js
nnew file mode 100644
iindex 0000000..a3bf937
---- /dev/null
++++ b/test/indexnow.test.js
@@@ -0,0 +1,77 @@
++'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 a release pings
++ * the engines with the release page, the app page, /updates and the sitemap. A draft never pings.
++ */
++const assert = require('assert');
++const manifests = require('../server/domain/manifests');
++const { boot, check, done } = require('./helpers/boot');
++
++const KEY = 'k'.repeat(32);
++const SITEMAP = 'https://openvibe.codes/sitemap.xml';
++const UPDATES = 'https://openvibe.codes/updates';
++
++(async () => {
++ const off = await boot();
++ await check('without a key IndexNow is off: no key route and nothing sent', async () => {
++ assert.strictEqual(off.ctx.indexnow.enabled, false);
++ const res = await off.get(`/${KEY}.txt`);
++ assert.strictEqual(res.status, 404, res.text);
++ });
++ await off.close();
++
++ const on = await boot({ env: { INDEXNOW_KEY: KEY } });
++ await check('with a key the key file answers text/plain with the key', async () => {
++ assert.strictEqual(on.ctx.indexnow.enabled, true);
++ const res = await on.get(`/${KEY}.txt`);
++ assert.strictEqual(res.status, 200, res.text);
++ assert.match(res.headers.get('content-type'), /text\/plain/);
++ assert.strictEqual(res.text, KEY);
++ });
++ await on.close();
++
++ // 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 t = await boot({ indexnow: spy });
++ const owner = t.network.addUser('rosa');
++ const projectId = await t.project(owner, 'IndexNow');
++ const app = await t.app(owner, projectId, { name: 'Pinger' });
++ const base = `/projects/${projectId}/apps/${app.id}`;
++ const manifest = (version) => JSON.stringify({ ...manifests.template('app', { appId: app.id, projectId, environment: 'sandbox', name: 'Pinger' }), version });
++ let release;
++
++ await check('a draft never pings', async () => {
++ const r = await t.get(`${base}/releases`, { as: owner, form: { kind: 'app', manifest: manifest('1.0.0'), intent: 'create' } });
++ assert.strictEqual(r.status, 303, r.text.slice(0, 400));
++ release = r.headers.get('location').split('/').pop();
++ assert.deepStrictEqual(pings, []);
++ });
++
++ await check('a publish pings the release page, the app page, /updates and the sitemap', async () => {
++ const r = await t.get(`/releases/${release}/publish`, { as: owner, form: {} });
++ assert.strictEqual(r.status, 303, r.text);
++ assert.ok(pings.includes(`https://openvibe.codes/releases/${release}`), JSON.stringify(pings));
++ assert.ok(pings.includes(`https://openvibe.codes/apps/${app.id}`), JSON.stringify(pings));
++ assert.ok(pings.includes(UPDATES), JSON.stringify(pings));
++ assert.ok(pings.includes(SITEMAP), JSON.stringify(pings));
++ });
++
++ await check('a revoke pings the page, the app page and the sitemap', async () => {
++ pings.length = 0;
++ const r = await t.get(`/releases/${release}/revoke`, { as: owner, form: { reason: 'Withdrawn', confirm: '1' } });
++ assert.strictEqual(r.status, 303, r.text);
++ assert.ok(pings.includes(`https://openvibe.codes/releases/${release}`), JSON.stringify(pings));
++ assert.ok(pings.includes(SITEMAP), JSON.stringify(pings));
++ });
++ 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..3de13f6
---- /dev/null
++++ b/test/perf-budget.test.js
@@@ -0,0 +1,62 @@
++'use strict';
++// Size budgets for openvibe.codes' 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 the
++// 2026-09-29 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: 21, // measured 19.5 (fresh database)
++ htmlBrotliKB: 5.6, // 5.1
++ jsFiles: 5, // 4
++ jsRawKB: 225, // 212.2
++ jsBrotliKB: 53, // 49.9
++ cssFiles: 2, // 1
++ cssRawKB: 7.5, // 6.6
++ cssBrotliKB: 2, // 1.6
++ externalFiles: 2, // 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: '', CODES_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 (CODES_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; });