openvibers/openvibe.vip Commits

VIP's money calls carry the member's own subject assertion (#191)

OP OpenVibers committed 7 days ago · 3aa361b602 · 12 files, +207 −38

* VIP's money calls carry the member's own subject assertion

* The stub Network checks the mint token's audience

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

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

Changes

ddiff --git a/README.md b/README.md
iindex 1d05b73..78f320a 100644
---- a/README.md
++++ b/README.md
@@@ -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 -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 3750003..6f96c5b 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/package-lock.json b/package-lock.json
iindex 4ca8cd0..6fbe8b1 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex 3b6f4ca..877e1e7 100644
---- a/package.json
++++ b/package.json
@@@ -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"
},
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 1ce973f..1460d9d 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -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 !== '') {
ddiff --git a/server/app.js b/server/app.js
iindex faf552c..fb9cd78 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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' })); }
ddiff --git a/server/billing-client.js b/server/billing-client.js
iindex 0090660..7b656c0 100644
---- a/server/billing-client.js
++++ b/server/billing-client.js
@@@ -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, tokenClient
return {
/** { 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(),
};
ddiff --git a/server/domain/checkout.js b/server/domain/checkout.js
iindex 663ad98..7aeadcd 100644
---- a/server/domain/checkout.js
++++ b/server/domain/checkout.js
@@@ -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, entitlement
if (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, entitlement
provider: 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({
ddiff --git a/server/domain/entitlements.js b/server/domain/entitlements.js
iindex 31381bc..ae43d8d 100644
---- a/server/domain/entitlements.js
++++ b/server/domain/entitlements.js
@@@ -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}`);
ddiff --git a/server/subject-assertion.js b/server/subject-assertion.js
nnew file mode 100644
iindex 0000000..e7e9340
---- /dev/null
++++ b/server/subject-assertion.js
@@@ -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 };
ddiff --git a/test/helpers/stubs.js b/test/helpers/stubs.js
iindex c39c442..840dd63 100644
---- a/test/helpers/stubs.js
++++ b/test/helpers/stubs.js
@@@ -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; };
ddiff --git a/test/subject-assertion.test.js b/test/subject-assertion.test.js
nnew file mode 100644
iindex 0000000..51ff3f2
---- /dev/null
++++ b/test/subject-assertion.test.js
@@@ -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); });