openvibers/openvibe.network Commits

Relay wallet balance changes through Frame (#840)

OP OpenVibers committed 2 days ago · da1d8813ef · 2 files, +48 −6

Changes

ddiff --git a/server/frame/service.js b/server/frame/service.js
iindex f59b7568..0a88c8cd 100644
---- a/server/frame/service.js
++++ b/server/frame/service.js
@@@ -738,13 +738,22 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
const path = upstreamPath(req);
if (!path) return res.status(404).send('{"error":"not found"}');
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 ((path.startsWith('/api/v1/me/') || path.startsWith('/api/wallet/changes')) && !auth) return res.status(401).send('{"error":"sign in to open your wallet"}');
++ const fallback = service === 'billing' ? 'http://127.0.0.1:4600' : service === 'network' ? config.internalUrl || 'http://127.0.0.1:4000' : 'http://127.0.0.1:5030';
++ const u = new URL(path, svcUrl(service, fallback));
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);
++ if (path === '/api/wallet/changes') {
++ const patterns = { since_vibes: /^txn_[0-9A-HJKMNP-TV-Z]{26}$/, since_opencoins: /^[1-9]\d{0,15}$/, streamer: /^[1-9]\d{0,15}$/, since_channel_points: /^[1-9]\d{0,15}$/ };
++ for (const [name, pattern] of Object.entries(patterns)) {
++ const value = String(req.query[name] || '');
++ if (pattern.test(value)) u.searchParams.set(name, value);
++ }
++ } else {
++ 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) };
@@@ -764,6 +773,9 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
});
// 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'));
++ // Ask 273: every site reads and marks the same per-currency change window through Network.
++ router.get('/wallet/changes', walletRelay('network', 'GET', '/api/wallet/changes'));
++ router.put('/wallet/changes/read', walletJson, walletRelay('network', 'PUT', '/api/wallet/changes/read'));
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).
ddiff --git a/test/frame-wallet.test.js b/test/frame-wallet.test.js
iindex 1a3c6671..463f3b0e 100644
---- a/test/frame-wallet.test.js
++++ b/test/frame-wallet.test.js
@@@ -43,10 +43,16 @@ const ITD = 'itd_01JZ00000000000000000000B1';
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 network = stub('network', (req, res, u, body) => {
++ if (u.pathname === '/api/wallet/changes' && req.method === 'GET') return res.end(JSON.stringify({ opencoins: { net: 5, cursor: 42, moves: [{ id: 42, delta: 5, label: 'For a quest' }] }, vibes: null, channel_points: null }));
++ if (u.pathname === '/api/wallet/changes/read' && req.method === 'PUT') return res.end(JSON.stringify({ currency: JSON.parse(body).currency, read_cursor: JSON.parse(body).cursor }));
++ res.statusCode = 404; return res.end('{"error":"no route"}');
++ });
++ await Promise.all([billing, inventory, network].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}` },
++ network: { internalUrl: `http://127.0.0.1:${network.address().port}` },
} }, null);
const app = express();
app.set('trust proxy', 'loopback');
@@@ -93,6 +99,30 @@ const ITD = 'itd_01JZ00000000000000000000B1';
assert.strictEqual(r.status, 401, 'a malformed cookie is no token');
assert.strictEqual(asked.length, askedBefore, 'none of these asked upstream');
++ // Change reads keep their three cursor types and streamer, while unrelated input never reaches Network.
++ const vibesCursor = 'txn_01JZ00000000000000000000A1';
++ ({ r, d } = await get(`/wallet/changes?since_vibes=${vibesCursor}&since_opencoins=41&streamer=7&since_channel_points=9&limit=10&subject=usr_other`, { Authorization: token }));
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(d.opencoins.net, 5);
++ const changes = asked.at(-1), query = new URL(changes.url, 'http://x').searchParams;
++ assert.strictEqual(changes.svc, 'network');
++ assert.strictEqual(changes.auth, token);
++ assert.strictEqual(changes.cookie, '');
++ assert.deepStrictEqual([...query.keys()].sort(), ['limit', 'since_channel_points', 'since_opencoins', 'since_vibes', 'streamer']);
++ assert.strictEqual(query.get('since_vibes'), vibesCursor);
++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
++ ({ r } = await get('/wallet/changes'));
++ assert.strictEqual(r.status, 401);
++ ({ r } = await send('/wallet/changes/read', 'PUT', { currency: 'opencoins', cursor: 42 }));
++ assert.strictEqual(r.status, 200);
++ assert.deepStrictEqual(JSON.parse(asked.at(-1).body), { currency: 'opencoins', cursor: 42 });
++ ({ r } = await send('/wallet/changes/read', 'PUT', { currency: 'vibes', cursor: vibesCursor }, { Authorization: '', Cookie: `ov_token=${session}`, Origin: 'https://openvibe.events' }));
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(asked.at(-1).auth, `Bearer ${session}`);
++ assert.notStrictEqual(r.headers.get('access-control-allow-origin'), '*');
++ ({ r } = await send('/wallet/changes/read', 'PUT', { currency: 'vibes', cursor: vibesCursor }, { Authorization: '', Cookie: `ov_token=${session}` }));
++ assert.strictEqual(r.status, 401, 'a cookie write without Origin is refused');
++
// 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);
@@@ -151,7 +181,7 @@ const ITD = 'itd_01JZ00000000000000000000B1';
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();
++ server.close(); inventory.close(); network.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); });