openvibers/openvibe.network Commits

Wallet relay: sites with their own cookie session read the wallet with the person's Network cookie (#724)

OP OpenVibers committed 4 days ago · 783e1e08fc · 2 files, +39 −3

openvibe-shared 4.170's wallet chips and sheet send only a Bearer token, and a site that signs
people in with its own cookie (Events, Space, Design and most others) has none to hand the navbar:
since the 4.172 round their chips sat at '…' and the wallet did not open. The relay now also takes
the ov_token cookie (from credentials: 'include'), sends it upstream as the Bearer (never the
cookie), only from an origin the CORS allow-list let through, never from an extension page, and a
write needs an Origin. The answer keeps the allow-list's echoed Origin (a '*' would be dropped).

Changes

ddiff --git a/server/frame/service.js b/server/frame/service.js
iindex 864bab87..29b38963 100644
---- a/server/frame/service.js
++++ b/server/frame/service.js
@@@ -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') {
ddiff --git a/test/frame-wallet.test.js b/test/frame-wallet.test.js
iindex f574d5ef..1a3c6671 100644
---- a/test/frame-wallet.test.js
++++ b/test/frame-wallet.test.js
@@@ -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);