openvibers/openvibe.billing Commits

The money routes check the person's own subject assertion (#200)

OP OpenVibers committed 7 days ago · 3079f0c4fa · 11 files, +310 −24

* Subject assertions on the money routes (ADR-003, 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.

BILLING_REQUIRE_SUBJECT_ASSERTION (off) turns the check on for every
per-person route and refuses the old way.

* Subject assertions on the money routes (ADR-003, 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. An assertion that names another
person never moves money; one minted for another service is never accepted.

BILLING_REQUIRE_SUBJECT_ASSERTION (off) turns the check on for every
per-person route and refuses the old way.

The SDK is pinned to its branch until the release is tagged.

* A usage reading names its subject in its own way: only a canonical person subject is checked

* Pin the SDK branch again (the commerce client grew the assertion header)

* The money routes say on /metrics how many calls came without an assertion

* Merge main into the branch (the shared and SDK pins)

* Pin openvibe-sdk v0.90.0 and openvibe-contracts v0.297.0

Changes

ddiff --git a/README.md b/README.md
iindex 69a8c3e..881e1b8 100644
---- a/README.md
++++ b/README.md
@@@ -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 -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 877f02d..fc0d39a 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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": [
ddiff --git a/package-lock.json b/package-lock.json
iindex 93cb3cd..cd157bf 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex f4ccf68..ff6bd72 100644
---- a/package.json
++++ b/package.json
@@@ -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"
},
ddiff --git a/server/api/subject-assertion.js b/server/api/subject-assertion.js
nnew file mode 100644
iindex 0000000..b9d11db
---- /dev/null
++++ b/server/api/subject-assertion.js
@@@ -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 };
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex f5e099d..e0846b8 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -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) });
ddiff --git a/server/app.js b/server/app.js
iindex 065564a..d85b436 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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 }));
ddiff --git a/server/config.js b/server/config.js
iindex 967b983..cad5385 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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).
ddiff --git a/test/dependency-pins.test.js b/test/dependency-pins.test.js
iindex c3b86f5..d98ce18 100644
---- a/test/dependency-pins.test.js
++++ b/test/dependency-pins.test.js
@@@ -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-shared
assert.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');
ddiff --git a/test/helpers/stubs.js b/test/helpers/stubs.js
iindex ce30f93..def4533 100644
---- a/test/helpers/stubs.js
++++ b/test/helpers/stubs.js
@@@ -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 id
const 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() {
ddiff --git a/test/subject-assertion.test.js b/test/subject-assertion.test.js
nnew file mode 100644
iindex 0000000..a68b15b
---- /dev/null
++++ b/test/subject-assertion.test.js
@@@ -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); });