openvibers/openvibe.network Commits
Relay wallet balance changes through Frame (#840)
Changes
@@@ -738,13 +738,22 @@ async function createFrameService(db, config, analytics, { privateKey = null, isconst 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).@@@ -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); });
@@ -738,13 +738,22 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -738,13 +738,22 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
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 = bearer || fromCookie;
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') {
if (method === 'GET') {
const limit = String(req.query.limit || '');
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)));
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 headers = { Accept: 'application/json', ...(auth ? { Authorization: auth } : {}) };
const init = { method, headers, redirect: 'manual', signal: AbortSignal.timeout(8000) };
const init = { method, headers, redirect: 'manual', signal: AbortSignal.timeout(8000) };
@@ -764,6 +773,9 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -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.
// 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.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/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'));
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).
// Inventory: the store, the person's items, and the equipped slots (worn or not).
@@ -43,10 +43,16 @@ const ITD = 'itd_01JZ00000000000000000000B1';
@@ -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'] }] }));
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"}');
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: {
const svc = await createFrameService(getDb(), { services: {
billing: { internalUrl: `http://127.0.0.1:${billing.address().port}` },
billing: { internalUrl: `http://127.0.0.1:${billing.address().port}` },
inventory: { internalUrl: `http://127.0.0.1:${inventory.address().port}` },
inventory: { internalUrl: `http://127.0.0.1:${inventory.address().port}` },
+ network: { internalUrl: `http://127.0.0.1:${network.address().port}` },
} }, null);
} }, null);
const app = express();
const app = express();
app.set('trust proxy', 'loopback');
app.set('trust proxy', 'loopback');
@@ -93,6 +99,30 @@ const ITD = 'itd_01JZ00000000000000000000B1';
@@ -93,6 +99,30 @@ const ITD = 'itd_01JZ00000000000000000000B1';
assert.strictEqual(r.status, 401, 'a malformed cookie is no token');
assert.strictEqual(r.status, 401, 'a malformed cookie is no token');
assert.strictEqual(asked.length, askedBefore, 'none of these asked upstream');
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.
// 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);
@@ -151,7 +181,7 @@ const ITD = 'itd_01JZ00000000000000000000B1';
@@ -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.strictEqual(r.status, 502, 'a service that does not answer is a failure, never an empty wallet');
assert.match(d.error, /did not answer/);
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');
console.log('frame wallet: fixed paths, bearer only, no cache, 8 KB bodies, statuses back, bad ids and foreign paths refused');
process.exit(0);
process.exit(0);
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });