openvibers/openvibe.tips Commits
The tip carries the person's own subject assertion on to Billing (#195)
Changes
@@@ -505,7 +505,7 @@ A placeholder is never counted as an implemented service.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.82.0++- openvibe-contracts: v0.297.0++- openvibe-sdk: v0.90.0- openvibe-shared: v4.115.1<!-- versions:end -->@@@ -9,8 +9,8 @@"deployed": true,"deployment": "Launched 2026-10-17 at https://openvibe.tips (openvibe-contracts 0.200.0 made Tips live; vhost deploy/nginx/openvibe.tips.conf installed): deployed with `sudo ovhost deploy tips`, /opt/openvibe.tips, unit openvibe-tips, 127.0.0.1:4610 behind nginx. Principal tips; Events subscriptions billing.transaction.* and billing.receipt.* -> /internal/events; TIPS_CHAT_ADAPTER=chat (chat lines and alerts through OpenVibe.Chat). OpenVibe.Live subscribes to tips.interaction.ready for its channel goal and tip history. TIPS_POWERCHAT_LINK_TEMPLATE turns Billing's pcorder: ref into PowerChat's canonical tip link on the site account (since 2026-10-17).","database": "PostgreSQL 18 through PgBouncer (transaction mode) as the system of record, Valkey 9 for shared state (per-actor limits, overlay fan-out, overlay stream slots), ADR-035: database ov_tips from OpenVibe.Host's data role, schema in migrations/ (0001_initial expand, 0002_drop_legacy_import contract), async on openvibe-sdk/db v0.28.0, money paths serializable with row locks, PostgreSQL outbox and inbox.",-- "contracts": "openvibe-contracts v0.289.1",-- "sdk": "openvibe-sdk v0.82.0",++ "contracts": "openvibe-contracts v0.297.0",++ "sdk": "openvibe-sdk v0.90.0","shared": "openvibe-shared v4.115.1","notes": "Runtime, API, SSR pages, overlays, the Billing settlement consumer and the events outbox are built, tested and deployed, together with /metrics (loopback only), truthful /api/ready (Billing and Events checks), supporter privacy (anonymous, hidden amount, private message), goal and supporter-page settings, export and erasure (tips.interaction.erased), moderation (word filter, hide/show, invited moderators, tips.interaction.moderated) and per-actor limits. Capabilities (18 tips.*) and payload schemas are in openvibe-contracts. Billing is the money ledger since the cutover (2026-10-17). A written internal threat review (docs/threat-review.md) with its fixes and test/security.test.js. Paid sounds (creators upload up to 12, supporters pay to play one on the overlay) since 2026-10-17. Not done: an independent security review.","features": [@@@ -13,8 +13,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.82.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"},@@@ -1118,9 +1118,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",@@@ -1134,9 +1134,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.82.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",-- "integrity": "sha512-oJarlzFGEkqYY0eFumIRjJUqMpOT70kgbhgeDtA8QHnuMZR3VIGQubM2UlOaqXgkxD5uhdZ/G6jLHbjvUhqTLQ==",++ "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"@@@ -25,8 +25,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.82.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,107 @@++'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 tip in anyone's name or read anyone's receipts.++ *++ * 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`; Tips checks the++ * signature against Network's JWKS and that the person it names is the person the route acts for.++ *++ * The switch is `TIPS_REQUIRE_SUBJECT_ASSERTION`:++ *++ * off (today) a 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 route that names a person must carry an assertion, and it must verify. The old++ * way is then refused (403 `subject.assertion_required`).++ *++ * A person calling with their own Network token is not a delegated call and needs no assertion:++ * they act for themselves.++ */++const { createSubjectAssertionVerifier, createSubjectAssertionMinter } = require('openvibe-sdk/assertions');++const { fail } = require('../util');++++/** 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.tips';++ 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 });++ // The other side: Tips asks Network to sign the person's own token into an assertion it may++ // carry on to Billing. A tip moves the person's Vibes, so Billing wants the person's own++ // answer, not Tips' word. Without the person's token (a service that vouches for them) there++ // is nothing to sign and no assertion is minted.++ const minter = createSubjectAssertionMinter({++ internalUrl: config.network && config.network.internalUrl,++ oauth: config.oauth,++ 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.++ if (registry && typeof registry.gauge === 'function') {++ registry.gauge({++ name: 'tips_subject_assertion_calls',++ help: 'Per-person 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 principal = req.principal || {};++ const r = await verifier.verify(raw, { caller: principal.kind === 'service' ? 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 route that names a person: the assertion must name the same person.++ *++ * `subject` is the person the route acts for (the supporter, 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 tip: 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 TIPS_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, mint: minter.mint };++}++++module.exports = { createSubjectAssertion, HEADER };@@@ -75,7 +75,7 @@ const CAP = {postModerate: 'tips.post.moderate',};--function v1Router({ domain, apiAuth, limits }) {++function v1Router({ domain, apiAuth, limits, subjectAssertion }) {const r = express.Router();const { db, profiles, goals, interactions, overlays } = domain;const idem = idempotent(db, domain.now, domain.log);@@@ -106,6 +106,9 @@ function v1Router({ domain, apiAuth, limits }) {return profile;}const write = (handler) => [idem, wrap(handler)];++ // The person a 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 };// ── Profiles ─────────────────────────────────────────────r.get('/profiles/:creator', wrap(async (req, res) => {@@@ -191,6 +194,8 @@ function v1Router({ domain, apiAuth, limits }) {allow(req, cap);supporter = userSubject(b.supporter, 'supporter');supporterName = displayName(b.supporter_name) || null;++ // The service pays for this person: the person's own assertion says they allowed it.++ await assertion.person(req, supporter);} else {supporter = p.subject;supporterName = displayName(b.supporter_name) || p.name || p.username;@@@ -198,18 +203,28 @@ function v1Router({ domain, apiAuth, limits }) {const profile = await creatorOf(req, b.creator);const funding = b.pay_with || 'credit';const tp = req.ov && req.ov.traceparent;++ // The person's own subject assertion (plan H1): it travels on to Billing, which checks it++ // against the person the money is for. A service that vouches for the person brings its++ // own assertion; a person tipping for themselves has their token, so Tips mints one for++ // Billing (Tips is the service that carries it).++ let carried = String(req.headers['x-ov-subject-assertion'] || '').trim() || null;++ if (!carried && p.kind === 'user' && typeof assertion.mint === 'function') {++ const auth = String(req.headers.authorization || '');++ const userToken = /^Bearer\s+/i.test(auth) ? auth.slice(7).trim() : null;++ carried = await assertion.mint({ userToken, subject: supporter, audience: 'openvibe.billing', for: ['svc:tips'] });++ }const { interaction, replay } = await interactions.request(profile, { ...b, kind }, { supporter, supporterName, funding, idempotencyKey: req.idempotencyKey });let i = interaction;const viewer = p.kind === 'service' ? 'service' : 'supporter';if (!replay) {if (funding === 'credit') {-- const out = await interactions.transfer(i, { traceparent: tp });++ const out = await interactions.transfer(i, { traceparent: tp, assertion: carried });i = await interactions.get(db, i.id);if (out.refused) {fail(out.refused === 'billing.insufficient_funds' ? 409 : 422, out.refused, out.detail || 'Billing refused the tip', { interaction: await interactions.present(db, i, { viewer: 'supporter' }) });}} else {-- i = (await interactions.startCheckout(i, { provider: b.provider, traceparent: tp })).interaction;++ i = (await interactions.startCheckout(i, { provider: b.provider, traceparent: tp, assertion: carried })).interaction;}} else if (i.payment_state === 'failed') {fail(409, 'tips.payment_failed', i.failure || 'the payment for this request failed', { interaction: await interactions.present(db, i, { viewer: 'supporter' }) });@@@ -245,6 +260,7 @@ function v1Router({ domain, apiAuth, limits }) {creator: q.creator ? (await creatorOf(req, q.creator)).creator_subject : null,supporter: q.supporter ? userSubject(String(q.supporter), 'supporter') : null,};++ if (filter.supporter) await assertion.person(req, filter.supporter);if (!filter.creator && !filter.supporter) fail(422, 'tips.invalid_input', 'creator or supporter is required');} else if (p.kind === 'user') {filter = q.as === 'creator' ? { creator: p.subject } : { supporter: p.subject };@@@ -145,7 +145,11 @@ function createApp(opts = {}) {// Per-actor limits on /api/v1 (api/actor-limits.js), counted once apiAuth resolved the caller.// opts.limitsNow: the limiter's clock (tests).const limits = createActorLimits({ config, valkey, now: opts.limitsNow || now, registry: metrics.registry, log });-- app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, 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' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits, subjectAssertion }));app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl, log }));{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'tips', service: 'tips', host: 'openvibe.tips', name: 'OpenVibe.Tips', profile: 'ugc' })); }@@@ -18,6 +18,13 @@*/const { createCommerceClient, CommerceError: BillingCallError } = require('openvibe-sdk/commerce');++/**++ * The person's own subject assertion (ADR-003, the 2026-11-06 amendment; plan H1), when the call++ * carries one: Billing checks it against the person the money is for. The assertion travels with the++ * tip: the site vouched for the person, Tips carries it on to Billing.++ */++const assertionHeader = (assertion) => (assertion ? { headers: { 'X-OV-Subject-Assertion': String(assertion) } } : {});++// One token for every call, as before: every logical capability maps to the same scope, so// commerce creates (or takes) a single token client.const SCOPE = 'billing.intent.create billing.transfer.create';@@@ -27,11 +34,11 @@ function createBillingClient(config, { fetchImpl = globalThis.fetch, tokenClientconst commerce = createCommerceClient(config, { caps: CAPS, fetchImpl, tokenClients: tokenClient ? { [SCOPE]: tokenClient } : null });return {/** { intent, checkout_url } — kind purchase: the supporter buys `bits` of credit. */-- createIntent: async ({ provider, subject, bits, successUrl, cancelUrl, key, traceparent }) =>-- await commerce.createIntent({ provider, kind: 'purchase', subject, bits, successUrl, cancelUrl, key, traceparent }),++ createIntent: async ({ provider, subject, bits, successUrl, cancelUrl, key, traceparent, assertion }) =>++ await commerce.createIntent({ provider, kind: 'purchase', subject, bits, successUrl, cancelUrl, key, traceparent, ...assertionHeader(assertion) }),/** { transaction } — credit of `from` → payable of `to`, tagged with the interaction. */-- createTransfer: async ({ from, to, amount, kind, target, message, key, traceparent }) =>-- await commerce.createTransfer({ from, to, amount, kind, target, message, key, traceparent }),++ createTransfer: async ({ from, to, amount, kind, target, message, key, traceparent, assertion }) =>++ await commerce.createTransfer({ from, to, amount, kind, target, message, key, traceparent, ...assertionHeader(assertion) }),refundTransfer: async ({ txnId, amount, reason, key }) => await commerce.refundTransfer({ txnId, amount, reason, key }),rates: async () => await commerce.rates(),};@@@ -53,6 +53,13 @@ function loadConfig(env = process.env) {audience: env.OV_NETWORK_AUDIENCE || 'openvibe.network',},audience: env.TIPS_AUDIENCE || 'openvibe.tips',++ // Subject assertions (ADR-003, the 2026-11-06 amendment; plan H1): a route that names a++ // person carries the person's own Network-signed assertion (X-OV-Subject-Assertion).++ // TIPS_REQUIRE_SUBJECT_ASSERTION=on refuses the old way (the service vouching for its++ // person) and turns the check on for every route that names a person.++ subjectAssertion: {++ required: String(env.TIPS_REQUIRE_SUBJECT_ASSERTION || 'off').toLowerCase() === 'on',++ },// Tips' own client credentials (client `tips` in the Network): service tokens for Billing,// Events, Chat (delivery) and the Network; and the OAuth code flow for browser sign-in.oauth: {@@@ -209,12 +209,12 @@ function createInteractions(ctx) {const transferKind = (i) => (i.kind === 'tip' ? 'tip' : 'paid_interaction');/** Try the Billing transfer now; retryable failures leave it due for the jobs loop. */-- async function transfer(i, { traceparent } = {}) {++ async function transfer(i, { traceparent, assertion } = {}) {try {const out = await ctx.billing.createTransfer({from: i.supporter_subject, to: i.creator_subject, amount: i.amount, kind: transferKind(i),target: { service: 'tips', type: 'interaction', id: i.id }, message: i.message || undefined,-- key: `tips:transfer:${i.id}`, traceparent,++ key: `tips:transfer:${i.id}`, traceparent, assertion,});const txn = out.transaction;await ctx.tx(async (t) => await settleByTransaction(t, await lock(t, i.id), { id: txn.id, test: !!txn.test }), MONEY);@@@ -234,7 +234,7 @@ function createInteractions(ctx) {}}-- async function startCheckout(i, { provider, traceparent }) {++ async function startCheckout(i, { provider, traceparent, assertion }) {const p = String(provider || config.billing.providers[0] || '').toLowerCase();if (!config.billing.providers.includes(p)) fail(422, 'tips.invalid_input', `provider must be one of ${config.billing.providers.join(', ') || '(none configured)'}`);let out;@@@ -242,7 +242,7 @@ function createInteractions(ctx) {out = await ctx.billing.createIntent({provider: p, subject: i.supporter_subject, bits: i.amount,successUrl: `${config.baseUrl}/receipts/${i.id}`, cancelUrl: `${config.baseUrl}/receipts/${i.id}?cancelled=1`,-- key: `tips:intent:${i.id}`, traceparent,++ key: `tips:intent:${i.id}`, traceparent, assertion,});} catch (e) {if (e instanceof BillingCallError && !e.retryable) {@@@ -27,9 +27,18 @@ function readBody(req) {const send = (res, status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };async function startNetwork() {++ // What a service holds for an audience when it asks for no scope (Network issues the token's++ // capabilities from the principal's grants, so the request's scope is only a narrowing).++ const DEFAULT_CAPS = {++ 'openvibe.network': ['identity.subject.resolve'],++ 'openvibe.chat': ['chat.message.send chat.event.publish'],++ };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 grants = [];// The sign-in codes Network issued (openvibe-sdk/sso exchanges one with the PKCE verifier): code → tokens.const codes = new Map();@@@ -38,6 +47,20 @@ async function startNetwork() {let issuer = 'http://network.test';let n = 100;++ const b64url = (b) => Buffer.from(b).toString('base64url');++ /** A subject assertion, as Network signs it: the person, the audience(s), the services that may carry it. */++ function signAssertion({ subject, audience = 'openvibe.tips', act = 'svc:live', for: forServices, expSec = 60, kid = 'ov-network-assertion-1', jti } = {}) {++ const now = Math.floor(Date.now() / 1000);++ const aud = Array.isArray(audience) ? audience : [audience];++ const header = b64url(JSON.stringify({ alg: 'ES256', typ: 'JWT', kid }));++ const body = b64url(JSON.stringify({++ iss: issuer, sub: subject, aud, act,++ ...(forServices ? { for: forServices } : {}),++ 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.tips'], cap = [], expSec = 300 } = {}) {const now = Math.floor(Date.now() / 1000);return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type: 'service', aud, cap, iat: now, exp: now + expSec, jti: crypto.randomBytes(8).toString('hex') }, privatePem);@@@ -55,11 +78,27 @@ 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') {// The Network takes the token grant as JSON (openvibe-sdk/sso) or as a form (the service clients).const body = raw.trim().startsWith('{') ? JSON.parse(raw) : Object.fromEntries(new URLSearchParams(raw));-- grants.push(body);++ // A token's capabilities come from the principal's grants for the audience, not from the++ // request: a client that asks for no scope gets the audience's default grants (Network's++ // own token endpoint does the same, so a scope-less client is not left with an empty token).++ const asked = String(body.scope || '').split(/\s+/).filter(Boolean);++ const cap = asked.length ? asked : (DEFAULT_CAPS[String(body.audience || '')] || []);++ grants.push({ ...body, scope: cap.join(' ') || body.scope });if (body.client_secret !== 'shh') return send(res, 401, { error: 'invalid_client' });// A sign-in's code: the Network answers invalid_grant for a code it never issued.if (body.grant_type === 'authorization_code') {@@@ -68,9 +107,26 @@ async function startNetwork() {codes.delete(String(body.code));return send(res, 200, { access_token: c.access_token, refresh_token: c.refresh_token || null, token_type: 'Bearer', expires_in: 300 });}-- const cap = String(body.scope || '').split(/\s+/).filter(Boolean);return send(res, 200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience], cap }), token_type: 'Bearer', expires_in: 300 });}++ if (req.url === '/internal/subject-assertions' && req.method === 'POST') {++ // The mint route (ADR-003, the 2026-11-06 amendment): a first-party service hands over the++ // person's own token and gets back a 60-second assertion naming the person.++ const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });++ if (!v.ok) return send(res, 401, { code: v.code });++ if (!/^svc:[a-z][a-z0-9-]{1,39}$/.test(String(v.claims.sub || ''))) return send(res, 403, { code: 'assertion.not_a_service' });++ const body = JSON.parse(raw || '{}');++ if (!body.user_token) return send(res, 401, { code: 'assertion.no_user_token' });++ let claims;++ try { claims = jwt.verify(String(body.user_token), publicPem, { issuer }); } catch { return send(res, 401, { code: 'assertion.invalid_user_token' }); }++ const subject = claims.subject_id;++ if (body.subject && body.subject !== subject) return send(res, 403, { code: 'assertion.subject_mismatch' });++ const audience = Array.isArray(body.audience) ? body.audience : [body.audience];++ const forServices = [...new Set([v.claims.sub, ...(Array.isArray(body.for) ? body.for : [])])];++ const jti = `as_${crypto.randomBytes(8).toString('hex')}`;++ const assertion = signAssertion({ subject, audience, act: v.claims.sub, for: forServices, jti });++ return send(res, 200, { assertion, subject, audience: body.audience, act: v.claims.sub, for: forServices, jti, expires_in: 60 });++ }if (req.url.startsWith('/internal/identity/resolve?') && req.method === 'GET') {const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });if (!v.ok) return send(res, 401, { code: v.code });@@@ -103,7 +159,7 @@ async function startNetwork() {codes.set(code, { access_token: access_token || signUser({ subject: 'usr_test', username: 'someone' }), refresh_token });return code;}-- return { url, publicPem, signService, signUser, newUser, legacy, grants, resolves, issueCode, close: () => new Promise((r) => server.close(r)) };++ return { url, publicPem, signService, signUser, signAssertion, newUser, legacy, grants, resolves, issueCode, close: () => new Promise((r) => server.close(r)) };}/** Billing as Tips sees it. */@@@ -158,7 +214,7 @@ async function startBilling(network) {const raw = await readBody(req);if (req.url === '/api/health') return send(res, state.down ? 503 : 200, state.down ? { code: 'billing.unavailable' } : { ok: true, service: 'billing' });const body = raw ? JSON.parse(raw) : {};-- calls.push({ method: req.method, url: req.url, body, key: req.headers['idempotency-key'] || null });++ calls.push({ method: req.method, url: req.url, body, key: req.headers['idempotency-key'] || null, assertion: req.headers['x-ov-subject-assertion'] || null });if (state.down) return send(res, 503, { code: 'billing.unavailable' });const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.billing' });if (!v.ok) return send(res, 401, { code: v.code });nnew file mode 100644@@@ -0,0 +1,153 @@++'use strict';++/**++ * Subject assertions on the money routes (server/api/subject-assertion.js; ADR-003, the 2026-11-06++ * amendment; plan H1).++ *++ * A service that tips for a person carries 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 tips in their name.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++++(async () => {++ console.log('subject assertions');++ const t = await boot();++ const alex = await t.creator('alex');++ const viewer = t.network.newUser('viewer');++ const other = t.network.newUser('other');++ t.billing.fund(viewer.subject, 10_000);++ const tip = (o = {}) => t.call('POST', '/api/v1/checkout', {++ cap: ['tips.checkout.create'],++ body: { creator: 'alex', supporter: { type: 'user', id: viewer.subject }, amount: 10 },++ ...o,++ });++ const assertion = (o = {}) => t.network.signAssertion({ subject: viewer.subject, act: 'svc:live', ...o });++++ await check('without the flag, a service tips for a person 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', async () => {++ const r = await tip({ headers: { 'x-ov-subject-assertion': assertion({ subject: other.subject }) } });++ assert.strictEqual(r.status, 403, r.text);++ assert.strictEqual(r.json.code, 'subject.assertion_mismatch');++ });++++ 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('a service that is not named in the chain may not carry the assertion', async () => {++ // Live vouches for the person; only the services it names may carry it.++ const chained = assertion({ for: ['svc:live', 'svc:tips'] });++ const r = await tip({ headers: { 'x-ov-subject-assertion': chained } });++ assert.strictEqual(r.status, 201, r.text);++ const stranger = await tip({ headers: { 'x-ov-subject-assertion': assertion({ for: ['svc:live'] }) }, sub: 'svc:vip' });++ assert.strictEqual(stranger.status, 403, stranger.text);++ assert.strictEqual(stranger.json.code, 'assertion.actor_mismatch');++ });++++ await check('without the flag, a forged assertion does not stop the tip (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: { TIPS_REQUIRE_SUBJECT_ASSERTION: 'on' } });++ await t2.creator('alex');++ const fan = t2.network.newUser('fan');++ t2.billing.fund(fan.subject, 10_000);++ const tip2 = (o = {}) => t2.call('POST', '/api/v1/checkout', {++ cap: ['tips.checkout.create'],++ body: { creator: 'alex', supporter: { type: 'user', id: fan.subject }, amount: 10 },++ ...o,++ });++ const assertion2 = (o = {}) => t2.network.signAssertion({ subject: fan.subject, act: 'svc:live', ...o });++++ await check('with the flag on, a tip 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 tip 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.billing' }) } });++ 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('a person tipping for themselves needs no assertion', async () => {++ const r = await t2.call('POST', '/api/v1/checkout', { user: fan, body: { creator: 'alex', amount: 10 } });++ assert.strictEqual(r.status, 201, r.text);++ });++++ await check('a person tipping for themselves mints an assertion for Billing', async () => {++ // Tips is the one that asks Billing to move the money, so Tips mints the assertion from the++ // person's own token and carries it on.++ const r = await t2.call('POST', '/api/v1/checkout', { user: fan, body: { creator: 'alex', amount: 10 } });++ assert.strictEqual(r.status, 201, r.text);++ const call = t2.billing.transfers().pop();++ assert.ok(call && call.assertion, 'the tip carried an assertion to Billing');++ const claims = JSON.parse(Buffer.from(call.assertion.split('.')[1], 'base64url').toString('utf8'));++ assert.strictEqual(claims.sub, fan.subject);++ assert.deepStrictEqual(claims.aud, ['openvibe.billing']);++ assert.strictEqual(claims.act, 'svc:tips');++ assert.ok(claims.for.includes('svc:tips'), 'Tips may carry it');++ });++++ await check('a service that tips for a person carries its own assertion on to Billing', async () => {++ const a = t2.network.signAssertion({ subject: fan.subject, act: 'svc:live', audience: ['openvibe.tips', 'openvibe.billing'], for: ['svc:live', 'svc:tips'] });++ const r = await t2.call('POST', '/api/v1/checkout', {++ cap: ['tips.checkout.create'], body: { creator: 'alex', supporter: { type: 'user', id: fan.subject }, amount: 10 },++ headers: { 'x-ov-subject-assertion': a },++ });++ assert.strictEqual(r.status, 201, r.text);++ const call = t2.billing.transfers().pop();++ assert.strictEqual(call.assertion, a, 'the assertion the service brought is the one Billing gets');++ });++++ await check('the counters the flag plan waits for are on /metrics', async () => {++ const m = await fetch(`${t2.base}/metrics`).then((r) => r.text());++ assert.ok(/tips_subject_assertion_calls\{state="asserted"\}/.test(m), 'the calls that carried an assertion are counted');++ assert.ok(/tips_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 done();++})().catch((err) => { console.error(err); process.exit(1); });
@@ -505,7 +505,7 @@ A placeholder is never counted as an implemented service.
@@ -505,7 +505,7 @@ A placeholder is never counted as an implemented service.
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.82.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 -->
@@ -9,8 +9,8 @@
@@ -9,8 +9,8 @@
"deployed": true,
"deployed": true,
"deployment": "Launched 2026-10-17 at https://openvibe.tips (openvibe-contracts 0.200.0 made Tips live; vhost deploy/nginx/openvibe.tips.conf installed): deployed with `sudo ovhost deploy tips`, /opt/openvibe.tips, unit openvibe-tips, 127.0.0.1:4610 behind nginx. Principal tips; Events subscriptions billing.transaction.* and billing.receipt.* -> /internal/events; TIPS_CHAT_ADAPTER=chat (chat lines and alerts through OpenVibe.Chat). OpenVibe.Live subscribes to tips.interaction.ready for its channel goal and tip history. TIPS_POWERCHAT_LINK_TEMPLATE turns Billing's pcorder: ref into PowerChat's canonical tip link on the site account (since 2026-10-17).",
"deployment": "Launched 2026-10-17 at https://openvibe.tips (openvibe-contracts 0.200.0 made Tips live; vhost deploy/nginx/openvibe.tips.conf installed): deployed with `sudo ovhost deploy tips`, /opt/openvibe.tips, unit openvibe-tips, 127.0.0.1:4610 behind nginx. Principal tips; Events subscriptions billing.transaction.* and billing.receipt.* -> /internal/events; TIPS_CHAT_ADAPTER=chat (chat lines and alerts through OpenVibe.Chat). OpenVibe.Live subscribes to tips.interaction.ready for its channel goal and tip history. TIPS_POWERCHAT_LINK_TEMPLATE turns Billing's pcorder: ref into PowerChat's canonical tip link on the site account (since 2026-10-17).",
"database": "PostgreSQL 18 through PgBouncer (transaction mode) as the system of record, Valkey 9 for shared state (per-actor limits, overlay fan-out, overlay stream slots), ADR-035: database ov_tips from OpenVibe.Host's data role, schema in migrations/ (0001_initial expand, 0002_drop_legacy_import contract), async on openvibe-sdk/db v0.28.0, money paths serializable with row locks, PostgreSQL outbox and inbox.",
"database": "PostgreSQL 18 through PgBouncer (transaction mode) as the system of record, Valkey 9 for shared state (per-actor limits, overlay fan-out, overlay stream slots), ADR-035: database ov_tips from OpenVibe.Host's data role, schema in migrations/ (0001_initial expand, 0002_drop_legacy_import contract), async on openvibe-sdk/db v0.28.0, money paths serializable with row locks, PostgreSQL outbox and inbox.",
- "contracts": "openvibe-contracts v0.289.1",
- "sdk": "openvibe-sdk v0.82.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, API, SSR pages, overlays, the Billing settlement consumer and the events outbox are built, tested and deployed, together with /metrics (loopback only), truthful /api/ready (Billing and Events checks), supporter privacy (anonymous, hidden amount, private message), goal and supporter-page settings, export and erasure (tips.interaction.erased), moderation (word filter, hide/show, invited moderators, tips.interaction.moderated) and per-actor limits. Capabilities (18 tips.*) and payload schemas are in openvibe-contracts. Billing is the money ledger since the cutover (2026-10-17). A written internal threat review (docs/threat-review.md) with its fixes and test/security.test.js. Paid sounds (creators upload up to 12, supporters pay to play one on the overlay) since 2026-10-17. Not done: an independent security review.",
"notes": "Runtime, API, SSR pages, overlays, the Billing settlement consumer and the events outbox are built, tested and deployed, together with /metrics (loopback only), truthful /api/ready (Billing and Events checks), supporter privacy (anonymous, hidden amount, private message), goal and supporter-page settings, export and erasure (tips.interaction.erased), moderation (word filter, hide/show, invited moderators, tips.interaction.moderated) and per-actor limits. Capabilities (18 tips.*) and payload schemas are in openvibe-contracts. Billing is the money ledger since the cutover (2026-10-17). A written internal threat review (docs/threat-review.md) with its fixes and test/security.test.js. Paid sounds (creators upload up to 12, supporters pay to play one on the overlay) since 2026-10-17. Not done: an independent security review.",
"features": [
"features": [
@@ -13,8 +13,8 @@
@@ -13,8 +13,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.82.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"
},
},
@@ -1118,9 +1118,9 @@
@@ -1118,9 +1118,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",
@@ -1134,9 +1134,9 @@
@@ -1134,9 +1134,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.82.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
- "integrity": "sha512-oJarlzFGEkqYY0eFumIRjJUqMpOT70kgbhgeDtA8QHnuMZR3VIGQubM2UlOaqXgkxD5uhdZ/G6jLHbjvUhqTLQ==",
+ "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"
@@ -25,8 +25,8 @@
@@ -25,8 +25,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.82.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,107 @@
@@ -0,0 +1,107 @@
+'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 tip in anyone's name or read anyone's receipts.
+ *
+ * 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`; Tips checks the
+ * signature against Network's JWKS and that the person it names is the person the route acts for.
+ *
+ * The switch is `TIPS_REQUIRE_SUBJECT_ASSERTION`:
+ *
+ * off (today) a 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 route that names a person must carry an assertion, and it must verify. The old
+ * way is then refused (403 `subject.assertion_required`).
+ *
+ * A person calling with their own Network token is not a delegated call and needs no assertion:
+ * they act for themselves.
+ */
+const { createSubjectAssertionVerifier, createSubjectAssertionMinter } = require('openvibe-sdk/assertions');
+const { fail } = require('../util');
+
+/** 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.tips';
+ 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 });
+ // The other side: Tips asks Network to sign the person's own token into an assertion it may
+ // carry on to Billing. A tip moves the person's Vibes, so Billing wants the person's own
+ // answer, not Tips' word. Without the person's token (a service that vouches for them) there
+ // is nothing to sign and no assertion is minted.
+ const minter = createSubjectAssertionMinter({
+ internalUrl: config.network && config.network.internalUrl,
+ oauth: config.oauth,
+ 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.
+ if (registry && typeof registry.gauge === 'function') {
+ registry.gauge({
+ name: 'tips_subject_assertion_calls',
+ help: 'Per-person 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 principal = req.principal || {};
+ const r = await verifier.verify(raw, { caller: principal.kind === 'service' ? 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 route that names a person: the assertion must name the same person.
+ *
+ * `subject` is the person the route acts for (the supporter, 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 tip: 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 TIPS_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, mint: minter.mint };
+}
+
+module.exports = { createSubjectAssertion, HEADER };
@@ -75,7 +75,7 @@ const CAP = {
@@ -75,7 +75,7 @@ const CAP = {
postModerate: 'tips.post.moderate',
postModerate: 'tips.post.moderate',
};
};
-function v1Router({ domain, apiAuth, limits }) {
+function v1Router({ domain, apiAuth, limits, subjectAssertion }) {
const r = express.Router();
const r = express.Router();
const { db, profiles, goals, interactions, overlays } = domain;
const { db, profiles, goals, interactions, overlays } = domain;
const idem = idempotent(db, domain.now, domain.log);
const idem = idempotent(db, domain.now, domain.log);
@@ -106,6 +106,9 @@ function v1Router({ domain, apiAuth, limits }) {
@@ -106,6 +106,9 @@ function v1Router({ domain, apiAuth, limits }) {
return profile;
return profile;
}
}
const write = (handler) => [idem, wrap(handler)];
const write = (handler) => [idem, wrap(handler)];
+ // The person a 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 };
// ── Profiles ─────────────────────────────────────────────
// ── Profiles ─────────────────────────────────────────────
r.get('/profiles/:creator', wrap(async (req, res) => {
r.get('/profiles/:creator', wrap(async (req, res) => {
@@ -191,6 +194,8 @@ function v1Router({ domain, apiAuth, limits }) {
@@ -191,6 +194,8 @@ function v1Router({ domain, apiAuth, limits }) {
allow(req, cap);
allow(req, cap);
supporter = userSubject(b.supporter, 'supporter');
supporter = userSubject(b.supporter, 'supporter');
supporterName = displayName(b.supporter_name) || null;
supporterName = displayName(b.supporter_name) || null;
+ // The service pays for this person: the person's own assertion says they allowed it.
+ await assertion.person(req, supporter);
} else {
} else {
supporter = p.subject;
supporter = p.subject;
supporterName = displayName(b.supporter_name) || p.name || p.username;
supporterName = displayName(b.supporter_name) || p.name || p.username;
@@ -198,18 +203,28 @@ function v1Router({ domain, apiAuth, limits }) {
@@ -198,18 +203,28 @@ function v1Router({ domain, apiAuth, limits }) {
const profile = await creatorOf(req, b.creator);
const profile = await creatorOf(req, b.creator);
const funding = b.pay_with || 'credit';
const funding = b.pay_with || 'credit';
const tp = req.ov && req.ov.traceparent;
const tp = req.ov && req.ov.traceparent;
+ // The person's own subject assertion (plan H1): it travels on to Billing, which checks it
+ // against the person the money is for. A service that vouches for the person brings its
+ // own assertion; a person tipping for themselves has their token, so Tips mints one for
+ // Billing (Tips is the service that carries it).
+ let carried = String(req.headers['x-ov-subject-assertion'] || '').trim() || null;
+ if (!carried && p.kind === 'user' && typeof assertion.mint === 'function') {
+ const auth = String(req.headers.authorization || '');
+ const userToken = /^Bearer\s+/i.test(auth) ? auth.slice(7).trim() : null;
+ carried = await assertion.mint({ userToken, subject: supporter, audience: 'openvibe.billing', for: ['svc:tips'] });
+ }
const { interaction, replay } = await interactions.request(profile, { ...b, kind }, { supporter, supporterName, funding, idempotencyKey: req.idempotencyKey });
const { interaction, replay } = await interactions.request(profile, { ...b, kind }, { supporter, supporterName, funding, idempotencyKey: req.idempotencyKey });
let i = interaction;
let i = interaction;
const viewer = p.kind === 'service' ? 'service' : 'supporter';
const viewer = p.kind === 'service' ? 'service' : 'supporter';
if (!replay) {
if (!replay) {
if (funding === 'credit') {
if (funding === 'credit') {
- const out = await interactions.transfer(i, { traceparent: tp });
+ const out = await interactions.transfer(i, { traceparent: tp, assertion: carried });
i = await interactions.get(db, i.id);
i = await interactions.get(db, i.id);
if (out.refused) {
if (out.refused) {
fail(out.refused === 'billing.insufficient_funds' ? 409 : 422, out.refused, out.detail || 'Billing refused the tip', { interaction: await interactions.present(db, i, { viewer: 'supporter' }) });
fail(out.refused === 'billing.insufficient_funds' ? 409 : 422, out.refused, out.detail || 'Billing refused the tip', { interaction: await interactions.present(db, i, { viewer: 'supporter' }) });
}
}
} else {
} else {
- i = (await interactions.startCheckout(i, { provider: b.provider, traceparent: tp })).interaction;
+ i = (await interactions.startCheckout(i, { provider: b.provider, traceparent: tp, assertion: carried })).interaction;
}
}
} else if (i.payment_state === 'failed') {
} else if (i.payment_state === 'failed') {
fail(409, 'tips.payment_failed', i.failure || 'the payment for this request failed', { interaction: await interactions.present(db, i, { viewer: 'supporter' }) });
fail(409, 'tips.payment_failed', i.failure || 'the payment for this request failed', { interaction: await interactions.present(db, i, { viewer: 'supporter' }) });
@@ -245,6 +260,7 @@ function v1Router({ domain, apiAuth, limits }) {
@@ -245,6 +260,7 @@ function v1Router({ domain, apiAuth, limits }) {
creator: q.creator ? (await creatorOf(req, q.creator)).creator_subject : null,
creator: q.creator ? (await creatorOf(req, q.creator)).creator_subject : null,
supporter: q.supporter ? userSubject(String(q.supporter), 'supporter') : null,
supporter: q.supporter ? userSubject(String(q.supporter), 'supporter') : null,
};
};
+ if (filter.supporter) await assertion.person(req, filter.supporter);
if (!filter.creator && !filter.supporter) fail(422, 'tips.invalid_input', 'creator or supporter is required');
if (!filter.creator && !filter.supporter) fail(422, 'tips.invalid_input', 'creator or supporter is required');
} else if (p.kind === 'user') {
} else if (p.kind === 'user') {
filter = q.as === 'creator' ? { creator: p.subject } : { supporter: p.subject };
filter = q.as === 'creator' ? { creator: p.subject } : { supporter: p.subject };
@@ -145,7 +145,11 @@ function createApp(opts = {}) {
@@ -145,7 +145,11 @@ function createApp(opts = {}) {
// Per-actor limits on /api/v1 (api/actor-limits.js), counted once apiAuth resolved the caller.
// Per-actor limits on /api/v1 (api/actor-limits.js), counted once apiAuth resolved the caller.
// opts.limitsNow: the limiter's clock (tests).
// opts.limitsNow: the limiter's clock (tests).
const limits = createActorLimits({ config, valkey, now: opts.limitsNow || now, registry: metrics.registry, log });
const limits = createActorLimits({ config, valkey, now: opts.limitsNow || now, registry: metrics.registry, log });
- app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, 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' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits, subjectAssertion }));
app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl, log }));
app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl, log }));
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'tips', service: 'tips', host: 'openvibe.tips', name: 'OpenVibe.Tips', profile: 'ugc' })); }
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'tips', service: 'tips', host: 'openvibe.tips', name: 'OpenVibe.Tips', profile: 'ugc' })); }
@@ -18,6 +18,13 @@
@@ -18,6 +18,13 @@
*/
*/
const { createCommerceClient, CommerceError: BillingCallError } = require('openvibe-sdk/commerce');
const { createCommerceClient, CommerceError: BillingCallError } = require('openvibe-sdk/commerce');
+/**
+ * The person's own subject assertion (ADR-003, the 2026-11-06 amendment; plan H1), when the call
+ * carries one: Billing checks it against the person the money is for. The assertion travels with the
+ * tip: the site vouched for the person, Tips carries it on to Billing.
+ */
+const assertionHeader = (assertion) => (assertion ? { headers: { 'X-OV-Subject-Assertion': String(assertion) } } : {});
+
// One token for every call, as before: every logical capability maps to the same scope, so
// One token for every call, as before: every logical capability maps to the same scope, so
// commerce creates (or takes) a single token client.
// commerce creates (or takes) a single token client.
const SCOPE = 'billing.intent.create billing.transfer.create';
const SCOPE = 'billing.intent.create billing.transfer.create';
@@ -27,11 +34,11 @@ function createBillingClient(config, { fetchImpl = globalThis.fetch, tokenClient
@@ -27,11 +34,11 @@ function createBillingClient(config, { fetchImpl = globalThis.fetch, tokenClient
const commerce = createCommerceClient(config, { caps: CAPS, fetchImpl, tokenClients: tokenClient ? { [SCOPE]: tokenClient } : null });
const commerce = createCommerceClient(config, { caps: CAPS, fetchImpl, tokenClients: tokenClient ? { [SCOPE]: tokenClient } : null });
return {
return {
/** { intent, checkout_url } — kind purchase: the supporter buys `bits` of credit. */
/** { intent, checkout_url } — kind purchase: the supporter buys `bits` of credit. */
- createIntent: async ({ provider, subject, bits, successUrl, cancelUrl, key, traceparent }) =>
- await commerce.createIntent({ provider, kind: 'purchase', subject, bits, successUrl, cancelUrl, key, traceparent }),
+ createIntent: async ({ provider, subject, bits, successUrl, cancelUrl, key, traceparent, assertion }) =>
+ await commerce.createIntent({ provider, kind: 'purchase', subject, bits, successUrl, cancelUrl, key, traceparent, ...assertionHeader(assertion) }),
/** { transaction } — credit of `from` → payable of `to`, tagged with the interaction. */
/** { transaction } — credit of `from` → payable of `to`, tagged with the interaction. */
- createTransfer: async ({ from, to, amount, kind, target, message, key, traceparent }) =>
- await commerce.createTransfer({ from, to, amount, kind, target, message, key, traceparent }),
+ createTransfer: async ({ from, to, amount, kind, target, message, key, traceparent, assertion }) =>
+ await commerce.createTransfer({ from, to, amount, kind, target, message, key, traceparent, ...assertionHeader(assertion) }),
refundTransfer: async ({ txnId, amount, reason, key }) => await commerce.refundTransfer({ txnId, amount, reason, key }),
refundTransfer: async ({ txnId, amount, reason, key }) => await commerce.refundTransfer({ txnId, amount, reason, key }),
rates: async () => await commerce.rates(),
rates: async () => await commerce.rates(),
};
};
@@ -53,6 +53,13 @@ function loadConfig(env = process.env) {
@@ -53,6 +53,13 @@ function loadConfig(env = process.env) {
audience: env.OV_NETWORK_AUDIENCE || 'openvibe.network',
audience: env.OV_NETWORK_AUDIENCE || 'openvibe.network',
},
},
audience: env.TIPS_AUDIENCE || 'openvibe.tips',
audience: env.TIPS_AUDIENCE || 'openvibe.tips',
+ // Subject assertions (ADR-003, the 2026-11-06 amendment; plan H1): a route that names a
+ // person carries the person's own Network-signed assertion (X-OV-Subject-Assertion).
+ // TIPS_REQUIRE_SUBJECT_ASSERTION=on refuses the old way (the service vouching for its
+ // person) and turns the check on for every route that names a person.
+ subjectAssertion: {
+ required: String(env.TIPS_REQUIRE_SUBJECT_ASSERTION || 'off').toLowerCase() === 'on',
+ },
// Tips' own client credentials (client `tips` in the Network): service tokens for Billing,
// Tips' own client credentials (client `tips` in the Network): service tokens for Billing,
// Events, Chat (delivery) and the Network; and the OAuth code flow for browser sign-in.
// Events, Chat (delivery) and the Network; and the OAuth code flow for browser sign-in.
oauth: {
oauth: {
@@ -209,12 +209,12 @@ function createInteractions(ctx) {
@@ -209,12 +209,12 @@ function createInteractions(ctx) {
const transferKind = (i) => (i.kind === 'tip' ? 'tip' : 'paid_interaction');
const transferKind = (i) => (i.kind === 'tip' ? 'tip' : 'paid_interaction');
/** Try the Billing transfer now; retryable failures leave it due for the jobs loop. */
/** Try the Billing transfer now; retryable failures leave it due for the jobs loop. */
- async function transfer(i, { traceparent } = {}) {
+ async function transfer(i, { traceparent, assertion } = {}) {
try {
try {
const out = await ctx.billing.createTransfer({
const out = await ctx.billing.createTransfer({
from: i.supporter_subject, to: i.creator_subject, amount: i.amount, kind: transferKind(i),
from: i.supporter_subject, to: i.creator_subject, amount: i.amount, kind: transferKind(i),
target: { service: 'tips', type: 'interaction', id: i.id }, message: i.message || undefined,
target: { service: 'tips', type: 'interaction', id: i.id }, message: i.message || undefined,
- key: `tips:transfer:${i.id}`, traceparent,
+ key: `tips:transfer:${i.id}`, traceparent, assertion,
});
});
const txn = out.transaction;
const txn = out.transaction;
await ctx.tx(async (t) => await settleByTransaction(t, await lock(t, i.id), { id: txn.id, test: !!txn.test }), MONEY);
await ctx.tx(async (t) => await settleByTransaction(t, await lock(t, i.id), { id: txn.id, test: !!txn.test }), MONEY);
@@ -234,7 +234,7 @@ function createInteractions(ctx) {
@@ -234,7 +234,7 @@ function createInteractions(ctx) {
}
}
}
}
- async function startCheckout(i, { provider, traceparent }) {
+ async function startCheckout(i, { provider, traceparent, assertion }) {
const p = String(provider || config.billing.providers[0] || '').toLowerCase();
const p = String(provider || config.billing.providers[0] || '').toLowerCase();
if (!config.billing.providers.includes(p)) fail(422, 'tips.invalid_input', `provider must be one of ${config.billing.providers.join(', ') || '(none configured)'}`);
if (!config.billing.providers.includes(p)) fail(422, 'tips.invalid_input', `provider must be one of ${config.billing.providers.join(', ') || '(none configured)'}`);
let out;
let out;
@@ -242,7 +242,7 @@ function createInteractions(ctx) {
@@ -242,7 +242,7 @@ function createInteractions(ctx) {
out = await ctx.billing.createIntent({
out = await ctx.billing.createIntent({
provider: p, subject: i.supporter_subject, bits: i.amount,
provider: p, subject: i.supporter_subject, bits: i.amount,
successUrl: `${config.baseUrl}/receipts/${i.id}`, cancelUrl: `${config.baseUrl}/receipts/${i.id}?cancelled=1`,
successUrl: `${config.baseUrl}/receipts/${i.id}`, cancelUrl: `${config.baseUrl}/receipts/${i.id}?cancelled=1`,
- key: `tips:intent:${i.id}`, traceparent,
+ key: `tips:intent:${i.id}`, traceparent, assertion,
});
});
} catch (e) {
} catch (e) {
if (e instanceof BillingCallError && !e.retryable) {
if (e instanceof BillingCallError && !e.retryable) {
@@ -27,9 +27,18 @@ function readBody(req) {
@@ -27,9 +27,18 @@ function readBody(req) {
const send = (res, status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
const send = (res, status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
async function startNetwork() {
async function startNetwork() {
+ // What a service holds for an audience when it asks for no scope (Network issues the token's
+ // capabilities from the principal's grants, so the request's scope is only a narrowing).
+ const DEFAULT_CAPS = {
+ 'openvibe.network': ['identity.subject.resolve'],
+ 'openvibe.chat': ['chat.message.send chat.event.publish'],
+ };
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 grants = [];
const grants = [];
// The sign-in codes Network issued (openvibe-sdk/sso exchanges one with the PKCE verifier): code → tokens.
// The sign-in codes Network issued (openvibe-sdk/sso exchanges one with the PKCE verifier): code → tokens.
const codes = new Map();
const codes = new Map();
@@ -38,6 +47,20 @@ async function startNetwork() {
@@ -38,6 +47,20 @@ async function startNetwork() {
let issuer = 'http://network.test';
let issuer = 'http://network.test';
let n = 100;
let n = 100;
+ const b64url = (b) => Buffer.from(b).toString('base64url');
+ /** A subject assertion, as Network signs it: the person, the audience(s), the services that may carry it. */
+ function signAssertion({ subject, audience = 'openvibe.tips', act = 'svc:live', for: forServices, expSec = 60, kid = 'ov-network-assertion-1', jti } = {}) {
+ const now = Math.floor(Date.now() / 1000);
+ const aud = Array.isArray(audience) ? audience : [audience];
+ const header = b64url(JSON.stringify({ alg: 'ES256', typ: 'JWT', kid }));
+ const body = b64url(JSON.stringify({
+ iss: issuer, sub: subject, aud, act,
+ ...(forServices ? { for: forServices } : {}),
+ 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.tips'], cap = [], expSec = 300 } = {}) {
function signService({ sub = 'svc:live', aud = ['openvibe.tips'], cap = [], expSec = 300 } = {}) {
const now = Math.floor(Date.now() / 1000);
const now = Math.floor(Date.now() / 1000);
return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type: 'service', aud, cap, iat: now, exp: now + expSec, jti: crypto.randomBytes(8).toString('hex') }, privatePem);
return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type: 'service', aud, cap, iat: now, exp: now + expSec, jti: crypto.randomBytes(8).toString('hex') }, privatePem);
@@ -55,11 +78,27 @@ async function startNetwork() {
@@ -55,11 +78,27 @@ 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') {
// The Network takes the token grant as JSON (openvibe-sdk/sso) or as a form (the service clients).
// The Network takes the token grant as JSON (openvibe-sdk/sso) or as a form (the service clients).
const body = raw.trim().startsWith('{') ? JSON.parse(raw) : Object.fromEntries(new URLSearchParams(raw));
const body = raw.trim().startsWith('{') ? JSON.parse(raw) : Object.fromEntries(new URLSearchParams(raw));
- grants.push(body);
+ // A token's capabilities come from the principal's grants for the audience, not from the
+ // request: a client that asks for no scope gets the audience's default grants (Network's
+ // own token endpoint does the same, so a scope-less client is not left with an empty token).
+ const asked = String(body.scope || '').split(/\s+/).filter(Boolean);
+ const cap = asked.length ? asked : (DEFAULT_CAPS[String(body.audience || '')] || []);
+ grants.push({ ...body, scope: cap.join(' ') || body.scope });
if (body.client_secret !== 'shh') return send(res, 401, { error: 'invalid_client' });
if (body.client_secret !== 'shh') return send(res, 401, { error: 'invalid_client' });
// A sign-in's code: the Network answers invalid_grant for a code it never issued.
// A sign-in's code: the Network answers invalid_grant for a code it never issued.
if (body.grant_type === 'authorization_code') {
if (body.grant_type === 'authorization_code') {
@@ -68,9 +107,26 @@ async function startNetwork() {
@@ -68,9 +107,26 @@ async function startNetwork() {
codes.delete(String(body.code));
codes.delete(String(body.code));
return send(res, 200, { access_token: c.access_token, refresh_token: c.refresh_token || null, token_type: 'Bearer', expires_in: 300 });
return send(res, 200, { access_token: c.access_token, refresh_token: c.refresh_token || null, token_type: 'Bearer', expires_in: 300 });
}
}
- const cap = String(body.scope || '').split(/\s+/).filter(Boolean);
return send(res, 200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience], cap }), token_type: 'Bearer', expires_in: 300 });
return send(res, 200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience], cap }), token_type: 'Bearer', expires_in: 300 });
}
}
+ if (req.url === '/internal/subject-assertions' && req.method === 'POST') {
+ // The mint route (ADR-003, the 2026-11-06 amendment): a first-party service hands over the
+ // person's own token and gets back a 60-second assertion naming the person.
+ const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });
+ if (!v.ok) return send(res, 401, { code: v.code });
+ if (!/^svc:[a-z][a-z0-9-]{1,39}$/.test(String(v.claims.sub || ''))) return send(res, 403, { code: 'assertion.not_a_service' });
+ const body = JSON.parse(raw || '{}');
+ if (!body.user_token) return send(res, 401, { code: 'assertion.no_user_token' });
+ let claims;
+ try { claims = jwt.verify(String(body.user_token), publicPem, { issuer }); } catch { return send(res, 401, { code: 'assertion.invalid_user_token' }); }
+ const subject = claims.subject_id;
+ if (body.subject && body.subject !== subject) return send(res, 403, { code: 'assertion.subject_mismatch' });
+ const audience = Array.isArray(body.audience) ? body.audience : [body.audience];
+ const forServices = [...new Set([v.claims.sub, ...(Array.isArray(body.for) ? body.for : [])])];
+ const jti = `as_${crypto.randomBytes(8).toString('hex')}`;
+ const assertion = signAssertion({ subject, audience, act: v.claims.sub, for: forServices, jti });
+ return send(res, 200, { assertion, subject, audience: body.audience, act: v.claims.sub, for: forServices, jti, expires_in: 60 });
+ }
if (req.url.startsWith('/internal/identity/resolve?') && req.method === 'GET') {
if (req.url.startsWith('/internal/identity/resolve?') && req.method === 'GET') {
const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });
const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });
if (!v.ok) return send(res, 401, { code: v.code });
if (!v.ok) return send(res, 401, { code: v.code });
@@ -103,7 +159,7 @@ async function startNetwork() {
@@ -103,7 +159,7 @@ async function startNetwork() {
codes.set(code, { access_token: access_token || signUser({ subject: 'usr_test', username: 'someone' }), refresh_token });
codes.set(code, { access_token: access_token || signUser({ subject: 'usr_test', username: 'someone' }), refresh_token });
return code;
return code;
}
}
- return { url, publicPem, signService, signUser, newUser, legacy, grants, resolves, issueCode, close: () => new Promise((r) => server.close(r)) };
+ return { url, publicPem, signService, signUser, signAssertion, newUser, legacy, grants, resolves, issueCode, close: () => new Promise((r) => server.close(r)) };
}
}
/** Billing as Tips sees it. */
/** Billing as Tips sees it. */
@@ -158,7 +214,7 @@ async function startBilling(network) {
@@ -158,7 +214,7 @@ async function startBilling(network) {
const raw = await readBody(req);
const raw = await readBody(req);
if (req.url === '/api/health') return send(res, state.down ? 503 : 200, state.down ? { code: 'billing.unavailable' } : { ok: true, service: 'billing' });
if (req.url === '/api/health') return send(res, state.down ? 503 : 200, state.down ? { code: 'billing.unavailable' } : { ok: true, service: 'billing' });
const body = raw ? JSON.parse(raw) : {};
const body = raw ? JSON.parse(raw) : {};
- calls.push({ method: req.method, url: req.url, body, key: req.headers['idempotency-key'] || null });
+ calls.push({ method: req.method, url: req.url, body, key: req.headers['idempotency-key'] || null, assertion: req.headers['x-ov-subject-assertion'] || null });
if (state.down) return send(res, 503, { code: 'billing.unavailable' });
if (state.down) return send(res, 503, { code: 'billing.unavailable' });
const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.billing' });
const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.billing' });
if (!v.ok) return send(res, 401, { code: v.code });
if (!v.ok) return send(res, 401, { code: v.code });
new file mode 100644
new file mode 100644
@@ -0,0 +1,153 @@
@@ -0,0 +1,153 @@
+'use strict';
+/**
+ * Subject assertions on the money routes (server/api/subject-assertion.js; ADR-003, the 2026-11-06
+ * amendment; plan H1).
+ *
+ * A service that tips for a person carries 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 tips in their name.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+
+(async () => {
+ console.log('subject assertions');
+ const t = await boot();
+ const alex = await t.creator('alex');
+ const viewer = t.network.newUser('viewer');
+ const other = t.network.newUser('other');
+ t.billing.fund(viewer.subject, 10_000);
+ const tip = (o = {}) => t.call('POST', '/api/v1/checkout', {
+ cap: ['tips.checkout.create'],
+ body: { creator: 'alex', supporter: { type: 'user', id: viewer.subject }, amount: 10 },
+ ...o,
+ });
+ const assertion = (o = {}) => t.network.signAssertion({ subject: viewer.subject, act: 'svc:live', ...o });
+
+ await check('without the flag, a service tips for a person 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', async () => {
+ const r = await tip({ headers: { 'x-ov-subject-assertion': assertion({ subject: other.subject }) } });
+ assert.strictEqual(r.status, 403, r.text);
+ assert.strictEqual(r.json.code, 'subject.assertion_mismatch');
+ });
+
+ 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('a service that is not named in the chain may not carry the assertion', async () => {
+ // Live vouches for the person; only the services it names may carry it.
+ const chained = assertion({ for: ['svc:live', 'svc:tips'] });
+ const r = await tip({ headers: { 'x-ov-subject-assertion': chained } });
+ assert.strictEqual(r.status, 201, r.text);
+ const stranger = await tip({ headers: { 'x-ov-subject-assertion': assertion({ for: ['svc:live'] }) }, sub: 'svc:vip' });
+ assert.strictEqual(stranger.status, 403, stranger.text);
+ assert.strictEqual(stranger.json.code, 'assertion.actor_mismatch');
+ });
+
+ await check('without the flag, a forged assertion does not stop the tip (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: { TIPS_REQUIRE_SUBJECT_ASSERTION: 'on' } });
+ await t2.creator('alex');
+ const fan = t2.network.newUser('fan');
+ t2.billing.fund(fan.subject, 10_000);
+ const tip2 = (o = {}) => t2.call('POST', '/api/v1/checkout', {
+ cap: ['tips.checkout.create'],
+ body: { creator: 'alex', supporter: { type: 'user', id: fan.subject }, amount: 10 },
+ ...o,
+ });
+ const assertion2 = (o = {}) => t2.network.signAssertion({ subject: fan.subject, act: 'svc:live', ...o });
+
+ await check('with the flag on, a tip 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 tip 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.billing' }) } });
+ 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('a person tipping for themselves needs no assertion', async () => {
+ const r = await t2.call('POST', '/api/v1/checkout', { user: fan, body: { creator: 'alex', amount: 10 } });
+ assert.strictEqual(r.status, 201, r.text);
+ });
+
+ await check('a person tipping for themselves mints an assertion for Billing', async () => {
+ // Tips is the one that asks Billing to move the money, so Tips mints the assertion from the
+ // person's own token and carries it on.
+ const r = await t2.call('POST', '/api/v1/checkout', { user: fan, body: { creator: 'alex', amount: 10 } });
+ assert.strictEqual(r.status, 201, r.text);
+ const call = t2.billing.transfers().pop();
+ assert.ok(call && call.assertion, 'the tip carried an assertion to Billing');
+ const claims = JSON.parse(Buffer.from(call.assertion.split('.')[1], 'base64url').toString('utf8'));
+ assert.strictEqual(claims.sub, fan.subject);
+ assert.deepStrictEqual(claims.aud, ['openvibe.billing']);
+ assert.strictEqual(claims.act, 'svc:tips');
+ assert.ok(claims.for.includes('svc:tips'), 'Tips may carry it');
+ });
+
+ await check('a service that tips for a person carries its own assertion on to Billing', async () => {
+ const a = t2.network.signAssertion({ subject: fan.subject, act: 'svc:live', audience: ['openvibe.tips', 'openvibe.billing'], for: ['svc:live', 'svc:tips'] });
+ const r = await t2.call('POST', '/api/v1/checkout', {
+ cap: ['tips.checkout.create'], body: { creator: 'alex', supporter: { type: 'user', id: fan.subject }, amount: 10 },
+ headers: { 'x-ov-subject-assertion': a },
+ });
+ assert.strictEqual(r.status, 201, r.text);
+ const call = t2.billing.transfers().pop();
+ assert.strictEqual(call.assertion, a, 'the assertion the service brought is the one Billing gets');
+ });
+
+ await check('the counters the flag plan waits for are on /metrics', async () => {
+ const m = await fetch(`${t2.base}/metrics`).then((r) => r.text());
+ assert.ok(/tips_subject_assertion_calls\{state="asserted"\}/.test(m), 'the calls that carried an assertion are counted');
+ assert.ok(/tips_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 done();
+})().catch((err) => { console.error(err); process.exit(1); });