openvibers/openvibe.network Commits

Ask 273: OpenCoins gain/loss API with cursor, read state, balance.changed event (#801)

OP OpenVibers committed 2 days ago · 1984d5a5bd · 9 files, +484 −2

* Ask 273: OpenCoins gain/loss API with cursor, read state, balance.changed event

* Ask 273: list 0070 in the migration-sequence tests

Changes

ddiff --git a/migrations/0070_coin_balance_reads.sql b/migrations/0070_coin_balance_reads.sql
nnew file mode 100644
iindex 00000000..3fe54902
---- /dev/null
++++ b/migrations/0070_coin_balance_reads.sql
@@@ -0,0 +1,10 @@
++-- phase: expand
++-- Ask 273: the person's own read state for OpenCoins "what changed since I last looked".
++-- One row per person: the coin_transactions id cursor the bar has already shown.
++CREATE TABLE IF NOT EXISTS coin_balance_reads (
++ user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
++ currency text NOT NULL CHECK (currency IN ('opencoins')),
++ last_seen_tx bigint NOT NULL REFERENCES coin_transactions(id),
++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
++ PRIMARY KEY (user_id, currency)
++);
ddiff --git a/server/coins/changes.js b/server/coins/changes.js
nnew file mode 100644
iindex 00000000..a3d97673
---- /dev/null
++++ b/server/coins/changes.js
@@@ -0,0 +1,152 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// openvibe.network — OpenCoins "what changed since I last looked" (ask 273).
++//
++// changes(db, userId, { currency, since, limit })
++// → { currency, balance, since, moves, parts, net, cursor, next_since, read_cursor }
++//
++// One move per coin_transactions row of the person, oldest first: { id, at, delta, part, reason }.
++// `parts` sums the window's moves into earned / spent / received / sent and `net` is their total —
++// what the bar needs for a gain or a loss with its reason. `since` is a transaction 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 person's first move. `cursor` is the newest move seen (or the
++// head when the window is empty) and `next_since` pages the moves; parts and net always cover the
++// whole window. markRead() stores the read state (monotonic: looking backwards is refused).
++//
++// The buckets: earnings this ledger's reasons name (quests, fun, requests filled, merges and
++// migrations) arrive as earned, anything else incoming as received; money leaving for another
++// person or out of the system (tips, gifts, transfers, refunds, a deleted account's sweep) is
++// sent, money consumed (redemptions, spends) is spent. Every move keeps its raw reason, so the bar
++// always shows the ledger's own words beside the bucket.
++//
++// There is no subject parameter: the caller's user id is the only account read, so another
++// person's balance is unreachable.
++// ═══════════════════════════════════════════════════════════════
++
++const money = require('../money/reasons');
++
++const DEFAULT_LIMIT = 100;
++const MAX_LIMIT = 500;
++const WINDOW_TXN_CAP = 5000;
++
++/** Incoming earnings this ledger names, versus anything else arriving. */
++const EARNED_RE = /^(quest:|fun:|migration:|account_merge|Media request:)/;
++/** Outgoing to another person or out of the system, versus consumed. */
++const SENT_RE = /(tip|gift|transfer|sent|refund|account_deleted)/i;
++
++function partOf({ delta, reason }) {
++ const r = String(reason || '');
++ if (delta > 0) return EARNED_RE.test(r) ? 'earned' : 'received';
++ return SENT_RE.test(r) ? 'sent' : 'spent';
++}
++
++async function txnById(db, since) {
++ const id = Number(since);
++ if (!Number.isInteger(id) || id <= 0) throw badCursor();
++ const row = await db.prepare('SELECT id, created_at FROM coin_transactions WHERE id = ?').get(id);
++ if (!row) throw badCursor();
++ return row;
++}
++
++function badCursor() {
++ const e = new Error('since must be a transaction id from this list');
++ e.status = 422; e.code = 'coins.invalid_cursor';
++ return e;
++}
++
++function badCurrency() {
++ const e = new Error('currency is opencoins');
++ e.status = 422; e.code = 'coins.invalid_currency';
++ return e;
++}
++
++async function readCursor(db, userId, currency) {
++ const row = await db.prepare('SELECT last_seen_tx AS cursor FROM coin_balance_reads WHERE user_id = ? AND currency = ?').get(userId, currency);
++ return row ? Number(row.cursor) : null;
++}
++
++async function changes(db, userId, { currency = 'opencoins', since = null, limit = DEFAULT_LIMIT } = {}) {
++ if (currency !== 'opencoins') throw badCurrency();
++ 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 = await txnById(db, since);
++ } else {
++ const stored = await readCursor(db, userId, currency);
++ if (stored) start = await txnById(db, stored);
++ }
++
++ let where = 'WHERE user_id = ?';
++ const args = [userId];
++ if (start) {
++ where += ' AND (created_at, id) > (?, ?)';
++ args.push(start.created_at, start.id);
++ }
++ const ids = await db.prepare(`SELECT id FROM coin_transactions ${where} ORDER BY id ASC LIMIT ?`).all(...args, WINDOW_TXN_CAP + 1);
++ const truncated = ids.length > WINDOW_TXN_CAP;
++ const windowIds = (truncated ? ids.slice(0, WINDOW_TXN_CAP) : ids).map((r) => Number(r.id));
++ const { getBalance } = require('./wallet');
++ const balance = await getBalance(db, userId);
++ if (!windowIds.length) {
++ const head = await db.prepare('SELECT id FROM coin_transactions WHERE user_id = ? ORDER BY id DESC LIMIT 1').get(userId);
++ return {
++ currency, balance, since: start ? Number(start.id) : null, moves: [],
++ parts: { earned: 0, spent: 0, received: 0, sent: 0 }, net: 0,
++ cursor: head ? Number(head.id) : null, next_since: null, read_cursor: await readCursor(db, userId, currency),
++ };
++ }
++
++ const holes = windowIds.map(() => '?').join(',');
++ const rows = await db.prepare(`SELECT id, app_id, delta, reason, ref, created_at FROM coin_transactions
++ WHERE id IN (${holes}) ORDER BY id ASC`).all(...windowIds);
++
++ const parts = { earned: 0, spent: 0, received: 0, sent: 0 };
++ const moves = [];
++ for (const r of rows) {
++ const delta = Number(r.delta);
++ if (!Number.isSafeInteger(delta) || delta === 0) continue;
++ const part = partOf({ delta, reason: r.reason });
++ parts[part] += delta;
++ const d = money.describeRow(r);
++ moves.push({
++ id: Number(r.id), at: r.created_at, delta, part,
++ label: d.text, subject: d.subject, reason_key: d.key, reason: r.reason || null,
++ });
++ }
++ const net = parts.earned + parts.spent + parts.received + parts.sent;
++ const page = moves.slice(0, take);
++ return {
++ currency, balance,
++ since: start ? Number(start.id) : null,
++ moves: page,
++ parts, net,
++ cursor: moves.length ? moves[moves.length - 1].id : (start ? Number(start.id) : null),
++ next_since: moves.length > take ? page[page.length - 1].id : null,
++ read_cursor: await readCursor(db, userId, currency),
++ ...(truncated ? { truncated: true } : {}),
++ };
++}
++
++/** Store "I looked": the cursor must name a real transaction and may only move forward. */
++async function markRead(db, userId, { currency = 'opencoins', cursor } = {}) {
++ if (currency !== 'opencoins') throw badCurrency();
++ const at = await txnById(db, cursor);
++ const stored = await db.prepare('SELECT last_seen_tx AS cursor FROM coin_balance_reads WHERE user_id = ? AND currency = ?').get(userId, currency);
++ if (stored != null) {
++ const prev = await txnById(db, stored.cursor);
++ if (at.created_at < prev.created_at || (at.created_at === prev.created_at && at.id < prev.id)) {
++ const e = new Error('the read cursor only moves forward');
++ e.status = 409; e.code = 'coins.stale_read';
++ throw e;
++ }
++ }
++ await db.prepare(`INSERT INTO coin_balance_reads (user_id, currency, last_seen_tx, updated_at) VALUES (?, ?, ?, ov_now())
++ ON CONFLICT (user_id, currency) DO UPDATE SET last_seen_tx = EXCLUDED.last_seen_tx, updated_at = EXCLUDED.updated_at`)
++ .run(userId, currency, at.id);
++ return { currency, read_cursor: Number(at.id) };
++}
++
++module.exports = { changes, markRead };
ddiff --git a/server/coins/routes.js b/server/coins/routes.js
iindex ff70e44f..63fc7525 100644
---- a/server/coins/routes.js
++++ b/server/coins/routes.js
@@@ -9,6 +9,7 @@
const express = require('express');
const wallet = require('./wallet');
++const coinChanges = require('./changes');
const money = require('../money/reasons');
const config = require('../config');
@@@ -41,6 +42,34 @@ module.exports = function createCoinsRoutes(db, requireAuth, opts = {}) {
res.json({ balance, vibes: await vibesOf(req) });
});
++ // ── GET /api/coins/me/changes?currency=opencoins&since=<id>&limit=100 ──
++ // Ask 273: what changed in the person's OpenCoins since they last looked — the bar's gain or
++ // loss with its reason: moves oldest first with delta, part (earned/spent/received/sent) and
++ // plain-words reason; parts and net over the whole window; cursor/next_since for paging. An
++ // omitted since falls back to the person's stored read state. Their token, their rows only:
++ // there is no user parameter, so another person's balance is unreachable.
++ router.get('/me/changes', requireAuth, async (req, res) => {
++ try {
++ res.set('Cache-Control', 'private, no-store');
++ res.json(await coinChanges.changes(db, req.user.id, {
++ currency: req.query.currency || 'opencoins', since: req.query.since, limit: req.query.limit,
++ }));
++ } catch (e) {
++ res.status(e.status || 500).json({ error: e.code || 'coins.unavailable', message: e.message });
++ }
++ });
++
++ // ── PUT /api/coins/me/read { currency, cursor } ──
++ // Store "I looked" (monotonic: looking backwards is refused).
++ router.put('/me/read', requireAuth, express.json({ limit: '16kb' }), async (req, res) => {
++ try {
++ res.set('Cache-Control', 'private, no-store');
++ res.json(await coinChanges.markRead(db, req.user.id, { currency: (req.body || {}).currency || 'opencoins', cursor: (req.body || {}).cursor }));
++ } catch (e) {
++ res.status(e.status || 500).json({ error: e.code || 'coins.unavailable', message: e.message });
++ }
++ });
++
// ── GET /api/coins/me/history?limit=50&offset=0 ──────────
// → { transactions: [{ id, app_id, delta, reason, reason_text, reason_subject, reason_site, created_at }] }
// `reason` stays for support; no page prints it. `reason_text` is the phrase without the amount
ddiff --git a/server/coins/wallet.js b/server/coins/wallet.js
iindex db7ce8b6..595d34fa 100644
---- a/server/coins/wallet.js
++++ b/server/coins/wallet.js
@@@ -13,6 +13,9 @@
// id <= N — which is how replays reconstruct the original result.
// ═══════════════════════════════════════════════════════════════
++const { ids, validate } = require('openvibe-contracts');
++const eventRelay = require('../developer/event-relay');
++
class WalletError extends Error {
constructor(status, body) {
super(body.error || 'wallet_error');
@@@ -21,6 +24,30 @@ class WalletError extends Error {
}
}
++/**
++ * Ask 273: one subject-visible network.balance.changed per person whose OpenCoins moved, enqueued
++ * in the same transaction as the movement (relayed by server/developer/event-relay.js, at least
++ * once), so an open page updates without a reload. Guests and accounts without a usr_ subject get
++ * no event — Events could not address them. The payload is what the bar needs, never what it
++ * shows: delta, balance, and the ledger's own reason.
++ */
++async function announceBalanceChange(db, { user_id, delta, balance, reason, app_id, ref }) {
++ const u = await db.prepare('SELECT subject_id FROM users WHERE id = ?').get(user_id);
++ const subjectId = u && u.subject_id;
++ if (typeof subjectId !== 'string' || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subjectId)) return null;
++ const ms = Date.now();
++ const env = {
++ event_id: ids.newId('event', ms), event_type: 'network.balance.changed', version: 1, source: 'network',
++ actor: { type: 'service', id: 'network' }, timestamp: new Date(ms).toISOString(),
++ visibility: 'subject', subject: { type: 'user', id: subjectId },
++ payload: { currency: 'opencoins', delta, balance, reason: reason || null, app_id: app_id || null, ref: ref || null },
++ };
++ const v = validate('events.event-envelope@1', env);
++ if (!v.valid) throw new Error(`coins: bad balance.changed envelope: ${v.errors.map((e) => `${e.path} ${e.message}`).join('; ')}`);
++ await eventRelay.writerFor(db).enqueue(env);
++ return env;
++}
++
function isPositiveInt(value) {
return Number.isInteger(value) && value > 0;
}
@@@ -103,6 +130,7 @@ async function creditOrDebit(db, sign, { user_id, app_id, amount, reason, ref, i
idempotency_key,
});
if (sign > 0) await require('../levels/coins').onCredit(db, { user_id: uid, app_id, delta: amount, reason, idempotency_key });
++ await announceBalanceChange(db, { user_id: uid, delta: sign * amount, balance, reason, app_id, ref });
return { balance, deduped: false };
});
return run();
@@@ -152,6 +180,8 @@ async function transfer(db, { from_user_id, to_user_id, app_id, amount, reason,
const to_balance = await applyDelta(db, {
user_id: toId, app_id, delta: amount, reason, ref, idempotency_key: inKey,
});
++ await announceBalanceChange(db, { user_id: fromId, delta: -amount, balance: from_balance, reason, app_id, ref });
++ await announceBalanceChange(db, { user_id: toId, delta: amount, balance: to_balance, reason, app_id, ref });
return { from_balance, to_balance, deduped: false };
});
return run();
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex da9fd407..8b7a2d72 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -408,6 +408,8 @@ 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.
++ await del('coin_balance_reads', `user_id IN ${inU}`, userIds, 'coin_balance_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 26ed4fca..55568422 100644
---- a/server/wallet/routes.js
++++ b/server/wallet/routes.js
@@@ -24,6 +24,7 @@
const express = require('express');
const wallet = require('../coins/wallet');
++const coinChanges = require('../coins/changes');
const money = require('../money/reasons');
const config = require('../config');
@@@ -447,6 +448,60 @@ module.exports = function createWalletRoutes(db, requireAuth, opts = {}) {
return res.json(out);
});
++ // ── 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.
++ r.get('/changes', requireAuth, async (req, res) => {
++ cacheHeaders(res);
++ const auth = personToken(req);
++ const limit = req.query.limit;
++ const [vibes, opencoins] = await Promise.all([
++ (async () => {
++ if (!auth || !billingUrl) return { part: null, state: 'unavailable' };
++ try {
++ const qs = new URLSearchParams({ currency: 'vibes', ...(limit != null ? { limit: String(limit) } : {}),
++ ...(req.query.since_vibes != null && req.query.since_vibes !== '' ? { since: String(req.query.since_vibes) } : {}) });
++ const out = await ask(`${billingUrl}/api/me/balances/changes?${qs}`, { authorization: auth });
++ if (out.status === 200 && out.body) return { part: out.body, state: 'open' };
++ if (out.status === 409) return { part: null, state: 'not_authoritative' };
++ return { part: null, state: 'unavailable' };
++ } catch { return { part: null, state: 'unavailable' }; }
++ })(),
++ (async () => {
++ try {
++ 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' }; }
++ })(),
++ ]);
++ return res.json({ vibes: vibes.part, opencoins: opencoins.part, state: { vibes: vibes.state, opencoins: opencoins.state } });
++ });
++
++ // ── PUT /api/wallet/changes/read { currency, cursor } ────
++ // "I looked", per currency: vibes is stored by Billing (the person's token, forwarded),
++ // opencoins here. A write needs the page's bearer token, like every money write.
++ r.put('/changes/read', requireAuth, bearerOnly, json, async (req, res) => {
++ cacheHeaders(res);
++ const b = req.body || {};
++ const currency = String(b.currency || 'opencoins');
++ const cursor = b.cursor;
++ if (currency === 'vibes') {
++ if (!billingUrl) return problem(res, 502, 'billing.unavailable', 'Billing is not configured');
++ try {
++ const out = await ask(`${billingUrl}/api/me/balances/read`, { authorization: personToken(req), method: 'PUT', body: { currency, cursor } });
++ if (out && out.body && typeof out.body === 'object') return res.status(out.status).json(out.body);
++ 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');
++ try {
++ return res.json(await coinChanges.markRead(db, req.user.id, { currency, cursor }));
++ } catch (e) {
++ return problem(res, e.status || 500, e.code || 'coins.unavailable', e.message);
++ }
++ });
++
// ── GET /api/wallet/rates ────────────────────────────────
r.get('/rates', async (_req, res) => {
const body = await rates();
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex 8173c114..2345dfa8 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']]);
++ 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']]);
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
nnew file mode 100644
iindex 00000000..b2acf11b
---- /dev/null
++++ b/test/coins-changes.test.js
@@@ -0,0 +1,204 @@
++'use strict';
++// Ask 273, Network part: GET /api/coins/me/changes (a gain, a loss, a cursor, another person's
++// balance refused) and PUT /api/coins/me/read (the person's own read state), plus the
++// network.balance.changed subject-visible message a wallet move enqueues for an open page.
++// node --import ./test/helpers/pg-preload.mjs test/coins-changes.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 createCoinsRoutes = require('../server/coins/routes');
++const createWalletRoutes = require('../server/wallet/routes');
++const wallet = require('../server/coins/wallet');
++
++const ISSUER = 'https://openvibe.network';
++
++(async () => {
++ console.log('coins changes');
++ 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(`ann${Date.now() % 100000}`);
++ const bob = await mk(`bob${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 bobToken = tok(bob);
++ const requireAuth = makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), authRoutes.signToken);
++
++ const billingSeen = [];
++ let billingMode = 'ok';
++ const billingStub = express();
++ billingStub.use(express.json());
++ billingStub.get('/api/me/balances/changes', (req, res) => {
++ billingSeen.push({ url: req.url, auth: req.headers.authorization });
++ if (billingMode === 'down') return res.status(503).json({ error: 'down' });
++ if (billingMode === 'shadow') return res.status(409).json({ code: 'billing.not_authoritative' });
++ res.json({ currency: 'vibes', balance: { credit: 400, payable: 0 }, since: null, moves: [], parts: { earned: 0, spent: 0, received: 0, sent: 0 }, net: 0, cursor: 'txn_stub', next_since: null, read_cursor: null });
++ });
++ billingStub.put('/api/me/balances/read', (req, res) => {
++ billingSeen.push({ url: req.url, auth: req.headers.authorization, body: req.body });
++ res.json({ currency: 'vibes', read_cursor: req.body && req.body.cursor });
++ });
++ const billingServer = billingStub.listen(0, '127.0.0.1');
++ await new Promise((r) => billingServer.once('listening', r));
++
++ const app = express();
++ app.use('/api/coins', createCoinsRoutes(db, requireAuth, {}));
++ app.use('/api/wallet', createWalletRoutes(db, requireAuth, { billingUrl: `http://127.0.0.1:${billingServer.address().port}` }));
++ 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}`;
++ const get = async (token, q = '') => {
++ const r = await fetch(`${base}/api/coins/me/changes${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}/api/coins/me/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) };
++ };
++
++ const credited = await wallet.credit(db, { user_id: ann.id, app_id: 'quest', amount: 250, reason: 'quest:publish-video', idempotency_key: `k-${Date.now()}-1` });
++ assert.strictEqual(credited.balance, 250);
++ const moved = await wallet.transfer(db, { from_user_id: ann.id, to_user_id: bob.id, app_id: 'live', amount: 60, reason: 'tip to @bob', idempotency_key: `k-${Date.now()}-2` });
++ assert.strictEqual(moved.from_balance, 190);
++
++ 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: quest earnings arrive earned with their plain words', async () => {
++ const { status, body } = await get(annToken);
++ assert.strictEqual(status, 200);
++ assert.strictEqual(body.currency, 'opencoins');
++ assert.strictEqual(body.balance, 190);
++ assert.strictEqual(body.moves.length, 2);
++ assert.strictEqual(body.moves[0].delta, 250);
++ assert.strictEqual(body.moves[0].part, 'earned');
++ assert.ok(body.moves[0].label, 'a plain-words reason rides along');
++ assert.ok(body.moves[0].reason_key, 'and the ledger key for support');
++ assert.deepStrictEqual(body.parts, { earned: 250, spent: 0, received: 0, sent: -60 });
++ assert.strictEqual(body.net, 190);
++ assert.strictEqual(body.next_since, null);
++ });
++
++ await check("a loss: the tip leaves as sent, and the other side receives it", async () => {
++ const { body } = await get(annToken);
++ assert.strictEqual(body.moves[1].delta, -60);
++ assert.strictEqual(body.moves[1].part, 'sent');
++ const seen = (await get(bobToken)).body;
++ assert.strictEqual(seen.moves.length, 1);
++ assert.strictEqual(seen.moves[0].delta, 60);
++ assert.strictEqual(seen.balance, 60);
++ });
++
++ let firstId = null;
++ await check('a cursor: since replays only what came after', async () => {
++ const first = (await get(annToken)).body.moves[0].id;
++ firstId = first;
++ const { status, body } = await get(annToken, `?since=${first}`);
++ assert.strictEqual(status, 200);
++ assert.strictEqual(body.since, first);
++ assert.strictEqual(body.moves.length, 1);
++ assert.strictEqual(body.moves[0].delta, -60);
++ assert.deepStrictEqual(body.parts, { earned: 0, spent: 0, received: 0, sent: -60 });
++ assert.strictEqual((await get(annToken, '?since=999999999')).status, 422, 'a made-up cursor is refused');
++ });
++
++ await check("another person's balance is refused: no user parameter, tokens only open their own", async () => {
++ assert.strictEqual((await get(null)).status, 401);
++ assert.strictEqual((await get('bogus')).status, 401);
++ const seen = (await get(bobToken)).body.moves;
++ assert.ok(seen.every((m) => m.delta === 60), 'bob never sees ann’s quest earnings');
++ assert.strictEqual((await get(bobToken, '?currency=coins')).status, 422);
++ });
++
++ await check('one call for every currency: the wallet combines Billing vibes with local opencoins', async () => {
++ const r = await fetch(`${base}/api/wallet/changes`, { headers: { Authorization: `Bearer ${annToken}` } });
++ assert.strictEqual(r.status, 200);
++ const body = await r.json();
++ 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.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/);
++
++ const paged = await (await fetch(`${base}/api/wallet/changes?since_vibes=txn_zzz&since_opencoins=${firstId}&limit=1`, { headers: { Authorization: `Bearer ${annToken}` } })).json();
++ assert.strictEqual(paged.opencoins.moves.length, 1, 'per-currency cursors page each part');
++ assert.ok(billingSeen[billingSeen.length - 1].url.includes('since=txn_zzz'), 'the vibes cursor is forwarded');
++
++ billingMode = 'down';
++ const down = await (await fetch(`${base}/api/wallet/changes`, { headers: { Authorization: `Bearer ${annToken}` } })).json();
++ assert.strictEqual(down.vibes, null, 'Billing down: no vibes, the coins still answer');
++ assert.strictEqual(down.state.vibes, 'unavailable');
++ assert.strictEqual(down.opencoins.balance, 190);
++ billingMode = 'shadow';
++ const shadow = await (await fetch(`${base}/api/wallet/changes`, { headers: { Authorization: `Bearer ${annToken}` } })).json();
++ assert.strictEqual(shadow.state.vibes, 'not_authoritative', 'Billing in shadow names its state');
++ billingMode = 'ok';
++
++ const anon = await fetch(`${base}/api/wallet/changes`);
++ assert.strictEqual(anon.status, 401, 'no token: no balances');
++ });
++
++ await check('read-marking routes per currency: vibes to Billing, opencoins here', async () => {
++ const coinsMark = await putRead(annToken, { currency: 'vibes', cursor: firstId });
++ assert.strictEqual(coinsMark.status, 422, 'the coins router only marks opencoins');
++ const r = await fetch(`${base}/api/wallet/changes/read`, { method: 'PUT', headers: { Authorization: `Bearer ${annToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ currency: 'vibes', cursor: 'txn_abc' }) });
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual((await r.json()).read_cursor, 'txn_abc');
++ const lastSeen = billingSeen[billingSeen.length - 1];
++ assert.strictEqual(lastSeen.url, '/api/me/balances/read');
++ assert.strictEqual(lastSeen.auth, `Bearer ${annToken}`);
++ assert.deepStrictEqual(lastSeen.body, { currency: 'vibes', cursor: 'txn_abc' });
++ const local = await (await fetch(`${base}/api/wallet/changes/read`, { method: 'PUT', headers: { Authorization: `Bearer ${annToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ currency: 'opencoins', cursor: firstId }) })).json();
++ assert.strictEqual(local.read_cursor, firstId);
++ const bad = await fetch(`${base}/api/wallet/changes/read`, { method: 'PUT', headers: { Authorization: `Bearer ${annToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ currency: 'stars', cursor: firstId }) });
++ assert.strictEqual(bad.status, 422);
++ });
++
++ await check('read state: marking read empties "since I last looked", and it only moves forward', async () => {
++ const last = (await get(annToken)).body.cursor;
++ const marked = await putRead(annToken, { currency: 'opencoins', cursor: last });
++ assert.strictEqual(marked.status, 200);
++ assert.strictEqual(marked.body.read_cursor, last);
++ const after = await get(annToken);
++ assert.strictEqual(after.body.moves.length, 0, 'nothing since the look');
++ assert.strictEqual(after.body.read_cursor, last);
++ assert.strictEqual((await putRead(annToken, { currency: 'opencoins', cursor: firstId })).status, 409, 'looking backwards is refused');
++ assert.strictEqual((await putRead(annToken, { currency: 'opencoins', cursor: 999999999 })).status, 422);
++ assert.strictEqual((await putRead(null, { currency: 'opencoins', cursor: last })).status, 401);
++ });
++
++ await check('a wallet move enqueues network.balance.changed, subject-visible, per person', async () => {
++ const rows = await db.prepare("SELECT envelope FROM network_event_outbox WHERE envelope->>'event_type' = 'network.balance.changed' ORDER BY id").all();
++ const events = rows.map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope));
++ assert.strictEqual(events.length, 3, `credit + transfer both sides, got ${events.length}`);
++ for (const e of events) {
++ assert.strictEqual(e.visibility, 'subject', 'only the person sees their own over realtime');
++ assert.strictEqual(e.subject.type, 'user');
++ assert.strictEqual(e.payload.currency, 'opencoins');
++ }
++ assert.deepStrictEqual(events.filter((e) => e.subject.id === ann.subject_id).map((e) => e.payload.delta), [250, -60]);
++ assert.deepStrictEqual(events.filter((e) => e.subject.id === bob.subject_id).map((e) => e.payload.delta), [60]);
++ });
++
++ server.close();
++ billingServer.close();
++ if (failures) { console.log(`\n${failures} failed`); process.exit(1); }
++ console.log('all passed');
++ process.exit(0);
++})();
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex 01515d0d..bdca6d52 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']]);
++ 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']]);
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.