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).
Changes
@@@ -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:4900npx 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")@@@ -39,6 +39,7 @@ const { createPortalRoutes, createReleaseActionRoutes } = require('./http/portalconst { 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());@@@ -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() },};}@@@ -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;}@@@ -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 };}@@@ -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)); });nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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; });
@@ -75,7 +75,18 @@ For the playgrounds to succeed end to end (not Codes' code; configuration elsewh
@@ -75,7 +75,18 @@ For the playgrounds to succeed end to end (not Codes' code; configuration elsewh
## Configuration
## 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
### Per-actor limits
@@ -137,7 +148,7 @@ fnm exec --using=22.22.1 npm run dev # http://localhost:4900
@@ -137,7 +148,7 @@ fnm exec --using=22.22.1 npm run dev # http://localhost:4900
npx openvibe-contracts-check --service codes --src server
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")
## Acceptance (must be true before "done")
@@ -39,6 +39,7 @@ const { createPortalRoutes, createReleaseActionRoutes } = require('./http/portal
@@ -39,6 +39,7 @@ const { createPortalRoutes, createReleaseActionRoutes } = require('./http/portal
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
const { createCodesReadiness } = require('./observability');
const { createCodesReadiness } = require('./observability');
const { createActorLimits } = require('./http/actor-limits');
const { createActorLimits } = require('./http/actor-limits');
+const { createIndexNow } = require('openvibe-shared/indexnow');
const { assetVersion, send } = require('./render/layout');
const { assetVersion, send } = require('./render/layout');
const { html } = require('./render/html');
const { html } = require('./render/html');
@@ -62,13 +63,18 @@ async function createApp(opts = {}) {
@@ -62,13 +63,18 @@ async function createApp(opts = {}) {
const network = createNetworkClient({ config, fetchImpl, log });
const network = createNetworkClient({ config, fetchImpl, log });
const outbox = createCodesOutbox({ db: store.db, config, fetchImpl, now: store.now, log });
const outbox = createCodesOutbox({ db: store.db, config, fetchImpl, now: store.now, log });
const trust = createTrust({ store, outbox });
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 playground = createPlayground({ store, config, network, keys, fetchImpl, log });
const archiver = createArchiver({ config, fetchImpl, log });
const archiver = createArchiver({ config, fetchImpl, log });
// Each enforcing service's /limits.json, for /docs/limits and the project usage page.
// Each enforcing service's /limits.json, for /docs/limits and the project usage page.
const limits = createLimitsReader();
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();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -128,6 +134,9 @@ async function createApp(opts = {}) {
@@ -128,6 +134,9 @@ async function createApp(opts = {}) {
app.use('/auth', sso.routes());
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' })); }
{ 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) ──────
// ── Static assets (content-hashed ?v= → immutable) ──────
// This site's own pinned copy of the OpenVibe Frame's browser files (openvibe-shared/serve).
// This site's own pinned copy of the OpenVibe Frame's browser files (openvibe-shared/serve).
app.use('/shared', require('openvibe-shared/serve').handler());
app.use('/shared', require('openvibe-shared/serve').handler());
@@ -41,7 +41,9 @@ function load(env = process.env) {
@@ -41,7 +41,9 @@ function load(env = process.env) {
},
},
// PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role).
// PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role).
- db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '' },
+ // 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:' },
valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:codes:' },
// OpenVibe.Network: SSO (OAuth2 authorization server with PKCE), JWKS, the developer
// OpenVibe.Network: SSO (OAuth2 authorization server with PKCE), JWKS, the developer
@@ -95,6 +97,11 @@ function load(env = process.env) {
@@ -95,6 +97,11 @@ function load(env = process.env) {
url: trim(env.EVENTS_URL || ''),
url: trim(env.EVENTS_URL || ''),
intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
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() },
};
};
}
}
@@ -27,9 +27,10 @@ const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
@@ -27,9 +27,10 @@ const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
async function openDb(config, { log = console, registry } = {}) {
async function openDb(config, { log = console, registry } = {}) {
if (!config.db.url) {
if (!config.db.url) {
if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh codes)');
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 });
await db.migrate({ dir: MIGRATIONS, log });
return db;
return db;
}
}
@@ -26,8 +26,28 @@ const fail = (status, code, detail, extra) => { throw new ReleaseError(status, c
@@ -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). */
/** Role needed to manage releases of an app in this environment (Network's rule for apps). */
const manageRole = (environment) => (environment === 'production' ? 'admin' : 'developer');
const manageRole = (environment) => (environment === 'production' ? 'admin' : 'developer');
-function createReleases({ store, outbox, trust }) {
+function createReleases({ store, outbox, trust, config, indexnow }) {
const { db } = store;
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) {
async function view(r) {
if (!r) return null;
if (!r) return null;
@@ -137,6 +157,7 @@ function createReleases({ store, outbox, trust }) {
@@ -137,6 +157,7 @@ function createReleases({ store, outbox, trust }) {
await writeLog(r.id, 'published', actor.label);
await writeLog(r.id, 'published', actor.label);
env = await event('codes.app.published', r, actor, { compatibility: JSON.parse(r.compatibility || '{}') });
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 };
return { release: await get(r.id), event_id: env.event_id };
}
}
@@ -160,6 +181,7 @@ function createReleases({ store, outbox, trust }) {
@@ -160,6 +181,7 @@ function createReleases({ store, outbox, trust }) {
await writeLog(r.id, 'deprecated', actor.label, { reason: why, replacement: repl });
await writeLog(r.id, 'deprecated', actor.label, { reason: why, replacement: repl });
env = await event('codes.app.deprecated', r, actor, { 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 };
return { release: await get(r.id), event_id: env.event_id };
}
}
@@ -188,6 +210,7 @@ function createReleases({ store, outbox, trust }) {
@@ -188,6 +210,7 @@ function createReleases({ store, outbox, trust }) {
}, { traceparent: actor.traceparent });
}, { traceparent: actor.traceparent });
}
}
});
});
+ if (wasPublic) pingChange(r.status, 'revoked', r);
return { release: await get(r.id), event_id: env ? env.event_id : null };
return { release: await get(r.id), event_id: env ? env.event_id : null };
}
}
@@ -44,7 +44,7 @@ async function boot(opts = {}) {
@@ -44,7 +44,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const { createStore } = require('../../server/db');
// One database per boot (PGlite, or CODES_TEST_STORE=pg: the containers), dropped when the boot closes.
// One database per boot (PGlite, or CODES_TEST_STORE=pg: the containers), dropped when the boot closes.
const testdb = await require('./db').testDb();
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();
await built.ctx.keys.ensure();
if (opts.relay) built.ctx.outbox.start();
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)); });
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -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; });