openvibers/openvibe.network Commits
Subject assertions: Network signs the person's own answer (#584)
Changes
@@@ -95,7 +95,7 @@ person's username and picture (a service token, `identity.subject.resolve`).## Depends on--- `openvibe-contracts` v0.294.0 (registry manifests, capability checks), `openvibe-sdk` v0.82.0 (PostgreSQL++- `openvibe-contracts` v0.297.0 (registry manifests, capability checks), `openvibe-sdk` v0.90.0 (PostgreSQLand Valkey adapters, events, per-actor limits, universal telemetry), `openvibe-shared` v4.30.0, pinned by release tarball- OpenVibe.Events (Network's outbox relay; the events it consumes), OpenVibe.AI (the `network.site_copy`workflow), OpenVibe.Blog (the changelog feed it proxies)@@@ -1025,7 +1025,7 @@ Every change also sends the person a Network notification, so a swap nobody askeMIT (`license` in [package.json](package.json)).<!-- versions:start -->--- openvibe-contracts: v0.294.0--- openvibe-sdk: v0.82.0++- openvibe-contracts: v0.297.0++- openvibe-sdk: v0.90.0- openvibe-shared: v4.115.1<!-- versions:end -->@@@ -6,8 +6,8 @@"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9","port": 4000,"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",-- "contracts": "openvibe-contracts v0.294.0",-- "sdk": "openvibe-sdk v0.82.0",++ "contracts": "openvibe-contracts v0.297.0",++ "sdk": "openvibe-sdk v0.90.0","shared": "openvibe-shared v4.115.1","capabilities": ["identity.subject.resolve",@@@ -20,8 +20,8 @@"iovalkey": "^0.4.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.86.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","qrcode": "1.5.4",@@@ -1722,9 +1722,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.294.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0",-- "integrity": "sha512-CBDymrE4VrgslAAgCTCvecNrVcaevhBcIrb4nU4ApkJasst+6xt3RdR9lktsdHuhBeipdlKAfaxLAFmRadkKqA==",++ "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",@@@ -1738,9 +1738,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.86.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.86.0",-- "integrity": "sha512-APn4qK1ErX9f/gR6l2AhGfj+n8/3ul87Otm69xN4wyJUt43WF6A2b0lMKDG9Ds6oQiOoCZJCjxCSanP0wtMuGA==",++ "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"@@@ -24,8 +24,8 @@"iovalkey": "^0.4.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.86.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","qrcode": "1.5.4",@@@ -19,6 +19,11 @@ module.exports = {// openssl rsa -in data/keys/private.pem -pubout -out data/keys/public.pemprivateKeyPath: process.env.JWT_PRIVATE_KEY || 'data/keys/private.pem',publicKeyPath: process.env.JWT_PUBLIC_KEY || 'data/keys/public.pem',++ // The EC (P-256) key that signs the subject assertions (ADR-003, the 2026-11-06 amendment;++ // plan H1). It is published in the JWKS next to the RS256 key, so Billing and Tips can verify++ // an assertion offline. It is generated and kept on first boot when the files are not there.++ assertionPrivateKeyPath: process.env.JWT_ASSERTION_PRIVATE_KEY || 'data/keys/assertion-private.pem',++ assertionPublicKeyPath: process.env.JWT_ASSERTION_PUBLIC_KEY || 'data/keys/assertion-public.pem',accessTokenExpiry: '7d',refreshTokenExpiry: '30d',// issuer is the canonical public network URL — updated at runtime from registry@@@ -26,6 +31,13 @@ module.exports = {},++ // Subject assertions (ADR-003, the 2026-11-06 amendment; plan H1): the audiences Network signs an++ // assertion for. The money services by default; a deployment may narrow or widen the list.++ subjectAssertions: {++ audiences: String(process.env.NETWORK_SUBJECT_ASSERTION_AUDIENCES || 'openvibe.billing,openvibe.tips')++ .split(',').map((s) => s.trim()).filter(Boolean),++ },++// Sign in on another device (owner, 2026-11-04; server/auth/qr-signin.js): a QR on the source device, a// scan on the target and a two-digit match. Off unless NETWORK_QR_SIGNIN is explicitly on; off means the routes and /q are not mounted.qrSignin: {nnew file mode 100644@@@ -0,0 +1,237 @@++'use strict';++++/**++ * Subject assertions (ADR-003, amendment 2026-11-06; plan H1).++ *++ * A subject assertion is a short-lived, Network-signed proof that a person authorized the calling++ * service to act for them on ONE other service (the audience). It is what a money call carries++ * beside the name of the person:++ *++ * Authorization: Bearer <the service's own token> who is calling++ * X-OV-Subject: usr_… who the service says it acts for++ * X-OV-Subject-Assertion: <the assertion> that the person allowed it++ *++ * The assertion is minted here, and only here: POST /internal/subject-assertions takes the++ * person's OWN Network access token (the service has it from their session) and signs it into an++ * ES256 JWT that lives 60 seconds. Without the person's token nothing is signed: a service that++ * knows a person's id cannot say the person allowed anything.++ *++ * The key is Network's own EC (P-256) key, published in the JWKS next to the RS256 session key++ * (kid `ov-network-assertion-1`), so Billing and Tips can verify the signature offline.++ *++ * The claims (contract identity.subject-assertion-claims@1):++ *++ * iss the Network issuer++ * sub the person: usr_…++ * aud the service the assertion is for (openvibe.billing)++ * act the service it was minted for (svc:live)++ * jti unique per assertion, so a replay can be refused++ * iat, exp 60 seconds++ */++const crypto = require('node:crypto');++const fs = require('node:fs');++const path = require('node:path');++const jwt = require('jsonwebtoken');++const { http, serviceAuth } = require('openvibe-contracts');++++const KID = 'ov-network-assertion-1';++/** How long an assertion lives. */++const TTL_S = 60;++/** The audiences an assertion may be minted for: the money services (H1). */++const DEFAULT_AUDIENCES = ['openvibe.billing', 'openvibe.tips'];++const AUDIENCE_RE = /^openvibe\.[a-z0-9][a-z0-9.-]*$/;++const SERVICE_RE = /^svc:[a-z][a-z0-9-]{1,39}$/;++const USER_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++++/**++ * The EC key pair, loaded from disk or generated once and kept there.++ * `data/keys/assertion-private.pem` (0600) and `data/keys/assertion-public.pem`. An environment++ * pair wins, so a deployment may keep the key outside the repository.++ */++function loadAssertionKeys({ privateKeyPath, publicKeyPath, log = console } = {}) {++ const priv = privateKeyPath ? path.resolve(privateKeyPath) : null;++ const pub = publicKeyPath ? path.resolve(publicKeyPath) : null;++ let privateKey = null;++ let publicKey = null;++ try {++ if (priv) privateKey = fs.readFileSync(priv, 'utf8');++ } catch { /* not there yet */ }++ try {++ if (pub) publicKey = fs.readFileSync(pub, 'utf8');++ } catch { /* not there yet */ }++ if (privateKey && !publicKey) {++ // The private key is enough: the public half is derived from it.++ try { publicKey = crypto.createPublicKey(privateKey).export({ type: 'spki', format: 'pem' }).toString(); } catch { /* unreadable key */ }++ }++ if (!privateKey || !publicKey) {++ const pair = crypto.generateKeyPairSync('ec', {++ namedCurve: 'P-256',++ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },++ publicKeyEncoding: { type: 'spki', format: 'pem' },++ });++ privateKey = pair.privateKey;++ publicKey = pair.publicKey;++ // Keep it: an assertion signed before a restart must still verify after it.++ for (const [file, key] of [[priv, privateKey], [pub, publicKey]]) {++ if (!file) continue;++ try {++ fs.mkdirSync(path.dirname(file), { recursive: true });++ fs.writeFileSync(file, key, { mode: 0o600 });++ } catch (err) {++ (log && log.warn) ? log.warn(`[Assertions] the EC key could not be written to ${file}: ${err.message}; it will change on every restart`) : null;++ }++ }++ }++ return { privateKey, publicKey, kid: KID };++}++++/** The JWK of the assertion key, as the JWKS publishes it. */++function assertionJwk(publicKey) {++ const jwk = crypto.createPublicKey(publicKey).export({ format: 'jwk' });++ return { ...jwk, use: 'sig', alg: 'ES256', kid: KID };++}++++/**++ * Sign an assertion for a person.++ *++ * `act` is the service that vouched for the person (it held their token), `for` the services that++ * may carry the assertion (the minting service alone by default) and `audience` the service (or the++ * services, when the assertion travels a chain) the assertion is for.++ */++function signAssertion({ privateKey, subject, audience, act, for: forServices, issuer, ttlS = TTL_S, now = () => Date.now() }) {++ const iat = Math.floor(now() / 1000);++ const jti = `as_${crypto.randomBytes(12).toString('hex')}`;++ const token = jwt.sign(++ { sub: subject, act, ...(forServices && forServices.length ? { for: forServices } : {}) },++ privateKey,++ {++ algorithm: 'ES256', keyid: KID, issuer,++ audience: Array.isArray(audience) ? audience : [audience],++ expiresIn: ttlS, jwtid: jti, noTimestamp: false,++ },++ );++ return { assertion: token, jti, issued_at: iat, expires_at: iat + ttlS };++}++++/** A small in-process limiter: one service may not mint assertions without bound. */++function createLimiter({ perMinute = 120, now = () => Date.now() } = {}) {++ const hits = new Map(); // key -> { count, at }++ return function allow(key) {++ const t = now();++ const row = hits.get(key);++ if (!row || t - row.at > 60000) { hits.set(key, { count: 1, at: t }); return true; }++ row.count += 1;++ if (hits.size > 1000) { for (const [k, v] of hits) if (t - v.at > 60000) hits.delete(k); }++ return row.count <= perMinute;++ };++}++++/**++ * The route: POST /internal/subject-assertions.++ *++ * A first-party service (a service token, never an app or a mod) hands over the person's own++ * access token and the audience it needs an assertion for. Network checks that token is a live++ * session of a person, that the person is the one the caller says it acts for, and signs a++ * 60-second assertion naming the caller as `act`.++ */++function subjectAssertionRouter({ log = console } = {}) {++ const limit = createLimiter();++++ /** The caller's own service token: the route takes nothing else. */++ function requireService(req, res, next) {++ const auth = String(req.headers.authorization || '');++ if (!auth.startsWith('Bearer ')) {++ return http.sendProblem(res, 401, 'token.missing', { detail: 'a service token is required', ctx: req.ov });++ }++ const r = serviceAuth.verifyServiceToken(auth.slice(7).trim(), {++ publicKey: req.app.locals.publicKey,++ issuer: (req.app.locals.config || {}).jwt && req.app.locals.config.jwt.issuer,++ // The token must be one Network itself issued for its own API: a token minted for another++ // audience (a service talking to Billing, say) may not ask Network to sign anything.++ audience: 'openvibe.network',++ });++ if (!r.ok) return http.sendProblem(res, 401, r.code, { detail: r.reason, ctx: req.ov });++ // Only a first-party service may ask for an assertion. A developer app, a mod or an agent++ // never gets one: their own authorization (on_behalf_of) is what they act on.++ if (r.claims.actor_type !== 'service' || !/^svc:[a-z][a-z0-9-]{1,39}$/.test(String(r.claims.sub || ''))) {++ return http.sendProblem(res, 403, 'assertion.not_a_service', { detail: 'only a first-party service may ask for a subject assertion', ctx: req.ov });++ }++ req.principal = { sub: r.claims.sub, cap: r.claims.cap || [], jti: r.claims.jti };++ return next();++ }++++ async function handle(req, res) {++ try {++ const config = req.app.locals.config || {};++ const audiences_allowed = (config.subjectAssertions && Array.isArray(config.subjectAssertions.audiences) && config.subjectAssertions.audiences.length)++ ? config.subjectAssertions.audiences : DEFAULT_AUDIENCES;++ const body = req.body || {};++ const userToken = String(body.user_token || '').trim();++ // The audience is one service, or a list when the assertion travels a chain (a tip goes++ // from the site to Tips and on to Billing).++ const audiences = (Array.isArray(body.audience) ? body.audience : [body.audience]).map((a) => String(a || '').trim()).filter(Boolean);++ // The services that may carry it. The minting service is always one of them.++ const forServices = [...new Set([req.principal.sub, ...(Array.isArray(body.for) ? body.for : body.for ? [body.for] : []).map((f) => String(f || '').trim()).filter(Boolean)])];++ if (!userToken) return http.sendProblem(res, 401, 'assertion.no_user_token', { detail: "the person's own access token is required", ctx: req.ov });++ if (!audiences.length || audiences.some((a) => !AUDIENCE_RE.test(a))) {++ return http.sendProblem(res, 422, 'assertion.invalid_audience', { detail: 'the audience must be an openvibe.<service> id', ctx: req.ov });++ }++ if (audiences.some((a) => !audiences_allowed.includes(a))) {++ return http.sendProblem(res, 403, 'assertion.audience_not_allowed', { detail: `no assertion is minted for ${audiences.join(', ')}`, ctx: req.ov });++ }++ if (forServices.some((f) => !SERVICE_RE.test(f))) {++ return http.sendProblem(res, 422, 'assertion.invalid_for', { detail: 'the services that may carry the assertion are svc:<id>', ctx: req.ov });++ }++ if (!limit(req.principal.sub)) {++ return http.sendProblem(res, 429, 'assertion.rate_limited', { detail: 'too many assertions for this service', ctx: req.ov });++ }++++ // The person's own token: a live session, not a service or app token.++ const session = await require('../auth/session').verifySession(userToken, { db: req.app.locals.db, publicKey: req.app.locals.publicKey, config });++ if (session.error) {++ return http.sendProblem(res, session.status || 401, 'assertion.invalid_user_token', { detail: session.error, ctx: req.ov });++ }++ const subject = await require('./subjects').ensureUserSubject(req.app.locals.db, session.user);++ if (!subject || !USER_RE.test(String(subject))) {++ return http.sendProblem(res, 403, 'assertion.no_subject', { detail: 'that account has no canonical subject yet', ctx: req.ov });++ }++ // The caller may only assert for the person it says it acts for.++ const asked = body.subject ? String(body.subject).trim() : null;++ if (asked && asked !== subject) {++ return http.sendProblem(res, 403, 'assertion.subject_mismatch', { detail: 'that token names another person', ctx: req.ov });++ }++++ const assertionKeys = req.app.locals.assertionKeys || {};++ if (!assertionKeys.privateKey) {++ return http.sendProblem(res, 503, 'assertion.unavailable', { detail: 'the assertion key is not loaded yet', ctx: req.ov });++ }++ const out = signAssertion({++ privateKey: assertionKeys.privateKey,++ subject,++ audience: audiences.length === 1 ? audiences[0] : audiences,++ act: req.principal.sub,++ for: forServices,++ issuer: config.jwt.issuer,++ });++ res.set('Cache-Control', 'no-store');++ return res.json({++ assertion: out.assertion,++ subject,++ audience: audiences.length === 1 ? audiences[0] : audiences,++ act: req.principal.sub,++ for: forServices,++ jti: out.jti,++ expires_at: new Date(out.expires_at * 1000).toISOString(),++ expires_in: TTL_S,++ });++ } catch (err) {++ (log && log.error) ? log.error('[Assertions] mint failed:', err && err.stack ? err.stack : err) : null;++ return http.sendProblem(res, 500, 'internal.error', { detail: 'unexpected error', ctx: req.ov });++ }++ }++++ // The two middlewares: the caller's own service token, then the mint.++ return [requireService, handle];++}++++module.exports = { KID, TTL_S, DEFAULT_AUDIENCES, loadAssertionKeys, assertionJwk, signAssertion, subjectAssertionRouter, createLimiter };@@@ -371,8 +371,17 @@ try {console.warn('[Auth] Using ephemeral HS256 key — DO NOT use in production');}++// The EC key that signs the subject assertions (ADR-003, the 2026-11-06 amendment; plan H1). It is++// published in the JWKS beside the RS256 key, so Billing and Tips verify an assertion offline.++const assertionKeys = require('./identity/subject-assertions').loadAssertionKeys({++ privateKeyPath: config.jwt.assertionPrivateKeyPath,++ publicKeyPath: config.jwt.assertionPublicKeyPath,++});++console.log('[Auth] ES256 assertion keypair loaded (kid ' + assertionKeys.kid + ')');++// Make keys available to route modulesapp.locals.db = db;++app.locals.assertionKeys = assertionKeys;app.locals.privateKey = privateKey;// Network's own service tokens for its calls to Live, Media and Tools (identity/self-token.js).const selfToken = require('./identity/self-token').createSelfTokens({ privateKey, issuer: config.jwt.issuer });@@@ -635,12 +644,19 @@ app.get('/api/.well-known/jwks', (_req, res) => {// anyone sign sessions. Only a real public key is ever served.if (privateKey === publicKey || !String(publicKey).includes('BEGIN')) return res.json({ algorithm: 'HS256', keys: [] });const out = { public_key: publicKey, algorithm: 'RS256' };++ const keys = [];if (publicKey.includes('BEGIN')) {try {const jwk = require('crypto').createPublicKey(publicKey).export({ format: 'jwk' });-- out.keys = [{ ...jwk, use: 'sig', alg: 'RS256', kid: 'ov-network-1' }];++ keys.push({ ...jwk, use: 'sig', alg: 'RS256', kid: 'ov-network-1' });} catch { /* legacy shape still served */ }}++ // The subject-assertion key (ES256): Billing and Tips verify a subject assertion with it.++ // It is a public key, like the RS256 one, and it never signs anything else.++ try {++ keys.push(require('./identity/subject-assertions').assertionJwk(app.locals.assertionKeys.publicKey));++ } catch { /* the assertion key is not loaded */ }++ if (keys.length) out.keys = keys;res.json(out);});@@@ -55,6 +55,15 @@ router.post('/identity/resolve-batch', principals.guard('identity.subject.resolvrouter.post('/identity/legacy-map', principals.guard('identity.subject.resolve'), ownSourceSystem);router.use('/identity', require('../identity/internal-routes'));++// ── Subject assertions (ADR-003, the 2026-11-06 amendment; plan H1) ───────────────────++// A first-party service hands over the person's own access token and gets back a 60-second++// Network-signed assertion naming the person, the service it was minted for and the audience it is++// for. Without the person's token nothing is signed: a service that knows a person's id cannot say++// the person allowed anything (that is the H1 narrowing of "the service vouches for its person").++router.post('/subject-assertions',++ express.json({ limit: '16kb' }),++ ...require('../identity/subject-assertions').subjectAssertionRouter());++// ── Project secrets (ADR-056) ────────────────────────────────// A service the project granted a secret to signs with it (the value never leaves) or receives it for an origin the// grant lists. network.secret.use is the door; the secret's own grant decides, and anything refused is 404.@@@ -54,10 +54,14 @@ async function readJson(r) {}/** One request to Billing. The person's own bearer token unless `token` says otherwise. */--async function ask(url, { authorization, method = 'GET', body, timeoutMs = TIMEOUT_MS } = {}) {++async function ask(url, { authorization, method = 'GET', body, timeoutMs = TIMEOUT_MS, assertion } = {}) {const headers = { accept: 'application/json' };if (authorization) headers.authorization = authorization;if (body !== undefined) headers['content-type'] = 'application/json';++ // The person's own subject assertion (ADR-003, the 2026-11-06 amendment; plan H1): Billing checks it++ // against the person the read is for. Only a service-token read needs it; the person's own token is++ // the proof already.++ if (assertion) headers['x-ov-subject-assertion'] = String(assertion);const r = await fetch(url, {method, headers,...(body !== undefined ? { body: JSON.stringify(body) } : {}),@@@ -308,6 +312,34 @@ module.exports = function createWalletRoutes(db, requireAuth, opts = {}) {* marked TEMPORARY FALLBACK reads the journal with Network's service token. The fallback is not seeded as a* default grant: without an operator's explicit billing.balance.read grant it reads nothing.*/++ /**++ * The person's own subject assertion for a service-token read of their money (ADR-003, the 2026-11-06++ * amendment; plan H1). Network asks its own mint route for it: the person's Network token is on the++ * request, and without it nothing is signed (so a read for a person whose token is not here has no++ * assertion and travels the old way).++ */++ async function mintAssertion(req, subject) {++ if (!selfToken) return null;++ const auth = personToken(req);++ if (!auth) return null;++ const token = selfToken('openvibe.network', ['identity.subject.resolve']);++ if (!token) return null;++ const net = String(opts.networkInternalUrl || config.internalUrl || `http://127.0.0.1:${config.port || 4000}`).replace(/\/+$/, '');++ try {++ const res = await fetch(`${net}/internal/subject-assertions`, {++ method: 'POST',++ headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },++ body: JSON.stringify({ user_token: auth.replace(/^Bearer\s+/i, '').trim(), audience: 'openvibe.billing', subject }),++ signal: AbortSignal.timeout(2500),++ });++ if (!res.ok) return null;++ const out = await res.json().catch(() => null);++ return (out && out.assertion) || null;++ } catch {++ return null;++ }++ }++async function activityOf(req) {const subject = req.user && req.user.subject_id;if (!subject) return { rows: [], source: 'none' };@@@ -328,7 +360,10 @@ module.exports = function createWalletRoutes(db, requireAuth, opts = {}) {const token = selfToken(SERVICE_AUDIENCE, BALANCE_READ);if (!token) return { rows: [], source: 'none' };try {-- const out = await ask(`${billingUrl}/api/v1/transactions?subject=${encodeURIComponent(subject)}&limit=80`, { authorization: `Bearer ${token}` });++ // The person's own assertion: Network reads their journal with its service token, so it++ // shows Billing the person's own answer instead of Network's word (ADR-003, plan H1).++ const assertion = await mintAssertion(req, subject);++ const out = await ask(`${billingUrl}/api/v1/transactions?subject=${encodeURIComponent(subject)}&limit=80`, { authorization: `Bearer ${token}`, assertion });if (out.status !== 200 || !out.body) return { rows: [], source: 'none' };return { rows: mapActivity(subject, out.body.transactions), source: 'journal' };} catch {@@@ -41,12 +41,20 @@ withWorld('secrets-reads', async (w) => {const jwks = await w.call(null, 'GET', '/api/.well-known/jwks');assert.strictEqual(jwks.status, 200);-- assert.ok(jwks.body.public_key.includes('BEGIN PUBLIC KEY') && jwks.body.keys.length === 1);++ // Two keys are published: the RS256 session key and the ES256 key the subject assertions are++ // signed with (ADR-003, the 2026-11-06 amendment). Neither half of either private key is here.++ assert.ok(jwks.body.public_key.includes('BEGIN PUBLIC KEY') && jwks.body.keys.length === 2);for (const f of ['d', 'p', 'q', 'dp', 'dq', 'qi']) assert.ok(!(f in jwks.body.keys[0]), `JWKS has no private member ${f}`);const jwk = jwks.body.keys[0];assert.strictEqual(jwk.kty, 'RSA');assert.strictEqual(jwk.kid, 'ov-network-1', 'the kid /api/.well-known/jwks publishes');assert.strictEqual(jwk.n, crypto.createPublicKey(w.keys.publicKey).export({ format: 'jwk' }).n, 'the modulus is the configured key\'s');++ const assertionJwk = jwks.body.keys[1];++ assert.strictEqual(assertionJwk.kty, 'EC', 'the assertion key is the EC one');++ assert.strictEqual(assertionJwk.alg, 'ES256');++ assert.strictEqual(assertionJwk.kid, 'ov-network-assertion-1');++ assert.ok(assertionJwk.x && assertionJwk.y, 'the public halves are published');++ assert.ok(!('d' in assertionJwk), 'the private half of the assertion key is not published');assert.ok(!/PRIVATE/.test(jwks.text));await checkSecretsStored(w, needlesFor);}).then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });@@@ -121,7 +121,7 @@ const REVIEWED = {'server/domains/catalog.js fetch': [3, 'the tools catalog URL from operator config'],'server/today/live-follows.js fetch': [1, 'Live\'s internal URL from config (OV_LIVE_INTERNAL_URL): its public list of live streams, fixed path, 2.5 s timeout, held 10 s; never a request value'],'server/coins/routes.js fetch': [1, 'Billing internal URL from config (the person\'s Vibes with their own token, 2 s timeout)'],-- 'server/wallet/routes.js fetch': [1, 'Billing internal URL from config (OV_BILLING_INTERNAL_URL, loopback 4600) only: the person\'s own token for /api/me/activity and every /api/me read and write, and — only while /api/me/activity is missing — the marked temporary journal fallback, a billing.balance.read service read scoped to the signed-in person\'s own usr_ id; the paths are fixed, a charge id must be a vch_ ULID and is percent-encoded, and no request value ever names a host'],++ 'server/wallet/routes.js fetch': [2, 'Billing internal URL from config (OV_BILLING_INTERNAL_URL, loopback 4600) only: the person\'s own token for /api/me/activity and every /api/me read and write, and — only while /api/me/activity is missing — the marked temporary journal fallback, a billing.balance.read service read scoped to the signed-in person\'s own usr_ id; the paths are fixed, a charge id must be a vch_ ULID and is percent-encoded, and no request value ever names a host. The second call site is Network\'s own loopback (config.internalUrl, else 127.0.0.1:<port>): the fixed path POST /internal/subject-assertions, which signs the person\'s own token into a 60-second subject assertion; the person\'s token is in the body, never in the URL, and no request value names a host'],'server/frame/service.js fetch': [3, 'Live and AI internal URLs from config (navbar ranking copy); https://<open site host>/privacy, hosts from the site registry only, no redirects, 5 s (legal links)'],'server/stats/open.js fetch': [1, 'open stats (/stats): fixed first-party loopback URLs only — Live (OV_LIVE_INTERNAL_URL, its public home numbers and service-token analytics totals) and Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; the request only picks 7, 30 or 90 days'],'server/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],nnew file mode 100644@@@ -0,0 +1,145 @@++'use strict';++// Subject assertions (server/identity/subject-assertions.js): a first-party service signs the++// person's own access token into a 60-second assertion, and nothing is signed without it.++// ADR-003, the 2026-11-06 amendment; plan H1.++// node test/subject-assertions.test.js++const assert = require('assert');++const crypto = require('crypto');++const http = require('http');++const express = require('express');++const jwt = require('jsonwebtoken');++const { serviceAuth } = require('openvibe-contracts');++const { getDb } = require('../server/db/database');++const assertions = require('../server/identity/subject-assertions');++++(async () => {++const db = await getDb();++const ISSUER = 'https://openvibe.network';++++await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (77, 'payer', 'x') ON CONFLICT (id) DO NOTHING").run();++const person = await db.prepare('SELECT id, subject_id FROM users WHERE id = 77').get();++person.subject_id = await require('../server/identity/subjects').ensureUserSubject(db, person);++assert.ok(person.subject_id, 'the person has a canonical subject');++++const rsa = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++const keys = assertions.loadAssertionKeys({ privateKeyPath: null, publicKeyPath: null, log: { warn: () => {} } });++++const app = express();++app.use(express.json());++app.locals.db = db;++app.locals.privateKey = rsa.privateKey;++app.locals.publicKey = rsa.publicKey;++app.locals.assertionKeys = keys;++app.locals.config = { jwt: { issuer: ISSUER }, subjectAssertions: { audiences: ['openvibe.billing', 'openvibe.tips'] } };++app.use('/internal', require('../server/internal/routes'));++const server = http.createServer(app);++await new Promise((r) => server.listen(0, '127.0.0.1', r));++const base = `http://127.0.0.1:${server.address().port}`;++++/** A service token (client_credentials, signed here) for the service `svc:<id>`. */++const serviceToken = (id, over = {}) => serviceAuth.signServiceToken({++ iss: ISSUER, sub: `svc:${id}`, actor_type: 'service', aud: ['openvibe.network'], cap: [], ns: [],++ iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}`, ...over,++}, rsa.privateKey);++/** The person's own access token (a Network session). */++const personToken = () => jwt.sign({ sub: '77' }, rsa.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });++++const post = (body, headers) => fetch(`${base}/internal/subject-assertions`, {++ method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(body),++}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));++++try {++ // ── The happy path ──++ const out = await post({ user_token: personToken(), audience: 'openvibe.billing' }, { authorization: `Bearer ${serviceToken('tips')}` });++ assert.strictEqual(out.status, 200, JSON.stringify(out.body));++ assert.ok(out.body.assertion, 'an assertion comes back');++ assert.strictEqual(out.body.subject, person.subject_id, 'it names the person');++ assert.strictEqual(out.body.audience, 'openvibe.billing');++ assert.strictEqual(out.body.act, 'svc:tips', 'it names the service it was minted for');++ assert.strictEqual(out.body.expires_in, 60);++++ // The assertion verifies with Network's published key and carries the H1 claims.++ const decoded = jwt.decode(out.body.assertion);++ assert.strictEqual(decoded.iss, ISSUER);++ assert.strictEqual(decoded.sub, person.subject_id);++ assert.deepStrictEqual(decoded.aud, ['openvibe.billing']);++ assert.strictEqual(decoded.act, 'svc:tips');++ assert.ok(decoded.jti, 'it has a jti');++ assert.strictEqual(decoded.exp - decoded.iat, 60, 'it lives 60 seconds');++ const header = JSON.parse(Buffer.from(out.body.assertion.split('.')[0], 'base64url').toString('utf8'));++ assert.strictEqual(header.alg, 'ES256');++ assert.strictEqual(header.kid, 'ov-network-assertion-1');++ const verified = jwt.verify(out.body.assertion, keys.publicKey, { algorithms: ['ES256'], issuer: ISSUER, audience: 'openvibe.billing' });++ assert.strictEqual(verified.sub, person.subject_id);++++ // The key is published in the JWKS, so a receiver can verify it offline.++ const jwk = assertions.assertionJwk(keys.publicKey);++ assert.strictEqual(jwk.kty, 'EC');++ assert.strictEqual(jwk.alg, 'ES256');++ assert.strictEqual(jwk.kid, 'ov-network-assertion-1');++ assert.ok(jwk.x && jwk.y && !jwk.d, 'the private half is not published');++++ // ── Without the person's token, nothing is signed ──++ const noToken = await post({ audience: 'openvibe.billing' }, { authorization: `Bearer ${serviceToken('tips')}` });++ assert.strictEqual(noToken.status, 401);++ assert.strictEqual(noToken.body.code, 'assertion.no_user_token');++++ const badToken = await post({ user_token: 'not-a-token', audience: 'openvibe.billing' }, { authorization: `Bearer ${serviceToken('tips')}` });++ assert.strictEqual(badToken.status, 401);++ assert.strictEqual(badToken.body.code, 'assertion.invalid_user_token');++++ // A service token of a service, naming no person: the service may not vouch for itself.++ const noService = await post({ user_token: personToken(), audience: 'openvibe.billing' }, {});++ assert.strictEqual(noService.status, 401);++ assert.strictEqual(noService.body.code, 'token.missing');++++ // A developer app token is not a first-party service: it never gets an assertion.++ const appToken = serviceToken('live', { sub: 'app:app_01H8XYZABCDEFGHJKMNPQRRSTV', actor_type: 'app', project_id: 'prj_01H8XYZABCDEFGHJKMNPQRRSTV', env: 'production' });++ const notService = await post({ user_token: personToken(), audience: 'openvibe.billing' }, { authorization: `Bearer ${appToken}` });++ assert.strictEqual(notService.status, 403);++ assert.strictEqual(notService.body.code, 'assertion.not_a_service');++++ // A token minted for another audience (a service talking to Billing) may not ask Network to sign.++ const wrongAudience = await post({ user_token: personToken(), audience: 'openvibe.billing' }, { authorization: `Bearer ${serviceToken('tips', { aud: ['openvibe.billing'] })}` });++ assert.strictEqual(wrongAudience.status, 401);++ assert.strictEqual(wrongAudience.body.code, 'token.wrong_audience');++++ // ── The person is the one the token names ──++ await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (78, 'other', 'x') ON CONFLICT (id) DO NOTHING").run();++ const otherSubject = await require('../server/identity/subjects').ensureUserSubject(db, await db.prepare('SELECT id, subject_id FROM users WHERE id = 78').get());++ const mismatch = await post({ user_token: personToken(), audience: 'openvibe.billing', subject: otherSubject }, { authorization: `Bearer ${serviceToken('tips')}` });++ assert.strictEqual(mismatch.status, 403);++ assert.strictEqual(mismatch.body.code, 'assertion.subject_mismatch');++++ const same = await post({ user_token: personToken(), audience: 'openvibe.billing', subject: person.subject_id }, { authorization: `Bearer ${serviceToken('tips')}` });++ assert.strictEqual(same.status, 200, JSON.stringify(same.body));++++ // ── The audience is one Network signs assertions for ──++ const badAudience = await post({ user_token: personToken(), audience: 'openvibe.somethingelse' }, { authorization: `Bearer ${serviceToken('tips')}` });++ assert.strictEqual(badAudience.status, 403);++ assert.strictEqual(badAudience.body.code, 'assertion.audience_not_allowed');++++ const noAudience = await post({ user_token: personToken() }, { authorization: `Bearer ${serviceToken('tips')}` });++ assert.strictEqual(noAudience.status, 422);++ assert.strictEqual(noAudience.body.code, 'assertion.invalid_audience');++++ // ── The chain: an assertion that travels from the site to Tips and on to Billing ──++ const chained = await post({ user_token: personToken(), audience: ['openvibe.tips', 'openvibe.billing'], for: ['svc:tips'] }, { authorization: `Bearer ${serviceToken('live')}` });++ assert.strictEqual(chained.status, 200, JSON.stringify(chained.body));++ const chainClaims = jwt.decode(chained.body.assertion);++ assert.deepStrictEqual(chainClaims.aud, ['openvibe.tips', 'openvibe.billing']);++ assert.deepStrictEqual(chainClaims.for, ['svc:live', 'svc:tips'], 'the minting service is always one that may carry it');++ assert.strictEqual(chainClaims.act, 'svc:live', 'it names the service that vouched for the person');++ assert.strictEqual(chainClaims.sub, person.subject_id);++++ // A service that is not named may not carry it.++ const stranger = await post({ user_token: personToken(), audience: ['openvibe.tips'], for: ['nope'] }, { authorization: `Bearer ${serviceToken('live')}` });++ assert.strictEqual(stranger.status, 422);++ assert.strictEqual(stranger.body.code, 'assertion.invalid_for');++++ console.log('ok - subject assertions: minted with the person\'s own token, refused without it');++} finally {++ server.close();++ await db.close();++}++})().catch((err) => { console.error(err); process.exit(1); });
@@ -95,7 +95,7 @@ person's username and picture (a service token, `identity.subject.resolve`).
@@ -95,7 +95,7 @@ person's username and picture (a service token, `identity.subject.resolve`).
## Depends on
## Depends on
-- `openvibe-contracts` v0.294.0 (registry manifests, capability checks), `openvibe-sdk` v0.82.0 (PostgreSQL
+- `openvibe-contracts` v0.297.0 (registry manifests, capability checks), `openvibe-sdk` v0.90.0 (PostgreSQL
and Valkey adapters, events, per-actor limits, universal telemetry), `openvibe-shared` v4.30.0, pinned by release tarball
and Valkey adapters, events, per-actor limits, universal telemetry), `openvibe-shared` v4.30.0, pinned by release tarball
- OpenVibe.Events (Network's outbox relay; the events it consumes), OpenVibe.AI (the `network.site_copy`
- OpenVibe.Events (Network's outbox relay; the events it consumes), OpenVibe.AI (the `network.site_copy`
workflow), OpenVibe.Blog (the changelog feed it proxies)
workflow), OpenVibe.Blog (the changelog feed it proxies)
@@ -1025,7 +1025,7 @@ Every change also sends the person a Network notification, so a swap nobody aske
@@ -1025,7 +1025,7 @@ Every change also sends the person a Network notification, so a swap nobody aske
MIT (`license` in [package.json](package.json)).
MIT (`license` in [package.json](package.json)).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.294.0
-- 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 -->
@@ -6,8 +6,8 @@
@@ -6,8 +6,8 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4000,
"port": 4000,
"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",
"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",
- "contracts": "openvibe-contracts v0.294.0",
- "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",
"capabilities": [
"capabilities": [
"identity.subject.resolve",
"identity.subject.resolve",
@@ -20,8 +20,8 @@
@@ -20,8 +20,8 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.86.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",
"qrcode": "1.5.4",
"qrcode": "1.5.4",
@@ -1722,9 +1722,9 @@
@@ -1722,9 +1722,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.294.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0",
- "integrity": "sha512-CBDymrE4VrgslAAgCTCvecNrVcaevhBcIrb4nU4ApkJasst+6xt3RdR9lktsdHuhBeipdlKAfaxLAFmRadkKqA==",
+ "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",
@@ -1738,9 +1738,9 @@
@@ -1738,9 +1738,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.86.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.86.0",
- "integrity": "sha512-APn4qK1ErX9f/gR6l2AhGfj+n8/3ul87Otm69xN4wyJUt43WF6A2b0lMKDG9Ds6oQiOoCZJCjxCSanP0wtMuGA==",
+ "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"
@@ -24,8 +24,8 @@
@@ -24,8 +24,8 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.86.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",
"qrcode": "1.5.4",
"qrcode": "1.5.4",
@@ -19,6 +19,11 @@ module.exports = {
@@ -19,6 +19,11 @@ module.exports = {
// openssl rsa -in data/keys/private.pem -pubout -out data/keys/public.pem
// openssl rsa -in data/keys/private.pem -pubout -out data/keys/public.pem
privateKeyPath: process.env.JWT_PRIVATE_KEY || 'data/keys/private.pem',
privateKeyPath: process.env.JWT_PRIVATE_KEY || 'data/keys/private.pem',
publicKeyPath: process.env.JWT_PUBLIC_KEY || 'data/keys/public.pem',
publicKeyPath: process.env.JWT_PUBLIC_KEY || 'data/keys/public.pem',
+ // The EC (P-256) key that signs the subject assertions (ADR-003, the 2026-11-06 amendment;
+ // plan H1). It is published in the JWKS next to the RS256 key, so Billing and Tips can verify
+ // an assertion offline. It is generated and kept on first boot when the files are not there.
+ assertionPrivateKeyPath: process.env.JWT_ASSERTION_PRIVATE_KEY || 'data/keys/assertion-private.pem',
+ assertionPublicKeyPath: process.env.JWT_ASSERTION_PUBLIC_KEY || 'data/keys/assertion-public.pem',
accessTokenExpiry: '7d',
accessTokenExpiry: '7d',
refreshTokenExpiry: '30d',
refreshTokenExpiry: '30d',
// issuer is the canonical public network URL — updated at runtime from registry
// issuer is the canonical public network URL — updated at runtime from registry
@@ -26,6 +31,13 @@ module.exports = {
@@ -26,6 +31,13 @@ module.exports = {
},
},
+ // Subject assertions (ADR-003, the 2026-11-06 amendment; plan H1): the audiences Network signs an
+ // assertion for. The money services by default; a deployment may narrow or widen the list.
+ subjectAssertions: {
+ audiences: String(process.env.NETWORK_SUBJECT_ASSERTION_AUDIENCES || 'openvibe.billing,openvibe.tips')
+ .split(',').map((s) => s.trim()).filter(Boolean),
+ },
+
// Sign in on another device (owner, 2026-11-04; server/auth/qr-signin.js): a QR on the source device, a
// Sign in on another device (owner, 2026-11-04; server/auth/qr-signin.js): a QR on the source device, a
// scan on the target and a two-digit match. Off unless NETWORK_QR_SIGNIN is explicitly on; off means the routes and /q are not mounted.
// scan on the target and a two-digit match. Off unless NETWORK_QR_SIGNIN is explicitly on; off means the routes and /q are not mounted.
qrSignin: {
qrSignin: {
new file mode 100644
new file mode 100644
@@ -0,0 +1,237 @@
@@ -0,0 +1,237 @@
+'use strict';
+
+/**
+ * Subject assertions (ADR-003, amendment 2026-11-06; plan H1).
+ *
+ * A subject assertion is a short-lived, Network-signed proof that a person authorized the calling
+ * service to act for them on ONE other service (the audience). It is what a money call carries
+ * beside the name of the person:
+ *
+ * Authorization: Bearer <the service's own token> who is calling
+ * X-OV-Subject: usr_… who the service says it acts for
+ * X-OV-Subject-Assertion: <the assertion> that the person allowed it
+ *
+ * The assertion is minted here, and only here: POST /internal/subject-assertions takes the
+ * person's OWN Network access token (the service has it from their session) and signs it into an
+ * ES256 JWT that lives 60 seconds. Without the person's token nothing is signed: a service that
+ * knows a person's id cannot say the person allowed anything.
+ *
+ * The key is Network's own EC (P-256) key, published in the JWKS next to the RS256 session key
+ * (kid `ov-network-assertion-1`), so Billing and Tips can verify the signature offline.
+ *
+ * The claims (contract identity.subject-assertion-claims@1):
+ *
+ * iss the Network issuer
+ * sub the person: usr_…
+ * aud the service the assertion is for (openvibe.billing)
+ * act the service it was minted for (svc:live)
+ * jti unique per assertion, so a replay can be refused
+ * iat, exp 60 seconds
+ */
+const crypto = require('node:crypto');
+const fs = require('node:fs');
+const path = require('node:path');
+const jwt = require('jsonwebtoken');
+const { http, serviceAuth } = require('openvibe-contracts');
+
+const KID = 'ov-network-assertion-1';
+/** How long an assertion lives. */
+const TTL_S = 60;
+/** The audiences an assertion may be minted for: the money services (H1). */
+const DEFAULT_AUDIENCES = ['openvibe.billing', 'openvibe.tips'];
+const AUDIENCE_RE = /^openvibe\.[a-z0-9][a-z0-9.-]*$/;
+const SERVICE_RE = /^svc:[a-z][a-z0-9-]{1,39}$/;
+const USER_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+/**
+ * The EC key pair, loaded from disk or generated once and kept there.
+ * `data/keys/assertion-private.pem` (0600) and `data/keys/assertion-public.pem`. An environment
+ * pair wins, so a deployment may keep the key outside the repository.
+ */
+function loadAssertionKeys({ privateKeyPath, publicKeyPath, log = console } = {}) {
+ const priv = privateKeyPath ? path.resolve(privateKeyPath) : null;
+ const pub = publicKeyPath ? path.resolve(publicKeyPath) : null;
+ let privateKey = null;
+ let publicKey = null;
+ try {
+ if (priv) privateKey = fs.readFileSync(priv, 'utf8');
+ } catch { /* not there yet */ }
+ try {
+ if (pub) publicKey = fs.readFileSync(pub, 'utf8');
+ } catch { /* not there yet */ }
+ if (privateKey && !publicKey) {
+ // The private key is enough: the public half is derived from it.
+ try { publicKey = crypto.createPublicKey(privateKey).export({ type: 'spki', format: 'pem' }).toString(); } catch { /* unreadable key */ }
+ }
+ if (!privateKey || !publicKey) {
+ const pair = crypto.generateKeyPairSync('ec', {
+ namedCurve: 'P-256',
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
+ });
+ privateKey = pair.privateKey;
+ publicKey = pair.publicKey;
+ // Keep it: an assertion signed before a restart must still verify after it.
+ for (const [file, key] of [[priv, privateKey], [pub, publicKey]]) {
+ if (!file) continue;
+ try {
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(file, key, { mode: 0o600 });
+ } catch (err) {
+ (log && log.warn) ? log.warn(`[Assertions] the EC key could not be written to ${file}: ${err.message}; it will change on every restart`) : null;
+ }
+ }
+ }
+ return { privateKey, publicKey, kid: KID };
+}
+
+/** The JWK of the assertion key, as the JWKS publishes it. */
+function assertionJwk(publicKey) {
+ const jwk = crypto.createPublicKey(publicKey).export({ format: 'jwk' });
+ return { ...jwk, use: 'sig', alg: 'ES256', kid: KID };
+}
+
+/**
+ * Sign an assertion for a person.
+ *
+ * `act` is the service that vouched for the person (it held their token), `for` the services that
+ * may carry the assertion (the minting service alone by default) and `audience` the service (or the
+ * services, when the assertion travels a chain) the assertion is for.
+ */
+function signAssertion({ privateKey, subject, audience, act, for: forServices, issuer, ttlS = TTL_S, now = () => Date.now() }) {
+ const iat = Math.floor(now() / 1000);
+ const jti = `as_${crypto.randomBytes(12).toString('hex')}`;
+ const token = jwt.sign(
+ { sub: subject, act, ...(forServices && forServices.length ? { for: forServices } : {}) },
+ privateKey,
+ {
+ algorithm: 'ES256', keyid: KID, issuer,
+ audience: Array.isArray(audience) ? audience : [audience],
+ expiresIn: ttlS, jwtid: jti, noTimestamp: false,
+ },
+ );
+ return { assertion: token, jti, issued_at: iat, expires_at: iat + ttlS };
+}
+
+/** A small in-process limiter: one service may not mint assertions without bound. */
+function createLimiter({ perMinute = 120, now = () => Date.now() } = {}) {
+ const hits = new Map(); // key -> { count, at }
+ return function allow(key) {
+ const t = now();
+ const row = hits.get(key);
+ if (!row || t - row.at > 60000) { hits.set(key, { count: 1, at: t }); return true; }
+ row.count += 1;
+ if (hits.size > 1000) { for (const [k, v] of hits) if (t - v.at > 60000) hits.delete(k); }
+ return row.count <= perMinute;
+ };
+}
+
+/**
+ * The route: POST /internal/subject-assertions.
+ *
+ * A first-party service (a service token, never an app or a mod) hands over the person's own
+ * access token and the audience it needs an assertion for. Network checks that token is a live
+ * session of a person, that the person is the one the caller says it acts for, and signs a
+ * 60-second assertion naming the caller as `act`.
+ */
+function subjectAssertionRouter({ log = console } = {}) {
+ const limit = createLimiter();
+
+ /** The caller's own service token: the route takes nothing else. */
+ function requireService(req, res, next) {
+ const auth = String(req.headers.authorization || '');
+ if (!auth.startsWith('Bearer ')) {
+ return http.sendProblem(res, 401, 'token.missing', { detail: 'a service token is required', ctx: req.ov });
+ }
+ const r = serviceAuth.verifyServiceToken(auth.slice(7).trim(), {
+ publicKey: req.app.locals.publicKey,
+ issuer: (req.app.locals.config || {}).jwt && req.app.locals.config.jwt.issuer,
+ // The token must be one Network itself issued for its own API: a token minted for another
+ // audience (a service talking to Billing, say) may not ask Network to sign anything.
+ audience: 'openvibe.network',
+ });
+ if (!r.ok) return http.sendProblem(res, 401, r.code, { detail: r.reason, ctx: req.ov });
+ // Only a first-party service may ask for an assertion. A developer app, a mod or an agent
+ // never gets one: their own authorization (on_behalf_of) is what they act on.
+ if (r.claims.actor_type !== 'service' || !/^svc:[a-z][a-z0-9-]{1,39}$/.test(String(r.claims.sub || ''))) {
+ return http.sendProblem(res, 403, 'assertion.not_a_service', { detail: 'only a first-party service may ask for a subject assertion', ctx: req.ov });
+ }
+ req.principal = { sub: r.claims.sub, cap: r.claims.cap || [], jti: r.claims.jti };
+ return next();
+ }
+
+ async function handle(req, res) {
+ try {
+ const config = req.app.locals.config || {};
+ const audiences_allowed = (config.subjectAssertions && Array.isArray(config.subjectAssertions.audiences) && config.subjectAssertions.audiences.length)
+ ? config.subjectAssertions.audiences : DEFAULT_AUDIENCES;
+ const body = req.body || {};
+ const userToken = String(body.user_token || '').trim();
+ // The audience is one service, or a list when the assertion travels a chain (a tip goes
+ // from the site to Tips and on to Billing).
+ const audiences = (Array.isArray(body.audience) ? body.audience : [body.audience]).map((a) => String(a || '').trim()).filter(Boolean);
+ // The services that may carry it. The minting service is always one of them.
+ const forServices = [...new Set([req.principal.sub, ...(Array.isArray(body.for) ? body.for : body.for ? [body.for] : []).map((f) => String(f || '').trim()).filter(Boolean)])];
+ if (!userToken) return http.sendProblem(res, 401, 'assertion.no_user_token', { detail: "the person's own access token is required", ctx: req.ov });
+ if (!audiences.length || audiences.some((a) => !AUDIENCE_RE.test(a))) {
+ return http.sendProblem(res, 422, 'assertion.invalid_audience', { detail: 'the audience must be an openvibe.<service> id', ctx: req.ov });
+ }
+ if (audiences.some((a) => !audiences_allowed.includes(a))) {
+ return http.sendProblem(res, 403, 'assertion.audience_not_allowed', { detail: `no assertion is minted for ${audiences.join(', ')}`, ctx: req.ov });
+ }
+ if (forServices.some((f) => !SERVICE_RE.test(f))) {
+ return http.sendProblem(res, 422, 'assertion.invalid_for', { detail: 'the services that may carry the assertion are svc:<id>', ctx: req.ov });
+ }
+ if (!limit(req.principal.sub)) {
+ return http.sendProblem(res, 429, 'assertion.rate_limited', { detail: 'too many assertions for this service', ctx: req.ov });
+ }
+
+ // The person's own token: a live session, not a service or app token.
+ const session = await require('../auth/session').verifySession(userToken, { db: req.app.locals.db, publicKey: req.app.locals.publicKey, config });
+ if (session.error) {
+ return http.sendProblem(res, session.status || 401, 'assertion.invalid_user_token', { detail: session.error, ctx: req.ov });
+ }
+ const subject = await require('./subjects').ensureUserSubject(req.app.locals.db, session.user);
+ if (!subject || !USER_RE.test(String(subject))) {
+ return http.sendProblem(res, 403, 'assertion.no_subject', { detail: 'that account has no canonical subject yet', ctx: req.ov });
+ }
+ // The caller may only assert for the person it says it acts for.
+ const asked = body.subject ? String(body.subject).trim() : null;
+ if (asked && asked !== subject) {
+ return http.sendProblem(res, 403, 'assertion.subject_mismatch', { detail: 'that token names another person', ctx: req.ov });
+ }
+
+ const assertionKeys = req.app.locals.assertionKeys || {};
+ if (!assertionKeys.privateKey) {
+ return http.sendProblem(res, 503, 'assertion.unavailable', { detail: 'the assertion key is not loaded yet', ctx: req.ov });
+ }
+ const out = signAssertion({
+ privateKey: assertionKeys.privateKey,
+ subject,
+ audience: audiences.length === 1 ? audiences[0] : audiences,
+ act: req.principal.sub,
+ for: forServices,
+ issuer: config.jwt.issuer,
+ });
+ res.set('Cache-Control', 'no-store');
+ return res.json({
+ assertion: out.assertion,
+ subject,
+ audience: audiences.length === 1 ? audiences[0] : audiences,
+ act: req.principal.sub,
+ for: forServices,
+ jti: out.jti,
+ expires_at: new Date(out.expires_at * 1000).toISOString(),
+ expires_in: TTL_S,
+ });
+ } catch (err) {
+ (log && log.error) ? log.error('[Assertions] mint failed:', err && err.stack ? err.stack : err) : null;
+ return http.sendProblem(res, 500, 'internal.error', { detail: 'unexpected error', ctx: req.ov });
+ }
+ }
+
+ // The two middlewares: the caller's own service token, then the mint.
+ return [requireService, handle];
+}
+
+module.exports = { KID, TTL_S, DEFAULT_AUDIENCES, loadAssertionKeys, assertionJwk, signAssertion, subjectAssertionRouter, createLimiter };
@@ -371,8 +371,17 @@ try {
@@ -371,8 +371,17 @@ try {
console.warn('[Auth] Using ephemeral HS256 key — DO NOT use in production');
console.warn('[Auth] Using ephemeral HS256 key — DO NOT use in production');
}
}
+// The EC key that signs the subject assertions (ADR-003, the 2026-11-06 amendment; plan H1). It is
+// published in the JWKS beside the RS256 key, so Billing and Tips verify an assertion offline.
+const assertionKeys = require('./identity/subject-assertions').loadAssertionKeys({
+ privateKeyPath: config.jwt.assertionPrivateKeyPath,
+ publicKeyPath: config.jwt.assertionPublicKeyPath,
+});
+console.log('[Auth] ES256 assertion keypair loaded (kid ' + assertionKeys.kid + ')');
+
// Make keys available to route modules
// Make keys available to route modules
app.locals.db = db;
app.locals.db = db;
+app.locals.assertionKeys = assertionKeys;
app.locals.privateKey = privateKey;
app.locals.privateKey = privateKey;
// Network's own service tokens for its calls to Live, Media and Tools (identity/self-token.js).
// Network's own service tokens for its calls to Live, Media and Tools (identity/self-token.js).
const selfToken = require('./identity/self-token').createSelfTokens({ privateKey, issuer: config.jwt.issuer });
const selfToken = require('./identity/self-token').createSelfTokens({ privateKey, issuer: config.jwt.issuer });
@@ -635,12 +644,19 @@ app.get('/api/.well-known/jwks', (_req, res) => {
@@ -635,12 +644,19 @@ app.get('/api/.well-known/jwks', (_req, res) => {
// anyone sign sessions. Only a real public key is ever served.
// anyone sign sessions. Only a real public key is ever served.
if (privateKey === publicKey || !String(publicKey).includes('BEGIN')) return res.json({ algorithm: 'HS256', keys: [] });
if (privateKey === publicKey || !String(publicKey).includes('BEGIN')) return res.json({ algorithm: 'HS256', keys: [] });
const out = { public_key: publicKey, algorithm: 'RS256' };
const out = { public_key: publicKey, algorithm: 'RS256' };
+ const keys = [];
if (publicKey.includes('BEGIN')) {
if (publicKey.includes('BEGIN')) {
try {
try {
const jwk = require('crypto').createPublicKey(publicKey).export({ format: 'jwk' });
const jwk = require('crypto').createPublicKey(publicKey).export({ format: 'jwk' });
- out.keys = [{ ...jwk, use: 'sig', alg: 'RS256', kid: 'ov-network-1' }];
+ keys.push({ ...jwk, use: 'sig', alg: 'RS256', kid: 'ov-network-1' });
} catch { /* legacy shape still served */ }
} catch { /* legacy shape still served */ }
}
}
+ // The subject-assertion key (ES256): Billing and Tips verify a subject assertion with it.
+ // It is a public key, like the RS256 one, and it never signs anything else.
+ try {
+ keys.push(require('./identity/subject-assertions').assertionJwk(app.locals.assertionKeys.publicKey));
+ } catch { /* the assertion key is not loaded */ }
+ if (keys.length) out.keys = keys;
res.json(out);
res.json(out);
});
});
@@ -55,6 +55,15 @@ router.post('/identity/resolve-batch', principals.guard('identity.subject.resolv
@@ -55,6 +55,15 @@ router.post('/identity/resolve-batch', principals.guard('identity.subject.resolv
router.post('/identity/legacy-map', principals.guard('identity.subject.resolve'), ownSourceSystem);
router.post('/identity/legacy-map', principals.guard('identity.subject.resolve'), ownSourceSystem);
router.use('/identity', require('../identity/internal-routes'));
router.use('/identity', require('../identity/internal-routes'));
+// ── Subject assertions (ADR-003, the 2026-11-06 amendment; plan H1) ───────────────────
+// A first-party service hands over the person's own access token and gets back a 60-second
+// Network-signed assertion naming the person, the service it was minted for and the audience it is
+// for. Without the person's token nothing is signed: a service that knows a person's id cannot say
+// the person allowed anything (that is the H1 narrowing of "the service vouches for its person").
+router.post('/subject-assertions',
+ express.json({ limit: '16kb' }),
+ ...require('../identity/subject-assertions').subjectAssertionRouter());
+
// ── Project secrets (ADR-056) ────────────────────────────────
// ── Project secrets (ADR-056) ────────────────────────────────
// A service the project granted a secret to signs with it (the value never leaves) or receives it for an origin the
// A service the project granted a secret to signs with it (the value never leaves) or receives it for an origin the
// grant lists. network.secret.use is the door; the secret's own grant decides, and anything refused is 404.
// grant lists. network.secret.use is the door; the secret's own grant decides, and anything refused is 404.
@@ -54,10 +54,14 @@ async function readJson(r) {
@@ -54,10 +54,14 @@ async function readJson(r) {
}
}
/** One request to Billing. The person's own bearer token unless `token` says otherwise. */
/** One request to Billing. The person's own bearer token unless `token` says otherwise. */
-async function ask(url, { authorization, method = 'GET', body, timeoutMs = TIMEOUT_MS } = {}) {
+async function ask(url, { authorization, method = 'GET', body, timeoutMs = TIMEOUT_MS, assertion } = {}) {
const headers = { accept: 'application/json' };
const headers = { accept: 'application/json' };
if (authorization) headers.authorization = authorization;
if (authorization) headers.authorization = authorization;
if (body !== undefined) headers['content-type'] = 'application/json';
if (body !== undefined) headers['content-type'] = 'application/json';
+ // The person's own subject assertion (ADR-003, the 2026-11-06 amendment; plan H1): Billing checks it
+ // against the person the read is for. Only a service-token read needs it; the person's own token is
+ // the proof already.
+ if (assertion) headers['x-ov-subject-assertion'] = String(assertion);
const r = await fetch(url, {
const r = await fetch(url, {
method, headers,
method, headers,
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
@@ -308,6 +312,34 @@ module.exports = function createWalletRoutes(db, requireAuth, opts = {}) {
@@ -308,6 +312,34 @@ module.exports = function createWalletRoutes(db, requireAuth, opts = {}) {
* marked TEMPORARY FALLBACK reads the journal with Network's service token. The fallback is not seeded as a
* marked TEMPORARY FALLBACK reads the journal with Network's service token. The fallback is not seeded as a
* default grant: without an operator's explicit billing.balance.read grant it reads nothing.
* default grant: without an operator's explicit billing.balance.read grant it reads nothing.
*/
*/
+ /**
+ * The person's own subject assertion for a service-token read of their money (ADR-003, the 2026-11-06
+ * amendment; plan H1). Network asks its own mint route for it: the person's Network token is on the
+ * request, and without it nothing is signed (so a read for a person whose token is not here has no
+ * assertion and travels the old way).
+ */
+ async function mintAssertion(req, subject) {
+ if (!selfToken) return null;
+ const auth = personToken(req);
+ if (!auth) return null;
+ const token = selfToken('openvibe.network', ['identity.subject.resolve']);
+ if (!token) return null;
+ const net = String(opts.networkInternalUrl || config.internalUrl || `http://127.0.0.1:${config.port || 4000}`).replace(/\/+$/, '');
+ try {
+ const res = await fetch(`${net}/internal/subject-assertions`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
+ body: JSON.stringify({ user_token: auth.replace(/^Bearer\s+/i, '').trim(), audience: 'openvibe.billing', subject }),
+ signal: AbortSignal.timeout(2500),
+ });
+ if (!res.ok) return null;
+ const out = await res.json().catch(() => null);
+ return (out && out.assertion) || null;
+ } catch {
+ return null;
+ }
+ }
+
async function activityOf(req) {
async function activityOf(req) {
const subject = req.user && req.user.subject_id;
const subject = req.user && req.user.subject_id;
if (!subject) return { rows: [], source: 'none' };
if (!subject) return { rows: [], source: 'none' };
@@ -328,7 +360,10 @@ module.exports = function createWalletRoutes(db, requireAuth, opts = {}) {
@@ -328,7 +360,10 @@ module.exports = function createWalletRoutes(db, requireAuth, opts = {}) {
const token = selfToken(SERVICE_AUDIENCE, BALANCE_READ);
const token = selfToken(SERVICE_AUDIENCE, BALANCE_READ);
if (!token) return { rows: [], source: 'none' };
if (!token) return { rows: [], source: 'none' };
try {
try {
- const out = await ask(`${billingUrl}/api/v1/transactions?subject=${encodeURIComponent(subject)}&limit=80`, { authorization: `Bearer ${token}` });
+ // The person's own assertion: Network reads their journal with its service token, so it
+ // shows Billing the person's own answer instead of Network's word (ADR-003, plan H1).
+ const assertion = await mintAssertion(req, subject);
+ const out = await ask(`${billingUrl}/api/v1/transactions?subject=${encodeURIComponent(subject)}&limit=80`, { authorization: `Bearer ${token}`, assertion });
if (out.status !== 200 || !out.body) return { rows: [], source: 'none' };
if (out.status !== 200 || !out.body) return { rows: [], source: 'none' };
return { rows: mapActivity(subject, out.body.transactions), source: 'journal' };
return { rows: mapActivity(subject, out.body.transactions), source: 'journal' };
} catch {
} catch {
@@ -41,12 +41,20 @@ withWorld('secrets-reads', async (w) => {
@@ -41,12 +41,20 @@ withWorld('secrets-reads', async (w) => {
const jwks = await w.call(null, 'GET', '/api/.well-known/jwks');
const jwks = await w.call(null, 'GET', '/api/.well-known/jwks');
assert.strictEqual(jwks.status, 200);
assert.strictEqual(jwks.status, 200);
- assert.ok(jwks.body.public_key.includes('BEGIN PUBLIC KEY') && jwks.body.keys.length === 1);
+ // Two keys are published: the RS256 session key and the ES256 key the subject assertions are
+ // signed with (ADR-003, the 2026-11-06 amendment). Neither half of either private key is here.
+ assert.ok(jwks.body.public_key.includes('BEGIN PUBLIC KEY') && jwks.body.keys.length === 2);
for (const f of ['d', 'p', 'q', 'dp', 'dq', 'qi']) assert.ok(!(f in jwks.body.keys[0]), `JWKS has no private member ${f}`);
for (const f of ['d', 'p', 'q', 'dp', 'dq', 'qi']) assert.ok(!(f in jwks.body.keys[0]), `JWKS has no private member ${f}`);
const jwk = jwks.body.keys[0];
const jwk = jwks.body.keys[0];
assert.strictEqual(jwk.kty, 'RSA');
assert.strictEqual(jwk.kty, 'RSA');
assert.strictEqual(jwk.kid, 'ov-network-1', 'the kid /api/.well-known/jwks publishes');
assert.strictEqual(jwk.kid, 'ov-network-1', 'the kid /api/.well-known/jwks publishes');
assert.strictEqual(jwk.n, crypto.createPublicKey(w.keys.publicKey).export({ format: 'jwk' }).n, 'the modulus is the configured key\'s');
assert.strictEqual(jwk.n, crypto.createPublicKey(w.keys.publicKey).export({ format: 'jwk' }).n, 'the modulus is the configured key\'s');
+ const assertionJwk = jwks.body.keys[1];
+ assert.strictEqual(assertionJwk.kty, 'EC', 'the assertion key is the EC one');
+ assert.strictEqual(assertionJwk.alg, 'ES256');
+ assert.strictEqual(assertionJwk.kid, 'ov-network-assertion-1');
+ assert.ok(assertionJwk.x && assertionJwk.y, 'the public halves are published');
+ assert.ok(!('d' in assertionJwk), 'the private half of the assertion key is not published');
assert.ok(!/PRIVATE/.test(jwks.text));
assert.ok(!/PRIVATE/.test(jwks.text));
await checkSecretsStored(w, needlesFor);
await checkSecretsStored(w, needlesFor);
}).then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
}).then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
@@ -121,7 +121,7 @@ const REVIEWED = {
@@ -121,7 +121,7 @@ const REVIEWED = {
'server/domains/catalog.js fetch': [3, 'the tools catalog URL from operator config'],
'server/domains/catalog.js fetch': [3, 'the tools catalog URL from operator config'],
'server/today/live-follows.js fetch': [1, 'Live\'s internal URL from config (OV_LIVE_INTERNAL_URL): its public list of live streams, fixed path, 2.5 s timeout, held 10 s; never a request value'],
'server/today/live-follows.js fetch': [1, 'Live\'s internal URL from config (OV_LIVE_INTERNAL_URL): its public list of live streams, fixed path, 2.5 s timeout, held 10 s; never a request value'],
'server/coins/routes.js fetch': [1, 'Billing internal URL from config (the person\'s Vibes with their own token, 2 s timeout)'],
'server/coins/routes.js fetch': [1, 'Billing internal URL from config (the person\'s Vibes with their own token, 2 s timeout)'],
- 'server/wallet/routes.js fetch': [1, 'Billing internal URL from config (OV_BILLING_INTERNAL_URL, loopback 4600) only: the person\'s own token for /api/me/activity and every /api/me read and write, and — only while /api/me/activity is missing — the marked temporary journal fallback, a billing.balance.read service read scoped to the signed-in person\'s own usr_ id; the paths are fixed, a charge id must be a vch_ ULID and is percent-encoded, and no request value ever names a host'],
+ 'server/wallet/routes.js fetch': [2, 'Billing internal URL from config (OV_BILLING_INTERNAL_URL, loopback 4600) only: the person\'s own token for /api/me/activity and every /api/me read and write, and — only while /api/me/activity is missing — the marked temporary journal fallback, a billing.balance.read service read scoped to the signed-in person\'s own usr_ id; the paths are fixed, a charge id must be a vch_ ULID and is percent-encoded, and no request value ever names a host. The second call site is Network\'s own loopback (config.internalUrl, else 127.0.0.1:<port>): the fixed path POST /internal/subject-assertions, which signs the person\'s own token into a 60-second subject assertion; the person\'s token is in the body, never in the URL, and no request value names a host'],
'server/frame/service.js fetch': [3, 'Live and AI internal URLs from config (navbar ranking copy); https://<open site host>/privacy, hosts from the site registry only, no redirects, 5 s (legal links)'],
'server/frame/service.js fetch': [3, 'Live and AI internal URLs from config (navbar ranking copy); https://<open site host>/privacy, hosts from the site registry only, no redirects, 5 s (legal links)'],
'server/stats/open.js fetch': [1, 'open stats (/stats): fixed first-party loopback URLs only — Live (OV_LIVE_INTERNAL_URL, its public home numbers and service-token analytics totals) and Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; the request only picks 7, 30 or 90 days'],
'server/stats/open.js fetch': [1, 'open stats (/stats): fixed first-party loopback URLs only — Live (OV_LIVE_INTERNAL_URL, its public home numbers and service-token analytics totals) and Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; the request only picks 7, 30 or 90 days'],
'server/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],
'server/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],
new file mode 100644
new file mode 100644
@@ -0,0 +1,145 @@
@@ -0,0 +1,145 @@
+'use strict';
+// Subject assertions (server/identity/subject-assertions.js): a first-party service signs the
+// person's own access token into a 60-second assertion, and nothing is signed without it.
+// ADR-003, the 2026-11-06 amendment; plan H1.
+// node test/subject-assertions.test.js
+const assert = require('assert');
+const crypto = require('crypto');
+const http = require('http');
+const express = require('express');
+const jwt = require('jsonwebtoken');
+const { serviceAuth } = require('openvibe-contracts');
+const { getDb } = require('../server/db/database');
+const assertions = require('../server/identity/subject-assertions');
+
+(async () => {
+const db = await getDb();
+const ISSUER = 'https://openvibe.network';
+
+await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (77, 'payer', 'x') ON CONFLICT (id) DO NOTHING").run();
+const person = await db.prepare('SELECT id, subject_id FROM users WHERE id = 77').get();
+person.subject_id = await require('../server/identity/subjects').ensureUserSubject(db, person);
+assert.ok(person.subject_id, 'the person has a canonical subject');
+
+const rsa = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+const keys = assertions.loadAssertionKeys({ privateKeyPath: null, publicKeyPath: null, log: { warn: () => {} } });
+
+const app = express();
+app.use(express.json());
+app.locals.db = db;
+app.locals.privateKey = rsa.privateKey;
+app.locals.publicKey = rsa.publicKey;
+app.locals.assertionKeys = keys;
+app.locals.config = { jwt: { issuer: ISSUER }, subjectAssertions: { audiences: ['openvibe.billing', 'openvibe.tips'] } };
+app.use('/internal', require('../server/internal/routes'));
+const server = http.createServer(app);
+await new Promise((r) => server.listen(0, '127.0.0.1', r));
+const base = `http://127.0.0.1:${server.address().port}`;
+
+/** A service token (client_credentials, signed here) for the service `svc:<id>`. */
+const serviceToken = (id, over = {}) => serviceAuth.signServiceToken({
+ iss: ISSUER, sub: `svc:${id}`, actor_type: 'service', aud: ['openvibe.network'], cap: [], ns: [],
+ iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}`, ...over,
+}, rsa.privateKey);
+/** The person's own access token (a Network session). */
+const personToken = () => jwt.sign({ sub: '77' }, rsa.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
+
+const post = (body, headers) => fetch(`${base}/internal/subject-assertions`, {
+ method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(body),
+}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
+
+try {
+ // ── The happy path ──
+ const out = await post({ user_token: personToken(), audience: 'openvibe.billing' }, { authorization: `Bearer ${serviceToken('tips')}` });
+ assert.strictEqual(out.status, 200, JSON.stringify(out.body));
+ assert.ok(out.body.assertion, 'an assertion comes back');
+ assert.strictEqual(out.body.subject, person.subject_id, 'it names the person');
+ assert.strictEqual(out.body.audience, 'openvibe.billing');
+ assert.strictEqual(out.body.act, 'svc:tips', 'it names the service it was minted for');
+ assert.strictEqual(out.body.expires_in, 60);
+
+ // The assertion verifies with Network's published key and carries the H1 claims.
+ const decoded = jwt.decode(out.body.assertion);
+ assert.strictEqual(decoded.iss, ISSUER);
+ assert.strictEqual(decoded.sub, person.subject_id);
+ assert.deepStrictEqual(decoded.aud, ['openvibe.billing']);
+ assert.strictEqual(decoded.act, 'svc:tips');
+ assert.ok(decoded.jti, 'it has a jti');
+ assert.strictEqual(decoded.exp - decoded.iat, 60, 'it lives 60 seconds');
+ const header = JSON.parse(Buffer.from(out.body.assertion.split('.')[0], 'base64url').toString('utf8'));
+ assert.strictEqual(header.alg, 'ES256');
+ assert.strictEqual(header.kid, 'ov-network-assertion-1');
+ const verified = jwt.verify(out.body.assertion, keys.publicKey, { algorithms: ['ES256'], issuer: ISSUER, audience: 'openvibe.billing' });
+ assert.strictEqual(verified.sub, person.subject_id);
+
+ // The key is published in the JWKS, so a receiver can verify it offline.
+ const jwk = assertions.assertionJwk(keys.publicKey);
+ assert.strictEqual(jwk.kty, 'EC');
+ assert.strictEqual(jwk.alg, 'ES256');
+ assert.strictEqual(jwk.kid, 'ov-network-assertion-1');
+ assert.ok(jwk.x && jwk.y && !jwk.d, 'the private half is not published');
+
+ // ── Without the person's token, nothing is signed ──
+ const noToken = await post({ audience: 'openvibe.billing' }, { authorization: `Bearer ${serviceToken('tips')}` });
+ assert.strictEqual(noToken.status, 401);
+ assert.strictEqual(noToken.body.code, 'assertion.no_user_token');
+
+ const badToken = await post({ user_token: 'not-a-token', audience: 'openvibe.billing' }, { authorization: `Bearer ${serviceToken('tips')}` });
+ assert.strictEqual(badToken.status, 401);
+ assert.strictEqual(badToken.body.code, 'assertion.invalid_user_token');
+
+ // A service token of a service, naming no person: the service may not vouch for itself.
+ const noService = await post({ user_token: personToken(), audience: 'openvibe.billing' }, {});
+ assert.strictEqual(noService.status, 401);
+ assert.strictEqual(noService.body.code, 'token.missing');
+
+ // A developer app token is not a first-party service: it never gets an assertion.
+ const appToken = serviceToken('live', { sub: 'app:app_01H8XYZABCDEFGHJKMNPQRRSTV', actor_type: 'app', project_id: 'prj_01H8XYZABCDEFGHJKMNPQRRSTV', env: 'production' });
+ const notService = await post({ user_token: personToken(), audience: 'openvibe.billing' }, { authorization: `Bearer ${appToken}` });
+ assert.strictEqual(notService.status, 403);
+ assert.strictEqual(notService.body.code, 'assertion.not_a_service');
+
+ // A token minted for another audience (a service talking to Billing) may not ask Network to sign.
+ const wrongAudience = await post({ user_token: personToken(), audience: 'openvibe.billing' }, { authorization: `Bearer ${serviceToken('tips', { aud: ['openvibe.billing'] })}` });
+ assert.strictEqual(wrongAudience.status, 401);
+ assert.strictEqual(wrongAudience.body.code, 'token.wrong_audience');
+
+ // ── The person is the one the token names ──
+ await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (78, 'other', 'x') ON CONFLICT (id) DO NOTHING").run();
+ const otherSubject = await require('../server/identity/subjects').ensureUserSubject(db, await db.prepare('SELECT id, subject_id FROM users WHERE id = 78').get());
+ const mismatch = await post({ user_token: personToken(), audience: 'openvibe.billing', subject: otherSubject }, { authorization: `Bearer ${serviceToken('tips')}` });
+ assert.strictEqual(mismatch.status, 403);
+ assert.strictEqual(mismatch.body.code, 'assertion.subject_mismatch');
+
+ const same = await post({ user_token: personToken(), audience: 'openvibe.billing', subject: person.subject_id }, { authorization: `Bearer ${serviceToken('tips')}` });
+ assert.strictEqual(same.status, 200, JSON.stringify(same.body));
+
+ // ── The audience is one Network signs assertions for ──
+ const badAudience = await post({ user_token: personToken(), audience: 'openvibe.somethingelse' }, { authorization: `Bearer ${serviceToken('tips')}` });
+ assert.strictEqual(badAudience.status, 403);
+ assert.strictEqual(badAudience.body.code, 'assertion.audience_not_allowed');
+
+ const noAudience = await post({ user_token: personToken() }, { authorization: `Bearer ${serviceToken('tips')}` });
+ assert.strictEqual(noAudience.status, 422);
+ assert.strictEqual(noAudience.body.code, 'assertion.invalid_audience');
+
+ // ── The chain: an assertion that travels from the site to Tips and on to Billing ──
+ const chained = await post({ user_token: personToken(), audience: ['openvibe.tips', 'openvibe.billing'], for: ['svc:tips'] }, { authorization: `Bearer ${serviceToken('live')}` });
+ assert.strictEqual(chained.status, 200, JSON.stringify(chained.body));
+ const chainClaims = jwt.decode(chained.body.assertion);
+ assert.deepStrictEqual(chainClaims.aud, ['openvibe.tips', 'openvibe.billing']);
+ assert.deepStrictEqual(chainClaims.for, ['svc:live', 'svc:tips'], 'the minting service is always one that may carry it');
+ assert.strictEqual(chainClaims.act, 'svc:live', 'it names the service that vouched for the person');
+ assert.strictEqual(chainClaims.sub, person.subject_id);
+
+ // A service that is not named may not carry it.
+ const stranger = await post({ user_token: personToken(), audience: ['openvibe.tips'], for: ['nope'] }, { authorization: `Bearer ${serviceToken('live')}` });
+ assert.strictEqual(stranger.status, 422);
+ assert.strictEqual(stranger.body.code, 'assertion.invalid_for');
+
+ console.log('ok - subject assertions: minted with the person\'s own token, refused without it');
+} finally {
+ server.close();
+ await db.close();
+}
+})().catch((err) => { console.error(err); process.exit(1); });