openvibers/openvibe.network Commits
Frame: relay the shared wallet's Billing and Inventory calls (#718)
Changes
@@@ -160,10 +160,15 @@ module.exports = {search: {internalUrl: process.env.OV_SEARCH_INTERNAL_URL || 'http://127.0.0.1:4710',},-- // OpenVibe.Billing: the person's Vibes beside their OpenCoins (GET /api/coins/me?with=vibes, server/coins/routes.js).++ // OpenVibe.Billing: the person's Vibes beside their OpenCoins (GET /api/coins/me?with=vibes, server/coins/routes.js),++ // and the wallet sheet's money reads, checkouts and top-ups (/api/frame/wallet/…, server/frame/service.js).billing: {internalUrl: process.env.OV_BILLING_INTERNAL_URL || 'http://127.0.0.1:4600',},++ // OpenVibe.Inventory: the wallet sheet's store, items and equipped slots (/api/frame/wallet/…).++ inventory: {++ internalUrl: process.env.OV_INVENTORY_INTERNAL_URL || 'http://127.0.0.1:5030',++ },// OpenVibe.Mail: the account home's Receipts card (server/today/receipts.js) reads the person's own receipt// summaries with their token. Mail is the authority on the mailbox and the Inbox AI opt-in.mail: {@@@ -5,6 +5,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++// GET|POST|PUT /api/frame/wallet/… the wallet sheet's fixed Billing/Inventory paths, relayed (bearer only, no cache)// 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)@@@ -525,6 +526,57 @@ async function createFrameService(db, config, analytics, { privateKey = null, isfor: req.query.for === 'me' ? 'me' : '', origin: ['user', 'ai', 'system', 'all'].includes(String(req.query.origin || '')) ? String(req.query.origin) : '',}));++ // ── The wallet sheet's money and items (openvibe-shared ov-wallet.js) ──────────────────────────────────────++ // Every site's connect-src allows openvibe.network and refuses billing./inventory.openvibe.network, so the++ // sheet asks these fixed paths here and nothing else. Only the person's Bearer token goes upstream, never a++ // cookie; bodies are JSON of at most 8 KB; redirects are not followed; Billing's and Inventory's status and++ // body come back as they are. Nothing personal is kept, and no query is forwarded except a page limit/cursor.++ const walletAuth = (req) => (/^Bearer [A-Za-z0-9._~+/=-]{10,4096}$/.test(String(req.get('authorization') || '')) ? req.get('authorization') : '');++ function walletRelay(service, method, upstream) {++ const upstreamPath = typeof upstream === 'function' ? upstream : () => upstream;++ return async (req, res) => {++ res.set({ 'Access-Control-Allow-Origin': '*', 'Vary': 'Authorization', 'Cache-Control': 'private, no-store', 'Content-Type': 'application/json; charset=utf-8' });++ const path = upstreamPath(req);++ if (!path) return res.status(404).send('{"error":"not found"}');++ const auth = walletAuth(req);++ if (path.startsWith('/api/v1/me/') && !auth) return res.status(401).send('{"error":"sign in to open your wallet"}');++ const u = new URL(path, svcUrl(service, service === 'billing' ? 'http://127.0.0.1:4600' : 'http://127.0.0.1:5030'));++ if (method === 'GET') {++ const limit = String(req.query.limit || '');++ if (/^\d{1,3}$/.test(limit)) u.searchParams.set('limit', String(Math.min(Math.max(Number(limit), 1), 100)));++ const cursor = String(req.query.cursor || '');++ if (/^[A-Za-z0-9._~=-]{1,300}$/.test(cursor)) u.searchParams.set('cursor', cursor);++ }++ const headers = { Accept: 'application/json', ...(auth ? { Authorization: auth } : {}) };++ const init = { method, headers, redirect: 'manual', signal: AbortSignal.timeout(8000) };++ if (method !== 'GET') { headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(req.body || {}); }++ try {++ const r = await fetch(u.href, init);++ const text = await r.text();++ return res.status(r.status).send(text.slice(0, 64 * 1024));++ } catch { return res.status(502).send('{"error":"The wallet service did not answer"}'); }++ };++ }++ const walletJson = express.json({ limit: '8kb' });++ const USR = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/, ITEM = /^itd_[0-9A-HJKMNP-TV-Z]{26}$/;++ router.options('/wallet/*', (req, res) => {++ res.set({ 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Authorization, Content-Type', 'Access-Control-Allow-Methods': 'GET, POST, PUT', 'Access-Control-Max-Age': '600' });++ res.status(204).end();++ });++ // Billing: the three balances and the sheet's two checkouts. All personal, all no-store.++ router.get('/wallet/api/v1/me/wallet', walletRelay('billing', 'GET', '/api/v1/me/wallet'));++ router.post('/wallet/api/v1/me/vibes/buy', walletJson, walletRelay('billing', 'POST', '/api/v1/me/vibes/buy'));++ router.post('/wallet/api/v1/me/wallet/topups', walletJson, walletRelay('billing', 'POST', '/api/v1/me/wallet/topups'));++ // Inventory: the store, the person's items, and the equipped slots (worn or not).++ router.get('/wallet/api/v1/me/items', walletRelay('inventory', 'GET', '/api/v1/me/items'));++ router.get('/wallet/api/v1/people/:id/equipped', walletRelay('inventory', 'GET',++ (req) => (USR.test(String(req.params.id || '')) ? `/api/v1/people/${req.params.id}/equipped` : null)));++ router.put('/wallet/api/v1/me/equipped', walletJson, walletRelay('inventory', 'PUT', '/api/v1/me/equipped'));++ router.get('/wallet/api/v1/store', walletRelay('inventory', 'GET', '/api/v1/store'));++ router.post('/wallet/api/v1/store/:id/buy', walletJson, walletRelay('inventory', 'POST',++ (req) => (ITEM.test(String(req.params.id || '')) ? `/api/v1/store/${req.params.id}/buy` : null)));++ router.get('/wallet/api/v1/kinds', walletRelay('inventory', 'GET', '/api/v1/kinds'));++// 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,139 @@++'use strict';++// /api/frame/wallet/…: the shared wallet sheet's Billing and Inventory calls on every site, relayed through Network++// because every site's connect-src lets the navbar reach openvibe.network and refuses billing./inventory.openvibe.network++// (2026-11-09). Only the person's Bearer token goes upstream, never a cookie; fixed paths; JSON bodies at most 8 KB;++// upstream status and body pass back; nothing is cached.++const assert = require('assert');++const http = require('http');++const express = require('express');++const { getDb } = require('../server/db/database');++const { createFrameService } = require('../server/frame/service');++++const USR = 'usr_01JZ00000000000000000000A1';++const ITD = 'itd_01JZ00000000000000000000B1';++++(async () => {++ const asked = [];++ let down = false;++ const stub = (name, handler) => {++ const server = http.createServer((req, res) => {++ let body = '';++ req.on('data', (c) => { body += c; });++ req.on('end', () => {++ asked.push({ svc: name, method: req.method, url: req.url, auth: req.headers.authorization || '', cookie: req.headers.cookie || '', body });++ if (down) { res.statusCode = 503; return res.end('{"error":"down"}'); }++ res.setHeader('content-type', 'application/json');++ handler(req, res, new URL(req.url, 'http://x'), body);++ });++ });++ return server;++ };++ const billing = stub('billing', (req, res, u) => {++ if (u.pathname === '/api/v1/me/wallet' && req.method === 'GET') return res.end(JSON.stringify({ subject: { type: 'user', id: USR }, balances: { coins: { amount: '1', scale: 0, currency: 'opencoins' } }, checkout: { providers: [{ name: 'stripe', hosts: ['checkout.stripe.com'] }] } }));++ if (u.pathname === '/api/v1/me/vibes/buy' && req.method === 'POST') { res.statusCode = 201; return res.end(JSON.stringify({ checkout_url: 'https://checkout.stripe.com/c/pay/x', intent: { id: 'pin_1' } })); }++ if (u.pathname === '/api/v1/me/wallet/topups' && req.method === 'POST') { res.statusCode = 402; return res.end(JSON.stringify({ detail: 'Choose an offered top-up' })); }++ res.statusCode = 404; return res.end('{"error":"no route"}');++ });++ const inventory = stub('inventory', (req, res, u) => {++ if (u.pathname === '/api/v1/me/items') return res.end(JSON.stringify({ instances: [{ id: 'inv_1' }], definitions: {}, next_cursor: 'c2' }));++ if (u.pathname === `/api/v1/people/${USR}/equipped`) return res.end(JSON.stringify({ subject: USR, slots: { 'live.hat:hat': null } }));++ if (u.pathname === '/api/v1/me/equipped' && req.method === 'PUT') return res.end(JSON.stringify({ subject: USR, slots: {} }));++ if (u.pathname === '/api/v1/store') return res.end(JSON.stringify({ items: [{ id: ITD, name: 'Crown', prices: { vibes: 5 } }] }));++ if (u.pathname === `/api/v1/store/${ITD}/buy` && req.method === 'POST') return res.end(JSON.stringify({ status: 'fulfilled' }));++ if (u.pathname === '/api/v1/kinds') return res.end(JSON.stringify({ kinds: [{ id: 'live.hat', slots: ['hat'] }] }));++ res.statusCode = 404; return res.end('{"error":"no route"}');++ });++ await Promise.all([billing, inventory].map((s) => new Promise((r) => s.listen(0, '127.0.0.1', r))));++ const svc = await createFrameService(getDb(), { services: {++ billing: { internalUrl: `http://127.0.0.1:${billing.address().port}` },++ inventory: { internalUrl: `http://127.0.0.1:${inventory.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 token = 'Bearer abcdefghijklmnop.qrstu';++ const get = async (p, headers) => { const r = await fetch(`${base}${p}`, { headers }); return { r, d: await r.json() }; };++ const send = async (p, method, body, headers = {}) => {++ const r = await fetch(`${base}${p}`, { method, headers: { Authorization: token, 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body) });++ return { r, d: await r.json().catch(() => null) };++ };++++ // The wallet read: the person's token upstream, never their cookie, and never kept.++ let { r, d } = await get('/wallet/api/v1/me/wallet', { Authorization: token, Cookie: 'ov_session=secret' });++ assert.strictEqual(r.status, 200);++ assert.strictEqual(d.subject.id, USR);++ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*', 'any site may ask');++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store', 'a personal answer is not kept');++ const wallet = asked.find((x) => x.url.startsWith('/api/v1/me/wallet') && x.method === 'GET');++ assert.strictEqual(wallet.auth, token, "the person's token goes along");++ assert.strictEqual(wallet.cookie, '', 'a cookie never does');++++ ({ r, d } = await get('/wallet/api/v1/me/wallet'));++ assert.strictEqual(r.status, 401, 'no token, no wallet');++ assert.strictEqual(asked.filter((x) => x.url.startsWith('/api/v1/me/wallet')).length, 1, 'and nothing is asked upstream');++++ // A page limit and cursor pass; every other query is dropped.++ ({ r } = await get('/wallet/api/v1/me/items?limit=500&cursor=c1&subject=usr_other', { Authorization: token }));++ assert.strictEqual(r.status, 200);++ const items = new URL(asked[asked.length - 1].url, 'http://x');++ assert.deepStrictEqual([items.pathname, items.searchParams.get('limit'), items.searchParams.get('cursor'), items.searchParams.has('subject')], ['/api/v1/me/items', '100', 'c1', false]);++++ // A checkout: the JSON body goes up and Billing's status and body come back as they are.++ ({ r, d } = await send('/wallet/api/v1/me/vibes/buy', 'POST', { provider: 'stripe', vibes: 100, idempotency_key: 'k1' }));++ assert.strictEqual(r.status, 201);++ assert.strictEqual(d.checkout_url, 'https://checkout.stripe.com/c/pay/x');++ const buy = asked[asked.length - 1];++ assert.deepStrictEqual([buy.method, buy.auth, JSON.parse(buy.body)], ['POST', token, { provider: 'stripe', vibes: 100, idempotency_key: 'k1' }]);++++ ({ r, d } = await send('/wallet/api/v1/me/wallet/topups', 'POST', { provider: 'stripe', amount_cents: 1 }));++ assert.strictEqual(r.status, 402, "Billing's refusal is passed back");++ assert.strictEqual(d.detail, 'Choose an offered top-up');++++ // Equipping is a PUT with a body; the store, kinds and a person's worn slots are public reads.++ ({ r } = await send('/wallet/api/v1/me/equipped', 'PUT', { kind: 'live.hat', slot: 'hat', instance_id: 'inv_1' }));++ assert.strictEqual(r.status, 200);++ assert.strictEqual(asked[asked.length - 1].method, 'PUT');++ ({ d } = await get(`/wallet/api/v1/people/${USR}/equipped`));++ assert.strictEqual(d.subject, USR);++ assert.strictEqual(asked[asked.length - 1].auth, '', 'a public read asks anonymously');++ ({ d } = await get('/wallet/api/v1/store'));++ assert.strictEqual(d.items[0].id, ITD);++ ({ d } = await get('/wallet/api/v1/kinds'));++ assert.strictEqual(d.kinds[0].id, 'live.hat');++++ ({ r } = await send(`/wallet/api/v1/store/${ITD}/buy`, 'POST', { currency: 'vibes', idempotency_key: 'k2' }));++ assert.strictEqual(r.status, 200);++ const before = asked.length;++ ({ r } = await send('/wallet/api/v1/store/itd_bad/buy', 'POST', {}));++ assert.strictEqual(r.status, 404, 'a malformed item id never reaches Inventory');++ ({ r } = await get('/wallet/api/v1/people/not-a-subject/equipped'));++ assert.strictEqual(r.status, 404, 'a malformed person id never reaches Inventory');++ assert.strictEqual(asked.length, before, 'neither asked upstream');++++ const foreign = await fetch(`${base}/wallet/api/v1/admin`, { headers: { Authorization: token } });++ assert.strictEqual(foreign.status, 404, 'only the sheet paths exist');++++ const big = await fetch(`${base}/wallet/api/v1/me/vibes/buy`, { method: 'POST', headers: { Authorization: token, 'Content-Type': 'application/json' }, body: JSON.stringify({ text: 'x'.repeat(9000) }) });++ assert.strictEqual(big.status, 413, 'a body over 8 KB is refused');++++ const pre = await fetch(`${base}/wallet/api/v1/me/equipped`, { method: 'OPTIONS', headers: { Origin: 'https://openvibe.live', 'Access-Control-Request-Method': 'PUT', 'Access-Control-Request-Headers': 'authorization, content-type' } });++ assert.strictEqual(pre.status, 204);++ assert.match(pre.headers.get('access-control-allow-headers') || '', /Authorization/i);++ assert.match(pre.headers.get('access-control-allow-methods') || '', /PUT/);++++ down = true;++ ({ r, d } = await get('/wallet/api/v1/me/wallet', { Authorization: token }));++ assert.strictEqual(r.status, 503, "an upstream refusal is passed back, never turned into an empty wallet");++++ await new Promise((r) => billing.close(r));++ ({ r, d } = await get('/wallet/api/v1/me/wallet', { Authorization: token }));++ assert.strictEqual(r.status, 502, 'a service that does not answer is a failure, never an empty wallet');++ assert.match(d.error, /did not answer/);++++ server.close(); inventory.close();++ console.log('frame wallet: fixed paths, bearer only, no cache, 8 KB bodies, statuses back, bad ids and foreign paths refused');++ 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': [5, 'The composer\'s pictures and posts go to OpenVibe.Community\'s fixed paths (POST /api/v1/spaces/status/attachments, POST /api/v1/posts) on its internal URL. 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/service.js fetch': [6, 'The composer\'s pictures and posts go to OpenVibe.Community\'s fixed paths (POST /api/v1/spaces/status/attachments, POST /api/v1/posts) on its internal URL. 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); Billing and Inventory internal URLs from config for the wallet relays (/api/frame/wallet/…, fixed paths, a usr_/itd_ id must match its pattern, the person\'s Bearer only, no redirects, 8 s)'],'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'],
@@ -160,10 +160,15 @@ module.exports = {
@@ -160,10 +160,15 @@ module.exports = {
search: {
search: {
internalUrl: process.env.OV_SEARCH_INTERNAL_URL || 'http://127.0.0.1:4710',
internalUrl: process.env.OV_SEARCH_INTERNAL_URL || 'http://127.0.0.1:4710',
},
},
- // OpenVibe.Billing: the person's Vibes beside their OpenCoins (GET /api/coins/me?with=vibes, server/coins/routes.js).
+ // OpenVibe.Billing: the person's Vibes beside their OpenCoins (GET /api/coins/me?with=vibes, server/coins/routes.js),
+ // and the wallet sheet's money reads, checkouts and top-ups (/api/frame/wallet/…, server/frame/service.js).
billing: {
billing: {
internalUrl: process.env.OV_BILLING_INTERNAL_URL || 'http://127.0.0.1:4600',
internalUrl: process.env.OV_BILLING_INTERNAL_URL || 'http://127.0.0.1:4600',
},
},
+ // OpenVibe.Inventory: the wallet sheet's store, items and equipped slots (/api/frame/wallet/…).
+ inventory: {
+ internalUrl: process.env.OV_INVENTORY_INTERNAL_URL || 'http://127.0.0.1:5030',
+ },
// OpenVibe.Mail: the account home's Receipts card (server/today/receipts.js) reads the person's own receipt
// OpenVibe.Mail: the account home's Receipts card (server/today/receipts.js) reads the person's own receipt
// summaries with their token. Mail is the authority on the mailbox and the Inbox AI opt-in.
// summaries with their token. Mail is the authority on the mailbox and the Inbox AI opt-in.
mail: {
mail: {
@@ -5,6 +5,7 @@
@@ -5,6 +5,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
// GET /api/frame/feed, /api/frame/wall the menu's Feed and wall: Community's /api/v1/feed and /api/v1/pulse, relayed
+// GET|POST|PUT /api/frame/wallet/… the wallet sheet's fixed Billing/Inventory paths, relayed (bearer only, no cache)
// 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)
@@ -525,6 +526,57 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -525,6 +526,57 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
for: req.query.for === 'me' ? 'me' : '', origin: ['user', 'ai', 'system', 'all'].includes(String(req.query.origin || '')) ? String(req.query.origin) : '',
for: req.query.for === 'me' ? 'me' : '', origin: ['user', 'ai', 'system', 'all'].includes(String(req.query.origin || '')) ? String(req.query.origin) : '',
}));
}));
+ // ── The wallet sheet's money and items (openvibe-shared ov-wallet.js) ──────────────────────────────────────
+ // Every site's connect-src allows openvibe.network and refuses billing./inventory.openvibe.network, so the
+ // sheet asks these fixed paths here and nothing else. Only the person's Bearer token goes upstream, never a
+ // cookie; bodies are JSON of at most 8 KB; redirects are not followed; Billing's and Inventory's status and
+ // body come back as they are. Nothing personal is kept, and no query is forwarded except a page limit/cursor.
+ const walletAuth = (req) => (/^Bearer [A-Za-z0-9._~+/=-]{10,4096}$/.test(String(req.get('authorization') || '')) ? req.get('authorization') : '');
+ function walletRelay(service, method, upstream) {
+ const upstreamPath = typeof upstream === 'function' ? upstream : () => upstream;
+ return async (req, res) => {
+ res.set({ 'Access-Control-Allow-Origin': '*', 'Vary': 'Authorization', 'Cache-Control': 'private, no-store', 'Content-Type': 'application/json; charset=utf-8' });
+ const path = upstreamPath(req);
+ if (!path) return res.status(404).send('{"error":"not found"}');
+ const auth = walletAuth(req);
+ if (path.startsWith('/api/v1/me/') && !auth) return res.status(401).send('{"error":"sign in to open your wallet"}');
+ const u = new URL(path, svcUrl(service, service === 'billing' ? 'http://127.0.0.1:4600' : 'http://127.0.0.1:5030'));
+ if (method === 'GET') {
+ const limit = String(req.query.limit || '');
+ if (/^\d{1,3}$/.test(limit)) u.searchParams.set('limit', String(Math.min(Math.max(Number(limit), 1), 100)));
+ const cursor = String(req.query.cursor || '');
+ if (/^[A-Za-z0-9._~=-]{1,300}$/.test(cursor)) u.searchParams.set('cursor', cursor);
+ }
+ const headers = { Accept: 'application/json', ...(auth ? { Authorization: auth } : {}) };
+ const init = { method, headers, redirect: 'manual', signal: AbortSignal.timeout(8000) };
+ if (method !== 'GET') { headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(req.body || {}); }
+ try {
+ const r = await fetch(u.href, init);
+ const text = await r.text();
+ return res.status(r.status).send(text.slice(0, 64 * 1024));
+ } catch { return res.status(502).send('{"error":"The wallet service did not answer"}'); }
+ };
+ }
+ const walletJson = express.json({ limit: '8kb' });
+ const USR = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/, ITEM = /^itd_[0-9A-HJKMNP-TV-Z]{26}$/;
+ router.options('/wallet/*', (req, res) => {
+ res.set({ 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Authorization, Content-Type', 'Access-Control-Allow-Methods': 'GET, POST, PUT', 'Access-Control-Max-Age': '600' });
+ res.status(204).end();
+ });
+ // Billing: the three balances and the sheet's two checkouts. All personal, all no-store.
+ router.get('/wallet/api/v1/me/wallet', walletRelay('billing', 'GET', '/api/v1/me/wallet'));
+ router.post('/wallet/api/v1/me/vibes/buy', walletJson, walletRelay('billing', 'POST', '/api/v1/me/vibes/buy'));
+ router.post('/wallet/api/v1/me/wallet/topups', walletJson, walletRelay('billing', 'POST', '/api/v1/me/wallet/topups'));
+ // Inventory: the store, the person's items, and the equipped slots (worn or not).
+ router.get('/wallet/api/v1/me/items', walletRelay('inventory', 'GET', '/api/v1/me/items'));
+ router.get('/wallet/api/v1/people/:id/equipped', walletRelay('inventory', 'GET',
+ (req) => (USR.test(String(req.params.id || '')) ? `/api/v1/people/${req.params.id}/equipped` : null)));
+ router.put('/wallet/api/v1/me/equipped', walletJson, walletRelay('inventory', 'PUT', '/api/v1/me/equipped'));
+ router.get('/wallet/api/v1/store', walletRelay('inventory', 'GET', '/api/v1/store'));
+ router.post('/wallet/api/v1/store/:id/buy', walletJson, walletRelay('inventory', 'POST',
+ (req) => (ITEM.test(String(req.params.id || '')) ? `/api/v1/store/${req.params.id}/buy` : null)));
+ router.get('/wallet/api/v1/kinds', walletRelay('inventory', 'GET', '/api/v1/kinds'));
+
// 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,139 @@
@@ -0,0 +1,139 @@
+'use strict';
+// /api/frame/wallet/…: the shared wallet sheet's Billing and Inventory calls on every site, relayed through Network
+// because every site's connect-src lets the navbar reach openvibe.network and refuses billing./inventory.openvibe.network
+// (2026-11-09). Only the person's Bearer token goes upstream, never a cookie; fixed paths; JSON bodies at most 8 KB;
+// upstream status and body pass back; nothing is cached.
+const assert = require('assert');
+const http = require('http');
+const express = require('express');
+const { getDb } = require('../server/db/database');
+const { createFrameService } = require('../server/frame/service');
+
+const USR = 'usr_01JZ00000000000000000000A1';
+const ITD = 'itd_01JZ00000000000000000000B1';
+
+(async () => {
+ const asked = [];
+ let down = false;
+ const stub = (name, handler) => {
+ const server = http.createServer((req, res) => {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ asked.push({ svc: name, method: req.method, url: req.url, auth: req.headers.authorization || '', cookie: req.headers.cookie || '', body });
+ if (down) { res.statusCode = 503; return res.end('{"error":"down"}'); }
+ res.setHeader('content-type', 'application/json');
+ handler(req, res, new URL(req.url, 'http://x'), body);
+ });
+ });
+ return server;
+ };
+ const billing = stub('billing', (req, res, u) => {
+ if (u.pathname === '/api/v1/me/wallet' && req.method === 'GET') return res.end(JSON.stringify({ subject: { type: 'user', id: USR }, balances: { coins: { amount: '1', scale: 0, currency: 'opencoins' } }, checkout: { providers: [{ name: 'stripe', hosts: ['checkout.stripe.com'] }] } }));
+ if (u.pathname === '/api/v1/me/vibes/buy' && req.method === 'POST') { res.statusCode = 201; return res.end(JSON.stringify({ checkout_url: 'https://checkout.stripe.com/c/pay/x', intent: { id: 'pin_1' } })); }
+ if (u.pathname === '/api/v1/me/wallet/topups' && req.method === 'POST') { res.statusCode = 402; return res.end(JSON.stringify({ detail: 'Choose an offered top-up' })); }
+ res.statusCode = 404; return res.end('{"error":"no route"}');
+ });
+ const inventory = stub('inventory', (req, res, u) => {
+ if (u.pathname === '/api/v1/me/items') return res.end(JSON.stringify({ instances: [{ id: 'inv_1' }], definitions: {}, next_cursor: 'c2' }));
+ if (u.pathname === `/api/v1/people/${USR}/equipped`) return res.end(JSON.stringify({ subject: USR, slots: { 'live.hat:hat': null } }));
+ if (u.pathname === '/api/v1/me/equipped' && req.method === 'PUT') return res.end(JSON.stringify({ subject: USR, slots: {} }));
+ if (u.pathname === '/api/v1/store') return res.end(JSON.stringify({ items: [{ id: ITD, name: 'Crown', prices: { vibes: 5 } }] }));
+ if (u.pathname === `/api/v1/store/${ITD}/buy` && req.method === 'POST') return res.end(JSON.stringify({ status: 'fulfilled' }));
+ if (u.pathname === '/api/v1/kinds') return res.end(JSON.stringify({ kinds: [{ id: 'live.hat', slots: ['hat'] }] }));
+ res.statusCode = 404; return res.end('{"error":"no route"}');
+ });
+ await Promise.all([billing, inventory].map((s) => new Promise((r) => s.listen(0, '127.0.0.1', r))));
+ const svc = await createFrameService(getDb(), { services: {
+ billing: { internalUrl: `http://127.0.0.1:${billing.address().port}` },
+ inventory: { internalUrl: `http://127.0.0.1:${inventory.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 token = 'Bearer abcdefghijklmnop.qrstu';
+ const get = async (p, headers) => { const r = await fetch(`${base}${p}`, { headers }); return { r, d: await r.json() }; };
+ const send = async (p, method, body, headers = {}) => {
+ const r = await fetch(`${base}${p}`, { method, headers: { Authorization: token, 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body) });
+ return { r, d: await r.json().catch(() => null) };
+ };
+
+ // The wallet read: the person's token upstream, never their cookie, and never kept.
+ let { r, d } = await get('/wallet/api/v1/me/wallet', { Authorization: token, Cookie: 'ov_session=secret' });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(d.subject.id, USR);
+ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*', 'any site may ask');
+ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store', 'a personal answer is not kept');
+ const wallet = asked.find((x) => x.url.startsWith('/api/v1/me/wallet') && x.method === 'GET');
+ assert.strictEqual(wallet.auth, token, "the person's token goes along");
+ assert.strictEqual(wallet.cookie, '', 'a cookie never does');
+
+ ({ r, d } = await get('/wallet/api/v1/me/wallet'));
+ assert.strictEqual(r.status, 401, 'no token, no wallet');
+ assert.strictEqual(asked.filter((x) => x.url.startsWith('/api/v1/me/wallet')).length, 1, 'and nothing is asked upstream');
+
+ // A page limit and cursor pass; every other query is dropped.
+ ({ r } = await get('/wallet/api/v1/me/items?limit=500&cursor=c1&subject=usr_other', { Authorization: token }));
+ assert.strictEqual(r.status, 200);
+ const items = new URL(asked[asked.length - 1].url, 'http://x');
+ assert.deepStrictEqual([items.pathname, items.searchParams.get('limit'), items.searchParams.get('cursor'), items.searchParams.has('subject')], ['/api/v1/me/items', '100', 'c1', false]);
+
+ // A checkout: the JSON body goes up and Billing's status and body come back as they are.
+ ({ r, d } = await send('/wallet/api/v1/me/vibes/buy', 'POST', { provider: 'stripe', vibes: 100, idempotency_key: 'k1' }));
+ assert.strictEqual(r.status, 201);
+ assert.strictEqual(d.checkout_url, 'https://checkout.stripe.com/c/pay/x');
+ const buy = asked[asked.length - 1];
+ assert.deepStrictEqual([buy.method, buy.auth, JSON.parse(buy.body)], ['POST', token, { provider: 'stripe', vibes: 100, idempotency_key: 'k1' }]);
+
+ ({ r, d } = await send('/wallet/api/v1/me/wallet/topups', 'POST', { provider: 'stripe', amount_cents: 1 }));
+ assert.strictEqual(r.status, 402, "Billing's refusal is passed back");
+ assert.strictEqual(d.detail, 'Choose an offered top-up');
+
+ // Equipping is a PUT with a body; the store, kinds and a person's worn slots are public reads.
+ ({ r } = await send('/wallet/api/v1/me/equipped', 'PUT', { kind: 'live.hat', slot: 'hat', instance_id: 'inv_1' }));
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(asked[asked.length - 1].method, 'PUT');
+ ({ d } = await get(`/wallet/api/v1/people/${USR}/equipped`));
+ assert.strictEqual(d.subject, USR);
+ assert.strictEqual(asked[asked.length - 1].auth, '', 'a public read asks anonymously');
+ ({ d } = await get('/wallet/api/v1/store'));
+ assert.strictEqual(d.items[0].id, ITD);
+ ({ d } = await get('/wallet/api/v1/kinds'));
+ assert.strictEqual(d.kinds[0].id, 'live.hat');
+
+ ({ r } = await send(`/wallet/api/v1/store/${ITD}/buy`, 'POST', { currency: 'vibes', idempotency_key: 'k2' }));
+ assert.strictEqual(r.status, 200);
+ const before = asked.length;
+ ({ r } = await send('/wallet/api/v1/store/itd_bad/buy', 'POST', {}));
+ assert.strictEqual(r.status, 404, 'a malformed item id never reaches Inventory');
+ ({ r } = await get('/wallet/api/v1/people/not-a-subject/equipped'));
+ assert.strictEqual(r.status, 404, 'a malformed person id never reaches Inventory');
+ assert.strictEqual(asked.length, before, 'neither asked upstream');
+
+ const foreign = await fetch(`${base}/wallet/api/v1/admin`, { headers: { Authorization: token } });
+ assert.strictEqual(foreign.status, 404, 'only the sheet paths exist');
+
+ const big = await fetch(`${base}/wallet/api/v1/me/vibes/buy`, { method: 'POST', headers: { Authorization: token, 'Content-Type': 'application/json' }, body: JSON.stringify({ text: 'x'.repeat(9000) }) });
+ assert.strictEqual(big.status, 413, 'a body over 8 KB is refused');
+
+ const pre = await fetch(`${base}/wallet/api/v1/me/equipped`, { method: 'OPTIONS', headers: { Origin: 'https://openvibe.live', 'Access-Control-Request-Method': 'PUT', 'Access-Control-Request-Headers': 'authorization, content-type' } });
+ assert.strictEqual(pre.status, 204);
+ assert.match(pre.headers.get('access-control-allow-headers') || '', /Authorization/i);
+ assert.match(pre.headers.get('access-control-allow-methods') || '', /PUT/);
+
+ down = true;
+ ({ r, d } = await get('/wallet/api/v1/me/wallet', { Authorization: token }));
+ assert.strictEqual(r.status, 503, "an upstream refusal is passed back, never turned into an empty wallet");
+
+ await new Promise((r) => billing.close(r));
+ ({ r, d } = await get('/wallet/api/v1/me/wallet', { Authorization: token }));
+ assert.strictEqual(r.status, 502, 'a service that does not answer is a failure, never an empty wallet');
+ assert.match(d.error, /did not answer/);
+
+ server.close(); inventory.close();
+ console.log('frame wallet: fixed paths, bearer only, no cache, 8 KB bodies, statuses back, bad ids and foreign paths refused');
+ 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': [5, 'The composer\'s pictures and posts go to OpenVibe.Community\'s fixed paths (POST /api/v1/spaces/status/attachments, POST /api/v1/posts) on its internal URL. 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/service.js fetch': [6, 'The composer\'s pictures and posts go to OpenVibe.Community\'s fixed paths (POST /api/v1/spaces/status/attachments, POST /api/v1/posts) on its internal URL. 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); Billing and Inventory internal URLs from config for the wallet relays (/api/frame/wallet/…, fixed paths, a usr_/itd_ id must match its pattern, the person\'s Bearer only, no redirects, 8 s)'],
'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'],