openvibers/openvibe.billing Commits
Per-person rate limits on Billing's money-creating routes (roadmap WS-R task 4; openvibe-sdk v0.12.0 is now a dependency). Every /api/v1 caller is a first-party service (Live, Tips, VIP) acting for many people, so server/api/actor-limits.js never counts per service. A request counts against the person its body names (the buyer, the payer, the creator, the subscriber), whichever service sends it, and a refund counts against the payer of the transfer it gives back. The limit runs after the capability and freeze checks (a request refused while frozen is not counted) and before the idempotency store and the ledger. A refusal therefore stores nothing, and the same Idempotency-Key works after Retry-After. Past a limit a route answers 429 problem+json rate_limited with Retry-After, logs one line and counts billing_rate_limited_total{limit,window}. BILLING_LIMITS=off turns every limit off, as a rollback lever that needs no deploy.
Changes
@@@ -36,6 +36,15 @@ BILLING_AUDIENCE=openvibe.billingOV_OAUTH_CLIENT_ID=billingOV_OAUTH_CLIENT_SECRET=change-me++# ── Per-person limits on /api/v1's money routes (server/api/actor-limits.js; README "Per-person limits") ──++# Counted per person named in the request, whichever service sends it; past a limit 429 rate_limited with++# Retry-After. MINUTE/HOUR: a person's history reads; checkouts, transfers, refunds, payout requests,++# recycling and credit subscriptions have fixed, tighter numbers. Webhooks are never limited.++# BILLING_LIMITS=off counts nobody (rollback without a deploy).++#BILLING_LIMITS=on++#BILLING_LIMITS_MINUTE=120++#BILLING_LIMITS_HOUR=3000++# ── Staff console (Network SSO; see README "Staff console") ──# The Network must list ${BASE_URL}/auth/callback (https://billing.openvibe.network/auth/callback)# as a redirect URI of OAuth client `billing`.@@@ -129,6 +129,31 @@ The capabilities and the service manifest are released in openvibe-contracts (v0[docs/service-manifest-proposal.json](docs/service-manifest-proposal.json). Grants are matched withcontracts' own `capabilities.grants()` (exact id or a `.*` family such as `billing.*`).++### Per-person limits++++The money-creating routes also limit how often one person can use them: `server/api/actor-limits.js`,++openvibe-sdk/limits, roadmap WS-R task 4. Every caller is a first-party service acting for many people,++so nothing is counted per service: a request counts against the person its body names (the buyer, the++payer, the creator, the subscriber), whichever service sends it; a refund counts against the payer of the++transfer it gives back. The limit runs after the capability and freeze checks (a request refused while++frozen is not counted) and before the idempotency store and the ledger, so a refusal stores nothing and++the same `Idempotency-Key` works after `Retry-After`. Past a limit: `429` problem+json `rate_limited`, one log line and++`billing_rate_limited_total{limit,window}`. `BILLING_LIMITS=off` turns them all off (a rollback lever).++++| Route | Per person, a minute / an hour |++|---|---|++| `POST /api/v1/intents` (checkout) | 5 / 30 |++| `POST /api/v1/transfers` (per payer) | 60 / 1200 |++| `POST /api/v1/transfers/:id/refund` (per payer of the transfer) | 20 / 200 |++| `POST /api/v1/cashouts` (payout request) | 3 / 10 |++| `POST /api/v1/recycle` | 10 / 60 |++| `POST /api/v1/subscriptions` (per subscriber) | 10 / 60 |++| `GET /api/v1/transactions?subject=` (history) | `BILLING_LIMITS_MINUTE` / `BILLING_LIMITS_HOUR` (120 / 3000) |++++Never limited: the provider webhooks (`/webhooks/*`), capturing an approved order, cashout approve and++deny, subscription cancel, the freeze, every admin route, the staff console, balance and entitlement++reads, `/api/health`, `/api/ready`, `/release.json` and `/metrics`. `test/actor-limits.test.js`.++## Staff consoleServer-rendered pages at the root of `https://billing.openvibe.network` for the people who decide@@@ -13,6 +13,7 @@"dotenv": "^16.4.7","express": "^4.21.2","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"},"engines": {@@@ -982,6 +983,15 @@"node": ">=20"}},++ "node_modules/openvibe-sdk": {++ "version": "0.12.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",++ "integrity": "sha512-iG1h905O1f7y/PnP8xLA6GCVzXEBDfSaBj2tc1ucNreWg2sT/AJWosb8NW0WRMK5WS4sIQ6tMsprmDLj/fjjeQ==",++ "license": "MIT",++ "engines": {++ "node": ">=20"++ }++ },"node_modules/openvibe-shared": {"version": "1.22.0","resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",@@@ -20,6 +20,7 @@"dotenv": "^16.4.7","express": "^4.21.2","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"},"engines": {nnew file mode 100644@@@ -0,0 +1,108 @@++'use strict';++++/**++ * Per-person rate limits on the money-creating routes of /api/v1 (roadmap WS-R task 4;++ * openvibe-sdk/limits).++ *++ * Every /api/v1 caller is a first-party service (Live, Tips, VIP) acting for many people, so a limit is++ * never counted per service: it is counted per PERSON, the one the request names in its body (the buyer,++ * the payer, the creator), whichever service sends it. A refund counts against the payer of the transfer++ * it gives back. A request that names no valid person counts against its principal (it is then refused++ * 422 by the route anyway). Each limit sits after the capability and freeze checks (a request refused++ * while frozen is not counted) and before the idempotency store and any ledger work: a refusal stores++ * nothing, so a retry with the same Idempotency-Key after Retry-After is a fresh attempt. Past a limit:++ * 429 problem+json `rate_limited` with Retry-After, one log line and billing_rate_limited_total{limit,window}.++ * Counters live in this process: a restart forgets them. BILLING_LIMITS=off counts nobody (a rollback++ * lever, no deploy needed).++ *++ * Never limited: the provider webhooks (/webhooks/*: a refused receipt would be money received and not++ * credited), capturing an approved order (the person already paid; bounded by the intents they started),++ * cashout approve and deny, subscription cancel, the freeze, every admin route, the staff console,++ * balance and entitlement reads (hot paths the services call for every page and chat message),++ * /api/health, /api/ready, /release.json and /metrics.++ */++const { createActorLimiter } = require('openvibe-sdk/limits');++const { ids } = require('openvibe-contracts');++++/** Each budget per person: a minute, an hour. */++const BUDGETS = {++ // Starting a checkout (POST /intents) prices it and opens a provider checkout (Stripe, PayPal…).++ // Someone buying Vibes or subscribing starts one, and perhaps retries it once or twice; dozens are card++ // testing. Tips' supporters are also capped at 20 unpaid checkouts a day there.++ 'billing.intent.create': { minute: 5, hour: 30 },++ // A tip, donation or paid interaction from a person's credit: one a second at most, above Tips' own++ // 20 a minute per supporter, so no real supporter meets it.++ 'billing.transfer.create': { minute: 60, hour: 1200 },++ // Giving a transfer back to its payer. There cannot be more refunds than the payer's transfers (60 a++ // minute, above), Live's media queue holds 3 requests per person by default, and a refund Live sees++ // refused stays unrefunded, so the streamer's refund button asks again.++ 'billing.transfer.refund': { minute: 20, hour: 200 },++ // A payout request moves a creator's payable into escrow for staff to pay out: a creator asks now and++ // then (a replayed Idempotency-Key counts too).++ 'billing.cashout.request': { minute: 3, hour: 10 },++ // Recycling payable into spendable credit: a creator's button, a few at a time.++ 'billing.recycle': { minute: 10, hour: 60 },++ // A subscription period paid from credit: a person subscribes to a few channels at a time.++ 'billing.subscription.create': { minute: 10, hour: 60 },++};++++/** The bare usr_… id from a SubjectRef or a bare id, else null. */++function personOf(v) {++ const id = v && typeof v === 'object' ? v.id : v;++ return typeof id === 'string' && ids.isSubjectId('user', id) && (!v || typeof v !== 'object' || v.type === 'user') ? id : null;++}++++/**++ * createActorLimits({ config, db, now, registry, log }) → { budget(name, personFrom), history, refund }.++ * personFrom(req) names the person a request is counted against (or null: its principal).++ */++function createActorLimits({ config, db, now = () => Date.now(), registry = null, log = console }) {++ const refused = registry++ ? registry.counter({ name: 'billing_rate_limited_total', help: 'Requests refused 429 by a per-person limit, by limit name and window', labelNames: ['limit', 'window'] })++ : null;++ const enabled = config.actorLimits.enabled;++++ const limiter = createActorLimiter({++ limits: { minute: config.actorLimits.minute, hour: config.actorLimits.hour },++ now,++ actor(req) {++ if (!enabled) return null;++ const person = req.limitPerson;++ if (person) return `user:${person}`;++ return req.principal && req.principal.sub ? req.principal.sub : null;++ },++ onLimited(e) {++ // The actor is a subject id or a principal, never a token or an amount.++ log.warn(`[Billing] limit ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);++ if (refused) refused.inc({ limit: e.name, window: e.window });++ },++ });++++ /** The budget `name`, counted against personFrom(req). */++ function budget(name, personFrom) {++ const own = BUDGETS[name];++ if (!own) throw new Error(`limits: no budget named ${name}`);++ const limit = limiter(name, own);++ return function billingActorLimit(req, res, next) {++ try { req.limitPerson = personFrom(req); } catch { req.limitPerson = null; }++ return limit(req, res, next);++ };++ }++++ /** A person's history (GET /transactions?subject=): BILLING_LIMITS_MINUTE / BILLING_LIMITS_HOUR, 120 and 3000. */++ const historyLimit = limiter('billing.history.read');++ function history(req, res, next) {++ req.limitPerson = personOf(req.query && req.query.subject);++ return historyLimit(req, res, next);++ }++++ /** The payer of the transfer a refund gives back (one primary-key read), else null. */++ function payerOf(req) {++ const row = db.prepare('SELECT from_subject FROM transactions WHERE id = ?').get(String(req.params.id));++ return row ? personOf(row.from_subject) : null;++ }++++ return { budget, history, payerOf };++}++++module.exports = { createActorLimits, personOf, BUDGETS };@@@ -14,6 +14,7 @@ const { getTxn, present: presentTxn, balance, BillingError } = require('../ledgeconst { A, userSubject, positiveInt, isFrozen, fail } = require('../ops/common');const { idempotent } = require('./idempotency');const { actorOf } = require('./auth');++const { personOf } = require('./actor-limits');const purchases = require('../ops/purchases');const transfers = require('../ops/transfers');const cashouts = require('../ops/cashouts');@@@ -35,16 +36,21 @@ const CAP = {admin: 'billing.ledger.admin',};--function v1Router({ ctx, auth, adapters }) {++function v1Router({ ctx, auth, adapters, limits }) {const r = express.Router();const { db } = ctx;const idem = idempotent(db, ctx.now);const notFrozen = (req, res, next) => (isFrozen(db)? http.sendProblem(res, 503, 'billing.frozen', { detail: 'the economy is frozen: writes are refused, reads are served', ctx: req.ov }): next());-- /** A mutating route: capability → freeze → idempotency → handler. */-- const write = (cap, handler) => [auth.needs(cap), notFrozen, idem, wrap(handler)];-- const read = (cap, handler) => [auth.needs(cap), wrap(handler)];++ /**++ * A mutating route: capability → freeze → per-person limit (when given) → idempotency → handler.++ * A limit refusal reaches neither the idempotency store nor the ledger; a frozen refusal is not counted.++ */++ const write = (cap, handler, limit) => [auth.needs(cap), notFrozen, ...(limit ? [limit] : []), idem, wrap(handler)];++ const read = (cap, handler, limit) => [auth.needs(cap), ...(limit ? [limit] : []), wrap(handler)];++ // Who a money route is counted against (api/actor-limits.js): the person the body names.++ const person = (field) => (req) => personOf(req.body && req.body[field]);const txnOut = (t, extra = {}) => ({ transaction: presentTxn(t), ...extra });const pathSubject = (v) => userSubject(String(v), 'subject');@@@ -83,7 +89,7 @@ function v1Router({ ctx, auth, adapters }) {}}res.status(201).json({ intent: intents.present(intents.find(db, intent.id)), checkout_url: checkoutUrl });-- }));++ }, limits.budget('billing.intent.create', person('subject'))));r.get('/intents/:id', ...read(CAP.intent, (req, res) => {const i = intents.find(db, req.params.id);if (!i) fail(404, 'billing.intent_not_found', `no intent ${req.params.id}`);@@@ -119,12 +125,12 @@ function v1Router({ ctx, auth, adapters }) {message: b.message, idempotencyKey: req.idempotencyKey, actor: actorOf(req), traceId: req.ov.traceId,});res.status(201).json(txnOut(out.txn, { balance: { credit: balance(db, A.credit(out.txn.from_subject)) } }));-- }));++ }, limits.budget('billing.transfer.create', person('from'))));r.post('/transfers/:id/refund', ...write(CAP.transfer, (req, res) => {const b = req.body || {};const out = transfers.refund(ctx, { txnId: req.params.id, amount: b.amount, reason: b.reason, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });res.status(201).json(txnOut(out.txn));-- }));++ }, limits.budget('billing.transfer.refund', limits.payerOf)));// ── Recycle: creator payable → own spendable credit ──────r.post('/recycle', ...write(CAP.cashoutRequest, (req, res) => {@@@ -132,14 +138,14 @@ function v1Router({ ctx, auth, adapters }) {const subject = userSubject(b.subject);const out = cashouts.recycle(ctx, { subject, amount: b.amount, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });res.status(201).json(txnOut(out.txn, { balance: balanceOf(subject) }));-- }));++ }, limits.budget('billing.recycle', person('subject'))));// ── Cashouts ─────────────────────────────────────────────r.post('/cashouts', ...write(CAP.cashoutRequest, (req, res) => {const b = req.body || {};const out = cashouts.request(ctx, { subject: userSubject(b.subject), amount: b.amount, payout_method: b.payout_method, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });res.status(201).json({ cashout: cashouts.present(out.cashout) });-- }));++ }, limits.budget('billing.cashout.request', person('subject'))));r.get('/cashouts', ...read(CAP.cashoutManage, (req, res) => {const list = cashouts.list(db, { status: req.query.status, subject: req.query.subject, limit: Number(req.query.limit) || 100 });res.json({ cashouts: list.map(cashouts.present) });@@@ -179,7 +185,7 @@ function v1Router({ ctx, auth, adapters }) {priceCents: b.price_cents, autoRenew: b.auto_renew, receipt, idempotencyKey: req.idempotencyKey, actor: actorOf(req),});res.status(201).json({ subscription: subscriptions.present(out.subscription), entitlement: out.entitlement, transaction: presentTxn(out.txn) });-- }));++ }, limits.budget('billing.subscription.create', person('subscriber'))));r.get('/subscriptions', ...read([CAP.entitlement, CAP.subscription], (req, res) => {const list = subscriptions.list(db, {subscriber: req.query.subscriber ? pathSubject(req.query.subscriber) : null,@@@ -230,7 +236,7 @@ function v1Router({ ctx, auth, adapters }) {const page = rows.slice(0, limit);const next = rows.length > limit ? Buffer.from(JSON.stringify([page[page.length - 1].created_at, page[page.length - 1].id])).toString('base64url') : null;res.json({ transactions: page.map((x) => presentTxn(getTxn(db, x.id))), next_cursor: next });-- }));++ }, limits.history));r.get('/transactions/:id', ...read(CAP.balance, (req, res) => {const t = getTxn(db, req.params.id);if (!t) fail(404, 'billing.transaction_not_found', `no transaction ${req.params.id}`);@@@ -10,7 +10,8 @@* /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, limitsNow }) — everything injectable++ * (limitsNow: the per-person limiter's clock, tests; default the wall clock).*/const path = require('path');const express = require('express');@@@ -23,6 +24,7 @@ const { createRates } = require('./rates');const { createKeyProvider, createIdentity } = require('./network');const { createAuth } = require('./api/auth');const { v1Router } = require('./api/v1');++const { createActorLimits } = require('./api/actor-limits');const { webhooksRouter } = require('./api/webhooks');const { consoleRouter } = require('./console');const providers = require('./providers');@@@ -79,7 +81,9 @@ function createApp(opts = {}) {app.get('/api/ready', readiness.handler);app.use('/webhooks', webhooksRouter({ ctx, adapters }));-- app.use('/api/v1', express.json({ limit: '64kb' }), v1Router({ ctx, auth, adapters }));++ // Per-person limits on the money-creating routes (api/actor-limits.js); the webhooks above never are.++ const limits = createActorLimits({ config, db, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log });++ app.use('/api/v1', express.json({ limit: '64kb' }), v1Router({ ctx, auth, adapters, limits }));// The billing policy is public and indexable; the staff console and the API are not.app.get('/robots.txt', (req, res) => res.type('text/plain').send('User-agent: *\nAllow: /policy\nDisallow: /\n'));@@@ -83,6 +83,14 @@ function loadConfig(env = process.env) {publicKey: env.OV_NETWORK_PUBLIC_KEY ? env.OV_NETWORK_PUBLIC_KEY.replace(/\\n/g, '\n') : null,},audience: env.BILLING_AUDIENCE || 'openvibe.billing',++ // Per-person limits on /api/v1's money-creating routes (server/api/actor-limits.js, roadmap WS-R++ // task 4). minute/hour: a person's history reads; the money routes set tighter numbers there.++ // BILLING_LIMITS=off counts nobody (a rollback lever).++ actorLimits: {++ enabled: String(env.BILLING_LIMITS || 'on').toLowerCase() !== 'off',++ minute: Math.max(1, int(env.BILLING_LIMITS_MINUTE, 120)),++ hour: Math.max(1, int(env.BILLING_LIMITS_HOUR, 3000)),++ },oauth: {clientId: env.OV_OAUTH_CLIENT_ID || 'billing',clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',nnew file mode 100644@@@ -0,0 +1,128 @@++'use strict';++/**++ * Per-person limits on the money-creating routes (server/api/actor-limits.js, roadmap WS-R task 4), on a++ * fixed limiter clock: past its limit one person gets 429 problem+json `rate_limited` with Retry-After,++ * before the idempotency store and the ledger, whichever service sends the request, while another person++ * still passes; the window reopens and the same Idempotency-Key then goes through. Checkouts, transfers,++ * refunds (counted against the payer) and payout requests have their own numbers. Balance reads, the++ * provider webhooks, health, ready, release.json and metrics are never limited; refusals are logged (no++ * token) and counted.++ */++const assert = require('assert');++const { boot, fund, check, done } = require('./helpers/app');++++(async () => {++ // 15 s into a minute: the minute window has 45 s left. A person's history reads: 3 a minute.++ let clock = Date.UTC(2026, 8, 27, 12, 0, 15);++ const t = await boot({ limitsNow: () => clock, env: { BILLING_LIMITS_MINUTE: '3', BILLING_LIMITS_HOUR: '100' } });++ const fan = t.user(31);++ const other = t.user(32);++ const creator = t.user(33);++ const second = t.user(34);++ console.log('actor limits');++ const count = (table) => t.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n;++ const checkout = (subject, o = {}) => t.call('POST', '/api/v1/intents', { cap: ['billing.intent.create'], body: { provider: 'powerchat', kind: 'purchase', subject, bits: 500 }, ...o });++++ await check('checkouts: 5 a minute per person, then 429 rate_limited with Retry-After; nothing is stored', async () => {++ for (let i = 0; i < 5; i++) assert.strictEqual((await checkout(fan)).status, 201, `checkout ${i + 1}`);++ const before = count('payment_intents');++ const r = await checkout(fan, { key: 'limits-retry-key-1' });++ assert.strictEqual(r.status, 429, r.text);++ assert.strictEqual(r.headers.get('retry-after'), '45');++ assert.strictEqual(r.headers.get('content-type'), 'application/problem+json');++ assert.deepStrictEqual([r.json.code, r.json.status, r.json.retry_after_seconds], ['rate_limited', 429, 45]);++ assert.ok(r.json.detail.includes('billing.intent.create'), r.json.detail);++ assert.strictEqual(count('payment_intents'), before, 'no intent stored');++ assert.strictEqual(t.db.prepare("SELECT COUNT(*) AS n FROM idempotency_keys WHERE key = 'limits-retry-key-1'").get().n, 0, 'the refusal is not an idempotent answer');++ });++++ await check('counted per person, not per service: another service for the same person is refused, another person passes', async () => {++ assert.strictEqual((await checkout(fan, { sub: 'svc:tips' })).status, 429, 'Tips speaking for the same person');++ assert.strictEqual((await checkout(other)).status, 201, 'another person still starts a checkout');++ });++++ await check('the next minute reopens, and the refused Idempotency-Key goes through', async () => {++ clock += 45 * 1000;++ const r = await checkout(fan, { key: 'limits-retry-key-1' });++ assert.strictEqual(r.status, 201, r.text);++ });++++ await check('transfers: 60 a minute per payer; the 61st moves nothing; another payer passes', async () => {++ clock = Date.UTC(2026, 8, 27, 12, 5, 0);++ await fund(t, fan, 10000);++ await fund(t, other, 1000);++ for (let i = 0; i < 60; i++) {++ const r = await t.call('POST', '/api/v1/transfers', { body: { from: fan, to: creator, amount: 10 } });++ if (r.status !== 201) assert.fail(`transfer ${i + 1}: ${r.text}`);++ }++ const credit = (await t.balances(fan.id)).credit;++ const r = await t.call('POST', '/api/v1/transfers', { body: { from: fan, to: creator, amount: 10 } });++ assert.deepStrictEqual([r.status, r.json.code, r.headers.get('retry-after')], [429, 'rate_limited', '60']);++ assert.strictEqual((await t.balances(fan.id)).credit, credit, 'no money moved');++ assert.strictEqual((await t.call('POST', '/api/v1/transfers', { body: { from: other, to: creator, amount: 10 } })).status, 201);++ });++++ await check('refunds count against the payer: 20 a minute', async () => {++ clock = Date.UTC(2026, 8, 27, 12, 10, 0);++ const tip = await t.call('POST', '/api/v1/transfers', { body: { from: other, to: creator, amount: 100 } });++ assert.strictEqual(tip.status, 201, tip.text);++ const id = tip.json.transaction.id;++ for (let i = 0; i < 20; i++) {++ const r = await t.call('POST', `/api/v1/transfers/${id}/refund`, { body: { amount: 1, reason: 'test' }, sub: i % 2 ? 'svc:tips' : 'svc:live' });++ if (r.status !== 201) assert.fail(`refund ${i + 1}: ${r.text}`);++ }++ const r = await t.call('POST', `/api/v1/transfers/${id}/refund`, { body: { amount: 1, reason: 'test' } });++ assert.deepStrictEqual([r.status, r.json.code], [429, 'rate_limited']);++ });++++ await check('payout requests: 3 a minute per creator; nothing is escrowed past it; another creator passes', async () => {++ clock = Date.UTC(2026, 8, 27, 12, 15, 0);++ await fund(t, second, 1000);++ await t.call('POST', '/api/v1/transfers', { body: { from: second, to: creator, amount: 1000 } });++ const tipSecond = await t.call('POST', '/api/v1/transfers', { body: { from: other, to: second, amount: 800 } });++ assert.strictEqual(tipSecond.status, 201, tipSecond.text);++ const cashout = (subject) => t.call('POST', '/api/v1/cashouts', { body: { subject, amount: 500, payout_method: { type: 'paypal', address: '[email protected]' } } });++ for (let i = 0; i < 3; i++) assert.strictEqual((await cashout(creator)).status, 201, `cashout ${i + 1}`);++ const before = count('cashouts');++ const r = await cashout(creator);++ assert.deepStrictEqual([r.status, r.json.code], [429, 'rate_limited']);++ assert.strictEqual(count('cashouts'), before, 'nothing escrowed');++ const ok = await cashout(second);++ assert.strictEqual(ok.status, 201, ok.text);++ });++++ await check('history reads per person (BILLING_LIMITS_MINUTE); balance reads are never limited', async () => {++ clock = Date.UTC(2026, 8, 27, 12, 20, 0);++ const history = (who) => t.call('GET', `/api/v1/transactions?subject=${who.id}`, { cap: ['billing.balance.read'] });++ for (let i = 0; i < 3; i++) assert.strictEqual((await history(fan)).status, 200);++ assert.strictEqual((await history(fan)).status, 429);++ assert.strictEqual((await history(other)).status, 200, 'another person still reads');++ for (let i = 0; i < 8; i++) assert.strictEqual((await t.call('GET', `/api/v1/balances/${fan.id}`, { cap: ['billing.balance.read'] })).status, 200);++ });++++ await check('provider webhooks, health, ready, release.json and metrics are never limited', async () => {++ for (let i = 0; i < 8; i++) {++ const d = await t.powerchat({ type: 'donation.completed', streamer: { id: 'pc1', username: 'openvibe' }, data: { eventId: `limits-${i}`, amountUsdCents: 100 } });++ assert.notStrictEqual(d.status, 429, JSON.stringify(d.json));++ assert.strictEqual((await t.call('GET', '/api/health', { token: null })).status, 200);++ assert.notStrictEqual((await t.call('GET', '/api/ready', { token: null })).status, 429);++ assert.strictEqual((await t.call('GET', '/release.json', { token: null })).status, 200);++ assert.strictEqual((await t.call('GET', '/metrics', { token: null })).status, 200);++ }++ });++++ await check('refusals are logged (the person, never a token) and counted in billing_rate_limited_total', async () => {++ assert.ok(t.logs.includes(`[Billing] limit billing.intent.create: user:${fan.id} refused, over 5 per minute`), t.logs.join('\n'));++ assert.ok(t.logs.includes(`[Billing] limit billing.transfer.refund: user:${other.id} refused, over 20 per minute`), t.logs.join('\n'));++ assert.ok(!t.logs.some((l) => /Bearer|eyJ/.test(l)), 'no token in the log');++ const m = (await t.call('GET', '/metrics', { token: null })).text;++ const lines = m.split('\n').filter((l) => l.includes('billing_rate_limited_total')).join('\n');++ for (const [name, n] of [['billing.intent.create', 2], ['billing.transfer.create', 1], ['billing.transfer.refund', 1], ['billing.cashout.request', 1], ['billing.history.read', 1]]) {++ assert.ok(new RegExp(`billing_rate_limited_total\\{limit="${name.replace(/\./g, '\\.')}",window="minute"\\} ${n}`).test(m), `${name}:\n${lines}`);++ }++ t.assertReconciled('after the refusals');++ });++++ await t.close();++ done();++})();@@@ -18,7 +18,9 @@ const DAY = 86_400_000;const staff = network.addUser(); // admin + listedconst 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 } });++ // BILLING_LIMITS=off: this file opens several payouts for one creator within a second to fill the staff++ // queue, past the per-person cashout limit (3 a minute); test/actor-limits.test.js covers the limits.++ const t = await boot({ network, env: { BASE_URL: 'https://billing.test', BILLING_STAFF_SUBJECTS: `${staff}, ${listedNonAdmin}, not-a-subject`, BILLING_SESSION_SECRET: SESSION_SECRET, BILLING_LIMITS: 'off' } });const fan = t.user(21);const creator = t.user(22);console.log('staff console');@@@ -35,7 +35,8 @@ async function boot(opts = {}) {const clock = { offset: 0 };const logs = [];const log = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')) };-- const app = createApp({ config, now: () => Date.now() + clock.offset, log });++ // opts.limitsNow: the per-person limiter's clock (default the wall clock).++ const app = createApp({ config, now: () => Date.now() + clock.offset, log, limitsNow: opts.limitsNow });await app.locals.keys.load();const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });const base = `http://127.0.0.1:${server.address().port}`;
@@ -36,6 +36,15 @@ BILLING_AUDIENCE=openvibe.billing
@@ -36,6 +36,15 @@ BILLING_AUDIENCE=openvibe.billing
OV_OAUTH_CLIENT_ID=billing
OV_OAUTH_CLIENT_ID=billing
OV_OAUTH_CLIENT_SECRET=change-me
OV_OAUTH_CLIENT_SECRET=change-me
+# ── Per-person limits on /api/v1's money routes (server/api/actor-limits.js; README "Per-person limits") ──
+# Counted per person named in the request, whichever service sends it; past a limit 429 rate_limited with
+# Retry-After. MINUTE/HOUR: a person's history reads; checkouts, transfers, refunds, payout requests,
+# recycling and credit subscriptions have fixed, tighter numbers. Webhooks are never limited.
+# BILLING_LIMITS=off counts nobody (rollback without a deploy).
+#BILLING_LIMITS=on
+#BILLING_LIMITS_MINUTE=120
+#BILLING_LIMITS_HOUR=3000
+
# ── Staff console (Network SSO; see README "Staff console") ──
# ── Staff console (Network SSO; see README "Staff console") ──
# The Network must list ${BASE_URL}/auth/callback (https://billing.openvibe.network/auth/callback)
# The Network must list ${BASE_URL}/auth/callback (https://billing.openvibe.network/auth/callback)
# as a redirect URI of OAuth client `billing`.
# as a redirect URI of OAuth client `billing`.
@@ -129,6 +129,31 @@ The capabilities and the service manifest are released in openvibe-contracts (v0
@@ -129,6 +129,31 @@ The capabilities and the service manifest are released in openvibe-contracts (v0
[docs/service-manifest-proposal.json](docs/service-manifest-proposal.json). Grants are matched with
[docs/service-manifest-proposal.json](docs/service-manifest-proposal.json). Grants are matched with
contracts' own `capabilities.grants()` (exact id or a `.*` family such as `billing.*`).
contracts' own `capabilities.grants()` (exact id or a `.*` family such as `billing.*`).
+### Per-person limits
+
+The money-creating routes also limit how often one person can use them: `server/api/actor-limits.js`,
+openvibe-sdk/limits, roadmap WS-R task 4. Every caller is a first-party service acting for many people,
+so nothing is counted per service: a request counts against the person its body names (the buyer, the
+payer, the creator, the subscriber), whichever service sends it; a refund counts against the payer of the
+transfer it gives back. The limit runs after the capability and freeze checks (a request refused while
+frozen is not counted) and before the idempotency store and the ledger, so a refusal stores nothing and
+the same `Idempotency-Key` works after `Retry-After`. Past a limit: `429` problem+json `rate_limited`, one log line and
+`billing_rate_limited_total{limit,window}`. `BILLING_LIMITS=off` turns them all off (a rollback lever).
+
+| Route | Per person, a minute / an hour |
+|---|---|
+| `POST /api/v1/intents` (checkout) | 5 / 30 |
+| `POST /api/v1/transfers` (per payer) | 60 / 1200 |
+| `POST /api/v1/transfers/:id/refund` (per payer of the transfer) | 20 / 200 |
+| `POST /api/v1/cashouts` (payout request) | 3 / 10 |
+| `POST /api/v1/recycle` | 10 / 60 |
+| `POST /api/v1/subscriptions` (per subscriber) | 10 / 60 |
+| `GET /api/v1/transactions?subject=` (history) | `BILLING_LIMITS_MINUTE` / `BILLING_LIMITS_HOUR` (120 / 3000) |
+
+Never limited: the provider webhooks (`/webhooks/*`), capturing an approved order, cashout approve and
+deny, subscription cancel, the freeze, every admin route, the staff console, balance and entitlement
+reads, `/api/health`, `/api/ready`, `/release.json` and `/metrics`. `test/actor-limits.test.js`.
+
## Staff console
## Staff console
Server-rendered pages at the root of `https://billing.openvibe.network` for the people who decide
Server-rendered pages at the root of `https://billing.openvibe.network` for the people who decide
@@ -13,6 +13,7 @@
@@ -13,6 +13,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
},
},
"engines": {
"engines": {
@@ -982,6 +983,15 @@
@@ -982,6 +983,15 @@
"node": ">=20"
"node": ">=20"
}
}
},
},
+ "node_modules/openvibe-sdk": {
+ "version": "0.12.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
+ "integrity": "sha512-iG1h905O1f7y/PnP8xLA6GCVzXEBDfSaBj2tc1ucNreWg2sT/AJWosb8NW0WRMK5WS4sIQ6tMsprmDLj/fjjeQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
"version": "1.22.0",
"version": "1.22.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
@@ -20,6 +20,7 @@
@@ -20,6 +20,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
},
},
"engines": {
"engines": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -0,0 +1,108 @@
+'use strict';
+
+/**
+ * Per-person rate limits on the money-creating routes of /api/v1 (roadmap WS-R task 4;
+ * openvibe-sdk/limits).
+ *
+ * Every /api/v1 caller is a first-party service (Live, Tips, VIP) acting for many people, so a limit is
+ * never counted per service: it is counted per PERSON, the one the request names in its body (the buyer,
+ * the payer, the creator), whichever service sends it. A refund counts against the payer of the transfer
+ * it gives back. A request that names no valid person counts against its principal (it is then refused
+ * 422 by the route anyway). Each limit sits after the capability and freeze checks (a request refused
+ * while frozen is not counted) and before the idempotency store and any ledger work: a refusal stores
+ * nothing, so a retry with the same Idempotency-Key after Retry-After is a fresh attempt. Past a limit:
+ * 429 problem+json `rate_limited` with Retry-After, one log line and billing_rate_limited_total{limit,window}.
+ * Counters live in this process: a restart forgets them. BILLING_LIMITS=off counts nobody (a rollback
+ * lever, no deploy needed).
+ *
+ * Never limited: the provider webhooks (/webhooks/*: a refused receipt would be money received and not
+ * credited), capturing an approved order (the person already paid; bounded by the intents they started),
+ * cashout approve and deny, subscription cancel, the freeze, every admin route, the staff console,
+ * balance and entitlement reads (hot paths the services call for every page and chat message),
+ * /api/health, /api/ready, /release.json and /metrics.
+ */
+const { createActorLimiter } = require('openvibe-sdk/limits');
+const { ids } = require('openvibe-contracts');
+
+/** Each budget per person: a minute, an hour. */
+const BUDGETS = {
+ // Starting a checkout (POST /intents) prices it and opens a provider checkout (Stripe, PayPal…).
+ // Someone buying Vibes or subscribing starts one, and perhaps retries it once or twice; dozens are card
+ // testing. Tips' supporters are also capped at 20 unpaid checkouts a day there.
+ 'billing.intent.create': { minute: 5, hour: 30 },
+ // A tip, donation or paid interaction from a person's credit: one a second at most, above Tips' own
+ // 20 a minute per supporter, so no real supporter meets it.
+ 'billing.transfer.create': { minute: 60, hour: 1200 },
+ // Giving a transfer back to its payer. There cannot be more refunds than the payer's transfers (60 a
+ // minute, above), Live's media queue holds 3 requests per person by default, and a refund Live sees
+ // refused stays unrefunded, so the streamer's refund button asks again.
+ 'billing.transfer.refund': { minute: 20, hour: 200 },
+ // A payout request moves a creator's payable into escrow for staff to pay out: a creator asks now and
+ // then (a replayed Idempotency-Key counts too).
+ 'billing.cashout.request': { minute: 3, hour: 10 },
+ // Recycling payable into spendable credit: a creator's button, a few at a time.
+ 'billing.recycle': { minute: 10, hour: 60 },
+ // A subscription period paid from credit: a person subscribes to a few channels at a time.
+ 'billing.subscription.create': { minute: 10, hour: 60 },
+};
+
+/** The bare usr_… id from a SubjectRef or a bare id, else null. */
+function personOf(v) {
+ const id = v && typeof v === 'object' ? v.id : v;
+ return typeof id === 'string' && ids.isSubjectId('user', id) && (!v || typeof v !== 'object' || v.type === 'user') ? id : null;
+}
+
+/**
+ * createActorLimits({ config, db, now, registry, log }) → { budget(name, personFrom), history, refund }.
+ * personFrom(req) names the person a request is counted against (or null: its principal).
+ */
+function createActorLimits({ config, db, now = () => Date.now(), registry = null, log = console }) {
+ const refused = registry
+ ? registry.counter({ name: 'billing_rate_limited_total', help: 'Requests refused 429 by a per-person limit, by limit name and window', labelNames: ['limit', 'window'] })
+ : null;
+ const enabled = config.actorLimits.enabled;
+
+ const limiter = createActorLimiter({
+ limits: { minute: config.actorLimits.minute, hour: config.actorLimits.hour },
+ now,
+ actor(req) {
+ if (!enabled) return null;
+ const person = req.limitPerson;
+ if (person) return `user:${person}`;
+ return req.principal && req.principal.sub ? req.principal.sub : null;
+ },
+ onLimited(e) {
+ // The actor is a subject id or a principal, never a token or an amount.
+ log.warn(`[Billing] limit ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
+ if (refused) refused.inc({ limit: e.name, window: e.window });
+ },
+ });
+
+ /** The budget `name`, counted against personFrom(req). */
+ function budget(name, personFrom) {
+ const own = BUDGETS[name];
+ if (!own) throw new Error(`limits: no budget named ${name}`);
+ const limit = limiter(name, own);
+ return function billingActorLimit(req, res, next) {
+ try { req.limitPerson = personFrom(req); } catch { req.limitPerson = null; }
+ return limit(req, res, next);
+ };
+ }
+
+ /** A person's history (GET /transactions?subject=): BILLING_LIMITS_MINUTE / BILLING_LIMITS_HOUR, 120 and 3000. */
+ const historyLimit = limiter('billing.history.read');
+ function history(req, res, next) {
+ req.limitPerson = personOf(req.query && req.query.subject);
+ return historyLimit(req, res, next);
+ }
+
+ /** The payer of the transfer a refund gives back (one primary-key read), else null. */
+ function payerOf(req) {
+ const row = db.prepare('SELECT from_subject FROM transactions WHERE id = ?').get(String(req.params.id));
+ return row ? personOf(row.from_subject) : null;
+ }
+
+ return { budget, history, payerOf };
+}
+
+module.exports = { createActorLimits, personOf, BUDGETS };
@@ -14,6 +14,7 @@ const { getTxn, present: presentTxn, balance, BillingError } = require('../ledge
@@ -14,6 +14,7 @@ const { getTxn, present: presentTxn, balance, BillingError } = require('../ledge
const { A, userSubject, positiveInt, isFrozen, fail } = require('../ops/common');
const { A, userSubject, positiveInt, isFrozen, fail } = require('../ops/common');
const { idempotent } = require('./idempotency');
const { idempotent } = require('./idempotency');
const { actorOf } = require('./auth');
const { actorOf } = require('./auth');
+const { personOf } = require('./actor-limits');
const purchases = require('../ops/purchases');
const purchases = require('../ops/purchases');
const transfers = require('../ops/transfers');
const transfers = require('../ops/transfers');
const cashouts = require('../ops/cashouts');
const cashouts = require('../ops/cashouts');
@@ -35,16 +36,21 @@ const CAP = {
@@ -35,16 +36,21 @@ const CAP = {
admin: 'billing.ledger.admin',
admin: 'billing.ledger.admin',
};
};
-function v1Router({ ctx, auth, adapters }) {
+function v1Router({ ctx, auth, adapters, limits }) {
const r = express.Router();
const r = express.Router();
const { db } = ctx;
const { db } = ctx;
const idem = idempotent(db, ctx.now);
const idem = idempotent(db, ctx.now);
const notFrozen = (req, res, next) => (isFrozen(db)
const notFrozen = (req, res, next) => (isFrozen(db)
? http.sendProblem(res, 503, 'billing.frozen', { detail: 'the economy is frozen: writes are refused, reads are served', ctx: req.ov })
? http.sendProblem(res, 503, 'billing.frozen', { detail: 'the economy is frozen: writes are refused, reads are served', ctx: req.ov })
: next());
: next());
- /** A mutating route: capability → freeze → idempotency → handler. */
- const write = (cap, handler) => [auth.needs(cap), notFrozen, idem, wrap(handler)];
- const read = (cap, handler) => [auth.needs(cap), wrap(handler)];
+ /**
+ * A mutating route: capability → freeze → per-person limit (when given) → idempotency → handler.
+ * A limit refusal reaches neither the idempotency store nor the ledger; a frozen refusal is not counted.
+ */
+ const write = (cap, handler, limit) => [auth.needs(cap), notFrozen, ...(limit ? [limit] : []), idem, wrap(handler)];
+ const read = (cap, handler, limit) => [auth.needs(cap), ...(limit ? [limit] : []), wrap(handler)];
+ // Who a money route is counted against (api/actor-limits.js): the person the body names.
+ const person = (field) => (req) => personOf(req.body && req.body[field]);
const txnOut = (t, extra = {}) => ({ transaction: presentTxn(t), ...extra });
const txnOut = (t, extra = {}) => ({ transaction: presentTxn(t), ...extra });
const pathSubject = (v) => userSubject(String(v), 'subject');
const pathSubject = (v) => userSubject(String(v), 'subject');
@@ -83,7 +89,7 @@ function v1Router({ ctx, auth, adapters }) {
@@ -83,7 +89,7 @@ function v1Router({ ctx, auth, adapters }) {
}
}
}
}
res.status(201).json({ intent: intents.present(intents.find(db, intent.id)), checkout_url: checkoutUrl });
res.status(201).json({ intent: intents.present(intents.find(db, intent.id)), checkout_url: checkoutUrl });
- }));
+ }, limits.budget('billing.intent.create', person('subject'))));
r.get('/intents/:id', ...read(CAP.intent, (req, res) => {
r.get('/intents/:id', ...read(CAP.intent, (req, res) => {
const i = intents.find(db, req.params.id);
const i = intents.find(db, req.params.id);
if (!i) fail(404, 'billing.intent_not_found', `no intent ${req.params.id}`);
if (!i) fail(404, 'billing.intent_not_found', `no intent ${req.params.id}`);
@@ -119,12 +125,12 @@ function v1Router({ ctx, auth, adapters }) {
@@ -119,12 +125,12 @@ function v1Router({ ctx, auth, adapters }) {
message: b.message, idempotencyKey: req.idempotencyKey, actor: actorOf(req), traceId: req.ov.traceId,
message: b.message, idempotencyKey: req.idempotencyKey, actor: actorOf(req), traceId: req.ov.traceId,
});
});
res.status(201).json(txnOut(out.txn, { balance: { credit: balance(db, A.credit(out.txn.from_subject)) } }));
res.status(201).json(txnOut(out.txn, { balance: { credit: balance(db, A.credit(out.txn.from_subject)) } }));
- }));
+ }, limits.budget('billing.transfer.create', person('from'))));
r.post('/transfers/:id/refund', ...write(CAP.transfer, (req, res) => {
r.post('/transfers/:id/refund', ...write(CAP.transfer, (req, res) => {
const b = req.body || {};
const b = req.body || {};
const out = transfers.refund(ctx, { txnId: req.params.id, amount: b.amount, reason: b.reason, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });
const out = transfers.refund(ctx, { txnId: req.params.id, amount: b.amount, reason: b.reason, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });
res.status(201).json(txnOut(out.txn));
res.status(201).json(txnOut(out.txn));
- }));
+ }, limits.budget('billing.transfer.refund', limits.payerOf)));
// ── Recycle: creator payable → own spendable credit ──────
// ── Recycle: creator payable → own spendable credit ──────
r.post('/recycle', ...write(CAP.cashoutRequest, (req, res) => {
r.post('/recycle', ...write(CAP.cashoutRequest, (req, res) => {
@@ -132,14 +138,14 @@ function v1Router({ ctx, auth, adapters }) {
@@ -132,14 +138,14 @@ function v1Router({ ctx, auth, adapters }) {
const subject = userSubject(b.subject);
const subject = userSubject(b.subject);
const out = cashouts.recycle(ctx, { subject, amount: b.amount, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });
const out = cashouts.recycle(ctx, { subject, amount: b.amount, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });
res.status(201).json(txnOut(out.txn, { balance: balanceOf(subject) }));
res.status(201).json(txnOut(out.txn, { balance: balanceOf(subject) }));
- }));
+ }, limits.budget('billing.recycle', person('subject'))));
// ── Cashouts ─────────────────────────────────────────────
// ── Cashouts ─────────────────────────────────────────────
r.post('/cashouts', ...write(CAP.cashoutRequest, (req, res) => {
r.post('/cashouts', ...write(CAP.cashoutRequest, (req, res) => {
const b = req.body || {};
const b = req.body || {};
const out = cashouts.request(ctx, { subject: userSubject(b.subject), amount: b.amount, payout_method: b.payout_method, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });
const out = cashouts.request(ctx, { subject: userSubject(b.subject), amount: b.amount, payout_method: b.payout_method, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });
res.status(201).json({ cashout: cashouts.present(out.cashout) });
res.status(201).json({ cashout: cashouts.present(out.cashout) });
- }));
+ }, limits.budget('billing.cashout.request', person('subject'))));
r.get('/cashouts', ...read(CAP.cashoutManage, (req, res) => {
r.get('/cashouts', ...read(CAP.cashoutManage, (req, res) => {
const list = cashouts.list(db, { status: req.query.status, subject: req.query.subject, limit: Number(req.query.limit) || 100 });
const list = cashouts.list(db, { status: req.query.status, subject: req.query.subject, limit: Number(req.query.limit) || 100 });
res.json({ cashouts: list.map(cashouts.present) });
res.json({ cashouts: list.map(cashouts.present) });
@@ -179,7 +185,7 @@ function v1Router({ ctx, auth, adapters }) {
@@ -179,7 +185,7 @@ function v1Router({ ctx, auth, adapters }) {
priceCents: b.price_cents, autoRenew: b.auto_renew, receipt, idempotencyKey: req.idempotencyKey, actor: actorOf(req),
priceCents: b.price_cents, autoRenew: b.auto_renew, receipt, idempotencyKey: req.idempotencyKey, actor: actorOf(req),
});
});
res.status(201).json({ subscription: subscriptions.present(out.subscription), entitlement: out.entitlement, transaction: presentTxn(out.txn) });
res.status(201).json({ subscription: subscriptions.present(out.subscription), entitlement: out.entitlement, transaction: presentTxn(out.txn) });
- }));
+ }, limits.budget('billing.subscription.create', person('subscriber'))));
r.get('/subscriptions', ...read([CAP.entitlement, CAP.subscription], (req, res) => {
r.get('/subscriptions', ...read([CAP.entitlement, CAP.subscription], (req, res) => {
const list = subscriptions.list(db, {
const list = subscriptions.list(db, {
subscriber: req.query.subscriber ? pathSubject(req.query.subscriber) : null,
subscriber: req.query.subscriber ? pathSubject(req.query.subscriber) : null,
@@ -230,7 +236,7 @@ function v1Router({ ctx, auth, adapters }) {
@@ -230,7 +236,7 @@ function v1Router({ ctx, auth, adapters }) {
const page = rows.slice(0, limit);
const page = rows.slice(0, limit);
const next = rows.length > limit ? Buffer.from(JSON.stringify([page[page.length - 1].created_at, page[page.length - 1].id])).toString('base64url') : null;
const next = rows.length > limit ? Buffer.from(JSON.stringify([page[page.length - 1].created_at, page[page.length - 1].id])).toString('base64url') : null;
res.json({ transactions: page.map((x) => presentTxn(getTxn(db, x.id))), next_cursor: next });
res.json({ transactions: page.map((x) => presentTxn(getTxn(db, x.id))), next_cursor: next });
- }));
+ }, limits.history));
r.get('/transactions/:id', ...read(CAP.balance, (req, res) => {
r.get('/transactions/:id', ...read(CAP.balance, (req, res) => {
const t = getTxn(db, req.params.id);
const t = getTxn(db, req.params.id);
if (!t) fail(404, 'billing.transaction_not_found', `no transaction ${req.params.id}`);
if (!t) fail(404, 'billing.transaction_not_found', `no transaction ${req.params.id}`);
@@ -10,7 +10,8 @@
@@ -10,7 +10,8 @@
* /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)
* /, /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, limitsNow }) — everything injectable
+ * (limitsNow: the per-person limiter's clock, tests; default the wall clock).
*/
*/
const path = require('path');
const path = require('path');
const express = require('express');
const express = require('express');
@@ -23,6 +24,7 @@ const { createRates } = require('./rates');
@@ -23,6 +24,7 @@ const { createRates } = require('./rates');
const { createKeyProvider, createIdentity } = require('./network');
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 { createActorLimits } = require('./api/actor-limits');
const { webhooksRouter } = require('./api/webhooks');
const { webhooksRouter } = require('./api/webhooks');
const { consoleRouter } = require('./console');
const { consoleRouter } = require('./console');
const providers = require('./providers');
const providers = require('./providers');
@@ -79,7 +81,9 @@ function createApp(opts = {}) {
@@ -79,7 +81,9 @@ function createApp(opts = {}) {
app.get('/api/ready', readiness.handler);
app.get('/api/ready', readiness.handler);
app.use('/webhooks', webhooksRouter({ ctx, adapters }));
app.use('/webhooks', webhooksRouter({ ctx, adapters }));
- app.use('/api/v1', express.json({ limit: '64kb' }), v1Router({ ctx, auth, adapters }));
+ // Per-person limits on the money-creating routes (api/actor-limits.js); the webhooks above never are.
+ const limits = createActorLimits({ config, db, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log });
+ app.use('/api/v1', express.json({ limit: '64kb' }), v1Router({ ctx, auth, adapters, limits }));
// The billing policy is public and indexable; the staff console and the API are not.
// The billing policy is public and indexable; the staff console and the API are not.
app.get('/robots.txt', (req, res) => res.type('text/plain').send('User-agent: *\nAllow: /policy\nDisallow: /\n'));
app.get('/robots.txt', (req, res) => res.type('text/plain').send('User-agent: *\nAllow: /policy\nDisallow: /\n'));
@@ -83,6 +83,14 @@ function loadConfig(env = process.env) {
@@ -83,6 +83,14 @@ function loadConfig(env = process.env) {
publicKey: env.OV_NETWORK_PUBLIC_KEY ? env.OV_NETWORK_PUBLIC_KEY.replace(/\\n/g, '\n') : null,
publicKey: env.OV_NETWORK_PUBLIC_KEY ? env.OV_NETWORK_PUBLIC_KEY.replace(/\\n/g, '\n') : null,
},
},
audience: env.BILLING_AUDIENCE || 'openvibe.billing',
audience: env.BILLING_AUDIENCE || 'openvibe.billing',
+ // Per-person limits on /api/v1's money-creating routes (server/api/actor-limits.js, roadmap WS-R
+ // task 4). minute/hour: a person's history reads; the money routes set tighter numbers there.
+ // BILLING_LIMITS=off counts nobody (a rollback lever).
+ actorLimits: {
+ enabled: String(env.BILLING_LIMITS || 'on').toLowerCase() !== 'off',
+ minute: Math.max(1, int(env.BILLING_LIMITS_MINUTE, 120)),
+ hour: Math.max(1, int(env.BILLING_LIMITS_HOUR, 3000)),
+ },
oauth: {
oauth: {
clientId: env.OV_OAUTH_CLIENT_ID || 'billing',
clientId: env.OV_OAUTH_CLIENT_ID || 'billing',
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
new file mode 100644
new file mode 100644
@@ -0,0 +1,128 @@
@@ -0,0 +1,128 @@
+'use strict';
+/**
+ * Per-person limits on the money-creating routes (server/api/actor-limits.js, roadmap WS-R task 4), on a
+ * fixed limiter clock: past its limit one person gets 429 problem+json `rate_limited` with Retry-After,
+ * before the idempotency store and the ledger, whichever service sends the request, while another person
+ * still passes; the window reopens and the same Idempotency-Key then goes through. Checkouts, transfers,
+ * refunds (counted against the payer) and payout requests have their own numbers. Balance reads, the
+ * provider webhooks, health, ready, release.json and metrics are never limited; refusals are logged (no
+ * token) and counted.
+ */
+const assert = require('assert');
+const { boot, fund, check, done } = require('./helpers/app');
+
+(async () => {
+ // 15 s into a minute: the minute window has 45 s left. A person's history reads: 3 a minute.
+ let clock = Date.UTC(2026, 8, 27, 12, 0, 15);
+ const t = await boot({ limitsNow: () => clock, env: { BILLING_LIMITS_MINUTE: '3', BILLING_LIMITS_HOUR: '100' } });
+ const fan = t.user(31);
+ const other = t.user(32);
+ const creator = t.user(33);
+ const second = t.user(34);
+ console.log('actor limits');
+ const count = (table) => t.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n;
+ const checkout = (subject, o = {}) => t.call('POST', '/api/v1/intents', { cap: ['billing.intent.create'], body: { provider: 'powerchat', kind: 'purchase', subject, bits: 500 }, ...o });
+
+ await check('checkouts: 5 a minute per person, then 429 rate_limited with Retry-After; nothing is stored', async () => {
+ for (let i = 0; i < 5; i++) assert.strictEqual((await checkout(fan)).status, 201, `checkout ${i + 1}`);
+ const before = count('payment_intents');
+ const r = await checkout(fan, { key: 'limits-retry-key-1' });
+ assert.strictEqual(r.status, 429, r.text);
+ assert.strictEqual(r.headers.get('retry-after'), '45');
+ assert.strictEqual(r.headers.get('content-type'), 'application/problem+json');
+ assert.deepStrictEqual([r.json.code, r.json.status, r.json.retry_after_seconds], ['rate_limited', 429, 45]);
+ assert.ok(r.json.detail.includes('billing.intent.create'), r.json.detail);
+ assert.strictEqual(count('payment_intents'), before, 'no intent stored');
+ assert.strictEqual(t.db.prepare("SELECT COUNT(*) AS n FROM idempotency_keys WHERE key = 'limits-retry-key-1'").get().n, 0, 'the refusal is not an idempotent answer');
+ });
+
+ await check('counted per person, not per service: another service for the same person is refused, another person passes', async () => {
+ assert.strictEqual((await checkout(fan, { sub: 'svc:tips' })).status, 429, 'Tips speaking for the same person');
+ assert.strictEqual((await checkout(other)).status, 201, 'another person still starts a checkout');
+ });
+
+ await check('the next minute reopens, and the refused Idempotency-Key goes through', async () => {
+ clock += 45 * 1000;
+ const r = await checkout(fan, { key: 'limits-retry-key-1' });
+ assert.strictEqual(r.status, 201, r.text);
+ });
+
+ await check('transfers: 60 a minute per payer; the 61st moves nothing; another payer passes', async () => {
+ clock = Date.UTC(2026, 8, 27, 12, 5, 0);
+ await fund(t, fan, 10000);
+ await fund(t, other, 1000);
+ for (let i = 0; i < 60; i++) {
+ const r = await t.call('POST', '/api/v1/transfers', { body: { from: fan, to: creator, amount: 10 } });
+ if (r.status !== 201) assert.fail(`transfer ${i + 1}: ${r.text}`);
+ }
+ const credit = (await t.balances(fan.id)).credit;
+ const r = await t.call('POST', '/api/v1/transfers', { body: { from: fan, to: creator, amount: 10 } });
+ assert.deepStrictEqual([r.status, r.json.code, r.headers.get('retry-after')], [429, 'rate_limited', '60']);
+ assert.strictEqual((await t.balances(fan.id)).credit, credit, 'no money moved');
+ assert.strictEqual((await t.call('POST', '/api/v1/transfers', { body: { from: other, to: creator, amount: 10 } })).status, 201);
+ });
+
+ await check('refunds count against the payer: 20 a minute', async () => {
+ clock = Date.UTC(2026, 8, 27, 12, 10, 0);
+ const tip = await t.call('POST', '/api/v1/transfers', { body: { from: other, to: creator, amount: 100 } });
+ assert.strictEqual(tip.status, 201, tip.text);
+ const id = tip.json.transaction.id;
+ for (let i = 0; i < 20; i++) {
+ const r = await t.call('POST', `/api/v1/transfers/${id}/refund`, { body: { amount: 1, reason: 'test' }, sub: i % 2 ? 'svc:tips' : 'svc:live' });
+ if (r.status !== 201) assert.fail(`refund ${i + 1}: ${r.text}`);
+ }
+ const r = await t.call('POST', `/api/v1/transfers/${id}/refund`, { body: { amount: 1, reason: 'test' } });
+ assert.deepStrictEqual([r.status, r.json.code], [429, 'rate_limited']);
+ });
+
+ await check('payout requests: 3 a minute per creator; nothing is escrowed past it; another creator passes', async () => {
+ clock = Date.UTC(2026, 8, 27, 12, 15, 0);
+ await fund(t, second, 1000);
+ await t.call('POST', '/api/v1/transfers', { body: { from: second, to: creator, amount: 1000 } });
+ const tipSecond = await t.call('POST', '/api/v1/transfers', { body: { from: other, to: second, amount: 800 } });
+ assert.strictEqual(tipSecond.status, 201, tipSecond.text);
+ const cashout = (subject) => t.call('POST', '/api/v1/cashouts', { body: { subject, amount: 500, payout_method: { type: 'paypal', address: '[email protected]' } } });
+ for (let i = 0; i < 3; i++) assert.strictEqual((await cashout(creator)).status, 201, `cashout ${i + 1}`);
+ const before = count('cashouts');
+ const r = await cashout(creator);
+ assert.deepStrictEqual([r.status, r.json.code], [429, 'rate_limited']);
+ assert.strictEqual(count('cashouts'), before, 'nothing escrowed');
+ const ok = await cashout(second);
+ assert.strictEqual(ok.status, 201, ok.text);
+ });
+
+ await check('history reads per person (BILLING_LIMITS_MINUTE); balance reads are never limited', async () => {
+ clock = Date.UTC(2026, 8, 27, 12, 20, 0);
+ const history = (who) => t.call('GET', `/api/v1/transactions?subject=${who.id}`, { cap: ['billing.balance.read'] });
+ for (let i = 0; i < 3; i++) assert.strictEqual((await history(fan)).status, 200);
+ assert.strictEqual((await history(fan)).status, 429);
+ assert.strictEqual((await history(other)).status, 200, 'another person still reads');
+ for (let i = 0; i < 8; i++) assert.strictEqual((await t.call('GET', `/api/v1/balances/${fan.id}`, { cap: ['billing.balance.read'] })).status, 200);
+ });
+
+ await check('provider webhooks, health, ready, release.json and metrics are never limited', async () => {
+ for (let i = 0; i < 8; i++) {
+ const d = await t.powerchat({ type: 'donation.completed', streamer: { id: 'pc1', username: 'openvibe' }, data: { eventId: `limits-${i}`, amountUsdCents: 100 } });
+ assert.notStrictEqual(d.status, 429, JSON.stringify(d.json));
+ assert.strictEqual((await t.call('GET', '/api/health', { token: null })).status, 200);
+ assert.notStrictEqual((await t.call('GET', '/api/ready', { token: null })).status, 429);
+ assert.strictEqual((await t.call('GET', '/release.json', { token: null })).status, 200);
+ assert.strictEqual((await t.call('GET', '/metrics', { token: null })).status, 200);
+ }
+ });
+
+ await check('refusals are logged (the person, never a token) and counted in billing_rate_limited_total', async () => {
+ assert.ok(t.logs.includes(`[Billing] limit billing.intent.create: user:${fan.id} refused, over 5 per minute`), t.logs.join('\n'));
+ assert.ok(t.logs.includes(`[Billing] limit billing.transfer.refund: user:${other.id} refused, over 20 per minute`), t.logs.join('\n'));
+ assert.ok(!t.logs.some((l) => /Bearer|eyJ/.test(l)), 'no token in the log');
+ const m = (await t.call('GET', '/metrics', { token: null })).text;
+ const lines = m.split('\n').filter((l) => l.includes('billing_rate_limited_total')).join('\n');
+ for (const [name, n] of [['billing.intent.create', 2], ['billing.transfer.create', 1], ['billing.transfer.refund', 1], ['billing.cashout.request', 1], ['billing.history.read', 1]]) {
+ assert.ok(new RegExp(`billing_rate_limited_total\\{limit="${name.replace(/\./g, '\\.')}",window="minute"\\} ${n}`).test(m), `${name}:\n${lines}`);
+ }
+ t.assertReconciled('after the refusals');
+ });
+
+ await t.close();
+ done();
+})();
@@ -18,7 +18,9 @@ const DAY = 86_400_000;
@@ -18,7 +18,9 @@ const DAY = 86_400_000;
const staff = network.addUser(); // admin + listed
const staff = network.addUser(); // admin + listed
const listedNonAdmin = network.addUser(); // listed, but Network role 'user'
const listedNonAdmin = network.addUser(); // listed, but Network role 'user'
const unlistedAdmin = network.addUser(); // Network admin, not Billing staff
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 } });
+ // BILLING_LIMITS=off: this file opens several payouts for one creator within a second to fill the staff
+ // queue, past the per-person cashout limit (3 a minute); test/actor-limits.test.js covers the limits.
+ const t = await boot({ network, env: { BASE_URL: 'https://billing.test', BILLING_STAFF_SUBJECTS: `${staff}, ${listedNonAdmin}, not-a-subject`, BILLING_SESSION_SECRET: SESSION_SECRET, BILLING_LIMITS: 'off' } });
const fan = t.user(21);
const fan = t.user(21);
const creator = t.user(22);
const creator = t.user(22);
console.log('staff console');
console.log('staff console');
@@ -35,7 +35,8 @@ async function boot(opts = {}) {
@@ -35,7 +35,8 @@ async function boot(opts = {}) {
const clock = { offset: 0 };
const clock = { offset: 0 };
const logs = [];
const logs = [];
const log = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')) };
const log = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')) };
- const app = createApp({ config, now: () => Date.now() + clock.offset, log });
+ // opts.limitsNow: the per-person limiter's clock (default the wall clock).
+ const app = createApp({ config, now: () => Date.now() + clock.offset, log, limitsNow: opts.limitsNow });
await app.locals.keys.load();
await app.locals.keys.load();
const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;