openvibers/openvibe.host Commits

Host: tell search engines about new and repriced catalog pages (IndexNow) (#109)

OP OpenVibers committed 29 days ago · 0195ccbd21 · 7 files, +66 −3

Production has had an `INDEXNOW_KEY` in host.env, but nothing in Host used it. Now the catalog announces the pages a pull actually changes:

- A new plan sends its page and its provider's page.
- A new provider sends its page.
- A price or currency change sends the plan's page (only for an active plan of an active provider).
- A new offer post sends `/catalog/offers`.
- Each batch also carries `/sitemap.xml`. A plan re-checked at the same price sends nothing.

Search documents carry the full check time, so wiring IndexNow through `search-feed` would have re-sent every plan on every check. That's why these are explicit transitions in `applyItem`.

- The key file is served at `/<key>.txt` when a key is configured.
- A malformed key warns and leaves IndexNow off. Unset: off.
- Tests: `catalog-ingest` checks a new plan, a quiet re-check and a price change. `catalog-public` checks the key file with and without a key.
- The README and `.env.example` document the key.

Changes

ddiff --git a/.env.example b/.env.example
iindex 586b07a..9131584 100644
---- a/.env.example
++++ b/.env.example
@@@ -3,6 +3,9 @@ PORT=4910
HOST=127.0.0.1
NODE_ENV=development
BASE_URL=http://localhost:4910
++# IndexNow: with a key (8–128 letters and digits), new and repriced catalog pages are sent to search engines and the key
++# is served at /<key>.txt. Unset: off.
++#INDEXNOW_KEY=
TRUST_PROXY=1
DATABASE_URL=
DATABASE_DIRECT_URL=
ddiff --git a/README.md b/README.md
iindex f7446eb..a822b98 100644
---- a/README.md
++++ b/README.md
@@@ -29,6 +29,7 @@ OpenVibe's own static hosting moved to OpenVibe.Space ([below](#static-hosting-m
- **Staff.** `host.catalog.manage` (Network staff only) covers `POST`/`PATCH /api/v1/catalog/{providers,plans,promos}` and the no-JS forms at `/catalog/manage`.
- **Public reads.** `GET /api/v1/catalog/plans` (filters `kind`, `min_vcpu`, `min_ram_mb`, `min_disk_gb`, `max_price`, `currency`, `location`, `provider`; `sort`; cursor), `/plans/:id` (with price history), `/providers`, `/providers/:slug` and `/promos`.
- **Search.** Every public plan and provider page is an OpenVibe.Search document (`host.index_document.upserted|deleted`, `server/catalog/search-index.js` through openvibe-publishing/search-feed): sent a minute after boot, hourly, after each catalog pull and shortly after a staff change; only changed pages are sent, and a hidden or gone entry becomes a tombstone.
++- **IndexNow.** With `INDEXNOW_KEY`, a catalog pull that adds a plan or a provider, or changes a plan's price, sends those pages and `/sitemap.xml` to search engines in one batch (a re-check at the same price sends nothing); a new offer post sends `/catalog/offers`. The key is served at `/<key>.txt`.
- **Pages.** `/catalog`, `/catalog/providers`, `/catalog/providers/:slug`, `/catalog/plans/:id` and `/catalog/promos`. They are server-rendered and work without JavaScript; the styles are `public/css/catalog.css`.
- **Discussion.** Active public provider and plan pages lazily resolve Community threads with refs `{ service: 'host', type: 'provider', id: provider.id }` and `{ service: 'host', type: 'plan', id: plan.id }` (plus a display label), then render recent comments with a link to the thread on OpenVibe.Community. Host stores only the thread reference. `HOST_COMMUNITY_URL` enables the internal client; `OV_COMMUNITY_URL` sets the public link. With the internal URL unset, no Discussion block appears. Hidden providers and hidden or gone plans have no public page; status changes hide or reopen existing threads after commit. Host's Network service principal needs `community.comment.write` and `community.comment.moderate` for audience `openvibe.community`.
ddiff --git a/server/app.js b/server/app.js
iindex 91f3a3b..6e85c59 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -12,6 +12,7 @@ const sharedSeo = require('openvibe-shared/seo');
const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
const ovServe = require('openvibe-shared/serve');
++const { createIndexNow } = require('openvibe-shared/indexnow');
const configLib = require('./config');
const { openStore } = require('./db');
const { createHostOutbox } = require('./events/outbox');
@@@ -61,7 +62,16 @@ async function createApp(opts = {}) {
});
const viewers = createViewerResolver({ auth, config, log });
const community = createCommunity({ store, config, fetchImpl: opts.fetchImpl, log });
-- const catalog = createCatalog({ store, community, log });
++ // IndexNow: off without INDEXNOW_KEY; a malformed key warns and stays off rather than stopping the site.
++ const indexnow = opts.indexnow || (() => {
++ const warn = (...a) => (log.warn || console.warn)(...a);
++ const fetch = opts.fetchImpl || globalThis.fetch;
++ try { return createIndexNow({ host: config.baseUrl, key: config.indexnow.key, fetch, log: warn }); } catch (err) {
++ warn(`[Host] ${err.message} — IndexNow is off until INDEXNOW_KEY is fixed`);
++ return createIndexNow({ host: config.baseUrl, key: '', fetch, log: warn });
++ }
++ })();
++ const catalog = createCatalog({ store, community, log, indexnow, baseUrl: config.baseUrl });
const catalogIngest = createCatalogIngest({ config, store, catalog, fetchImpl: opts.fetchImpl });
const catalogSearch = createCatalogSearch({ config, store, outbox, log });
catalog.onChange(() => { if (config.worker.enabled) catalogSearch.sweepSoon(); });
@@@ -137,6 +147,8 @@ async function createApp(opts = {}) {
app.use('/auth', createHostSession({ auth, config }).router(express));
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'host', service: 'host', host: 'openvibe.host', name: 'OpenVibe.Host', profile: 'ugc' })); }
app.use('/shared', require('openvibe-shared/serve').handler());
++ // GET /<key>.txt — the IndexNow key file, only when a key is configured.
++ if (indexnow.enabled) app.use(indexnow.keyFile);
app.use(express.static(PUBLIC_DIR, { index: false, redirect: false, etag: true, setHeaders(res, filePath) {
const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!res.req.query.v && res.req.query.v === assetVersion(rel) }));
ddiff --git a/server/catalog/store.js b/server/catalog/store.js
iindex e3ca28e..1f6b4eb 100644
---- a/server/catalog/store.js
++++ b/server/catalog/store.js
@@@ -47,7 +47,13 @@ const clean = (type, input, current, now) => {
}
return v;
};
--function createCatalog({ store, community = null, log = console }) {
++function createCatalog({ store, community = null, log = console, indexnow = null, baseUrl = null }) {
++ // IndexNow (openvibe-shared/indexnow): a page the catalog pull adds or reprices is sent to search engines with the
++ // sitemap, in one batch. Only these transitions announce; a plan re-checked at the same price does not.
++ const announce = (paths) => {
++ if (!paths.length || !indexnow || !indexnow.enabled || !baseUrl) return;
++ indexnow.pingSoon([...paths, '/sitemap.xml'].map((path) => `${baseUrl}${path}`));
++ };
const db = store.db;
const listeners = [];
const changed = (type) => { for (const fn of listeners) { try { fn(type); } catch { /* a listener never breaks a save */ } } };
@@@ -109,23 +115,28 @@ function createCatalog({ store, community = null, log = console }) {
if (!v) return 'hold';
const now = store.now();
let p = await t.prepare('SELECT * FROM catalog_providers WHERE slug = ?').get(v.provider.slug);
++ const fresh = [];
if (!p) {
const id = newId('catalogProvider', now);
await t.prepare('INSERT INTO catalog_providers (id, slug, name, homepage_url, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)').run(id, v.provider.slug, v.provider.name, v.provider.homepage_url, 'active', now, now);
-- p = { id };
++ p = { id, status: 'active' };
++ fresh.push(`/catalog/providers/${encodeURIComponent(v.provider.slug)}`);
}
const old = await t.prepare('SELECT * FROM catalog_plans WHERE provider_id = ? AND external_id = ?').get(p.id, v.external_id);
const cols = ['name', 'kind', 'vcpu', 'ram_mb', 'disk_gb', 'disk_type', 'bandwidth_gb', 'unmetered', 'port_mbps', 'ipv4', 'locations', 'currency', 'price_monthly', 'price_hourly', 'source_key', 'source_item_id', 'source_url', 'checked_at'];
if (old) {
if (old.origin !== 'sources' || v.checked_at < Number(old.checked_at)) return 'hold';
if (Number(old.price_monthly) !== v.price_monthly || old.currency !== v.currency) {
++ if (old.status === 'active' && p.status === 'active') fresh.push(`/catalog/plans/${encodeURIComponent(old.id)}`);
await t.prepare('INSERT INTO catalog_price_changes (plan_id, observed_at, currency, price_monthly_before, price_monthly_after) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING').run(old.id, v.checked_at, v.currency, old.price_monthly, v.price_monthly);
}
await t.prepare(`UPDATE catalog_plans SET ${cols.map((k) => `${k} = ?`).join(', ')}, last_seen_at = ?, updated_at = ? WHERE id = ?`).run(...cols.map((k) => v[k]), v.checked_at, now, old.id);
} else {
const all = { id: newId('catalogPlan', now), provider_id: p.id, external_id: v.external_id, ...Object.fromEntries(cols.map((k) => [k, v[k]])), origin: 'sources', first_seen_at: v.first_seen_at, last_seen_at: v.checked_at, status: 'active', created_at: now, updated_at: now };
await t.prepare(`INSERT INTO catalog_plans (${Object.keys(all).join(', ')}) VALUES (${Object.keys(all).map(() => '?').join(', ')})`).run(...Object.values(all));
++ if (p.status === 'active') fresh.push(`/catalog/plans/${encodeURIComponent(all.id)}`, `/catalog/providers/${encodeURIComponent(v.provider.slug)}`);
}
++ announce([...new Set(fresh)]);
return 'applied';
}
// An offer post from a feed (server/catalog/offers.js): inserted once, its headline and stated price updated when
@@@ -146,6 +157,7 @@ function createCatalog({ store, community = null, log = console }) {
} else {
const all = { id: newId('catalogOffer', now), source_key: v.source_key, external_id: v.external_id, ...Object.fromEntries(cols.map((k) => [k, v[k]])), status: 'active', first_seen_at: v.first_seen_at, created_at: now, updated_at: now };
await t.prepare(`INSERT INTO catalog_offers (${Object.keys(all).join(', ')}) VALUES (${Object.keys(all).map(() => '?').join(', ')})`).run(...Object.values(all));
++ announce(['/catalog/offers']);
}
return 'applied';
}
ddiff --git a/server/config.js b/server/config.js
iindex 91a9036..affb218 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -19,6 +19,9 @@ function load(env = process.env) {
nodeEnv,
isProduction,
baseUrl,
++ // IndexNow (openvibe-shared/indexnow): with a key, each catalog page a pull adds or reprices is sent to search
++ // engines in one batch, and the key is served at /<key>.txt. Unset: off.
++ indexnow: { key: String(env.INDEXNOW_KEY || '').trim() },
trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 1, // nginx (realip) → Node
// PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role).
ddiff --git a/test/catalog-ingest.test.js b/test/catalog-ingest.test.js
iindex 7d590a9..de88411 100644
---- a/test/catalog-ingest.test.js
++++ b/test/catalog-ingest.test.js
@@@ -113,6 +113,20 @@ const item = (key, id, fields, time = at, title = id) => ({ id: `itm_${'0'.repea
const result = await on.pull();
assert.strictEqual(result.unknown, 1); assert.strictEqual(result.after, 1);
});
++ await check('IndexNow: a new plan announces its page and its provider; a re-check at the same price is quiet; a new price announces the plan', async () => {
++ const pinged = [];
++ const indexnow = { enabled: true, pingSoon: (urls) => { pinged.push(...urls); return urls.length; } };
++ const announcing = createCatalog({ store, indexnow, baseUrl: 'https://openvibe.host' });
++ const linode = { vcpus: 2, memory: 4096, disk: 81920, transfer: 4000, price_monthly: 24, price_hourly: 0.036, class: 'standard' };
++ await announcing.applyItem(item('linode-types', 'g6-standard-2', linode, at, 'Linode 4 GB'));
++ const id = (await db.db.prepare("SELECT id FROM catalog_plans WHERE external_id = 'g6-standard-2'").get()).id;
++ assert.deepStrictEqual(pinged, [`https://openvibe.host/catalog/plans/${id}`, 'https://openvibe.host/catalog/providers/linode', 'https://openvibe.host/sitemap.xml']);
++ pinged.length = 0;
++ await announcing.applyItem(item('linode-types', 'g6-standard-2', linode, '2026-10-12T12:00:00Z', 'Linode 4 GB'));
++ assert.deepStrictEqual(pinged, [], 'the same price checked again announces nothing');
++ await announcing.applyItem(item('linode-types', 'g6-standard-2', { ...linode, price_monthly: 26 }, '2026-10-13T12:00:00Z', 'Linode 4 GB'));
++ assert.deepStrictEqual(pinged, [`https://openvibe.host/catalog/plans/${id}`, 'https://openvibe.host/sitemap.xml']);
++ });
} finally { await db.close(); }
done();
})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/catalog-public.test.js b/test/catalog-public.test.js
iindex 262d31f..06c008a 100644
---- a/test/catalog-public.test.js
++++ b/test/catalog-public.test.js
@@@ -65,5 +65,23 @@ const { boot, check, done } = require('./helpers/boot');
assert.match(xml, /<loc>[^<]*\/terms<\/loc><\/url>/, 'a static page carries no lastmod');
});
} finally { await t.close(); }
++
++ // IndexNow's key file: served at /<key>.txt only when INDEXNOW_KEY is set (a computed stand-in key here).
++ const key = ['in', 'dex', 'now'].join('') + '0123456789';
++ const k = await boot({ env: { INDEXNOW_KEY: key } });
++ try {
++ await check('IndexNow: /<key>.txt answers the key as text/plain when a key is configured', async () => {
++ const r = await k.api('GET', `/${key}.txt`);
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers['content-type'], /^text\/plain/);
++ assert.strictEqual(r.text, key);
++ });
++ } finally { await k.close(); }
++ const off = await boot();
++ try {
++ await check('IndexNow: without a key there is no key file', async () => {
++ assert.notStrictEqual((await off.api('GET', `/${key}.txt`)).status, 200);
++ });
++ } finally { await off.close(); }
done();
})().catch((e) => { console.error(e); process.exit(1); });