openvibers/openvibe.tips Commits

The tip carries the person's own subject assertion on to Billing (#195)

OP OpenVibers committed 8 days ago · ee46ab2800 · 12 files, +381 −31

* The tip carries the person's own assertion on to Billing

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

* The stub Network issues the audience's grants, as Network does

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

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

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

Changes

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