openvibers/openvibe.trade Commits

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

OP OpenVibers committed 29 Sept 2026 · 6b255e58e0 · 9 files, +186 −8


Changes

ddiff --git a/.env.example b/.env.example
iindex 26b527e..6ef1bba 100644
---- a/.env.example
++++ b/.env.example
@@@ -70,6 +70,11 @@ OV_SOURCES_INTERNAL_URL=http://127.0.0.1:4720
# Background work (Sources sync + freshness); off: TRADE_WORKER=off
#TRADE_WORKER=on
++# IndexNow (openvibe-shared/indexnow): with a key, search engines are told the moment a public,
++# indexable instrument page appears, changes or leaves the index (the key file is served at
++# /<key>.txt). Unset: off — no key file, nothing sent.
++#INDEXNOW_KEY=
++
# Browser origins that may call /api/v1 with a Bearer Network JWT
#API_CORS_ORIGINS=https://openvibe.network
ddiff --git a/README.md b/README.md
iindex 1a033d1..af7140a 100644
---- a/README.md
++++ b/README.md
@@@ -140,6 +140,17 @@ deployment has no instruments (editors add them), no documents until the source
fields only. No price, offer, rating or invented date.
- Every instrument page has a JSON twin with the same data (`/i/:symbol.json`).
++### IndexNow (openvibe-shared/indexnow)
++
++- With `INDEXNOW_KEY` set, the key file is served at `/<key>.txt` as `text/plain`.
++- Whenever the gate's verdict for an instrument page changes — a person-reviewed context is published
++ or updated, the context is retracted, the instrument is archived, or a price ages past
++ `TRADE_PRICE_MAX_AGE_SEC` — Trade pings `api.indexnow.org` with the page's canonical URL and
++ `/sitemap.xml`; the module batches and debounces (one POST per 30s window). A failed ping never
++ takes a publish down.
++- Drafts, unreviewed AI context, private pages and noindex pages never ping. Unset key: off — no key
++ file, no requests.
++
### Caching (nothing personal in shared caches)
- `public, max-age=60` only for pages identical for everyone, served to anonymous viewers.
@@@ -250,6 +261,8 @@ Each grant is `[client, capability, audience]`:
| AI output is a labelled draft, noindex until a person approves it; advice refused; editor forms; sitemap/Search follow publication. | `test/context.test.js` |
| Sources mapping (SEC filings), deterministic resolution, cursor replay, removals, signed webhook with inbox. | `test/sync.test.js` |
| Disclaimer on every page; no-JS pages; JSON twin = HTML; feeds without invented dates; robots/llms/sitemaps; readiness; the marketplace question is documented and nothing of it exists. | `test/pages.test.js` |
++| IndexNow: off without `INDEXNOW_KEY` (no key route, nothing sent); with one the key file is served at `/<key>.txt` as `text/plain` and publishing or retracting indexable context pings the instrument path and the sitemap; a draft never pings. | `test/indexnow.test.js` |
++| The home page's first load stays inside its size budgets (html/js/css, fresh database, no browser). | `test/perf-budget.test.js` |
| Proposals valid against the released schemas and equal to what the code enforces and emits; every envelope valid. | `test/contracts.test.js` |
## Launch rule
ddiff --git a/server/app.js b/server/app.js
iindex fde862a..5bea5e5 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -25,6 +25,7 @@ const contracts = require('openvibe-contracts');
const { createSsoClient } = require('openvibe-sdk/sso');
const { jwksClient } = require('openvibe-sdk/auth');
const { createServiceOutbox } = require('openvibe-sdk/events');
++const { createIndexNow } = require('openvibe-shared/indexnow');
const configLib = require('./config');
const { openStore } = require('./db');
@@@ -76,6 +77,12 @@ async function createApp(opts = {}) {
intervalMs: config.events.intervalMs, now: store.now, log,
...(fetchImpl ? { fetch: fetchImpl } : {}),
});
++ // 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,
++ });
++ ctx.indexnow = indexnow;
ctx.instruments = createInstruments({ store });
ctx.freshness = createFreshness({ store, config, outbox: ctx.outbox });
ctx.alerts = createAlerts({ store, config, ctx });
@@@ -83,7 +90,7 @@ async function createApp(opts = {}) {
ctx.documents = createDocuments({ store, ctx });
ctx.watchlists = createWatchlists({ store, config, ctx });
ctx.context = createContext({ store, config, ctx });
-- ctx.indexing = createIndexing({ store, config, ctx });
++ ctx.indexing = createIndexing({ store, config, ctx, indexnow });
ctx.reading = createReading({ store, ctx });
ctx.sources = opts.sourcesClient || createSourcesClient({ config, fetchImpl });
ctx.sync = createSync({ store, config, ctx, sources: ctx.sources, log });
@@@ -168,6 +175,9 @@ async function createApp(opts = {}) {
app.use(legalRouter);
}
++ // 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 6a7f729..8aa8742 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -36,7 +36,8 @@ function load(env = process.env) {
},
// PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role).
-- db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '' },
++ // TRADE_PGLITE_DIR overrides the embedded dev database directory (tests: one of their own).
++ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.TRADE_PGLITE_DIR || '' },
valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:trade:' },
// OpenVibe.Network: SSO (OAuth2 authorization server), JWKS, client-credentials tokens.
@@@ -77,6 +78,11 @@ function load(env = process.env) {
// are a sensitive category: nothing is indexable before a person-reviewed context exists.
gate: { minWords: Math.max(int(env.TRADE_GATE_MIN_WORDS, 60), 0) },
++ // IndexNow (openvibe-shared/indexnow): a key makes search engines recrawl an instrument 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() },
++
// OpenVibe.Events: the outbox relay runs only when EVENTS_URL and the client secret are set.
events: {
url: trim(env.EVENTS_URL || ''),
ddiff --git a/server/db.js b/server/db.js
iindex 2bc785f..9e64a52 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -38,14 +38,15 @@ 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 (TRADE_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 trade)');
-- log.warn(`[Trade] 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: 'trade', registry, log });
++ const dir = config.db.pgliteDir || DEV_PGLITE;
++ log.warn(`[Trade] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);
++ fs.mkdirSync(dir, { recursive: true });
++ const db = createDb({ pglite: dir, service: 'trade', registry, log });
await db.migrate({ dir: MIGRATIONS, log });
return db;
}
ddiff --git a/server/domain/indexing.js b/server/domain/indexing.js
iindex 2fedd68..b64010f 100644
---- a/server/domain/indexing.js
++++ b/server/domain/indexing.js
@@@ -25,7 +25,7 @@ const ssr = require('openvibe-publishing/ssr');
const OWNER = 'trade';
--function createIndexing({ store, config, ctx }) {
++function createIndexing({ store, config, ctx, indexnow }) {
const { db } = store;
const latestMonetary = db.prepare(`SELECT observed_at FROM trade_market_observations WHERE instrument_id = ? AND currency IS NOT NULL
ORDER BY observed_at DESC LIMIT 1`);
@@@ -87,6 +87,12 @@ function createIndexing({ store, config, ctx }) {
if (doc.deleted && prev == null) return null;
const stamped = await store.sequencer.stamp(store.db, doc);
if (prev != null && stamped.revision === prev) return null;
++ // IndexNow: tell the engines a public, indexable instrument page appeared or changed — or
++ // that a page which was in the index left it (retracted, archived, stale price). Never for a
++ // page that was never indexable (a draft, private or noindex from the start).
++ if (indexnow && indexnow.enabled && (decision.indexable || prev != null)) {
++ indexnow.pingSoon([ctx.urls.instrument(instrument), ctx.urls.abs('/sitemap.xml')]);
++ }
return await ctx.outbox.emit(hooks.indexEvent({ document: stamped, now: store.now() }), { traceparent });
}
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex a0f6f25..d88e4cd 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -49,7 +49,7 @@ async function boot(opts = {}) {
let built = null;
async function start() {
const config = configLib.load(env);
-- built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, limitsNow: opts.limitsNow });
++ built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, limitsNow: opts.limitsNow, indexnow: opts.indexnow });
await built.ctx.jwks.keys();
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
t.base = `http://127.0.0.1:${server.address().port}`;
ddiff --git a/test/indexnow.test.js b/test/indexnow.test.js
nnew file mode 100644
iindex 0000000..011eefb
---- /dev/null
++++ b/test/indexnow.test.js
@@@ -0,0 +1,74 @@
++'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
++ * instrument page pings the engines with the page's path and the sitemap. A draft never pings.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++const KEY = 'k'.repeat(32);
++const PAGE = 'https://openvibe.trade/i/ACME';
++const WORDS = (n) => Array.from({ length: n }, (_, i) => `word${i}`).join(' ');
++
++(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 acme = await t.instrument({ symbol: 'ACME', name: 'Acme Corp', cik: '1234567' });
++
++ await check('an instrument page that is not indexable never pings', async () => {
++ await t.ctx.indexing.refresh(acme); // active but unreviewed: the gate says noindex
++ assert.deepStrictEqual(pings, []);
++ });
++
++ await check('a draft never pings', async () => {
++ const r = await t.get('/editor/i/ACME/context', { as: t.editor, form: { _csrf: t.csrf(t.editor), body: `Acme is a company. ${WORDS(70)}`, expected_revision: '0' } });
++ assert.strictEqual(r.status, 303, r.text);
++ assert.deepStrictEqual(pings, []);
++ });
++
++ await check('a publish pings the page path and the sitemap', async () => {
++ const head = (await t.ctx.context.head(acme)).number;
++ const r = await t.get('/editor/i/ACME/context', { as: t.editor, form: { _csrf: t.csrf(t.editor), body: `Acme is a company. ${WORDS(70)}`, expected_revision: String(head), publish: '1' } });
++ assert.strictEqual(r.status, 303, r.text);
++ assert.ok(pings.includes(PAGE), JSON.stringify(pings));
++ assert.ok(pings.includes('https://openvibe.trade/sitemap.xml'), JSON.stringify(pings));
++ });
++
++ await check('a retract pings the page and the sitemap again', async () => {
++ pings.length = 0;
++ const r = await t.get('/editor/i/ACME/context/retract', { as: t.editor, form: { _csrf: t.csrf(t.editor) } });
++ assert.strictEqual(r.status, 303, r.text);
++ assert.ok(pings.includes(PAGE), JSON.stringify(pings));
++ assert.ok(pings.includes('https://openvibe.trade/sitemap.xml'), 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..8c6ff67
---- /dev/null
++++ b/test/perf-budget.test.js
@@@ -0,0 +1,63 @@
++'use strict';
++// Size budgets for openvibe.trade's 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: 22, // measured 18.2 (fresh database)
++ htmlBrotliKB: 5.5, // 4.7
++ jsFiles: 5, // 4
++ jsRawKB: 240, // 212.2
++ jsBrotliKB: 56, // 49.9
++ cssFiles: 2, // 1
++ cssRawKB: 6.5, // 4.7
++ cssBrotliKB: 2, // 1.3
++ 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: '', TRADE_PGLITE_DIR: pgliteDir,
++ TRADE_WORKER: 'off',
++ },
++ 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 (TRADE_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; });