openvibers/openvibe.sites Commits

Freeze OpenVibe.Sites and retire its placeholder generator now that… (#2)

OP OpenVibers committed 4 Oct 2026 · 951ee184d3 · 8 files, +279 −4

local checks ✓ 3/3 · CI ✓ 3/3 · review SHIP by OpenCode Qwen3.7 Plus · 1 fix round

https://github.com/OpenVibers/OpenVibe.Sites/pull/2

Changes

ddiff --git a/README.md b/README.md
iindex 29390ac..0363f58 100644
---- a/README.md
++++ b/README.md
@@@ -14,7 +14,11 @@ All three are `noindex`. The existing page presentation is held in `notice-pages
## Frozen product pages
--`frozen.json` lists all 32 product domains, including OpenVibe.Actor and OpenVibe.Bot. Their committed front pages remain available as static files with their original indexing, status, and sitemaps. The build does not regenerate them. `node build.js --check` verifies that each frozen domain still has an index page and a Sites vhost where Sites owns one, while checking that notice outputs are current. The old catalog and repository facts snapshots are historical data, not build inputs.
++`frozen.json` lists all 32 product domains, including OpenVibe.Actor and OpenVibe.Bot. Their committed front pages remain available as static files with their original indexing, status, and sitemaps. The build does not regenerate them. `node build.js --check` verifies that each frozen domain still has an index page and a Sites vhost where Sites owns one, while checking that notice outputs are current. The repository facts snapshot is historical data, not a build input.
++
++## Product catalog
++
++The product catalog lives in OpenVibe.Contracts (`products.catalog()`, v0.84.0). `sites.json` is its generated mirror (`node scripts/sync-catalog.js`; `--check` fails when it differs); fields Contracts does not carry keep their frozen value, and a domain missing from the catalog is kept and reported, never dropped. `sites.json` is not a build input. Which product takes over each domain: [docs/retirement.md](docs/retirement.md).
The owner cutover will transfer DNS and nginx ownership one domain at a time after each product can serve its address. That work is outside this repository change. Until then, the existing product pages and vhosts continue to serve their addresses. A missing file path is a real 404 through `try_files ... =404` and `error_page 404 /404.html`.
@@@ -23,10 +27,12 @@ The owner cutover will transfer DNS and nginx ownership one domain at a time aft
```sh
node build.js
node build.js --check
++node scripts/sync-catalog.js --check
ov test test/build.test.js
++ov test test/sync-catalog.test.js
ov test test/deploy-wrapper.test.js
```
--The pinned `openvibe-shared` release supplies the Frame browser files under `dist/_shared/`. The deploy wrapper remains the existing OpenVibe.Host static build path. Sites has no server process, port, token, or environment file. The owner manages certificates and DNS.
++The pinned `openvibe-contracts` release supplies the catalog `sites.json` mirrors; the pinned `openvibe-shared` release supplies the Frame browser files under `dist/_shared/`. The deploy wrapper remains the existing OpenVibe.Host static build path. Sites has no server process, port, token, or environment file. The owner manages certificates and DNS.
Report security issues through [SECURITY.md](SECURITY.md).
ddiff --git a/STATUS.json b/STATUS.json
iindex 8a5fcf3..e4b622f 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -38,11 +38,12 @@
"realtime.openvibe.network (closed notice)",
"status.openvibe.network (status notice)"
],
-- "contracts": "none: package.json pins no openvibe-contracts (openvibe-shared v2.5.0 only)",
++ "contracts": "openvibe-contracts v0.84.0: sites.json mirrors products.catalog() (scripts/sync-catalog.js)",
"deployed": true,
"deployment": "Deployed with `sudo ovhost deploy sites --restart` (OpenVibe.Host strategy static-build since the 2026-09-27 cutover): /opt/openvibe.sites, nginx serves dist/<domain>/ through deploy/nginx/<domain>.conf; no unit, no port, no env file",
"features": [
"three generated notices and 32 frozen product domains",
++ "sites.json generated from the OpenVibe.Contracts product catalog",
"notice pages use preserved presentation; product pages are no longer generated",
"the pinned OpenVibe Frame served from dist/_shared/",
"noindex notices with robots, manifest and real 404 pages",
ddiff --git a/docs/retirement.md b/docs/retirement.md
nnew file mode 100644
iindex 0000000..99fcf04
---- /dev/null
++++ b/docs/retirement.md
@@@ -0,0 +1,66 @@
++# Retiring OpenVibe.Sites
++
++Sites is frozen (plan T11 lane D, audit item 12, 2026-10-02). The product catalog now lives in
++OpenVibe.Contracts (`products.catalog()`, v0.84.0), and `sites.json` is a generated mirror of it
++(`node scripts/sync-catalog.js`). The placeholder pages in `dist/<domain>/` and the vhosts in `deploy/nginx/`
++are no longer generated. They stay as committed, and production keeps serving every domain from them until
++the product named below serves that domain itself. Only then is the domain's host file removed here, in
++the release in which the product's own vhost is installed.
++
++"Own host config" means a `deploy/nginx/<domain>.conf` with that `server_name` on the product repository's
++`origin/main`, as of the local refs read on 2026-10-02. "Park: DNS only" means no repository will serve the
++domain yet. When the Sites vhost goes, the record stays in DNS (Cloudflare) and nothing on the host answers it.
++
++The Sites repository had 33 host files on 2026-10-02 (the plan said 36), one per `sites.json` entry. Since then OpenVibe.Bot
++supplies the `openvibe.bot` vhost (Sites keeps publishing its frozen pages), and `openvibe.work` and `openvibe.zone` were
++added; they have no Contracts manifest yet, so `sync-catalog.js` keeps them as they are. That makes 34 host files for 35
++`sites.json` entries.
++
++| Host file | Sites serves today | Goes to | Own host config on origin/main |
++|---|---|---|---|
++| `admin.openvibe.network.conf` | placeholder | OpenVibe.Network | no (`openvibe.network.conf` names only `openvibe.network`, `my.openvibe.network`) |
++| `ai.openvibe.network.conf` | moved notice | OpenVibe.AI | yes: `deploy/nginx/ai.openvibe.network.conf` (a reference vhost for the old address; Contracts says AI moved to `ai.openvibe.services`) |
++| `ai.openvibe.services.conf` | placeholder | OpenVibe.AI | no |
++| `api.openvibe.network.conf` | placeholder | OpenVibe.Network | no |
++| `auth.openvibe.network.conf` | placeholder | OpenVibe.Network | no |
++| `openre.stream.conf` | placeholder | OpenRe.Stream | yes: `deploy/nginx/openre.stream.conf` |
++| `openvibe.actor.conf` | placeholder (no repo) | park: DNS only (OpenVibe.Node exists but has no deploy config and does not run) | no |
++| (`openvibe.bot`, no Sites host file) | placeholder (no repo) | OpenVibe.Bot, which already supplies the vhost; Sites keeps only the frozen pages | yes (Contracts still says `noRepo`) |
++| `openvibe.coupons.conf` | placeholder | OpenVibe.Coupons | yes: `deploy/nginx/openvibe.coupons.conf` |
++| `openvibe.deals.conf` | placeholder | OpenVibe.Deals | yes: `deploy/nginx/openvibe.deals.conf` |
++| `openvibe.download.conf` | placeholder (no repo) | park: DNS only (Contracts names OpenVibe.MediaHub, which does not exist yet) | no |
++| `openvibe.events.conf` | placeholder | OpenVibe.Events | no (only `deploy/nginx/events.openvibe.network.conf`) |
++| `openvibe.food.conf` | placeholder (no repo) | park: DNS only | no |
++| `openvibe.help.conf` | placeholder (no repo) | park: DNS only | no |
++| `openvibe.homes.conf` | placeholder (no repo) | park: DNS only | no |
++| `openvibe.host.conf` | placeholder | OpenVibe.Host | yes: `deploy/nginx/openvibe.host.conf` (STATUS.json: loopback only, not launched) |
++| `openvibe.news.conf` | placeholder | OpenVibe.News | yes: `deploy/nginx/openvibe.news.conf` |
++| `openvibe.pics.conf` | placeholder (no repo) | park: DNS only (Contracts names OpenVibe.MediaHub, which does not exist yet) | no |
++| `openvibe.quest.conf` | placeholder (no repo) | park: DNS only | no |
++| `openvibe.rent.conf` | placeholder (no repo) | park: DNS only | no |
++| `openvibe.reviews.conf` | placeholder | OpenVibe.Reviews | yes: `deploy/nginx/openvibe.reviews.conf` |
++| `openvibe.run.conf` | placeholder (no repo) | park: DNS only | no |
++| `openvibe.services.conf` | placeholder (no repo) | park: DNS only | no |
++| `openvibe.space.conf` | placeholder (no repo) | park: DNS only (Contracts' `space` manifest names OpenVibe.Space, which does not exist yet) | no |
++| `openvibe.tips.conf` | placeholder | OpenVibe.Tips | yes: `deploy/nginx/openvibe.tips.conf` |
++| `openvibe.trade.conf` | placeholder | OpenVibe.Trade | yes: `deploy/nginx/openvibe.trade.conf` |
++| `openvibe.video.conf` | placeholder (no repo) | park: DNS only (Contracts names OpenVibe.MediaHub, which does not exist yet) | no |
++| `openvibe.vip.conf` | placeholder | OpenVibe.VIP | yes: `deploy/nginx/openvibe.vip.conf` |
++| `openvibe.watch.conf` | placeholder (no repo) | park: DNS only | no |
++| `openvibe.website.conf` | placeholder (no repo) | park: DNS only | no |
++| `openvibe.work.conf` | placeholder (no repo) | park: DNS only (not in the Contracts catalog yet) | no |
++| `openvibe.zone.conf` | placeholder (no repo) | park: DNS only (not in the Contracts catalog yet) | no |
++| `realtime.openvibe.network.conf` | closed notice | park: DNS only (Realtime is closed; delivery is part of OpenVibe.Events at `events.openvibe.network`) | no |
++| `status.openvibe.network.conf` | status notice | OpenVibe.Network: its status page `openvibe.network/status` (a redirect from this host) | no |
++| `themes.openvibe.network.conf` | placeholder | OpenVibe.Network | no |
++
++Totals: 18 go to a product repository; 11 of those have their own host config (10 on `origin/main` as of 2026-10-02, and OpenVibe.Bot's). The other 17 are parked: DNS only.
++
++The Sites vhost for a domain is removed only when all of these are true:
++
++1. The product's own vhost is installed on the host behind `nginx -t`.
++2. The product's Contracts manifest shows it serving the domain (`exposure.state: live`).
++3. In one Sites change, the domain's entry, its `dist/<domain>/` and its vhost are removed. `sync-catalog.js` keeps any domain
++ that is no longer in the catalog, so a domain never loses its page by accident.
++
++A Sites vhost that leaves `deploy/nginx/` stays installed on the host until someone removes it by hand (see the README's Deploy section).
ddiff --git a/package-lock.json b/package-lock.json
iindex be26860..ae42d51 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -6,12 +6,46 @@
"": {
"name": "openvibe-sites",
"dependencies": {
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.84.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0"
},
"engines": {
"node": ">=20"
}
},
++ "node_modules/ajv": {
++ "version": "8.20.0",
++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
++ "license": "MIT",
++ "dependencies": {
++ "fast-deep-equal": "^3.1.3",
++ "fast-uri": "^3.0.1",
++ "json-schema-traverse": "^1.0.0",
++ "require-from-string": "^2.0.2"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/epoberezkin"
++ }
++ },
++ "node_modules/ajv-formats": {
++ "version": "3.0.1",
++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
++ "license": "MIT",
++ "dependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependenciesMeta": {
++ "ajv": {
++ "optional": true
++ }
++ }
++ },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@@ -27,6 +61,34 @@
"safe-buffer": "^5.0.1"
}
},
++ "node_modules/fast-deep-equal": {
++ "version": "3.1.3",
++ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
++ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
++ "license": "MIT"
++ },
++ "node_modules/fast-uri": {
++ "version": "3.1.8",
++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
++ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fastify"
++ },
++ {
++ "type": "opencollective",
++ "url": "https://opencollective.com/fastify"
++ }
++ ],
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/json-schema-traverse": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
++ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
++ "license": "MIT"
++ },
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@@ -118,6 +180,22 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
++ "node_modules/openvibe-contracts": {
++ "version": "0.84.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.84.0",
++ "integrity": "sha512-wnmQbzffd2xXSGk3Zr1JxZYIL4oSlnJTo8lGpUApBPoDKeQ3U4VCLJgt3cZpbtkbl5NH1qNjBDXkwx+uASwoyA==",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "ajv": "^8.17.1",
++ "ajv-formats": "^3.0.1"
++ },
++ "bin": {
++ "openvibe-contracts-check": "bin/check-service.js"
++ },
++ "engines": {
++ "node": ">=20"
++ }
++ },
"node_modules/openvibe-shared": {
"version": "2.5.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
@@@ -130,6 +208,15 @@
"openvibe-build-assets": "scripts/build-assets.js"
}
},
++ "node_modules/require-from-string": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
ddiff --git a/package.json b/package.json
iindex 1424ba2..5ec82e3 100644
---- a/package.json
++++ b/package.json
@@@ -1,12 +1,13 @@
{
"name": "openvibe-sites",
"private": true,
-- "description": "Static notices and frozen product pages for OpenVibe domains",
++ "description": "Static notices and frozen product pages for OpenVibe domains; sites.json mirrors the OpenVibe.Contracts product catalog",
"scripts": {
"build": "node build.js",
"test": "node test/run.js"
},
"dependencies": {
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.84.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0"
},
"engines": {
ddiff --git a/scripts/sync-catalog.js b/scripts/sync-catalog.js
nnew file mode 100644
iindex 0000000..f5fd823
---- /dev/null
++++ b/scripts/sync-catalog.js
@@@ -0,0 +1,79 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * sites.json is a generated mirror of the product catalog in OpenVibe.Contracts (plan T11 lane D,
++ * audit item 12): the pinned openvibe-contracts' `products.catalog()`, one row per domain, from
++ * manifests/products/<domain>.json or a service manifest's `site` block.
++ *
++ * node scripts/sync-catalog.js → rewrite sites.json from the catalog
++ * node scripts/sync-catalog.js --check → exit 1 when sites.json differs (npm test)
++ *
++ * Contracts writes links as objects ({ title, text }, { question, answer }, { label, url, note });
++ * sites.json keeps the arrays the frozen pages were built from. What Contracts does not carry keeps
++ * the value sites.json has today (frozen, never dropped): the `network` block, LOCAL fields, and for
++ * a service-home row SERVICE_LOCAL (a service manifest's site name/icon/tagline/legalProfile are the
++ * console card's, not the page's). A sites.json domain missing from the catalog is kept and reported.
++ */
++const fs = require('fs');
++const path = require('path');
++const { catalog } = require('openvibe-contracts/lib/products');
++
++const FILE = path.join(__dirname, '..', 'sites.json');
++// Every field a sites.json entry may carry, in the order a new entry is written.
++const FIELDS = ['domain', 'zone', 'hostParts', 'tld', 'kind', 'name', 'icon', 'accent', 'plannedRepo', 'noRepo', 'legalProfile', 'tagline', 'description', 'statusApi', 'linksLead', 'links', 'pillars', 'highlight', 'meanwhile', 'keywords', 'launch', 'core', 'faq', 'vision'];
++const LOCAL = new Set(['zone', 'hostParts', 'core']);
++const SERVICE_LOCAL = new Set(['name', 'icon', 'tagline', 'legalProfile']);
++
++const link = (l) => (l.note === undefined ? [l.label, l.url] : [l.label, l.url, l.note]);
++const SHAPE = {
++ pillars: (v) => v.map(p => [p.title, p.text]),
++ faq: (v) => v.map(q => [q.question, q.answer]),
++ links: (v) => v.map(link),
++ meanwhile: (v) => v.map(link),
++ highlight: (h) => ({ ...h, ...(h.cta && { cta: link(h.cta) }), ...(h.more && { more: link(h.more) }) }),
++};
++
++/** A field's value from a catalog row, in sites.json's shape (undefined when Contracts lacks it). */
++function fromContracts(row, field) {
++ if (field === 'domain') return row.domain;
++ if (LOCAL.has(field) || (row.home === 'service' && SERVICE_LOCAL.has(field))) return undefined;
++ const e = row.entry;
++ const v = field in e ? e[field] : (e.relationships || {})[field];
++ if (v === undefined) return undefined;
++ return SHAPE[field] ? SHAPE[field](v) : v;
++}
++
++function sync(current) {
++ const rows = new Map(catalog().map(r => [r.domain, r]));
++ const kept = [];
++ const sites = current.sites.map(site => {
++ const row = rows.get(site.domain);
++ if (!row) { kept.push(site.domain); return site; }
++ rows.delete(site.domain);
++ const out = {};
++ for (const k of [...Object.keys(site), ...FIELDS.filter(f => !(f in site))]) {
++ const v = fromContracts(row, k);
++ if (v !== undefined) out[k] = v;
++ else if (site[k] !== undefined) out[k] = site[k];
++ }
++ return out;
++ });
++ for (const row of rows.values()) {
++ const out = {};
++ for (const k of FIELDS) { const v = fromContracts(row, k); if (v !== undefined) out[k] = v; }
++ sites.push(out);
++ }
++ return { json: { ...current, sites }, kept };
++}
++
++const before = fs.readFileSync(FILE, 'utf8');
++const { json, kept } = sync(JSON.parse(before));
++const after = JSON.stringify(json, null, 2) + '\n';
++for (const d of kept) console.error(`sync-catalog: ${d} is not in the Contracts catalog; kept as it is`);
++if (process.argv.includes('--check')) {
++ if (after !== before) { console.error('sync-catalog: sites.json differs from the Contracts catalog; run node scripts/sync-catalog.js'); process.exit(1); }
++ console.log(`sync-catalog: sites.json matches the Contracts catalog (${json.sites.length} sites)`);
++} else {
++ fs.writeFileSync(FILE, after);
++ console.log(`sync-catalog: wrote ${json.sites.length} sites to sites.json`);
++}
ddiff --git a/test/build.test.js b/test/build.test.js
iindex df5bb7b..b3ea552 100644
---- a/test/build.test.js
++++ b/test/build.test.js
@@@ -26,6 +26,16 @@ for (const domain of frozen) {
if (domain !== 'openvibe.bot') assert.ok(exists(`deploy/nginx/${domain}.conf`), `${domain}: frozen vhost`);
}
assert.ok(!exists('deploy/nginx/openvibe.bot.conf'), 'Bot owns its vhost');
++// A frozen placeholder never claims a product is live, never uses pricing copy (no "free"/"$0"/"no ads"; "free
++// speech" is fine) and never offers hosting no OpenVibe service provides (2026-09-24 audit).
++const PRICING = /\$0\b|\bfor free\b|\bfree (forever|to use|of charge|plan|tier)\b|\bno ads\b|\bad-free\b/i;
++const UNBACKED = /handled for you|host your (bot|mod)|bots,? (and|&amp;|&) mods hosting|\bservice hosting\b/i;
++for (const domain of frozen) {
++ const html = read(`dist/${domain}/index.html`);
++ assert.ok(html.includes('this page is a placeholder, nothing here is live yet'), `${domain}: says it is a placeholder`);
++ assert.doesNotMatch(html, PRICING, `${domain}: pricing copy`);
++ assert.doesNotMatch(html, UNBACKED, `${domain}: claims a hosting product that does not exist`);
++}
for (const domain of ['openvibe.actor', 'openvibe.bot']) {
assert.ok(frozen.includes(domain), `${domain}: remains frozen`);
assert.match(read(`dist/${domain}/index.html`), /<meta name="robots" content="index, follow, max-image-preview:large">/, `${domain}: original indexing`);
ddiff --git a/test/sync-catalog.test.js b/test/sync-catalog.test.js
nnew file mode 100644
iindex 0000000..846f7bc
---- /dev/null
++++ b/test/sync-catalog.test.js
@@@ -0,0 +1,25 @@
++'use strict';
++// sites.json is the generated mirror of the Contracts product catalog: it matches the pinned
++// openvibe-contracts, and every domain Sites serves is still in it (or listed below until it is).
++// node test/sync-catalog.test.js
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { execFileSync } = require('child_process');
++
++const ROOT = path.join(__dirname, '..');
++execFileSync(process.execPath, [path.join(ROOT, 'scripts', 'sync-catalog.js'), '--check'], { stdio: 'inherit' });
++
++const { catalog } = require('openvibe-contracts/lib/products');
++const domains = new Set(catalog().map(r => r.domain));
++// Placeholders added after openvibe-contracts v0.84.0 that have no product manifest there yet: sync-catalog
++// keeps them as they are. Drop a domain from this list with the Contracts bump that adds it.
++const AWAITING_CONTRACTS = ['openvibe.work', 'openvibe.zone'];
++for (const d of AWAITING_CONTRACTS) assert.ok(!domains.has(d), `${d}: is in the Contracts catalog now; remove it from AWAITING_CONTRACTS`);
++const sites = JSON.parse(fs.readFileSync(path.join(ROOT, 'sites.json'), 'utf8')).sites;
++for (const conf of fs.readdirSync(path.join(ROOT, 'deploy', 'nginx'))) {
++ const d = conf.replace(/\.conf$/, '');
++ assert.ok(sites.some(s => s.domain === d), `${d}: has a vhost, so sites.json keeps it`);
++ assert.ok(domains.has(d) || AWAITING_CONTRACTS.includes(d), `${d}: has a home in the Contracts catalog`);
++}
++console.log(`sync-catalog: ${sites.length} sites, all in the Contracts catalog but ${AWAITING_CONTRACTS.join(', ')}`);