openvibers/openvibe.network Commits
Deploy drift reads main's head through git's ls-refs, not the REST API (#729)
Changes
@@@ -8,8 +8,11 @@* diverged the deployed commit is not an ancestor of main (a local or force-pushed build)* unknown GitHub did not answer, or the commit is not on GitHub*-- * Read hourly with GitHub's compare API (GET /repos/{repo}/compare/{deployed}...main, one call per-- * running service; GITHUB_TOKEN or the admin GitHub token raises the rate limit). A failure keeps the++ * Read hourly. Main's head comes first through git's own protocol (ls-refs, as `git ls-remote` asks it), which is not++ * GitHub's REST API and spends none of its rate limit. Only when main or the deployed commit moved since the last check++ * does it ask GitHub's compare API (GET /repos/{repo}/compare/{deployed}...main; GITHUB_TOKEN or the admin GitHub token++ * raises the limit). Without a token the host's address gets 60 REST calls an hour, shared with ovhost's CI checks and++ * the Git service's mirrors; one compare per service per hour spent all of them (2026-11-09). A failure keeps the* last answer. Main ahead by documentation and tests only (README, STATUS.json, docs/*.md, tests, .github/) counts* as current: nothing that runs differs. `since` is the committer date of the oldest undeployed commit, so the drift age is how* long main has had something production does not run. Shown on /status and in@@@ -21,6 +24,9 @@ const SHA_RE = /^[0-9a-f]{7,40}$/;// deploy controller, does not deploy them either): main ahead by only these is not drift.const DOCS_OR_TESTS = /^(README\.md|STATUS\.json|CHANGELOG\.md|SECURITY\.md|docs\/.*\.md|tests?\/|\.github\/)|\.test\.[cm]?[jt]s$/;const REPO_RE = /^OpenVibers\/[A-Za-z0-9._-]{1,100}$/;++// ls-refs for refs/heads/main in git's protocol v2: three pkt-lines (length in hex, counting its own four) and a flush.++const LS_REFS_MAIN = '0014command=ls-refs\n0001001fref-prefix refs/heads/main\n0000';++const MAIN_REF_RE = /([0-9a-f]{40}) refs\/heads\/main(?:\n|$)/;const state = new Map(); // service id -> { repo, deployed, state, behind_by, since, main, checked_at, error }let timer = null;@@@ -45,10 +51,37 @@ function createDeployDrift({ services, fetchImpl = globalThis.fetch, token = ()// The token may come from an async source (server/index.js reads the integration's): awaited, or every call sent// "Bearer [object Promise]" and GitHub answered 401 (from 2026-09-25 until 10-23).const tokenNow = async () => String((await (typeof token === 'function' ? token() : token)) || '');++ /** Main's head commit, or null (a private repository, GitHub down, an answer that is not git's). */++ async function mainHead(repository) {++ try {++ const res = await fetchImpl(`https://github.com/${repository}.git/git-upload-pack`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/x-git-upload-pack-request', Accept: 'application/x-git-upload-pack-result', 'Git-Protocol': 'version=2', 'User-Agent': 'git/2 OpenVibe.Network registry' },++ body: LS_REFS_MAIN,++ signal: AbortSignal.timeout(10000),++ });++ if (!res || !res.ok || typeof res.text !== 'function') return null;++ const m = MAIN_REF_RE.exec(String(await res.text()).slice(0, 4096));++ return m ? m[1] : null;++ } catch { return null; }++ }async function checkOne({ id, release, repository }) {const at = new Date(now()).toISOString();if (!SHA_RE.test(String(release || '')) || !REPO_RE.test(String(repository || ''))) { state.delete(id); return null; }const prev = state.get(id);++ const head = await mainHead(repository);++ if (head) {++ // Production runs main's head: current, and no REST call.++ if (head.startsWith(String(release).toLowerCase())) {++ state.set(id, { repo: repository, deployed: release, main: head.slice(0, 12), checked_at: at, state: 'current', behind_by: 0 });++ return state.get(id);++ }++ // Neither main nor the deployed commit moved since the last full answer: it still holds.++ if (prev && !prev.stale && prev.state !== 'unknown' && prev.deployed === release && prev.main === head.slice(0, 12)) {++ state.set(id, { ...prev, checked_at: at });++ return state.get(id);++ }++ }try {const auth = await tokenNow();const res = await fetchImpl(`https://api.github.com/repos/${repository}/compare/${release}...main`, {@@@ -61,7 +94,7 @@ function createDeployDrift({ services, fetchImpl = globalThis.fetch, token = ()const ahead = Number(body.ahead_by) || 0;const behind = Number(body.behind_by) || 0;const commits = Array.isArray(body.commits) ? body.commits : [];-- const main = commits.length ? String(commits[commits.length - 1].sha || '').slice(0, 12) : String(release).slice(0, 12);++ const main = head ? head.slice(0, 12) : commits.length ? String(commits[commits.length - 1].sha || '').slice(0, 12) : String(release).slice(0, 12);// GitHub's status (base = deployed, head = main): identical | ahead (main has more) | behind |// diverged (the deployed build has commits main lacks); the counts when it is missing.const st = body.status || (behind > 0 ? 'diverged' : ahead > 0 ? 'ahead' : 'identical');@@@ -2,7 +2,8 @@// Production drift (WS-S task 7): the deployed commit against main through GitHub's compare API —// current, behind (how many commits and since the oldest undeployed one), diverged, unknown; a failed// lookup keeps the last answer; the gauge gives seconds behind (0 when current); services that stop--// running are dropped; a non-commit release is skipped; an async token is awaited.++// running are dropped; a non-commit release is skipped; an async token is awaited. Main's head is read first through++// git's ls-refs (no REST quota): production on main's head, or nothing moved since the last answer, asks no compare.const assert = require('assert');const drift = require('../server/registry/deploy-drift');@@@ -24,7 +25,16 @@ const answers = {};let failing = false;const asked = [];++const heads = {}; // repository -> main's head as git's ls-refs answers it (none: GitHub does not answer git)++const lsRefs = [];const fetchImpl = async (url, opts) => {++ if (url.startsWith('https://github.com/')) {++ const repo = url.slice('https://github.com/'.length, -'.git/git-upload-pack'.length);++ lsRefs.push({ repo, method: opts.method, body: opts.body, protocol: opts.headers['Git-Protocol'], auth: opts.headers.Authorization });++ if (failing || !heads[repo]) return { ok: false, status: 404, text: async () => '' };++ const line = `${heads[repo]} refs/heads/main\n`;++ return { ok: true, status: 200, text: async () => `${(line.length + 4).toString(16).padStart(4, '0')}${line}0000` };++ }const key = url.replace('https://api.github.com/repos/', '');asked.push({ key, auth: opts.headers.Authorization });if (failing) throw new Error('network down');@@@ -76,6 +86,34 @@ const fetchImpl = async (url, opts) => {await drift.createDeployDrift({ services: () => running.slice(0, 1), fetchImpl, token: async () => null, now: () => NOW, log: null }).refresh();assert.strictEqual(asked[1].auth, undefined);drift._reset();++++ // Main's head through ls-refs: no compare call when production runs it, or when nothing moved since the last answer.++ asked.length = 0; lsRefs.length = 0;++ const LIVE_HEAD = 'aaaaaaa1' + '0'.repeat(32);++ const CHAT_HEAD = 'c3ffffffffff' + '1'.repeat(28);++ Object.assign(heads, { 'OpenVibers/OpenVibe.Live': LIVE_HEAD, 'OpenVibers/OpenVibe.Chat': CHAT_HEAD });++ const two = [running[0], running[1]];++ const g2 = drift.createDeployDrift({ services: () => two, fetchImpl, token: async () => null, now: () => NOW, log: null });++ await g2.refresh();++ assert.deepStrictEqual(lsRefs.map((x) => [x.repo, x.method, x.protocol, x.auth]), [['OpenVibers/OpenVibe.Live', 'POST', 'version=2', undefined], ['OpenVibers/OpenVibe.Chat', 'POST', 'version=2', undefined]], 'git protocol v2, no token sent to it');++ assert.strictEqual(lsRefs[0].body, '0014command=ls-refs\n0001001fref-prefix refs/heads/main\n0000', 'ls-refs for main only');++ assert.deepStrictEqual(asked.map((x) => x.key), ['OpenVibers/OpenVibe.Chat/compare/bbbbbbb2...main'], 'production on main\'s head asks no compare');++ assert.deepStrictEqual([drift.current('live').state, drift.current('live').main], ['current', LIVE_HEAD.slice(0, 12)]);++ assert.deepStrictEqual([drift.current('chat').state, drift.current('chat').behind_by, drift.current('chat').main], ['behind', 3, CHAT_HEAD.slice(0, 12)]);++ await g2.refresh();++ assert.strictEqual(asked.length, 1, 'nothing moved: the last answer holds without a compare');++ assert.strictEqual(drift.current('chat').since, '2026-09-25T10:00:00.000Z', 'and keeps its since');++ heads['OpenVibers/OpenVibe.Chat'] = 'd'.repeat(40);++ await g2.refresh();++ assert.strictEqual(asked.length, 2, 'main moved: compare again');++ failing = true; asked.length = 0;++ await g2.refresh();++ assert.strictEqual(drift.current('chat').stale, true, 'git and GitHub both down: the last answer, stale');++ failing = false; asked.length = 0;++ await g2.refresh();++ assert.deepStrictEqual(asked.map((x) => x.key), ['OpenVibers/OpenVibe.Chat/compare/bbbbbbb2...main'], 'after a stale answer the compare runs again (Live is on main\'s head)');++ assert.strictEqual(drift.current('chat').stale, undefined);++ drift._reset();// server/index.js feeds the checks from the registry's releases view.const eco = require('../server/registry/ecosystem').createEcosystemRegistry({ issuer: 'https://openvibe.network', fetchImpl: async () => { throw new Error('offline'); } });assert.strictEqual(typeof eco.releases, 'function', 'the registry exposes releases()');@@@ -139,7 +139,7 @@ const REVIEWED = {'server/profile/public.js fetch': [6, 'OpenVibe.Inventory, Quest and Play use operator-configured internal URLs and fixed paths with a checked usr_ id. Public place reads use only the configured Live, Community and Space service URLs, fixed public paths, encoded canonical usernames, 2 s timeouts and no user-supplied host or token; Space results are restricted to the exact owner and Published cards'],'server/profile/avatar.js fetch': [4, 'Live (push) and Media (ingest, release) internal URLs from config; a picture is never fetched here — Media\'s ingest fetches it (asserted below)'],'server/push/push-service.js web-push': [1, 'a person\'s push endpoint: endpointAllowed() at subscribe and send, connections through safeLookup (asserted below)'],-- 'server/registry/deploy-drift.js fetch': [1, 'fixed host api.github.com'],++ 'server/registry/deploy-drift.js fetch': [2, 'fixed hosts: api.github.com (compare) and github.com (git ls-refs); the repository matches REPO_RE (OpenVibers/ only)'],'server/registry/ecosystem.js fetch': [2, 'service URLs from openvibe-contracts manifests and operator env overrides'],'server/registry/library-tags.js fetch': [1, 'fixed host api.github.com'],'server/link/release.js fetch': [1, 'the Node\'s own release page: a fixed GitHub API path (https://api.github.com/repos/OpenVibers/OpenVibe.Link/releases/latest), 8 s timeout, held 15 minutes in memory and on disk; no request value ever names a host and it only ever reads the public release list'],
@@ -8,8 +8,11 @@
@@ -8,8 +8,11 @@
* diverged the deployed commit is not an ancestor of main (a local or force-pushed build)
* diverged the deployed commit is not an ancestor of main (a local or force-pushed build)
* unknown GitHub did not answer, or the commit is not on GitHub
* unknown GitHub did not answer, or the commit is not on GitHub
*
*
- * Read hourly with GitHub's compare API (GET /repos/{repo}/compare/{deployed}...main, one call per
- * running service; GITHUB_TOKEN or the admin GitHub token raises the rate limit). A failure keeps the
+ * Read hourly. Main's head comes first through git's own protocol (ls-refs, as `git ls-remote` asks it), which is not
+ * GitHub's REST API and spends none of its rate limit. Only when main or the deployed commit moved since the last check
+ * does it ask GitHub's compare API (GET /repos/{repo}/compare/{deployed}...main; GITHUB_TOKEN or the admin GitHub token
+ * raises the limit). Without a token the host's address gets 60 REST calls an hour, shared with ovhost's CI checks and
+ * the Git service's mirrors; one compare per service per hour spent all of them (2026-11-09). A failure keeps the
* last answer. Main ahead by documentation and tests only (README, STATUS.json, docs/*.md, tests, .github/) counts
* last answer. Main ahead by documentation and tests only (README, STATUS.json, docs/*.md, tests, .github/) counts
* as current: nothing that runs differs. `since` is the committer date of the oldest undeployed commit, so the drift age is how
* as current: nothing that runs differs. `since` is the committer date of the oldest undeployed commit, so the drift age is how
* long main has had something production does not run. Shown on /status and in
* long main has had something production does not run. Shown on /status and in
@@ -21,6 +24,9 @@ const SHA_RE = /^[0-9a-f]{7,40}$/;
@@ -21,6 +24,9 @@ const SHA_RE = /^[0-9a-f]{7,40}$/;
// deploy controller, does not deploy them either): main ahead by only these is not drift.
// deploy controller, does not deploy them either): main ahead by only these is not drift.
const DOCS_OR_TESTS = /^(README\.md|STATUS\.json|CHANGELOG\.md|SECURITY\.md|docs\/.*\.md|tests?\/|\.github\/)|\.test\.[cm]?[jt]s$/;
const DOCS_OR_TESTS = /^(README\.md|STATUS\.json|CHANGELOG\.md|SECURITY\.md|docs\/.*\.md|tests?\/|\.github\/)|\.test\.[cm]?[jt]s$/;
const REPO_RE = /^OpenVibers\/[A-Za-z0-9._-]{1,100}$/;
const REPO_RE = /^OpenVibers\/[A-Za-z0-9._-]{1,100}$/;
+// ls-refs for refs/heads/main in git's protocol v2: three pkt-lines (length in hex, counting its own four) and a flush.
+const LS_REFS_MAIN = '0014command=ls-refs\n0001001fref-prefix refs/heads/main\n0000';
+const MAIN_REF_RE = /([0-9a-f]{40}) refs\/heads\/main(?:\n|$)/;
const state = new Map(); // service id -> { repo, deployed, state, behind_by, since, main, checked_at, error }
const state = new Map(); // service id -> { repo, deployed, state, behind_by, since, main, checked_at, error }
let timer = null;
let timer = null;
@@ -45,10 +51,37 @@ function createDeployDrift({ services, fetchImpl = globalThis.fetch, token = ()
@@ -45,10 +51,37 @@ function createDeployDrift({ services, fetchImpl = globalThis.fetch, token = ()
// The token may come from an async source (server/index.js reads the integration's): awaited, or every call sent
// The token may come from an async source (server/index.js reads the integration's): awaited, or every call sent
// "Bearer [object Promise]" and GitHub answered 401 (from 2026-09-25 until 10-23).
// "Bearer [object Promise]" and GitHub answered 401 (from 2026-09-25 until 10-23).
const tokenNow = async () => String((await (typeof token === 'function' ? token() : token)) || '');
const tokenNow = async () => String((await (typeof token === 'function' ? token() : token)) || '');
+ /** Main's head commit, or null (a private repository, GitHub down, an answer that is not git's). */
+ async function mainHead(repository) {
+ try {
+ const res = await fetchImpl(`https://github.com/${repository}.git/git-upload-pack`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-git-upload-pack-request', Accept: 'application/x-git-upload-pack-result', 'Git-Protocol': 'version=2', 'User-Agent': 'git/2 OpenVibe.Network registry' },
+ body: LS_REFS_MAIN,
+ signal: AbortSignal.timeout(10000),
+ });
+ if (!res || !res.ok || typeof res.text !== 'function') return null;
+ const m = MAIN_REF_RE.exec(String(await res.text()).slice(0, 4096));
+ return m ? m[1] : null;
+ } catch { return null; }
+ }
async function checkOne({ id, release, repository }) {
async function checkOne({ id, release, repository }) {
const at = new Date(now()).toISOString();
const at = new Date(now()).toISOString();
if (!SHA_RE.test(String(release || '')) || !REPO_RE.test(String(repository || ''))) { state.delete(id); return null; }
if (!SHA_RE.test(String(release || '')) || !REPO_RE.test(String(repository || ''))) { state.delete(id); return null; }
const prev = state.get(id);
const prev = state.get(id);
+ const head = await mainHead(repository);
+ if (head) {
+ // Production runs main's head: current, and no REST call.
+ if (head.startsWith(String(release).toLowerCase())) {
+ state.set(id, { repo: repository, deployed: release, main: head.slice(0, 12), checked_at: at, state: 'current', behind_by: 0 });
+ return state.get(id);
+ }
+ // Neither main nor the deployed commit moved since the last full answer: it still holds.
+ if (prev && !prev.stale && prev.state !== 'unknown' && prev.deployed === release && prev.main === head.slice(0, 12)) {
+ state.set(id, { ...prev, checked_at: at });
+ return state.get(id);
+ }
+ }
try {
try {
const auth = await tokenNow();
const auth = await tokenNow();
const res = await fetchImpl(`https://api.github.com/repos/${repository}/compare/${release}...main`, {
const res = await fetchImpl(`https://api.github.com/repos/${repository}/compare/${release}...main`, {
@@ -61,7 +94,7 @@ function createDeployDrift({ services, fetchImpl = globalThis.fetch, token = ()
@@ -61,7 +94,7 @@ function createDeployDrift({ services, fetchImpl = globalThis.fetch, token = ()
const ahead = Number(body.ahead_by) || 0;
const ahead = Number(body.ahead_by) || 0;
const behind = Number(body.behind_by) || 0;
const behind = Number(body.behind_by) || 0;
const commits = Array.isArray(body.commits) ? body.commits : [];
const commits = Array.isArray(body.commits) ? body.commits : [];
- const main = commits.length ? String(commits[commits.length - 1].sha || '').slice(0, 12) : String(release).slice(0, 12);
+ const main = head ? head.slice(0, 12) : commits.length ? String(commits[commits.length - 1].sha || '').slice(0, 12) : String(release).slice(0, 12);
// GitHub's status (base = deployed, head = main): identical | ahead (main has more) | behind |
// GitHub's status (base = deployed, head = main): identical | ahead (main has more) | behind |
// diverged (the deployed build has commits main lacks); the counts when it is missing.
// diverged (the deployed build has commits main lacks); the counts when it is missing.
const st = body.status || (behind > 0 ? 'diverged' : ahead > 0 ? 'ahead' : 'identical');
const st = body.status || (behind > 0 ? 'diverged' : ahead > 0 ? 'ahead' : 'identical');
@@ -2,7 +2,8 @@
@@ -2,7 +2,8 @@
// Production drift (WS-S task 7): the deployed commit against main through GitHub's compare API —
// Production drift (WS-S task 7): the deployed commit against main through GitHub's compare API —
// current, behind (how many commits and since the oldest undeployed one), diverged, unknown; a failed
// current, behind (how many commits and since the oldest undeployed one), diverged, unknown; a failed
// lookup keeps the last answer; the gauge gives seconds behind (0 when current); services that stop
// lookup keeps the last answer; the gauge gives seconds behind (0 when current); services that stop
-// running are dropped; a non-commit release is skipped; an async token is awaited.
+// running are dropped; a non-commit release is skipped; an async token is awaited. Main's head is read first through
+// git's ls-refs (no REST quota): production on main's head, or nothing moved since the last answer, asks no compare.
const assert = require('assert');
const assert = require('assert');
const drift = require('../server/registry/deploy-drift');
const drift = require('../server/registry/deploy-drift');
@@ -24,7 +25,16 @@ const answers = {
@@ -24,7 +25,16 @@ const answers = {
};
};
let failing = false;
let failing = false;
const asked = [];
const asked = [];
+const heads = {}; // repository -> main's head as git's ls-refs answers it (none: GitHub does not answer git)
+const lsRefs = [];
const fetchImpl = async (url, opts) => {
const fetchImpl = async (url, opts) => {
+ if (url.startsWith('https://github.com/')) {
+ const repo = url.slice('https://github.com/'.length, -'.git/git-upload-pack'.length);
+ lsRefs.push({ repo, method: opts.method, body: opts.body, protocol: opts.headers['Git-Protocol'], auth: opts.headers.Authorization });
+ if (failing || !heads[repo]) return { ok: false, status: 404, text: async () => '' };
+ const line = `${heads[repo]} refs/heads/main\n`;
+ return { ok: true, status: 200, text: async () => `${(line.length + 4).toString(16).padStart(4, '0')}${line}0000` };
+ }
const key = url.replace('https://api.github.com/repos/', '');
const key = url.replace('https://api.github.com/repos/', '');
asked.push({ key, auth: opts.headers.Authorization });
asked.push({ key, auth: opts.headers.Authorization });
if (failing) throw new Error('network down');
if (failing) throw new Error('network down');
@@ -76,6 +86,34 @@ const fetchImpl = async (url, opts) => {
@@ -76,6 +86,34 @@ const fetchImpl = async (url, opts) => {
await drift.createDeployDrift({ services: () => running.slice(0, 1), fetchImpl, token: async () => null, now: () => NOW, log: null }).refresh();
await drift.createDeployDrift({ services: () => running.slice(0, 1), fetchImpl, token: async () => null, now: () => NOW, log: null }).refresh();
assert.strictEqual(asked[1].auth, undefined);
assert.strictEqual(asked[1].auth, undefined);
drift._reset();
drift._reset();
+
+ // Main's head through ls-refs: no compare call when production runs it, or when nothing moved since the last answer.
+ asked.length = 0; lsRefs.length = 0;
+ const LIVE_HEAD = 'aaaaaaa1' + '0'.repeat(32);
+ const CHAT_HEAD = 'c3ffffffffff' + '1'.repeat(28);
+ Object.assign(heads, { 'OpenVibers/OpenVibe.Live': LIVE_HEAD, 'OpenVibers/OpenVibe.Chat': CHAT_HEAD });
+ const two = [running[0], running[1]];
+ const g2 = drift.createDeployDrift({ services: () => two, fetchImpl, token: async () => null, now: () => NOW, log: null });
+ await g2.refresh();
+ assert.deepStrictEqual(lsRefs.map((x) => [x.repo, x.method, x.protocol, x.auth]), [['OpenVibers/OpenVibe.Live', 'POST', 'version=2', undefined], ['OpenVibers/OpenVibe.Chat', 'POST', 'version=2', undefined]], 'git protocol v2, no token sent to it');
+ assert.strictEqual(lsRefs[0].body, '0014command=ls-refs\n0001001fref-prefix refs/heads/main\n0000', 'ls-refs for main only');
+ assert.deepStrictEqual(asked.map((x) => x.key), ['OpenVibers/OpenVibe.Chat/compare/bbbbbbb2...main'], 'production on main\'s head asks no compare');
+ assert.deepStrictEqual([drift.current('live').state, drift.current('live').main], ['current', LIVE_HEAD.slice(0, 12)]);
+ assert.deepStrictEqual([drift.current('chat').state, drift.current('chat').behind_by, drift.current('chat').main], ['behind', 3, CHAT_HEAD.slice(0, 12)]);
+ await g2.refresh();
+ assert.strictEqual(asked.length, 1, 'nothing moved: the last answer holds without a compare');
+ assert.strictEqual(drift.current('chat').since, '2026-09-25T10:00:00.000Z', 'and keeps its since');
+ heads['OpenVibers/OpenVibe.Chat'] = 'd'.repeat(40);
+ await g2.refresh();
+ assert.strictEqual(asked.length, 2, 'main moved: compare again');
+ failing = true; asked.length = 0;
+ await g2.refresh();
+ assert.strictEqual(drift.current('chat').stale, true, 'git and GitHub both down: the last answer, stale');
+ failing = false; asked.length = 0;
+ await g2.refresh();
+ assert.deepStrictEqual(asked.map((x) => x.key), ['OpenVibers/OpenVibe.Chat/compare/bbbbbbb2...main'], 'after a stale answer the compare runs again (Live is on main\'s head)');
+ assert.strictEqual(drift.current('chat').stale, undefined);
+ drift._reset();
// server/index.js feeds the checks from the registry's releases view.
// server/index.js feeds the checks from the registry's releases view.
const eco = require('../server/registry/ecosystem').createEcosystemRegistry({ issuer: 'https://openvibe.network', fetchImpl: async () => { throw new Error('offline'); } });
const eco = require('../server/registry/ecosystem').createEcosystemRegistry({ issuer: 'https://openvibe.network', fetchImpl: async () => { throw new Error('offline'); } });
assert.strictEqual(typeof eco.releases, 'function', 'the registry exposes releases()');
assert.strictEqual(typeof eco.releases, 'function', 'the registry exposes releases()');
@@ -139,7 +139,7 @@ const REVIEWED = {
@@ -139,7 +139,7 @@ const REVIEWED = {
'server/profile/public.js fetch': [6, 'OpenVibe.Inventory, Quest and Play use operator-configured internal URLs and fixed paths with a checked usr_ id. Public place reads use only the configured Live, Community and Space service URLs, fixed public paths, encoded canonical usernames, 2 s timeouts and no user-supplied host or token; Space results are restricted to the exact owner and Published cards'],
'server/profile/public.js fetch': [6, 'OpenVibe.Inventory, Quest and Play use operator-configured internal URLs and fixed paths with a checked usr_ id. Public place reads use only the configured Live, Community and Space service URLs, fixed public paths, encoded canonical usernames, 2 s timeouts and no user-supplied host or token; Space results are restricted to the exact owner and Published cards'],
'server/profile/avatar.js fetch': [4, 'Live (push) and Media (ingest, release) internal URLs from config; a picture is never fetched here — Media\'s ingest fetches it (asserted below)'],
'server/profile/avatar.js fetch': [4, 'Live (push) and Media (ingest, release) internal URLs from config; a picture is never fetched here — Media\'s ingest fetches it (asserted below)'],
'server/push/push-service.js web-push': [1, 'a person\'s push endpoint: endpointAllowed() at subscribe and send, connections through safeLookup (asserted below)'],
'server/push/push-service.js web-push': [1, 'a person\'s push endpoint: endpointAllowed() at subscribe and send, connections through safeLookup (asserted below)'],
- 'server/registry/deploy-drift.js fetch': [1, 'fixed host api.github.com'],
+ 'server/registry/deploy-drift.js fetch': [2, 'fixed hosts: api.github.com (compare) and github.com (git ls-refs); the repository matches REPO_RE (OpenVibers/ only)'],
'server/registry/ecosystem.js fetch': [2, 'service URLs from openvibe-contracts manifests and operator env overrides'],
'server/registry/ecosystem.js fetch': [2, 'service URLs from openvibe-contracts manifests and operator env overrides'],
'server/registry/library-tags.js fetch': [1, 'fixed host api.github.com'],
'server/registry/library-tags.js fetch': [1, 'fixed host api.github.com'],
'server/link/release.js fetch': [1, 'the Node\'s own release page: a fixed GitHub API path (https://api.github.com/repos/OpenVibers/OpenVibe.Link/releases/latest), 8 s timeout, held 15 minutes in memory and on disk; no request value ever names a host and it only ever reads the public release list'],
'server/link/release.js fetch': [1, 'the Node\'s own release page: a fixed GitHub API path (https://api.github.com/repos/OpenVibers/OpenVibe.Link/releases/latest), 8 s timeout, held 15 minutes in memory and on disk; no request value ever names a host and it only ever reads the public release list'],