openvibers/openvibe.network Commits
Ask 273: OpenCoins gain/loss API with cursor, read state, balance.changed event (#801)
Changes
nnew file mode 100644@@@ -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)++);nnew file mode 100644@@@ -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 };@@@ -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@@@ -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, iidempotency_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();@@@ -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) {@@@ -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();@@@ -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.nnew file mode 100644@@@ -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);++})();@@@ -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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -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)
+);
new file mode 100644
new file mode 100644
@@ -0,0 +1,152 @@
@@ -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 };
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
const express = require('express');
const express = require('express');
const wallet = require('./wallet');
const wallet = require('./wallet');
+const coinChanges = require('./changes');
const money = require('../money/reasons');
const money = require('../money/reasons');
const config = require('../config');
const config = require('../config');
@@ -41,6 +42,34 @@ module.exports = function createCoinsRoutes(db, requireAuth, opts = {}) {
@@ -41,6 +42,34 @@ module.exports = function createCoinsRoutes(db, requireAuth, opts = {}) {
res.json({ balance, vibes: await vibesOf(req) });
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 ──────────
// ── GET /api/coins/me/history?limit=50&offset=0 ──────────
// → { transactions: [{ id, app_id, delta, reason, reason_text, reason_subject, reason_site, created_at }] }
// → { 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
// `reason` stays for support; no page prints it. `reason_text` is the phrase without the amount
@@ -13,6 +13,9 @@
@@ -13,6 +13,9 @@
// id <= N — which is how replays reconstruct the original result.
// 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 {
class WalletError extends Error {
constructor(status, body) {
constructor(status, body) {
super(body.error || 'wallet_error');
super(body.error || 'wallet_error');
@@ -21,6 +24,30 @@ class WalletError extends 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) {
function isPositiveInt(value) {
return Number.isInteger(value) && value > 0;
return Number.isInteger(value) && value > 0;
}
}
@@ -103,6 +130,7 @@ async function creditOrDebit(db, sign, { user_id, app_id, amount, reason, ref, i
@@ -103,6 +130,7 @@ async function creditOrDebit(db, sign, { user_id, app_id, amount, reason, ref, i
idempotency_key,
idempotency_key,
});
});
if (sign > 0) await require('../levels/coins').onCredit(db, { user_id: uid, app_id, delta: amount, reason, 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 { balance, deduped: false };
});
});
return run();
return run();
@@ -152,6 +180,8 @@ async function transfer(db, { from_user_id, to_user_id, app_id, amount, reason,
@@ -152,6 +180,8 @@ async function transfer(db, { from_user_id, to_user_id, app_id, amount, reason,
const to_balance = await applyDelta(db, {
const to_balance = await applyDelta(db, {
user_id: toId, app_id, delta: amount, reason, ref, idempotency_key: inKey,
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 { from_balance, to_balance, deduped: false };
});
});
return run();
return run();
@@ -408,6 +408,8 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
@@ -408,6 +408,8 @@ 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.
+ 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).
// 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) {
@@ -24,6 +24,7 @@
@@ -24,6 +24,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 money = require('../money/reasons');
const money = require('../money/reasons');
const config = require('../config');
const config = require('../config');
@@ -447,6 +448,60 @@ module.exports = function createWalletRoutes(db, requireAuth, opts = {}) {
@@ -447,6 +448,60 @@ module.exports = function createWalletRoutes(db, requireAuth, opts = {}) {
return res.json(out);
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 ────────────────────────────────
// ── GET /api/wallet/rates ────────────────────────────────
r.get('/rates', async (_req, res) => {
r.get('/rates', async (_req, res) => {
const body = await rates();
const body = await rates();
@@ -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']]);
+ 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.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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,204 @@
@@ -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);
+})();
@@ -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']]);
+ 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');
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.