openvibers/openvibe.billing Commits

Staff console: Network SSO (authorization code + PKCE S256, client billing) for Network admins listed in BILLING_STAFF_SUBJECTS; server-rendered, no JS, noindex, no-store, strict CSP; host-only HttpOnly Secure SameSite=Strict short session + CSRF on every form

OP OpenVibers committed 23 Sept 2026 · fbbb0f3688 · 13 files, +1491 −7

Cashout queue and detail (approve needs the provider payout reference, a confirmation and the end of the escrow; deny needs a reason), receipts needing review (flagged, rejected incl. intent_settled_in_live, unprocessed, reversals for review; never payloads), import holds, reconciliation run + history, freeze/unfreeze with a reason, audit log. A staff session maps server-side to billing.cashout.manage + billing.ledger.admin and every action goes through the ops functions the API uses behind the same freeze guard. Every staff action and every refused attempt is a row in the append-only staff_audit table; done actions are published as billing.staff.action (internal) through the outbox in the same transaction.

Changes

ddiff --git a/server/app.js b/server/app.js
iindex 20ca13c..4d4cc77 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -7,6 +7,7 @@
* GET /api/health, /api/ready liveness / readiness
* /api/v1/* operations API (service tokens, see api/v1.js)
* /webhooks/<provider> provider receipts (see api/webhooks.js)
++ * /, /auth/*, /cashouts, … staff console (Network SSO, server-rendered; see console/index.js)
*
* createApp({ config, db, keys, identity, adapters, now, fetchImpl, log }) — everything injectable.
*/
@@@ -19,6 +20,7 @@ const { createKeyProvider, createIdentity } = require('./network');
const { createAuth } = require('./api/auth');
const { v1Router } = require('./api/v1');
const { webhooksRouter } = require('./api/webhooks');
++const { consoleRouter } = require('./console');
const providers = require('./providers');
const { isFrozen } = require('./ops/common');
@@@ -57,7 +59,8 @@ function createApp(opts = {}) {
app.use('/webhooks', webhooksRouter({ ctx, adapters }));
app.use('/api/v1', express.json({ limit: '64kb' }), v1Router({ ctx, auth, adapters }));
-- app.get('/', (req, res) => res.type('text/plain').send('OpenVibe.Billing — the OpenVibe money ledger. Service API only; see https://github.com/OpenVibers/OpenVibe.Billing\n'));
++ app.get('/robots.txt', (req, res) => res.type('text/plain').send('User-agent: *\nDisallow: /\n'));
++ app.use(consoleRouter({ ctx, adapters, keys, fetchImpl }));
app.use((req, res) => http.sendProblem(res, 404, 'not_found', { ctx: req.ov }));
// Malformed JSON and other body-parser errors.
// eslint-disable-next-line no-unused-vars
ddiff --git a/server/config.js b/server/config.js
iindex c4621be..3a29754 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -37,17 +37,27 @@ function parseTiers(raw) {
return [...tiers].sort((a, b) => b.min - a.min);
}
++// Canonical user subject ids (usr_ + ULID). Anything else in BILLING_STAFF_SUBJECTS is ignored
++// (and reported at boot) — a typo must never widen access, and must not stop the money API.
++const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++function parseStaff(raw) {
++ const items = String(raw || '').split(',').map((s) => s.trim()).filter(Boolean);
++ return { subjects: [...new Set(items.filter((s) => USER_SUBJECT_RE.test(s)))], invalid: items.filter((s) => !USER_SUBJECT_RE.test(s)) };
++}
++
function loadConfig(env = process.env) {
const nodeEnv = env.NODE_ENV || 'development';
const isProduction = nodeEnv === 'production';
const port = int(env.PORT, 4600);
const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
++ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://billing.openvibe.network' : `http://localhost:${port}`));
++ const staff = parseStaff(env.BILLING_STAFF_SUBJECTS);
return {
nodeEnv,
isProduction,
port,
host: env.HOST || '127.0.0.1',
-- baseUrl: trim(env.BASE_URL || (isProduction ? 'https://billing.openvibe.network' : `http://localhost:${port}`)),
++ baseUrl,
trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 1,
dbPath: env.BILLING_DB_PATH || './data/billing.db',
@@@ -123,7 +133,25 @@ function loadConfig(env = process.env) {
sweepIntervalMs: int(env.BILLING_SWEEP_INTERVAL_MS, 60 * 60 * 1000),
webhookRetryMs: int(env.BILLING_WEBHOOK_RETRY_MS, 60 * 1000),
},
++
++ // Staff console (server/console): Network SSO (authorization code + PKCE S256, OAuth
++ // client `billing`), only for Network admins listed in BILLING_STAFF_SUBJECTS.
++ console: {
++ staffSubjects: staff.subjects,
++ invalidStaffSubjects: staff.invalid,
++ // Signs the sign-in flow cookie and keys the IP hashes in staff_audit. Required in
++ // production (the console answers 503 without it); tests/dev get an ephemeral one.
++ sessionSecret: env.BILLING_SESSION_SECRET || '',
++ sessionTtlMin: Math.max(5, Math.min(12 * 60, int(env.BILLING_SESSION_TTL_MIN, 60))),
++ redirectUri: `${baseUrl}/auth/callback`,
++ // Secure cookies whenever the console is served over https (always in production).
++ cookieSecure: baseUrl.startsWith('https://'),
++ // Audience a Network user token must carry (Network user tokens are not minted for
++ // openvibe.billing; the Network's own audience is always present).
++ ssoAudience: env.BILLING_SSO_AUDIENCE || 'openvibe.network',
++ staffRole: 'admin',
++ },
};
}
--module.exports = { loadConfig, DEFAULT_PRICE_TIERS };
++module.exports = { loadConfig, DEFAULT_PRICE_TIERS, USER_SUBJECT_RE };
ddiff --git a/server/console/audit.js b/server/console/audit.js
nnew file mode 100644
iindex 0000000..88c1ef2
---- /dev/null
++++ b/server/console/audit.js
@@@ -0,0 +1,47 @@
++'use strict';
++
++/**
++ * staff_audit: one append-only row per staff action (and per refused one). A done action is also
++ * announced as `billing.staff.action` (visibility internal) through the outbox, in the same SQLite
++ * transaction as the row — and, for money actions, as the effect itself (callers wrap the ops call
++ * and record() in one db.transaction).
++ *
++ * The row holds no secrets: actor subject, action, target, reason, a small whitelisted detail
++ * (codes, payout reference, counts), the request id and a keyed hash of the client address.
++ */
++const { iso, prefixedId } = require('../ledger');
++const { enqueue } = require('../outbox');
++
++function record(ctx, { actor, action, target, reason, outcome = 'done', detail, requestId, ipHash, traceId }) {
++ const ms = ctx.now();
++ const id = prefixedId('sa', ms);
++ const row = {
++ id, at: iso(ms), actor_subject: actor && actor.subject ? actor.subject : null, actor_username: actor && actor.username ? actor.username : null,
++ action, target_type: target ? target.type : null, target_id: target ? String(target.id) : null,
++ reason: reason ? String(reason).slice(0, 500) : null, outcome, detail: JSON.stringify(detail || {}),
++ request_id: requestId || null, ip_hash: ipHash || null,
++ };
++ ctx.db.prepare(`INSERT INTO staff_audit (id, at, actor_subject, actor_username, action, target_type, target_id, reason, outcome, detail, request_id, ip_hash)
++ VALUES (@id, @at, @actor_subject, @actor_username, @action, @target_type, @target_id, @reason, @outcome, @detail, @request_id, @ip_hash)`).run(row);
++ if (outcome === 'done' && row.actor_subject) {
++ enqueue(ctx, {
++ event_type: 'billing.staff.action',
++ subject: { type: 'staff_action', id },
++ actor: { type: 'user', id: row.actor_subject },
++ traceId,
++ payload: {
++ audit_id: id, action, outcome,
++ target: target ? { type: target.type, id: String(target.id) } : null,
++ reason: row.reason, request_id: row.request_id, detail: detail || {},
++ },
++ });
++ }
++ return row;
++}
++
++function list(db, { limit = 200 } = {}) {
++ return db.prepare('SELECT * FROM staff_audit ORDER BY seq DESC LIMIT ?').all(Math.min(500, limit))
++ .map((r) => ({ ...r, detail: JSON.parse(r.detail || '{}') }));
++}
++
++module.exports = { record, list };
ddiff --git a/server/console/index.js b/server/console/index.js
nnew file mode 100644
iindex 0000000..ff2f033
---- /dev/null
++++ b/server/console/index.js
@@@ -0,0 +1,352 @@
++'use strict';
++
++/**
++ * The Billing staff console — server-rendered, no JavaScript, at the root of
++ * billing.openvibe.network. It replaces Live's cashout admin once Live runs with
++ * BILLING_AUTHORITY=billing (docs/live-cutover.md).
++ *
++ * GET /auth/login, /auth/callback Network SSO (authorization code + PKCE S256, client `billing`)
++ * POST /auth/logout
++ * GET / dashboard: freeze state, last reconciliation, outstanding totals
++ * GET /cashouts?tab=escrow|ready|paid|denied, /cashouts/:id
++ * POST /cashouts/:id/approve payout_reference + payout_provider + confirm (ops/cashouts.approve)
++ * POST /cashouts/:id/deny reason (ops/cashouts.deny)
++ * GET /receipts provider receipts needing review; POST /receipts/:id/reprocess
++ * GET /import-holds
++ * GET /reconciliation, /reconciliation/:id; POST /reconciliation (reconcile.js)
++ * GET /freeze; POST /freeze on/off + reason (ops/admin.setFreeze, then the held webhooks)
++ * GET /audit the staff_audit log
++ *
++ * Who: a Network user whose token says role `admin` AND whose subject is listed in
++ * BILLING_STAFF_SUBJECTS — checked at sign-in, and the list again on every request. Everyone else
++ * is refused (403) and no session is created.
++ *
++ * What a staff session may do is decided by the same capability ids the API checks: a staff
++ * session is the principal { sub: <usr_…>, cap: [billing.cashout.manage, billing.ledger.admin] }
++ * and each route names the one capability it needs. Every change goes through the same functions
++ * the API calls (server/ops/*, reconcile.js, providers.reprocess/processPending) with the same
++ * freeze guard; nothing here moves money on its own. Each staff action writes one staff_audit row
++ * and one billing.staff.action outbox event, in the same SQLite transaction as its effect.
++ */
++const crypto = require('crypto');
++const express = require('express');
++const { capabilities } = require('openvibe-contracts');
++const { BillingError, balance } = require('../ledger');
++const { A, assertNotFrozen, isFrozen, text } = require('../ops/common');
++const cashouts = require('../ops/cashouts');
++const admin = require('../ops/admin');
++const providers = require('../providers');
++const { reconcile } = require('../reconcile');
++const { CAP } = require('../api/v1');
++const { createSessions, sameString, random } = require('./session');
++const sso = require('./sso');
++const audit = require('./audit');
++const q = require('./queries');
++const { CSP, pages } = require('./views');
++
++/** Server-side mapping: what a staff session holds, in the API's own capability ids. */
++const STAFF_CAPABILITIES = Object.freeze([CAP.cashoutManage, CAP.admin]);
++const PAYOUT_PROVIDERS = ['paypal', 'wise', 'bank', 'crypto', 'other'];
++const ACTION_KEY_RE = /^[A-Za-z0-9_-]{16,64}$/;
++
++const NOTICES = {
++ approved: 'Payout recorded: the cashout is paid.',
++ already_paid: 'This cashout was already paid with that reference — nothing changed.',
++ denied: 'Cashout denied: the amount is back in the creator\'s payable.',
++ already_denied: 'This cashout was already denied — nothing changed.',
++ frozen: 'The economy is frozen.',
++ unfrozen: 'The economy is open again.',
++ reprocessed: 'The receipt was reprocessed.',
++ reconciled: 'Reconciliation finished.',
++};
++
++function sanitizeNext(v) {
++ const s = String(v || '');
++ if (s.length > 300 || !/^\/(?![/\\])/.test(s) || s.startsWith('/auth/')) return '/';
++ return s;
++}
++
++function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
++ const { db, config } = ctx;
++ const cc = config.console;
++ const log = ctx.log || console;
++ const r = express.Router();
++ const baseOrigin = (() => { try { return new URL(config.baseUrl).origin; } catch { return null; } })();
++
++ let secret = cc.sessionSecret;
++ let unavailable = null;
++ if (!secret || secret.length < 32) {
++ if (config.isProduction) unavailable = 'BILLING_SESSION_SECRET (at least 32 characters) is not set';
++ else { secret = crypto.randomBytes(32).toString('hex'); if (config.nodeEnv !== 'test') log.warn('[Billing] console: BILLING_SESSION_SECRET unset — using an ephemeral secret (development only)'); }
++ }
++ if (!config.oauth.clientSecret) unavailable = unavailable || 'OV_OAUTH_CLIENT_SECRET is not set';
++ if (cc.invalidStaffSubjects.length) log.warn(`[Billing] console: ignoring ${cc.invalidStaffSubjects.length} malformed BILLING_STAFF_SUBJECTS entr${cc.invalidStaffSubjects.length === 1 ? 'y' : 'ies'} (usr_ + ULID expected)`);
++ if (!cc.staffSubjects.length && config.nodeEnv !== 'test') log.warn('[Billing] console: BILLING_STAFF_SUBJECTS is empty — nobody can sign in');
++ if (unavailable) log.warn(`[Billing] console disabled: ${unavailable}`);
++ const sessions = createSessions(ctx, { secret: secret || 'unavailable' });
++
++ const send = (res, status, htmlText) => res.status(status).type('html').send(htmlText);
++ const ipHash = (req) => sessions.ipHash(req.ip);
++ const requestId = (req) => (req.ov ? req.ov.requestId : null);
++ const trace = (req) => (req.ov ? req.ov.traceId : undefined);
++ const actorOf = (req) => ({ principal: req.staff.subject, via: 'staff-console', username: req.staff.username || undefined, request_id: requestId(req) });
++ const record = (req, fields) => audit.record(ctx, {
++ actor: req.staff ? { subject: req.staff.subject, username: req.staff.username } : fields.actor,
++ requestId: requestId(req), ipHash: ipHash(req), traceId: trace(req), ...fields,
++ });
++ const common = (req) => ({ staff: req.staff, csrf: req.staff ? req.staff.csrf : '', frozen: isFrozen(db) });
++
++ // ── Every console response ───────────────────────────────
++ r.use((req, res, next) => {
++ res.setHeader('Content-Security-Policy', CSP);
++ res.setHeader('X-Frame-Options', 'DENY');
++ res.setHeader('Referrer-Policy', 'no-referrer');
++ res.setHeader('X-Robots-Tag', 'noindex, nofollow');
++ res.setHeader('X-Content-Type-Options', 'nosniff');
++ res.setHeader('Cache-Control', 'no-store');
++ if (unavailable) return send(res, 503, pages.message({ title: 'Console unavailable', text: 'The staff console is not configured on this host. The operator must set its environment (see .env.example).' }));
++ return next();
++ });
++ r.use(express.urlencoded({ extended: false, limit: '16kb', parameterLimit: 50 }));
++
++ // ── Session → req.staff ──────────────────────────────────
++ r.use((req, res, next) => {
++ req.staff = null;
++ const row = sessions.read(req);
++ if (!row) return next();
++ if (!cc.staffSubjects.includes(row.subject) || row.role !== cc.staffRole) {
++ sessions.revoke(row);
++ sessions.clear(res);
++ audit.record(ctx, { actor: { subject: row.subject, username: row.username }, action: 'session.use', outcome: 'refused', reason: 'no longer listed in BILLING_STAFF_SUBJECTS', requestId: requestId(req), ipHash: ipHash(req) });
++ return send(res, 403, pages.message({ title: 'Not authorized', text: 'Your account is no longer Billing staff. The session was ended.' }));
++ }
++ req.staff = { subject: row.subject, username: row.username, role: row.role, csrf: row.csrf, session: row, principal: { sub: row.subject, cap: STAFF_CAPABILITIES } };
++ return next();
++ });
++
++ /** Staff only, holding `cap` through the staff mapping. POSTs also need the CSRF token. */
++ function needs(cap) {
++ return (req, res, next) => {
++ if (!req.staff) {
++ if (req.method === 'GET') return send(res, 401, pages.signIn({ next: sanitizeNext(req.originalUrl) }));
++ return send(res, 401, pages.signIn({ message: 'Your session has ended. Sign in again, then repeat the action.' }));
++ }
++ if (!capabilities.grants(req.staff.principal.cap, cap)) {
++ return send(res, 403, pages.message({ title: 'Not authorized', text: `This needs ${cap}.`, ...common(req) }));
++ }
++ if (req.method === 'POST' && !csrfOk(req)) {
++ record(req, { action: 'request.csrf', outcome: 'refused', reason: 'missing or invalid CSRF token / cross-site request', detail: { path: req.path } });
++ return send(res, 403, pages.message({ title: 'Request refused', text: 'The form was stale or came from another site. Reload the page and try again.', ...common(req) }));
++ }
++ return next();
++ };
++ }
++ function csrfOk(req) {
++ const site = req.headers['sec-fetch-site'];
++ if (site && site !== 'same-origin' && site !== 'none') return false;
++ const origin = req.headers.origin;
++ if (origin && origin !== baseOrigin) return false;
++ return sameString((req.body || {})._csrf, req.staff.csrf);
++ }
++ const wrap = (fn) => (req, res, next) => Promise.resolve().then(() => fn(req, res, next)).catch((e) => {
++ log.error('[Billing] console error:', e);
++ if (!res.headersSent) send(res, 500, pages.message({ title: 'Error', text: 'Something went wrong. Nothing was changed unless the page says so; check the audit log.', ...common(req) }));
++ });
++ const actionKey = (v) => (ACTION_KEY_RE.test(String(v || '')) ? String(v) : null);
++ const doneNotice = (req) => NOTICES[req.query.done] ? `${NOTICES[req.query.done]}${req.query.processed ? ` ${Number(req.query.processed) || 0} held webhook(s) processed.` : ''}` : null;
++
++ // ── Sign-in ──────────────────────────────────────────────
++ r.get('/auth/login', (req, res) => {
++ const state = random(32);
++ const { verifier, challenge } = sso.pkcePair();
++ sessions.setFlow(res, { s: state, v: verifier, n: sanitizeNext(req.query.next) });
++ res.redirect(302, sso.authorizeUrl(config, { state, challenge }));
++ });
++
++ r.get('/auth/callback', wrap(async (req, res) => {
++ const flow = sessions.takeFlow(req, res);
++ if (req.query.error) return send(res, 400, pages.signIn({ message: `Sign-in was not completed (${String(req.query.error).slice(0, 60)}).` }));
++ if (!flow || !req.query.code || !sameString(req.query.state, flow.s)) {
++ return send(res, 400, pages.signIn({ message: 'The sign-in could not be verified (expired or mismatched state). Please try again.' }));
++ }
++ let who;
++ try {
++ const publicKey = keys.get() || await keys.load();
++ if (!publicKey) throw new Error('the Network key is not loaded');
++ who = await sso.exchange(ctx, { code: req.query.code, verifier: flow.v, publicKey, fetchImpl });
++ } catch (e) {
++ log.warn(`[Billing] console sign-in failed: ${e.message}`);
++ return send(res, e.status && e.status < 500 ? 400 : 502, pages.signIn({ message: 'OpenVibe.Network did not confirm the sign-in. Please try again.' }));
++ }
++ const listed = !!who.subject && cc.staffSubjects.includes(who.subject);
++ if (!listed || who.role !== cc.staffRole) {
++ audit.record(ctx, {
++ actor: { subject: who.subject, username: who.username }, action: 'session.sign_in', outcome: 'refused',
++ reason: !who.subject ? 'token carries no subject' : who.role !== cc.staffRole ? `role ${who.role || 'none'} is not ${cc.staffRole}` : 'not listed in BILLING_STAFF_SUBJECTS',
++ requestId: requestId(req), ipHash: ipHash(req),
++ });
++ return send(res, 403, pages.message({ title: 'Not authorized', text: 'This console is only for OpenVibe.Network admins who are Billing staff. Your sign-in was recorded.' }));
++ }
++ db.transaction(() => {
++ sessions.create(res, { subject: who.subject, username: who.username, role: who.role, ip: req.ip });
++ audit.record(ctx, { actor: { subject: who.subject, username: who.username }, action: 'session.sign_in', requestId: requestId(req), ipHash: ipHash(req), traceId: trace(req) });
++ })();
++ return res.redirect(303, flow.n || '/');
++ }));
++
++ r.post('/auth/logout', needs(CAP.cashoutManage), (req, res) => {
++ db.transaction(() => {
++ sessions.revoke(req.staff.session);
++ record(req, { action: 'session.sign_out' });
++ })();
++ sessions.clear(res);
++ res.redirect(303, '/');
++ });
++
++ // ── Dashboard ────────────────────────────────────────────
++ r.get('/', (req, res) => {
++ if (!req.staff) return send(res, 200, pages.signIn({}));
++ return send(res, 200, pages.dashboard({
++ ...common(req), freeze: admin.freezeState(db), last: q.lastReconciliation(db), totals: q.outstanding(db),
++ counts: q.counts(db, ctx.now()), rates: ctx.rates,
++ }));
++ });
++
++ // ── Cashouts ─────────────────────────────────────────────
++ r.get('/cashouts', needs(CAP.cashoutManage), (req, res) => {
++ const tab = q.CASHOUT_TABS[req.query.tab] ? req.query.tab : 'ready';
++ send(res, 200, pages.cashouts({ ...common(req), tab, tabs: q.CASHOUT_TABS, rows: q.cashoutQueue(db, tab, ctx.now()), counts: q.counts(db, ctx.now()), nowMs: ctx.now(), rates: ctx.rates }));
++ });
++
++ function cashoutPage(req, res, status, c, extra = {}) {
++ return send(res, status, pages.cashout({
++ ...common(req), c, nowMs: ctx.now(), approveKey: random(18), denyKey: random(18),
++ balances: { payable: balance(db, A.payable(c.subject)), pending: balance(db, A.pending(c.subject)) },
++ history: db.prepare("SELECT * FROM staff_audit WHERE target_type = 'cashout' AND target_id = ? ORDER BY seq DESC LIMIT 50").all(c.id).map((x) => ({ ...x, detail: JSON.parse(x.detail) })),
++ notice: doneNotice(req), ...extra,
++ }));
++ }
++ r.get('/cashouts/:id', needs(CAP.cashoutManage), (req, res) => {
++ const c = cashouts.find(db, req.params.id);
++ if (!c) return send(res, 404, pages.message({ title: 'Not found', text: `No cashout ${req.params.id}.`, ...common(req) }));
++ return cashoutPage(req, res, 200, c);
++ });
++
++ /** Run a cashout decision through ops; audit once (not on a replay); refusals audited as refused. */
++ function decide(action, run, validate) {
++ return (req, res) => {
++ const c = cashouts.find(db, req.params.id);
++ if (!c) return send(res, 404, pages.message({ title: 'Not found', text: `No cashout ${req.params.id}.`, ...common(req) }));
++ const b = req.body || {};
++ const target = { type: 'cashout', id: c.id };
++ const form = { payout_reference: String(b.payout_reference || '').trim().slice(0, 200), payout_provider: String(b.payout_provider || '').slice(0, 20), reason: String(b.reason || '').slice(0, 300) };
++ try {
++ const key = actionKey(b.action_key);
++ if (!key) throw new BillingError(422, 'console.stale_form', 'the form is missing its action key; reload the page');
++ if (validate) validate(b);
++ const out = db.transaction(() => {
++ assertNotFrozen(ctx); // the API's notFrozen guard
++ const o = run(req, c, b, `staff:${action}:${key}`);
++ if (!o.replay) {
++ record(req, {
++ action, target, reason: action === 'cashout.deny' ? form.reason : null,
++ detail: action === 'cashout.approve'
++ ? { payout_reference: o.cashout.payout_reference, payout_provider: o.cashout.payout_provider, amount_bits: c.amount_bits, value_cents: c.value_cents, txn: o.cashout.settle_txn }
++ : { amount_bits: c.amount_bits, txn: o.cashout.settle_txn },
++ });
++ }
++ return o;
++ })();
++ const done = action === 'cashout.approve' ? (out.replay ? 'already_paid' : 'approved') : (out.replay ? 'already_denied' : 'denied');
++ return res.redirect(303, `/cashouts/${encodeURIComponent(c.id)}?done=${done}`);
++ } catch (e) {
++ if (!(e instanceof BillingError)) throw e;
++ record(req, { action, target, outcome: 'refused', reason: action === 'cashout.deny' ? form.reason || null : null, detail: { code: e.code } });
++ return cashoutPage(req, res, e.status, cashouts.find(db, c.id), { error: `${e.detail || e.message} (${e.code})`, form });
++ }
++ };
++ }
++
++ r.post('/cashouts/:id/approve', needs(CAP.cashoutManage), wrap(decide('cashout.approve', (req, c, b, key) => {
++ const provider = String(b.payout_provider || 'paypal').toLowerCase();
++ return cashouts.approve(ctx, { id: c.id, payout_reference: b.payout_reference, payout_provider: provider, idempotencyKey: key, actor: actorOf(req) });
++ }, (b) => {
++ if (!text(b.payout_reference, 'payout_reference', 200)) throw new BillingError(422, 'billing.payout_reference_required', 'approving a payout needs the payout reference from the provider');
++ if (!PAYOUT_PROVIDERS.includes(String(b.payout_provider || 'paypal').toLowerCase())) throw new BillingError(422, 'billing.invalid_input', `payout provider must be one of ${PAYOUT_PROVIDERS.join(', ')}`);
++ if (b.confirm !== 'yes') throw new BillingError(422, 'console.confirm_required', 'tick the confirmation that the payout was sent');
++ })));
++
++ r.post('/cashouts/:id/deny', needs(CAP.cashoutManage), wrap(decide('cashout.deny', (req, c, b, key) => (
++ cashouts.deny(ctx, { id: c.id, reason: b.reason, idempotencyKey: key, actor: actorOf(req) })
++ ), (b) => {
++ if (!text(b.reason, 'reason', 300)) throw new BillingError(422, 'console.reason_required', 'a denial needs a reason');
++ })));
++
++ // ── Receipts needing review ──────────────────────────────
++ r.get('/receipts', needs(CAP.admin), (req, res) => send(res, 200, pages.receipts({ ...common(req), q: q.reviewQueue(db), notice: doneNotice(req) })));
++ r.post('/receipts/:id/reprocess', needs(CAP.admin), wrap(async (req, res) => {
++ const id = Number(req.params.id);
++ const target = { type: 'provider_event', id: req.params.id };
++ try {
++ if (!Number.isInteger(id) || id <= 0) throw new BillingError(404, 'billing.event_not_found', `no provider event ${req.params.id}`);
++ assertNotFrozen(ctx);
++ const row = await providers.reprocess(ctx, adapters, id);
++ record(req, { action: 'provider_event.reprocess', target, detail: { processed: !!row.processed_at, effect: row.result ? row.result.effect : null, code: row.result && row.result.code ? row.result.code : undefined } });
++ return res.redirect(303, '/receipts?done=reprocessed');
++ } catch (e) {
++ if (!(e instanceof BillingError)) throw e;
++ record(req, { action: 'provider_event.reprocess', target, outcome: 'refused', detail: { code: e.code } });
++ return send(res, e.status, pages.receipts({ ...common(req), q: q.reviewQueue(db), error: `${e.detail || e.message} (${e.code})` }));
++ }
++ }));
++
++ r.get('/import-holds', needs(CAP.admin), (req, res) => send(res, 200, pages.holds({ ...common(req), rows: q.importHolds(db) })));
++
++ // ── Reconciliation ───────────────────────────────────────
++ r.get('/reconciliation', needs(CAP.admin), (req, res) => send(res, 200, pages.reconciliation({ ...common(req), runs: q.reconciliationRuns(db), notice: doneNotice(req) })));
++ r.get('/reconciliation/:id', needs(CAP.admin), (req, res) => {
++ const run = q.reconciliationRun(db, req.params.id);
++ if (!run) return send(res, 404, pages.message({ title: 'Not found', text: `No reconciliation run ${req.params.id}.`, ...common(req) }));
++ return send(res, 200, pages.reconciliationRun({ ...common(req), run }));
++ });
++ r.post('/reconciliation', needs(CAP.admin), (req, res) => {
++ const report = db.transaction(() => {
++ const rep = reconcile(ctx);
++ record(req, { action: 'reconciliation.run', target: { type: 'reconciliation_run', id: rep.id }, detail: { ok: rep.ok, failed: rep.checks.filter((c) => !c.ok).map((c) => c.id) } });
++ return rep;
++ })();
++ res.redirect(303, `/reconciliation/${encodeURIComponent(report.id)}`);
++ });
++
++ // ── Freeze ───────────────────────────────────────────────
++ const heldCount = () => db.prepare('SELECT COUNT(*) AS n FROM provider_events WHERE processed_at IS NULL').get().n;
++ r.get('/freeze', needs(CAP.admin), (req, res) => send(res, 200, pages.freeze({ ...common(req), state: admin.freezeState(db), held: heldCount(), notice: doneNotice(req) })));
++ r.post('/freeze', needs(CAP.admin), wrap(async (req, res) => {
++ const b = req.body || {};
++ const on = b.on === '1';
++ const action = on ? 'economy.freeze' : 'economy.unfreeze';
++ const target = { type: 'settings', id: 'freeze' };
++ const refuse = (status, code, detail) => {
++ record(req, { action, target, outcome: 'refused', reason: b.reason ? String(b.reason).slice(0, 300) : null, detail: { code } });
++ return send(res, status, pages.freeze({ ...common(req), state: admin.freezeState(db), held: heldCount(), error: detail }));
++ };
++ if (b.on !== '1' && b.on !== '0') return refuse(422, 'billing.invalid_input', 'on must be 1 or 0');
++ let reason;
++ try { reason = text(b.reason, 'reason', 300); } catch (e) { return refuse(422, e.code, e.detail); }
++ if (!reason) return refuse(422, 'console.reason_required', `${on ? 'Freezing' : 'Unfreezing'} needs a reason.`);
++ if (isFrozen(db) === on) return refuse(409, 'console.no_change', `The economy is already ${on ? 'frozen' : 'open'}.`);
++ db.transaction(() => {
++ admin.setFreeze(ctx, { on, reason, actor: actorOf(req) });
++ record(req, { action, target, reason });
++ })();
++ // As POST /api/v1/admin/freeze: deliveries stored while frozen are processed now, in order.
++ const drained = on ? null : await providers.processPending(ctx, adapters);
++ return res.redirect(303, `/freeze?done=${on ? 'frozen' : 'unfrozen'}${drained ? `&processed=${drained.processed || 0}` : ''}`);
++ }));
++
++ r.get('/audit', needs(CAP.admin), (req, res) => send(res, 200, pages.audit({ ...common(req), rows: audit.list(db) })));
++
++ return r;
++}
++
++module.exports = { consoleRouter, STAFF_CAPABILITIES, sanitizeNext };
ddiff --git a/server/console/queries.js b/server/console/queries.js
nnew file mode 100644
iindex 0000000..efdfb2d
---- /dev/null
++++ b/server/console/queries.js
@@@ -0,0 +1,115 @@
++'use strict';
++
++/**
++ * Read models for the staff console. Reads only — every change goes through server/ops/* (the same
++ * functions the API uses). Provider payloads are never selected: receipts are shown by id,
++ * provider, type, times, payload hash and a whitelisted part of their processing result.
++ */
++const { iso } = require('../ledger');
++
++/** Outstanding balances per account kind, counting only non-test transactions (ADR-012 rule 9). */
++function outstanding(db) {
++ const sum = (kind, currency) => db.prepare(`SELECT COALESCE(SUM(e.amount), 0) AS n FROM ledger_entries e
++ JOIN accounts a ON a.id = e.account_id JOIN transactions t ON t.id = e.txn_id
++ WHERE a.kind = ? AND a.currency = ? AND t.test = 0`).get(kind, currency).n;
++ return {
++ credit_bits: sum('user_credit', 'vibes-bits'),
++ payable_bits: sum('creator_payable', 'vibes-bits'),
++ payouts_pending_bits: sum('payouts_pending', 'vibes-bits'),
++ platform_revenue_cents: sum('platform_revenue', 'usd-cents'),
++ chargeback_loss_cents: sum('chargeback_loss', 'usd-cents'),
++ test_transactions: db.prepare('SELECT COUNT(*) AS n FROM transactions WHERE test = 1').get().n,
++ };
++}
++
++/** A stored reconciliation report reduced to what the console shows (no offender rows). */
++function summarizeReport(report) {
++ const count = (v) => (Array.isArray(v) ? v.length : 0);
++ const offenders = (c) => {
++ const d = c.detail || {};
++ return count(d.offenders) + count(d.mismatches);
++ };
++ return {
++ id: report.id, ok: !!report.ok, started_at: report.started_at, finished_at: report.finished_at,
++ checks: (report.checks || []).map((c) => ({ id: c.id, ok: !!c.ok, offenders: offenders(c) })),
++ warnings: Object.fromEntries(Object.entries(report.warnings || {}).map(([k, v]) => [k, count(v)])),
++ totals: report.totals || {},
++ };
++}
++
++function reconciliationRun(db, id) {
++ const row = db.prepare('SELECT * FROM reconciliation_runs WHERE id = ?').get(id);
++ return row ? summarizeReport(JSON.parse(row.report)) : null;
++}
++function lastReconciliation(db) {
++ const row = db.prepare('SELECT * FROM reconciliation_runs ORDER BY finished_at DESC, id DESC LIMIT 1').get();
++ return row ? summarizeReport(JSON.parse(row.report)) : null;
++}
++function reconciliationRuns(db, limit = 50) {
++ return db.prepare('SELECT id, finished_at, ok, report FROM reconciliation_runs ORDER BY finished_at DESC, id DESC LIMIT ?').all(limit)
++ .map((r) => {
++ const s = summarizeReport(JSON.parse(r.report));
++ return { id: r.id, finished_at: r.finished_at, ok: !!r.ok, failed: s.checks.filter((c) => !c.ok).map((c) => c.id) };
++ });
++}
++
++/** The part of a provider event's processing result the console may show. */
++function safeResult(result) {
++ if (!result || typeof result !== 'object') return null;
++ const out = {};
++ for (const k of ['effect', 'code', 'reason', 'txn_id']) if (result[k] != null) out[k] = String(result[k]).slice(0, 300);
++ if (result.review) out.review = true;
++ return out;
++}
++const EVENT_COLUMNS = 'id, provider, provider_event_id, type, payload_hash, received_at, processed_at, result, attempts, last_error';
++const eventRow = (r) => ({ ...r, result: safeResult(r.result ? JSON.parse(r.result) : null), last_error: r.last_error ? String(r.last_error).slice(0, 300) : null });
++
++/**
++ * Provider receipts needing a person: flagged for review (unattributed site tips, underpaid
++ * deliveries, held site-routed tips), rejected (e.g. a delivery for an order Live already credited —
++ * the double-credit refusal — or a refund for an unknown payment), stored but not processed, and
++ * reversing transactions flagged review=required (chargebacks on credit already given away).
++ */
++function reviewQueue(db) {
++ const review = db.prepare(`SELECT ${EVENT_COLUMNS} FROM provider_events WHERE json_extract(result, '$.review') = 1 ORDER BY id DESC LIMIT 200`).all().map(eventRow);
++ const rejected = db.prepare(`SELECT ${EVENT_COLUMNS} FROM provider_events WHERE json_extract(result, '$.effect') = 'rejected' ORDER BY id DESC LIMIT 200`).all().map(eventRow);
++ const unprocessed = db.prepare(`SELECT ${EVENT_COLUMNS} FROM provider_events WHERE processed_at IS NULL ORDER BY id LIMIT 200`).all().map(eventRow);
++ const transactions = db.prepare(`SELECT id, type, reverses_txn, created_at, provider, receipt_ref, from_subject, to_subject,
++ json_extract(metadata, '$.cents') AS cents, json_extract(metadata, '$.unrecovered_bits') AS unrecovered_bits,
++ json_extract(metadata, '$.creator_payable_kept_bits') AS creator_payable_kept_bits
++ FROM transactions WHERE json_extract(metadata, '$.review') = 'required' ORDER BY created_at DESC LIMIT 200`).all();
++ return { review, rejected, unprocessed, transactions };
++}
++
++function importHolds(db) {
++ return db.prepare(`SELECT live_user_id, owner, reason, credit_bits, payable_bits, resolved_subject, first_seen_at, updated_at
++ FROM import_holds ORDER BY resolved_subject IS NOT NULL, live_user_id`).all();
++}
++
++const CASHOUT_TABS = {
++ escrow: { label: 'In escrow', where: "status = 'requested' AND escrow_until > @now", order: 'escrow_until ASC' },
++ ready: { label: 'Ready to pay', where: "status = 'requested' AND escrow_until <= @now", order: 'escrow_until ASC' },
++ paid: { label: 'Paid', where: "status = 'paid'", order: 'updated_at DESC' },
++ denied: { label: 'Denied', where: "status = 'denied'", order: 'updated_at DESC' },
++};
++
++function cashoutQueue(db, tab, nowMs) {
++ const t = CASHOUT_TABS[tab] || CASHOUT_TABS.ready;
++ return db.prepare(`SELECT id, subject, amount_bits, value_cents, status, payout_method, escrow_until, payout_provider, payout_reference, reason, created_at, updated_at
++ FROM cashouts WHERE ${t.where} ORDER BY ${t.order} LIMIT 300`).all({ now: iso(nowMs) })
++ .map((r) => ({ ...r, payout_method: JSON.parse(r.payout_method || '{}') }));
++}
++
++function counts(db, nowMs) {
++ const now = iso(nowMs);
++ const one = (sql, ...a) => db.prepare(sql).get(...a).n;
++ return {
++ escrow: one("SELECT COUNT(*) AS n FROM cashouts WHERE status = 'requested' AND escrow_until > ?", now),
++ ready: one("SELECT COUNT(*) AS n FROM cashouts WHERE status = 'requested' AND escrow_until <= ?", now),
++ review_events: one("SELECT COUNT(*) AS n FROM provider_events WHERE json_extract(result, '$.review') = 1 OR json_extract(result, '$.effect') = 'rejected' OR processed_at IS NULL"),
++ review_transactions: one("SELECT COUNT(*) AS n FROM transactions WHERE json_extract(metadata, '$.review') = 'required'"),
++ import_holds: one('SELECT COUNT(*) AS n FROM import_holds WHERE resolved_subject IS NULL'),
++ };
++}
++
++module.exports = { outstanding, summarizeReport, reconciliationRun, lastReconciliation, reconciliationRuns, reviewQueue, importHolds, cashoutQueue, counts, CASHOUT_TABS, safeResult };
ddiff --git a/server/console/session.js b/server/console/session.js
nnew file mode 100644
iindex 0000000..f372e7b
---- /dev/null
++++ b/server/console/session.js
@@@ -0,0 +1,111 @@
++'use strict';
++
++/**
++ * Staff console sessions, the sign-in flow cookie, CSRF tokens and IP hashing.
++ *
++ * session cookie `__Host-ovb_staff` (plain `ovb_staff` when the console is served over http in
++ * development): a random 256-bit id, host-only, Path=/, HttpOnly, Secure,
++ * SameSite=Strict, Max-Age = the session TTL (absolute, BILLING_SESSION_TTL_MIN,
++ * 60 min by default). The database keeps only its SHA-256.
++ * flow cookie `ovb_flow`, Path=/auth, HttpOnly, Secure, SameSite=Lax, 10 minutes: the OAuth
++ * state, the PKCE verifier and the page to return to, HMAC-signed with
++ * BILLING_SESSION_SECRET. Lax because the callback is a top-level navigation back
++ * from openvibe.network; the state and PKCE checks are what make it safe.
++ * CSRF one random token per session, embedded in every form and compared in constant
++ * time on every POST, plus an Origin / Sec-Fetch-Site check.
++ */
++const crypto = require('crypto');
++const { iso } = require('../ledger');
++
++const FLOW_COOKIE = 'ovb_flow';
++const FLOW_TTL_MS = 10 * 60 * 1000;
++
++const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
++const random = (bytes = 32) => crypto.randomBytes(bytes).toString('base64url');
++
++function sameString(a, b) {
++ const x = Buffer.from(String(a || ''));
++ const y = Buffer.from(String(b || ''));
++ return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);
++}
++
++function parseCookies(req) {
++ const out = {};
++ for (const part of String(req.headers.cookie || '').split(';')) {
++ const i = part.indexOf('=');
++ if (i < 1) continue;
++ const k = part.slice(0, i).trim();
++ if (!(k in out)) out[k] = decodeURIComponent(part.slice(i + 1).trim());
++ }
++ return out;
++}
++
++function cookieHeader(name, value, { maxAgeSec, path = '/', sameSite = 'Strict', secure }) {
++ const parts = [`${name}=${value}`, `Path=${path}`, 'HttpOnly', `SameSite=${sameSite}`];
++ if (secure) parts.push('Secure');
++ parts.push(`Max-Age=${Math.max(0, Math.floor(maxAgeSec))}`);
++ return parts.join('; ');
++}
++
++function appendCookie(res, value) {
++ const prev = res.getHeader('Set-Cookie');
++ res.setHeader('Set-Cookie', prev ? [].concat(prev, value) : [value]);
++}
++
++function createSessions(ctx, { secret }) {
++ const { db, config } = ctx;
++ const cc = config.console;
++ const sessionCookie = cc.cookieSecure ? '__Host-ovb_staff' : 'ovb_staff';
++ const hmac = (s) => crypto.createHmac('sha256', secret).update(String(s)).digest('base64url');
++
++ /** Keyed hash of the client address: comparable across rows, not reversible by enumerating IPv4. */
++ const ipHash = (ip) => (ip ? crypto.createHmac('sha256', secret).update(`ip:${ip}`).digest('hex').slice(0, 32) : null);
++
++ // ── Sign-in flow cookie ──────────────────────────────────
++ function setFlow(res, flow) {
++ const body = Buffer.from(JSON.stringify({ ...flow, e: ctx.now() + FLOW_TTL_MS })).toString('base64url');
++ appendCookie(res, cookieHeader(FLOW_COOKIE, `${body}.${hmac(`flow:${body}`)}`, { maxAgeSec: FLOW_TTL_MS / 1000, path: '/auth', sameSite: 'Lax', secure: cc.cookieSecure }));
++ }
++ function takeFlow(req, res) {
++ appendCookie(res, cookieHeader(FLOW_COOKIE, '', { maxAgeSec: 0, path: '/auth', sameSite: 'Lax', secure: cc.cookieSecure }));
++ const raw = parseCookies(req)[FLOW_COOKIE];
++ if (!raw) return null;
++ const [body, sig] = raw.split('.');
++ if (!body || !sig || !sameString(sig, hmac(`flow:${body}`))) return null;
++ try {
++ const flow = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
++ return flow && typeof flow.e === 'number' && flow.e > ctx.now() ? flow : null;
++ } catch { return null; }
++ }
++
++ // ── Sessions ─────────────────────────────────────────────
++ function create(res, { subject, username, role, ip }) {
++ const now = ctx.now();
++ db.prepare('DELETE FROM staff_sessions WHERE expires_at < ?').run(iso(now - 24 * 3600 * 1000));
++ const id = random(32);
++ const ttlMs = cc.sessionTtlMin * 60 * 1000;
++ db.prepare(`INSERT INTO staff_sessions (id_hash, subject, username, role, csrf, created_at, expires_at, ip_hash)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(sha256(id), subject, username || null, role, random(32), iso(now), iso(now + ttlMs), ipHash(ip));
++ appendCookie(res, cookieHeader(sessionCookie, id, { maxAgeSec: ttlMs / 1000, secure: cc.cookieSecure }));
++ }
++
++ /** The live session of this request, or null (unknown, expired or revoked). */
++ function read(req) {
++ const id = parseCookies(req)[sessionCookie];
++ if (!id || !/^[A-Za-z0-9_-]{43}$/.test(id)) return null;
++ const row = db.prepare('SELECT * FROM staff_sessions WHERE id_hash = ?').get(sha256(id));
++ if (!row || row.revoked_at || Date.parse(row.expires_at) <= ctx.now()) return null;
++ return row;
++ }
++
++ function revoke(row) {
++ if (row) db.prepare('UPDATE staff_sessions SET revoked_at = ? WHERE id_hash = ? AND revoked_at IS NULL').run(iso(ctx.now()), row.id_hash);
++ }
++ function clear(res) {
++ appendCookie(res, cookieHeader(sessionCookie, '', { maxAgeSec: 0, secure: cc.cookieSecure }));
++ }
++
++ return { setFlow, takeFlow, create, read, revoke, clear, ipHash, sessionCookie };
++}
++
++module.exports = { createSessions, parseCookies, sameString, sha256, random, FLOW_COOKIE };
ddiff --git a/server/console/sso.js b/server/console/sso.js
nnew file mode 100644
iindex 0000000..ff9af77
---- /dev/null
++++ b/server/console/sso.js
@@@ -0,0 +1,103 @@
++'use strict';
++
++/**
++ * OpenVibe.Network single sign-on for the staff console: OAuth 2 authorization code with PKCE
++ * (S256), as OAuth client `billing`.
++ *
++ * authorizeUrl() https://openvibe.network/oauth/authorize?response_type=code&client_id=billing
++ * &redirect_uri=<BASE_URL>/auth/callback&scope=profile&state=…&code_challenge=…
++ * &code_challenge_method=S256
++ * exchange() POST <OV_NETWORK_INTERNAL_URL>/oauth/token (client secret + code_verifier),
++ * server to server; the access token is then verified offline with the Network's
++ * RS256 key Billing already holds (issuer, audience, expiry)
++ *
++ * The Network's user access token carries `role` and `subject_id` (server/auth/oauth-routes.js
++ * issueTokenPair), minted from the users row at the moment of the code exchange, so the role read
++ * here is current as of sign-in; the console session is short and re-checks the staff list on
++ * every request. Billing keeps none of the Network's tokens: the refresh token it is handed is
++ * revoked straight away (best effort).
++ */
++const crypto = require('crypto');
++
++const b64urlJson = (s) => JSON.parse(Buffer.from(String(s), 'base64url').toString('utf8'));
++
++function pkcePair() {
++ const verifier = crypto.randomBytes(32).toString('base64url'); // 43 chars
++ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
++ return { verifier, challenge };
++}
++
++function authorizeUrl(config, { state, challenge }) {
++ const q = new URLSearchParams({
++ response_type: 'code',
++ client_id: config.oauth.clientId,
++ redirect_uri: config.console.redirectUri,
++ scope: 'profile',
++ state,
++ code_challenge: challenge,
++ code_challenge_method: 'S256',
++ });
++ return `${config.network.url}/oauth/authorize?${q.toString()}`;
++}
++
++/** Verify a Network user access token (RS256). Returns claims or throws with a short reason. */
++function verifyUserToken(token, { publicKey, issuer, audience, now }) {
++ const parts = typeof token === 'string' ? token.split('.') : [];
++ if (parts.length !== 3) throw new Error('not a JWT');
++ let header, claims;
++ try { header = b64urlJson(parts[0]); claims = b64urlJson(parts[1]); } catch { throw new Error('undecodable token'); }
++ if (header.alg !== 'RS256') throw new Error(`alg ${header.alg} not accepted`);
++ let good = false;
++ try { good = crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), publicKey, Buffer.from(parts[2], 'base64url')); } catch { good = false; }
++ if (!good) throw new Error('signature does not verify');
++ const t = Math.floor(now / 1000);
++ if (typeof claims.exp !== 'number' || claims.exp + 30 < t) throw new Error('token expired');
++ if (typeof claims.iat === 'number' && claims.iat - 30 > t) throw new Error('token issued in the future');
++ if (claims.iss !== issuer) throw new Error('wrong issuer');
++ const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
++ if (!aud.includes(audience)) throw new Error(`token is not for ${audience}`);
++ return claims;
++}
++
++async function exchange(ctx, { code, verifier, publicKey, fetchImpl = globalThis.fetch }) {
++ const { config } = ctx;
++ const body = new URLSearchParams({
++ grant_type: 'authorization_code',
++ code: String(code),
++ redirect_uri: config.console.redirectUri,
++ client_id: config.oauth.clientId,
++ client_secret: config.oauth.clientSecret,
++ code_verifier: verifier,
++ });
++ const res = await fetchImpl(`${config.network.internalUrl}/oauth/token`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
++ body,
++ signal: AbortSignal.timeout(10_000),
++ });
++ const data = await res.json().catch(() => null);
++ if (!res.ok || !data || !data.access_token) {
++ const err = new Error((data && (data.error_description || data.error)) || `token endpoint ${res.status}`);
++ err.status = res.status;
++ throw err;
++ }
++ // Billing never uses the Network session again: drop the refresh token now.
++ if (data.refresh_token) {
++ fetchImpl(`${config.network.internalUrl}/oauth/revoke`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, token: data.refresh_token }),
++ signal: AbortSignal.timeout(3000),
++ }).catch(() => {});
++ }
++ const claims = verifyUserToken(data.access_token, {
++ publicKey, issuer: config.network.issuer, audience: config.console.ssoAudience, now: ctx.now(),
++ });
++ return {
++ subject: typeof claims.subject_id === 'string' ? claims.subject_id : null,
++ username: typeof claims.username === 'string' ? claims.username.slice(0, 64) : null,
++ role: typeof claims.role === 'string' ? claims.role : null,
++ };
++}
++
++module.exports = { pkcePair, authorizeUrl, verifyUserToken, exchange };
ddiff --git a/server/console/views.js b/server/console/views.js
nnew file mode 100644
iindex 0000000..4456e37
---- /dev/null
++++ b/server/console/views.js
@@@ -0,0 +1,280 @@
++'use strict';
++
++/**
++ * Server-rendered pages of the staff console. No JavaScript, no external resources: one inline
++ * stylesheet allowed by its hash in the Content-Security-Policy. Every interpolated value is
++ * HTML-escaped by the `html` tag unless it is itself `html`.
++ */
++const crypto = require('crypto');
++
++class Raw { constructor(s) { this.s = s; } }
++const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++function render(v) {
++ if (v instanceof Raw) return v.s;
++ if (Array.isArray(v)) return v.map(render).join('');
++ if (v == null || v === false) return '';
++ return esc(v);
++}
++function html(strings, ...vals) {
++ let out = strings[0];
++ vals.forEach((v, i) => { out += render(v) + strings[i + 1]; });
++ return new Raw(out);
++}
++
++const CSS = `
++:root{color-scheme:light dark;--bg:#f6f7f9;--fg:#15181d;--muted:#5b6472;--card:#fff;--line:#dde1e7;--accent:#1f5fd6;--ok:#11703b;--bad:#b3261e;--warn:#8a5a00;--warnbg:#fff4d6}
++@media (prefers-color-scheme:dark){:root{--bg:#101318;--fg:#e8ebf0;--muted:#9aa4b2;--card:#171b22;--line:#2a313c;--accent:#7aa7ff;--ok:#5fd394;--bad:#ff8a80;--warn:#ffcf66;--warnbg:#3a2e0b}}
++*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
++header{background:var(--card);border-bottom:1px solid var(--line)}
++.bar{max-width:1180px;margin:0 auto;padding:10px 16px;display:flex;flex-wrap:wrap;gap:8px 18px;align-items:center}
++.brand{font-weight:700;margin-right:8px}nav{display:flex;flex-wrap:wrap;gap:4px 14px}
++nav a{color:var(--muted);text-decoration:none}nav a.on,nav a:hover{color:var(--fg)}
++.who{margin-left:auto;color:var(--muted);font-size:13px;display:flex;gap:10px;align-items:center}
++main{max-width:1180px;margin:0 auto;padding:18px 16px 48px}
++h1{font-size:22px;margin:4px 0 14px}h2{font-size:17px;margin:22px 0 8px}
++a{color:var(--accent)}
++.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:12px}
++.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px 16px}
++.card .k{color:var(--muted);font-size:13px}.card .v{font-size:20px;font-weight:650}
++.tablewrap{overflow-x:auto;background:var(--card);border:1px solid var(--line);border-radius:10px}
++table{border-collapse:collapse;width:100%;font-size:14px}th,td{text-align:left;padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:top}
++th{color:var(--muted);font-weight:600;white-space:nowrap}tr:last-child td{border-bottom:0}
++code,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;word-break:break-all}
++.ok{color:var(--ok);font-weight:600}.bad{color:var(--bad);font-weight:600}.muted{color:var(--muted)}
++.banner{padding:10px 14px;border-radius:8px;margin:0 0 14px;border:1px solid var(--line)}
++.banner.frozen,.banner.error{background:var(--warnbg);color:var(--warn);border-color:var(--warn)}.banner.done{border-color:var(--ok);color:var(--ok)}
++.tabs{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 12px}.tabs a{padding:4px 10px;border:1px solid var(--line);border-radius:999px;text-decoration:none;color:var(--fg)}.tabs a.on{background:var(--fg);color:var(--bg)}
++form.inline{display:inline}form.stack{display:grid;gap:10px;max-width:520px}
++label{display:grid;gap:4px;font-size:14px}label.check{display:flex;gap:8px;align-items:flex-start}
++input[type=text],select,textarea{font:inherit;padding:7px 9px;border:1px solid var(--line);border-radius:7px;background:var(--bg);color:var(--fg);width:100%}
++textarea{min-height:70px}
++button{font:inherit;padding:7px 14px;border-radius:7px;border:1px solid var(--line);background:var(--card);color:var(--fg);cursor:pointer}
++button.primary{background:var(--accent);border-color:var(--accent);color:#fff}button.danger{border-color:var(--bad);color:var(--bad)}
++button.link{border:0;background:none;padding:0;color:var(--accent);text-decoration:underline}
++fieldset{border:1px solid var(--line);border-radius:10px;padding:12px 14px;margin:0}fieldset[disabled]{opacity:.6}
++legend{padding:0 6px;font-weight:600}
++dl{display:grid;grid-template-columns:max-content 1fr;gap:6px 16px;margin:0}dt{color:var(--muted)}dd{margin:0}
++.cols{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:16px;align-items:start}
++`;
++const CSS_HASH = `sha256-${crypto.createHash('sha256').update(CSS).digest('base64')}`;
++const CSP = `default-src 'none'; style-src '${CSS_HASH}'; img-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'`;
++
++// ── Formatting ───────────────────────────────────────────────
++const n = (v) => Number(v || 0).toLocaleString('en-US');
++const bits = (v) => `${n(v)} bits`;
++const usd = (c) => `${c < 0 ? '−' : ''}$${(Math.abs(Number(c) || 0) / 100).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
++const when = (s) => (s ? `${String(s).replace('T', ' ').slice(0, 16)} UTC` : '—');
++function relative(s, nowMs) {
++ if (!s) return '';
++ const d = Date.parse(s) - nowMs;
++ const abs = Math.abs(d);
++ const days = Math.floor(abs / 86_400_000);
++ const hours = Math.floor((abs % 86_400_000) / 3_600_000);
++ const mins = Math.floor((abs % 3_600_000) / 60_000);
++ const span = days ? `${days} d ${hours} h` : hours ? `${hours} h ${mins} min` : `${mins} min`;
++ return d >= 0 ? `in ${span}` : `${span} ago`;
++}
++const okBad = (ok, yes = 'OK', no = 'FAILED') => (ok ? html`<span class="ok">${yes}</span>` : html`<span class="bad">${no}</span>`);
++
++const NAV = [
++ ['/', 'Dashboard', 'dashboard'], ['/cashouts', 'Cashouts', 'cashouts'], ['/receipts', 'Receipts to review', 'receipts'],
++ ['/import-holds', 'Import holds', 'holds'], ['/reconciliation', 'Reconciliation', 'reconciliation'], ['/freeze', 'Freeze', 'freeze'], ['/audit', 'Audit log', 'audit'],
++];
++
++function layout({ title, section, staff, csrf, frozen, notice, error, body }) {
++ return `<!doctype html>${render(html`<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
++<meta name="robots" content="noindex,nofollow"><meta name="referrer" content="no-referrer"><title>${title} · Billing staff</title><style>${new Raw(CSS)}</style></head>
++<body><header><div class="bar"><span class="brand">OpenVibe.Billing · staff</span>
++${staff ? html`<nav>${NAV.map(([href, label, id]) => html`<a href="${href}" class="${id === section ? 'on' : ''}">${label}</a>`)}</nav>
++<span class="who"><span>${staff.username ? `@${staff.username}` : ''} <code>${staff.subject}</code></span>
++<form class="inline" method="post" action="/auth/logout"><input type="hidden" name="_csrf" value="${csrf}"><button class="link" type="submit">Sign out</button></form></span>` : ''}
++</div></header><main>
++${frozen ? html`<div class="banner frozen"><strong>The economy is frozen.</strong> Writes are refused, reads are served, provider webhooks are stored and held. <a href="/freeze">Freeze controls</a></div>` : ''}
++${notice ? html`<div class="banner done">${notice}</div>` : ''}
++${error ? html`<div class="banner error"><strong>Refused:</strong> ${error}</div>` : ''}
++${body}
++</main></body></html>`)}`;
++}
++
++// ── Pages ────────────────────────────────────────────────────
++function signIn({ next, message }) {
++ const href = `/auth/login${next && next !== '/' ? `?next=${encodeURIComponent(next)}` : ''}`;
++ return layout({
++ title: 'Sign in', body: html`<h1>Billing staff console</h1>
++<div class="card"><p>${message || 'Sign in with your OpenVibe.Network account. Only network admins listed as Billing staff can use this console.'}</p>
++<p><a href="${href}">Sign in with OpenVibe.Network</a></p></div>`,
++ });
++}
++
++function message({ title, text, staff, csrf }) {
++ return layout({ title, staff, csrf, body: html`<h1>${title}</h1><div class="card"><p>${text}</p><p><a href="/">Back</a></p></div>` });
++}
++
++function dashboard({ staff, csrf, freeze, last, totals, counts, rates }) {
++ const v = (c) => usd(rates.valueCents(c));
++ return layout({
++ title: 'Dashboard', section: 'dashboard', staff, csrf, frozen: freeze.frozen, body: html`<h1>Dashboard</h1>
++<div class="grid">
++<div class="card"><div class="k">Economy</div><div class="v">${freeze.frozen ? html`<span class="bad">Frozen</span>` : html`<span class="ok">Open</span>`}</div>
++<div class="muted">${freeze.frozen ? html`${freeze.reason || 'no reason given'} · since ${when(freeze.frozen_at)}` : 'writes accepted'}</div><a href="/freeze">Freeze controls</a></div>
++<div class="card"><div class="k">Last reconciliation</div>${last ? html`<div class="v">${okBad(last.ok)}</div>
++<div class="muted">${when(last.finished_at)}${last.ok ? '' : html` · failed: ${last.checks.filter((c) => !c.ok).map((c) => c.id).join(', ')}`}</div><a href="/reconciliation/${last.id}">Report</a>`
++ : html`<div class="v muted">never run</div>`}
++<form method="post" action="/reconciliation"><input type="hidden" name="_csrf" value="${csrf}"><button type="submit">Run reconciliation now</button></form></div>
++<div class="card"><div class="k">Cashouts</div><div class="v">${n(counts.ready)} ready to pay</div><div class="muted">${n(counts.escrow)} in escrow</div><a href="/cashouts?tab=ready">Queue</a></div>
++<div class="card"><div class="k">Needs review</div><div class="v">${n(counts.review_events + counts.review_transactions)}</div><div class="muted">${n(counts.review_events)} receipts · ${n(counts.review_transactions)} reversals · ${n(counts.import_holds)} import holds</div><a href="/receipts">Receipts</a></div>
++</div>
++<h2>Outstanding (test transactions excluded)</h2>
++<div class="tablewrap"><table><thead><tr><th>Account kind</th><th>Balance</th><th>Value</th></tr></thead><tbody>
++<tr><td>Spendable credit (user_credit)</td><td>${bits(totals.credit_bits)}</td><td>${v(totals.credit_bits)}</td></tr>
++<tr><td>Creator payable (creator_payable)</td><td>${bits(totals.payable_bits)}</td><td>${v(totals.payable_bits)}</td></tr>
++<tr><td>Payouts pending (payouts_pending)</td><td>${bits(totals.payouts_pending_bits)}</td><td>${v(totals.payouts_pending_bits)}</td></tr>
++<tr><td>Platform revenue</td><td>—</td><td>${usd(totals.platform_revenue_cents)}</td></tr>
++<tr><td>Chargeback loss</td><td>—</td><td>${usd(totals.chargeback_loss_cents)}</td></tr>
++</tbody></table></div>
++<p class="muted">${n(totals.test_transactions)} test transaction(s) are left out of these totals. Value at ${n(rates.bitsPerUsd)} bits per USD.</p>`,
++ });
++}
++
++function cashouts({ staff, csrf, frozen, tab, tabs, rows, counts, nowMs, rates }) {
++ return layout({
++ title: 'Cashouts', section: 'cashouts', staff, csrf, frozen, body: html`<h1>Cashouts</h1>
++<p class="muted">A cashout is requested by the creator (payable moves to payouts pending) and waits out a ${n(rates.escrowDays)}-day escrow. Approving it records the payout you made at the provider — its payout reference is required — and marks it paid; denying it returns the amount to the creator's payable.</p>
++<div class="tabs">${Object.entries(tabs).map(([id, t]) => html`<a href="/cashouts?tab=${id}" class="${id === tab ? 'on' : ''}">${t.label}${counts[id] != null ? ` (${n(counts[id])})` : ''}</a>`)}</div>
++${rows.length ? html`<div class="tablewrap"><table><thead><tr><th>Cashout</th><th>Creator</th><th>Amount</th><th>Method</th><th>Requested</th><th>Escrow until</th><th>Outcome</th></tr></thead><tbody>
++${rows.map((c) => html`<tr><td><a class="mono" href="/cashouts/${c.id}">${c.id}</a></td><td><code>${c.subject}</code></td><td>${bits(c.amount_bits)}<br><span class="muted">${usd(c.value_cents)}</span></td>
++<td>${c.payout_method.type || '—'}</td><td>${when(c.created_at)}</td><td>${when(c.escrow_until)}<br><span class="muted">${relative(c.escrow_until, nowMs)}</span></td>
++<td>${c.status === 'paid' ? html`paid · <code>${c.payout_reference}</code>` : c.status === 'denied' ? html`denied${c.reason ? html` · ${c.reason}` : ''}` : 'pending'}</td></tr>`)}
++</tbody></table></div>` : html`<div class="card muted">Nothing here.</div>`}`,
++ });
++}
++
++function cashout({ staff, csrf, frozen, c, balances, nowMs, approveKey, denyKey, form = {}, error, notice, history }) {
++ const inEscrow = c.status === 'requested' && nowMs < Date.parse(c.escrow_until);
++ const pending = c.status === 'requested';
++ return layout({
++ title: `Cashout ${c.id}`, section: 'cashouts', staff, csrf, frozen, error, notice, body: html`<h1>Cashout <span class="mono">${c.id}</span></h1>
++<div class="cols"><div class="card"><dl>
++<dt>Status</dt><dd>${c.status === 'requested' ? (inEscrow ? 'pending — in escrow' : 'pending — ready to pay') : c.status}</dd>
++<dt>Creator</dt><dd><code>${c.subject}</code></dd>
++<dt>Amount</dt><dd>${bits(c.amount_bits)} (${usd(c.value_cents)})</dd>
++<dt>Pay to</dt><dd>${c.payout_method.type || '—'} · <span class="mono">${c.payout_method.address || '—'}</span></dd>
++<dt>Requested</dt><dd>${when(c.created_at)}</dd>
++<dt>Escrow until</dt><dd>${when(c.escrow_until)} <span class="muted">(${relative(c.escrow_until, nowMs)})</span></dd>
++<dt>Request txn</dt><dd><code>${c.request_txn}</code></dd>
++${c.settle_txn ? html`<dt>Decision txn</dt><dd><code>${c.settle_txn}</code></dd>` : ''}
++${c.payout_reference ? html`<dt>Payout</dt><dd>${c.payout_provider} · <code>${c.payout_reference}</code></dd>` : ''}
++${c.reason ? html`<dt>Reason</dt><dd>${c.reason}</dd>` : ''}
++${c.decided_by && c.decided_by.principal ? html`<dt>Decided by</dt><dd><code>${c.decided_by.principal}</code></dd>` : ''}
++<dt>Creator now holds</dt><dd>${bits(balances.payable)} payable · ${bits(balances.pending)} pending payouts</dd>
++</dl></div>
++${pending ? html`<div>
++<form class="stack" method="post" action="/cashouts/${c.id}/approve"><input type="hidden" name="_csrf" value="${csrf}"><input type="hidden" name="action_key" value="${approveKey}">
++<fieldset ${inEscrow || frozen ? html`disabled` : ''}><legend>Approve — record the payout</legend>
++${inEscrow ? html`<p class="muted">Escrow runs until ${when(c.escrow_until)}; Billing refuses approval before then.</p>` : ''}
++<p class="muted">Pay ${usd(c.value_cents)} to the address above at the provider first, then enter the provider's payout reference.</p>
++<label>Payout provider<select name="payout_provider">${['paypal', 'wise', 'bank', 'crypto', 'other'].map((p) => html`<option value="${p}" ${(form.payout_provider || 'paypal') === p ? html`selected` : ''}>${p}</option>`)}</select></label>
++<label>Payout reference (required)<input type="text" name="payout_reference" maxlength="200" value="${form.payout_reference || ''}" autocomplete="off"></label>
++<label class="check"><input type="checkbox" name="confirm" value="yes"> I sent ${usd(c.value_cents)} to this creator and the reference above is the provider's.</label>
++<button class="primary" type="submit">Approve payout</button></fieldset></form>
++<p></p>
++<form class="stack" method="post" action="/cashouts/${c.id}/deny"><input type="hidden" name="_csrf" value="${csrf}"><input type="hidden" name="action_key" value="${denyKey}">
++<fieldset ${frozen ? html`disabled` : ''}><legend>Deny — return to the creator's payable</legend>
++<label>Reason (required, recorded)<textarea name="reason" maxlength="300">${form.reason || ''}</textarea></label>
++<button class="danger" type="submit">Deny cashout</button></fieldset></form></div>` : ''}
++</div>
++${history.length ? html`<h2>Staff actions on this cashout</h2>${auditTable(history)}` : ''}`,
++ });
++}
++
++function eventsTable(rows, { csrf, reprocess, frozen }) {
++ if (!rows.length) return html`<div class="card muted">None.</div>`;
++ return html`<div class="tablewrap"><table><thead><tr><th>#</th><th>Provider</th><th>Event</th><th>Type</th><th>Received</th><th>Result</th><th>Attempts</th>${reprocess ? html`<th></th>` : ''}</tr></thead><tbody>
++${rows.map((e) => html`<tr><td>${e.id}</td><td>${e.provider}</td><td class="mono">${e.provider_event_id}</td><td>${e.type || '—'}</td><td>${when(e.received_at)}</td>
++<td>${e.result ? html`${e.result.effect}${e.result.code ? html` · <code>${e.result.code}</code>` : ''}${e.result.reason ? html`<br><span class="muted">${e.result.reason}</span>` : ''}` : html`<span class="muted">not processed</span>${e.last_error ? html`<br><span class="muted">${e.last_error}</span>` : ''}`}</td>
++<td>${e.attempts}</td>${reprocess ? html`<td>${!e.processed_at || (e.result && e.result.effect === 'rejected') ? html`<form class="inline" method="post" action="/receipts/${e.id}/reprocess"><input type="hidden" name="_csrf" value="${csrf}"><button type="submit" ${frozen ? html`disabled` : ''}>Reprocess</button></form>` : ''}</td>` : ''}</tr>`)}
++</tbody></table></div>`;
++}
++
++function receipts({ staff, csrf, frozen, q, notice, error }) {
++ return layout({
++ title: 'Receipts to review', section: 'receipts', staff, csrf, frozen, notice, error, body: html`<h1>Provider receipts to review</h1>
++<p class="muted">Receipts are shown by id and outcome only; provider payloads stay in the database. Resolve a money difference with an adjustment through the API (<code>POST /api/v1/admin/adjustments</code>, <code>relates_to</code> = the transaction).</p>
++<h2>Flagged for review</h2><p class="muted">Unattributed tips to the site PowerChat account, underpaid deliveries, site-routed tips for an unknown streamer.</p>
++${eventsTable(q.review, { csrf, reprocess: false, frozen })}
++<h2>Rejected</h2><p class="muted">Refused receipts — including deliveries for orders Live already credited before the cutover (<code>billing.intent_settled_in_live</code>, never credited twice) and refunds for unknown payments. Reprocess only after fixing the cause.</p>
++${eventsTable(q.rejected, { csrf, reprocess: true, frozen })}
++<h2>Stored, not processed</h2><p class="muted">Held while frozen, or failing transiently; the service retries them every minute.</p>
++${eventsTable(q.unprocessed, { csrf, reprocess: true, frozen })}
++<h2>Reversals flagged for review</h2><p class="muted">Refunds and chargebacks on credit already given away: the recipient kept it, the loss was booked.</p>
++${q.transactions.length ? html`<div class="tablewrap"><table><thead><tr><th>Transaction</th><th>Type</th><th>Reverses</th><th>Provider</th><th>Cents</th><th>Unrecovered</th><th>At</th></tr></thead><tbody>
++${q.transactions.map((t) => html`<tr><td><code>${t.id}</code></td><td>${t.type}</td><td><code>${t.reverses_txn || '—'}</code></td><td>${t.provider || '—'}</td><td>${t.cents != null ? usd(t.cents) : '—'}</td>
++<td>${t.unrecovered_bits ? bits(t.unrecovered_bits) : t.creator_payable_kept_bits ? `${bits(t.creator_payable_kept_bits)} kept by creator` : '—'}</td><td>${when(t.created_at)}</td></tr>`)}
++</tbody></table></div>` : html`<div class="card muted">None.</div>`}`,
++ });
++}
++
++function holds({ staff, csrf, frozen, rows }) {
++ return layout({
++ title: 'Import holds', section: 'holds', staff, csrf, frozen, body: html`<h1>Import holds</h1>
++<p class="muted">Live users the importer could not map to a Network subject. Their balances sit on <code>hold:live:&lt;id&gt;</code> until a later import (after the Network maps them) releases them.</p>
++${rows.length ? html`<div class="tablewrap"><table><thead><tr><th>Live user</th><th>Account owner</th><th>Reason</th><th>Credit</th><th>Payable</th><th>Resolved to</th><th>First seen</th></tr></thead><tbody>
++${rows.map((h) => html`<tr><td>${h.live_user_id}</td><td><code>${h.owner}</code></td><td>${h.reason}</td><td>${bits(h.credit_bits)}</td><td>${bits(h.payable_bits)}</td><td>${h.resolved_subject ? html`<code>${h.resolved_subject}</code>` : html`<span class="bad">unresolved</span>`}</td><td>${when(h.first_seen_at)}</td></tr>`)}
++</tbody></table></div>` : html`<div class="card muted">No import holds.</div>`}`,
++ });
++}
++
++function reconciliation({ staff, csrf, frozen, runs, notice }) {
++ return layout({
++ title: 'Reconciliation', section: 'reconciliation', staff, csrf, frozen, notice, body: html`<h1>Reconciliation</h1>
++<form method="post" action="/reconciliation"><input type="hidden" name="_csrf" value="${csrf}"><button class="primary" type="submit">Run reconciliation now</button></form>
++<h2>History</h2>
++${runs.length ? html`<div class="tablewrap"><table><thead><tr><th>Run</th><th>Finished</th><th>Result</th><th>Failed checks</th></tr></thead><tbody>
++${runs.map((r) => html`<tr><td><a class="mono" href="/reconciliation/${r.id}">${r.id}</a></td><td>${when(r.finished_at)}</td><td>${okBad(r.ok)}</td><td>${r.failed.join(', ') || '—'}</td></tr>`)}
++</tbody></table></div>` : html`<div class="card muted">No runs yet.</div>`}`,
++ });
++}
++
++function reconciliationRun({ staff, csrf, frozen, run }) {
++ const t = run.totals || {};
++ return layout({
++ title: `Reconciliation ${run.id}`, section: 'reconciliation', staff, csrf, frozen, body: html`<h1>Reconciliation <span class="mono">${run.id}</span> ${okBad(run.ok)}</h1>
++<p class="muted">Started ${when(run.started_at)}, finished ${when(run.finished_at)}. The full report (with offending rows) is available to operators through <code>npm run reconcile</code> on the host.</p>
++<div class="cols"><div><h2>Checks</h2><div class="tablewrap"><table><thead><tr><th>Check</th><th>Result</th><th>Offenders</th></tr></thead><tbody>
++${run.checks.map((c) => html`<tr><td><code>${c.id}</code></td><td>${okBad(c.ok)}</td><td>${c.offenders || '—'}</td></tr>`)}
++</tbody></table></div></div>
++<div><h2>Warnings</h2><div class="tablewrap"><table><tbody>${Object.entries(run.warnings).map(([k, v]) => html`<tr><td>${k.replace(/_/g, ' ')}</td><td>${n(v)}</td></tr>`)}</tbody></table></div>
++<h2>Totals (test excluded where marked)</h2><div class="tablewrap"><table><tbody>${Object.entries(t).map(([k, v]) => html`<tr><td>${k.replace(/_/g, ' ')}</td><td>${typeof v === 'number' ? n(v) : String(v)}</td></tr>`)}</tbody></table></div></div></div>`,
++ });
++}
++
++function freeze({ staff, csrf, state, held, notice, error }) {
++ return layout({
++ title: 'Freeze', section: 'freeze', staff, csrf, frozen: state.frozen, notice, error, body: html`<h1>Economy freeze</h1>
++<div class="card"><dl><dt>State</dt><dd>${state.frozen ? html`<span class="bad">Frozen</span>` : html`<span class="ok">Open</span>`}</dd>
++${state.frozen ? html`<dt>Reason</dt><dd>${state.reason || '—'}</dd><dt>Since</dt><dd>${when(state.frozen_at)}</dd><dt>By</dt><dd><code>${(state.frozen_by && state.frozen_by.principal) || '—'}</code></dd>` : ''}
++<dt>Held webhooks</dt><dd>${n(held)}</dd></dl></div>
++<p class="muted">While frozen every money write (API and console) is refused with <code>billing.frozen</code>, reads are served, provider webhooks are stored and held, jobs pause. Unfreezing processes the held webhooks in arrival order.</p>
++<form class="stack" method="post" action="/freeze"><input type="hidden" name="_csrf" value="${csrf}"><input type="hidden" name="on" value="${state.frozen ? '0' : '1'}">
++<label>Reason (required, recorded)<textarea name="reason" maxlength="300"></textarea></label>
++<button class="${state.frozen ? 'primary' : 'danger'}" type="submit">${state.frozen ? 'Unfreeze the economy' : 'Freeze the economy'}</button></form>`,
++ });
++}
++
++function auditTable(rows) {
++ return html`<div class="tablewrap"><table><thead><tr><th>At</th><th>Actor</th><th>Action</th><th>Target</th><th>Outcome</th><th>Reason / detail</th><th>Request</th></tr></thead><tbody>
++${rows.map((r) => html`<tr><td>${when(r.at)}</td><td>${r.actor_username ? `@${r.actor_username} ` : ''}<code>${r.actor_subject || '—'}</code></td><td><code>${r.action}</code></td>
++<td>${r.target_type ? html`${r.target_type} <code>${r.target_id}</code>` : '—'}</td><td>${r.outcome === 'done' ? html`<span class="ok">done</span>` : html`<span class="bad">refused</span>`}</td>
++<td>${r.reason || ''}${Object.keys(r.detail || {}).length ? html`<br><span class="muted mono">${Object.entries(r.detail).map(([k, v]) => `${k}=${typeof v === 'object' ? JSON.stringify(v) : v}`).join(' ')}</span>` : ''}</td><td class="mono muted">${r.request_id || ''}</td></tr>`)}
++</tbody></table></div>`;
++}
++
++function audit({ staff, csrf, frozen, rows }) {
++ return layout({
++ title: 'Audit log', section: 'audit', staff, csrf, frozen, body: html`<h1>Staff audit log</h1>
++<p class="muted">Append-only: every staff action and every refused attempt, newest first (last 200). Done actions are also published as <code>billing.staff.action</code>.</p>
++${rows.length ? auditTable(rows) : html`<div class="card muted">Empty.</div>`}`,
++ });
++}
++
++module.exports = { CSP, html, esc, pages: { signIn, message, dashboard, cashouts, cashout, receipts, holds, reconciliation, reconciliationRun, freeze, audit } };
ddiff --git a/server/db.js b/server/db.js
iindex 5c87f99..9df9aa9 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -240,6 +240,43 @@ CREATE TABLE IF NOT EXISTS outbox (
last_error TEXT
);
CREATE INDEX IF NOT EXISTS outbox_unsent ON outbox (sent_at, seq);
++
++-- Staff console (server/console). Every staff action — and every refused one — is one row here,
++-- never updated or deleted. Done actions are also announced as billing.staff.action (outbox).
++CREATE TABLE IF NOT EXISTS staff_audit (
++ seq INTEGER PRIMARY KEY AUTOINCREMENT,
++ id TEXT NOT NULL UNIQUE,
++ at TEXT NOT NULL,
++ actor_subject TEXT,
++ actor_username TEXT,
++ action TEXT NOT NULL,
++ target_type TEXT,
++ target_id TEXT,
++ reason TEXT,
++ outcome TEXT NOT NULL CHECK (outcome IN ('done', 'refused')),
++ detail TEXT NOT NULL DEFAULT '{}',
++ request_id TEXT,
++ ip_hash TEXT
++);
++CREATE INDEX IF NOT EXISTS staff_audit_target ON staff_audit (target_type, target_id);
++CREATE TRIGGER IF NOT EXISTS staff_audit_no_update BEFORE UPDATE ON staff_audit
++BEGIN SELECT RAISE(ABORT, 'staff_audit is append-only'); END;
++CREATE TRIGGER IF NOT EXISTS staff_audit_no_delete BEFORE DELETE ON staff_audit
++BEGIN SELECT RAISE(ABORT, 'staff_audit is append-only'); END;
++
++-- Console sessions: the cookie holds a random id, the table only its SHA-256.
++CREATE TABLE IF NOT EXISTS staff_sessions (
++ id_hash TEXT PRIMARY KEY,
++ subject TEXT NOT NULL,
++ username TEXT,
++ role TEXT NOT NULL,
++ csrf TEXT NOT NULL,
++ created_at TEXT NOT NULL,
++ expires_at TEXT NOT NULL,
++ revoked_at TEXT,
++ ip_hash TEXT
++);
++CREATE INDEX IF NOT EXISTS staff_sessions_expiry ON staff_sessions (expires_at);
`;
function openDb(dbPath) {
ddiff --git a/server/outbox.js b/server/outbox.js
iindex ae711e6..37714fe 100644
---- a/server/outbox.js
++++ b/server/outbox.js
@@@ -12,14 +12,15 @@ const { ids, validate, serviceAuth } = require('openvibe-contracts');
const ACTOR = { type: 'service', id: 'billing' };
--function enqueue(ctx, { event_type, subject, payload, priority = 'important', traceId }) {
++/** actor defaults to the service itself; staff actions pass the person ({ type: 'user', id }). */
++function enqueue(ctx, { event_type, subject, payload, priority = 'important', traceId, actor }) {
const ms = ctx.now();
const env = {
event_id: ids.newId('event', ms),
event_type,
version: 1,
source: 'billing',
-- actor: ACTOR,
++ actor: actor || ACTOR,
timestamp: new Date(ms).toISOString(),
priority,
visibility: 'internal',
ddiff --git a/test/console.test.js b/test/console.test.js
nnew file mode 100644
iindex 0000000..a263d48
---- /dev/null
++++ b/test/console.test.js
@@@ -0,0 +1,374 @@
++'use strict';
++/**
++ * The staff console: Network SSO with PKCE, staff-only access (Network role admin AND listed in
++ * BILLING_STAFF_SUBJECTS), session cookie attributes, CSRF on every form, cashout approve/deny
++ * through ops/cashouts with the payout reference and escrow enforced, the freeze, reconciliation,
++ * review queue — every action audited once in staff_audit and announced as billing.staff.action —
++ * and no secrets or provider payloads on any page.
++ */
++const assert = require('assert');
++const { boot, fund, check, done } = require('./helpers/app');
++const { startNetwork } = require('./helpers/stubs');
++
++const SESSION_SECRET = 'console-test-secret-0123456789abcdef-0123456789';
++const DAY = 86_400_000;
++
++(async () => {
++ const network = await startNetwork();
++ const staff = network.addUser(); // admin + listed
++ const listedNonAdmin = network.addUser(); // listed, but Network role 'user'
++ const unlistedAdmin = network.addUser(); // Network admin, not Billing staff
++ const t = await boot({ network, env: { BASE_URL: 'https://billing.test', BILLING_STAFF_SUBJECTS: `${staff}, ${listedNonAdmin}, not-a-subject`, BILLING_SESSION_SECRET: SESSION_SECRET } });
++ const fan = t.user(21);
++ const creator = t.user(22);
++ console.log('staff console');
++
++ const setCookies = (res) => (res.headers.getSetCookie ? res.headers.getSetCookie() : []);
++ async function get(path, cookie, headers = {}) {
++ const res = await fetch(t.base + path, { redirect: 'manual', headers: { ...(cookie ? { Cookie: cookie } : {}), ...headers } });
++ return { status: res.status, headers: res.headers, text: await res.text(), cookies: setCookies(res) };
++ }
++ async function post(path, cookie, form, headers = {}) {
++ const res = await fetch(t.base + path, {
++ method: 'POST', redirect: 'manual',
++ headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(cookie ? { Cookie: cookie } : {}), ...headers },
++ body: new URLSearchParams(form).toString(),
++ });
++ return { status: res.status, headers: res.headers, text: await res.text(), cookies: setCookies(res) };
++ }
++ async function signIn(subject, { role = 'admin', username = 'staffer', next = '/cashouts' } = {}) {
++ const login = await get(`/auth/login?next=${encodeURIComponent(next)}`);
++ assert.strictEqual(login.status, 302);
++ const loc = new URL(login.headers.get('location'));
++ const flow = login.cookies.find((c) => c.startsWith('ovb_flow='));
++ const code = network.authorize({
++ subject_id: subject, role, username, challenge: loc.searchParams.get('code_challenge'),
++ redirect_uri: loc.searchParams.get('redirect_uri'), nowMs: Date.now() + t.clock.offset,
++ });
++ const cb = await get(`/auth/callback?code=${code}&state=${encodeURIComponent(loc.searchParams.get('state'))}`, flow.split(';')[0]);
++ const sess = cb.cookies.find((c) => c.startsWith('__Host-ovb_staff=') && !c.startsWith('__Host-ovb_staff=;'));
++ return { cb, loc, flow, setCookie: sess || null, cookie: sess ? sess.split(';')[0] : null };
++ }
++ const csrfOf = (page) => (page.text.match(/name="_csrf" value="([^"]+)"/) || [])[1];
++ const keyOf = (page, action) => (page.text.match(new RegExp(`action="${action}"><input type="hidden" name="_csrf" value="[^"]+"><input type="hidden" name="action_key" value="([^"]+)"`)) || [])[1];
++ const auditRows = (action, outcome = 'done') => t.db.prepare('SELECT * FROM staff_audit WHERE action = ? AND outcome = ?').all(action, outcome);
++ const staffEvents = (action) => t.db.prepare("SELECT event FROM outbox WHERE json_extract(event, '$.event_type') = 'billing.staff.action' AND json_extract(event, '$.payload.action') = ?").all(action).map((r) => JSON.parse(r.event));
++ async function newCashout(amount = 600) {
++ const r = await t.call('POST', '/api/v1/cashouts', { body: { subject: creator, amount, payout_method: { type: 'paypal', address: '[email protected]' } } });
++ assert.strictEqual(r.status, 201, r.text);
++ return r.json.cashout.id;
++ }
++
++ // Money for the cashouts: the fan buys credit and tips the creator (creator payable).
++ await fund(t, fan, 5000);
++ const tip = await t.call('POST', '/api/v1/transfers', { body: { from: fan, to: creator, amount: 3000 } });
++ assert.strictEqual(tip.status, 201, tip.text);
++
++ let s; // the staff session cookie
++
++ await check('sign-in redirects to the Network with PKCE S256 and a signed, host-only flow cookie', async () => {
++ const login = await get('/auth/login?next=/cashouts');
++ assert.strictEqual(login.status, 302);
++ const loc = new URL(login.headers.get('location'));
++ assert.strictEqual(loc.origin + loc.pathname, 'https://openvibe.network/oauth/authorize');
++ assert.strictEqual(loc.searchParams.get('client_id'), 'billing');
++ assert.strictEqual(loc.searchParams.get('redirect_uri'), 'https://billing.test/auth/callback');
++ assert.strictEqual(loc.searchParams.get('code_challenge_method'), 'S256');
++ assert.match(loc.searchParams.get('code_challenge'), /^[A-Za-z0-9_-]{43}$/);
++ assert.ok(loc.searchParams.get('state').length >= 32);
++ const flow = login.cookies.find((c) => c.startsWith('ovb_flow='));
++ assert.ok(flow, 'flow cookie');
++ for (const attr of ['HttpOnly', 'Secure', 'Path=/auth', 'SameSite=Lax']) assert.ok(flow.includes(attr), `flow cookie ${attr}: ${flow}`);
++ assert.ok(!/domain=/i.test(flow));
++ assert.ok(!flow.includes(loc.searchParams.get('code_challenge')), 'the cookie carries the verifier, never the challenge');
++ });
++
++ await check('the callback refuses a missing or mismatched state and a forged flow cookie', async () => {
++ const login = await get('/auth/login');
++ const loc = new URL(login.headers.get('location'));
++ const flow = login.cookies.find((c) => c.startsWith('ovb_flow=')).split(';')[0];
++ const code = network.authorize({ subject_id: staff, role: 'admin', challenge: loc.searchParams.get('code_challenge'), redirect_uri: loc.searchParams.get('redirect_uri') });
++ assert.strictEqual((await get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`)).status, 400, 'no flow cookie');
++ assert.strictEqual((await get(`/auth/callback?code=${code}&state=wrong`, flow)).status, 400, 'wrong state');
++ const [body] = flow.slice('ovb_flow='.length).split('.');
++ assert.strictEqual((await get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`, `ovb_flow=${body}.forged`)).status, 400, 'forged signature');
++ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM staff_sessions').get().n, 0);
++ });
++
++ await check('a code bound to another PKCE challenge is refused by the Network, no session', async () => {
++ const login = await get('/auth/login');
++ const loc = new URL(login.headers.get('location'));
++ const flow = login.cookies.find((c) => c.startsWith('ovb_flow=')).split(';')[0];
++ const code = network.authorize({ subject_id: staff, role: 'admin', challenge: 'A'.repeat(43), redirect_uri: loc.searchParams.get('redirect_uri') });
++ const cb = await get(`/auth/callback?code=${code}&state=${encodeURIComponent(loc.searchParams.get('state'))}`, flow);
++ assert.strictEqual(cb.status, 400);
++ assert.ok(!cb.cookies.some((c) => c.startsWith('__Host-ovb_staff=') && !c.startsWith('__Host-ovb_staff=;')));
++ });
++
++ await check('non-staff get 403 and no session: a Network admin not listed, a listed non-admin', async () => {
++ const a = await signIn(unlistedAdmin, { role: 'admin' });
++ assert.strictEqual(a.cb.status, 403);
++ assert.strictEqual(a.cookie, null);
++ const b = await signIn(listedNonAdmin, { role: 'user' });
++ assert.strictEqual(b.cb.status, 403);
++ assert.strictEqual(b.cookie, null);
++ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM staff_sessions').get().n, 0);
++ const refused = auditRows('session.sign_in', 'refused');
++ assert.deepStrictEqual(refused.map((r) => r.actor_subject).sort(), [listedNonAdmin, unlistedAdmin].sort());
++ assert.strictEqual(staffEvents('session.sign_in').length, 0, 'refusals are not published');
++ });
++
++ await check('staff sign in: host-only, HttpOnly, Secure, SameSite=Strict, short-lived session cookie', async () => {
++ const r = await signIn(staff, { username: 'goosely' });
++ assert.strictEqual(r.cb.status, 303);
++ assert.strictEqual(r.cb.headers.get('location'), '/cashouts');
++ assert.ok(r.setCookie, 'session cookie set');
++ for (const attr of ['Path=/', 'HttpOnly', 'Secure', 'SameSite=Strict']) assert.ok(r.setCookie.includes(attr), `${attr}: ${r.setCookie}`);
++ assert.ok(!/domain=/i.test(r.setCookie), 'host-only');
++ const maxAge = Number((r.setCookie.match(/Max-Age=(\d+)/) || [])[1]);
++ assert.ok(maxAge > 0 && maxAge <= 3600, `max-age ${maxAge}`);
++ s = r.cookie;
++ const row = t.db.prepare('SELECT * FROM staff_sessions').get();
++ assert.notStrictEqual(row.id_hash, s.split('=')[1], 'only a hash of the session id is stored');
++ assert.strictEqual(auditRows('session.sign_in').length, 1);
++ assert.strictEqual(staffEvents('session.sign_in').length, 1);
++ });
++
++ await check('pages: staff only, server-rendered, no script, noindex, no-store, strict CSP', async () => {
++ for (const p of ['/', '/cashouts', '/cashouts?tab=paid', '/receipts', '/import-holds', '/reconciliation', '/freeze', '/audit']) {
++ const r = await get(p, s);
++ assert.strictEqual(r.status, 200, `${p}: ${r.status}`);
++ assert.ok(!/<script/i.test(r.text), `${p} has no script`);
++ assert.ok(r.text.includes('<meta name="robots" content="noindex,nofollow">'), p);
++ assert.strictEqual(r.headers.get('x-robots-tag'), 'noindex, nofollow');
++ assert.strictEqual(r.headers.get('cache-control'), 'no-store');
++ assert.match(r.headers.get('content-security-policy'), /default-src 'none'.*form-action 'self'.*frame-ancestors 'none'/);
++ assert.strictEqual(r.headers.get('x-frame-options'), 'DENY');
++ }
++ const anon = await get('/cashouts');
++ assert.strictEqual(anon.status, 401);
++ assert.ok(anon.text.includes('/auth/login?next=%2Fcashouts'));
++ assert.strictEqual((await get('/', null)).status, 200, 'the root shows the sign-in page');
++ assert.strictEqual((await get('/robots.txt')).text, 'User-agent: *\nDisallow: /\n');
++ });
++
++ await check('the staff session is not an API credential: /api/v1 still needs a service token', async () => {
++ const r = await get('/api/v1/cashouts', s);
++ assert.strictEqual(r.status, 401);
++ });
++
++ let co1;
++ await check('CSRF: a POST without the token, with a wrong one, or from another site changes nothing', async () => {
++ co1 = await newCashout(600);
++ const page = await get(`/cashouts/${co1}`, s);
++ const csrf = csrfOf(page);
++ const key = keyOf(page, `/cashouts/${co1}/approve`);
++ assert.ok(csrf && key);
++ const form = { payout_reference: 'PP-1', payout_provider: 'paypal', confirm: 'yes', action_key: key };
++ assert.strictEqual((await post(`/cashouts/${co1}/approve`, s, form)).status, 403);
++ assert.strictEqual((await post(`/cashouts/${co1}/approve`, s, { ...form, _csrf: 'x'.repeat(43) })).status, 403);
++ assert.strictEqual((await post(`/cashouts/${co1}/approve`, s, { ...form, _csrf: csrf }, { Origin: 'https://evil.example' })).status, 403);
++ assert.strictEqual((await post(`/cashouts/${co1}/approve`, s, { ...form, _csrf: csrf }, { 'Sec-Fetch-Site': 'cross-site' })).status, 403);
++ assert.strictEqual((await post('/freeze', s, { on: '1', reason: 'csrf test' })).status, 403);
++ assert.strictEqual((await post('/auth/logout', s, {})).status, 403);
++ assert.strictEqual(t.db.prepare('SELECT status FROM cashouts WHERE id = ?').get(co1).status, 'requested');
++ assert.strictEqual(t.db.prepare('SELECT freeze FROM settings').get().freeze, 0);
++ assert.ok(auditRows('request.csrf', 'refused').length >= 5);
++ assert.strictEqual((await get('/cashouts', s)).status, 200, 'the session survived');
++ });
++
++ await check('approve is refused without a payout reference, without the confirmation, and before the escrow ends', async () => {
++ const page = await get(`/cashouts/${co1}`, s);
++ const csrf = csrfOf(page);
++ const key = keyOf(page, `/cashouts/${co1}/approve`);
++ assert.ok(page.text.includes('in escrow'), 'shown as in escrow');
++ const noRef = await post(`/cashouts/${co1}/approve`, s, { _csrf: csrf, action_key: key, payout_reference: ' ', payout_provider: 'paypal', confirm: 'yes' });
++ assert.strictEqual(noRef.status, 422);
++ assert.ok(noRef.text.includes('billing.payout_reference_required'));
++ const noConfirm = await post(`/cashouts/${co1}/approve`, s, { _csrf: csrf, action_key: key, payout_reference: 'PP-1', payout_provider: 'paypal' });
++ assert.strictEqual(noConfirm.status, 422);
++ const early = await post(`/cashouts/${co1}/approve`, s, { _csrf: csrf, action_key: key, payout_reference: 'PP-1', payout_provider: 'paypal', confirm: 'yes' });
++ assert.strictEqual(early.status, 409);
++ assert.ok(early.text.includes('billing.escrow_active'));
++ assert.strictEqual(t.db.prepare('SELECT status FROM cashouts WHERE id = ?').get(co1).status, 'requested');
++ assert.strictEqual(auditRows('cashout.approve').length, 0);
++ assert.strictEqual(auditRows('cashout.approve', 'refused').length, 3);
++ assert.strictEqual(staffEvents('cashout.approve').length, 0);
++ });
++
++ let co2;
++ await check('after the escrow: approve records the payout once (a resubmitted form replays), audited once', async () => {
++ co2 = await newCashout(700);
++ t.clock.offset = 15 * DAY;
++ assert.strictEqual((await get('/cashouts', s)).status, 401, 'the 60-minute session expired meanwhile');
++ s = (await signIn(staff, { username: 'goosely' })).cookie;
++ const ready = await get('/cashouts?tab=ready', s);
++ assert.ok(ready.text.includes(co1) && ready.text.includes(co2));
++ const page = await get(`/cashouts/${co1}`, s);
++ const form = { _csrf: csrfOf(page), action_key: keyOf(page, `/cashouts/${co1}/approve`), payout_reference: 'PAYOUT-7Q2', payout_provider: 'paypal', confirm: 'yes' };
++ const r = await post(`/cashouts/${co1}/approve`, s, form);
++ assert.strictEqual(r.status, 303, r.text);
++ assert.strictEqual(r.headers.get('location'), `/cashouts/${co1}?done=approved`);
++ const again = await post(`/cashouts/${co1}/approve`, s, form);
++ assert.strictEqual(again.status, 303);
++ assert.strictEqual(again.headers.get('location'), `/cashouts/${co1}?done=already_paid`);
++ const c = t.db.prepare('SELECT * FROM cashouts WHERE id = ?').get(co1);
++ assert.strictEqual(c.status, 'paid');
++ assert.strictEqual(c.payout_reference, 'PAYOUT-7Q2');
++ assert.strictEqual(JSON.parse(c.decided_by).principal, staff);
++ const rows = auditRows('cashout.approve');
++ assert.strictEqual(rows.length, 1);
++ assert.strictEqual(rows[0].actor_subject, staff);
++ assert.strictEqual(rows[0].target_id, co1);
++ assert.ok(rows[0].request_id && rows[0].ip_hash && !rows[0].ip_hash.includes('127.0.0.1'));
++ assert.strictEqual(JSON.parse(rows[0].detail).payout_reference, 'PAYOUT-7Q2');
++ const ev = staffEvents('cashout.approve');
++ assert.strictEqual(ev.length, 1);
++ assert.deepStrictEqual(ev[0].actor, { type: 'user', id: staff });
++ assert.strictEqual(ev[0].visibility, 'internal');
++ assert.deepStrictEqual(ev[0].payload.target, { type: 'cashout', id: co1 });
++ assert.strictEqual(t.db.prepare("SELECT COUNT(*) AS n FROM outbox WHERE json_extract(event, '$.event_type') = 'billing.cashout.paid'").get().n, 1);
++ const shown = await get(`/cashouts/${co1}?done=approved`, s);
++ assert.ok(shown.text.includes('Payout recorded') && shown.text.includes('PAYOUT-7Q2'));
++ t.assertReconciled('after a console approval');
++ });
++
++ await check('deny needs a reason; it returns the amount to payable and is audited once', async () => {
++ const before = (await t.balances(creator.id)).payable;
++ const page = await get(`/cashouts/${co2}`, s);
++ const csrf = csrfOf(page);
++ const key = keyOf(page, `/cashouts/${co2}/deny`);
++ const noReason = await post(`/cashouts/${co2}/deny`, s, { _csrf: csrf, action_key: key, reason: '' });
++ assert.strictEqual(noReason.status, 422);
++ const r = await post(`/cashouts/${co2}/deny`, s, { _csrf: csrf, action_key: key, reason: 'payout address bounced' });
++ assert.strictEqual(r.status, 303, r.text);
++ assert.strictEqual((await post(`/cashouts/${co2}/deny`, s, { _csrf: csrf, action_key: key, reason: 'payout address bounced' })).headers.get('location'), `/cashouts/${co2}?done=already_denied`);
++ assert.strictEqual(t.db.prepare('SELECT status FROM cashouts WHERE id = ?').get(co2).status, 'denied');
++ assert.strictEqual((await t.balances(creator.id)).payable, before + 700);
++ const rows = auditRows('cashout.deny');
++ assert.strictEqual(rows.length, 1);
++ assert.strictEqual(rows[0].reason, 'payout address bounced');
++ assert.strictEqual(staffEvents('cashout.deny').length, 1);
++ const approveDenied = await post(`/cashouts/${co2}/approve`, s, { _csrf: csrf, action_key: keyOf(page, `/cashouts/${co2}/approve`), payout_reference: 'X-1', payout_provider: 'paypal', confirm: 'yes' });
++ assert.strictEqual(approveDenied.status, 409, 'a denied cashout cannot be paid');
++ t.assertReconciled('after a console denial');
++ });
++
++ await check('freeze toggles with a reason, refuses money actions while on, and is audited', async () => {
++ const page = await get('/freeze', s);
++ const csrf = csrfOf(page);
++ assert.strictEqual((await post('/freeze', s, { _csrf: csrf, on: '1', reason: '' })).status, 422);
++ const on = await post('/freeze', s, { _csrf: csrf, on: '1', reason: 'cutover rehearsal' });
++ assert.strictEqual(on.status, 303);
++ assert.strictEqual(t.db.prepare('SELECT freeze FROM settings').get().freeze, 1);
++ assert.strictEqual((await post('/freeze', s, { _csrf: csrf, on: '1', reason: 'again' })).status, 409, 'no double toggle');
++ const co3 = await newCashout(500).catch(() => null);
++ assert.strictEqual(co3, null, 'the API refuses writes while frozen');
++ const api = await t.call('POST', '/api/v1/transfers', { body: { from: fan, to: creator, amount: 5 } });
++ assert.strictEqual(api.status, 503);
++ // A held delivery while frozen: stored, processed on unfreeze.
++ const held = await t.powerchat({ type: 'donation.completed', streamer: { username: 'openvibe' }, data: { eventId: 'console-held-1', amountUsdCents: 300 } });
++ assert.strictEqual(held.status, 202);
++ const dash = await get('/', s);
++ assert.ok(dash.text.includes('The economy is frozen'));
++ const off = await post('/freeze', s, { _csrf: csrf, on: '0', reason: 'rehearsal done' });
++ assert.strictEqual(off.status, 303);
++ assert.match(off.headers.get('location'), /done=unfrozen&processed=1/);
++ assert.strictEqual(t.db.prepare('SELECT freeze FROM settings').get().freeze, 0);
++ assert.strictEqual(auditRows('economy.freeze').length, 1);
++ assert.strictEqual(auditRows('economy.freeze')[0].reason, 'cutover rehearsal');
++ assert.strictEqual(auditRows('economy.unfreeze').length, 1);
++ assert.strictEqual(auditRows('economy.freeze', 'refused').length, 2);
++ assert.strictEqual(staffEvents('economy.freeze').length, 1);
++ assert.strictEqual(staffEvents('economy.unfreeze').length, 1);
++ assert.strictEqual(JSON.parse(t.db.prepare('SELECT frozen_by FROM settings').get().frozen_by || 'null'), null);
++ });
++
++ await check('a money action is refused while frozen (same guard as the API)', async () => {
++ const co4 = await newCashout(500);
++ const csrf = csrfOf(await get('/freeze', s));
++ await post('/freeze', s, { _csrf: csrf, on: '1', reason: 'guard test' });
++ const page = await get(`/cashouts/${co4}`, s);
++ const r = await post(`/cashouts/${co4}/deny`, s, { _csrf: csrf, action_key: keyOf(page, `/cashouts/${co4}/deny`), reason: 'while frozen' });
++ assert.strictEqual(r.status, 503);
++ assert.ok(r.text.includes('billing.frozen'));
++ assert.strictEqual(t.db.prepare('SELECT status FROM cashouts WHERE id = ?').get(co4).status, 'requested');
++ await post('/freeze', s, { _csrf: csrf, on: '0', reason: 'guard test done' });
++ });
++
++ await check('reconciliation runs from the console, is stored, listed and audited', async () => {
++ const csrf = csrfOf(await get('/reconciliation', s));
++ const r = await post('/reconciliation', s, { _csrf: csrf });
++ assert.strictEqual(r.status, 303);
++ const id = r.headers.get('location').split('/').pop();
++ assert.match(id, /^rec_/);
++ const run = await get(`/reconciliation/${id}`, s);
++ assert.strictEqual(run.status, 200);
++ assert.ok(run.text.includes('journal.zero_sum'));
++ assert.ok((await get('/reconciliation', s)).text.includes(id));
++ assert.ok((await get('/', s)).text.includes(`/reconciliation/${id}`), 'the dashboard shows the last run');
++ assert.strictEqual(auditRows('reconciliation.run').length, 1);
++ assert.strictEqual(auditRows('reconciliation.run')[0].target_id, id);
++ });
++
++ await check('receipts needing review are listed without payloads; the console shows no secrets', async () => {
++ const marker = 'PAYLOAD-MARKER-9f3a';
++ const flagged = await t.powerchat({ type: 'donation.completed', streamer: { username: 'openvibe' }, data: { eventId: 'console-unattributed', amountUsdCents: 500, donorName: 'Donor Name', message: marker, donorEmail: '[email protected]' } });
++ assert.strictEqual(flagged.status, 200, JSON.stringify(flagged.json));
++ const rejected = await t.powerchat({ type: 'donation.refunded', streamer: { username: 'openvibe' }, data: { eventId: 'console-refund-x', originalEventId: 'nope', note: marker } });
++ assert.strictEqual(rejected.json.result.effect, 'rejected');
++ const page = await get('/receipts', s);
++ assert.ok(page.text.includes(`<td>${flagged.json.event}</td>`) && page.text.includes('unattributed tip to the site PowerChat account'), 'the flagged site tip is listed');
++ assert.ok(page.text.includes(`<td>${rejected.json.event}</td>`) && page.text.includes('rejected'), 'the rejected refund is listed');
++ // Reprocess the rejected one: audited (still rejected — the cause is not fixed).
++ const csrf = csrfOf(page);
++ const re = await post(`/receipts/${rejected.json.event}/reprocess`, s, { _csrf: csrf });
++ assert.strictEqual(re.status, 303, re.text);
++ assert.strictEqual(auditRows('provider_event.reprocess').length, 1);
++ assert.strictEqual((await post('/receipts/99999/reprocess', s, { _csrf: csrf })).status, 404);
++ assert.strictEqual(auditRows('provider_event.reprocess', 'refused').length, 1);
++
++ const secrets = [marker, '[email protected]', 'Donor Name', 'pc-secret', SESSION_SECRET, s.split('=')[1], '"payload"'];
++ for (const p of ['/', '/receipts', '/cashouts', `/cashouts/${co1}`, '/reconciliation', '/freeze', '/audit', '/import-holds']) {
++ const body = (await get(p, s)).text;
++ for (const x of secrets) assert.ok(!body.includes(x), `${p} shows ${x}`);
++ }
++ const runId = t.db.prepare('SELECT id FROM reconciliation_runs ORDER BY finished_at DESC LIMIT 1').get().id;
++ const csrf2 = csrfOf(await get('/reconciliation', s));
++ const rid = (await post('/reconciliation', s, { _csrf: csrf2 })).headers.get('location');
++ const rep = (await get(rid, s)).text;
++ assert.ok(runId && !rep.includes(marker) && !rep.includes('[email protected]'));
++ });
++
++ await check('the audit log is append-only and shown to staff', async () => {
++ assert.throws(() => t.db.prepare("UPDATE staff_audit SET reason = 'x'").run(), /append-only/);
++ assert.throws(() => t.db.prepare('DELETE FROM staff_audit').run(), /append-only/);
++ const page = await get('/audit', s);
++ assert.ok(page.text.includes('cashout.approve') && page.text.includes('economy.freeze'));
++ });
++
++ await check('removed from BILLING_STAFF_SUBJECTS → the next request is refused and the session ends', async () => {
++ const list = t.config.console.staffSubjects;
++ const saved = [...list];
++ list.splice(0, list.length);
++ const r = await get('/cashouts', s);
++ assert.strictEqual(r.status, 403);
++ list.push(...saved);
++ assert.strictEqual((await get('/cashouts', s)).status, 401, 'the session stays revoked');
++ s = (await signIn(staff)).cookie;
++ });
++
++ await check('sign out needs CSRF, ends the session and is audited', async () => {
++ const csrf = csrfOf(await get('/', s));
++ const r = await post('/auth/logout', s, { _csrf: csrf });
++ assert.strictEqual(r.status, 303);
++ assert.ok(r.cookies.some((c) => c.startsWith('__Host-ovb_staff=;') && c.includes('Max-Age=0')));
++ assert.strictEqual((await get('/cashouts', s)).status, 401);
++ assert.strictEqual(auditRows('session.sign_out').length, 1);
++ assert.ok(network.revoked.length >= 3, 'every Network refresh token handed to Billing was revoked, never kept');
++ });
++
++ await t.close();
++ await network.close();
++ done();
++})();
ddiff --git a/test/cutover.test.js b/test/cutover.test.js
iindex 0671632..24972b6 100644
---- a/test/cutover.test.js
++++ b/test/cutover.test.js
@@@ -79,6 +79,11 @@ function fabricateLive(file) {
assert.strictEqual(bal('user_credit', alice), 500);
const rec = t.assertReconciled('after a refused legacy delivery');
assert.ok(rec.warnings.rejected_events.some((e) => e.id === r.json.event), 'listed for review');
++ const queue = require('../server/console/queries').reviewQueue(t.db);
++ const shown = queue.rejected.find((e) => e.id === r.json.event);
++ assert.ok(shown, 'listed in the staff console review queue');
++ assert.strictEqual(shown.result.code, 'billing.intent_settled_in_live');
++ assert.ok(!('payload' in shown), 'without its payload');
});
await check('a subscription order Live credited is not paid (or granted) again either', async () => {
ddiff --git a/test/helpers/stubs.js b/test/helpers/stubs.js
iindex 5f9c76c..949d210 100644
---- a/test/helpers/stubs.js
++++ b/test/helpers/stubs.js
@@@ -3,7 +3,8 @@
* Stand-ins for the services Billing talks to, each on a random port with a real RS256 key pair.
*
* startNetwork() JWKS, client-credentials token endpoint (scope → cap), resolve-batch
-- * (insists on a service token for openvibe.network holding identity.subject.resolve)
++ * (insists on a service token for openvibe.network holding identity.subject.resolve),
++ * and SSO: authorize() issues a PKCE-bound code, /oauth/token redeems it, /oauth/revoke
* startStripe() /checkout/sessions and /subscriptions/:id, recording calls; `fail` makes it 500
* startEvents() POST /api/v1/events recording batches and the bearer tokens it saw
*/
@@@ -33,6 +34,23 @@ async function startNetwork() {
const now = Math.floor(Date.now() / 1000);
return serviceAuth.signServiceToken({ iss: iss || issuer, sub, actor_type: 'service', aud, cap, iat: now, exp: now + expSec, jti: crypto.randomBytes(8).toString('hex') }, privatePem);
}
++ // SSO: authorize() stands in for the account chooser + /oauth/confirm — it issues a code bound
++ // to the PKCE challenge and redirect URI; /oauth/token (authorization_code) checks both and
++ // returns a user access token shaped like Network's issueTokenPair().
++ const codes = new Map();
++ const revoked = [];
++ function authorize({ subject_id, role = 'user', username = 'someone', challenge, redirect_uri, nowMs = Date.now() }) {
++ const code = crypto.randomBytes(16).toString('hex');
++ codes.set(code, { subject_id, role, username, challenge, redirect_uri, nowMs });
++ return code;
++ }
++ function userToken(u) {
++ const now = Math.floor(u.nowMs / 1000);
++ return serviceAuth.signServiceToken({
++ sub: 42, id: 42, subject_id: u.subject_id, username: u.username, display_name: u.username, role: u.role,
++ iss: issuer, aud: ['openvibe.live', 'openvibe.tools', 'openvibe.games', 'openvibe.media', 'openvibe.network'], iat: now, exp: now + 86400,
++ }, privatePem);
++ }
function addUser(liveId) {
const subject = ids.newId('user');
if (liveId != null) legacy[String(liveId)] = subject;
@@@ -46,9 +64,19 @@ async function startNetwork() {
const body = Object.fromEntries(new URLSearchParams(raw));
grants.push(body);
if (body.client_secret !== 'shh') return send(res, 401, { error: 'invalid_client' });
++ if (body.grant_type === 'authorization_code') {
++ const c = codes.get(body.code);
++ if (!c) return send(res, 400, { error: 'invalid_grant', error_description: 'Invalid authorization code' });
++ codes.delete(body.code);
++ if (c.redirect_uri !== body.redirect_uri) return send(res, 400, { error: 'invalid_grant', error_description: 'Redirect URI mismatch' });
++ const digest = crypto.createHash('sha256').update(String(body.code_verifier || '')).digest('base64url');
++ if (!c.challenge || digest !== c.challenge) return send(res, 400, { error: 'invalid_grant', error_description: 'PKCE verification failed' });
++ return send(res, 200, { access_token: userToken(c), refresh_token: `rt_${crypto.randomBytes(8).toString('hex')}`, token_type: 'Bearer', expires_in: 86400, user: { id: 42, role: c.role } });
++ }
const cap = String(body.scope || '').split(/\s+/).filter(Boolean);
return send(res, 200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience], cap }), token_type: 'Bearer', expires_in: 300 });
}
++ if (req.url === '/oauth/revoke' && req.method === 'POST') { revoked.push(JSON.parse(raw || '{}').token); return send(res, 200, { revoked: true }); }
if (req.url === '/internal/identity/resolve-batch' && req.method === 'POST') {
const auth = String(req.headers.authorization || '');
const v = serviceAuth.verifyServiceToken(auth.slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });
@@@ -68,7 +96,7 @@ async function startNetwork() {
});
const url = await listen(server);
issuer = url;
-- return { url, publicPem, signService, addUser, legacy, resolveCalls, grants, state, close: () => new Promise((r) => server.close(r)) };
++ return { url, publicPem, signService, addUser, authorize, revoked, legacy, resolveCalls, grants, state, close: () => new Promise((r) => server.close(r)) };
}
async function startStripe() {