openvibers/openvibe.live Commits
Ask 273: internal channel-points changes read for Network (#794)
Changes
@@@ -24,7 +24,7 @@"multer": "^1.4.5-lts.1","node-media-server": "^2.7.1","onnxruntime-node": "^1.20.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.401.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.203.0","pg": "^8.23.1",@@@ -3373,9 +3373,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.399.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",-- "integrity": "sha512-7QqSrMH2BT6ZOJu+xxusjEPICaVKGrr+3ij3tg0leE3sy4jfPwvWVTwaXZSCPM1feoP7heUDnSO4/9QBmjguJw==",++ "version": "0.401.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.401.0",++ "integrity": "sha512-NKuWV2l97rd2f7SZ2PkdDRvw3SeY8WrmYByorDSLvadJFFEcdSwXAilsSIa1QA8YqRkzcXi2eHdCGRmb6WZR3w==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -44,7 +44,7 @@"multer": "^1.4.5-lts.1","node-media-server": "^2.7.1","onnxruntime-node": "^1.20.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.401.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.203.0","pg": "^8.23.1",@@@ -2222,7 +2222,10 @@ async function applyChannelPoints({ userId, streamerId, delta, key, reason = nulif (!userId || !streamerId || !Number.isInteger(delta) || delta === 0) {return { applied: false, replayed: false, balance: await getChannelPoints(userId, streamerId) };}-- return await getDb().tx(async () => {++ const events = require('../events/stream-events');++ events.init();++ let announced = false;++ const result = await getDb().tx(async () => {const seen = await get('SELECT user_id, streamer_id, delta FROM channel_points_log WHERE idempotency_key = ?', [key]);if (seen) {if (seen.user_id !== userId || seen.streamer_id !== streamerId || seen.delta !== delta) {@@@ -2242,8 +2245,19 @@ async function applyChannelPoints({ userId, streamerId, delta, key, reason = nul}await run('INSERT INTO channel_points_log (idempotency_key, user_id, streamer_id, delta, reason) VALUES (?, ?, ?, ?, ?)',[key, userId, streamerId, delta, reason ? String(reason).slice(0, 200) : null]);-- return { applied: true, replayed: false, balance: await getChannelPoints(userId, streamerId) };++ const balance = await getChannelPoints(userId, streamerId);++ const linked = await get("SELECT subject_id FROM linked_accounts WHERE service = 'network' AND user_id = ? AND subject_id IS NOT NULL ORDER BY id DESC LIMIT 1", [userId]);++ if (linked && /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(linked.subject_id)) {++ announced = !!(await events.enqueue({++ event_type: 'live.balance.changed', actor: { type: 'service', id: 'live' },++ subject: { type: 'user', id: linked.subject_id }, visibility: 'subject', priority: 'important',++ payload: { currency: 'channel-points', streamer_id: streamerId, delta, balance, reason: reason || null },++ }));++ }++ return { applied: true, replayed: false, balance };});++ if (announced) events.kick();++ return result;}/** Credit channel points for one event (key required). Returns the new balance. */async function addChannelPoints(userId, streamerId, amount, key, reason) {@@@ -9,6 +9,8 @@ const { guard } = require('../net/service-guard');const { http } = require('openvibe-contracts');const { bindSlot, BindError } = require('../openre/bind');++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++// Service-to-service only, loopback only (nothing that came through nginx): each route checks the one capability it// performs on a Network service token (server/net/service-guard.js); nothing else gets in (X-Internal-Key: plan T2).@@@ -66,6 +68,52 @@ router.post('/user-avatar', guard('live.avatar.write'), async (req, res) => {}});++// ── Channel points changes for Network's gain/loss API (ask 273) ────────────────++// GET /internal/channel-points/changes?network_user_id=<id>&since_id=&streamer_id=&limit=++// guard('live.channel_points.read'): a Network service token only, loopback only. Reads Live's own++// channel_points_log (the per-channel ledger; Billing stays the only money ledger) for the person++// behind the Network account: one move per row, oldest first, with the cursor for the next page.++router.get('/channel-points/changes', guard('live.channel_points.read'), async (req, res) => {++ try {++ const networkId = String(req.query.network_user_id || '');++ if (!networkId) return res.status(400).json({ ok: false, error: 'network_user_id is required' });++ // The account is named by its Network subject (usr_…); older links that only carry the numeric++ // service id still resolve.++ let linked = SUBJECT_RE.test(networkId)++ ? await db.getDb().prepare("SELECT user_id FROM linked_accounts WHERE service = 'network' AND subject_id = ? ORDER BY id DESC LIMIT 1").get(networkId)++ : null;++ if (!linked) linked = await db.getDb().prepare("SELECT user_id FROM linked_accounts WHERE service = 'network' AND service_user_id = ?").get(networkId);++ if (!linked) return res.status(404).json({ ok: false, error: 'no linked live account' });++ const userId = linked.user_id;++ let streamerId = null;++ if (req.query.streamer_id != null && req.query.streamer_id !== '') {++ streamerId = Number(req.query.streamer_id);++ if (!Number.isInteger(streamerId) || streamerId <= 0) return res.status(400).json({ ok: false, error: 'streamer_id is a user id' });++ }++ let sinceId = 0;++ if (req.query.since_id != null && req.query.since_id !== '') {++ sinceId = Number(req.query.since_id);++ if (!Number.isInteger(sinceId) || sinceId < 0) return res.status(400).json({ ok: false, error: 'since_id is a log id' });++ }++ const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 100, 1), 500);++ const where = streamerId ? 'user_id = ? AND streamer_id = ? AND id > ?' : 'user_id = ? AND id > ?';++ const args = streamerId ? [userId, streamerId, sinceId] : [userId, sinceId];++ const rows = await db.getDb().prepare(`SELECT id, streamer_id, delta, reason, created_at AS at FROM channel_points_log++ WHERE ${where} ORDER BY id ASC LIMIT ?`).all(...args, limit + 1);++ const page = rows.slice(0, limit);++ const balance = streamerId ? await db.getChannelPoints(userId, streamerId) : null;++ return res.json({++ ok: true, user_id: userId, streamer_id: streamerId, balance,++ moves: page.map((r) => ({ id: r.id, streamer_id: r.streamer_id, at: r.at, delta: r.delta, reason: r.reason || null })),++ cursor: page.length ? page[page.length - 1].id : sinceId,++ next_since: rows.length > limit ? page[page.length - 1].id : null,++ });++ } catch (err) {++ console.error('[Internal] channel-points/changes error:', err.message);++ return res.status(500).json({ ok: false, error: err.message });++ }++});++// ── OpenVibe Live on by default for OpenRestream (live.openre.slot.bind) ─────────// OpenRestream asks for a slot on the person's channel bound to their OpenRestream stream (server/openre/bind.js).router.post('/openre/slots', guard('live.openre.slot.bind'), async (req, res) => {nnew file mode 100644@@@ -0,0 +1,123 @@++'use strict';++++// Live's channel-points read for Network's gain/loss API (ask 273):++// GET /internal/channel-points/changes is a Network service token with live.channel_points.read only,++// loopback only; it reads Live's own channel_points_log for the person behind the Network account.++const assert = require('assert');++const crypto = require('crypto');++const fs = require('fs');++const os = require('os');++const path = require('path');++const express = require('express');++const { serviceAuth } = require('openvibe-contracts');++++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-cp-changes-'));++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++fs.writeFileSync(path.join(tmp, 'network.pem'), keys.publicKey);++process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'network.pem');++process.env.OV_NETWORK_URL = 'https://openvibe.network';++console.log = () => {};++console.warn = () => {};++console.error = () => {};++const out = (...a) => process.stdout.write(`${a.join(' ')}\n`);++++const now = () => Math.floor(Date.now() / 1000);++let jti = 0;++const token = (cap, { aud = 'openvibe.live', sub = 'svc:network', env } = {}) => serviceAuth.signServiceToken({++ iss: 'https://openvibe.network', sub, actor_type: 'service', aud: [aud], cap, iat: now(), exp: now() + 300, jti: `tok_test_${String(++jti).padStart(4, '0')}`, ...(env ? { env } : {}),++}, keys.privateKey);++++(async () => {++ const db = require('../server/db/database');++ await db.initDb();++ // A viewer and a streamer, the viewer linked to their Network account.++ const fan = Number((await db.createUser({ username: 'cpfan', password_hash: 'x', display_name: 'Cp Fan', stream_key: 'cpfansecretkey1' })).lastInsertRowid);++ const streamer = Number((await db.createUser({ username: 'cpstar', password_hash: 'x', display_name: 'Cp Star', stream_key: 'cpstarsecretkey1' })).lastInsertRowid);++ const other = Number((await db.createUser({ username: 'cpother', password_hash: 'x', display_name: 'Cp Other', stream_key: 'cpothersecretkey1' })).lastInsertRowid);++ await db.getDb().prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (?, 'network', ?, ?)").run(fan, 'net-fan-1', 'usr_01JAB2C3D4E5F6G7H8J9K0FAN1');++ await db.getDb().prepare("INSERT INTO linked_accounts (user_id, service, service_user_id) VALUES (?, 'network', ?)").run(other, 'net-other-1');++ const events = require('../server/events/stream-events');++ const original = { init: events.init, enqueue: events.enqueue, kick: events.kick };++ const sent = [];++ let kicks = 0;++ events.init = () => ({});++ events.enqueue = async (env) => { sent.push(env); return env; };++ events.kick = () => { kicks++; };++ await db.applyChannelPoints({ userId: fan, streamerId: streamer, delta: 100, key: 'test:cp:watch:1', reason: 'watch' });++ await db.applyChannelPoints({ userId: fan, streamerId: streamer, delta: -30, key: 'test:cp:redeem:1', reason: 'redeem reward 7' });++ await db.applyChannelPoints({ userId: other, streamerId: streamer, delta: 50, key: 'test:cp:watch:2', reason: 'watch' });++ await db.applyChannelPoints({ userId: fan, streamerId: streamer, delta: 100, key: 'test:cp:watch:1', reason: 'watch' });++ assert.deepStrictEqual(sent.map((e) => e.payload.delta), [100, -30], 'only applied moves for a linked person announce');++ assert(sent.every((e) => e.event_type === 'live.balance.changed' && e.visibility === 'subject' && e.subject.id === 'usr_01JAB2C3D4E5F6G7H8J9K0FAN1'));++ assert.strictEqual(kicks, 2);++ Object.assign(events, original);++++ const app = express();++ app.use(express.json());++ app.use('/internal', require('../server/internal/routes'));++ const server = app.listen(0, '127.0.0.1');++ if (!server.listening) await new Promise((r) => server.once('listening', r));++ const origin = `http://127.0.0.1:${server.address().port}`;++ const get = (p, headers = {}) => fetch(origin + p, { headers });++ const Bearer = (t) => ({ authorization: `Bearer ${t}` });++ const q = (o) => `/internal/channel-points/changes?${new URLSearchParams(o)}`;++++ let failures = 0;++ const check = async (name, fn) => {++ try { await fn(); out(' ✓', name); }++ catch (e) { failures++; out(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 6).join('\n ')); }++ };++ const good = Bearer(token(['live.channel_points.read']));++++ await check('a gain and a loss, oldest first, with the balance', async () => {++ const r = await get(q({ network_user_id: 'net-fan-1', streamer_id: String(streamer) }), good);++ assert.strictEqual(r.status, 200);++ const b = await r.json();++ assert.strictEqual(b.balance, 70);++ assert.strictEqual(b.moves.length, 2);++ assert.strictEqual(b.moves[0].delta, 100);++ assert.strictEqual(b.moves[0].reason, 'watch');++ assert.strictEqual(b.moves[1].delta, -30);++ assert.strictEqual(b.cursor, b.moves[1].id);++ assert.strictEqual(b.next_since, null);++ });++++ await check('the Network subject resolves the same account', async () => {++ const rr = await get(q({ network_user_id: 'usr_01JAB2C3D4E5F6G7H8J9K0FAN1', streamer_id: String(streamer) }), good);++ const b = await rr.json();++ assert.strictEqual(rr.status, 200, JSON.stringify(b));++ assert.strictEqual(b.moves.length, 2);++ assert.strictEqual(b.balance, 70);++ });++++ await check('a cursor pages forward', async () => {++ const first = await (await get(q({ network_user_id: 'net-fan-1', streamer_id: String(streamer), limit: '1' }), good)).json();++ assert.strictEqual(first.moves.length, 1);++ assert.strictEqual(first.next_since, first.moves[0].id);++ const rest = await (await get(q({ network_user_id: 'net-fan-1', streamer_id: String(streamer), since_id: String(first.next_since) }), good)).json();++ assert.strictEqual(rest.moves.length, 1);++ assert.strictEqual(rest.moves[0].delta, -30);++ });++++ await check("another person's points are unreachable: no user parameter beyond the linked account", async () => {++ const b = await (await get(q({ network_user_id: 'net-other-1', streamer_id: String(streamer) }), good)).json();++ assert.strictEqual(b.moves.length, 1);++ assert.strictEqual(b.moves[0].delta, 50, 'only their own row');++ assert.strictEqual((await get(q({ network_user_id: 'nobody' }), good)).status, 404);++ assert.strictEqual((await get(q({}), good)).status, 400);++ assert.strictEqual((await get(q({ network_user_id: 'net-fan-1', streamer_id: 'x' }), good)).status, 400);++ });++++ await check('only the capability opens it', async () => {++ const p = q({ network_user_id: 'net-fan-1' });++ assert.strictEqual((await get(p)).status, 401);++ assert.strictEqual((await get(p, Bearer(token(['live.avatar.write'])))).status, 403);++ assert.strictEqual((await get(p, Bearer(token(['live.channel_points.read'], { aud: 'openvibe.network' })))).status, 401);++ assert.strictEqual((await get(p, { ...good, 'x-forwarded-for': '203.0.113.9' })).status, 403);++ });++++ server.close();++ if (failures) { out(`${failures} failed`); process.exit(1); }++ out('channel-points changes: all passed');++ process.exit(0);++})().catch((e) => { process.stderr.write(String(e && e.stack || e)); process.exit(1); });
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.401.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.203.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.203.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
@@ -3373,9 +3373,9 @@
@@ -3373,9 +3373,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.399.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
- "integrity": "sha512-7QqSrMH2BT6ZOJu+xxusjEPICaVKGrr+3ij3tg0leE3sy4jfPwvWVTwaXZSCPM1feoP7heUDnSO4/9QBmjguJw==",
+ "version": "0.401.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.401.0",
+ "integrity": "sha512-NKuWV2l97rd2f7SZ2PkdDRvw3SeY8WrmYByorDSLvadJFFEcdSwXAilsSIa1QA8YqRkzcXi2eHdCGRmb6WZR3w==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -44,7 +44,7 @@
@@ -44,7 +44,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.401.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.203.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.203.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
@@ -2222,7 +2222,10 @@ async function applyChannelPoints({ userId, streamerId, delta, key, reason = nul
@@ -2222,7 +2222,10 @@ async function applyChannelPoints({ userId, streamerId, delta, key, reason = nul
if (!userId || !streamerId || !Number.isInteger(delta) || delta === 0) {
if (!userId || !streamerId || !Number.isInteger(delta) || delta === 0) {
return { applied: false, replayed: false, balance: await getChannelPoints(userId, streamerId) };
return { applied: false, replayed: false, balance: await getChannelPoints(userId, streamerId) };
}
}
- return await getDb().tx(async () => {
+ const events = require('../events/stream-events');
+ events.init();
+ let announced = false;
+ const result = await getDb().tx(async () => {
const seen = await get('SELECT user_id, streamer_id, delta FROM channel_points_log WHERE idempotency_key = ?', [key]);
const seen = await get('SELECT user_id, streamer_id, delta FROM channel_points_log WHERE idempotency_key = ?', [key]);
if (seen) {
if (seen) {
if (seen.user_id !== userId || seen.streamer_id !== streamerId || seen.delta !== delta) {
if (seen.user_id !== userId || seen.streamer_id !== streamerId || seen.delta !== delta) {
@@ -2242,8 +2245,19 @@ async function applyChannelPoints({ userId, streamerId, delta, key, reason = nul
@@ -2242,8 +2245,19 @@ async function applyChannelPoints({ userId, streamerId, delta, key, reason = nul
}
}
await run('INSERT INTO channel_points_log (idempotency_key, user_id, streamer_id, delta, reason) VALUES (?, ?, ?, ?, ?)',
await run('INSERT INTO channel_points_log (idempotency_key, user_id, streamer_id, delta, reason) VALUES (?, ?, ?, ?, ?)',
[key, userId, streamerId, delta, reason ? String(reason).slice(0, 200) : null]);
[key, userId, streamerId, delta, reason ? String(reason).slice(0, 200) : null]);
- return { applied: true, replayed: false, balance: await getChannelPoints(userId, streamerId) };
+ const balance = await getChannelPoints(userId, streamerId);
+ const linked = await get("SELECT subject_id FROM linked_accounts WHERE service = 'network' AND user_id = ? AND subject_id IS NOT NULL ORDER BY id DESC LIMIT 1", [userId]);
+ if (linked && /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(linked.subject_id)) {
+ announced = !!(await events.enqueue({
+ event_type: 'live.balance.changed', actor: { type: 'service', id: 'live' },
+ subject: { type: 'user', id: linked.subject_id }, visibility: 'subject', priority: 'important',
+ payload: { currency: 'channel-points', streamer_id: streamerId, delta, balance, reason: reason || null },
+ }));
+ }
+ return { applied: true, replayed: false, balance };
});
});
+ if (announced) events.kick();
+ return result;
}
}
/** Credit channel points for one event (key required). Returns the new balance. */
/** Credit channel points for one event (key required). Returns the new balance. */
async function addChannelPoints(userId, streamerId, amount, key, reason) {
async function addChannelPoints(userId, streamerId, amount, key, reason) {
@@ -9,6 +9,8 @@ const { guard } = require('../net/service-guard');
@@ -9,6 +9,8 @@ const { guard } = require('../net/service-guard');
const { http } = require('openvibe-contracts');
const { http } = require('openvibe-contracts');
const { bindSlot, BindError } = require('../openre/bind');
const { bindSlot, BindError } = require('../openre/bind');
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+
// Service-to-service only, loopback only (nothing that came through nginx): each route checks the one capability it
// Service-to-service only, loopback only (nothing that came through nginx): each route checks the one capability it
// performs on a Network service token (server/net/service-guard.js); nothing else gets in (X-Internal-Key: plan T2).
// performs on a Network service token (server/net/service-guard.js); nothing else gets in (X-Internal-Key: plan T2).
@@ -66,6 +68,52 @@ router.post('/user-avatar', guard('live.avatar.write'), async (req, res) => {
@@ -66,6 +68,52 @@ router.post('/user-avatar', guard('live.avatar.write'), async (req, res) => {
}
}
});
});
+// ── Channel points changes for Network's gain/loss API (ask 273) ────────────────
+// GET /internal/channel-points/changes?network_user_id=<id>&since_id=&streamer_id=&limit=
+// guard('live.channel_points.read'): a Network service token only, loopback only. Reads Live's own
+// channel_points_log (the per-channel ledger; Billing stays the only money ledger) for the person
+// behind the Network account: one move per row, oldest first, with the cursor for the next page.
+router.get('/channel-points/changes', guard('live.channel_points.read'), async (req, res) => {
+ try {
+ const networkId = String(req.query.network_user_id || '');
+ if (!networkId) return res.status(400).json({ ok: false, error: 'network_user_id is required' });
+ // The account is named by its Network subject (usr_…); older links that only carry the numeric
+ // service id still resolve.
+ let linked = SUBJECT_RE.test(networkId)
+ ? await db.getDb().prepare("SELECT user_id FROM linked_accounts WHERE service = 'network' AND subject_id = ? ORDER BY id DESC LIMIT 1").get(networkId)
+ : null;
+ if (!linked) linked = await db.getDb().prepare("SELECT user_id FROM linked_accounts WHERE service = 'network' AND service_user_id = ?").get(networkId);
+ if (!linked) return res.status(404).json({ ok: false, error: 'no linked live account' });
+ const userId = linked.user_id;
+ let streamerId = null;
+ if (req.query.streamer_id != null && req.query.streamer_id !== '') {
+ streamerId = Number(req.query.streamer_id);
+ if (!Number.isInteger(streamerId) || streamerId <= 0) return res.status(400).json({ ok: false, error: 'streamer_id is a user id' });
+ }
+ let sinceId = 0;
+ if (req.query.since_id != null && req.query.since_id !== '') {
+ sinceId = Number(req.query.since_id);
+ if (!Number.isInteger(sinceId) || sinceId < 0) return res.status(400).json({ ok: false, error: 'since_id is a log id' });
+ }
+ const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 100, 1), 500);
+ const where = streamerId ? 'user_id = ? AND streamer_id = ? AND id > ?' : 'user_id = ? AND id > ?';
+ const args = streamerId ? [userId, streamerId, sinceId] : [userId, sinceId];
+ const rows = await db.getDb().prepare(`SELECT id, streamer_id, delta, reason, created_at AS at FROM channel_points_log
+ WHERE ${where} ORDER BY id ASC LIMIT ?`).all(...args, limit + 1);
+ const page = rows.slice(0, limit);
+ const balance = streamerId ? await db.getChannelPoints(userId, streamerId) : null;
+ return res.json({
+ ok: true, user_id: userId, streamer_id: streamerId, balance,
+ moves: page.map((r) => ({ id: r.id, streamer_id: r.streamer_id, at: r.at, delta: r.delta, reason: r.reason || null })),
+ cursor: page.length ? page[page.length - 1].id : sinceId,
+ next_since: rows.length > limit ? page[page.length - 1].id : null,
+ });
+ } catch (err) {
+ console.error('[Internal] channel-points/changes error:', err.message);
+ return res.status(500).json({ ok: false, error: err.message });
+ }
+});
+
// ── OpenVibe Live on by default for OpenRestream (live.openre.slot.bind) ─────────
// ── OpenVibe Live on by default for OpenRestream (live.openre.slot.bind) ─────────
// OpenRestream asks for a slot on the person's channel bound to their OpenRestream stream (server/openre/bind.js).
// OpenRestream asks for a slot on the person's channel bound to their OpenRestream stream (server/openre/bind.js).
router.post('/openre/slots', guard('live.openre.slot.bind'), async (req, res) => {
router.post('/openre/slots', guard('live.openre.slot.bind'), async (req, res) => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,123 @@
@@ -0,0 +1,123 @@
+'use strict';
+
+// Live's channel-points read for Network's gain/loss API (ask 273):
+// GET /internal/channel-points/changes is a Network service token with live.channel_points.read only,
+// loopback only; it reads Live's own channel_points_log for the person behind the Network account.
+const assert = require('assert');
+const crypto = require('crypto');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const express = require('express');
+const { serviceAuth } = require('openvibe-contracts');
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-cp-changes-'));
+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+fs.writeFileSync(path.join(tmp, 'network.pem'), keys.publicKey);
+process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'network.pem');
+process.env.OV_NETWORK_URL = 'https://openvibe.network';
+console.log = () => {};
+console.warn = () => {};
+console.error = () => {};
+const out = (...a) => process.stdout.write(`${a.join(' ')}\n`);
+
+const now = () => Math.floor(Date.now() / 1000);
+let jti = 0;
+const token = (cap, { aud = 'openvibe.live', sub = 'svc:network', env } = {}) => serviceAuth.signServiceToken({
+ iss: 'https://openvibe.network', sub, actor_type: 'service', aud: [aud], cap, iat: now(), exp: now() + 300, jti: `tok_test_${String(++jti).padStart(4, '0')}`, ...(env ? { env } : {}),
+}, keys.privateKey);
+
+(async () => {
+ const db = require('../server/db/database');
+ await db.initDb();
+ // A viewer and a streamer, the viewer linked to their Network account.
+ const fan = Number((await db.createUser({ username: 'cpfan', password_hash: 'x', display_name: 'Cp Fan', stream_key: 'cpfansecretkey1' })).lastInsertRowid);
+ const streamer = Number((await db.createUser({ username: 'cpstar', password_hash: 'x', display_name: 'Cp Star', stream_key: 'cpstarsecretkey1' })).lastInsertRowid);
+ const other = Number((await db.createUser({ username: 'cpother', password_hash: 'x', display_name: 'Cp Other', stream_key: 'cpothersecretkey1' })).lastInsertRowid);
+ await db.getDb().prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (?, 'network', ?, ?)").run(fan, 'net-fan-1', 'usr_01JAB2C3D4E5F6G7H8J9K0FAN1');
+ await db.getDb().prepare("INSERT INTO linked_accounts (user_id, service, service_user_id) VALUES (?, 'network', ?)").run(other, 'net-other-1');
+ const events = require('../server/events/stream-events');
+ const original = { init: events.init, enqueue: events.enqueue, kick: events.kick };
+ const sent = [];
+ let kicks = 0;
+ events.init = () => ({});
+ events.enqueue = async (env) => { sent.push(env); return env; };
+ events.kick = () => { kicks++; };
+ await db.applyChannelPoints({ userId: fan, streamerId: streamer, delta: 100, key: 'test:cp:watch:1', reason: 'watch' });
+ await db.applyChannelPoints({ userId: fan, streamerId: streamer, delta: -30, key: 'test:cp:redeem:1', reason: 'redeem reward 7' });
+ await db.applyChannelPoints({ userId: other, streamerId: streamer, delta: 50, key: 'test:cp:watch:2', reason: 'watch' });
+ await db.applyChannelPoints({ userId: fan, streamerId: streamer, delta: 100, key: 'test:cp:watch:1', reason: 'watch' });
+ assert.deepStrictEqual(sent.map((e) => e.payload.delta), [100, -30], 'only applied moves for a linked person announce');
+ assert(sent.every((e) => e.event_type === 'live.balance.changed' && e.visibility === 'subject' && e.subject.id === 'usr_01JAB2C3D4E5F6G7H8J9K0FAN1'));
+ assert.strictEqual(kicks, 2);
+ Object.assign(events, original);
+
+ const app = express();
+ app.use(express.json());
+ app.use('/internal', require('../server/internal/routes'));
+ const server = app.listen(0, '127.0.0.1');
+ if (!server.listening) await new Promise((r) => server.once('listening', r));
+ const origin = `http://127.0.0.1:${server.address().port}`;
+ const get = (p, headers = {}) => fetch(origin + p, { headers });
+ const Bearer = (t) => ({ authorization: `Bearer ${t}` });
+ const q = (o) => `/internal/channel-points/changes?${new URLSearchParams(o)}`;
+
+ let failures = 0;
+ const check = async (name, fn) => {
+ try { await fn(); out(' ✓', name); }
+ catch (e) { failures++; out(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 6).join('\n ')); }
+ };
+ const good = Bearer(token(['live.channel_points.read']));
+
+ await check('a gain and a loss, oldest first, with the balance', async () => {
+ const r = await get(q({ network_user_id: 'net-fan-1', streamer_id: String(streamer) }), good);
+ assert.strictEqual(r.status, 200);
+ const b = await r.json();
+ assert.strictEqual(b.balance, 70);
+ assert.strictEqual(b.moves.length, 2);
+ assert.strictEqual(b.moves[0].delta, 100);
+ assert.strictEqual(b.moves[0].reason, 'watch');
+ assert.strictEqual(b.moves[1].delta, -30);
+ assert.strictEqual(b.cursor, b.moves[1].id);
+ assert.strictEqual(b.next_since, null);
+ });
+
+ await check('the Network subject resolves the same account', async () => {
+ const rr = await get(q({ network_user_id: 'usr_01JAB2C3D4E5F6G7H8J9K0FAN1', streamer_id: String(streamer) }), good);
+ const b = await rr.json();
+ assert.strictEqual(rr.status, 200, JSON.stringify(b));
+ assert.strictEqual(b.moves.length, 2);
+ assert.strictEqual(b.balance, 70);
+ });
+
+ await check('a cursor pages forward', async () => {
+ const first = await (await get(q({ network_user_id: 'net-fan-1', streamer_id: String(streamer), limit: '1' }), good)).json();
+ assert.strictEqual(first.moves.length, 1);
+ assert.strictEqual(first.next_since, first.moves[0].id);
+ const rest = await (await get(q({ network_user_id: 'net-fan-1', streamer_id: String(streamer), since_id: String(first.next_since) }), good)).json();
+ assert.strictEqual(rest.moves.length, 1);
+ assert.strictEqual(rest.moves[0].delta, -30);
+ });
+
+ await check("another person's points are unreachable: no user parameter beyond the linked account", async () => {
+ const b = await (await get(q({ network_user_id: 'net-other-1', streamer_id: String(streamer) }), good)).json();
+ assert.strictEqual(b.moves.length, 1);
+ assert.strictEqual(b.moves[0].delta, 50, 'only their own row');
+ assert.strictEqual((await get(q({ network_user_id: 'nobody' }), good)).status, 404);
+ assert.strictEqual((await get(q({}), good)).status, 400);
+ assert.strictEqual((await get(q({ network_user_id: 'net-fan-1', streamer_id: 'x' }), good)).status, 400);
+ });
+
+ await check('only the capability opens it', async () => {
+ const p = q({ network_user_id: 'net-fan-1' });
+ assert.strictEqual((await get(p)).status, 401);
+ assert.strictEqual((await get(p, Bearer(token(['live.avatar.write'])))).status, 403);
+ assert.strictEqual((await get(p, Bearer(token(['live.channel_points.read'], { aud: 'openvibe.network' })))).status, 401);
+ assert.strictEqual((await get(p, { ...good, 'x-forwarded-for': '203.0.113.9' })).status, 403);
+ });
+
+ server.close();
+ if (failures) { out(`${failures} failed`); process.exit(1); }
+ out('channel-points changes: all passed');
+ process.exit(0);
+})().catch((e) => { process.stderr.write(String(e && e.stack || e)); process.exit(1); });