openvibers/openvibe.network Commits

Frame code search: each person's own limit, and time for a fresh query (#880)

OP OpenVibers committed 22 h ago · 082c25b41c · 3 files, +34 −13

* Frame code search: each person's own limit, and time for a fresh query

Through the Frame every search counted as Network's one address against Exchange's 20 a minute; the
person's address and token now go along, as every Frame relay does. A fresh query asks GitHub and takes
5 to 10 s, so the wait is 9 s (was 4, which failed them); answers keep ten minutes; a limit is a 429 with
its Retry-After.

* Test: the review note's apostrophe escaped

Changes

ddiff --git a/server/frame/code-search.js b/server/frame/code-search.js
iindex 2160922a..89fc68b8 100644
---- a/server/frame/code-search.js
++++ b/server/frame/code-search.js
@@@ -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', fetchImp
if (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); }
ddiff --git a/test/frame-code-search.test.js b/test/frame-code-search.test.js
iindex 21cecac8..a795a113 100644
---- a/test/frame-code-search.test.js
++++ b/test/frame-code-search.test.js
@@@ -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); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 4a7ebb39..6f278c08 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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'],