openvibers/openvibe.host Commits
Host: tell search engines about new and repriced catalog pages (IndexNow) (#109)
Changes
@@@ -3,6 +3,9 @@ PORT=4910HOST=127.0.0.1NODE_ENV=developmentBASE_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=1DATABASE_URL=DATABASE_DIRECT_URL=@@@ -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`.@@@ -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) }));@@@ -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';}@@@ -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).@@@ -113,6 +113,20 @@ const item = (key, id, fields, time = at, title = id) => ({ id: `itm_${'0'.repeaconst 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); });@@@ -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); });
@@ -3,6 +3,9 @@ PORT=4910
@@ -3,6 +3,9 @@ PORT=4910
HOST=127.0.0.1
HOST=127.0.0.1
NODE_ENV=development
NODE_ENV=development
BASE_URL=http://localhost:4910
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
TRUST_PROXY=1
DATABASE_URL=
DATABASE_URL=
DATABASE_DIRECT_URL=
DATABASE_DIRECT_URL=
@@ -29,6 +29,7 @@ OpenVibe's own static hosting moved to OpenVibe.Space ([below](#static-hosting-m
@@ -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`.
- **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`.
- **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.
- **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`.
- **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`.
- **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`.
@@ -12,6 +12,7 @@ const sharedSeo = require('openvibe-shared/seo');
@@ -12,6 +12,7 @@ const sharedSeo = require('openvibe-shared/seo');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
+const { createIndexNow } = require('openvibe-shared/indexnow');
const configLib = require('./config');
const configLib = require('./config');
const { openStore } = require('./db');
const { openStore } = require('./db');
const { createHostOutbox } = require('./events/outbox');
const { createHostOutbox } = require('./events/outbox');
@@ -61,7 +62,16 @@ async function createApp(opts = {}) {
@@ -61,7 +62,16 @@ async function createApp(opts = {}) {
});
});
const viewers = createViewerResolver({ auth, config, log });
const viewers = createViewerResolver({ auth, config, log });
const community = createCommunity({ store, config, fetchImpl: opts.fetchImpl, 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 catalogIngest = createCatalogIngest({ config, store, catalog, fetchImpl: opts.fetchImpl });
const catalogSearch = createCatalogSearch({ config, store, outbox, log });
const catalogSearch = createCatalogSearch({ config, store, outbox, log });
catalog.onChange(() => { if (config.worker.enabled) catalogSearch.sweepSoon(); });
catalog.onChange(() => { if (config.worker.enabled) catalogSearch.sweepSoon(); });
@@ -137,6 +147,8 @@ async function createApp(opts = {}) {
@@ -137,6 +147,8 @@ async function createApp(opts = {}) {
app.use('/auth', createHostSession({ auth, config }).router(express));
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' })); }
{ 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());
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) {
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('/');
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) }));
res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!res.req.query.v && res.req.query.v === assetVersion(rel) }));
@@ -47,7 +47,13 @@ const clean = (type, input, current, now) => {
@@ -47,7 +47,13 @@ const clean = (type, input, current, now) => {
}
}
return v;
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 db = store.db;
const listeners = [];
const listeners = [];
const changed = (type) => { for (const fn of listeners) { try { fn(type); } catch { /* a listener never breaks a save */ } } };
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 }) {
@@ -109,23 +115,28 @@ function createCatalog({ store, community = null, log = console }) {
if (!v) return 'hold';
if (!v) return 'hold';
const now = store.now();
const now = store.now();
let p = await t.prepare('SELECT * FROM catalog_providers WHERE slug = ?').get(v.provider.slug);
let p = await t.prepare('SELECT * FROM catalog_providers WHERE slug = ?').get(v.provider.slug);
+ const fresh = [];
if (!p) {
if (!p) {
const id = newId('catalogProvider', now);
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);
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 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'];
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) {
if (old.origin !== 'sources' || v.checked_at < Number(old.checked_at)) return 'hold';
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 (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('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);
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 {
} 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 };
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));
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';
return 'applied';
}
}
// An offer post from a feed (server/catalog/offers.js): inserted once, its headline and stated price updated when
// 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 }) {
@@ -146,6 +157,7 @@ function createCatalog({ store, community = null, log = console }) {
} else {
} 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 };
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));
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';
return 'applied';
}
}
@@ -19,6 +19,9 @@ function load(env = process.env) {
@@ -19,6 +19,9 @@ function load(env = process.env) {
nodeEnv,
nodeEnv,
isProduction,
isProduction,
baseUrl,
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
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).
// PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role).
@@ -113,6 +113,20 @@ const item = (key, id, fields, time = at, title = id) => ({ id: `itm_${'0'.repea
@@ -113,6 +113,20 @@ const item = (key, id, fields, time = at, title = id) => ({ id: `itm_${'0'.repea
const result = await on.pull();
const result = await on.pull();
assert.strictEqual(result.unknown, 1); assert.strictEqual(result.after, 1);
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(); }
} finally { await db.close(); }
done();
done();
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });
@@ -65,5 +65,23 @@ const { boot, check, done } = require('./helpers/boot');
@@ -65,5 +65,23 @@ const { boot, check, done } = require('./helpers/boot');
assert.match(xml, /<loc>[^<]*\/terms<\/loc><\/url>/, 'a static page carries no lastmod');
assert.match(xml, /<loc>[^<]*\/terms<\/loc><\/url>/, 'a static page carries no lastmod');
});
});
} finally { await t.close(); }
} 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();
done();
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });