openvibers/openvibe.vip Commits
VIP's money calls carry the member's own subject assertion (#191)
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 -->@@@ -4,8 +4,8 @@"code": true,"deployed": true,"deployment": "Launched 2026-10-17 at https://openvibe.vip (openvibe-contracts 0.200.0 made VIP live; vhost deploy/nginx/openvibe.vip.conf installed): deployed with `sudo ovhost deploy vip`, /opt/openvibe.vip, unit openvibe-vip, 127.0.0.1:4620 behind nginx. Principal vip; three Events subscriptions for Billing's events. Checkout open since the Billing cutover (VIP_CHECKOUT_PROVIDERS=powerchat,credit): 43 plans imported from Live, 0 memberships sold on launch day.",-- "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","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","roadmap": "Wave 10",@@@ -14,8 +14,8 @@"express": "^4.21.2","iovalkey": "^0.4.0","multer": "^2.5.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"},@@@ -1154,9 +1154,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",@@@ -1170,9 +1170,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"@@@ -31,8 +31,8 @@"express": "^4.21.2","iovalkey": "^0.4.0","multer": "^2.5.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"},@@@ -32,8 +32,11 @@ const CAP = {postModerate: 'vip.post.moderate',};--function v1Router({ domain, apiAuth, limits }) {++function v1Router({ domain, apiAuth, limits, assertionFor }) {const r = express.Router();++ // The person's own subject assertion (ADR-003, the 2026-11-06 amendment; plan H1). Without the++ // minter (an old caller) no assertion is minted and every call travels the old way.++ const mint = typeof assertionFor === 'function' ? assertionFor : async () => null;const { creators, plans, perks, memberships, entitlements, policies, checkout, billing } = domain;// Per-actor limits (api/actor-limits.js), after the credential and before any work: reads take the@@@ -235,6 +238,9 @@ function v1Router({ domain, apiAuth, limits }) {member, planId: b.plan_id, provider: b.provider,successUrl: returnUrl(req, b.success_url, 'success_url'), cancelUrl: returnUrl(req, b.cancel_url, 'cancel_url'),autoRenew: b.auto_renew === undefined ? true : bool(b.auto_renew), traceparent: trace(req),++ // The person's own subject assertion (ADR-003, the 2026-11-06 amendment; plan H1): Billing++ // checks it against the member the money is for.++ assertion: await mint(req.headers, member),});res.status(201).json(out);}));@@@ -246,7 +252,7 @@ function v1Router({ domain, apiAuth, limits }) {const list = [];for (const m of await memberships.forMember(member)) {const c = await creators.byId(m.creator_id);-- const ent = c && c.subject ? await entitlements.check(member, c.subject, { mode, traceparent: trace(req) }) : null;++ const ent = c && c.subject ? await entitlements.check(member, c.subject, { mode, traceparent: trace(req), assertion: await mint(req.headers, member) }) : null;list.push({ ...await memberships.present(m), creator: creators.present(c), entitlement: ent, preferences: await memberships.preferences(member, m.creator_id) });}res.json({ member: { type: 'user', id: member }, memberships: list });@@@ -260,7 +266,7 @@ function v1Router({ domain, apiAuth, limits }) {r.post('/memberships/:creator/cancel', limits('vip.membership.cancel', { minute: 5, hour: 30 }), wrap(async (req, res) => {if (req.principal.kind !== 'user') fail(403, 'vip.member_only', 'only the member can cancel their membership');const c = await creatorParam(req);-- res.json(await checkout.cancel({ member: req.principal.subject, creatorId: c.id, traceparent: trace(req) }));++ res.json(await checkout.cancel({ member: req.principal.subject, creatorId: c.id, traceparent: trace(req), assertion: await mint(req.headers, req.principal.subject) }));}));r.put('/memberships/:creator/preferences', edit, wrap(async (req, res) => {if (req.principal.kind !== 'user') fail(403, 'vip.member_only', 'only the member sets their preferences');@@@ -282,7 +288,7 @@ function v1Router({ domain, apiAuth, limits }) {creatorSubject = c.subject;}const mode = ['projection', 'authoritative'].includes(src.mode) ? src.mode : 'auto';-- const out = await entitlements.check(member, creatorSubject, { mode, traceparent: trace(req) });++ const out = await entitlements.check(member, creatorSubject, { mode, traceparent: trace(req), assertion: await mint(req.headers, member) });// `product`: what this membership means in one product — the perks of the member's plan version// with that product's bindings (a chat badge, a gated post, …) and the member's badge preference.if (src.product != null && src.product !== '') {@@@ -140,7 +140,9 @@ async 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, now: opts.limitsNow || now, registry: metrics.registry, log, valkey });-- app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits }));++ // The person's own subject assertion on the money routes (ADR-003, the 2026-11-06 amendment; plan H1).++ const assertionFor = require('./subject-assertion').createSubjectAssertionMinter(config);++ app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits, assertionFor }));app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl, log }));{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'vip', service: 'vip', host: 'openvibe.vip', name: 'OpenVibe.VIP', profile: 'ugc' })); }@@@ -20,6 +20,7 @@* it means "unknown", never "yes").*/const { createCommerceClient, CommerceError } = require('openvibe-sdk/commerce');++const { assertionHeader } = require('./subject-assertion');const BillingCallError = CommerceError;@@@ -39,20 +40,23 @@ function createBillingClient(config, { fetchImpl = globalThis.fetch, tokenClientreturn {/** { intent, checkout_url } — a subscription checkout for `subject` to `creator`. */-- createIntent: async ({ provider, subject, creator, tier, autoRenew, successUrl, cancelUrl, key, traceparent }) => await commerce.createIntent({++ createIntent: async ({ provider, subject, creator, tier, autoRenew, successUrl, cancelUrl, key, traceparent, assertion }) => await commerce.createIntent({provider, kind: 'subscription', subject, creator, tier, autoRenew, successUrl, cancelUrl, key, traceparent,++ ...(assertion ? { headers: assertionHeader(assertion) } : {}),}),/** { subscription, entitlement, transaction } — one period paid from the member's credit, at the tier's price. */-- subscribeWithCredit: async ({ subscriber, creator, tier, autoRenew, key, traceparent }) => await commerce.subscribeWithCredit({ subscriber, creator, tier, autoRenew, key, traceparent }),++ subscribeWithCredit: async ({ subscriber, creator, tier, autoRenew, key, traceparent, assertion }) => await commerce.subscribeWithCredit({++ subscriber, creator, tier, autoRenew, key, traceparent, ...(assertion ? { headers: assertionHeader(assertion) } : {}),++ }),/** A creator's tier prices on Billing: { tiers: [{ tier, price_cents, status }], default_price_cents, min/max_price_cents, max_tiers }. */listTiers: async (creator) => await commerce.listTiers(creator),/** { tier } — set one tier's monthly price (billing.tier.manage). */setTier: async ({ creator, tier, priceCents, status, key, traceparent }) => await commerce.setTier({ creator, tier, priceCents, status, key, traceparent }),-- cancelSubscription: async ({ id, key, traceparent }) => await commerce.cancelSubscription({ id, key, traceparent }),-- getSubscription: async (id) => await commerce.getSubscription(id),-- listSubscriptions: async ({ streamer, subscriber, status } = {}) => await commerce.listSubscriptions({ streamer, subscriber, status }),++ cancelSubscription: async ({ id, key, traceparent, assertion }) => await commerce.cancelSubscription({ id, key, traceparent, ...(assertion ? { headers: assertionHeader(assertion) } : {}) }),++ getSubscription: async (id, { assertion } = {}) => await commerce.getSubscription(id, { ...(assertion ? { headers: assertionHeader(assertion) } : {}) }),++ listSubscriptions: async ({ streamer, subscriber, status } = {}, { assertion } = {}) => await commerce.listSubscriptions({ streamer, subscriber, status }, { ...(assertion ? { headers: assertionHeader(assertion) } : {}) }),/** { active, expires_at, subscription } — the authoritative answer. */-- entitlement: async (subject, creator) => await commerce.entitlement(subject, creator),++ entitlement: async (subject, creator, { assertion } = {}) => await commerce.entitlement(subject, creator, { ...(assertion ? { headers: assertionHeader(assertion) } : {}) }),rates: async () => await commerce.rates(),baseUrl: () => commerce.baseUrl(),};@@@ -33,7 +33,7 @@ function billingProblem(err) {function createCheckout({ db, now, config, billing, creators, plans, entitlements, periods }) {const byId = async (id) => await db.prepare('SELECT * FROM vip_checkouts WHERE id = ?').get(id) || null;-- async function start({ member, planId, provider, successUrl, cancelUrl, autoRenew = true, traceparent }) {++ async function start({ member, planId, provider, successUrl, cancelUrl, autoRenew = true, traceparent, assertion }) {const plan = await plans.byId(String(planId || ''));if (!plan || plan.status !== 'published') fail(404, 'vip.plan_not_found', 'no published plan with that id');if (!plan.billing_kind) fail(409, 'vip.checkout.unavailable', 'this plan is not sold through Billing yet');@@@ -45,7 +45,7 @@ function createCheckout({ db, now, config, billing, creators, plans, entitlement// Sensitive: ask Billing, not the projection. An active member renews through Billing, not here.let current;-- try { current = await entitlements.authoritative(member, creator.subject, { traceparent }); } catch (e) { billingProblem(e); }++ try { current = await entitlements.authoritative(member, creator.subject, { traceparent, assertion }); } catch (e) { billingProblem(e); }if (current && current.active && current.expires_at && Date.parse(current.expires_at) > now()) {fail(409, 'vip.already_member', 'you are already a member; your membership renews through Billing');}@@@ -62,7 +62,7 @@ function createCheckout({ db, now, config, billing, creators, plans, entitlementif (p === 'credit') {let out;try {-- out = await billing.subscribeWithCredit({ subscriber: member, creator: creator.subject, tier: Number(plan.billing_tier) || 1, autoRenew, key: `vip:checkout:${id}`, traceparent });++ out = await billing.subscribeWithCredit({ subscriber: member, creator: creator.subject, tier: Number(plan.billing_tier) || 1, autoRenew, key: `vip:checkout:${id}`, traceparent, assertion });} catch (e) { await mark({ status: 'failed', error: String(e.code || e.message).slice(0, 200) }); billingProblem(e); }const sub = out.subscription || {};const ent = out.entitlement || {};@@@ -91,7 +91,7 @@ function createCheckout({ db, now, config, billing, creators, plans, entitlementprovider: p, subject: member, creator: creator.subject, tier: Number(plan.billing_tier) || 1, autoRenew,successUrl: successUrl || `${config.baseUrl}/me?joined=${encodeURIComponent(creator.username || '')}`,cancelUrl: cancelUrl || `${config.baseUrl}/${encodeURIComponent(creator.username || '')}`,-- key: `vip:checkout:${id}`, traceparent,++ key: `vip:checkout:${id}`, traceparent, assertion,});} catch (e) { await mark({ status: 'failed', error: String(e.code || e.message).slice(0, 200) }); billingProblem(e); }const intent = out.intent || {};@@@ -106,22 +106,22 @@ function createCheckout({ db, now, config, billing, creators, plans, entitlement}/** Cancel at period end, through Billing. Only the member themself. */-- async function cancel({ member, creatorId, traceparent }) {++ async function cancel({ member, creatorId, traceparent, assertion }) {const creator = await creators.byId(creatorId);if (!creator || !creator.subject) fail(404, 'vip.creator_not_found', `no creator ${creatorId}`);let row;-- try { row = await entitlements.authoritative(member, creator.subject, { traceparent }); } catch (e) { billingProblem(e); }++ try { row = await entitlements.authoritative(member, creator.subject, { traceparent, assertion }); } catch (e) { billingProblem(e); }if (!row || !row.active || !row.subscription_id) fail(404, 'vip.membership_not_found', 'there is no active membership to cancel');if (row.cancel_at_period_end) return { already: true, expires_at: row.expires_at };let sub;try {-- const s = await billing.getSubscription(row.subscription_id);++ const s = await billing.getSubscription(row.subscription_id, { assertion });sub = s.subscription;} catch (e) { billingProblem(e); }if (!sub || !sub.subscriber || sub.subscriber.id !== member) fail(403, 'vip.not_yours', 'that membership is not yours');let out;try {-- out = await billing.cancelSubscription({ id: sub.id, key: `vip:cancel:${sub.id}:${sub.current_period_end || 'open'}`, traceparent });++ out = await billing.cancelSubscription({ id: sub.id, key: `vip:cancel:${sub.id}:${sub.current_period_end || 'open'}`, traceparent, assertion });} catch (e) { billingProblem(e); }const s = (out && out.subscription) || {};await entitlements.applyCanceled({@@@ -152,8 +152,8 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla};/** Ask Billing and project the answer. Throws BillingCallError when Billing cannot answer. */-- async function authoritative(member, creator, { traceparent } = {}) {-- const e = await billing.entitlement(member, creator);++ async function authoritative(member, creator, { traceparent, assertion } = {}) {++ const e = await billing.entitlement(member, creator, { assertion });const sub = e && e.subscription;const out = await apply({member, creator, active: !!(e && e.active), expiresAt: (e && e.expires_at) || null,@@@ -167,7 +167,7 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla* check(member, creator, { mode: 'auto' | 'projection' | 'authoritative' }) → answer.* `active` is true only for status 'active'; 'unknown' never authorizes.*/-- async function check(member, creator, { mode = 'auto', traceparent } = {}) {++ async function check(member, creator, { mode = 'auto', traceparent, assertion } = {}) {if (member === creator) return { ...unknown(member, creator, 'self'), status: 'inactive', reason: 'a creator is not a member of their own plans' };const t = now();let row = await getRow(member, creator);@@@ -181,7 +181,7 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla}if (mode === 'auto' && fresh) return await answer(row, 'projection', { membership: await membershipOf(member, creator) });try {-- row = await authoritative(member, creator, { traceparent });++ row = await authoritative(member, creator, { traceparent, assertion });return await answer(row, 'billing', { membership: await membershipOf(member, creator) });} catch (err) {log.warn(`[VIP] authoritative entitlement check failed (${member} → ${creator}): ${err.message}`);nnew file mode 100644@@@ -0,0 +1,71 @@++'use strict';++++/**++ * The person's own subject assertion for VIP's money calls (ADR-003, the 2026-11-06 amendment; plan H1).++ *++ * VIP names the member it acts for and Billing believes it. On a money route (a membership bought++ * from the member's Vibes, a cancelled membership) that is only VIP's word: a compromised VIP could++ * spend anyone's credit. A subject assertion is the person's own answer: Network signs their access++ * token into a 60-second proof that names them, and Billing checks that proof instead of VIP's word.++ *++ * VIP holds the person's Network access token on the request (the Authorization header or the session++ * cookie `ov_token`), so it can ask Network to sign it. Without that token there is nothing to sign++ * and no assertion is minted: the call then travels the old way, which Billing's++ * BILLING_REQUIRE_SUBJECT_ASSERTION flag decides on.++ *++ * The assertion is minted per call and never cached: Billing refuses a replayed one (it remembers the++ * jti), so two money calls must not share it.++ */++const { createSubjectAssertionMinter: createSubjectAssertionMinterSdk } = require('openvibe-sdk/assertions');++++const NETWORK_INTERNAL_URL = (process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '');++const CLIENT_ID = process.env.OV_OAUTH_CLIENT_ID || 'vip';++const CLIENT_SECRET = process.env.OV_OAUTH_CLIENT_SECRET || '';++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++++/** The person's own Network access token on this request, or null. */++function personToken(headers) {++ const h = headers || {};++ const auth = String(h.authorization || '');++ if (/^Bearer\s+/i.test(auth)) {++ const token = auth.slice(7).trim();++ if (token) return token;++ }++ for (const part of String(h.cookie || '').split(';')) {++ const idx = part.indexOf('=');++ if (idx < 0) continue;++ if (part.slice(0, idx).trim() !== 'ov_token') continue;++ const value = decodeURIComponent(part.slice(idx + 1).trim());++ if (value) return value;++ }++ return null;++}++++/**++ * The minter, from the config the app loaded (the service token it presents to Network comes from++ * VIP's own OAuth client).++ */++function createSubjectAssertionMinter(config) {++ const c = config || {};++ const network = c.network || {};++ const oauth = c.oauth || {};++ const minter = createSubjectAssertionMinterSdk({++ internalUrl: network.internalUrl || NETWORK_INTERNAL_URL,++ oauth: { clientId: oauth.clientId || CLIENT_ID, clientSecret: oauth.clientSecret || CLIENT_SECRET },++ });++ /**++ * The assertion for the member this request acts for, or null when it cannot be had (no session,++ * no grant, Network down). It never throws: a money call must not fail because a proof is missing.++ */++ return async function assertionFor(headers, subject) {++ if (!subject || !SUBJECT_RE.test(String(subject))) return null;++ const userToken = personToken(headers);++ if (!userToken) return null;++ return await minter.mint({ userToken, subject, audience: 'openvibe.billing' });++ };++}++++/** The header the assertion rides on. */++const assertionHeader = (assertion) => (assertion ? { 'X-OV-Subject-Assertion': String(assertion) } : {});++++module.exports = { createSubjectAssertionMinter, personToken, assertionHeader, SUBJECT_RE };@@@ -67,6 +67,27 @@ async function startNetwork() {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 now = Math.floor(Date.now() / 1000);++ const b64 = (b) => Buffer.from(b).toString('base64url');++ const header = b64(JSON.stringify({ alg: 'ES256', typ: 'JWT', kid: 'ov-network-assertion-1' }));++ const payload = b64(JSON.stringify({ iss: issuer, sub: subject, aud: audience, act: v.claims.sub, for: forServices, jti, iat: now, exp: now + 60 }));++ return send(res, 200, { assertion: `${header}.${payload}.${b64('signed')}`, subject, audience: body.audience, act: v.claims.sub, for: forServices, jti, expires_in: 60 });++ }send(res, 404, { error: 'not found' });});const url = await listen(server);@@@ -190,7 +211,7 @@ async function startBilling(network, clock) {const raw = await readBody(req);const u = new URL(req.url, 'http://x');const p = u.pathname;-- calls.push({ method: req.method, path: p, query: Object.fromEntries(u.searchParams), key: req.headers['idempotency-key'] || null, body: raw ? JSON.parse(raw) : null });++ calls.push({ method: req.method, path: p, query: Object.fromEntries(u.searchParams), key: req.headers['idempotency-key'] || null, body: raw ? JSON.parse(raw) : null, assertion: req.headers['x-ov-subject-assertion'] || null });if (p === '/api/health') return state.down ? send(res, 503, { ok: false }) : send(res, 200, { ok: true });if (state.down) { req.socket.destroy(); return undefined; }const need = (cap) => { const e = auth(req, cap); if (e) { send(res, e[0], e[1]); return false; } return true; };nnew file mode 100644@@@ -0,0 +1,65 @@++'use strict';++/**++ * The person's own subject assertion on VIP's money calls (ADR-003, the 2026-11-06 amendment; plan H1).++ *++ * VIP names the member it acts for and Billing believes it. On a money route (a membership bought++ * from the member's Vibes, an entitlement read) that is only VIP's word, so VIP asks Network to sign++ * the person's own token into a 60-second assertion and carries it on. Without the person's token++ * there is nothing to sign, and the call travels the old way — which Billing's++ * BILLING_REQUIRE_SUBJECT_ASSERTION flag decides on.++ */++const assert = require('assert');++const { boot, harness } = require('./helpers/app');++++const { test, run } = harness('subject-assertion');++++/** The claims of an assertion (the stub signs one; the signature is not what this test is about). */++const claimsOf = (assertion) => JSON.parse(Buffer.from(String(assertion).split('.')[1], 'base64url').toString('utf8'));++++(async () => {++ const t = await boot();++ const creator = t.network.newUser('lena', { role: 'streamer' });++ const buyer = t.network.newUser('milo');++ const plan = (await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Front row', publish: true } })).json.plan;++ t.billing.state.credit.set(buyer.subject, 10000); // the member has Vibes to spend++++ test('a checkout for a person carries the person\'s own assertion', async () => {++ const r = await t.call('POST', '/api/v1/checkout', { user: buyer, body: { plan_id: plan.id, provider: 'credit' } });++ assert.strictEqual(r.status, 201, r.text);++ const c = t.billing.calls.filter((x) => x.path === '/api/v1/subscriptions').pop();++ assert.ok(c, 'the checkout reached Billing');++ assert.ok(c.assertion, 'the call carried an assertion');++ const claims = claimsOf(c.assertion);++ assert.strictEqual(claims.sub, buyer.subject, 'the assertion names the member');++ assert.deepStrictEqual(claims.aud, ['openvibe.billing']);++ assert.strictEqual(claims.act, 'svc:vip', 'VIP is the service it was minted for');++ });++++ test('a service that checks out for a member carries no assertion (it has no member token)', async () => {++ const other = t.network.newUser('nora');++ t.billing.state.credit.set(other.subject, 10000);++ const before = t.billing.calls.length;++ const r = await t.call('POST', '/api/v1/checkout', {++ cap: ['vip.membership.checkout'], sub: 'svc:live',++ body: { plan_id: plan.id, provider: 'credit', subject: { type: 'user', id: other.subject } },++ });++ assert.strictEqual(r.status, 201, r.text);++ const c = t.billing.calls.slice(before).filter((x) => x.path === '/api/v1/subscriptions').pop();++ assert.ok(c);++ assert.strictEqual(c.assertion, null, 'no assertion without the member\'s own token');++ assert.strictEqual(c.body.subscriber.id, other.subject, 'the member is still named in the body');++ });++++ test('two checkouts get two assertions (a replayed one is refused)', async () => {++ const other = t.network.newUser('oscar');++ const before = t.billing.calls.length;++ await t.call('POST', '/api/v1/checkout', { user: other, body: { plan_id: plan.id, provider: 'stripe' } });++ await t.call('POST', '/api/v1/checkout', { user: other, body: { plan_id: plan.id, provider: 'stripe' } });++ const c = t.billing.calls.slice(before).filter((x) => x.path === '/api/v1/intents');++ assert.strictEqual(c.length, 2);++ assert.ok(c[0].assertion && c[1].assertion);++ assert.notStrictEqual(c[0].assertion, c[1].assertion, 'each call has its own assertion');++ });++++ run();++})().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 -->
@@ -4,8 +4,8 @@
@@ -4,8 +4,8 @@
"code": true,
"code": true,
"deployed": true,
"deployed": true,
"deployment": "Launched 2026-10-17 at https://openvibe.vip (openvibe-contracts 0.200.0 made VIP live; vhost deploy/nginx/openvibe.vip.conf installed): deployed with `sudo ovhost deploy vip`, /opt/openvibe.vip, unit openvibe-vip, 127.0.0.1:4620 behind nginx. Principal vip; three Events subscriptions for Billing's events. Checkout open since the Billing cutover (VIP_CHECKOUT_PROVIDERS=powerchat,credit): 43 plans imported from Live, 0 memberships sold on launch day.",
"deployment": "Launched 2026-10-17 at https://openvibe.vip (openvibe-contracts 0.200.0 made VIP live; vhost deploy/nginx/openvibe.vip.conf installed): deployed with `sudo ovhost deploy vip`, /opt/openvibe.vip, unit openvibe-vip, 127.0.0.1:4620 behind nginx. Principal vip; three Events subscriptions for Billing's events. Checkout open since the Billing cutover (VIP_CHECKOUT_PROVIDERS=powerchat,credit): 43 plans imported from Live, 0 memberships sold on launch day.",
- "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",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"roadmap": "Wave 10",
"roadmap": "Wave 10",
@@ -14,8 +14,8 @@
@@ -14,8 +14,8 @@
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.5.0",
"multer": "^2.5.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"
},
},
@@ -1154,9 +1154,9 @@
@@ -1154,9 +1154,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",
@@ -1170,9 +1170,9 @@
@@ -1170,9 +1170,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"
@@ -31,8 +31,8 @@
@@ -31,8 +31,8 @@
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.5.0",
"multer": "^2.5.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"
},
},
@@ -32,8 +32,11 @@ const CAP = {
@@ -32,8 +32,11 @@ const CAP = {
postModerate: 'vip.post.moderate',
postModerate: 'vip.post.moderate',
};
};
-function v1Router({ domain, apiAuth, limits }) {
+function v1Router({ domain, apiAuth, limits, assertionFor }) {
const r = express.Router();
const r = express.Router();
+ // The person's own subject assertion (ADR-003, the 2026-11-06 amendment; plan H1). Without the
+ // minter (an old caller) no assertion is minted and every call travels the old way.
+ const mint = typeof assertionFor === 'function' ? assertionFor : async () => null;
const { creators, plans, perks, memberships, entitlements, policies, checkout, billing } = domain;
const { creators, plans, perks, memberships, entitlements, policies, checkout, billing } = domain;
// Per-actor limits (api/actor-limits.js), after the credential and before any work: reads take the
// Per-actor limits (api/actor-limits.js), after the credential and before any work: reads take the
@@ -235,6 +238,9 @@ function v1Router({ domain, apiAuth, limits }) {
@@ -235,6 +238,9 @@ function v1Router({ domain, apiAuth, limits }) {
member, planId: b.plan_id, provider: b.provider,
member, planId: b.plan_id, provider: b.provider,
successUrl: returnUrl(req, b.success_url, 'success_url'), cancelUrl: returnUrl(req, b.cancel_url, 'cancel_url'),
successUrl: returnUrl(req, b.success_url, 'success_url'), cancelUrl: returnUrl(req, b.cancel_url, 'cancel_url'),
autoRenew: b.auto_renew === undefined ? true : bool(b.auto_renew), traceparent: trace(req),
autoRenew: b.auto_renew === undefined ? true : bool(b.auto_renew), traceparent: trace(req),
+ // The person's own subject assertion (ADR-003, the 2026-11-06 amendment; plan H1): Billing
+ // checks it against the member the money is for.
+ assertion: await mint(req.headers, member),
});
});
res.status(201).json(out);
res.status(201).json(out);
}));
}));
@@ -246,7 +252,7 @@ function v1Router({ domain, apiAuth, limits }) {
@@ -246,7 +252,7 @@ function v1Router({ domain, apiAuth, limits }) {
const list = [];
const list = [];
for (const m of await memberships.forMember(member)) {
for (const m of await memberships.forMember(member)) {
const c = await creators.byId(m.creator_id);
const c = await creators.byId(m.creator_id);
- const ent = c && c.subject ? await entitlements.check(member, c.subject, { mode, traceparent: trace(req) }) : null;
+ const ent = c && c.subject ? await entitlements.check(member, c.subject, { mode, traceparent: trace(req), assertion: await mint(req.headers, member) }) : null;
list.push({ ...await memberships.present(m), creator: creators.present(c), entitlement: ent, preferences: await memberships.preferences(member, m.creator_id) });
list.push({ ...await memberships.present(m), creator: creators.present(c), entitlement: ent, preferences: await memberships.preferences(member, m.creator_id) });
}
}
res.json({ member: { type: 'user', id: member }, memberships: list });
res.json({ member: { type: 'user', id: member }, memberships: list });
@@ -260,7 +266,7 @@ function v1Router({ domain, apiAuth, limits }) {
@@ -260,7 +266,7 @@ function v1Router({ domain, apiAuth, limits }) {
r.post('/memberships/:creator/cancel', limits('vip.membership.cancel', { minute: 5, hour: 30 }), wrap(async (req, res) => {
r.post('/memberships/:creator/cancel', limits('vip.membership.cancel', { minute: 5, hour: 30 }), wrap(async (req, res) => {
if (req.principal.kind !== 'user') fail(403, 'vip.member_only', 'only the member can cancel their membership');
if (req.principal.kind !== 'user') fail(403, 'vip.member_only', 'only the member can cancel their membership');
const c = await creatorParam(req);
const c = await creatorParam(req);
- res.json(await checkout.cancel({ member: req.principal.subject, creatorId: c.id, traceparent: trace(req) }));
+ res.json(await checkout.cancel({ member: req.principal.subject, creatorId: c.id, traceparent: trace(req), assertion: await mint(req.headers, req.principal.subject) }));
}));
}));
r.put('/memberships/:creator/preferences', edit, wrap(async (req, res) => {
r.put('/memberships/:creator/preferences', edit, wrap(async (req, res) => {
if (req.principal.kind !== 'user') fail(403, 'vip.member_only', 'only the member sets their preferences');
if (req.principal.kind !== 'user') fail(403, 'vip.member_only', 'only the member sets their preferences');
@@ -282,7 +288,7 @@ function v1Router({ domain, apiAuth, limits }) {
@@ -282,7 +288,7 @@ function v1Router({ domain, apiAuth, limits }) {
creatorSubject = c.subject;
creatorSubject = c.subject;
}
}
const mode = ['projection', 'authoritative'].includes(src.mode) ? src.mode : 'auto';
const mode = ['projection', 'authoritative'].includes(src.mode) ? src.mode : 'auto';
- const out = await entitlements.check(member, creatorSubject, { mode, traceparent: trace(req) });
+ const out = await entitlements.check(member, creatorSubject, { mode, traceparent: trace(req), assertion: await mint(req.headers, member) });
// `product`: what this membership means in one product — the perks of the member's plan version
// `product`: what this membership means in one product — the perks of the member's plan version
// with that product's bindings (a chat badge, a gated post, …) and the member's badge preference.
// with that product's bindings (a chat badge, a gated post, …) and the member's badge preference.
if (src.product != null && src.product !== '') {
if (src.product != null && src.product !== '') {
@@ -140,7 +140,9 @@ async function createApp(opts = {}) {
@@ -140,7 +140,9 @@ async 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, now: opts.limitsNow || now, registry: metrics.registry, log, valkey });
const limits = createActorLimits({ config, now: opts.limitsNow || now, registry: metrics.registry, log, valkey });
- app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits }));
+ // The person's own subject assertion on the money routes (ADR-003, the 2026-11-06 amendment; plan H1).
+ const assertionFor = require('./subject-assertion').createSubjectAssertionMinter(config);
+ app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits, assertionFor }));
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: 'vip', service: 'vip', host: 'openvibe.vip', name: 'OpenVibe.VIP', profile: 'ugc' })); }
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'vip', service: 'vip', host: 'openvibe.vip', name: 'OpenVibe.VIP', profile: 'ugc' })); }
@@ -20,6 +20,7 @@
@@ -20,6 +20,7 @@
* it means "unknown", never "yes").
* it means "unknown", never "yes").
*/
*/
const { createCommerceClient, CommerceError } = require('openvibe-sdk/commerce');
const { createCommerceClient, CommerceError } = require('openvibe-sdk/commerce');
+const { assertionHeader } = require('./subject-assertion');
const BillingCallError = CommerceError;
const BillingCallError = CommerceError;
@@ -39,20 +40,23 @@ function createBillingClient(config, { fetchImpl = globalThis.fetch, tokenClient
@@ -39,20 +40,23 @@ function createBillingClient(config, { fetchImpl = globalThis.fetch, tokenClient
return {
return {
/** { intent, checkout_url } — a subscription checkout for `subject` to `creator`. */
/** { intent, checkout_url } — a subscription checkout for `subject` to `creator`. */
- createIntent: async ({ provider, subject, creator, tier, autoRenew, successUrl, cancelUrl, key, traceparent }) => await commerce.createIntent({
+ createIntent: async ({ provider, subject, creator, tier, autoRenew, successUrl, cancelUrl, key, traceparent, assertion }) => await commerce.createIntent({
provider, kind: 'subscription', subject, creator, tier, autoRenew, successUrl, cancelUrl, key, traceparent,
provider, kind: 'subscription', subject, creator, tier, autoRenew, successUrl, cancelUrl, key, traceparent,
+ ...(assertion ? { headers: assertionHeader(assertion) } : {}),
}),
}),
/** { subscription, entitlement, transaction } — one period paid from the member's credit, at the tier's price. */
/** { subscription, entitlement, transaction } — one period paid from the member's credit, at the tier's price. */
- subscribeWithCredit: async ({ subscriber, creator, tier, autoRenew, key, traceparent }) => await commerce.subscribeWithCredit({ subscriber, creator, tier, autoRenew, key, traceparent }),
+ subscribeWithCredit: async ({ subscriber, creator, tier, autoRenew, key, traceparent, assertion }) => await commerce.subscribeWithCredit({
+ subscriber, creator, tier, autoRenew, key, traceparent, ...(assertion ? { headers: assertionHeader(assertion) } : {}),
+ }),
/** A creator's tier prices on Billing: { tiers: [{ tier, price_cents, status }], default_price_cents, min/max_price_cents, max_tiers }. */
/** A creator's tier prices on Billing: { tiers: [{ tier, price_cents, status }], default_price_cents, min/max_price_cents, max_tiers }. */
listTiers: async (creator) => await commerce.listTiers(creator),
listTiers: async (creator) => await commerce.listTiers(creator),
/** { tier } — set one tier's monthly price (billing.tier.manage). */
/** { tier } — set one tier's monthly price (billing.tier.manage). */
setTier: async ({ creator, tier, priceCents, status, key, traceparent }) => await commerce.setTier({ creator, tier, priceCents, status, key, traceparent }),
setTier: async ({ creator, tier, priceCents, status, key, traceparent }) => await commerce.setTier({ creator, tier, priceCents, status, key, traceparent }),
- cancelSubscription: async ({ id, key, traceparent }) => await commerce.cancelSubscription({ id, key, traceparent }),
- getSubscription: async (id) => await commerce.getSubscription(id),
- listSubscriptions: async ({ streamer, subscriber, status } = {}) => await commerce.listSubscriptions({ streamer, subscriber, status }),
+ cancelSubscription: async ({ id, key, traceparent, assertion }) => await commerce.cancelSubscription({ id, key, traceparent, ...(assertion ? { headers: assertionHeader(assertion) } : {}) }),
+ getSubscription: async (id, { assertion } = {}) => await commerce.getSubscription(id, { ...(assertion ? { headers: assertionHeader(assertion) } : {}) }),
+ listSubscriptions: async ({ streamer, subscriber, status } = {}, { assertion } = {}) => await commerce.listSubscriptions({ streamer, subscriber, status }, { ...(assertion ? { headers: assertionHeader(assertion) } : {}) }),
/** { active, expires_at, subscription } — the authoritative answer. */
/** { active, expires_at, subscription } — the authoritative answer. */
- entitlement: async (subject, creator) => await commerce.entitlement(subject, creator),
+ entitlement: async (subject, creator, { assertion } = {}) => await commerce.entitlement(subject, creator, { ...(assertion ? { headers: assertionHeader(assertion) } : {}) }),
rates: async () => await commerce.rates(),
rates: async () => await commerce.rates(),
baseUrl: () => commerce.baseUrl(),
baseUrl: () => commerce.baseUrl(),
};
};
@@ -33,7 +33,7 @@ function billingProblem(err) {
@@ -33,7 +33,7 @@ function billingProblem(err) {
function createCheckout({ db, now, config, billing, creators, plans, entitlements, periods }) {
function createCheckout({ db, now, config, billing, creators, plans, entitlements, periods }) {
const byId = async (id) => await db.prepare('SELECT * FROM vip_checkouts WHERE id = ?').get(id) || null;
const byId = async (id) => await db.prepare('SELECT * FROM vip_checkouts WHERE id = ?').get(id) || null;
- async function start({ member, planId, provider, successUrl, cancelUrl, autoRenew = true, traceparent }) {
+ async function start({ member, planId, provider, successUrl, cancelUrl, autoRenew = true, traceparent, assertion }) {
const plan = await plans.byId(String(planId || ''));
const plan = await plans.byId(String(planId || ''));
if (!plan || plan.status !== 'published') fail(404, 'vip.plan_not_found', 'no published plan with that id');
if (!plan || plan.status !== 'published') fail(404, 'vip.plan_not_found', 'no published plan with that id');
if (!plan.billing_kind) fail(409, 'vip.checkout.unavailable', 'this plan is not sold through Billing yet');
if (!plan.billing_kind) fail(409, 'vip.checkout.unavailable', 'this plan is not sold through Billing yet');
@@ -45,7 +45,7 @@ function createCheckout({ db, now, config, billing, creators, plans, entitlement
@@ -45,7 +45,7 @@ function createCheckout({ db, now, config, billing, creators, plans, entitlement
// Sensitive: ask Billing, not the projection. An active member renews through Billing, not here.
// Sensitive: ask Billing, not the projection. An active member renews through Billing, not here.
let current;
let current;
- try { current = await entitlements.authoritative(member, creator.subject, { traceparent }); } catch (e) { billingProblem(e); }
+ try { current = await entitlements.authoritative(member, creator.subject, { traceparent, assertion }); } catch (e) { billingProblem(e); }
if (current && current.active && current.expires_at && Date.parse(current.expires_at) > now()) {
if (current && current.active && current.expires_at && Date.parse(current.expires_at) > now()) {
fail(409, 'vip.already_member', 'you are already a member; your membership renews through Billing');
fail(409, 'vip.already_member', 'you are already a member; your membership renews through Billing');
}
}
@@ -62,7 +62,7 @@ function createCheckout({ db, now, config, billing, creators, plans, entitlement
@@ -62,7 +62,7 @@ function createCheckout({ db, now, config, billing, creators, plans, entitlement
if (p === 'credit') {
if (p === 'credit') {
let out;
let out;
try {
try {
- out = await billing.subscribeWithCredit({ subscriber: member, creator: creator.subject, tier: Number(plan.billing_tier) || 1, autoRenew, key: `vip:checkout:${id}`, traceparent });
+ out = await billing.subscribeWithCredit({ subscriber: member, creator: creator.subject, tier: Number(plan.billing_tier) || 1, autoRenew, key: `vip:checkout:${id}`, traceparent, assertion });
} catch (e) { await mark({ status: 'failed', error: String(e.code || e.message).slice(0, 200) }); billingProblem(e); }
} catch (e) { await mark({ status: 'failed', error: String(e.code || e.message).slice(0, 200) }); billingProblem(e); }
const sub = out.subscription || {};
const sub = out.subscription || {};
const ent = out.entitlement || {};
const ent = out.entitlement || {};
@@ -91,7 +91,7 @@ function createCheckout({ db, now, config, billing, creators, plans, entitlement
@@ -91,7 +91,7 @@ function createCheckout({ db, now, config, billing, creators, plans, entitlement
provider: p, subject: member, creator: creator.subject, tier: Number(plan.billing_tier) || 1, autoRenew,
provider: p, subject: member, creator: creator.subject, tier: Number(plan.billing_tier) || 1, autoRenew,
successUrl: successUrl || `${config.baseUrl}/me?joined=${encodeURIComponent(creator.username || '')}`,
successUrl: successUrl || `${config.baseUrl}/me?joined=${encodeURIComponent(creator.username || '')}`,
cancelUrl: cancelUrl || `${config.baseUrl}/${encodeURIComponent(creator.username || '')}`,
cancelUrl: cancelUrl || `${config.baseUrl}/${encodeURIComponent(creator.username || '')}`,
- key: `vip:checkout:${id}`, traceparent,
+ key: `vip:checkout:${id}`, traceparent, assertion,
});
});
} catch (e) { await mark({ status: 'failed', error: String(e.code || e.message).slice(0, 200) }); billingProblem(e); }
} catch (e) { await mark({ status: 'failed', error: String(e.code || e.message).slice(0, 200) }); billingProblem(e); }
const intent = out.intent || {};
const intent = out.intent || {};
@@ -106,22 +106,22 @@ function createCheckout({ db, now, config, billing, creators, plans, entitlement
@@ -106,22 +106,22 @@ function createCheckout({ db, now, config, billing, creators, plans, entitlement
}
}
/** Cancel at period end, through Billing. Only the member themself. */
/** Cancel at period end, through Billing. Only the member themself. */
- async function cancel({ member, creatorId, traceparent }) {
+ async function cancel({ member, creatorId, traceparent, assertion }) {
const creator = await creators.byId(creatorId);
const creator = await creators.byId(creatorId);
if (!creator || !creator.subject) fail(404, 'vip.creator_not_found', `no creator ${creatorId}`);
if (!creator || !creator.subject) fail(404, 'vip.creator_not_found', `no creator ${creatorId}`);
let row;
let row;
- try { row = await entitlements.authoritative(member, creator.subject, { traceparent }); } catch (e) { billingProblem(e); }
+ try { row = await entitlements.authoritative(member, creator.subject, { traceparent, assertion }); } catch (e) { billingProblem(e); }
if (!row || !row.active || !row.subscription_id) fail(404, 'vip.membership_not_found', 'there is no active membership to cancel');
if (!row || !row.active || !row.subscription_id) fail(404, 'vip.membership_not_found', 'there is no active membership to cancel');
if (row.cancel_at_period_end) return { already: true, expires_at: row.expires_at };
if (row.cancel_at_period_end) return { already: true, expires_at: row.expires_at };
let sub;
let sub;
try {
try {
- const s = await billing.getSubscription(row.subscription_id);
+ const s = await billing.getSubscription(row.subscription_id, { assertion });
sub = s.subscription;
sub = s.subscription;
} catch (e) { billingProblem(e); }
} catch (e) { billingProblem(e); }
if (!sub || !sub.subscriber || sub.subscriber.id !== member) fail(403, 'vip.not_yours', 'that membership is not yours');
if (!sub || !sub.subscriber || sub.subscriber.id !== member) fail(403, 'vip.not_yours', 'that membership is not yours');
let out;
let out;
try {
try {
- out = await billing.cancelSubscription({ id: sub.id, key: `vip:cancel:${sub.id}:${sub.current_period_end || 'open'}`, traceparent });
+ out = await billing.cancelSubscription({ id: sub.id, key: `vip:cancel:${sub.id}:${sub.current_period_end || 'open'}`, traceparent, assertion });
} catch (e) { billingProblem(e); }
} catch (e) { billingProblem(e); }
const s = (out && out.subscription) || {};
const s = (out && out.subscription) || {};
await entitlements.applyCanceled({
await entitlements.applyCanceled({
@@ -152,8 +152,8 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
@@ -152,8 +152,8 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
};
};
/** Ask Billing and project the answer. Throws BillingCallError when Billing cannot answer. */
/** Ask Billing and project the answer. Throws BillingCallError when Billing cannot answer. */
- async function authoritative(member, creator, { traceparent } = {}) {
- const e = await billing.entitlement(member, creator);
+ async function authoritative(member, creator, { traceparent, assertion } = {}) {
+ const e = await billing.entitlement(member, creator, { assertion });
const sub = e && e.subscription;
const sub = e && e.subscription;
const out = await apply({
const out = await apply({
member, creator, active: !!(e && e.active), expiresAt: (e && e.expires_at) || null,
member, creator, active: !!(e && e.active), expiresAt: (e && e.expires_at) || null,
@@ -167,7 +167,7 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
@@ -167,7 +167,7 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
* check(member, creator, { mode: 'auto' | 'projection' | 'authoritative' }) → answer.
* check(member, creator, { mode: 'auto' | 'projection' | 'authoritative' }) → answer.
* `active` is true only for status 'active'; 'unknown' never authorizes.
* `active` is true only for status 'active'; 'unknown' never authorizes.
*/
*/
- async function check(member, creator, { mode = 'auto', traceparent } = {}) {
+ async function check(member, creator, { mode = 'auto', traceparent, assertion } = {}) {
if (member === creator) return { ...unknown(member, creator, 'self'), status: 'inactive', reason: 'a creator is not a member of their own plans' };
if (member === creator) return { ...unknown(member, creator, 'self'), status: 'inactive', reason: 'a creator is not a member of their own plans' };
const t = now();
const t = now();
let row = await getRow(member, creator);
let row = await getRow(member, creator);
@@ -181,7 +181,7 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
@@ -181,7 +181,7 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
}
}
if (mode === 'auto' && fresh) return await answer(row, 'projection', { membership: await membershipOf(member, creator) });
if (mode === 'auto' && fresh) return await answer(row, 'projection', { membership: await membershipOf(member, creator) });
try {
try {
- row = await authoritative(member, creator, { traceparent });
+ row = await authoritative(member, creator, { traceparent, assertion });
return await answer(row, 'billing', { membership: await membershipOf(member, creator) });
return await answer(row, 'billing', { membership: await membershipOf(member, creator) });
} catch (err) {
} catch (err) {
log.warn(`[VIP] authoritative entitlement check failed (${member} → ${creator}): ${err.message}`);
log.warn(`[VIP] authoritative entitlement check failed (${member} → ${creator}): ${err.message}`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
+'use strict';
+
+/**
+ * The person's own subject assertion for VIP's money calls (ADR-003, the 2026-11-06 amendment; plan H1).
+ *
+ * VIP names the member it acts for and Billing believes it. On a money route (a membership bought
+ * from the member's Vibes, a cancelled membership) that is only VIP's word: a compromised VIP could
+ * spend anyone's credit. A subject assertion is the person's own answer: Network signs their access
+ * token into a 60-second proof that names them, and Billing checks that proof instead of VIP's word.
+ *
+ * VIP holds the person's Network access token on the request (the Authorization header or the session
+ * cookie `ov_token`), so it can ask Network to sign it. Without that token there is nothing to sign
+ * and no assertion is minted: the call then travels the old way, which Billing's
+ * BILLING_REQUIRE_SUBJECT_ASSERTION flag decides on.
+ *
+ * The assertion is minted per call and never cached: Billing refuses a replayed one (it remembers the
+ * jti), so two money calls must not share it.
+ */
+const { createSubjectAssertionMinter: createSubjectAssertionMinterSdk } = require('openvibe-sdk/assertions');
+
+const NETWORK_INTERNAL_URL = (process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '');
+const CLIENT_ID = process.env.OV_OAUTH_CLIENT_ID || 'vip';
+const CLIENT_SECRET = process.env.OV_OAUTH_CLIENT_SECRET || '';
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+/** The person's own Network access token on this request, or null. */
+function personToken(headers) {
+ const h = headers || {};
+ const auth = String(h.authorization || '');
+ if (/^Bearer\s+/i.test(auth)) {
+ const token = auth.slice(7).trim();
+ if (token) return token;
+ }
+ for (const part of String(h.cookie || '').split(';')) {
+ const idx = part.indexOf('=');
+ if (idx < 0) continue;
+ if (part.slice(0, idx).trim() !== 'ov_token') continue;
+ const value = decodeURIComponent(part.slice(idx + 1).trim());
+ if (value) return value;
+ }
+ return null;
+}
+
+/**
+ * The minter, from the config the app loaded (the service token it presents to Network comes from
+ * VIP's own OAuth client).
+ */
+function createSubjectAssertionMinter(config) {
+ const c = config || {};
+ const network = c.network || {};
+ const oauth = c.oauth || {};
+ const minter = createSubjectAssertionMinterSdk({
+ internalUrl: network.internalUrl || NETWORK_INTERNAL_URL,
+ oauth: { clientId: oauth.clientId || CLIENT_ID, clientSecret: oauth.clientSecret || CLIENT_SECRET },
+ });
+ /**
+ * The assertion for the member this request acts for, or null when it cannot be had (no session,
+ * no grant, Network down). It never throws: a money call must not fail because a proof is missing.
+ */
+ return async function assertionFor(headers, subject) {
+ if (!subject || !SUBJECT_RE.test(String(subject))) return null;
+ const userToken = personToken(headers);
+ if (!userToken) return null;
+ return await minter.mint({ userToken, subject, audience: 'openvibe.billing' });
+ };
+}
+
+/** The header the assertion rides on. */
+const assertionHeader = (assertion) => (assertion ? { 'X-OV-Subject-Assertion': String(assertion) } : {});
+
+module.exports = { createSubjectAssertionMinter, personToken, assertionHeader, SUBJECT_RE };
@@ -67,6 +67,27 @@ async function startNetwork() {
@@ -67,6 +67,27 @@ async function startNetwork() {
const cap = String(body.scope || '').split(/\s+/).filter(Boolean);
const cap = String(body.scope || '').split(/\s+/).filter(Boolean);
return send(res, 200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience], cap }), token_type: 'Bearer', expires_in: 300 });
return send(res, 200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience], cap }), token_type: 'Bearer', expires_in: 300 });
}
}
+ if (req.url === '/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 now = Math.floor(Date.now() / 1000);
+ const b64 = (b) => Buffer.from(b).toString('base64url');
+ const header = b64(JSON.stringify({ alg: 'ES256', typ: 'JWT', kid: 'ov-network-assertion-1' }));
+ const payload = b64(JSON.stringify({ iss: issuer, sub: subject, aud: audience, act: v.claims.sub, for: forServices, jti, iat: now, exp: now + 60 }));
+ return send(res, 200, { assertion: `${header}.${payload}.${b64('signed')}`, subject, audience: body.audience, act: v.claims.sub, for: forServices, jti, expires_in: 60 });
+ }
send(res, 404, { error: 'not found' });
send(res, 404, { error: 'not found' });
});
});
const url = await listen(server);
const url = await listen(server);
@@ -190,7 +211,7 @@ async function startBilling(network, clock) {
@@ -190,7 +211,7 @@ async function startBilling(network, clock) {
const raw = await readBody(req);
const raw = await readBody(req);
const u = new URL(req.url, 'http://x');
const u = new URL(req.url, 'http://x');
const p = u.pathname;
const p = u.pathname;
- calls.push({ method: req.method, path: p, query: Object.fromEntries(u.searchParams), key: req.headers['idempotency-key'] || null, body: raw ? JSON.parse(raw) : null });
+ calls.push({ method: req.method, path: p, query: Object.fromEntries(u.searchParams), key: req.headers['idempotency-key'] || null, body: raw ? JSON.parse(raw) : null, assertion: req.headers['x-ov-subject-assertion'] || null });
if (p === '/api/health') return state.down ? send(res, 503, { ok: false }) : send(res, 200, { ok: true });
if (p === '/api/health') return state.down ? send(res, 503, { ok: false }) : send(res, 200, { ok: true });
if (state.down) { req.socket.destroy(); return undefined; }
if (state.down) { req.socket.destroy(); return undefined; }
const need = (cap) => { const e = auth(req, cap); if (e) { send(res, e[0], e[1]); return false; } return true; };
const need = (cap) => { const e = auth(req, cap); if (e) { send(res, e[0], e[1]); return false; } return true; };
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -0,0 +1,65 @@
+'use strict';
+/**
+ * The person's own subject assertion on VIP's money calls (ADR-003, the 2026-11-06 amendment; plan H1).
+ *
+ * VIP names the member it acts for and Billing believes it. On a money route (a membership bought
+ * from the member's Vibes, an entitlement read) that is only VIP's word, so VIP asks Network to sign
+ * the person's own token into a 60-second assertion and carries it on. Without the person's token
+ * there is nothing to sign, and the call travels the old way — which Billing's
+ * BILLING_REQUIRE_SUBJECT_ASSERTION flag decides on.
+ */
+const assert = require('assert');
+const { boot, harness } = require('./helpers/app');
+
+const { test, run } = harness('subject-assertion');
+
+/** The claims of an assertion (the stub signs one; the signature is not what this test is about). */
+const claimsOf = (assertion) => JSON.parse(Buffer.from(String(assertion).split('.')[1], 'base64url').toString('utf8'));
+
+(async () => {
+ const t = await boot();
+ const creator = t.network.newUser('lena', { role: 'streamer' });
+ const buyer = t.network.newUser('milo');
+ const plan = (await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Front row', publish: true } })).json.plan;
+ t.billing.state.credit.set(buyer.subject, 10000); // the member has Vibes to spend
+
+ test('a checkout for a person carries the person\'s own assertion', async () => {
+ const r = await t.call('POST', '/api/v1/checkout', { user: buyer, body: { plan_id: plan.id, provider: 'credit' } });
+ assert.strictEqual(r.status, 201, r.text);
+ const c = t.billing.calls.filter((x) => x.path === '/api/v1/subscriptions').pop();
+ assert.ok(c, 'the checkout reached Billing');
+ assert.ok(c.assertion, 'the call carried an assertion');
+ const claims = claimsOf(c.assertion);
+ assert.strictEqual(claims.sub, buyer.subject, 'the assertion names the member');
+ assert.deepStrictEqual(claims.aud, ['openvibe.billing']);
+ assert.strictEqual(claims.act, 'svc:vip', 'VIP is the service it was minted for');
+ });
+
+ test('a service that checks out for a member carries no assertion (it has no member token)', async () => {
+ const other = t.network.newUser('nora');
+ t.billing.state.credit.set(other.subject, 10000);
+ const before = t.billing.calls.length;
+ const r = await t.call('POST', '/api/v1/checkout', {
+ cap: ['vip.membership.checkout'], sub: 'svc:live',
+ body: { plan_id: plan.id, provider: 'credit', subject: { type: 'user', id: other.subject } },
+ });
+ assert.strictEqual(r.status, 201, r.text);
+ const c = t.billing.calls.slice(before).filter((x) => x.path === '/api/v1/subscriptions').pop();
+ assert.ok(c);
+ assert.strictEqual(c.assertion, null, 'no assertion without the member\'s own token');
+ assert.strictEqual(c.body.subscriber.id, other.subject, 'the member is still named in the body');
+ });
+
+ test('two checkouts get two assertions (a replayed one is refused)', async () => {
+ const other = t.network.newUser('oscar');
+ const before = t.billing.calls.length;
+ await t.call('POST', '/api/v1/checkout', { user: other, body: { plan_id: plan.id, provider: 'stripe' } });
+ await t.call('POST', '/api/v1/checkout', { user: other, body: { plan_id: plan.id, provider: 'stripe' } });
+ const c = t.billing.calls.slice(before).filter((x) => x.path === '/api/v1/intents');
+ assert.strictEqual(c.length, 2);
+ assert.ok(c[0].assertion && c[1].assertion);
+ assert.notStrictEqual(c[0].assertion, c[1].assertion, 'each call has its own assertion');
+ });
+
+ run();
+})().catch((err) => { console.error(err); process.exit(1); });