openvibers/openvibe.billing Commits
The money routes check the person's own subject assertion (#200)
Changes
@@@ -66,7 +66,7 @@ promo write changes both in one transaction.- OpenVibe.Network (JWKS for service tokens; SSO with PKCE for the staff console; `identity.subject.resolve` for the Live import)- OpenVibe.Events (the SDK outbox relay, when `EVENTS_URL` and the OAuth client secret are set)- the payment providers whose secrets are configured (none in production today)--- `openvibe-contracts` v0.289.1, `openvibe-sdk` v0.84.0 (service tokens, per-person limits, `openvibe-sdk/db`, `openvibe-sdk/service`, `openvibe-sdk/vibes`), `openvibe-shared` v4.115.1, pinned by release tarball++- `openvibe-contracts` v0.297.0, `openvibe-sdk` v0.90.0 (service tokens, per-person limits, `openvibe-sdk/db`, `openvibe-sdk/service`, `openvibe-sdk/vibes`), `openvibe-shared` v4.115.1, pinned by release tarball- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through`openvibe-sdk/db`; Valkey holds the per-person limit counters (optional: without `VALKEY_URL` they count in the process)@@@ -513,7 +513,7 @@ PowerChat purchase settling end to end after the cutover.Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).<!-- versions:start -->--- openvibe-contracts: v0.289.1--- openvibe-sdk: v0.84.0++- openvibe-contracts: v0.297.0++- openvibe-sdk: v0.90.0- openvibe-shared: v4.115.1<!-- versions:end -->@@@ -8,8 +8,8 @@"authoritative": true,"deployed": true,"deployment": "The money ledger since the cutover of 2026-10-17 04:50-04:56 UTC (BILLING_AUTHORITY=billing in billing.env and live.env): deployed with `sudo ovhost deploy billing`, /opt/openvibe.billing, unit openvibe-billing, API on 127.0.0.1:4600 (loopback only; /api/v1 is denied on the public host). Public at https://billing.openvibe.network: the staff console (Network SSO + PKCE, BILLING_STAFF_SUBJECTS = the owner only), the billing policy (/policy), a person's own Vibes (/api/me/vibes, CORS for OpenVibe's sites), /api/health, /api/ready, /release.json and /webhooks/*. PowerChat deliveries reach /webhooks/powerchat through openvibe.live's /api/powerchat/webhook (proxied by Live's vhost, OpenVibe.Live#172).",-- "contracts": "openvibe-contracts v0.289.1",-- "sdk": "openvibe-sdk v0.84.0",++ "contracts": "openvibe-contracts v0.297.0",++ "sdk": "openvibe-sdk v0.90.0","shared": "openvibe-shared v4.115.1","notes": "Runtime, journal, adapters, import, reconciliation and the staff console (cashout approve/deny, review queue, reconciliation, freeze, audit log) are built, tested and deployed, with /metrics (loopback only), hourly scheduled reconciliation, EXTERNAL PowerChat receipts (billing.receipt.external, announced for OpenVibe.Tips), overflow guards and serializable settlement (PostgreSQL, ADR-035). The cutover (README runbook, docs/live-cutover.md) ran on 2026-10-17: final import 0 new rows, 0 holds, 0 anomalies; reconciliation OK before and after; Live's money tables are read-only behind a database tripwire. OpenVibe.Tips and OpenVibe.VIP launched right after (VIP checkout open). Still due: a small real PowerChat purchase settling end to end after the cutover. Live's payouts, refunds and plans have no tables of their own; where they map is docs/live-mapping.md.","features": [@@@ -12,8 +12,8 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.84.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.297.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.90.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.115.1","pg": "^8.23.0"},@@@ -1103,9 +1103,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.289.1",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",-- "integrity": "sha512-LrKTwe8VPCWn+b0MTNmfjAYEJ0rtgLxeM6EHONBDl0ynt/Igndz+ruDEpwuz5SZ9HYCIMkCqSV5ujhedh6zmzQ==",++ "version": "0.297.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.297.0",++ "integrity": "sha512-FycRElRNzeFLEfzVUKIyE3ieKphUrQbAbsprnvs0Uu7ZdlBKJG6wQV/9gD77Neu1Etcsyezf9Boyq+MsjuraVA==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -1119,9 +1119,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.84.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.84.0",-- "integrity": "sha512-OLngno3URAIUOqvEtQ9e/dvg3mhZrndGOEBEIgA7yn/uEDtYykgpc6HKajiyeub4UvZ8Dn2Vqn0WdqIYr/8/Uw==",++ "version": "0.90.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.90.0",++ "integrity": "sha512-uOcYG+w5BVKePmSmCsmhFe6E1CNTr7/vLUqyOLSzsB9Z8D3VNbWsPCwOd5W0eY+3Dd4YhYIf4iT0QTIj9dinGg==","license": "MIT","bin": {"openvibe-download": "bin/openvibe-download.js"@@@ -2,7 +2,7 @@"name": "openvibe-billing","version": "0.1.0","private": true,-- "description": "OpenVibe.Billing — the isolated money ledger of the OpenVibe network: a balanced append-only journal, provider receipts, subscriptions/entitlements, refunds and payouts (ADR-012).",++ "description": "OpenVibe.Billing \u2014 the isolated money ledger of the OpenVibe network: a balanced append-only journal, provider receipts, subscriptions/entitlements, refunds and payouts (ADR-012).","main": "server/index.js","scripts": {"start": "node server/index.js",@@@ -26,8 +26,8 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.84.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.297.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.90.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.115.1","pg": "^8.23.0"},nnew file mode 100644@@@ -0,0 +1,97 @@++'use strict';++++/**++ * Subject assertions on the money routes (ADR-003, the 2026-11-06 amendment; plan H1).++ *++ * ADR-003 says a first-party service vouches for its own signed-in person: it names them in the++ * request and the receiver believes it. On a money route that is too much to believe, because a++ * compromised service could then read anyone's transactions or tip in their name.++ *++ * A subject assertion is the person's own answer to the same question: a Network-signed,++ * 60-second JWT that names the person (`sub`), the service it was minted for (`act`) and the++ * service it is for (`aud`). The caller sends it in `X-OV-Subject-Assertion`; Billing checks the++ * signature against Network's JWKS and that the person it names is the person the route acts for.++ *++ * The switch is `BILLING_REQUIRE_SUBJECT_ASSERTION`:++ *++ * off (today) a money route that names a person still works without an assertion (the old,++ * vouched way). An assertion that comes is checked: one that names another person++ * is refused, and one that does not verify is counted and ignored.++ * on a money route that names a person must carry an assertion, and it must verify.++ * The old way is then refused (403 `subject.assertion_required`).++ *++ * The flag turns on once every caller sends assertions (the callers' PRs are part of this change).++ */++const { createSubjectAssertionVerifier } = require('openvibe-sdk/assertions');++const { fail } = require('../ops/common');++++/** The header a money call carries the assertion in. */++const HEADER = 'x-ov-subject-assertion';++++function createSubjectAssertion({ config, log = console, registry = null }) {++ const audience = config.audience || 'openvibe.billing';++ const base = String((config.network && (config.network.internalUrl || config.network.url)) || '').replace(/\/+$/, '');++ const jwksUrl = `${base}/api/.well-known/jwks`;++ const verifier = createSubjectAssertionVerifier({ jwks: jwksUrl, audience, log });++ // What the routes did while the flag was off (and what a dashboard would show).++ const counters = { asserted: 0, unasserted: 0, invalid: 0, refused: 0 };++ // The number the flag plan waits for: the per-person calls that still come without an assertion.++ // It is a gauge, not a counter, so a restart reads zero again and the operator watches the shape.++ if (registry && typeof registry.gauge === 'function') {++ registry.gauge({++ name: 'billing_subject_assertion_calls',++ help: 'Per-person money calls by their subject assertion state (asserted, unasserted, invalid, refused)',++ labelNames: ['state'],++ collect: () => Object.entries(counters).map(([state, value]) => ({ labels: { state }, value })),++ });++ }++++ /** True when the old way is refused and an assertion is required. */++ const required = () => Boolean(config.subjectAssertion && config.subjectAssertion.required);++++ /** The assertion on this request, verified. */++ async function verify(req) {++ const raw = String((req.headers && req.headers[HEADER]) || '').trim();++ if (!raw) return { present: false, ok: false, code: null, reason: null };++ const r = await verifier.verify(raw, { caller: req.principal && req.principal.sub ? req.principal.sub : undefined });++ if (!r.ok) return { present: true, ok: false, code: r.code, reason: r.reason };++ return { present: true, ok: true, claims: r.claims };++ }++++ /**++ * A money route that names a person: the assertion must name the same person.++ *++ * `subject` is the person the route acts for (the payer, the subscriber, the reader). Pass++ * null when the route names nobody: there is nothing to check then.++ */++ async function person(req, subject) {++ if (!subject) return null;++ const v = await verify(req);++ if (!v.present) {++ counters.unasserted += 1;++ if (required()) fail(403, 'subject.assertion_required', "this call must carry the person's own subject assertion (X-OV-Subject-Assertion)");++ return null;++ }++ if (!v.ok) {++ // An assertion minted for another service is never accepted here, flag or no flag: it is++ // another service's proof, carried here.++ if (v.code === 'assertion.actor_mismatch') { counters.refused += 1; fail(403, v.code, v.reason); }++ counters.invalid += 1;++ if (required()) fail(403, v.code, v.reason);++ // While the flag is off a stale or broken assertion does not stop the money: the old++ // way continues, and the count says how many calls still run without one.++ (log && log.warn) ? log.warn(`[Assertions] a subject assertion was refused (${v.code}); the call continues while BILLING_REQUIRE_SUBJECT_ASSERTION is off`) : null;++ return null;++ }++ counters.asserted += 1;++ if (v.claims.sub !== subject) {++ counters.refused += 1;++ fail(403, 'subject.assertion_mismatch', `the assertion names ${v.claims.sub}, not ${subject}`);++ }++ return v.claims;++ }++++ return { person, verify, counters, required, jwksUrl, audience };++}++++module.exports = { createSubjectAssertion, HEADER };@@@ -49,7 +49,7 @@ const CAP = {vibesRead: 'billing.vibes.read',};--function v1Router({ ctx, auth, adapters, limits }) {++function v1Router({ ctx, auth, adapters, limits, subjectAssertion }) {const r = express.Router();const { db } = ctx;const idem = idempotent(db, ctx.now);@@@ -64,6 +64,9 @@ function v1Router({ ctx, auth, adapters, limits }) {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]);++ // The person a money route names must be the person the assertion names (plan H1: ADR-003, the++ // 2026-11-06 amendment). Without the module (an old caller) nothing is checked.++ const assertion = subjectAssertion || { person: async () => null };const txnOut = (t, extra = {}) => ({ transaction: presentTxn(t), ...extra });const pathSubject = (v) => userSubject(String(v), 'subject');@@@ -86,6 +89,7 @@ function v1Router({ ctx, auth, adapters, limits }) {// ── Checkout intents ─────────────────────────────────────r.post('/intents', ...write(CAP.intent, async (req, res) => {const b = req.body || {};++ await assertion.person(req, b.subject ? userSubject(b.subject, 'subject') : null);const adapter = adapters[String(b.provider || '').toLowerCase()];if (!adapter) fail(422, 'billing.invalid_input', `unknown provider ${b.provider}`);if (!adapter.enabled) fail(409, 'billing.provider_disabled', `${adapter.name} is not enabled on Billing`);@@@ -123,6 +127,8 @@ function v1Router({ ctx, auth, adapters, limits }) {}));// ── Purchases: settle a provider receipt (operator/reconciler grade) ──++ // The operator settles a provider receipt: a staff action about a payment, not a service acting++ // for a person, so it carries no subject assertion.r.post('/purchases/settle', ...write(CAP.admin, async (req, res) => {const b = req.body || {};if (!b.provider || !b.provider_ref) fail(422, 'billing.invalid_input', 'provider and provider_ref (the provider payment id) are required');@@@ -137,6 +143,7 @@ function v1Router({ ctx, auth, adapters, limits }) {// ── Transfers (tips / donations / paid interactions) ─────r.post('/transfers', ...write(CAP.transfer, async (req, res) => {const b = req.body || {};++ await assertion.person(req, b.from ? userSubject(b.from, 'from') : null);const out = await transfers.create(ctx, {from: userSubject(b.from, 'from'), to: userSubject(b.to, 'to'), amount: b.amount, kind: b.kind, target: b.target,message: b.message, idempotencyKey: req.idempotencyKey, actor: actorOf(req), traceId: req.ov.traceId,@@@ -153,6 +160,7 @@ function v1Router({ ctx, auth, adapters, limits }) {r.post('/recycle', ...write(CAP.cashoutRequest, async (req, res) => {const b = req.body || {};const subject = userSubject(b.subject);++ await assertion.person(req, subject);const out = await cashouts.recycle(ctx, { subject, amount: b.amount, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });res.status(201).json(txnOut(out.txn, { balance: await balanceOf(subject) }));}, limits.budget('billing.recycle', person('subject'))));@@@ -160,6 +168,7 @@ function v1Router({ ctx, auth, adapters, limits }) {// ── Cashouts ─────────────────────────────────────────────r.post('/cashouts', ...write(CAP.cashoutRequest, async (req, res) => {const b = req.body || {};++ await assertion.person(req, b.subject ? userSubject(b.subject, 'subject') : null);const out = await cashouts.request(ctx, {subject: userSubject(b.subject), amount: b.amount, payout_method: b.payout_method,projectId: b.project_id, idempotencyKey: req.idempotencyKey, actor: actorOf(req),@@@ -167,12 +176,14 @@ function v1Router({ ctx, auth, adapters, limits }) {res.status(201).json({ cashout: cashouts.present(out.cashout) });}, limits.budget('billing.cashout.request', person('subject'))));r.get('/cashouts', ...read(CAP.cashoutManage, async (req, res) => {++ await assertion.person(req, req.query.subject ? pathSubject(req.query.subject) : null);const list = await cashouts.list(db, { status: req.query.status, subject: req.query.subject, limit: Number(req.query.limit) || 100 });res.json({ cashouts: list.map(cashouts.present) });}));r.get('/cashouts/:id', ...read([CAP.cashoutManage, CAP.cashoutRequest], async (req, res) => {const c = await cashouts.find(db, req.params.id);if (!c) fail(404, 'billing.cashout_not_found', `no cashout ${req.params.id}`);++ await assertion.person(req, c.subject);res.json({ cashout: cashouts.present(c) });}));r.post('/cashouts/:id/approve', ...write(CAP.cashoutManage, async (req, res) => {@@@ -200,8 +211,10 @@ function v1Router({ ctx, auth, adapters, limits }) {const provider = String(rc.provider).toLowerCase();receipt = { provider, receiptRef: `${provider}:${rc.provider_ref}`, paidCents: rc.amount_cents, feeCents: rc.fee_cents, route: rc.route, providerRef: rc.subscription_ref || null, test: !!rc.test };}++ const subscriber = userSubject(b.subscriber, 'subscriber');++ await assertion.person(req, subscriber);const out = await subscriptions.pay(ctx, {-- subscriber: userSubject(b.subscriber, 'subscriber'), streamer: userSubject(b.streamer, 'streamer'), source,++ subscriber, streamer: userSubject(b.streamer, 'streamer'), source,// A tier is priced by Billing (ops/tiers.js); an explicit price_cents only applies without one.tier: b.tier, priceCents: b.tier != null ? undefined : b.price_cents,autoRenew: b.auto_renew, receipt, idempotencyKey: req.idempotencyKey, actor: actorOf(req),@@@ -224,6 +237,7 @@ function v1Router({ ctx, auth, adapters, limits }) {res.json({ tier });}));r.get('/subscriptions', ...read([CAP.entitlement, CAP.subscription], async (req, res) => {++ await assertion.person(req, req.query.subscriber ? pathSubject(req.query.subscriber) : null);const list = await subscriptions.list(db, {subscriber: req.query.subscriber ? pathSubject(req.query.subscriber) : null,streamer: req.query.streamer ? pathSubject(req.query.streamer) : null,@@@ -234,9 +248,12 @@ function v1Router({ ctx, auth, adapters, limits }) {r.get('/subscriptions/:id', ...read([CAP.entitlement, CAP.subscription], async (req, res) => {const s = await subscriptions.find(db, req.params.id);if (!s) fail(404, 'billing.subscription_not_found', `no subscription ${req.params.id}`);++ await assertion.person(req, s.subscriber);res.json({ subscription: subscriptions.present(s), entitlement: await subscriptions.entitlement(db, s.subscriber, s.streamer, ctx.now()) });}));r.post('/subscriptions/:id/cancel', ...write(CAP.subscription, async (req, res) => {++ const s = await subscriptions.find(db, req.params.id);++ if (s) await assertion.person(req, s.subscriber);const out = await subscriptions.cancel(ctx, { id: req.params.id, actor: actorOf(req) }, adapters);res.json({ subscription: subscriptions.present(out.subscription), provider_sync: out.provider_sync });}));@@@ -253,6 +270,7 @@ function v1Router({ ctx, auth, adapters, limits }) {}));r.get('/entitlements/:subject', ...read(CAP.entitlement, async (req, res) => {const subject = pathSubject(req.params.subject);++ await assertion.person(req, subject);if (req.query.streamer) return res.json(await subscriptions.entitlement(db, subject, pathSubject(req.query.streamer), ctx.now()));return res.json({ subject: { type: 'user', id: subject }, entitlements: await subscriptions.activeEntitlements(db, subject, ctx.now()) });}));@@@ -268,11 +286,16 @@ function v1Router({ ctx, auth, adapters, limits }) {payable_value_cents: ctx.rates.valueCents(payable), bits_per_usd: ctx.rates.bitsPerUsd,};}-- r.get('/balances/:subject', ...read(CAP.balance, async (req, res) => res.json(await balanceOf(pathSubject(req.params.subject)))));++ r.get('/balances/:subject', ...read(CAP.balance, async (req, res) => {++ const subject = pathSubject(req.params.subject);++ await assertion.person(req, subject);++ return res.json(await balanceOf(subject));++ }));r.get('/transactions', ...read(CAP.balance, async (req, res) => {const subject = req.query.subject ? pathSubject(req.query.subject) : null;if (!subject) fail(422, 'billing.invalid_input', 'subject is required');++ await assertion.person(req, subject);const limit = Math.min(200, positiveInt(req.query.limit || 50, 'limit'));let cur = null;if (req.query.cursor) {@@@ -289,6 +312,7 @@ function v1Router({ ctx, auth, adapters, limits }) {r.get('/transactions/:id', ...read(CAP.balance, async (req, res) => {const t = await getTxn(db, req.params.id);if (!t) fail(404, 'billing.transaction_not_found', `no transaction ${req.params.id}`);++ await assertion.person(req, t.from_subject);const reversals = (await db.prepare('SELECT id FROM transactions WHERE reverses_txn = ? ORDER BY created_at').all(t.id)).map((x) => x.id);res.json({ transaction: presentTxn(t), reversed_by: reversals });}));@@@ -297,6 +321,12 @@ function v1Router({ ctx, auth, adapters, limits }) {// Stored and reported; never charged here (the rating sweep, ops/rating.js, charges later). Not a money movement, so accepted while frozen; idempotent by the// reading's own idempotency_key (no Idempotency-Key header): a replay is 200 with the stored row, a new one 201.r.post('/usage', auth.needs(CAP.usage), wrap(async (req, res) => {++ const b = req.body || {};++ // A reading names its subject in its own way (platform.usage-sample@1 allows any subject string, and a++ // project's own readings may use a project-local id). Only a canonical person subject (usr_…) can be++ // checked against an assertion, so only that one is.++ const readSubject = String(b.subject || '');++ await assertion.person(req, /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(readSubject) ? readSubject : null);const out = await usage.record(ctx, req.body, { principal: req.principal.sub });if (out.replayed) res.setHeader('Idempotent-Replayed', 'true');res.status(out.replayed ? 200 : 201).json({ record: out.record });@@@ -400,6 +430,7 @@ function v1Router({ ctx, auth, adapters, limits }) {const out = await admin.adjust(ctx, { from: b.from, to: b.to, amount: b.amount, reason: b.reason, relatesTo: b.relates_to, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });res.status(201).json(txnOut(out.txn));}));++ // A staff grant of promo credit: an operator action, not a service acting for a person.r.post('/admin/promo/grant', ...write(CAP.admin, async (req, res) => {const b = req.body || {};const out = await promo.grant(ctx, { subject: b.subject, service: b.service, bits: b.bits, period: b.period, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });@@@ -137,7 +137,11 @@ async function createApp(opts = {}) {app.use('/internal/credits', express.json({ limit: '64kb' }), internalRouter({ ctx, auth }));// 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, valkey });-- app.use('/api/v1', express.json({ limit: '64kb' }), v1Router({ ctx, auth, adapters, limits }));++ // Subject assertions on the money routes (ADR-003, the 2026-11-06 amendment; plan H1).++ // The registry carries the counters: the number of per-person calls that still come without an++ // assertion is what the flag plan waits for.++ const subjectAssertion = require('./api/subject-assertion').createSubjectAssertion({ config, log, registry: metrics.registry });++ app.use('/api/v1', express.json({ limit: '64kb' }), v1Router({ ctx, auth, adapters, limits, subjectAssertion }));// A signed-in person's own Vibes, for the shared navbar on every OpenVibe site (api/me.js; user tokens, CORS for owned sites).app.use('/api/me', require('./api/me').meRouter({ ctx, config, keys, limits, adapters }));@@@ -85,6 +85,13 @@ 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',++ // Subject assertions (ADR-003, the 2026-11-06 amendment; plan H1): a money route that names++ // a person carries the person's own Network-signed assertion (X-OV-Subject-Assertion).++ // BILLING_REQUIRE_SUBJECT_ASSERTION=on refuses the old way (the service vouching for its++ // person) and turns the check on for every per-person route.++ subjectAssertion: {++ required: String(env.BILLING_REQUIRE_SUBJECT_ASSERTION || 'off').toLowerCase() === 'on',++ },// 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).@@@ -11,12 +11,17 @@ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))const readme = fs.readFileSync(path.join(root, 'README.md'), 'utf8');let checked = 0;++let branches = 0;for (const spec of Object.values(pkg.dependencies || {})) {const m = /codeload\.github\.com\/OpenVibers\/(OpenVibe\.[A-Za-z]+)\/tar\.gz\/refs\/tags\/v(\d+\.\d+\.\d+)$/.exec(String(spec));-- if (!m) continue;++ if (!m) {++ // A branch pin (a library whose release is not cut yet): no version to print in the README.++ if (/codeload\.github\.com\/OpenVibers\/OpenVibe\.[A-Za-z]+\/tar\.gz\/refs\/heads\//.test(String(spec))) branches++;++ continue;++ }const name = m[1].toLowerCase().replace(/^openvibe\./, 'openvibe-'); // OpenVibe.Shared -> openvibe-sharedassert.ok(readme.includes('`' + name + '` v' + m[2]), `the README pins ${name} at v${m[2]}`);checked++;}--assert.ok(checked >= 3, 'the three shared libraries are pinned by tag');++assert.ok(checked + branches >= 3, 'the three shared libraries are pinned (by tag, or by branch until the tag is cut)');console.log('dependency pins: the README matches every pinned library');@@@ -25,6 +25,9 @@ async function startNetwork() {const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });const publicPem = publicKey.export({ type: 'spki', format: 'pem' });const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });++ // The subject-assertion key (ES256): Network publishes it beside the RS256 key, and the stub++ // signs assertions with it so a test can play the caller that carries one.++ const assertionKeys = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });const legacy = {}; // live user id → subject idconst resolveCalls = [];const grants = [];@@@ -35,6 +38,18 @@ async function startNetwork() {let issuer = 'http://network.test';const state = { down: false };++ const b64url = (b) => Buffer.from(b).toString('base64url');++ /** A subject assertion, as Network signs it: the person, the audience, the service it was minted for. */++ function signAssertion({ subject, audience = 'openvibe.billing', act = 'svc:live', expSec = 60, kid = 'ov-network-assertion-1', jti } = {}) {++ const now = Math.floor(Date.now() / 1000);++ const header = b64url(JSON.stringify({ alg: 'ES256', typ: 'JWT', kid }));++ const body = b64url(JSON.stringify({++ iss: issuer, sub: subject, aud: audience, act, jti: jti || `as_${crypto.randomBytes(8).toString('hex')}`,++ iat: now, exp: now + expSec,++ }));++ const sig = crypto.sign('sha256', Buffer.from(`${header}.${body}`), { key: assertionKeys.privateKey, dsaEncoding: 'ieee-p1363' });++ return `${header}.${body}.${b64url(sig)}`;++ }function signService({ sub = 'svc:live', aud = ['openvibe.billing'], cap = [], expSec = 300, iss } = {}) {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);@@@ -97,7 +112,18 @@ async function startNetwork() {const server = http.createServer(async (req, res) => {const raw = await readBody(req);-- if (req.url === '/api/.well-known/jwks') return send(res, 200, { public_key: publicPem, algorithm: 'RS256' });++ if (req.url === '/api/.well-known/jwks') {++ // Both keys, as Network publishes them: the RS256 session key and the ES256 assertion key.++ const rsa = publicKey.export({ format: 'jwk' });++ const ec = assertionKeys.publicKey.export({ format: 'jwk' });++ return send(res, 200, {++ public_key: publicPem, algorithm: 'RS256',++ keys: [++ { ...rsa, use: 'sig', alg: 'RS256', kid: 'ov-network-1' },++ { ...ec, use: 'sig', alg: 'ES256', kid: 'ov-network-assertion-1' },++ ],++ });++ }if (req.url === '/oauth/token' && req.method === 'POST') {// openvibe-sdk/sso posts the grant as JSON; the real Network takes that (and this stub keeps the form shape too).const text = String(raw || '');@@@ -175,7 +201,7 @@ async function startNetwork() {issuer = url;// A person's Network session token, as the browser holds it (the shared navbar sends it).const signUser = (u) => userToken({ role: 'user', username: 'someone', nowMs: Date.now(), ...u });-- return { url, publicPem, signService, signApp, signUser, addUser, addMembership, addProject, addApp, memberships, projects, apps, publicNames, authorize, revoked, legacy, resolveCalls, grants, state, close: () => new Promise((r) => server.close(r)) };++ return { url, publicPem, signService, signApp, signUser, signAssertion, addUser, addMembership, addProject, addApp, memberships, projects, apps, publicNames, authorize, revoked, legacy, resolveCalls, grants, state, close: () => new Promise((r) => server.close(r)) };}async function startStripe() {nnew file mode 100644@@@ -0,0 +1,116 @@++'use strict';++/**++ * Subject assertions on the money routes (server/api/subject-assertion.js; ADR-003, the 2026-11-06++ * amendment; plan H1).++ *++ * A money route that names a person checks the person's own Network-signed assertion++ * (X-OV-Subject-Assertion): the signature, the audience, the service it was minted for and the person++ * it names. A forged, expired, replayed or wrong-audience assertion is refused; one that names another++ * person never moves money.++ */++const assert = require('assert');++const { boot, fund, check, done } = require('./helpers/app');++++(async () => {++ console.log('subject assertions');++ // The flag off: the old way still works, and an assertion that comes is checked.++ const t = await boot();++ const fan = t.user(41);++ const creator = t.user(42);++ const other = t.user(43);++ await fund(t, fan, 10000);++ const tip = (o = {}) => t.call('POST', '/api/v1/transfers', { cap: ['billing.transfer.create'], body: { from: fan.id, to: creator.id, amount: 10, kind: 'donation' }, ...o });++ const assertion = (o = {}) => t.network.signAssertion({ subject: fan.id, act: 'svc:live', ...o });++++ await check('without the flag, a money route that names a person still works the old way', async () => {++ const r = await tip();++ assert.strictEqual(r.status, 201, r.text);++ });++++ await check('without the flag, an assertion that names the person is accepted', async () => {++ const r = await tip({ headers: { 'x-ov-subject-assertion': assertion() } });++ assert.strictEqual(r.status, 201, r.text);++ });++++ await check('an assertion that names another person is refused, and no money moves', async () => {++ const before = (await t.balances(fan.id)).credit;++ const r = await tip({ headers: { 'x-ov-subject-assertion': assertion({ subject: other.id }) } });++ assert.strictEqual(r.status, 403, r.text);++ assert.strictEqual(r.json.code, 'subject.assertion_mismatch');++ assert.strictEqual((await t.balances(fan.id)).credit, before, 'the money did not move');++ });++++ await check('an assertion minted for another service is refused', async () => {++ const r = await tip({ headers: { 'x-ov-subject-assertion': assertion({ act: 'svc:vip' }) } });++ assert.strictEqual(r.status, 403, r.text);++ assert.strictEqual(r.json.code, 'assertion.actor_mismatch');++ });++++ await check('without the flag, a forged assertion does not stop the money (the old way continues)', async () => {++ const r = await tip({ headers: { 'x-ov-subject-assertion': 'not.a.jwt' } });++ assert.strictEqual(r.status, 201, r.text);++ });++++ // The flag on: the old way is refused.++ const t2 = await boot({ env: { BILLING_REQUIRE_SUBJECT_ASSERTION: 'on' } });++ const payer = t2.user(44);++ const payee = t2.user(45);++ await fund(t2, payer, 10000);++ const tip2 = (o = {}) => t2.call('POST', '/api/v1/transfers', { cap: ['billing.transfer.create'], body: { from: payer.id, to: payee.id, amount: 10, kind: 'donation' }, ...o });++ const assertion2 = (o = {}) => t2.network.signAssertion({ subject: payer.id, act: 'svc:live', ...o });++++ await check('with the flag on, a money route without an assertion is refused', async () => {++ const r = await tip2();++ assert.strictEqual(r.status, 403, r.text);++ assert.strictEqual(r.json.code, 'subject.assertion_required');++ });++++ await check('with the flag on, the person\'s own assertion lets the money through', async () => {++ const r = await tip2({ headers: { 'x-ov-subject-assertion': assertion2() } });++ assert.strictEqual(r.status, 201, r.text);++ });++++ await check('with the flag on, a forged assertion is refused', async () => {++ const r = await tip2({ headers: { 'x-ov-subject-assertion': 'not.a.jwt' } });++ assert.strictEqual(r.status, 403, r.text);++ assert.strictEqual(r.json.code, 'assertion.malformed');++ });++++ await check('with the flag on, an expired assertion is refused', async () => {++ const r = await tip2({ headers: { 'x-ov-subject-assertion': assertion2({ expSec: -30 }) } });++ assert.strictEqual(r.status, 403, r.text);++ assert.strictEqual(r.json.code, 'assertion.expired');++ });++++ await check('with the flag on, an assertion for another audience is refused', async () => {++ const r = await tip2({ headers: { 'x-ov-subject-assertion': assertion2({ audience: 'openvibe.tips' }) } });++ assert.strictEqual(r.status, 403, r.text);++ assert.strictEqual(r.json.code, 'assertion.wrong_audience');++ });++++ await check('with the flag on, a replayed assertion is refused the second time', async () => {++ const a = assertion2();++ const first = await tip2({ headers: { 'x-ov-subject-assertion': a } });++ assert.strictEqual(first.status, 201, first.text);++ const second = await tip2({ headers: { 'x-ov-subject-assertion': a } });++ assert.strictEqual(second.status, 403, second.text);++ assert.strictEqual(second.json.code, 'assertion.replayed');++ });++++ await check('the counters the flag plan waits for are on /metrics', async () => {++ const m = await fetch(`${t.base}/metrics`).then((r) => r.text());++ assert.ok(/billing_subject_assertion_calls\{state="asserted"\}/.test(m), 'the calls that carried an assertion are counted');++ assert.ok(/billing_subject_assertion_calls\{state="unasserted"\}/.test(m), 'the calls that came without one are counted');++ assert.ok(!/usr_[0-9A-Z]{26}/.test(m), 'no subject ever appears in metrics');++ });++++ await check('a balance read for a person is a per-person route too', async () => {++ const r = await t2.call('GET', `/api/v1/balances/${payer.id}`, { cap: ['billing.balance.read'] });++ assert.strictEqual(r.status, 403, r.text);++ assert.strictEqual(r.json.code, 'subject.assertion_required');++ const ok = await t2.call('GET', `/api/v1/balances/${payer.id}`, { cap: ['billing.balance.read'], headers: { 'x-ov-subject-assertion': assertion2() } });++ assert.strictEqual(ok.status, 200, ok.text);++ });++++ await done();++})().catch((err) => { console.error(err); process.exit(1); });
@@ -66,7 +66,7 @@ promo write changes both in one transaction.
@@ -66,7 +66,7 @@ promo write changes both in one transaction.
- OpenVibe.Network (JWKS for service tokens; SSO with PKCE for the staff console; `identity.subject.resolve` for the Live import)
- OpenVibe.Network (JWKS for service tokens; SSO with PKCE for the staff console; `identity.subject.resolve` for the Live import)
- OpenVibe.Events (the SDK outbox relay, when `EVENTS_URL` and the OAuth client secret are set)
- OpenVibe.Events (the SDK outbox relay, when `EVENTS_URL` and the OAuth client secret are set)
- the payment providers whose secrets are configured (none in production today)
- the payment providers whose secrets are configured (none in production today)
-- `openvibe-contracts` v0.289.1, `openvibe-sdk` v0.84.0 (service tokens, per-person limits, `openvibe-sdk/db`, `openvibe-sdk/service`, `openvibe-sdk/vibes`), `openvibe-shared` v4.115.1, pinned by release tarball
+- `openvibe-contracts` v0.297.0, `openvibe-sdk` v0.90.0 (service tokens, per-person limits, `openvibe-sdk/db`, `openvibe-sdk/service`, `openvibe-sdk/vibes`), `openvibe-shared` v4.115.1, pinned by release tarball
- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through
- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through
`openvibe-sdk/db`; Valkey holds the per-person limit counters (optional: without `VALKEY_URL` they count in the process)
`openvibe-sdk/db`; Valkey holds the per-person limit counters (optional: without `VALKEY_URL` they count in the process)
@@ -513,7 +513,7 @@ PowerChat purchase settling end to end after the cutover.
@@ -513,7 +513,7 @@ PowerChat purchase settling end to end after the cutover.
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.289.1
-- openvibe-sdk: v0.84.0
+- openvibe-contracts: v0.297.0
+- openvibe-sdk: v0.90.0
- openvibe-shared: v4.115.1
- openvibe-shared: v4.115.1
<!-- versions:end -->
<!-- versions:end -->
@@ -8,8 +8,8 @@
@@ -8,8 +8,8 @@
"authoritative": true,
"authoritative": true,
"deployed": true,
"deployed": true,
"deployment": "The money ledger since the cutover of 2026-10-17 04:50-04:56 UTC (BILLING_AUTHORITY=billing in billing.env and live.env): deployed with `sudo ovhost deploy billing`, /opt/openvibe.billing, unit openvibe-billing, API on 127.0.0.1:4600 (loopback only; /api/v1 is denied on the public host). Public at https://billing.openvibe.network: the staff console (Network SSO + PKCE, BILLING_STAFF_SUBJECTS = the owner only), the billing policy (/policy), a person's own Vibes (/api/me/vibes, CORS for OpenVibe's sites), /api/health, /api/ready, /release.json and /webhooks/*. PowerChat deliveries reach /webhooks/powerchat through openvibe.live's /api/powerchat/webhook (proxied by Live's vhost, OpenVibe.Live#172).",
"deployment": "The money ledger since the cutover of 2026-10-17 04:50-04:56 UTC (BILLING_AUTHORITY=billing in billing.env and live.env): deployed with `sudo ovhost deploy billing`, /opt/openvibe.billing, unit openvibe-billing, API on 127.0.0.1:4600 (loopback only; /api/v1 is denied on the public host). Public at https://billing.openvibe.network: the staff console (Network SSO + PKCE, BILLING_STAFF_SUBJECTS = the owner only), the billing policy (/policy), a person's own Vibes (/api/me/vibes, CORS for OpenVibe's sites), /api/health, /api/ready, /release.json and /webhooks/*. PowerChat deliveries reach /webhooks/powerchat through openvibe.live's /api/powerchat/webhook (proxied by Live's vhost, OpenVibe.Live#172).",
- "contracts": "openvibe-contracts v0.289.1",
- "sdk": "openvibe-sdk v0.84.0",
+ "contracts": "openvibe-contracts v0.297.0",
+ "sdk": "openvibe-sdk v0.90.0",
"shared": "openvibe-shared v4.115.1",
"shared": "openvibe-shared v4.115.1",
"notes": "Runtime, journal, adapters, import, reconciliation and the staff console (cashout approve/deny, review queue, reconciliation, freeze, audit log) are built, tested and deployed, with /metrics (loopback only), hourly scheduled reconciliation, EXTERNAL PowerChat receipts (billing.receipt.external, announced for OpenVibe.Tips), overflow guards and serializable settlement (PostgreSQL, ADR-035). The cutover (README runbook, docs/live-cutover.md) ran on 2026-10-17: final import 0 new rows, 0 holds, 0 anomalies; reconciliation OK before and after; Live's money tables are read-only behind a database tripwire. OpenVibe.Tips and OpenVibe.VIP launched right after (VIP checkout open). Still due: a small real PowerChat purchase settling end to end after the cutover. Live's payouts, refunds and plans have no tables of their own; where they map is docs/live-mapping.md.",
"notes": "Runtime, journal, adapters, import, reconciliation and the staff console (cashout approve/deny, review queue, reconciliation, freeze, audit log) are built, tested and deployed, with /metrics (loopback only), hourly scheduled reconciliation, EXTERNAL PowerChat receipts (billing.receipt.external, announced for OpenVibe.Tips), overflow guards and serializable settlement (PostgreSQL, ADR-035). The cutover (README runbook, docs/live-cutover.md) ran on 2026-10-17: final import 0 new rows, 0 holds, 0 anomalies; reconciliation OK before and after; Live's money tables are read-only behind a database tripwire. OpenVibe.Tips and OpenVibe.VIP launched right after (VIP checkout open). Still due: a small real PowerChat purchase settling end to end after the cutover. Live's payouts, refunds and plans have no tables of their own; where they map is docs/live-mapping.md.",
"features": [
"features": [
@@ -12,8 +12,8 @@
@@ -12,8 +12,8 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.84.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.297.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.90.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.115.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.115.1",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -1103,9 +1103,9 @@
@@ -1103,9 +1103,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.289.1",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
- "integrity": "sha512-LrKTwe8VPCWn+b0MTNmfjAYEJ0rtgLxeM6EHONBDl0ynt/Igndz+ruDEpwuz5SZ9HYCIMkCqSV5ujhedh6zmzQ==",
+ "version": "0.297.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.297.0",
+ "integrity": "sha512-FycRElRNzeFLEfzVUKIyE3ieKphUrQbAbsprnvs0Uu7ZdlBKJG6wQV/9gD77Neu1Etcsyezf9Boyq+MsjuraVA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1119,9 +1119,9 @@
@@ -1119,9 +1119,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.84.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.84.0",
- "integrity": "sha512-OLngno3URAIUOqvEtQ9e/dvg3mhZrndGOEBEIgA7yn/uEDtYykgpc6HKajiyeub4UvZ8Dn2Vqn0WdqIYr/8/Uw==",
+ "version": "0.90.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.90.0",
+ "integrity": "sha512-uOcYG+w5BVKePmSmCsmhFe6E1CNTr7/vLUqyOLSzsB9Z8D3VNbWsPCwOd5W0eY+3Dd4YhYIf4iT0QTIj9dinGg==",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
"name": "openvibe-billing",
"name": "openvibe-billing",
"version": "0.1.0",
"version": "0.1.0",
"private": true,
"private": true,
- "description": "OpenVibe.Billing — the isolated money ledger of the OpenVibe network: a balanced append-only journal, provider receipts, subscriptions/entitlements, refunds and payouts (ADR-012).",
+ "description": "OpenVibe.Billing \u2014 the isolated money ledger of the OpenVibe network: a balanced append-only journal, provider receipts, subscriptions/entitlements, refunds and payouts (ADR-012).",
"main": "server/index.js",
"main": "server/index.js",
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
@@ -26,8 +26,8 @@
@@ -26,8 +26,8 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.84.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.297.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.90.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.115.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.115.1",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,97 @@
@@ -0,0 +1,97 @@
+'use strict';
+
+/**
+ * Subject assertions on the money routes (ADR-003, the 2026-11-06 amendment; plan H1).
+ *
+ * ADR-003 says a first-party service vouches for its own signed-in person: it names them in the
+ * request and the receiver believes it. On a money route that is too much to believe, because a
+ * compromised service could then read anyone's transactions or tip in their name.
+ *
+ * A subject assertion is the person's own answer to the same question: a Network-signed,
+ * 60-second JWT that names the person (`sub`), the service it was minted for (`act`) and the
+ * service it is for (`aud`). The caller sends it in `X-OV-Subject-Assertion`; Billing checks the
+ * signature against Network's JWKS and that the person it names is the person the route acts for.
+ *
+ * The switch is `BILLING_REQUIRE_SUBJECT_ASSERTION`:
+ *
+ * off (today) a money route that names a person still works without an assertion (the old,
+ * vouched way). An assertion that comes is checked: one that names another person
+ * is refused, and one that does not verify is counted and ignored.
+ * on a money route that names a person must carry an assertion, and it must verify.
+ * The old way is then refused (403 `subject.assertion_required`).
+ *
+ * The flag turns on once every caller sends assertions (the callers' PRs are part of this change).
+ */
+const { createSubjectAssertionVerifier } = require('openvibe-sdk/assertions');
+const { fail } = require('../ops/common');
+
+/** The header a money call carries the assertion in. */
+const HEADER = 'x-ov-subject-assertion';
+
+function createSubjectAssertion({ config, log = console, registry = null }) {
+ const audience = config.audience || 'openvibe.billing';
+ const base = String((config.network && (config.network.internalUrl || config.network.url)) || '').replace(/\/+$/, '');
+ const jwksUrl = `${base}/api/.well-known/jwks`;
+ const verifier = createSubjectAssertionVerifier({ jwks: jwksUrl, audience, log });
+ // What the routes did while the flag was off (and what a dashboard would show).
+ const counters = { asserted: 0, unasserted: 0, invalid: 0, refused: 0 };
+ // The number the flag plan waits for: the per-person calls that still come without an assertion.
+ // It is a gauge, not a counter, so a restart reads zero again and the operator watches the shape.
+ if (registry && typeof registry.gauge === 'function') {
+ registry.gauge({
+ name: 'billing_subject_assertion_calls',
+ help: 'Per-person money calls by their subject assertion state (asserted, unasserted, invalid, refused)',
+ labelNames: ['state'],
+ collect: () => Object.entries(counters).map(([state, value]) => ({ labels: { state }, value })),
+ });
+ }
+
+ /** True when the old way is refused and an assertion is required. */
+ const required = () => Boolean(config.subjectAssertion && config.subjectAssertion.required);
+
+ /** The assertion on this request, verified. */
+ async function verify(req) {
+ const raw = String((req.headers && req.headers[HEADER]) || '').trim();
+ if (!raw) return { present: false, ok: false, code: null, reason: null };
+ const r = await verifier.verify(raw, { caller: req.principal && req.principal.sub ? req.principal.sub : undefined });
+ if (!r.ok) return { present: true, ok: false, code: r.code, reason: r.reason };
+ return { present: true, ok: true, claims: r.claims };
+ }
+
+ /**
+ * A money route that names a person: the assertion must name the same person.
+ *
+ * `subject` is the person the route acts for (the payer, the subscriber, the reader). Pass
+ * null when the route names nobody: there is nothing to check then.
+ */
+ async function person(req, subject) {
+ if (!subject) return null;
+ const v = await verify(req);
+ if (!v.present) {
+ counters.unasserted += 1;
+ if (required()) fail(403, 'subject.assertion_required', "this call must carry the person's own subject assertion (X-OV-Subject-Assertion)");
+ return null;
+ }
+ if (!v.ok) {
+ // An assertion minted for another service is never accepted here, flag or no flag: it is
+ // another service's proof, carried here.
+ if (v.code === 'assertion.actor_mismatch') { counters.refused += 1; fail(403, v.code, v.reason); }
+ counters.invalid += 1;
+ if (required()) fail(403, v.code, v.reason);
+ // While the flag is off a stale or broken assertion does not stop the money: the old
+ // way continues, and the count says how many calls still run without one.
+ (log && log.warn) ? log.warn(`[Assertions] a subject assertion was refused (${v.code}); the call continues while BILLING_REQUIRE_SUBJECT_ASSERTION is off`) : null;
+ return null;
+ }
+ counters.asserted += 1;
+ if (v.claims.sub !== subject) {
+ counters.refused += 1;
+ fail(403, 'subject.assertion_mismatch', `the assertion names ${v.claims.sub}, not ${subject}`);
+ }
+ return v.claims;
+ }
+
+ return { person, verify, counters, required, jwksUrl, audience };
+}
+
+module.exports = { createSubjectAssertion, HEADER };
@@ -49,7 +49,7 @@ const CAP = {
@@ -49,7 +49,7 @@ const CAP = {
vibesRead: 'billing.vibes.read',
vibesRead: 'billing.vibes.read',
};
};
-function v1Router({ ctx, auth, adapters, limits }) {
+function v1Router({ ctx, auth, adapters, limits, subjectAssertion }) {
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);
@@ -64,6 +64,9 @@ function v1Router({ ctx, auth, adapters, limits }) {
@@ -64,6 +64,9 @@ function v1Router({ ctx, auth, adapters, limits }) {
const read = (cap, handler, limit) => [auth.needs(cap), ...(limit ? [limit] : []), 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.
// 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 person = (field) => (req) => personOf(req.body && req.body[field]);
+ // The person a money route names must be the person the assertion names (plan H1: ADR-003, the
+ // 2026-11-06 amendment). Without the module (an old caller) nothing is checked.
+ const assertion = subjectAssertion || { person: async () => null };
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');
@@ -86,6 +89,7 @@ function v1Router({ ctx, auth, adapters, limits }) {
@@ -86,6 +89,7 @@ function v1Router({ ctx, auth, adapters, limits }) {
// ── Checkout intents ─────────────────────────────────────
// ── Checkout intents ─────────────────────────────────────
r.post('/intents', ...write(CAP.intent, async (req, res) => {
r.post('/intents', ...write(CAP.intent, async (req, res) => {
const b = req.body || {};
const b = req.body || {};
+ await assertion.person(req, b.subject ? userSubject(b.subject, 'subject') : null);
const adapter = adapters[String(b.provider || '').toLowerCase()];
const adapter = adapters[String(b.provider || '').toLowerCase()];
if (!adapter) fail(422, 'billing.invalid_input', `unknown provider ${b.provider}`);
if (!adapter) fail(422, 'billing.invalid_input', `unknown provider ${b.provider}`);
if (!adapter.enabled) fail(409, 'billing.provider_disabled', `${adapter.name} is not enabled on Billing`);
if (!adapter.enabled) fail(409, 'billing.provider_disabled', `${adapter.name} is not enabled on Billing`);
@@ -123,6 +127,8 @@ function v1Router({ ctx, auth, adapters, limits }) {
@@ -123,6 +127,8 @@ function v1Router({ ctx, auth, adapters, limits }) {
}));
}));
// ── Purchases: settle a provider receipt (operator/reconciler grade) ──
// ── Purchases: settle a provider receipt (operator/reconciler grade) ──
+ // The operator settles a provider receipt: a staff action about a payment, not a service acting
+ // for a person, so it carries no subject assertion.
r.post('/purchases/settle', ...write(CAP.admin, async (req, res) => {
r.post('/purchases/settle', ...write(CAP.admin, async (req, res) => {
const b = req.body || {};
const b = req.body || {};
if (!b.provider || !b.provider_ref) fail(422, 'billing.invalid_input', 'provider and provider_ref (the provider payment id) are required');
if (!b.provider || !b.provider_ref) fail(422, 'billing.invalid_input', 'provider and provider_ref (the provider payment id) are required');
@@ -137,6 +143,7 @@ function v1Router({ ctx, auth, adapters, limits }) {
@@ -137,6 +143,7 @@ function v1Router({ ctx, auth, adapters, limits }) {
// ── Transfers (tips / donations / paid interactions) ─────
// ── Transfers (tips / donations / paid interactions) ─────
r.post('/transfers', ...write(CAP.transfer, async (req, res) => {
r.post('/transfers', ...write(CAP.transfer, async (req, res) => {
const b = req.body || {};
const b = req.body || {};
+ await assertion.person(req, b.from ? userSubject(b.from, 'from') : null);
const out = await transfers.create(ctx, {
const out = await transfers.create(ctx, {
from: userSubject(b.from, 'from'), to: userSubject(b.to, 'to'), amount: b.amount, kind: b.kind, target: b.target,
from: userSubject(b.from, 'from'), to: userSubject(b.to, 'to'), amount: b.amount, kind: b.kind, target: b.target,
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,
@@ -153,6 +160,7 @@ function v1Router({ ctx, auth, adapters, limits }) {
@@ -153,6 +160,7 @@ function v1Router({ ctx, auth, adapters, limits }) {
r.post('/recycle', ...write(CAP.cashoutRequest, async (req, res) => {
r.post('/recycle', ...write(CAP.cashoutRequest, async (req, res) => {
const b = req.body || {};
const b = req.body || {};
const subject = userSubject(b.subject);
const subject = userSubject(b.subject);
+ await assertion.person(req, subject);
const out = await cashouts.recycle(ctx, { subject, amount: b.amount, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });
const out = await cashouts.recycle(ctx, { subject, amount: b.amount, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });
res.status(201).json(txnOut(out.txn, { balance: await balanceOf(subject) }));
res.status(201).json(txnOut(out.txn, { balance: await balanceOf(subject) }));
}, limits.budget('billing.recycle', person('subject'))));
}, limits.budget('billing.recycle', person('subject'))));
@@ -160,6 +168,7 @@ function v1Router({ ctx, auth, adapters, limits }) {
@@ -160,6 +168,7 @@ function v1Router({ ctx, auth, adapters, limits }) {
// ── Cashouts ─────────────────────────────────────────────
// ── Cashouts ─────────────────────────────────────────────
r.post('/cashouts', ...write(CAP.cashoutRequest, async (req, res) => {
r.post('/cashouts', ...write(CAP.cashoutRequest, async (req, res) => {
const b = req.body || {};
const b = req.body || {};
+ await assertion.person(req, b.subject ? userSubject(b.subject, 'subject') : null);
const out = await cashouts.request(ctx, {
const out = await cashouts.request(ctx, {
subject: userSubject(b.subject), amount: b.amount, payout_method: b.payout_method,
subject: userSubject(b.subject), amount: b.amount, payout_method: b.payout_method,
projectId: b.project_id, idempotencyKey: req.idempotencyKey, actor: actorOf(req),
projectId: b.project_id, idempotencyKey: req.idempotencyKey, actor: actorOf(req),
@@ -167,12 +176,14 @@ function v1Router({ ctx, auth, adapters, limits }) {
@@ -167,12 +176,14 @@ function v1Router({ ctx, auth, adapters, limits }) {
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'))));
}, limits.budget('billing.cashout.request', person('subject'))));
r.get('/cashouts', ...read(CAP.cashoutManage, async (req, res) => {
r.get('/cashouts', ...read(CAP.cashoutManage, async (req, res) => {
+ await assertion.person(req, req.query.subject ? pathSubject(req.query.subject) : null);
const list = await cashouts.list(db, { status: req.query.status, subject: req.query.subject, limit: Number(req.query.limit) || 100 });
const list = await 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) });
}));
}));
r.get('/cashouts/:id', ...read([CAP.cashoutManage, CAP.cashoutRequest], async (req, res) => {
r.get('/cashouts/:id', ...read([CAP.cashoutManage, CAP.cashoutRequest], async (req, res) => {
const c = await cashouts.find(db, req.params.id);
const c = await cashouts.find(db, req.params.id);
if (!c) fail(404, 'billing.cashout_not_found', `no cashout ${req.params.id}`);
if (!c) fail(404, 'billing.cashout_not_found', `no cashout ${req.params.id}`);
+ await assertion.person(req, c.subject);
res.json({ cashout: cashouts.present(c) });
res.json({ cashout: cashouts.present(c) });
}));
}));
r.post('/cashouts/:id/approve', ...write(CAP.cashoutManage, async (req, res) => {
r.post('/cashouts/:id/approve', ...write(CAP.cashoutManage, async (req, res) => {
@@ -200,8 +211,10 @@ function v1Router({ ctx, auth, adapters, limits }) {
@@ -200,8 +211,10 @@ function v1Router({ ctx, auth, adapters, limits }) {
const provider = String(rc.provider).toLowerCase();
const provider = String(rc.provider).toLowerCase();
receipt = { provider, receiptRef: `${provider}:${rc.provider_ref}`, paidCents: rc.amount_cents, feeCents: rc.fee_cents, route: rc.route, providerRef: rc.subscription_ref || null, test: !!rc.test };
receipt = { provider, receiptRef: `${provider}:${rc.provider_ref}`, paidCents: rc.amount_cents, feeCents: rc.fee_cents, route: rc.route, providerRef: rc.subscription_ref || null, test: !!rc.test };
}
}
+ const subscriber = userSubject(b.subscriber, 'subscriber');
+ await assertion.person(req, subscriber);
const out = await subscriptions.pay(ctx, {
const out = await subscriptions.pay(ctx, {
- subscriber: userSubject(b.subscriber, 'subscriber'), streamer: userSubject(b.streamer, 'streamer'), source,
+ subscriber, streamer: userSubject(b.streamer, 'streamer'), source,
// A tier is priced by Billing (ops/tiers.js); an explicit price_cents only applies without one.
// A tier is priced by Billing (ops/tiers.js); an explicit price_cents only applies without one.
tier: b.tier, priceCents: b.tier != null ? undefined : b.price_cents,
tier: b.tier, priceCents: b.tier != null ? undefined : b.price_cents,
autoRenew: b.auto_renew, receipt, idempotencyKey: req.idempotencyKey, actor: actorOf(req),
autoRenew: b.auto_renew, receipt, idempotencyKey: req.idempotencyKey, actor: actorOf(req),
@@ -224,6 +237,7 @@ function v1Router({ ctx, auth, adapters, limits }) {
@@ -224,6 +237,7 @@ function v1Router({ ctx, auth, adapters, limits }) {
res.json({ tier });
res.json({ tier });
}));
}));
r.get('/subscriptions', ...read([CAP.entitlement, CAP.subscription], async (req, res) => {
r.get('/subscriptions', ...read([CAP.entitlement, CAP.subscription], async (req, res) => {
+ await assertion.person(req, req.query.subscriber ? pathSubject(req.query.subscriber) : null);
const list = await subscriptions.list(db, {
const list = await subscriptions.list(db, {
subscriber: req.query.subscriber ? pathSubject(req.query.subscriber) : null,
subscriber: req.query.subscriber ? pathSubject(req.query.subscriber) : null,
streamer: req.query.streamer ? pathSubject(req.query.streamer) : null,
streamer: req.query.streamer ? pathSubject(req.query.streamer) : null,
@@ -234,9 +248,12 @@ function v1Router({ ctx, auth, adapters, limits }) {
@@ -234,9 +248,12 @@ function v1Router({ ctx, auth, adapters, limits }) {
r.get('/subscriptions/:id', ...read([CAP.entitlement, CAP.subscription], async (req, res) => {
r.get('/subscriptions/:id', ...read([CAP.entitlement, CAP.subscription], async (req, res) => {
const s = await subscriptions.find(db, req.params.id);
const s = await subscriptions.find(db, req.params.id);
if (!s) fail(404, 'billing.subscription_not_found', `no subscription ${req.params.id}`);
if (!s) fail(404, 'billing.subscription_not_found', `no subscription ${req.params.id}`);
+ await assertion.person(req, s.subscriber);
res.json({ subscription: subscriptions.present(s), entitlement: await subscriptions.entitlement(db, s.subscriber, s.streamer, ctx.now()) });
res.json({ subscription: subscriptions.present(s), entitlement: await subscriptions.entitlement(db, s.subscriber, s.streamer, ctx.now()) });
}));
}));
r.post('/subscriptions/:id/cancel', ...write(CAP.subscription, async (req, res) => {
r.post('/subscriptions/:id/cancel', ...write(CAP.subscription, async (req, res) => {
+ const s = await subscriptions.find(db, req.params.id);
+ if (s) await assertion.person(req, s.subscriber);
const out = await subscriptions.cancel(ctx, { id: req.params.id, actor: actorOf(req) }, adapters);
const out = await subscriptions.cancel(ctx, { id: req.params.id, actor: actorOf(req) }, adapters);
res.json({ subscription: subscriptions.present(out.subscription), provider_sync: out.provider_sync });
res.json({ subscription: subscriptions.present(out.subscription), provider_sync: out.provider_sync });
}));
}));
@@ -253,6 +270,7 @@ function v1Router({ ctx, auth, adapters, limits }) {
@@ -253,6 +270,7 @@ function v1Router({ ctx, auth, adapters, limits }) {
}));
}));
r.get('/entitlements/:subject', ...read(CAP.entitlement, async (req, res) => {
r.get('/entitlements/:subject', ...read(CAP.entitlement, async (req, res) => {
const subject = pathSubject(req.params.subject);
const subject = pathSubject(req.params.subject);
+ await assertion.person(req, subject);
if (req.query.streamer) return res.json(await subscriptions.entitlement(db, subject, pathSubject(req.query.streamer), ctx.now()));
if (req.query.streamer) return res.json(await subscriptions.entitlement(db, subject, pathSubject(req.query.streamer), ctx.now()));
return res.json({ subject: { type: 'user', id: subject }, entitlements: await subscriptions.activeEntitlements(db, subject, ctx.now()) });
return res.json({ subject: { type: 'user', id: subject }, entitlements: await subscriptions.activeEntitlements(db, subject, ctx.now()) });
}));
}));
@@ -268,11 +286,16 @@ function v1Router({ ctx, auth, adapters, limits }) {
@@ -268,11 +286,16 @@ function v1Router({ ctx, auth, adapters, limits }) {
payable_value_cents: ctx.rates.valueCents(payable), bits_per_usd: ctx.rates.bitsPerUsd,
payable_value_cents: ctx.rates.valueCents(payable), bits_per_usd: ctx.rates.bitsPerUsd,
};
};
}
}
- r.get('/balances/:subject', ...read(CAP.balance, async (req, res) => res.json(await balanceOf(pathSubject(req.params.subject)))));
+ r.get('/balances/:subject', ...read(CAP.balance, async (req, res) => {
+ const subject = pathSubject(req.params.subject);
+ await assertion.person(req, subject);
+ return res.json(await balanceOf(subject));
+ }));
r.get('/transactions', ...read(CAP.balance, async (req, res) => {
r.get('/transactions', ...read(CAP.balance, async (req, res) => {
const subject = req.query.subject ? pathSubject(req.query.subject) : null;
const subject = req.query.subject ? pathSubject(req.query.subject) : null;
if (!subject) fail(422, 'billing.invalid_input', 'subject is required');
if (!subject) fail(422, 'billing.invalid_input', 'subject is required');
+ await assertion.person(req, subject);
const limit = Math.min(200, positiveInt(req.query.limit || 50, 'limit'));
const limit = Math.min(200, positiveInt(req.query.limit || 50, 'limit'));
let cur = null;
let cur = null;
if (req.query.cursor) {
if (req.query.cursor) {
@@ -289,6 +312,7 @@ function v1Router({ ctx, auth, adapters, limits }) {
@@ -289,6 +312,7 @@ function v1Router({ ctx, auth, adapters, limits }) {
r.get('/transactions/:id', ...read(CAP.balance, async (req, res) => {
r.get('/transactions/:id', ...read(CAP.balance, async (req, res) => {
const t = await getTxn(db, req.params.id);
const t = await 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}`);
+ await assertion.person(req, t.from_subject);
const reversals = (await db.prepare('SELECT id FROM transactions WHERE reverses_txn = ? ORDER BY created_at').all(t.id)).map((x) => x.id);
const reversals = (await db.prepare('SELECT id FROM transactions WHERE reverses_txn = ? ORDER BY created_at').all(t.id)).map((x) => x.id);
res.json({ transaction: presentTxn(t), reversed_by: reversals });
res.json({ transaction: presentTxn(t), reversed_by: reversals });
}));
}));
@@ -297,6 +321,12 @@ function v1Router({ ctx, auth, adapters, limits }) {
@@ -297,6 +321,12 @@ function v1Router({ ctx, auth, adapters, limits }) {
// Stored and reported; never charged here (the rating sweep, ops/rating.js, charges later). Not a money movement, so accepted while frozen; idempotent by the
// Stored and reported; never charged here (the rating sweep, ops/rating.js, charges later). Not a money movement, so accepted while frozen; idempotent by the
// reading's own idempotency_key (no Idempotency-Key header): a replay is 200 with the stored row, a new one 201.
// reading's own idempotency_key (no Idempotency-Key header): a replay is 200 with the stored row, a new one 201.
r.post('/usage', auth.needs(CAP.usage), wrap(async (req, res) => {
r.post('/usage', auth.needs(CAP.usage), wrap(async (req, res) => {
+ const b = req.body || {};
+ // A reading names its subject in its own way (platform.usage-sample@1 allows any subject string, and a
+ // project's own readings may use a project-local id). Only a canonical person subject (usr_…) can be
+ // checked against an assertion, so only that one is.
+ const readSubject = String(b.subject || '');
+ await assertion.person(req, /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(readSubject) ? readSubject : null);
const out = await usage.record(ctx, req.body, { principal: req.principal.sub });
const out = await usage.record(ctx, req.body, { principal: req.principal.sub });
if (out.replayed) res.setHeader('Idempotent-Replayed', 'true');
if (out.replayed) res.setHeader('Idempotent-Replayed', 'true');
res.status(out.replayed ? 200 : 201).json({ record: out.record });
res.status(out.replayed ? 200 : 201).json({ record: out.record });
@@ -400,6 +430,7 @@ function v1Router({ ctx, auth, adapters, limits }) {
@@ -400,6 +430,7 @@ function v1Router({ ctx, auth, adapters, limits }) {
const out = await admin.adjust(ctx, { from: b.from, to: b.to, amount: b.amount, reason: b.reason, relatesTo: b.relates_to, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });
const out = await admin.adjust(ctx, { from: b.from, to: b.to, amount: b.amount, reason: b.reason, relatesTo: b.relates_to, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });
res.status(201).json(txnOut(out.txn));
res.status(201).json(txnOut(out.txn));
}));
}));
+ // A staff grant of promo credit: an operator action, not a service acting for a person.
r.post('/admin/promo/grant', ...write(CAP.admin, async (req, res) => {
r.post('/admin/promo/grant', ...write(CAP.admin, async (req, res) => {
const b = req.body || {};
const b = req.body || {};
const out = await promo.grant(ctx, { subject: b.subject, service: b.service, bits: b.bits, period: b.period, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });
const out = await promo.grant(ctx, { subject: b.subject, service: b.service, bits: b.bits, period: b.period, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });
@@ -137,7 +137,11 @@ async function createApp(opts = {}) {
@@ -137,7 +137,11 @@ async function createApp(opts = {}) {
app.use('/internal/credits', express.json({ limit: '64kb' }), internalRouter({ ctx, auth }));
app.use('/internal/credits', express.json({ limit: '64kb' }), internalRouter({ ctx, auth }));
// Per-person limits on the money-creating routes (api/actor-limits.js); the webhooks above never are.
// 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, valkey });
const limits = createActorLimits({ config, db, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log, valkey });
- app.use('/api/v1', express.json({ limit: '64kb' }), v1Router({ ctx, auth, adapters, limits }));
+ // Subject assertions on the money routes (ADR-003, the 2026-11-06 amendment; plan H1).
+ // The registry carries the counters: the number of per-person calls that still come without an
+ // assertion is what the flag plan waits for.
+ const subjectAssertion = require('./api/subject-assertion').createSubjectAssertion({ config, log, registry: metrics.registry });
+ app.use('/api/v1', express.json({ limit: '64kb' }), v1Router({ ctx, auth, adapters, limits, subjectAssertion }));
// A signed-in person's own Vibes, for the shared navbar on every OpenVibe site (api/me.js; user tokens, CORS for owned sites).
// A signed-in person's own Vibes, for the shared navbar on every OpenVibe site (api/me.js; user tokens, CORS for owned sites).
app.use('/api/me', require('./api/me').meRouter({ ctx, config, keys, limits, adapters }));
app.use('/api/me', require('./api/me').meRouter({ ctx, config, keys, limits, adapters }));
@@ -85,6 +85,13 @@ function loadConfig(env = process.env) {
@@ -85,6 +85,13 @@ 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',
+ // Subject assertions (ADR-003, the 2026-11-06 amendment; plan H1): a money route that names
+ // a person carries the person's own Network-signed assertion (X-OV-Subject-Assertion).
+ // BILLING_REQUIRE_SUBJECT_ASSERTION=on refuses the old way (the service vouching for its
+ // person) and turns the check on for every per-person route.
+ subjectAssertion: {
+ required: String(env.BILLING_REQUIRE_SUBJECT_ASSERTION || 'off').toLowerCase() === 'on',
+ },
// Per-person limits on /api/v1's money-creating routes (server/api/actor-limits.js, roadmap WS-R
// 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.
// task 4). minute/hour: a person's history reads; the money routes set tighter numbers there.
// BILLING_LIMITS=off counts nobody (a rollback lever).
// BILLING_LIMITS=off counts nobody (a rollback lever).
@@ -11,12 +11,17 @@ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))
@@ -11,12 +11,17 @@ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))
const readme = fs.readFileSync(path.join(root, 'README.md'), 'utf8');
const readme = fs.readFileSync(path.join(root, 'README.md'), 'utf8');
let checked = 0;
let checked = 0;
+let branches = 0;
for (const spec of Object.values(pkg.dependencies || {})) {
for (const spec of Object.values(pkg.dependencies || {})) {
const m = /codeload\.github\.com\/OpenVibers\/(OpenVibe\.[A-Za-z]+)\/tar\.gz\/refs\/tags\/v(\d+\.\d+\.\d+)$/.exec(String(spec));
const m = /codeload\.github\.com\/OpenVibers\/(OpenVibe\.[A-Za-z]+)\/tar\.gz\/refs\/tags\/v(\d+\.\d+\.\d+)$/.exec(String(spec));
- if (!m) continue;
+ if (!m) {
+ // A branch pin (a library whose release is not cut yet): no version to print in the README.
+ if (/codeload\.github\.com\/OpenVibers\/OpenVibe\.[A-Za-z]+\/tar\.gz\/refs\/heads\//.test(String(spec))) branches++;
+ continue;
+ }
const name = m[1].toLowerCase().replace(/^openvibe\./, 'openvibe-'); // OpenVibe.Shared -> openvibe-shared
const name = m[1].toLowerCase().replace(/^openvibe\./, 'openvibe-'); // OpenVibe.Shared -> openvibe-shared
assert.ok(readme.includes('`' + name + '` v' + m[2]), `the README pins ${name} at v${m[2]}`);
assert.ok(readme.includes('`' + name + '` v' + m[2]), `the README pins ${name} at v${m[2]}`);
checked++;
checked++;
}
}
-assert.ok(checked >= 3, 'the three shared libraries are pinned by tag');
+assert.ok(checked + branches >= 3, 'the three shared libraries are pinned (by tag, or by branch until the tag is cut)');
console.log('dependency pins: the README matches every pinned library');
console.log('dependency pins: the README matches every pinned library');
@@ -25,6 +25,9 @@ async function startNetwork() {
@@ -25,6 +25,9 @@ async function startNetwork() {
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
+ // The subject-assertion key (ES256): Network publishes it beside the RS256 key, and the stub
+ // signs assertions with it so a test can play the caller that carries one.
+ const assertionKeys = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
const legacy = {}; // live user id → subject id
const legacy = {}; // live user id → subject id
const resolveCalls = [];
const resolveCalls = [];
const grants = [];
const grants = [];
@@ -35,6 +38,18 @@ async function startNetwork() {
@@ -35,6 +38,18 @@ async function startNetwork() {
let issuer = 'http://network.test';
let issuer = 'http://network.test';
const state = { down: false };
const state = { down: false };
+ const b64url = (b) => Buffer.from(b).toString('base64url');
+ /** A subject assertion, as Network signs it: the person, the audience, the service it was minted for. */
+ function signAssertion({ subject, audience = 'openvibe.billing', act = 'svc:live', expSec = 60, kid = 'ov-network-assertion-1', jti } = {}) {
+ const now = Math.floor(Date.now() / 1000);
+ const header = b64url(JSON.stringify({ alg: 'ES256', typ: 'JWT', kid }));
+ const body = b64url(JSON.stringify({
+ iss: issuer, sub: subject, aud: audience, act, jti: jti || `as_${crypto.randomBytes(8).toString('hex')}`,
+ iat: now, exp: now + expSec,
+ }));
+ const sig = crypto.sign('sha256', Buffer.from(`${header}.${body}`), { key: assertionKeys.privateKey, dsaEncoding: 'ieee-p1363' });
+ return `${header}.${body}.${b64url(sig)}`;
+ }
function signService({ sub = 'svc:live', aud = ['openvibe.billing'], cap = [], expSec = 300, iss } = {}) {
function signService({ sub = 'svc:live', aud = ['openvibe.billing'], cap = [], expSec = 300, iss } = {}) {
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);
@@ -97,7 +112,18 @@ async function startNetwork() {
@@ -97,7 +112,18 @@ async function startNetwork() {
const server = http.createServer(async (req, res) => {
const server = http.createServer(async (req, res) => {
const raw = await readBody(req);
const raw = await readBody(req);
- if (req.url === '/api/.well-known/jwks') return send(res, 200, { public_key: publicPem, algorithm: 'RS256' });
+ if (req.url === '/api/.well-known/jwks') {
+ // Both keys, as Network publishes them: the RS256 session key and the ES256 assertion key.
+ const rsa = publicKey.export({ format: 'jwk' });
+ const ec = assertionKeys.publicKey.export({ format: 'jwk' });
+ return send(res, 200, {
+ public_key: publicPem, algorithm: 'RS256',
+ keys: [
+ { ...rsa, use: 'sig', alg: 'RS256', kid: 'ov-network-1' },
+ { ...ec, use: 'sig', alg: 'ES256', kid: 'ov-network-assertion-1' },
+ ],
+ });
+ }
if (req.url === '/oauth/token' && req.method === 'POST') {
if (req.url === '/oauth/token' && req.method === 'POST') {
// openvibe-sdk/sso posts the grant as JSON; the real Network takes that (and this stub keeps the form shape too).
// openvibe-sdk/sso posts the grant as JSON; the real Network takes that (and this stub keeps the form shape too).
const text = String(raw || '');
const text = String(raw || '');
@@ -175,7 +201,7 @@ async function startNetwork() {
@@ -175,7 +201,7 @@ async function startNetwork() {
issuer = url;
issuer = url;
// A person's Network session token, as the browser holds it (the shared navbar sends it).
// A person's Network session token, as the browser holds it (the shared navbar sends it).
const signUser = (u) => userToken({ role: 'user', username: 'someone', nowMs: Date.now(), ...u });
const signUser = (u) => userToken({ role: 'user', username: 'someone', nowMs: Date.now(), ...u });
- return { url, publicPem, signService, signApp, signUser, addUser, addMembership, addProject, addApp, memberships, projects, apps, publicNames, authorize, revoked, legacy, resolveCalls, grants, state, close: () => new Promise((r) => server.close(r)) };
+ return { url, publicPem, signService, signApp, signUser, signAssertion, addUser, addMembership, addProject, addApp, memberships, projects, apps, publicNames, authorize, revoked, legacy, resolveCalls, grants, state, close: () => new Promise((r) => server.close(r)) };
}
}
async function startStripe() {
async function startStripe() {
new file mode 100644
new file mode 100644
@@ -0,0 +1,116 @@
@@ -0,0 +1,116 @@
+'use strict';
+/**
+ * Subject assertions on the money routes (server/api/subject-assertion.js; ADR-003, the 2026-11-06
+ * amendment; plan H1).
+ *
+ * A money route that names a person checks the person's own Network-signed assertion
+ * (X-OV-Subject-Assertion): the signature, the audience, the service it was minted for and the person
+ * it names. A forged, expired, replayed or wrong-audience assertion is refused; one that names another
+ * person never moves money.
+ */
+const assert = require('assert');
+const { boot, fund, check, done } = require('./helpers/app');
+
+(async () => {
+ console.log('subject assertions');
+ // The flag off: the old way still works, and an assertion that comes is checked.
+ const t = await boot();
+ const fan = t.user(41);
+ const creator = t.user(42);
+ const other = t.user(43);
+ await fund(t, fan, 10000);
+ const tip = (o = {}) => t.call('POST', '/api/v1/transfers', { cap: ['billing.transfer.create'], body: { from: fan.id, to: creator.id, amount: 10, kind: 'donation' }, ...o });
+ const assertion = (o = {}) => t.network.signAssertion({ subject: fan.id, act: 'svc:live', ...o });
+
+ await check('without the flag, a money route that names a person still works the old way', async () => {
+ const r = await tip();
+ assert.strictEqual(r.status, 201, r.text);
+ });
+
+ await check('without the flag, an assertion that names the person is accepted', async () => {
+ const r = await tip({ headers: { 'x-ov-subject-assertion': assertion() } });
+ assert.strictEqual(r.status, 201, r.text);
+ });
+
+ await check('an assertion that names another person is refused, and no money moves', async () => {
+ const before = (await t.balances(fan.id)).credit;
+ const r = await tip({ headers: { 'x-ov-subject-assertion': assertion({ subject: other.id }) } });
+ assert.strictEqual(r.status, 403, r.text);
+ assert.strictEqual(r.json.code, 'subject.assertion_mismatch');
+ assert.strictEqual((await t.balances(fan.id)).credit, before, 'the money did not move');
+ });
+
+ await check('an assertion minted for another service is refused', async () => {
+ const r = await tip({ headers: { 'x-ov-subject-assertion': assertion({ act: 'svc:vip' }) } });
+ assert.strictEqual(r.status, 403, r.text);
+ assert.strictEqual(r.json.code, 'assertion.actor_mismatch');
+ });
+
+ await check('without the flag, a forged assertion does not stop the money (the old way continues)', async () => {
+ const r = await tip({ headers: { 'x-ov-subject-assertion': 'not.a.jwt' } });
+ assert.strictEqual(r.status, 201, r.text);
+ });
+
+ // The flag on: the old way is refused.
+ const t2 = await boot({ env: { BILLING_REQUIRE_SUBJECT_ASSERTION: 'on' } });
+ const payer = t2.user(44);
+ const payee = t2.user(45);
+ await fund(t2, payer, 10000);
+ const tip2 = (o = {}) => t2.call('POST', '/api/v1/transfers', { cap: ['billing.transfer.create'], body: { from: payer.id, to: payee.id, amount: 10, kind: 'donation' }, ...o });
+ const assertion2 = (o = {}) => t2.network.signAssertion({ subject: payer.id, act: 'svc:live', ...o });
+
+ await check('with the flag on, a money route without an assertion is refused', async () => {
+ const r = await tip2();
+ assert.strictEqual(r.status, 403, r.text);
+ assert.strictEqual(r.json.code, 'subject.assertion_required');
+ });
+
+ await check('with the flag on, the person\'s own assertion lets the money through', async () => {
+ const r = await tip2({ headers: { 'x-ov-subject-assertion': assertion2() } });
+ assert.strictEqual(r.status, 201, r.text);
+ });
+
+ await check('with the flag on, a forged assertion is refused', async () => {
+ const r = await tip2({ headers: { 'x-ov-subject-assertion': 'not.a.jwt' } });
+ assert.strictEqual(r.status, 403, r.text);
+ assert.strictEqual(r.json.code, 'assertion.malformed');
+ });
+
+ await check('with the flag on, an expired assertion is refused', async () => {
+ const r = await tip2({ headers: { 'x-ov-subject-assertion': assertion2({ expSec: -30 }) } });
+ assert.strictEqual(r.status, 403, r.text);
+ assert.strictEqual(r.json.code, 'assertion.expired');
+ });
+
+ await check('with the flag on, an assertion for another audience is refused', async () => {
+ const r = await tip2({ headers: { 'x-ov-subject-assertion': assertion2({ audience: 'openvibe.tips' }) } });
+ assert.strictEqual(r.status, 403, r.text);
+ assert.strictEqual(r.json.code, 'assertion.wrong_audience');
+ });
+
+ await check('with the flag on, a replayed assertion is refused the second time', async () => {
+ const a = assertion2();
+ const first = await tip2({ headers: { 'x-ov-subject-assertion': a } });
+ assert.strictEqual(first.status, 201, first.text);
+ const second = await tip2({ headers: { 'x-ov-subject-assertion': a } });
+ assert.strictEqual(second.status, 403, second.text);
+ assert.strictEqual(second.json.code, 'assertion.replayed');
+ });
+
+ await check('the counters the flag plan waits for are on /metrics', async () => {
+ const m = await fetch(`${t.base}/metrics`).then((r) => r.text());
+ assert.ok(/billing_subject_assertion_calls\{state="asserted"\}/.test(m), 'the calls that carried an assertion are counted');
+ assert.ok(/billing_subject_assertion_calls\{state="unasserted"\}/.test(m), 'the calls that came without one are counted');
+ assert.ok(!/usr_[0-9A-Z]{26}/.test(m), 'no subject ever appears in metrics');
+ });
+
+ await check('a balance read for a person is a per-person route too', async () => {
+ const r = await t2.call('GET', `/api/v1/balances/${payer.id}`, { cap: ['billing.balance.read'] });
+ assert.strictEqual(r.status, 403, r.text);
+ assert.strictEqual(r.json.code, 'subject.assertion_required');
+ const ok = await t2.call('GET', `/api/v1/balances/${payer.id}`, { cap: ['billing.balance.read'], headers: { 'x-ov-subject-assertion': assertion2() } });
+ assert.strictEqual(ok.status, 200, ok.text);
+ });
+
+ await done();
+})().catch((err) => { console.error(err); process.exit(1); });