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
Changes
@@@ -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@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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:<id></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 } };@@@ -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) {@@@ -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',nnew file mode 100644@@@ -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();++})();@@@ -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 () => {@@@ -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() {
@@ -7,6 +7,7 @@
@@ -7,6 +7,7 @@
* GET /api/health, /api/ready liveness / readiness
* GET /api/health, /api/ready liveness / readiness
* /api/v1/* operations API (service tokens, see api/v1.js)
* /api/v1/* operations API (service tokens, see api/v1.js)
* /webhooks/<provider> provider receipts (see api/webhooks.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.
* createApp({ config, db, keys, identity, adapters, now, fetchImpl, log }) — everything injectable.
*/
*/
@@ -19,6 +20,7 @@ const { createKeyProvider, createIdentity } = require('./network');
@@ -19,6 +20,7 @@ const { createKeyProvider, createIdentity } = require('./network');
const { createAuth } = require('./api/auth');
const { createAuth } = require('./api/auth');
const { v1Router } = require('./api/v1');
const { v1Router } = require('./api/v1');
const { webhooksRouter } = require('./api/webhooks');
const { webhooksRouter } = require('./api/webhooks');
+const { consoleRouter } = require('./console');
const providers = require('./providers');
const providers = require('./providers');
const { isFrozen } = require('./ops/common');
const { isFrozen } = require('./ops/common');
@@ -57,7 +59,8 @@ function createApp(opts = {}) {
@@ -57,7 +59,8 @@ function createApp(opts = {}) {
app.use('/webhooks', webhooksRouter({ ctx, adapters }));
app.use('/webhooks', webhooksRouter({ ctx, adapters }));
app.use('/api/v1', express.json({ limit: '64kb' }), v1Router({ ctx, auth, 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 }));
app.use((req, res) => http.sendProblem(res, 404, 'not_found', { ctx: req.ov }));
// Malformed JSON and other body-parser errors.
// Malformed JSON and other body-parser errors.
// eslint-disable-next-line no-unused-vars
// eslint-disable-next-line no-unused-vars
@@ -37,17 +37,27 @@ function parseTiers(raw) {
@@ -37,17 +37,27 @@ function parseTiers(raw) {
return [...tiers].sort((a, b) => b.min - a.min);
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) {
function loadConfig(env = process.env) {
const nodeEnv = env.NODE_ENV || 'development';
const nodeEnv = env.NODE_ENV || 'development';
const isProduction = nodeEnv === 'production';
const isProduction = nodeEnv === 'production';
const port = int(env.PORT, 4600);
const port = int(env.PORT, 4600);
const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
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 {
return {
nodeEnv,
nodeEnv,
isProduction,
isProduction,
port,
port,
host: env.HOST || '127.0.0.1',
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,
trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 1,
dbPath: env.BILLING_DB_PATH || './data/billing.db',
dbPath: env.BILLING_DB_PATH || './data/billing.db',
@@ -123,7 +133,25 @@ function loadConfig(env = process.env) {
@@ -123,7 +133,25 @@ function loadConfig(env = process.env) {
sweepIntervalMs: int(env.BILLING_SWEEP_INTERVAL_MS, 60 * 60 * 1000),
sweepIntervalMs: int(env.BILLING_SWEEP_INTERVAL_MS, 60 * 60 * 1000),
webhookRetryMs: int(env.BILLING_WEBHOOK_RETRY_MS, 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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,352 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,115 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,103 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,280 @@
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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:<id></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 } };
@@ -240,6 +240,43 @@ CREATE TABLE IF NOT EXISTS outbox (
@@ -240,6 +240,43 @@ CREATE TABLE IF NOT EXISTS outbox (
last_error TEXT
last_error TEXT
);
);
CREATE INDEX IF NOT EXISTS outbox_unsent ON outbox (sent_at, seq);
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) {
function openDb(dbPath) {
@@ -12,14 +12,15 @@ const { ids, validate, serviceAuth } = require('openvibe-contracts');
@@ -12,14 +12,15 @@ const { ids, validate, serviceAuth } = require('openvibe-contracts');
const ACTOR = { type: 'service', id: 'billing' };
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 ms = ctx.now();
const env = {
const env = {
event_id: ids.newId('event', ms),
event_id: ids.newId('event', ms),
event_type,
event_type,
version: 1,
version: 1,
source: 'billing',
source: 'billing',
- actor: ACTOR,
+ actor: actor || ACTOR,
timestamp: new Date(ms).toISOString(),
timestamp: new Date(ms).toISOString(),
priority,
priority,
visibility: 'internal',
visibility: 'internal',
new file mode 100644
new file mode 100644
@@ -0,0 +1,374 @@
@@ -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();
+})();
@@ -79,6 +79,11 @@ function fabricateLive(file) {
@@ -79,6 +79,11 @@ function fabricateLive(file) {
assert.strictEqual(bal('user_credit', alice), 500);
assert.strictEqual(bal('user_credit', alice), 500);
const rec = t.assertReconciled('after a refused legacy delivery');
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');
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 () => {
await check('a subscription order Live credited is not paid (or granted) again either', async () => {
@@ -3,7 +3,8 @@
@@ -3,7 +3,8 @@
* Stand-ins for the services Billing talks to, each on a random port with a real RS256 key pair.
* 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
* 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
* 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
* startEvents() POST /api/v1/events recording batches and the bearer tokens it saw
*/
*/
@@ -33,6 +34,23 @@ async function startNetwork() {
@@ -33,6 +34,23 @@ async function startNetwork() {
const now = Math.floor(Date.now() / 1000);
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);
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) {
function addUser(liveId) {
const subject = ids.newId('user');
const subject = ids.newId('user');
if (liveId != null) legacy[String(liveId)] = subject;
if (liveId != null) legacy[String(liveId)] = subject;
@@ -46,9 +64,19 @@ async function startNetwork() {
@@ -46,9 +64,19 @@ async function startNetwork() {
const body = Object.fromEntries(new URLSearchParams(raw));
const body = Object.fromEntries(new URLSearchParams(raw));
grants.push(body);
grants.push(body);
if (body.client_secret !== 'shh') return send(res, 401, { error: 'invalid_client' });
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);
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 });
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') {
if (req.url === '/internal/identity/resolve-batch' && req.method === 'POST') {
const auth = String(req.headers.authorization || '');
const auth = String(req.headers.authorization || '');
const v = serviceAuth.verifyServiceToken(auth.slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });
const v = serviceAuth.verifyServiceToken(auth.slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });
@@ -68,7 +96,7 @@ async function startNetwork() {
@@ -68,7 +96,7 @@ async function startNetwork() {
});
});
const url = await listen(server);
const url = await listen(server);
issuer = url;
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() {
async function startStripe() {