openvibers/openvibe.network Commits
Wallet relay: sites with their own cookie session read the wallet with the person's Network cookie (#724)
Changes
@@@ -5,7 +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)++// GET|POST|PUT /api/frame/wallet/… the wallet sheet's fixed Billing/Inventory paths, relayed (bearer, or the ov_token cookie; 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)@@@ -25,6 +25,7 @@// and screened. AI unreachable, failing or answering badly → the last good copy stays (kept in// frame_cache across restarts), or the hand-written copy when there has never been one.// ═══════════════════════════════════════════════════════════════++const { isExtensionOrigin } = require('../auth/extension-origin');const express = require('express');const crypto = require('crypto');const { SITES, siteForHost, GROUPS, groupOf, PRODUCTS, ICON_ALIAS, MENU_LINE, MENU_NAME, fullName } = require('./sites');@@@ -532,13 +533,30 @@ async function createFrameService(db, config, analytics, { privateKey = null, is// 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') : '');++ // A site that signs people in with its own cookie has no token to hand the navbar; its page asks with++ // credentials: 'include' and the person's Network session cookie (ov_token) comes along. That token goes upstream++ // as the Bearer (never the cookie itself), only from an origin the CORS allow-list already let through (or none:++ // Network's own pages), never from an extension page, and a write needs an Origin: a cross-site form post carries++ // a foreign one and the allow-list refuses it before this runs. The answer keeps the allow-list's echoed Origin with++ // credentials (a '*' would make the browser drop it). Openvibe-shared 4.173's chips and sheet use it (2026-11-09:++ // 4.170's chips sat at "…" on every such site).++ const OV_TOKEN = /^[A-Za-z0-9._~+/=-]{10,4096}$/;++ const cookieToken = (req) => {++ const parsed = req.cookies && req.cookies.ov_token;++ const raw = typeof parsed === 'string' ? parsed : ((String(req.get('cookie') || '').match(/(?:^|;\s*)ov_token=([^;]+)/) || [])[1] || '');++ return OV_TOKEN.test(raw) ? raw : '';++ };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 bearer = walletAuth(req);++ const origin = String(req.get('origin') || '');++ const fromCookie = !bearer && cookieToken(req) && !isExtensionOrigin(origin) && (method === 'GET' || origin) ? `Bearer ${cookieToken(req)}` : '';++ res.set({ 'Vary': 'Authorization, Origin, Cookie', 'Cache-Control': 'private, no-store', 'Content-Type': 'application/json; charset=utf-8' });++ if (!fromCookie) res.set('Access-Control-Allow-Origin', '*');const path = upstreamPath(req);if (!path) return res.status(404).send('{"error":"not found"}');-- const auth = walletAuth(req);++ const auth = bearer || fromCookie;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') {@@@ -75,6 +75,24 @@ const ITD = 'itd_01JZ00000000000000000000B1';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 site with its own cookie session has no token for the navbar: the person's Network cookie (ov_token) is the++ // token, sent upstream as the Bearer and never as a cookie; the answer keeps the allow-list's echoed Origin.++ const session = 'abcdefghijklmnop.cookie';++ ({ r, d } = await get('/wallet/api/v1/me/wallet', { Cookie: `ov_sso=x; ov_token=${session}`, Origin: 'https://openvibe.events' }));++ assert.strictEqual(r.status, 200);++ const viaCookie = asked.filter((x) => x.url.startsWith('/api/v1/me/wallet')).at(-1);++ assert.strictEqual(viaCookie.auth, `Bearer ${session}`, "the person's session token goes along as the Bearer");++ assert.strictEqual(viaCookie.cookie, '', 'the cookie itself never does');++ assert.notStrictEqual(r.headers.get('access-control-allow-origin'), '*', "a credentialed answer is never '*'");++ const askedBefore = asked.length;++ ({ r } = await send('/wallet/api/v1/me/vibes/buy', 'POST', { pack: 'vibes_500' }, { Authorization: '', Cookie: `ov_token=${session}` }));++ assert.strictEqual(r.status, 401, 'a write by cookie needs an Origin');++ ({ r } = await get('/wallet/api/v1/me/wallet', { Cookie: `ov_token=${session}`, Origin: 'chrome-extension://abcdefghijklmnopabcdefghijklmnop' }));++ assert.strictEqual(r.status, 401, 'never from an extension page');++ ({ r } = await get('/wallet/api/v1/me/wallet', { Cookie: 'ov_token=short' }));++ assert.strictEqual(r.status, 401, 'a malformed cookie is no token');++ assert.strictEqual(asked.length, askedBefore, 'none of these 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);
@@ -5,7 +5,7 @@
@@ -5,7 +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)
+// GET|POST|PUT /api/frame/wallet/… the wallet sheet's fixed Billing/Inventory paths, relayed (bearer, or the ov_token cookie; 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)
@@ -25,6 +25,7 @@
@@ -25,6 +25,7 @@
// and screened. AI unreachable, failing or answering badly → the last good copy stays (kept in
// and screened. AI unreachable, failing or answering badly → the last good copy stays (kept in
// frame_cache across restarts), or the hand-written copy when there has never been one.
// frame_cache across restarts), or the hand-written copy when there has never been one.
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
+const { isExtensionOrigin } = require('../auth/extension-origin');
const express = require('express');
const express = require('express');
const crypto = require('crypto');
const crypto = require('crypto');
const { SITES, siteForHost, GROUPS, groupOf, PRODUCTS, ICON_ALIAS, MENU_LINE, MENU_NAME, fullName } = require('./sites');
const { SITES, siteForHost, GROUPS, groupOf, PRODUCTS, ICON_ALIAS, MENU_LINE, MENU_NAME, fullName } = require('./sites');
@@ -532,13 +533,30 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -532,13 +533,30 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
// cookie; bodies are JSON of at most 8 KB; redirects are not followed; Billing's and Inventory's status and
// 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.
// 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') : '');
const walletAuth = (req) => (/^Bearer [A-Za-z0-9._~+/=-]{10,4096}$/.test(String(req.get('authorization') || '')) ? req.get('authorization') : '');
+ // A site that signs people in with its own cookie has no token to hand the navbar; its page asks with
+ // credentials: 'include' and the person's Network session cookie (ov_token) comes along. That token goes upstream
+ // as the Bearer (never the cookie itself), only from an origin the CORS allow-list already let through (or none:
+ // Network's own pages), never from an extension page, and a write needs an Origin: a cross-site form post carries
+ // a foreign one and the allow-list refuses it before this runs. The answer keeps the allow-list's echoed Origin with
+ // credentials (a '*' would make the browser drop it). Openvibe-shared 4.173's chips and sheet use it (2026-11-09:
+ // 4.170's chips sat at "…" on every such site).
+ const OV_TOKEN = /^[A-Za-z0-9._~+/=-]{10,4096}$/;
+ const cookieToken = (req) => {
+ const parsed = req.cookies && req.cookies.ov_token;
+ const raw = typeof parsed === 'string' ? parsed : ((String(req.get('cookie') || '').match(/(?:^|;\s*)ov_token=([^;]+)/) || [])[1] || '');
+ return OV_TOKEN.test(raw) ? raw : '';
+ };
function walletRelay(service, method, upstream) {
function walletRelay(service, method, upstream) {
const upstreamPath = typeof upstream === 'function' ? upstream : () => upstream;
const upstreamPath = typeof upstream === 'function' ? upstream : () => upstream;
return async (req, res) => {
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 bearer = walletAuth(req);
+ const origin = String(req.get('origin') || '');
+ const fromCookie = !bearer && cookieToken(req) && !isExtensionOrigin(origin) && (method === 'GET' || origin) ? `Bearer ${cookieToken(req)}` : '';
+ res.set({ 'Vary': 'Authorization, Origin, Cookie', 'Cache-Control': 'private, no-store', 'Content-Type': 'application/json; charset=utf-8' });
+ if (!fromCookie) res.set('Access-Control-Allow-Origin', '*');
const path = upstreamPath(req);
const path = upstreamPath(req);
if (!path) return res.status(404).send('{"error":"not found"}');
if (!path) return res.status(404).send('{"error":"not found"}');
- const auth = walletAuth(req);
+ const auth = bearer || fromCookie;
if (path.startsWith('/api/v1/me/') && !auth) return res.status(401).send('{"error":"sign in to open your wallet"}');
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'));
const u = new URL(path, svcUrl(service, service === 'billing' ? 'http://127.0.0.1:4600' : 'http://127.0.0.1:5030'));
if (method === 'GET') {
if (method === 'GET') {
@@ -75,6 +75,24 @@ const ITD = 'itd_01JZ00000000000000000000B1';
@@ -75,6 +75,24 @@ const ITD = 'itd_01JZ00000000000000000000B1';
assert.strictEqual(r.status, 401, 'no token, no 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');
assert.strictEqual(asked.filter((x) => x.url.startsWith('/api/v1/me/wallet')).length, 1, 'and nothing is asked upstream');
+ // A site with its own cookie session has no token for the navbar: the person's Network cookie (ov_token) is the
+ // token, sent upstream as the Bearer and never as a cookie; the answer keeps the allow-list's echoed Origin.
+ const session = 'abcdefghijklmnop.cookie';
+ ({ r, d } = await get('/wallet/api/v1/me/wallet', { Cookie: `ov_sso=x; ov_token=${session}`, Origin: 'https://openvibe.events' }));
+ assert.strictEqual(r.status, 200);
+ const viaCookie = asked.filter((x) => x.url.startsWith('/api/v1/me/wallet')).at(-1);
+ assert.strictEqual(viaCookie.auth, `Bearer ${session}`, "the person's session token goes along as the Bearer");
+ assert.strictEqual(viaCookie.cookie, '', 'the cookie itself never does');
+ assert.notStrictEqual(r.headers.get('access-control-allow-origin'), '*', "a credentialed answer is never '*'");
+ const askedBefore = asked.length;
+ ({ r } = await send('/wallet/api/v1/me/vibes/buy', 'POST', { pack: 'vibes_500' }, { Authorization: '', Cookie: `ov_token=${session}` }));
+ assert.strictEqual(r.status, 401, 'a write by cookie needs an Origin');
+ ({ r } = await get('/wallet/api/v1/me/wallet', { Cookie: `ov_token=${session}`, Origin: 'chrome-extension://abcdefghijklmnopabcdefghijklmnop' }));
+ assert.strictEqual(r.status, 401, 'never from an extension page');
+ ({ r } = await get('/wallet/api/v1/me/wallet', { Cookie: 'ov_token=short' }));
+ assert.strictEqual(r.status, 401, 'a malformed cookie is no token');
+ assert.strictEqual(asked.length, askedBefore, 'none of these asked upstream');
+
// A page limit and cursor pass; every other query is dropped.
// 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 }));
({ r } = await get('/wallet/api/v1/me/items?limit=500&cursor=c1&subject=usr_other', { Authorization: token }));
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);