openvibers/openvibe.network Commits
Frame code search: each person's own limit, and time for a fresh query (#880)
Changes
@@@ -6,12 +6,17 @@** Only what a card needs leaves here: the repository's owner/name, its GitHub address, a short description, its* language, stars, licence, the owner's picture (GitHub's avatar host only) and when it was last pushed. Answers are-- * kept a minute per query (public data), at most `max` queries; a slow or failing Exchange is a 502 that says so.++ * kept ten minutes per query (public data, as Exchange keeps them), at most `max` queries.++ *++ * Exchange limits searches per person (or per address, signed out), so the person's address and token go along, as++ * every Frame relay does: through the Frame they would otherwise all count as Network's one address. A fresh query++ * asks GitHub and can take several seconds (measured 5 to 10 s); a limit from Exchange or GitHub is a 429 with its++ * Retry-After, anything else a 502 that says so.*/const NAME = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;--function createFrameCodeSearch({ exchangeUrl = 'http://127.0.0.1:5150', fetchImpl = globalThis.fetch, now = Date.now, ttlMs = 60000, max = 200, timeoutMs = 4000 } = {}) {++function createFrameCodeSearch({ exchangeUrl = 'http://127.0.0.1:5150', fetchImpl = globalThis.fetch, now = Date.now, ttlMs = 600000, max = 300, timeoutMs = 9000 } = {}) {const base = String(exchangeUrl || '').replace(/\/+$/, '');const cache = new Map();const clip = (v, n) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, n);@@@ -38,17 +43,23 @@ function createFrameCodeSearch({ exchangeUrl = 'http://127.0.0.1:5150', fetchImpif (q.length < 2) return res.set('Cache-Control', 'public, max-age=300').json({ items: [] });const key = `${q.toLowerCase()}|${limit}`;const hit = cache.get(key);-- if (hit && now() - hit.at < ttlMs) return res.set('Cache-Control', 'public, max-age=60').json({ items: hit.items, source: 'exchange' });++ if (hit && now() - hit.at < ttlMs) return res.set('Cache-Control', 'public, max-age=300').json({ items: hit.items, source: 'exchange' });const ctl = new AbortController();const timer = setTimeout(() => ctl.abort(), timeoutMs);try {-- const r = await fetchImpl(`${base}/api/v1/code/search?q=${encodeURIComponent(q)}&limit=${limit}`, { headers: { accept: 'application/json' }, redirect: 'error', signal: ctl.signal });++ const auth = String((req.get && req.get('authorization')) || '');++ const headers = { accept: 'application/json', ...(req.ip ? { 'X-Forwarded-For': req.ip } : {}), ...(/^Bearer [A-Za-z0-9._~+/=-]{10,4096}$/.test(auth) ? { Authorization: auth } : {}) };++ const r = await fetchImpl(`${base}/api/v1/code/search?q=${encodeURIComponent(q)}&limit=${limit}`, { headers, redirect: 'error', signal: ctl.signal });++ if (r.status === 429) {++ const wait = Math.min(Math.max(parseInt((r.headers && r.headers.get && r.headers.get('retry-after')) || '60', 10) || 60, 1), 600);++ return res.status(429).set({ 'Retry-After': String(wait), 'Cache-Control': 'no-store' }).json({ error: `Too many searches just now: try again in ${wait < 90 ? `${wait} seconds` : `${Math.round(wait / 60)} minutes`}`, code: 'code.rate_limited' });++ }if (!r.ok) throw new Error(`exchange ${r.status}`);const j = await r.json();const items = (Array.isArray(j && j.items) ? j.items : []).map(shape).filter(Boolean).slice(0, limit);cache.set(key, { at: now(), items });if (cache.size > max) cache.delete(cache.keys().next().value);-- return res.set('Cache-Control', 'public, max-age=60').json({ items, source: 'exchange' });++ return res.set('Cache-Control', 'public, max-age=300').json({ items, source: 'exchange' });} catch {return res.status(502).set('Cache-Control', 'no-store').json({ error: 'Code search is unavailable right now', code: 'code.unavailable' });} finally { clearTimeout(timer); }@@@ -5,10 +5,11 @@ const express = require('express');const { createFrameCodeSearch } = require('../server/frame/code-search');(async () => {-- const asked = [];-- let down = false;-- const exchange = async (url) => {-- asked.push(url);++ const asked = []; const sent = [];++ let down = false, limited = false;++ const exchange = async (url, init) => {++ asked.push(url); sent.push(init.headers);++ if (limited) return { ok: false, status: 429, headers: { get: (k) => (k === 'retry-after' ? '45' : null) }, json: async () => ({}) };if (down) return { ok: false, status: 503, json: async () => ({}) };return { ok: true, status: 200, json: async () => ({ items: [{ full_name: 'ada/goal-bar', url: 'https://github.com/ada/goal-bar', description: ' A goal bar\n for streams ', language: 'JavaScript', stars: 42, license: 'MIT', owner_avatar_url: 'https://avatars.githubusercontent.com/u/1?v=4', pushed: '2026-11-10T10:00:00Z', homepage: 'https://x.example' },@@@ -20,6 +21,7 @@ const { createFrameCodeSearch } = require('../server/frame/code-search');let t = 1000;const cs = createFrameCodeSearch({ exchangeUrl: 'http://exchange.local/', fetchImpl: exchange, now: () => t });const app = express();++ app.set('trust proxy', 'loopback');app.get('/api/frame/code/search', (req, res) => cs.handle(req, res));const server = await new Promise((ok) => { const s = app.listen(0, '127.0.0.1', () => ok(s)); });const base = `http://127.0.0.1:${server.address().port}/api/frame/code/search`;@@@ -33,18 +35,26 @@ const { createFrameCodeSearch } = require('../server/frame/code-search');{ full_name: 'bo/tracker', url: 'https://github.com/bo/tracker' },], 'only what a card needs; a script address, a bad name and a picture from elsewhere are left out');assert.strictEqual(asked[0], 'http://exchange.local/api/v1/code/search?q=goal%20bar&limit=10', 'at most ten');++ assert.ok(sent[0]['X-Forwarded-For'], 'the person\'s address goes along (Exchange limits per person, not per Frame)');++ assert.strictEqual(sent[0].Authorization, undefined, 'no token was sent, none is forwarded');await fetch(`${base}?q=Goal%20Bar&limit=50`);-- assert.strictEqual(asked.length, 1, 'the same query within a minute is answered from the cache');-- t += 61000;++ assert.strictEqual(asked.length, 1, 'the same query within ten minutes is answered from the cache');++ t += 601000;await fetch(`${base}?q=goal%20bar&limit=50`);assert.strictEqual(asked.length, 2, 'and asked again after it');r = await fetch(`${base}?q=a`);assert.deepStrictEqual(await r.json(), { items: [] }, 'one letter asks nothing');assert.strictEqual(asked.length, 2);++ await fetch(`${base}?q=with%20a%20token`, { headers: { Authorization: 'Bearer tok_abcdefghijkl' } });++ assert.strictEqual(sent.at(-1).Authorization, 'Bearer tok_abcdefghijkl', 'a person\'s token goes along');++ limited = true;++ r = await fetch(`${base}?q=one%20more`);++ assert.deepStrictEqual([r.status, r.headers.get('retry-after'), (await r.json()).code], [429, '45', 'code.rate_limited'], 'a limit is a 429 with its wait, not a failure');++ limited = false;down = true;r = await fetch(`${base}?q=something%20else`);assert.strictEqual(r.status, 502);assert.strictEqual((await r.json()).code, 'code.unavailable');-- console.log('frame code search: through the Frame, only what a card needs, cached a minute, says when Exchange is down');++ console.log('frame code search: through the Frame with the person\'s address and token, only what a card needs, cached ten minutes, a limit is a 429, says when Exchange is down');} finally { server.close(); }})().catch((e) => { console.error(e); process.exit(1); });@@@ -119,7 +119,7 @@ const REVIEWED = {'server/composer/routes.js fetch': [2, 'mention search: the operator-configured OpenVibe.Git internal URL (config.services.git) with a fixed /api/v1/repos path and the query percent-encoded; no caller-chosen host, 2.5 s timeout, and no token or body is sent with it'],'server/frame/today.js fetch': [1, 'Report internal URL from operator config, fixed /api/v1/stats?days=30 path, five-second timeout; no request-selected URL'],'server/frame/choices.js fetch': [1, 'the composer\'s Model sheet: AI (OV_AI_INTERNAL_URL), Exchange (OV_EXCHANGE_INTERNAL_URL / config.services.exchange) and Report (OV_REPORT_INTERNAL_URL) internal URLs from operator config, fixed /api/v1/choices, /api/v1/models and /api/v1/ai/leaderboard paths, the task percent-encoded and a checked person Bearer only; no request value names a host, redirects refused, 3-8 s timeouts'],-- 'server/frame/code-search.js fetch': [1, 'The composer\'s GitHub tool: only the operator-configured internal URL of Exchange (services.exchange.internalUrl), the fixed /api/v1/code/search path with the query encoded and the limit clamped to 10, no credentials, redirects refused, a 4 s timeout; never a request-chosen host'],++ 'server/frame/code-search.js fetch': [1, 'The composer\'s GitHub tool: only the operator-configured internal URL of Exchange (services.exchange.internalUrl), the fixed /api/v1/code/search path with the query encoded and the limit clamped to 10, the person\'s address and Bearer forwarded as every Frame relay does, redirects refused, a 9 s timeout; never a request-chosen host'],'server/frame/runs.js fetch': [1, 'Frame Runs: only the operator-configured internal URLs of Chat, Codes and Actor (svcUrl), fixed /api/vibe and /api/v1 paths with encoded ids from a checked ref, the person\'s own Bearer, redirects refused, 4-15 s timeouts; never a request-chosen host'],'server/frame/ask.js fetch': [2, 'the composer\'s Ask: Search (OV_SEARCH_INTERNAL_URL) fixed /api/v1/query path with checked scopes and the person\'s Bearer, and AI (OV_AI_INTERNAL_URL) fixed /api/v1/runs?stream=1 path with the person\'s Bearer; both operator-configured internal URLs, no request value names a host, redirects refused, 30-150 s timeouts'],'server/admin/analytics-routes.js fetch': [2, 'staff analytics: the services\' internal URLs from the registry (owner/admin set), and Insights\' Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; never a request value'],
@@ -6,12 +6,17 @@
@@ -6,12 +6,17 @@
*
*
* Only what a card needs leaves here: the repository's owner/name, its GitHub address, a short description, its
* Only what a card needs leaves here: the repository's owner/name, its GitHub address, a short description, its
* language, stars, licence, the owner's picture (GitHub's avatar host only) and when it was last pushed. Answers are
* language, stars, licence, the owner's picture (GitHub's avatar host only) and when it was last pushed. Answers are
- * kept a minute per query (public data), at most `max` queries; a slow or failing Exchange is a 502 that says so.
+ * kept ten minutes per query (public data, as Exchange keeps them), at most `max` queries.
+ *
+ * Exchange limits searches per person (or per address, signed out), so the person's address and token go along, as
+ * every Frame relay does: through the Frame they would otherwise all count as Network's one address. A fresh query
+ * asks GitHub and can take several seconds (measured 5 to 10 s); a limit from Exchange or GitHub is a 429 with its
+ * Retry-After, anything else a 502 that says so.
*/
*/
const NAME = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
const NAME = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
-function createFrameCodeSearch({ exchangeUrl = 'http://127.0.0.1:5150', fetchImpl = globalThis.fetch, now = Date.now, ttlMs = 60000, max = 200, timeoutMs = 4000 } = {}) {
+function createFrameCodeSearch({ exchangeUrl = 'http://127.0.0.1:5150', fetchImpl = globalThis.fetch, now = Date.now, ttlMs = 600000, max = 300, timeoutMs = 9000 } = {}) {
const base = String(exchangeUrl || '').replace(/\/+$/, '');
const base = String(exchangeUrl || '').replace(/\/+$/, '');
const cache = new Map();
const cache = new Map();
const clip = (v, n) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, n);
const clip = (v, n) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, n);
@@ -38,17 +43,23 @@ function createFrameCodeSearch({ exchangeUrl = 'http://127.0.0.1:5150', fetchImp
@@ -38,17 +43,23 @@ function createFrameCodeSearch({ exchangeUrl = 'http://127.0.0.1:5150', fetchImp
if (q.length < 2) return res.set('Cache-Control', 'public, max-age=300').json({ items: [] });
if (q.length < 2) return res.set('Cache-Control', 'public, max-age=300').json({ items: [] });
const key = `${q.toLowerCase()}|${limit}`;
const key = `${q.toLowerCase()}|${limit}`;
const hit = cache.get(key);
const hit = cache.get(key);
- if (hit && now() - hit.at < ttlMs) return res.set('Cache-Control', 'public, max-age=60').json({ items: hit.items, source: 'exchange' });
+ if (hit && now() - hit.at < ttlMs) return res.set('Cache-Control', 'public, max-age=300').json({ items: hit.items, source: 'exchange' });
const ctl = new AbortController();
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), timeoutMs);
const timer = setTimeout(() => ctl.abort(), timeoutMs);
try {
try {
- const r = await fetchImpl(`${base}/api/v1/code/search?q=${encodeURIComponent(q)}&limit=${limit}`, { headers: { accept: 'application/json' }, redirect: 'error', signal: ctl.signal });
+ const auth = String((req.get && req.get('authorization')) || '');
+ const headers = { accept: 'application/json', ...(req.ip ? { 'X-Forwarded-For': req.ip } : {}), ...(/^Bearer [A-Za-z0-9._~+/=-]{10,4096}$/.test(auth) ? { Authorization: auth } : {}) };
+ const r = await fetchImpl(`${base}/api/v1/code/search?q=${encodeURIComponent(q)}&limit=${limit}`, { headers, redirect: 'error', signal: ctl.signal });
+ if (r.status === 429) {
+ const wait = Math.min(Math.max(parseInt((r.headers && r.headers.get && r.headers.get('retry-after')) || '60', 10) || 60, 1), 600);
+ return res.status(429).set({ 'Retry-After': String(wait), 'Cache-Control': 'no-store' }).json({ error: `Too many searches just now: try again in ${wait < 90 ? `${wait} seconds` : `${Math.round(wait / 60)} minutes`}`, code: 'code.rate_limited' });
+ }
if (!r.ok) throw new Error(`exchange ${r.status}`);
if (!r.ok) throw new Error(`exchange ${r.status}`);
const j = await r.json();
const j = await r.json();
const items = (Array.isArray(j && j.items) ? j.items : []).map(shape).filter(Boolean).slice(0, limit);
const items = (Array.isArray(j && j.items) ? j.items : []).map(shape).filter(Boolean).slice(0, limit);
cache.set(key, { at: now(), items });
cache.set(key, { at: now(), items });
if (cache.size > max) cache.delete(cache.keys().next().value);
if (cache.size > max) cache.delete(cache.keys().next().value);
- return res.set('Cache-Control', 'public, max-age=60').json({ items, source: 'exchange' });
+ return res.set('Cache-Control', 'public, max-age=300').json({ items, source: 'exchange' });
} catch {
} catch {
return res.status(502).set('Cache-Control', 'no-store').json({ error: 'Code search is unavailable right now', code: 'code.unavailable' });
return res.status(502).set('Cache-Control', 'no-store').json({ error: 'Code search is unavailable right now', code: 'code.unavailable' });
} finally { clearTimeout(timer); }
} finally { clearTimeout(timer); }
@@ -5,10 +5,11 @@ const express = require('express');
@@ -5,10 +5,11 @@ const express = require('express');
const { createFrameCodeSearch } = require('../server/frame/code-search');
const { createFrameCodeSearch } = require('../server/frame/code-search');
(async () => {
(async () => {
- const asked = [];
- let down = false;
- const exchange = async (url) => {
- asked.push(url);
+ const asked = []; const sent = [];
+ let down = false, limited = false;
+ const exchange = async (url, init) => {
+ asked.push(url); sent.push(init.headers);
+ if (limited) return { ok: false, status: 429, headers: { get: (k) => (k === 'retry-after' ? '45' : null) }, json: async () => ({}) };
if (down) return { ok: false, status: 503, json: async () => ({}) };
if (down) return { ok: false, status: 503, json: async () => ({}) };
return { ok: true, status: 200, json: async () => ({ items: [
return { ok: true, status: 200, json: async () => ({ items: [
{ full_name: 'ada/goal-bar', url: 'https://github.com/ada/goal-bar', description: ' A goal bar\n for streams ', language: 'JavaScript', stars: 42, license: 'MIT', owner_avatar_url: 'https://avatars.githubusercontent.com/u/1?v=4', pushed: '2026-11-10T10:00:00Z', homepage: 'https://x.example' },
{ full_name: 'ada/goal-bar', url: 'https://github.com/ada/goal-bar', description: ' A goal bar\n for streams ', language: 'JavaScript', stars: 42, license: 'MIT', owner_avatar_url: 'https://avatars.githubusercontent.com/u/1?v=4', pushed: '2026-11-10T10:00:00Z', homepage: 'https://x.example' },
@@ -20,6 +21,7 @@ const { createFrameCodeSearch } = require('../server/frame/code-search');
@@ -20,6 +21,7 @@ const { createFrameCodeSearch } = require('../server/frame/code-search');
let t = 1000;
let t = 1000;
const cs = createFrameCodeSearch({ exchangeUrl: 'http://exchange.local/', fetchImpl: exchange, now: () => t });
const cs = createFrameCodeSearch({ exchangeUrl: 'http://exchange.local/', fetchImpl: exchange, now: () => t });
const app = express();
const app = express();
+ app.set('trust proxy', 'loopback');
app.get('/api/frame/code/search', (req, res) => cs.handle(req, res));
app.get('/api/frame/code/search', (req, res) => cs.handle(req, res));
const server = await new Promise((ok) => { const s = app.listen(0, '127.0.0.1', () => ok(s)); });
const server = await new Promise((ok) => { const s = app.listen(0, '127.0.0.1', () => ok(s)); });
const base = `http://127.0.0.1:${server.address().port}/api/frame/code/search`;
const base = `http://127.0.0.1:${server.address().port}/api/frame/code/search`;
@@ -33,18 +35,26 @@ const { createFrameCodeSearch } = require('../server/frame/code-search');
@@ -33,18 +35,26 @@ const { createFrameCodeSearch } = require('../server/frame/code-search');
{ full_name: 'bo/tracker', url: 'https://github.com/bo/tracker' },
{ full_name: 'bo/tracker', url: 'https://github.com/bo/tracker' },
], 'only what a card needs; a script address, a bad name and a picture from elsewhere are left out');
], 'only what a card needs; a script address, a bad name and a picture from elsewhere are left out');
assert.strictEqual(asked[0], 'http://exchange.local/api/v1/code/search?q=goal%20bar&limit=10', 'at most ten');
assert.strictEqual(asked[0], 'http://exchange.local/api/v1/code/search?q=goal%20bar&limit=10', 'at most ten');
+ assert.ok(sent[0]['X-Forwarded-For'], 'the person\'s address goes along (Exchange limits per person, not per Frame)');
+ assert.strictEqual(sent[0].Authorization, undefined, 'no token was sent, none is forwarded');
await fetch(`${base}?q=Goal%20Bar&limit=50`);
await fetch(`${base}?q=Goal%20Bar&limit=50`);
- assert.strictEqual(asked.length, 1, 'the same query within a minute is answered from the cache');
- t += 61000;
+ assert.strictEqual(asked.length, 1, 'the same query within ten minutes is answered from the cache');
+ t += 601000;
await fetch(`${base}?q=goal%20bar&limit=50`);
await fetch(`${base}?q=goal%20bar&limit=50`);
assert.strictEqual(asked.length, 2, 'and asked again after it');
assert.strictEqual(asked.length, 2, 'and asked again after it');
r = await fetch(`${base}?q=a`);
r = await fetch(`${base}?q=a`);
assert.deepStrictEqual(await r.json(), { items: [] }, 'one letter asks nothing');
assert.deepStrictEqual(await r.json(), { items: [] }, 'one letter asks nothing');
assert.strictEqual(asked.length, 2);
assert.strictEqual(asked.length, 2);
+ await fetch(`${base}?q=with%20a%20token`, { headers: { Authorization: 'Bearer tok_abcdefghijkl' } });
+ assert.strictEqual(sent.at(-1).Authorization, 'Bearer tok_abcdefghijkl', 'a person\'s token goes along');
+ limited = true;
+ r = await fetch(`${base}?q=one%20more`);
+ assert.deepStrictEqual([r.status, r.headers.get('retry-after'), (await r.json()).code], [429, '45', 'code.rate_limited'], 'a limit is a 429 with its wait, not a failure');
+ limited = false;
down = true;
down = true;
r = await fetch(`${base}?q=something%20else`);
r = await fetch(`${base}?q=something%20else`);
assert.strictEqual(r.status, 502);
assert.strictEqual(r.status, 502);
assert.strictEqual((await r.json()).code, 'code.unavailable');
assert.strictEqual((await r.json()).code, 'code.unavailable');
- console.log('frame code search: through the Frame, only what a card needs, cached a minute, says when Exchange is down');
+ console.log('frame code search: through the Frame with the person\'s address and token, only what a card needs, cached ten minutes, a limit is a 429, says when Exchange is down');
} finally { server.close(); }
} finally { server.close(); }
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });
@@ -119,7 +119,7 @@ const REVIEWED = {
@@ -119,7 +119,7 @@ const REVIEWED = {
'server/composer/routes.js fetch': [2, 'mention search: the operator-configured OpenVibe.Git internal URL (config.services.git) with a fixed /api/v1/repos path and the query percent-encoded; no caller-chosen host, 2.5 s timeout, and no token or body is sent with it'],
'server/composer/routes.js fetch': [2, 'mention search: the operator-configured OpenVibe.Git internal URL (config.services.git) with a fixed /api/v1/repos path and the query percent-encoded; no caller-chosen host, 2.5 s timeout, and no token or body is sent with it'],
'server/frame/today.js fetch': [1, 'Report internal URL from operator config, fixed /api/v1/stats?days=30 path, five-second timeout; no request-selected URL'],
'server/frame/today.js fetch': [1, 'Report internal URL from operator config, fixed /api/v1/stats?days=30 path, five-second timeout; no request-selected URL'],
'server/frame/choices.js fetch': [1, 'the composer\'s Model sheet: AI (OV_AI_INTERNAL_URL), Exchange (OV_EXCHANGE_INTERNAL_URL / config.services.exchange) and Report (OV_REPORT_INTERNAL_URL) internal URLs from operator config, fixed /api/v1/choices, /api/v1/models and /api/v1/ai/leaderboard paths, the task percent-encoded and a checked person Bearer only; no request value names a host, redirects refused, 3-8 s timeouts'],
'server/frame/choices.js fetch': [1, 'the composer\'s Model sheet: AI (OV_AI_INTERNAL_URL), Exchange (OV_EXCHANGE_INTERNAL_URL / config.services.exchange) and Report (OV_REPORT_INTERNAL_URL) internal URLs from operator config, fixed /api/v1/choices, /api/v1/models and /api/v1/ai/leaderboard paths, the task percent-encoded and a checked person Bearer only; no request value names a host, redirects refused, 3-8 s timeouts'],
- 'server/frame/code-search.js fetch': [1, 'The composer\'s GitHub tool: only the operator-configured internal URL of Exchange (services.exchange.internalUrl), the fixed /api/v1/code/search path with the query encoded and the limit clamped to 10, no credentials, redirects refused, a 4 s timeout; never a request-chosen host'],
+ 'server/frame/code-search.js fetch': [1, 'The composer\'s GitHub tool: only the operator-configured internal URL of Exchange (services.exchange.internalUrl), the fixed /api/v1/code/search path with the query encoded and the limit clamped to 10, the person\'s address and Bearer forwarded as every Frame relay does, redirects refused, a 9 s timeout; never a request-chosen host'],
'server/frame/runs.js fetch': [1, 'Frame Runs: only the operator-configured internal URLs of Chat, Codes and Actor (svcUrl), fixed /api/vibe and /api/v1 paths with encoded ids from a checked ref, the person\'s own Bearer, redirects refused, 4-15 s timeouts; never a request-chosen host'],
'server/frame/runs.js fetch': [1, 'Frame Runs: only the operator-configured internal URLs of Chat, Codes and Actor (svcUrl), fixed /api/vibe and /api/v1 paths with encoded ids from a checked ref, the person\'s own Bearer, redirects refused, 4-15 s timeouts; never a request-chosen host'],
'server/frame/ask.js fetch': [2, 'the composer\'s Ask: Search (OV_SEARCH_INTERNAL_URL) fixed /api/v1/query path with checked scopes and the person\'s Bearer, and AI (OV_AI_INTERNAL_URL) fixed /api/v1/runs?stream=1 path with the person\'s Bearer; both operator-configured internal URLs, no request value names a host, redirects refused, 30-150 s timeouts'],
'server/frame/ask.js fetch': [2, 'the composer\'s Ask: Search (OV_SEARCH_INTERNAL_URL) fixed /api/v1/query path with checked scopes and the person\'s Bearer, and AI (OV_AI_INTERNAL_URL) fixed /api/v1/runs?stream=1 path with the person\'s Bearer; both operator-configured internal URLs, no request value names a host, redirects refused, 30-150 s timeouts'],
'server/admin/analytics-routes.js fetch': [2, 'staff analytics: the services\' internal URLs from the registry (owner/admin set), and Insights\' Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; never a request value'],
'server/admin/analytics-routes.js fetch': [2, 'staff analytics: the services\' internal URLs from the registry (owner/admin set), and Insights\' Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; never a request value'],