openvibers/openvibe.network Commits

Ask 273: channel-points currency in the wallet changes API (#805)

OP OpenVibers committed 2 days ago · 659a90f4a8 · 11 files, +388 −15

* Ask 273: channel-points currency in the wallet changes API

* Network: rebase channel points wallet read API

* Network: review channel points fetch

* Merge main and move channel points migration to 0074

Changes

ddiff --git a/migrations/0074_channel_points_reads.sql b/migrations/0074_channel_points_reads.sql
nnew file mode 100644
iindex 00000000..0284684f
---- /dev/null
++++ b/migrations/0074_channel_points_reads.sql
@@@ -0,0 +1,11 @@
++-- phase: expand
++-- Ask 273: the person's own read state for channel points ("since I last looked" per channel).
++-- One row per person per streamer: the Live channel_points_log id the bar has already shown.
++-- The id names a row in Live's database, so there is no foreign key to it.
++CREATE TABLE IF NOT EXISTS channel_points_reads (
++ user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
++ streamer_id bigint NOT NULL,
++ last_seen_tx bigint NOT NULL,
++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
++ PRIMARY KEY (user_id, streamer_id)
++);
ddiff --git a/package-lock.json b/package-lock.json
iindex ffe4ea81..e813d5bf 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -22,7 +22,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.400.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.202.0",
"otpauth": "9.6.0",
@@@ -2679,9 +2679,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.400.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.400.0",
-- "integrity": "sha512-37gMtbK0gQy1P3TdD0KauSDvV/p2J0fepd1qfsPhj8SWSZXHwry9zdwzJWDKiKsqKWnG5jvy9hLf4bH/1zIrpg==",
++ "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",
ddiff --git a/package.json b/package.json
iindex 940e4eac..b4c94238 100644
---- a/package.json
++++ b/package.json
@@@ -26,7 +26,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.400.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.202.0",
"otpauth": "9.6.0",
ddiff --git a/server/coins/channel-points.js b/server/coins/channel-points.js
nnew file mode 100644
iindex 00000000..de15b58d
---- /dev/null
++++ b/server/coins/channel-points.js
@@@ -0,0 +1,165 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// openvibe.network — Channel points "what changed since I last looked" (ask 273).
++//
++// The ledger lives on OpenVibe.Live (channel_points_log, per streamer); Network reads it through
++// Live's internal GET /internal/channel-points/changes with a Network service token carrying
++// live.channel_points.read, and shapes it like every other currency:
++//
++// changes({ live, subject, streamerId, since, limit })
++// → { currency, streamer, balance, since, moves, parts, net, cursor, next_since, read_cursor }
++//
++// One move per Live log row, oldest first: { id, streamer_id, at, delta, part, label, reason }.
++// `parts` sums the window into earned / spent / received / sent and `net` is their total. `since`
++// is a Live log id from an earlier answer; omitted, the person's own stored read state answers
++// ("since I last looked"); with neither, the window starts at the first move. The read state is
++// Network's own (channel_points_reads, per person per streamer); a cursor only needs to be a
++// positive Live log id, and it only moves forward. markRead() stores it.
++//
++// The buckets: watching, chatting, following and bonus games arrive as earned, refunds back as
++// received; redemptions and media requests consume as spent, anything else leaving as sent. Every
++// move keeps Live's raw reason, so the bar always shows Live's own words beside the bucket.
++//
++// There is no subject parameter: the caller's Network subject is the only account read, so another
++// person's points are unreachable.
++// ═══════════════════════════════════════════════════════════════
++
++const DEFAULT_LIMIT = 100;
++const MAX_LIMIT = 500;
++const TIMEOUT_MS = 2500;
++
++const LABELS = {
++ watch: 'for watching the stream',
++ chat: 'for chat activity',
++ follow: 'for the follow',
++ 'bonus game': 'for the bonus game',
++};
++
++function partOf({ delta, reason }) {
++ const r = String(reason || '');
++ if (delta > 0) return /^(refund|returned)/i.test(r) ? 'received' : 'earned';
++ return /(redeem|media)/i.test(r) ? 'spent' : 'sent';
++}
++
++function labelFor(reason) {
++ const r = String(reason || '');
++ if (LABELS[r]) return LABELS[r];
++ if (/^redeem /i.test(r)) return 'for a channel reward';
++ if (/^refund /i.test(r)) return 'returned';
++ return r || 'channel points';
++}
++
++/** Live's answer, or an error with .status/.code. `live` is { url, token }. */
++async function askLive(live, { subject, streamerId, since, limit }) {
++ if (!live || !live.url) {
++ const e = new Error('Live is not configured');
++ e.status = 502; e.code = 'channel_points.unavailable';
++ throw e;
++ }
++ const qs = new URLSearchParams({ network_user_id: subject });
++ if (streamerId != null) qs.set('streamer_id', String(streamerId));
++ if (since != null && since !== '') qs.set('since_id', String(since));
++ if (limit != null) qs.set('limit', String(limit));
++ let r;
++ try {
++ r = await fetch(`${live.url.replace(/\/+$/, '')}/internal/channel-points/changes?${qs}`, {
++ headers: { Accept: 'application/json', ...(live.token ? { Authorization: `Bearer ${live.token}` } : {}) },
++ signal: AbortSignal.timeout(TIMEOUT_MS),
++ });
++ } catch {
++ const e = new Error('Live is unreachable');
++ e.status = 502; e.code = 'channel_points.unavailable';
++ throw e;
++ }
++ const body = await r.json().catch(() => null);
++ if (r.status === 404) {
++ const e = new Error('no linked live account');
++ e.status = 404; e.code = 'channel_points.unlinked';
++ throw e;
++ }
++ if (!r.ok || !body || body.ok !== true) {
++ const e = new Error((body && (body.error || body.message)) || 'Live cannot say');
++ e.status = r.status === 400 ? 400 : 502; e.code = 'channel_points.unavailable';
++ throw e;
++ }
++ return body;
++}
++
++function badCursor(message = 'since must be a channel-points move id') {
++ const e = new Error(message);
++ e.status = 422; e.code = 'channel_points.invalid_cursor';
++ return e;
++}
++
++function positiveInt(v, what) {
++ const n = Number(v);
++ if (!Number.isInteger(n) || n <= 0) throw badCursor(`${what} is a channel-points move id`);
++ return n;
++}
++
++async function readCursor(db, userId, streamerId) {
++ const row = await db.prepare('SELECT last_seen_tx AS cursor FROM channel_points_reads WHERE user_id = ? AND streamer_id = ?').get(userId, streamerId);
++ return row ? Number(row.cursor) : null;
++}
++
++async function changes(db, { live, userId, subject, streamerId, since = null, limit = DEFAULT_LIMIT } = {}) {
++ if (!subject) {
++ const e = new Error('the person has no Network subject');
++ e.status = 500; e.code = 'channel_points.unavailable';
++ throw e;
++ }
++ const streamer = positiveInt(streamerId, 'streamer');
++ const n = Number(limit);
++ const take = Number.isSafeInteger(n) && n > 0 ? Math.min(n, MAX_LIMIT) : DEFAULT_LIMIT;
++
++ let start = null;
++ if (since != null && since !== '') {
++ start = positiveInt(since, 'since');
++ } else {
++ const stored = await readCursor(db, userId, streamer);
++ if (stored) start = stored;
++ }
++
++ const body = await askLive(live, { subject, streamerId: streamer, since: start || undefined, limit: take });
++ const parts = { earned: 0, spent: 0, received: 0, sent: 0 };
++ const moves = [];
++ for (const m of body.moves || []) {
++ const delta = Number(m.delta);
++ if (!Number.isSafeInteger(delta) || delta === 0) continue;
++ const part = partOf({ delta, reason: m.reason });
++ parts[part] += delta;
++ moves.push({
++ id: Number(m.id), streamer_id: Number(m.streamer_id), at: m.at, delta, part,
++ label: labelFor(m.reason), reason: m.reason || null,
++ });
++ }
++ const net = parts.earned + parts.spent + parts.received + parts.sent;
++ return {
++ currency: 'channel-points', streamer,
++ balance: body.balance == null ? null : Number(body.balance),
++ since: start,
++ moves, parts, net,
++ cursor: moves.length ? moves[moves.length - 1].id : (start || null),
++ next_since: body.next_since == null ? null : Number(body.next_since),
++ read_cursor: await readCursor(db, userId, streamer),
++ };
++}
++
++/** Store "I looked" for one channel: the cursor is a positive Live log id and only moves forward. */
++async function markRead(db, userId, streamerId, cursor) {
++ const streamer = positiveInt(streamerId, 'streamer');
++ const at = positiveInt(cursor, 'cursor');
++ const stored = await readCursor(db, userId, streamer);
++ if (stored != null && at < stored) {
++ const e = new Error('the read cursor only moves forward');
++ e.status = 409; e.code = 'channel_points.stale_read';
++ throw e;
++ }
++ await db.prepare(`INSERT INTO channel_points_reads (user_id, streamer_id, last_seen_tx, updated_at) VALUES (?, ?, ?, ov_now())
++ ON CONFLICT (user_id, streamer_id) DO UPDATE SET last_seen_tx = EXCLUDED.last_seen_tx, updated_at = EXCLUDED.updated_at`)
++ .run(userId, streamer, at);
++ return { currency: 'channel-points', streamer, read_cursor: at };
++}
++
++module.exports = { changes, markRead };
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 7bb97fae..a443e568 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -416,8 +416,9 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
count('opencoins_closed', bal);
}
}
-- // Ask 273: the "since I last looked" cursor is ephemeral UI state: gone with the account.
++ // Ask 273: the "since I last looked" cursors are ephemeral UI state: gone with the account.
await del('coin_balance_reads', `user_id IN ${inU}`, userIds, 'coin_balance_reads');
++ await del('channel_points_reads', `user_id IN ${inU}`, userIds, 'channel_points_reads');
// The tombstones: the username is released, the rest cleared; the subject stays (never reused).
const cols = await columnsOf(db, 'users');
for (const id of userIds) {
ddiff --git a/server/wallet/routes.js b/server/wallet/routes.js
iindex 55568422..e27c98c9 100644
---- a/server/wallet/routes.js
++++ b/server/wallet/routes.js
@@@ -25,6 +25,7 @@
const express = require('express');
const wallet = require('../coins/wallet');
const coinChanges = require('../coins/changes');
++const channelPoints = require('../coins/channel-points');
const money = require('../money/reasons');
const config = require('../config');
@@@ -450,14 +451,20 @@ module.exports = function createWalletRoutes(db, requireAuth, opts = {}) {
// ── GET /api/wallet/changes ──────────────────────────────
// Ask 273: one call for what changed in the person's balances since they last looked, per
-- // currency: Vibes from Billing (the person's own token, forwarded) and OpenCoins from
-- // Network's own ledger. A currency that is down or not open yet nulls its part and says so;
-- // the rest still draws. Channel points ride the same shape once Live's read lands.
++ // currency: Vibes from Billing (the person's own token, forwarded), OpenCoins from Network's
++ // own ledger, and one channel's points from Live (the bar names the channel with ?streamer=).
++ // A currency that is down or not open yet nulls its part and says so; the rest still draws.
++ function liveOf() {
++ const url = String(opts.liveUrl || (config.services && config.services.live && config.services.live.internalUrl) || '').replace(/\/+$/, '');
++ if (!url) return null;
++ const token = typeof selfToken === 'function' ? selfToken('openvibe.live', ['live.channel_points.read']) : null;
++ return { url, token };
++ }
r.get('/changes', requireAuth, async (req, res) => {
cacheHeaders(res);
const auth = personToken(req);
const limit = req.query.limit;
-- const [vibes, opencoins] = await Promise.all([
++ const [vibes, opencoins, points] = await Promise.all([
(async () => {
if (!auth || !billingUrl) return { part: null, state: 'unavailable' };
try {
@@@ -474,8 +481,26 @@ module.exports = function createWalletRoutes(db, requireAuth, opts = {}) {
return { part: await coinChanges.changes(db, req.user.id, { currency: 'opencoins', since: req.query.since_opencoins, limit }), state: 'open' };
} catch (e) { return { part: null, state: 'unavailable', error: e.code || 'coins.unavailable' }; }
})(),
++ (async () => {
++ if (req.query.streamer == null || req.query.streamer === '') return { part: null, state: 'needs_streamer' };
++ try {
++ return {
++ part: await channelPoints.changes(db, {
++ live: liveOf(), userId: req.user.id, subject: req.user.subject_id, streamerId: req.query.streamer,
++ since: req.query.since_channel_points, limit,
++ }),
++ state: 'open',
++ };
++ } catch (e) {
++ if (e && e.code === 'channel_points.unlinked') return { part: null, state: 'unlinked' };
++ return { part: null, state: 'unavailable', error: (e && e.code) || 'channel_points.unavailable' };
++ }
++ })(),
]);
-- return res.json({ vibes: vibes.part, opencoins: opencoins.part, state: { vibes: vibes.state, opencoins: opencoins.state } });
++ return res.json({
++ vibes: vibes.part, opencoins: opencoins.part, channel_points: points.part,
++ state: { vibes: vibes.state, opencoins: opencoins.state, channel_points: points.state },
++ });
});
// ── PUT /api/wallet/changes/read { currency, cursor } ────
@@@ -494,7 +519,14 @@ module.exports = function createWalletRoutes(db, requireAuth, opts = {}) {
return problem(res, 502, 'billing.unavailable', 'the read state is unavailable');
} catch { return problem(res, 502, 'billing.unavailable', 'the read state is unavailable'); }
}
-- if (currency !== 'opencoins') return problem(res, 422, 'wallet.invalid_currency', 'currency is vibes or opencoins');
++ if (currency === 'channel-points') {
++ try {
++ return res.json(await channelPoints.markRead(db, req.user.id, b.streamer, cursor));
++ } catch (e) {
++ return problem(res, e.status || 500, e.code || 'channel_points.unavailable', e.message);
++ }
++ }
++ if (currency !== 'opencoins') return problem(res, 422, 'wallet.invalid_currency', 'currency is vibes, opencoins or channel-points');
try {
return res.json(await coinChanges.markRead(db, req.user.id, { currency, cursor }));
} catch (e) {
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex 0166dd8f..d098f2e8 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand'], ['0072', 'composer_prefs', 'expand'], ['0073', 'card_actions', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand'], ['0072', 'composer_prefs', 'expand'], ['0073', 'card_actions', 'expand'], ['0074', 'channel_points_reads', 'expand']]);
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
ddiff --git a/test/coins-changes.test.js b/test/coins-changes.test.js
iindex b2acf11b..c99ad540 100644
---- a/test/coins-changes.test.js
++++ b/test/coins-changes.test.js
@@@ -131,7 +131,8 @@ const ISSUER = 'https://openvibe.network';
assert.strictEqual(body.opencoins.balance, 190, 'opencoins come from the local ledger');
assert.strictEqual(body.opencoins.moves.length, 2);
assert.deepStrictEqual(body.vibes.balance, { credit: 400, payable: 0 }, 'vibes come from Billing');
-- assert.deepStrictEqual(body.state, { vibes: 'open', opencoins: 'open' });
++ assert.deepStrictEqual(body.state, { vibes: 'open', opencoins: 'open', channel_points: 'needs_streamer' });
++ assert.strictEqual(body.channel_points, null, 'no channel named: no channel points');
assert.strictEqual(billingSeen.length, 1);
assert.strictEqual(billingSeen[0].auth, `Bearer ${annToken}`, "Billing is asked with the person's own token");
assert.match(billingSeen[0].url, /currency=vibes/);
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex b0deda67..38954001 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -111,6 +111,7 @@ const PATTERNS = {
// The reviewed list: where each goes, and why a stranger cannot choose it (or how it is guarded).
const REVIEWED = {
'server/admin/tools-workers.js fetch': [1, 'Owner-only fixed Tools admin paths at the operator-configured internal URL; no caller URL; redirects refused.'],
++ 'server/coins/channel-points.js fetch': [1, 'Live internal URL from operator config, fixed /internal/channel-points/changes path, user id in the encoded query, no caller-selected host, 2.5 s timeout'],
'server/link/egress-settings.js fetch': [2, 'browser-only GET and PUT to the fixed same-origin /api/v1/me/nodes/egress settings route; no caller-chosen host or URL'],
'server/connect/egress.js http.request': [1, 'Connect gateway (E7): only the app definition\'s fixed API host, through openvibe-shared/egress (IP literals refused before the socket, public DNS answers only, redirects followed by hand with every hop re-checked); never a URL from the input (Opus review 11-06)'],
'server/home/activity.js fetch': [1, 'front door activity: fixed first-party loopback URLs (OV_LIVE/COMMUNITY/BLOG_INTERNAL_URL, operator config) for public lists, never a request value'],
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex e80df42f..16320924 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -56,7 +56,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand'], ['0072', 'composer_prefs', 'expand'], ['0073', 'card_actions', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand'], ['0072', 'composer_prefs', 'expand'], ['0073', 'card_actions', 'expand'], ['0074', 'channel_points_reads', 'expand']]);
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
// 0020–0061 keep every row; 0062 moves the principal ids to lnk_ in place and references follow.
ddiff --git a/test/wallet-channel-points.test.js b/test/wallet-channel-points.test.js
nnew file mode 100644
iindex 00000000..e0f3a5e1
---- /dev/null
++++ b/test/wallet-channel-points.test.js
@@@ -0,0 +1,162 @@
++'use strict';
++// Ask 273, channel points: GET /api/wallet/changes?streamer= reads the person's channel points from
++// Live (their Network subject only), with the same gain/loss shape, cursor and read state as the
++// other currencies. Live is stubbed; its own route is covered by Live's channel-points-changes test.
++// node --import ./test/helpers/pg-preload.mjs test/wallet-channel-points.test.js
++const assert = require('assert');
++const crypto = require('crypto');
++const express = require('express');
++const jwt = require('jsonwebtoken');
++const { getDb } = require('../server/db/database');
++const subjects = require('../server/identity/subjects');
++const authRoutes = require('../server/auth/routes');
++const { makeRequireAuth } = require('../server/auth/session');
++const createWalletRoutes = require('../server/wallet/routes');
++
++const ISSUER = 'https://openvibe.network';
++
++(async () => {
++ console.log('wallet channel points');
++ const db = getDb();
++ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
++ const config = { jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };
++ const mk = async (name) => {
++ const id = (await db.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?) RETURNING id')
++ .run(name, `${name}@example.test`, 'x', 'user')).lastInsertRowid;
++ await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(Number(id)));
++ return db.prepare('SELECT * FROM users WHERE id = ?').get(Number(id));
++ };
++ const ann = await mk(`cpann${Date.now() % 100000}`);
++ const bob = await mk(`cpbob${Date.now() % 100000}`);
++ const tok = (u) => jwt.sign({ sub: u.id, id: u.id, subject_id: u.subject_id, username: u.username, role: 'user', auth_time: Math.floor(Date.now() / 1000) - 10, iat: Math.floor(Date.now() / 1000) - 10 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
++ const annToken = tok(ann);
++ const requireAuth = makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), authRoutes.signToken);
++
++ const ROWS = [
++ { id: 11, streamer_id: 7, at: '2026-11-01T10:00:00Z', delta: 100, reason: 'watch' },
++ { id: 12, streamer_id: 7, at: '2026-11-01T11:00:00Z', delta: -30, reason: 'redeem reward 9' },
++ { id: 13, streamer_id: 9, at: '2026-11-01T12:00:00Z', delta: 50, reason: 'watch' },
++ ];
++ const liveSeen = [];
++ let liveMode = 'ok';
++ const liveStub = express();
++ liveStub.get('/internal/channel-points/changes', (req, res) => {
++ liveSeen.push({ query: { ...req.query }, auth: req.headers.authorization });
++ if (liveMode === 'down') return res.status(503).json({ error: 'down' });
++ if (liveMode === 'unlinked') return res.status(404).json({ ok: false, error: 'no linked live account' });
++ const streamer = Number(req.query.streamer_id);
++ const since = Number(req.query.since_id || 0);
++ const limit = Math.min(Number(req.query.limit || 100), 500);
++ const rows = ROWS.filter((r) => r.streamer_id === streamer && r.id > since).slice(0, limit + 1);
++ const page = rows.slice(0, limit);
++ res.json({
++ ok: true, streamer_id: streamer, balance: page.reduce((n, r) => n + r.delta, 200),
++ moves: page.map((r) => ({ id: r.id, streamer_id: r.streamer_id, at: r.at, delta: r.delta, reason: r.reason })),
++ cursor: page.length ? page[page.length - 1].id : since,
++ next_since: rows.length > limit ? page[page.length - 1].id : null,
++ });
++ });
++ const liveServer = liveStub.listen(0, '127.0.0.1');
++ await new Promise((r) => liveServer.once('listening', r));
++
++ const billingStub = express();
++ billingStub.get('/api/me/balances/changes', (req, res) => res.json({ currency: 'vibes', balance: { credit: 0, payable: 0 }, since: null, moves: [], parts: { earned: 0, spent: 0, received: 0, sent: 0 }, net: 0, cursor: null, next_since: null, read_cursor: null }));
++ const billingServer = billingStub.listen(0, '127.0.0.1');
++ await new Promise((r) => billingServer.once('listening', r));
++
++ const app = express();
++ app.use('/api/wallet', createWalletRoutes(db, requireAuth, {
++ billingUrl: `http://127.0.0.1:${billingServer.address().port}`,
++ liveUrl: `http://127.0.0.1:${liveServer.address().port}`,
++ selfToken: () => 'tok-live',
++ }));
++ const server = app.listen(0, '127.0.0.1');
++ await new Promise((r) => server.once('listening', r));
++ const base = `http://127.0.0.1:${server.address().port}/api/wallet/changes`;
++ const get = async (token, q = '') => {
++ const r = await fetch(`${base}${q}`, { headers: token ? { Authorization: `Bearer ${token}` } : {} });
++ return { status: r.status, body: await r.json().catch(() => null) };
++ };
++ const putRead = async (token, body) => {
++ const r = await fetch(`${base}/read`, { method: 'PUT', headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
++ return { status: r.status, body: await r.json().catch(() => null) };
++ };
++
++ let failures = 0;
++ const check = async (name, fn) => {
++ try { await fn(); console.log(' ✓', name); }
++ catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 6).join('\n ')); }
++ };
++
++ await check('a gain and a loss on the channel, with plain words', async () => {
++ const { status, body } = await get(annToken, '?streamer=7');
++ assert.strictEqual(status, 200);
++ const cp = body.channel_points;
++ assert.strictEqual(cp.currency, 'channel-points');
++ assert.strictEqual(cp.streamer, 7);
++ assert.strictEqual(cp.balance, 270);
++ assert.strictEqual(cp.moves.length, 2);
++ assert.strictEqual(cp.moves[0].delta, 100);
++ assert.strictEqual(cp.moves[0].part, 'earned');
++ assert.ok(cp.moves[0].label, 'a plain-words reason rides along');
++ assert.strictEqual(cp.moves[1].part, 'spent');
++ assert.deepStrictEqual(cp.parts, { earned: 100, spent: -30, received: 0, sent: 0 });
++ assert.strictEqual(cp.net, 70);
++ assert.strictEqual(body.state.channel_points, 'open');
++ assert.strictEqual(liveSeen[0].auth, 'Bearer tok-live', 'Live gets the service token, never the person’s');
++ assert.strictEqual(liveSeen[0].query.network_user_id, ann.subject_id, 'Live is asked for the caller only');
++ });
++
++ await check('a cursor pages, and another channel is its own window', async () => {
++ const { body } = await get(annToken, '?streamer=7&limit=1');
++ assert.strictEqual(body.channel_points.moves.length, 1);
++ assert.strictEqual(body.channel_points.next_since, 11);
++ const rest = await get(annToken, '?streamer=7&since_channel_points=11');
++ assert.strictEqual(rest.body.channel_points.moves.length, 1);
++ assert.strictEqual(rest.body.channel_points.moves[0].delta, -30);
++ const other = await get(annToken, '?streamer=9');
++ assert.strictEqual(other.body.channel_points.moves.length, 1, 'channel 9 is a separate window');
++ assert.strictEqual((await get(annToken, '?streamer=x')).body.channel_points, null);
++ });
++
++ await check("another person's points are unreachable: Live is asked for the caller only", async () => {
++ const before = liveSeen.length;
++ await get(tok(bob), '?streamer=7');
++ assert.strictEqual(liveSeen.length, before + 1);
++ assert.strictEqual(liveSeen[liveSeen.length - 1].query.network_user_id, bob.subject_id);
++ assert.strictEqual((await get(null, '?streamer=7')).status, 401);
++ });
++
++ await check('read state: marking read empties "since I last looked", and it only moves forward', async () => {
++ const marked = await putRead(annToken, { currency: 'channel-points', streamer: 7, cursor: 12 });
++ assert.strictEqual(marked.status, 200);
++ assert.strictEqual(marked.body.read_cursor, 12);
++ const after = await get(annToken, '?streamer=7');
++ assert.strictEqual(after.body.channel_points.moves.length, 0, 'nothing since the look');
++ assert.strictEqual(after.body.channel_points.read_cursor, 12);
++ assert.strictEqual((await putRead(annToken, { currency: 'channel-points', streamer: 7, cursor: 11 })).status, 409, 'looking backwards is refused');
++ assert.strictEqual((await putRead(annToken, { currency: 'channel-points', streamer: 7, cursor: 'x' })).status, 422);
++ assert.strictEqual((await putRead(annToken, { currency: 'channel-points', cursor: 12 })).status, 422, 'marking needs its channel');
++ });
++
++ await check('Live down or unlinked nulls the part and says so; the rest still draws', async () => {
++ liveMode = 'down';
++ const down = await get(annToken, '?streamer=7');
++ assert.strictEqual(down.body.channel_points, null);
++ assert.strictEqual(down.body.state.channel_points, 'unavailable');
++ assert.notStrictEqual(down.body.opencoins, null);
++ liveMode = 'unlinked';
++ const unlinked = await get(annToken, '?streamer=7');
++ assert.strictEqual(unlinked.body.channel_points, null);
++ assert.strictEqual(unlinked.body.state.channel_points, 'unlinked');
++ liveMode = 'ok';
++ const none = await get(annToken);
++ assert.strictEqual(none.body.channel_points, null);
++ assert.strictEqual(none.body.state.channel_points, 'needs_streamer');
++ });
++
++ server.close(); liveServer.close(); billingServer.close();
++ if (failures) { console.log(`\n${failures} failed`); process.exit(1); }
++ console.log('all passed');
++ process.exit(0);
++})();