openvibers/openvibe.network Commits
Frame: the menu's Feed and wall through Network (#714)
Changes
@@@ -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',@@@ -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, isres.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 callnnew file mode 100644@@@ -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); });@@@ -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'],
@@ -148,6 +148,10 @@ module.exports = {
@@ -148,6 +148,10 @@ module.exports = {
media: {
media: {
internalUrl: process.env.OV_MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100',
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).
// OpenVibe.Chat: the admin panel's TTS settings (server/admin/service-proxies.js).
chat: {
chat: {
internalUrl: process.env.OV_CHAT_INTERNAL_URL || 'http://127.0.0.1:4400',
internalUrl: process.env.OV_CHAT_INTERNAL_URL || 'http://127.0.0.1:4400',
@@ -4,6 +4,7 @@
@@ -4,6 +4,7 @@
//
//
// GET /api/frame?host=<hostname> public, CORS *, max-age=600, ETag
// 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/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
// 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
// → { 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)
// on its own domain, served by that site)
@@ -434,6 +435,63 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -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 }));
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
// 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
// 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
// openvibe.network, and 33 of them refused search.openvibe.network (2026-10-28). Anonymous, as the browser's own call
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -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); });
@@ -126,7 +126,7 @@ const REVIEWED = {
@@ -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/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/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/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/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/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'],
'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'],