openvibers/openvibe.network Commits
Keep Link installer current during GitHub API rate limits (#826)
Changes
@@@ -149,6 +149,34 @@ async function newestTag({ fetchImpl = globalThis.fetch } = {}) {return tag;}++/** The newest *published* release, through GitHub's public redirect when its REST API is rate limited. */++async function publishedTag({ fetchImpl = globalThis.fetch } = {}) {++ const signal = typeof AbortSignal.timeout === 'function' ? AbortSignal.timeout(TIMEOUT_MS) : undefined;++ const res = await fetchImpl(LATEST, {++ method: 'HEAD', redirect: 'manual', headers: { 'User-Agent': 'openvibe-network' }, signal,++ });++ if (!res || res.status !== 302 || !res.headers || typeof res.headers.get !== 'function') {++ throw new Error('GitHub latest release did not redirect');++ }++ const target = new URL(res.headers.get('location'));++ const prefix = `/${REPO}/releases/tag/`;++ const tag = target.pathname.startsWith(prefix) ? target.pathname.slice(prefix.length) : '';++ if (target.origin !== 'https://github.com' || !TAG_REF_RE.test(tag)) {++ throw new Error('GitHub latest release did not name a version');++ }++ return tag;++}++++/** Release links that can be formed without the rate-limited asset metadata. */++function partialRelease(tag) {++ const base = `https://github.com/${REPO}/releases`;++ return {++ version: tag, assets: [], publishedAt: '', url: `${base}/tag/${tag}`,++ installUrl: `${base}/download/${tag}/install.sh`,++ checksumsUrl: `${base}/download/${tag}/SHA256SUMS`, partial: true,++ };++}++/** Ask GitHub once and keep the answer. Resolves to the shaped release; rejects when GitHub could not be read. */async function fetchLatest({ fetchImpl = globalThis.fetch, now = Date.now } = {}) {const controller = typeof AbortController === 'function' ? new AbortController() : null;@@@ -186,7 +214,7 @@ function refresh({ fetchImpl = globalThis.fetch, now = Date.now } = {}) {if (cached) {try {const tag = await newestTag({ fetchImpl });-- if (tag === cached.data.version) {++ if (tag === cached.data.version && !cached.data.partial) {cached = { at: now(), data: cached.data };failedAt = 0;saveDisk(cached);@@@ -196,7 +224,25 @@ function refresh({ fetchImpl = globalThis.fetch, now = Date.now } = {}) {// ls-refs did not answer: ask the REST API as before.}}-- return fetchLatest({ fetchImpl, now });++ try {++ return await fetchLatest({ fetchImpl, now });++ } catch {++ // The host may exhaust GitHub's unauthenticated REST limit. Its public latest-release redirect++ // still identifies a published release, so the page and installer can advance without guessing++ // from a tag that might not have any release assets yet.++ try {++ const tag = await publishedTag({ fetchImpl });++ if (tag !== cached.data.version || cached.data.partial) {++ cached = { at: now(), data: partialRelease(tag) };++ failedAt = 0;++ saveDisk(cached);++ return cached.data;++ }++ } catch {++ // Keep the last good answer when GitHub's public redirect is unreachable too.++ }++ return null;++ }})().catch(() => null).finally(() => { inflight = null; });@@@ -167,12 +167,12 @@ function createLinkRoutes({ releaseId, releaseApi = release, db = null, config =// www is not a second name for Link: every path lands on the apex, and nginx does the same before us.if (host === WWW_HOST) return res.redirect(301, `${SITE}${path}${url.search}`);-- // The installer: the latest release's own asset, so `curl -fsSL https://openvibe.link/install | sh`-- // installs that release. The same cached answer the page shows.++ // The installer: a stale cached release must never pin an older version while GitHub's metadata++ // API is rate limited. GitHub's latest/download redirect resolves to the newest published asset.if (path === '/install') {let rel;try { rel = await releaseApi.latest(); } catch { rel = release.FALLBACK; }-- const target = rel && rel.installUrl ? rel.installUrl : release.FALLBACK.installUrl;++ const target = rel && !rel.stale && rel.installUrl ? rel.installUrl : release.FALLBACK.installUrl;return res.set('Cache-Control', 'public, max-age=600').redirect(302, target);}@@@ -217,7 +217,12 @@ const SHAPED = {const linkInstall = await get('/install', 'openvibe.link');assert.strictEqual(linkInstall.status, 302);-- assert.ok(String(linkInstall.headers.location || '').endsWith('/install.sh'));++ assert.strictEqual(linkInstall.headers.location, SHAPED.installUrl);++ releaseApi.latest = async () => ({ ...SHAPED, stale: true });++ const staleInstall = await get('/install', 'openvibe.link');++ assert.strictEqual(staleInstall.headers.location, release.FALLBACK.installUrl,++ 'a stale cache sends the installer to GitHub\'s latest published asset');++ releaseApi.latest = async () => SHAPED;const machines = await get('/machines', 'openvibe.link');assert.strictEqual(machines.status, 302, 'a signed-out arrival starts the silent sign-in, not the hub');@@@ -80,7 +80,7 @@ const fresh = () => {assert.strictEqual(stale.stale, true, 'and marked stale');assert.ok(stale.ageMs >= 15 * 60e3, 'its age comes with it');await restarted.whenIdle();-- assert.strictEqual(tries, 2, 'the refresh ran in the background: ls-refs, then the REST fallback');++ assert.strictEqual(tries, 3, 'the refresh tried ls-refs, REST, then the public release redirect');const kept = await restarted.latest({fetchImpl: async () => { tries += 1; throw new Error('still down'); },@@@ -88,7 +88,7 @@ const fresh = () => {dataDir: dir,});assert.strictEqual(kept.version, 'v1.2.3', 'the failure did not lose the answer');-- assert.strictEqual(tries, 2, 'and the cooldown kept the next visitor off GitHub');++ assert.strictEqual(tries, 3, 'and the cooldown kept the next visitor off GitHub');// ── Refresh: once GitHub is back, the next request gets the new release from the cache ──now += 16 * 60e3;@@@ -164,6 +164,43 @@ const fresh = () => {fs.rmSync(tagDir, { recursive: true, force: true });}++ // A host can exhaust the REST rate limit while GitHub's public release redirect still works.++ // The published release advances the page; a later REST success fills in its asset metadata.++ const limitedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-node-limited-'));++ try {++ const limited = fresh();++ await limited.latest({ fetchImpl: ok('v0.54.0'), now: () => now, dataDir: limitedDir });++ now += 16 * 60e3;++ let restCalls = 0;++ const limitedFetch = async (url) => {++ if (isGit(url)) return { ok: true, status: 200, text: async () => LS(['v0.56.0']) };++ if (url === limited.LATEST) return {++ status: 302,++ headers: { get: () => 'https://github.com/OpenVibers/OpenVibe.Link/releases/tag/v0.56.0' },++ };++ restCalls += 1;++ return { ok: false, status: 403 };++ };++ const expired = await limited.latest({ fetchImpl: limitedFetch, now: () => now, dataDir: limitedDir });++ assert.strictEqual(expired.version, 'v0.54.0', 'the first expired request is answered immediately');++ await limited.whenIdle();++ const advanced = await limited.latest({ fetchImpl: limitedFetch, now: () => now + 1000, dataDir: limitedDir });++ assert.strictEqual(restCalls, 1, 'the rate-limited API was tried once');++ assert.strictEqual(advanced.version, 'v0.56.0', 'the public redirect names the published release');++ assert.strictEqual(advanced.partial, true, 'asset metadata is marked incomplete');++ assert.deepStrictEqual(advanced.assets, []);++ assert.strictEqual(advanced.installUrl, 'https://github.com/OpenVibers/OpenVibe.Link/releases/download/v0.56.0/install.sh');++++ now += 16 * 60e3;++ await limited.latest({ fetchImpl: ok('v0.56.0'), now: () => now, dataDir: limitedDir });++ await limited.whenIdle();++ const complete = await limited.latest({ fetchImpl: ok('v0.56.0'), now: () => now + 1000, dataDir: limitedDir });++ assert.ok(!complete.partial, 'a REST success replaces the incomplete answer');++ assert.ok(complete.assets.includes('install.sh'), 'and restores the asset list');++ } finally {++ fs.rmSync(limitedDir, { recursive: true, force: true });++ }++// ── The token: GITHUB_TOKEN is sent when set, from the environment only ──const sent = [];const tokenDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-node-token-'));@@@ -148,7 +148,7 @@ const REVIEWED = {'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': [2, 'the Node\'s own release page: fixed hosts only — https://github.com/OpenVibers/OpenVibe.Link.git/git-upload-pack (ls-refs of refs/tags/v, no REST call) and the fixed GitHub API path (https://api.github.com/repos/OpenVibers/OpenVibe.Link/releases/latest), each with an 8 s timeout; the REST answer is held 15 minutes in memory and on disk and read only when a new tag exists; no request value ever names a host and it only ever reads the public release list'],++ 'server/link/release.js fetch': [3, 'Link\'s own release page: fixed hosts only — GitHub git-upload-pack for release tags, GitHub releases/latest API for asset metadata, and a HEAD to the fixed github.com/OpenVibers/OpenVibe.Link/releases/latest page when REST is rate limited; the HEAD does not follow the redirect and its destination must be the expected GitHub release tag path; all requests time out after 8 s, no visitor value names a host, and the answer is cached for 15 minutes'],'server/registry/machines.js fetch': [1, 'the machines hub calls Bot: the internal URL from operator config (OV_BOT_INTERNAL_URL, loopback by default), the paths are fixed, the machine id is a checked dev_ id (percent-encoded) and the person is named in a header, never in the URL'],'server/presence/index.js fetch': [2, "Live and Chat internal URLs from operator config (loopback defaults): Live's public /api/streams (fixed path, 2 s timeout, held 30 s) and Chat's POST /internal/chat/dm/peers (fixed path, 1.5 s, the subject list in the body); never a request value names a host"],'server/updates/routes.js fetch': [1, 'OV_BLOG_INTERNAL_URL (operator config)'],
@@ -149,6 +149,34 @@ async function newestTag({ fetchImpl = globalThis.fetch } = {}) {
@@ -149,6 +149,34 @@ async function newestTag({ fetchImpl = globalThis.fetch } = {}) {
return tag;
return tag;
}
}
+/** The newest *published* release, through GitHub's public redirect when its REST API is rate limited. */
+async function publishedTag({ fetchImpl = globalThis.fetch } = {}) {
+ const signal = typeof AbortSignal.timeout === 'function' ? AbortSignal.timeout(TIMEOUT_MS) : undefined;
+ const res = await fetchImpl(LATEST, {
+ method: 'HEAD', redirect: 'manual', headers: { 'User-Agent': 'openvibe-network' }, signal,
+ });
+ if (!res || res.status !== 302 || !res.headers || typeof res.headers.get !== 'function') {
+ throw new Error('GitHub latest release did not redirect');
+ }
+ const target = new URL(res.headers.get('location'));
+ const prefix = `/${REPO}/releases/tag/`;
+ const tag = target.pathname.startsWith(prefix) ? target.pathname.slice(prefix.length) : '';
+ if (target.origin !== 'https://github.com' || !TAG_REF_RE.test(tag)) {
+ throw new Error('GitHub latest release did not name a version');
+ }
+ return tag;
+}
+
+/** Release links that can be formed without the rate-limited asset metadata. */
+function partialRelease(tag) {
+ const base = `https://github.com/${REPO}/releases`;
+ return {
+ version: tag, assets: [], publishedAt: '', url: `${base}/tag/${tag}`,
+ installUrl: `${base}/download/${tag}/install.sh`,
+ checksumsUrl: `${base}/download/${tag}/SHA256SUMS`, partial: true,
+ };
+}
+
/** Ask GitHub once and keep the answer. Resolves to the shaped release; rejects when GitHub could not be read. */
/** Ask GitHub once and keep the answer. Resolves to the shaped release; rejects when GitHub could not be read. */
async function fetchLatest({ fetchImpl = globalThis.fetch, now = Date.now } = {}) {
async function fetchLatest({ fetchImpl = globalThis.fetch, now = Date.now } = {}) {
const controller = typeof AbortController === 'function' ? new AbortController() : null;
const controller = typeof AbortController === 'function' ? new AbortController() : null;
@@ -186,7 +214,7 @@ function refresh({ fetchImpl = globalThis.fetch, now = Date.now } = {}) {
@@ -186,7 +214,7 @@ function refresh({ fetchImpl = globalThis.fetch, now = Date.now } = {}) {
if (cached) {
if (cached) {
try {
try {
const tag = await newestTag({ fetchImpl });
const tag = await newestTag({ fetchImpl });
- if (tag === cached.data.version) {
+ if (tag === cached.data.version && !cached.data.partial) {
cached = { at: now(), data: cached.data };
cached = { at: now(), data: cached.data };
failedAt = 0;
failedAt = 0;
saveDisk(cached);
saveDisk(cached);
@@ -196,7 +224,25 @@ function refresh({ fetchImpl = globalThis.fetch, now = Date.now } = {}) {
@@ -196,7 +224,25 @@ function refresh({ fetchImpl = globalThis.fetch, now = Date.now } = {}) {
// ls-refs did not answer: ask the REST API as before.
// ls-refs did not answer: ask the REST API as before.
}
}
}
}
- return fetchLatest({ fetchImpl, now });
+ try {
+ return await fetchLatest({ fetchImpl, now });
+ } catch {
+ // The host may exhaust GitHub's unauthenticated REST limit. Its public latest-release redirect
+ // still identifies a published release, so the page and installer can advance without guessing
+ // from a tag that might not have any release assets yet.
+ try {
+ const tag = await publishedTag({ fetchImpl });
+ if (tag !== cached.data.version || cached.data.partial) {
+ cached = { at: now(), data: partialRelease(tag) };
+ failedAt = 0;
+ saveDisk(cached);
+ return cached.data;
+ }
+ } catch {
+ // Keep the last good answer when GitHub's public redirect is unreachable too.
+ }
+ return null;
+ }
})()
})()
.catch(() => null)
.catch(() => null)
.finally(() => { inflight = null; });
.finally(() => { inflight = null; });
@@ -167,12 +167,12 @@ function createLinkRoutes({ releaseId, releaseApi = release, db = null, config =
@@ -167,12 +167,12 @@ function createLinkRoutes({ releaseId, releaseApi = release, db = null, config =
// www is not a second name for Link: every path lands on the apex, and nginx does the same before us.
// www is not a second name for Link: every path lands on the apex, and nginx does the same before us.
if (host === WWW_HOST) return res.redirect(301, `${SITE}${path}${url.search}`);
if (host === WWW_HOST) return res.redirect(301, `${SITE}${path}${url.search}`);
- // The installer: the latest release's own asset, so `curl -fsSL https://openvibe.link/install | sh`
- // installs that release. The same cached answer the page shows.
+ // The installer: a stale cached release must never pin an older version while GitHub's metadata
+ // API is rate limited. GitHub's latest/download redirect resolves to the newest published asset.
if (path === '/install') {
if (path === '/install') {
let rel;
let rel;
try { rel = await releaseApi.latest(); } catch { rel = release.FALLBACK; }
try { rel = await releaseApi.latest(); } catch { rel = release.FALLBACK; }
- const target = rel && rel.installUrl ? rel.installUrl : release.FALLBACK.installUrl;
+ const target = rel && !rel.stale && rel.installUrl ? rel.installUrl : release.FALLBACK.installUrl;
return res.set('Cache-Control', 'public, max-age=600').redirect(302, target);
return res.set('Cache-Control', 'public, max-age=600').redirect(302, target);
}
}
@@ -217,7 +217,12 @@ const SHAPED = {
@@ -217,7 +217,12 @@ const SHAPED = {
const linkInstall = await get('/install', 'openvibe.link');
const linkInstall = await get('/install', 'openvibe.link');
assert.strictEqual(linkInstall.status, 302);
assert.strictEqual(linkInstall.status, 302);
- assert.ok(String(linkInstall.headers.location || '').endsWith('/install.sh'));
+ assert.strictEqual(linkInstall.headers.location, SHAPED.installUrl);
+ releaseApi.latest = async () => ({ ...SHAPED, stale: true });
+ const staleInstall = await get('/install', 'openvibe.link');
+ assert.strictEqual(staleInstall.headers.location, release.FALLBACK.installUrl,
+ 'a stale cache sends the installer to GitHub\'s latest published asset');
+ releaseApi.latest = async () => SHAPED;
const machines = await get('/machines', 'openvibe.link');
const machines = await get('/machines', 'openvibe.link');
assert.strictEqual(machines.status, 302, 'a signed-out arrival starts the silent sign-in, not the hub');
assert.strictEqual(machines.status, 302, 'a signed-out arrival starts the silent sign-in, not the hub');
@@ -80,7 +80,7 @@ const fresh = () => {
@@ -80,7 +80,7 @@ const fresh = () => {
assert.strictEqual(stale.stale, true, 'and marked stale');
assert.strictEqual(stale.stale, true, 'and marked stale');
assert.ok(stale.ageMs >= 15 * 60e3, 'its age comes with it');
assert.ok(stale.ageMs >= 15 * 60e3, 'its age comes with it');
await restarted.whenIdle();
await restarted.whenIdle();
- assert.strictEqual(tries, 2, 'the refresh ran in the background: ls-refs, then the REST fallback');
+ assert.strictEqual(tries, 3, 'the refresh tried ls-refs, REST, then the public release redirect');
const kept = await restarted.latest({
const kept = await restarted.latest({
fetchImpl: async () => { tries += 1; throw new Error('still down'); },
fetchImpl: async () => { tries += 1; throw new Error('still down'); },
@@ -88,7 +88,7 @@ const fresh = () => {
@@ -88,7 +88,7 @@ const fresh = () => {
dataDir: dir,
dataDir: dir,
});
});
assert.strictEqual(kept.version, 'v1.2.3', 'the failure did not lose the answer');
assert.strictEqual(kept.version, 'v1.2.3', 'the failure did not lose the answer');
- assert.strictEqual(tries, 2, 'and the cooldown kept the next visitor off GitHub');
+ assert.strictEqual(tries, 3, 'and the cooldown kept the next visitor off GitHub');
// ── Refresh: once GitHub is back, the next request gets the new release from the cache ──
// ── Refresh: once GitHub is back, the next request gets the new release from the cache ──
now += 16 * 60e3;
now += 16 * 60e3;
@@ -164,6 +164,43 @@ const fresh = () => {
@@ -164,6 +164,43 @@ const fresh = () => {
fs.rmSync(tagDir, { recursive: true, force: true });
fs.rmSync(tagDir, { recursive: true, force: true });
}
}
+ // A host can exhaust the REST rate limit while GitHub's public release redirect still works.
+ // The published release advances the page; a later REST success fills in its asset metadata.
+ const limitedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-node-limited-'));
+ try {
+ const limited = fresh();
+ await limited.latest({ fetchImpl: ok('v0.54.0'), now: () => now, dataDir: limitedDir });
+ now += 16 * 60e3;
+ let restCalls = 0;
+ const limitedFetch = async (url) => {
+ if (isGit(url)) return { ok: true, status: 200, text: async () => LS(['v0.56.0']) };
+ if (url === limited.LATEST) return {
+ status: 302,
+ headers: { get: () => 'https://github.com/OpenVibers/OpenVibe.Link/releases/tag/v0.56.0' },
+ };
+ restCalls += 1;
+ return { ok: false, status: 403 };
+ };
+ const expired = await limited.latest({ fetchImpl: limitedFetch, now: () => now, dataDir: limitedDir });
+ assert.strictEqual(expired.version, 'v0.54.0', 'the first expired request is answered immediately');
+ await limited.whenIdle();
+ const advanced = await limited.latest({ fetchImpl: limitedFetch, now: () => now + 1000, dataDir: limitedDir });
+ assert.strictEqual(restCalls, 1, 'the rate-limited API was tried once');
+ assert.strictEqual(advanced.version, 'v0.56.0', 'the public redirect names the published release');
+ assert.strictEqual(advanced.partial, true, 'asset metadata is marked incomplete');
+ assert.deepStrictEqual(advanced.assets, []);
+ assert.strictEqual(advanced.installUrl, 'https://github.com/OpenVibers/OpenVibe.Link/releases/download/v0.56.0/install.sh');
+
+ now += 16 * 60e3;
+ await limited.latest({ fetchImpl: ok('v0.56.0'), now: () => now, dataDir: limitedDir });
+ await limited.whenIdle();
+ const complete = await limited.latest({ fetchImpl: ok('v0.56.0'), now: () => now + 1000, dataDir: limitedDir });
+ assert.ok(!complete.partial, 'a REST success replaces the incomplete answer');
+ assert.ok(complete.assets.includes('install.sh'), 'and restores the asset list');
+ } finally {
+ fs.rmSync(limitedDir, { recursive: true, force: true });
+ }
+
// ── The token: GITHUB_TOKEN is sent when set, from the environment only ──
// ── The token: GITHUB_TOKEN is sent when set, from the environment only ──
const sent = [];
const sent = [];
const tokenDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-node-token-'));
const tokenDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-node-token-'));
@@ -148,7 +148,7 @@ const REVIEWED = {
@@ -148,7 +148,7 @@ const REVIEWED = {
'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/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': [2, 'the Node\'s own release page: fixed hosts only — https://github.com/OpenVibers/OpenVibe.Link.git/git-upload-pack (ls-refs of refs/tags/v, no REST call) and the fixed GitHub API path (https://api.github.com/repos/OpenVibers/OpenVibe.Link/releases/latest), each with an 8 s timeout; the REST answer is held 15 minutes in memory and on disk and read only when a new tag exists; no request value ever names a host and it only ever reads the public release list'],
+ 'server/link/release.js fetch': [3, 'Link\'s own release page: fixed hosts only — GitHub git-upload-pack for release tags, GitHub releases/latest API for asset metadata, and a HEAD to the fixed github.com/OpenVibers/OpenVibe.Link/releases/latest page when REST is rate limited; the HEAD does not follow the redirect and its destination must be the expected GitHub release tag path; all requests time out after 8 s, no visitor value names a host, and the answer is cached for 15 minutes'],
'server/registry/machines.js fetch': [1, 'the machines hub calls Bot: the internal URL from operator config (OV_BOT_INTERNAL_URL, loopback by default), the paths are fixed, the machine id is a checked dev_ id (percent-encoded) and the person is named in a header, never in the URL'],
'server/registry/machines.js fetch': [1, 'the machines hub calls Bot: the internal URL from operator config (OV_BOT_INTERNAL_URL, loopback by default), the paths are fixed, the machine id is a checked dev_ id (percent-encoded) and the person is named in a header, never in the URL'],
'server/presence/index.js fetch': [2, "Live and Chat internal URLs from operator config (loopback defaults): Live's public /api/streams (fixed path, 2 s timeout, held 30 s) and Chat's POST /internal/chat/dm/peers (fixed path, 1.5 s, the subject list in the body); never a request value names a host"],
'server/presence/index.js fetch': [2, "Live and Chat internal URLs from operator config (loopback defaults): Live's public /api/streams (fixed path, 2 s timeout, held 30 s) and Chat's POST /internal/chat/dm/peers (fixed path, 1.5 s, the subject list in the body); never a request value names a host"],
'server/updates/routes.js fetch': [1, 'OV_BLOG_INTERNAL_URL (operator config)'],
'server/updates/routes.js fetch': [1, 'OV_BLOG_INTERNAL_URL (operator config)'],