openvibers/openvibe.network Commits

Frame: the menu's Feed and wall through Network (#714)

OP OpenVibers committed 4 days ago · 872aa6233a · 4 files, +138 −1

* Frame: the menu's Feed and wall through Network (/api/frame/feed, /api/frame/wall)

Every site's connect-src lets the navbar reach openvibe.network and refuses openvibe.community, so the menu's Feed
fell back to its old sections everywhere but five sites. Network relays Community's /api/v1/feed and /api/v1/pulse:
CORS *, the person's Bearer token along when sent (never a cookie), anonymous pages kept 30 s, personal ones not kept,
Community not answering is an empty page. config.services.community (OV_COMMUNITY_INTERNAL_URL, 127.0.0.1:4200).

* Frame: the composer's post through Network too (POST /api/frame/status, the person's token only, 8 KB)

* Outbound inventory: the composer relay's call to Community is reviewed

Changes

ddiff --git a/server/config.js b/server/config.js
iindex 142ee98c..888a846d 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -148,6 +148,10 @@ module.exports = {
media: {
internalUrl: process.env.OV_MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100',
},
++ // OpenVibe.Community: the menu's Feed and wall, asked through Network (server/frame/service.js /feed, /wall).
++ community: {
++ internalUrl: process.env.OV_COMMUNITY_INTERNAL_URL || 'http://127.0.0.1:4200',
++ },
// OpenVibe.Chat: the admin panel's TTS settings (server/admin/service-proxies.js).
chat: {
internalUrl: process.env.OV_CHAT_INTERNAL_URL || 'http://127.0.0.1:4400',
ddiff --git a/server/frame/service.js b/server/frame/service.js
iindex 3cc53f50..b5981922 100644
---- a/server/frame/service.js
++++ b/server/frame/service.js
@@@ -4,6 +4,7 @@
//
// GET /api/frame?host=<hostname> public, CORS *, max-age=600, ETag
// GET /api/frame/pulse public, CORS *, max-age=30: { live_now, watching, sites, views_7d, live, updated }
++// GET /api/frame/feed, /api/frame/wall the menu's Feed and wall: Community's /api/v1/feed and /api/v1/pulse, relayed
// POST /api/frame/menu-click public, CORS *, text/plain, max 512 bytes, 60/min per IP: one menu click counted
// → { updated, nav: [{ id, name, url, icon, tagline, group, of? }], open sites, most used first (of: a product
// on its own domain, served by that site)
@@@ -434,6 +435,63 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
res.send(JSON.stringify(pulse || { live_now: null, watching: null, sites: orderedOpen().length, views_7d: null, live: null, updated: null }));
});
++ // GET /api/frame/feed?kind=&cursor=&limit= and GET /api/frame/wall?limit=&after=&for= — the menu's Feed and wall
++ // (openvibe-shared ov-home.js) on every site, asked through Network because every site's connect-src lets the navbar
++ // reach openvibe.network, and the sites' policies refused openvibe.community (11-09: the Feed fell back to its old
++ // sections everywhere but five sites). Community's /api/v1/feed and /api/v1/pulse answer; the person's Bearer token
++ // goes along when the menu sends one (a personal wall, the composer's can_post), never a cookie. Anonymous answers are
++ // kept 30 s per query; personal ones are not kept. Community not answering is an empty page, never an error.
++ const relayed = new Map();
++ const FEED_KINDS = ['live', 'stream', 'clip', 'video', 'pic', 'post', 'meme', 'game', 'update'];
++ async function relay(req, res, path, params) {
++ res.set({ 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Authorization', 'Vary': 'Authorization', 'Content-Type': 'application/json; charset=utf-8' });
++ const auth = /^Bearer [A-Za-z0-9._~+/=-]{10,4096}$/.test(String(req.get('authorization') || '')) ? req.get('authorization') : '';
++ const u = new URL(path, svcUrl('community', 'http://127.0.0.1:4200'));
++ Object.entries(params).forEach(([k, v]) => { if (v) u.searchParams.set(k, v); });
++ const key = `${path}?${u.searchParams}`;
++ if (!auth) {
++ const kept = relayed.get(key);
++ if (kept && Date.now() - kept.at < 30_000) { res.set('Cache-Control', 'public, max-age=30'); return res.send(kept.body); }
++ }
++ const d = await getJson(u.href, auth ? { Authorization: auth, Accept: 'application/json' } : { Accept: 'application/json' });
++ const body = JSON.stringify(d && Array.isArray(d.items) ? d : { items: [], next_cursor: null });
++ if (!auth && d) {
++ if (relayed.size >= 500) relayed.clear();
++ relayed.set(key, { at: Date.now(), body });
++ }
++ res.set('Cache-Control', auth ? 'private, no-store' : 'public, max-age=30');
++ return res.send(body);
++ }
++ const qCursor = (v) => (/^[A-Za-z0-9._~=-]{1,300}$/.test(String(v || '')) ? String(v) : '');
++ const qLimit = (v, max) => String(Math.min(Math.max(parseInt(v, 10) || 20, 1), max));
++ router.options(['/feed', '/wall', '/status'], (req, res) => {
++ res.set({ 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Authorization, Content-Type', 'Access-Control-Allow-Methods': 'GET, POST', 'Access-Control-Max-Age': '600' });
++ res.status(204).end();
++ });
++ // POST /api/frame/status — the menu composer's post (Community's POST /api/v1/me/status), for the same reason: only
++ // with the person's Bearer token, a JSON body of at most 8 KB, Community's answer and status passed back as they are.
++ router.post('/status', express.json({ limit: '8kb' }), async (req, res) => {
++ res.set({ 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'no-store', 'Content-Type': 'application/json; charset=utf-8' });
++ const auth = /^Bearer [A-Za-z0-9._~+/=-]{10,4096}$/.test(String(req.get('authorization') || '')) ? req.get('authorization') : '';
++ if (!auth) return res.status(401).send('{"error":"sign in to post"}');
++ try {
++ const r = await fetch(new URL('/api/v1/me/status', svcUrl('community', 'http://127.0.0.1:4200')).href, {
++ method: 'POST', headers: { Authorization: auth, 'Content-Type': 'application/json', Accept: 'application/json' },
++ body: JSON.stringify(req.body || {}), signal: AbortSignal.timeout(8000),
++ });
++ const text = await r.text();
++ return res.status(r.status).send(text.slice(0, 64 * 1024));
++ } catch { return res.status(502).send('{"error":"Community did not answer"}'); }
++ });
++ router.get('/feed', (req, res) => relay(req, res, '/api/v1/feed', {
++ kind: FEED_KINDS.includes(String(req.query.kind || '')) ? String(req.query.kind) : '',
++ cursor: qCursor(req.query.cursor), limit: qLimit(req.query.limit, 40),
++ }));
++ router.get('/wall', (req, res) => relay(req, res, '/api/v1/pulse', {
++ limit: qLimit(req.query.limit, 50), after: qCursor(req.query.after), cursor: qCursor(req.query.cursor),
++ for: req.query.for === 'me' ? 'me' : '', origin: ['user', 'ai', 'system', 'all'].includes(String(req.query.origin || '')) ? String(req.query.origin) : '',
++ }));
++
// GET /api/frame/suggest?q=&owner=&limit= — the navbar's magnifier (openvibe-shared search.js): OpenVibe.Search's
// suggestions, asked through Network because every site's connect-src already lets the navbar reach
// openvibe.network, and 33 of them refused search.openvibe.network (2026-10-28). Anonymous, as the browser's own call
ddiff --git a/test/frame-feed.test.js b/test/frame-feed.test.js
nnew file mode 100644
iindex 00000000..e912a293
---- /dev/null
++++ b/test/frame-feed.test.js
@@@ -0,0 +1,75 @@
++'use strict';
++// GET /api/frame/feed and /api/frame/wall: the menu's Feed and wall on every site, relayed to OpenVibe.Community, because
++// every site's connect-src lets the navbar reach openvibe.network and refuses openvibe.community (2026-11-09).
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++const { getDb } = require('../server/db/database');
++const { createFrameService } = require('../server/frame/service');
++
++(async () => {
++ const asked = [];
++ let down = false;
++ const community = http.createServer((req, res) => {
++ asked.push({ url: req.url, auth: req.headers.authorization || '', cookie: req.headers.cookie || '' });
++ if (down) { res.statusCode = 503; return res.end('{}'); }
++ res.setHeader('content-type', 'application/json');
++ const u = new URL(req.url, 'http://x');
++ if (u.pathname === '/api/v1/feed') return res.end(JSON.stringify({ items: [{ id: 'p:1', kind: u.searchParams.get('kind') || 'post', url: 'https://openvibe.community/p/1', title: 'A post' }], next_cursor: 'c2' }));
++ if (u.pathname === '/api/v1/me/status' && req.method === 'POST') { let body = ''; req.on('data', (c) => { body += c; }); req.on('end', () => { asked.push({ url: req.url, auth: req.headers.authorization || '', body }); res.statusCode = req.headers.authorization ? 201 : 401; res.end(JSON.stringify({ ok: true })); }); return undefined; }
++ if (u.pathname === '/api/v1/pulse') return res.end(JSON.stringify({ items: [{ id: 1, title: 'ada went live' }], next_cursor: null, viewer: { can_post: !!req.headers.authorization } }));
++ res.statusCode = 404; return res.end('{}');
++ });
++ await new Promise((r) => community.listen(0, '127.0.0.1', r));
++ const svc = await createFrameService(getDb(), { services: { community: { internalUrl: `http://127.0.0.1:${community.address().port}` } } }, null);
++ const app = express();
++ app.set('trust proxy', 'loopback');
++ app.use('/api/frame', svc.router);
++ const server = app.listen(0, '127.0.0.1');
++ await new Promise((r) => server.once('listening', r));
++ const base = `http://127.0.0.1:${server.address().port}/api/frame`;
++ const get = async (p, headers) => { const r = await fetch(`${base}${p}`, { headers }); return { r, d: await r.json() }; };
++
++ let { r, d } = await get('/feed?kind=clip&limit=12&cursor=c1');
++ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*', 'any site may ask');
++ assert.deepStrictEqual(d.items.map((x) => x.kind), ['clip']);
++ assert.strictEqual(d.next_cursor, 'c2');
++ const first = new URL(asked[0].url, 'http://x');
++ assert.deepStrictEqual([first.pathname, first.searchParams.get('kind'), first.searchParams.get('limit'), first.searchParams.get('cursor')], ['/api/v1/feed', 'clip', '12', 'c1']);
++
++ ({ d } = await get('/feed?kind=clip&limit=12&cursor=c1'));
++ assert.strictEqual(asked.length, 1, 'the same anonymous page within 30 s is answered from memory');
++
++ ({ r, d } = await get('/wall?limit=20&for=me', { Authorization: 'Bearer abcdefghijklmnop.qrstu', Cookie: 'ov_session=secret' }));
++ const wall = asked[asked.length - 1];
++ assert.strictEqual(new URL(wall.url, 'http://x').pathname, '/api/v1/pulse');
++ assert.strictEqual(wall.auth, 'Bearer abcdefghijklmnop.qrstu', "the person's token goes along for their own wall");
++ assert.strictEqual(wall.cookie, '', 'a cookie never does');
++ assert.strictEqual(d.viewer.can_post, true);
++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store', 'a personal answer is not kept');
++
++ ({ d } = await get('/feed?kind=../admin&limit=999&cursor=<x>'));
++ const odd = new URL(asked[asked.length - 1].url, 'http://x');
++ assert.ok(!odd.searchParams.has('kind') && !odd.searchParams.has('cursor') && odd.searchParams.get('limit') === '40', 'an odd kind and cursor are dropped and the limit capped');
++
++ const pre = await fetch(`${base}/feed`, { method: 'OPTIONS', headers: { Origin: 'https://openvibe.video', 'Access-Control-Request-Method': 'GET', 'Access-Control-Request-Headers': 'authorization' } });
++ assert.strictEqual(pre.status, 204);
++ assert.match(pre.headers.get('access-control-allow-headers') || '', /Authorization/i);
++
++ const post = await fetch(`${base}/status`, { method: 'POST', headers: { Authorization: 'Bearer abcdefghijklmnop.qrstu', 'Content-Type': 'application/json' }, body: JSON.stringify({ text: 'hello' }) });
++ assert.strictEqual(post.status, 201, "Community's answer comes back");
++ const sent = asked.filter((x) => x.body)[0];
++ assert.strictEqual(sent.auth, 'Bearer abcdefghijklmnop.qrstu');
++ assert.deepStrictEqual(JSON.parse(sent.body), { text: 'hello' });
++ const anon = await fetch(`${base}/status`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
++ assert.strictEqual(anon.status, 401, 'no token, no post');
++
++ down = true;
++ ({ r, d } = await get('/feed?kind=meme'));
++ assert.strictEqual(r.status, 200);
++ assert.deepStrictEqual(d, { items: [], next_cursor: null }, 'Community not answering is an empty page');
++
++ server.close(); community.close();
++ console.log('frame feed: the Feed and wall through Network for every site, tokens along, cookies never, kept 30 s ok');
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 5bd6cb4c..738a447c 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -126,7 +126,7 @@ const REVIEWED = {
'server/coins/routes.js fetch': [1, 'Billing internal URL from config (the person\'s Vibes with their own token, 2 s timeout)'],
'server/today/receipts.js fetch': [1, 'Mail\'s internal URL from config (OV_MAIL_INTERNAL_URL, loopback 5250): the person\'s own token to the fixed path /api/v1/me/mail/receipts, 2.5 s timeout; never a request value names a host'],
'server/wallet/routes.js fetch': [2, 'Billing internal URL from config (OV_BILLING_INTERNAL_URL, loopback 4600) only: the person\'s own token for /api/me/activity and every /api/me read and write, and — only while /api/me/activity is missing — the marked temporary journal fallback, a billing.balance.read service read scoped to the signed-in person\'s own usr_ id; the paths are fixed, a charge id must be a vch_ ULID and is percent-encoded, and no request value ever names a host. The second call site is Network\'s own loopback (config.internalUrl, else 127.0.0.1:<port>): the fixed path POST /internal/subject-assertions, which signs the person\'s own token into a 60-second subject assertion; the person\'s token is in the body, never in the URL, and no request value names a host'],
-- 'server/frame/service.js fetch': [3, 'Live and AI internal URLs from config (navbar ranking copy); https://<open site host>/privacy, hosts from the site registry only, no redirects, 5 s (legal links)'],
++ 'server/frame/service.js fetch': [4, 'Live and AI internal URLs from config (navbar ranking copy); https://<open site host>/privacy, hosts from the site registry only, no redirects, 5 s (legal links); Community internal URL from config for the menu composer relay (POST /api/frame/status, fixed path, the person\'s Bearer only)'],
'server/frame/menu-cards.js fetch': [2, 'https://<open site host>/.well-known/openvibe-menu.json, hosts from the site registry only, no redirects, 5 s, 64 KB (menu cards)'],
'server/stats/open.js fetch': [1, 'open stats (/stats): fixed first-party loopback URLs only — Live (OV_LIVE_INTERNAL_URL, its public home numbers and service-token analytics totals) and Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; the request only picks 7, 30 or 90 days'],
'server/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],