openvibers/openvibe.network Commits

Frame: /api/frame/code/search for the composer's GitHub tool (#878)

OP OpenVibers committed 21 h ago · a9da84bdac · 4 files, +115 −0

Exchange's public code search through the Frame (every site reaches openvibe.network; Exchange sends no
CORS header). Only what a card needs: owner/name, the github.com address, description, language, stars,
licence, the owner's GitHub avatar, last push. Cached a minute per query; a failing Exchange is a 502.

Changes

ddiff --git a/server/frame/code-search.js b/server/frame/code-search.js
nnew file mode 100644
iindex 00000000..2160922a
---- /dev/null
++++ b/server/frame/code-search.js
@@@ -0,0 +1,60 @@
++'use strict';
++/**
++ * GET /api/frame/code/search?q=&limit= — repositories for the composer's GitHub tool (Shared 4.225.0), from
++ * OpenVibe.Exchange's public code search, through the Frame: every site's connect-src reaches openvibe.network and
++ * not openvibe.exchange, and Exchange's search sends no CORS header.
++ *
++ * 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.
++ */
++
++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 } = {}) {
++ const base = String(exchangeUrl || '').replace(/\/+$/, '');
++ const cache = new Map();
++ const clip = (v, n) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, n);
++ const httpsOn = (u, host) => { try { const x = new URL(String(u)); return x.protocol === 'https:' && (!host || x.hostname === host) ? x.href : null; } catch { return null; } };
++
++ function shape(it) {
++ if (!it || !NAME.test(String(it.full_name || ''))) return null;
++ const url = httpsOn(it.url, 'github.com');
++ if (!url) return null;
++ const out = { full_name: it.full_name, url };
++ const d = clip(it.description, 300); if (d) out.description = d;
++ const l = clip(it.language, 40); if (l) out.language = l;
++ if (Number.isFinite(Number(it.stars))) out.stars = Math.max(0, Math.round(Number(it.stars)));
++ const lic = clip(it.license, 60); if (lic) out.license = lic;
++ const av = httpsOn(it.owner_avatar_url, 'avatars.githubusercontent.com'); if (av) out.owner_avatar_url = av;
++ if (it.pushed && Number.isFinite(Date.parse(it.pushed))) out.pushed = new Date(Date.parse(it.pushed)).toISOString();
++ return out;
++ }
++
++ async function handle(req, res) {
++ res.set({ 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin', 'Content-Type': 'application/json; charset=utf-8' });
++ const q = clip(req.query && req.query.q, 120);
++ const limit = Math.min(Math.max(parseInt(req.query && req.query.limit, 10) || 8, 1), 10);
++ 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' });
++ 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 });
++ 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' });
++ } catch {
++ return res.status(502).set('Cache-Control', 'no-store').json({ error: 'Code search is unavailable right now', code: 'code.unavailable' });
++ } finally { clearTimeout(timer); }
++ }
++
++ return { handle, shape };
++}
++
++module.exports = { createFrameCodeSearch };
ddiff --git a/server/frame/service.js b/server/frame/service.js
iindex 5045c0ef..c521b8d0 100644
---- a/server/frame/service.js
++++ b/server/frame/service.js
@@@ -34,6 +34,7 @@ const { SITES, LISTED, LISTED_SITES, siteForHost, GROUPS, groupOf, PRODUCTS, MER
const { createMenuCards } = require('./menu-cards');
const { createFrameChoices } = require('./choices');
const { createFrameAsk } = require('./ask');
++const { createFrameCodeSearch } = require('./code-search');
const { read: readCommands } = require('./commands');
const { createFrameMentions } = require('./mentions');
const { createFrameRuns } = require('./runs');
@@@ -178,6 +179,8 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
exchangeUrl: (services.exchange && services.exchange.internalUrl) || 'http://127.0.0.1:5150',
reportUrl: (services.report && services.report.internalUrl) || 'http://127.0.0.1:5080',
});
++ // The composer's GitHub tool (Shared 4.225.0): Exchange's public code search, through the Frame.
++ const codeSearch = createFrameCodeSearch({ exchangeUrl: (services.exchange && services.exchange.internalUrl) || 'http://127.0.0.1:5150', now });
const ask = createFrameAsk({
aiUrl, now, requireAuth, sheet: choices,
searchUrl: (services.search && services.search.internalUrl) || 'http://127.0.0.1:4710',
@@@ -520,6 +523,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
updated: new Date().toISOString(),
};
}
++ router.get('/code/search', (req, res) => codeSearch.handle(req, res));
router.get('/pulse', async (req, res) => {
if (!pulse || Date.now() - pulseAt > 30_000) {
pulseInflight = pulseInflight || readPulse().then((p) => { pulse = p; pulseAt = Date.now(); }).catch(() => {}).finally(() => { pulseInflight = null; });
ddiff --git a/test/frame-code-search.test.js b/test/frame-code-search.test.js
nnew file mode 100644
iindex 00000000..21cecac8
---- /dev/null
++++ b/test/frame-code-search.test.js
@@@ -0,0 +1,50 @@
++'use strict';
++// GET /api/frame/code/search: the composer's GitHub tool reads Exchange's public code search through the Frame.
++const assert = require('assert');
++const express = require('express');
++const { createFrameCodeSearch } = require('../server/frame/code-search');
++
++(async () => {
++ const asked = [];
++ let down = false;
++ const exchange = async (url) => {
++ asked.push(url);
++ 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' },
++ { full_name: 'evil/x', url: 'javascript:alert(1)' },
++ { full_name: 'not a name', url: 'https://github.com/a/b' },
++ { full_name: 'bo/tracker', url: 'https://github.com/bo/tracker', owner_avatar_url: 'https://evil.example/p.png', stars: 'many' },
++ ] }) };
++ };
++ let t = 1000;
++ const cs = createFrameCodeSearch({ exchangeUrl: 'http://exchange.local/', fetchImpl: exchange, now: () => t });
++ const app = express();
++ 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`;
++ try {
++ let r = await fetch(`${base}?q=goal%20bar&limit=50`);
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*', 'every site may read it');
++ const body = await r.json();
++ assert.deepStrictEqual(body.items, [
++ { full_name: 'ada/goal-bar', url: 'https://github.com/ada/goal-bar', description: 'A goal bar for streams', language: 'JavaScript', stars: 42, license: 'MIT', owner_avatar_url: 'https://avatars.githubusercontent.com/u/1?v=4', pushed: '2026-11-10T10:00:00.000Z' },
++ { 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');
++ 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;
++ 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);
++ 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');
++ } 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 b92f01fb..95109c56 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -119,6 +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/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'],