openvibers/openvibe.network Commits
Ask 273: channel-points currency in the wallet changes API (#805)
Changes
nnew file mode 100644@@@ -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)++);@@@ -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",@@@ -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",nnew file mode 100644@@@ -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 };@@@ -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) {@@@ -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) {@@@ -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.@@@ -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/);@@@ -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'],@@@ -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.nnew file mode 100644@@@ -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);++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -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)
+);
@@ -22,7 +22,7 @@
@@ -22,7 +22,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0",
"otpauth": "9.6.0",
"otpauth": "9.6.0",
@@ -2679,9 +2679,9 @@
@@ -2679,9 +2679,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0",
"otpauth": "9.6.0",
"otpauth": "9.6.0",
new file mode 100644
new file mode 100644
@@ -0,0 +1,165 @@
@@ -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 };
@@ -416,8 +416,9 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
@@ -416,8 +416,9 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
count('opencoins_closed', bal);
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('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).
// The tombstones: the username is released, the rest cleared; the subject stays (never reused).
const cols = await columnsOf(db, 'users');
const cols = await columnsOf(db, 'users');
for (const id of userIds) {
for (const id of userIds) {
@@ -25,6 +25,7 @@
@@ -25,6 +25,7 @@
const express = require('express');
const express = require('express');
const wallet = require('../coins/wallet');
const wallet = require('../coins/wallet');
const coinChanges = require('../coins/changes');
const coinChanges = require('../coins/changes');
+const channelPoints = require('../coins/channel-points');
const money = require('../money/reasons');
const money = require('../money/reasons');
const config = require('../config');
const config = require('../config');
@@ -450,14 +451,20 @@ module.exports = function createWalletRoutes(db, requireAuth, opts = {}) {
@@ -450,14 +451,20 @@ module.exports = function createWalletRoutes(db, requireAuth, opts = {}) {
// ── GET /api/wallet/changes ──────────────────────────────
// ── GET /api/wallet/changes ──────────────────────────────
// Ask 273: one call for what changed in the person's balances since they last looked, per
// 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) => {
r.get('/changes', requireAuth, async (req, res) => {
cacheHeaders(res);
cacheHeaders(res);
const auth = personToken(req);
const auth = personToken(req);
const limit = req.query.limit;
const limit = req.query.limit;
- const [vibes, opencoins] = await Promise.all([
+ const [vibes, opencoins, points] = await Promise.all([
(async () => {
(async () => {
if (!auth || !billingUrl) return { part: null, state: 'unavailable' };
if (!auth || !billingUrl) return { part: null, state: 'unavailable' };
try {
try {
@@ -474,8 +481,26 @@ module.exports = function createWalletRoutes(db, requireAuth, opts = {}) {
@@ -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' };
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' }; }
} 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 } ────
// ── PUT /api/wallet/changes/read { currency, cursor } ────
@@ -494,7 +519,14 @@ module.exports = function createWalletRoutes(db, requireAuth, opts = {}) {
@@ -494,7 +519,14 @@ module.exports = function createWalletRoutes(db, requireAuth, opts = {}) {
return problem(res, 502, 'billing.unavailable', 'the read state is unavailable');
return problem(res, 502, 'billing.unavailable', 'the read state is unavailable');
} catch { 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 {
try {
return res.json(await coinChanges.markRead(db, req.user.id, { currency, cursor }));
return res.json(await coinChanges.markRead(db, req.user.id, { currency, cursor }));
} catch (e) {
} catch (e) {
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
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.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
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.
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
@@ -131,7 +131,8 @@ const ISSUER = 'https://openvibe.network';
@@ -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.balance, 190, 'opencoins come from the local ledger');
assert.strictEqual(body.opencoins.moves.length, 2);
assert.strictEqual(body.opencoins.moves.length, 2);
assert.deepStrictEqual(body.vibes.balance, { credit: 400, payable: 0 }, 'vibes come from Billing');
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.length, 1);
assert.strictEqual(billingSeen[0].auth, `Bearer ${annToken}`, "Billing is asked with the person's own token");
assert.strictEqual(billingSeen[0].auth, `Bearer ${annToken}`, "Billing is asked with the person's own token");
assert.match(billingSeen[0].url, /currency=vibes/);
assert.match(billingSeen[0].url, /currency=vibes/);
@@ -111,6 +111,7 @@ const PATTERNS = {
@@ -111,6 +111,7 @@ const PATTERNS = {
// The reviewed list: where each goes, and why a stranger cannot choose it (or how it is guarded).
// The reviewed list: where each goes, and why a stranger cannot choose it (or how it is guarded).
const REVIEWED = {
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/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/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/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'],
'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'],
@@ -56,7 +56,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -56,7 +56,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
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');
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
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.
// 0020–0061 keep every row; 0062 moves the principal ids to lnk_ in place and references follow.
new file mode 100644
new file mode 100644
@@ -0,0 +1,162 @@
@@ -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);
+})();