openvibers/openvibe.sdk Commits

Subject assertions: request, verify, and the callers' minter (#122)

OP OpenVibers committed 7 days ago · 2e615ad44f · 19 files, +713 −46

* Subject assertions: request one, verify one (ADR-003, plan H1)

A money call carries a Network-signed proof that the person allowed the calling
service to act for them, beside the name of the person.

- requestSubjectAssertion(): ask Network to sign the person's own access token
  into a 60-second assertion for one audience. Without the person's token
  Network signs nothing.
- verifySubjectAssertion(): the ES256 signature against Network's JWKS (the EC
  keys, cached like the RS256 ones), the audience, the expiry, the service the
  assertion was minted for and the jti.
- createSubjectAssertionVerifier(): remembers the jtis, so a replay is refused.
- The JWKS client takes a key family (RSA for the session keys, EC for the
  assertion key).

# Conflicts:
#	CHANGELOG.md
#	docs/one-point-oh.json
#	esm/auth.mjs

* The subject assertions get their own entry (openvibe-sdk/assertions)

The auth entry is frozen for 1.0, so the new names cannot join it.

* The assertions entry: the browser and types checks

* The assertions: a smaller clock skew (an assertion lives 60 s)

* The assertions travel a chain: an audience list and the services that may carry it

A tip goes from the site (Live) to Tips and on to Billing, so the assertion
names both money services, and the service that vouched for the person says
which services may carry it. The receiver refuses an assertion presented by a
service that is not named.

* The assertions: the chain in the types

* The commerce client carries extra headers (the subject assertion rides on them)

* The callers' minter: one helper that asks Network to sign the person's token

* The commerce client carries extra headers on the subscription reads too

* Merge main into the branch (the Locker release)

* Merge main into the branch (the Exchange release); 0.90.0: subject assertions

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 02597da..4203f46 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,20 @@
# Changelog
++## 0.90.0 (2026-11-06)
++
++- **Subject assertions** (ADR-003, the 2026-11-06 amendment; plan H1). A money call carries a Network-signed
++ proof that the person allowed the calling service to act for them, beside the name of the person.
++ - `requestSubjectAssertion({ userToken, audience, token | tokenClient, network | internalUrl })` asks Network to
++ sign the person's own access token into a 60-second assertion for one audience (`openvibe.billing`). The
++ person's token is the only thing that can produce one: without it Network signs nothing.
++ - `verifySubjectAssertion(assertion, { jwks, audience, act, seen })` checks the ES256 signature against
++ Network's JWKS (the EC keys, cached like the RS256 ones), the audience, the expiry and the jti; a forged,
++ expired, wrong-audience or wrong-service assertion is refused with a stable code
++ (`assertion.bad_signature`, `assertion.expired`, `assertion.wrong_audience`, `assertion.actor_mismatch`, …).
++ - `createSubjectAssertionVerifier({ jwks, audience })` remembers the jtis it has seen, so a replayed assertion is
++ refused inside the replay window.
++ - `subjectAssertionHeader(assertion)` gives the `x-ov-subject-assertion` header for the call.
++
## 0.89.0 (2026-11-06)
- Add `openvibe-sdk/exchange`: OpenVibe.Exchange, the buyer and the seller side of the model exchange (the plan of 2026-11-06, "For builders", item 7).
ddiff --git a/README.md b/README.md
iindex fd2439d..2a451ff 100644
---- a/README.md
++++ b/README.md
@@@ -2,16 +2,16 @@
> Supported browser and server clients for the OpenVibe platform.
--**Status:** alpha, v0.89.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
++**Status:** alpha, v0.90.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
--After v0.89.0 is tagged:
++After v0.90.0 is tagged:
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.89.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.90.0
```
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from the temporary Contracts sharing branch, a devDependency the tests check them against).
@@@ -566,7 +566,7 @@ Nothing at runtime. `openvibe-contracts` and `@electric-sql/pglite` are devDepen
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
--- openvibe-contracts: v0.292.0
++- openvibe-contracts: v0.297.0
<!-- versions:end -->
### Network-wide sharing (Node)
ddiff --git a/STATUS.json b/STATUS.json
iindex a3e8e73..f5a8135 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,11 +3,11 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.89.0",
++ "version": "0.90.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
-- "contracts": "openvibe-contracts v0.292.0",
++ "contracts": "openvibe-contracts v0.297.0",
"features": [
"exchange (openvibe-sdk/exchange): the buyer side (chat, models, offers, presets, usage, receipts) and the seller side (offer, earnings, verification) of OpenVibe.Exchange, with the platform webhook signature check",
"locker (openvibe-sdk/locker, openvibe-sdk/locker/server): the Locker envelope (locker.envelope@1) for end-to-end encryption, WebCrypto only, in Node and the browser: seal/open for text and bytes, sealStream/openStream, the three locks (people, password, link), person key pairs (ECDH P-256, ECDSA P-256), the eight-word fingerprint, wrapFor and rewrap, recoveryWords for the phrase to write down, opening a people lock by key, inspect for what an envelope says about itself, and the header checks a service runs without a key",
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex 5ea1f11..c364cc1 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.89.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.90.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, locker, registry, modules, realtime, media, community, bot, search, bench, meme, vision, themes, redact, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
@@@ -48,7 +48,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.89.0';
++const SDK_VERSION = '0.90.0';
module.exports = {
createClient,
ddiff --git a/docs/api-surface.json b/docs/api-surface.json
iindex 1d97336..1f57b97 100644
---- a/docs/api-surface.json
++++ b/docs/api-surface.json
@@@ -399,6 +399,17 @@
"createAppSignIn": "function",
"normalizeScope": "function"
},
++ "openvibe-sdk/assertions": {
++ "ASSERTION_HEADER": "string",
++ "ASSERTION_TTL_S": "number",
++ "CLOCK_SKEW_SEC": "number",
++ "REPLAY_WINDOW_MS": "number",
++ "createSubjectAssertionMinter": "function",
++ "createSubjectAssertionVerifier": "function",
++ "requestSubjectAssertion": "function",
++ "subjectAssertionHeader": "function",
++ "verifySubjectAssertion": "function"
++ },
"openvibe-sdk/auth": {
"TOKEN_VALID_AFTER": "string",
"base64url": "function",
ddiff --git a/docs/share.md b/docs/share.md
iindex b4b437a..b09ea01 100644
---- a/docs/share.md
++++ b/docs/share.md
@@@ -1,6 +1,6 @@
# Network-wide sharing (Node)
--`openvibe-sdk/share` implements the 2026-11-06 sharing design in each service's PostgreSQL database. The service is the authority. The module never reads titles, content or media URLs. It uses openvibe-contracts v0.296.0 (share.v1, with the Locker lock kind and the member key slots).
++`openvibe-sdk/share` implements the 2026-11-06 sharing design in each service's PostgreSQL database. The service is the authority. The module never reads titles, content or media URLs. It uses openvibe-contracts v0.297.0 (share.v1, with the Locker lock kind and the member key slots).
```js
const { createShares, sharesSchema, shareOpenApi } = require('openvibe-sdk/share');
ddiff --git a/esm/assertions.mjs b/esm/assertions.mjs
nnew file mode 100644
iindex 0000000..d365be6
---- /dev/null
++++ b/esm/assertions.mjs
@@@ -0,0 +1,8 @@
++// Generated by scripts/esm.js from src/assertions.js. Do not edit.
++import cjs from '../src/assertions.js';
++
++export const {
++ requestSubjectAssertion, verifySubjectAssertion, createSubjectAssertionVerifier, createSubjectAssertionMinter, subjectAssertionHeader, ASSERTION_HEADER,
++ ASSERTION_TTL_S, REPLAY_WINDOW_MS, CLOCK_SKEW_SEC,
++} = cjs;
++export default cjs;
ddiff --git a/package-lock.json b/package-lock.json
iindex d9b1174..fa82d74 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.89.0",
++ "version": "0.90.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.89.0",
++ "version": "0.90.0",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
@@@ -14,7 +14,7 @@
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.296.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.297.0",
"pg": "^8.23.0"
},
"engines": {
@@@ -198,9 +198,9 @@
"license": "MIT"
},
"node_modules/openvibe-contracts": {
-- "version": "0.296.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.296.0",
-- "integrity": "sha512-+irrEq65eTSkMp3aHt2a7Wuxo689QBrUpgdhkZGBtU4owrsmS3VlyWVBqmy4ZAprqJ1rQJKqpW7n3RlgZ1KKBw==",
++ "version": "0.297.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.297.0",
++ "integrity": "sha512-FycRElRNzeFLEfzVUKIyE3ieKphUrQbAbsprnvs0Uu7ZdlBKJG6wQV/9gD77Neu1Etcsyezf9Boyq+MsjuraVA==",
"dev": true,
"license": "AGPL-3.0-only",
"dependencies": {
ddiff --git a/package.json b/package.json
iindex 1ab7626..b84937b 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.89.0",
++ "version": "0.90.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
@@@ -28,6 +28,12 @@
"import": "./esm/auth-browser.mjs",
"default": "./src/auth/browser.js"
},
++ "./assertions": {
++ "types": "./types/assertions.d.ts",
++ "browser": null,
++ "import": "./esm/assertions.mjs",
++ "default": "./src/assertions.js"
++ },
"./registry": {
"types": "./types/registry.d.ts",
"import": "./esm/registry.mjs",
@@@ -520,7 +526,7 @@
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.296.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.297.0",
"pg": "^8.23.0"
},
"peerDependencies": {
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex 71fe21a..d240e38 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -20,6 +20,7 @@ const ENTRIES = {
'core.mjs': '../src/core/index.js',
'auth.mjs': '../src/auth/index.js',
'auth-browser.mjs': '../src/auth/browser.js',
++ 'assertions.mjs': '../src/assertions.js',
'registry.mjs': '../src/registry.js',
'identity.mjs': '../src/identity.js',
'notifications.mjs': '../src/notifications.js',
ddiff --git a/src/assertions.js b/src/assertions.js
nnew file mode 100644
iindex 0000000..ea0e685
---- /dev/null
++++ b/src/assertions.js
@@@ -0,0 +1,291 @@
++'use strict';
++/**
++ * openvibe-sdk/assertions: 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
++ *
++ * Without the assertion, ADR-003's rule holds: the service vouches for its own signed-in person,
++ * so any first-party service that knows a person's id could act for them. With it, the receiver
++ * checks a signature from Network instead of the service's word.
++ *
++ * The two halves:
++ *
++ * requestSubjectAssertion({ userToken, audience, … }) the caller: ask Network to sign the
++ * person's own access token into an assertion for `audience`. The person's token is the
++ * only thing that can produce one: without it Network answers 401.
++ * verifySubjectAssertion(assertion, { jwks, audience, … }) the receiver (Billing, Tips):
++ * check the ES256 signature against Network's JWKS, the audience, the expiry and the jti
++ * replay window.
++ *
++ * The assertion lives 60 seconds and names the service it was minted for (`act`), so it cannot be
++ * carried to another service, and a replayed one is refused.
++ */
++const crypto = require('node:crypto');
++const { OpenVibeError } = require('./core/errors');
++const { jwksClient, keysFromJwks } = require('./auth/jwks');
++
++/** The header a money call carries the assertion in. */
++const ASSERTION_HEADER = 'x-ov-subject-assertion';
++/** How long an assertion lives. */
++const ASSERTION_TTL_S = 60;
++/** The default replay window: a jti is remembered this long after it was seen. */
++const REPLAY_WINDOW_MS = 2 * 60 * 1000;
++/** How much clock difference is forgiven. An assertion lives 60 s, so this stays small. */
++const CLOCK_SKEW_SEC = 10;
++
++const USER_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const SERVICE_RE = /^svc:[a-z][a-z0-9-]{1,39}$/;
++
++const fromB64url = (s) => Buffer.from(String(s).replace(/-/g, '+').replace(/_/g, '/'), 'base64');
++
++/** The ES256 keys of a JWKS document (the assertion key is EC, the session key is RSA). */
++function ecKeysFromJwks(doc) { return keysFromJwks(doc, { kty: 'EC' }); }
++
++/** The JWKS client that keeps only the EC (ES256) keys (one per URL, shared by the process). */
++function ecJwksClient(url, opts = {}) { return jwksClient(url, { ...opts, kty: 'EC' }); }
++
++/**
++ * Ask Network to sign the person's own access token into an assertion for `audience`.
++ *
++ * const out = await requestSubjectAssertion({
++ * userToken: req.token, // the person's Network access token (their session)
++ * audience: 'openvibe.billing', // the service the assertion is for
++ * token: await tokens.getToken(), // the caller's own service token (Bearer)
++ * internalUrl: 'http://127.0.0.1:4000', // where Network's /internal is (loopback on the same host)
++ * });
++ * // → { assertion, subject, audience, jti, expires_at, expires_in }
++ *
++ * `tokenClient` may be given instead of `token` (the SDK's createServiceTokenClient, or anything
++ * with getToken()). The person's token is never optional: Network refuses to sign without it.
++ *
++ * `audience` may be a list, when the assertion travels a chain: a tip goes from the site (Live) to
++ * Tips and on to Billing, so the assertion names both money services. `for` names the services that
++ * may carry it (the minting service is always in it); the receiver refuses an assertion presented by
++ * a service that is not.
++ */
++async function requestSubjectAssertion({
++ userToken, audience, for: forServices, network, internalUrl, url, token, tokenClient, subject,
++ fetch: fetchImpl = globalThis.fetch, timeoutMs = 5000, log = null,
++} = {}) {
++ if (!userToken) throw new TypeError('requestSubjectAssertion: the person\'s own access token is required');
++ if (!audience) throw new TypeError('requestSubjectAssertion: pass the audience the assertion is for (openvibe.<service>)');
++ const endpoint = url || `${String(internalUrl || network || '').replace(/\/+$/, '')}/internal/subject-assertions`;
++ if (!/^https?:\/\//.test(endpoint)) throw new TypeError('requestSubjectAssertion: pass where Network lives (network or internalUrl)');
++ let bearer = token;
++ if (!bearer && tokenClient) bearer = await tokenClient.getToken();
++ if (!bearer) throw new TypeError('requestSubjectAssertion: pass the caller\'s own service token (token or tokenClient)');
++
++ const body = { user_token: String(userToken), audience: Array.isArray(audience) ? audience.map(String) : String(audience) };
++ if (forServices) body.for = (Array.isArray(forServices) ? forServices : [forServices]).map(String);
++ if (subject) body.subject = String(subject);
++
++ let res;
++ try {
++ res = await fetchImpl(endpoint, {
++ method: 'POST',
++ headers: { Authorization: `Bearer ${bearer}`, 'Content-Type': 'application/json', Accept: 'application/json' },
++ body: JSON.stringify(body),
++ signal: AbortSignal.timeout(timeoutMs),
++ });
++ } catch (err) {
++ throw new OpenVibeError({ code: 'assertion.network_error', status: 503, retryable: true, message: `Network did not answer: ${err && err.message}`, cause: err, method: 'POST', url: endpoint });
++ }
++ const json = await res.json().catch(() => null);
++ if (!res.ok || !json || !json.assertion) {
++ const problem = json || {};
++ throw OpenVibeError.fromResponse({
++ status: res.status,
++ body: { error: problem.code || problem.error || 'assertion.refused', error_description: problem.detail || problem.error_description || 'no assertion in the response' },
++ method: 'POST', url: endpoint,
++ });
++ }
++ return {
++ assertion: json.assertion,
++ subject: json.subject || null,
++ audience: json.audience || (Array.isArray(audience) ? audience : String(audience)),
++ jti: json.jti || null,
++ expires_at: json.expires_at || null,
++ expires_in: Number(json.expires_in) || ASSERTION_TTL_S,
++ };
++}
++
++/** The header a money call carries the assertion in: { 'x-ov-subject-assertion': assertion }. */
++function subjectAssertionHeader(assertion) { return { [ASSERTION_HEADER]: String(assertion || '') }; }
++
++/**
++ * The caller's own minter: one helper every service uses to ask Network for an assertion.
++ *
++ * const minter = createSubjectAssertionMinter({
++ * network: { internalUrl: 'http://127.0.0.1:4000' },
++ * oauth: { clientId: 'live', clientSecret: process.env.OV_OAUTH_CLIENT_SECRET },
++ * });
++ * const assertion = await minter.mint({ userToken: req.token, subject: 'usr_…', audience: 'openvibe.billing' });
++ *
++ * The service token it presents to Network is its own (client_credentials, audience openvibe.network);
++ * the person's own access token is what makes the assertion. Without a secret, without the person's
++ * token, or when Network is down, `mint` answers null: the call then travels the old way (the
++ * receiver's flag decides whether that is still allowed). Nothing here throws into the request.
++ */
++function createSubjectAssertionMinter({
++ network, internalUrl, url, oauth, audience: tokenAudience = 'openvibe.network', scope,
++ fetch: fetchImpl = globalThis.fetch, timeoutMs = 5000, log = null,
++} = {}) {
++ const base = String(internalUrl || (network && (network.internalUrl || network.url)) || url || '').replace(/\/+$/, '');
++ const endpoint = url || `${base}/internal/subject-assertions`;
++ let client = null;
++ /** The caller's own service token (cached by the token client). */
++ async function serviceToken() {
++ const secret = oauth && oauth.clientSecret;
++ if (!secret || !oauth.clientId) return null;
++ if (!client) {
++ const { serviceAuth } = require('openvibe-contracts');
++ client = serviceAuth.createTokenClient({
++ tokenUrl: `${base}/oauth/token`,
++ clientId: oauth.clientId,
++ clientSecret: secret,
++ audience: tokenAudience,
++ ...(scope ? { scope } : {}),
++ fetchImpl,
++ });
++ }
++ return await client.getToken();
++ }
++ /**
++ * The assertion for a person, or null when it cannot be had. Never throws: a money call must
++ * not fail because the proof could not be fetched (the receiver's flag is what turns it off).
++ */
++ async function mint({ userToken, subject, audience, for: forServices } = {}) {
++ if (!userToken || !audience) return null;
++ try {
++ const token = await serviceToken();
++ if (!token) return null;
++ const out = await requestSubjectAssertion({
++ userToken, subject, audience, for: forServices, token, url: endpoint, fetch: fetchImpl, timeoutMs,
++ });
++ return out.assertion || null;
++ } catch (err) {
++ try { (log && (log.warn || log.error) || (() => {})).call(log, `[openvibe-sdk] no subject assertion: ${(err && err.message) || err}`); } catch { /* never throw from logging */ }
++ return null;
++ }
++ }
++ return { mint, endpoint };
++}
++
++/** The ES256 signature check (raw r||s, as JWT ES256 signs it). */
++function verifyEs256(keys, header, input, sig) {
++ const byKid = header.kid ? keys.filter((k) => k.kid === header.kid) : [];
++ const candidates = byKid.length ? byKid : keys;
++ return candidates.some(({ key }) => {
++ try { return crypto.verify('sha256', Buffer.from(input), { key, dsaEncoding: 'ieee-p1363' }, sig); } catch { return false; }
++ });
++}
++
++/**
++ * Check a subject assertion.
++ *
++ * const r = await verifySubjectAssertion(assertion, { jwks: 'https://openvibe.network/api/.well-known/jwks', audience: 'openvibe.billing' });
++ * if (!r.ok) return problem(res, 403, r.code, { detail: r.reason });
++ * req.subject = { subject: r.claims.sub, act: r.claims.act, jti: r.claims.jti };
++ *
++ * `caller` is the service presenting the assertion (svc:live). The assertion names the service
++ * that vouched for the person (`act`) and the services that may carry it (`for`, the minting
++ * service alone by default): an assertion presented by a service that is not in `for` is refused.
++ * `seen` is a Map or Set of jtis already used: an assertion whose jti is in it is a replay and is
++ * refused.
++ *
++ * Returns { ok: true, claims } or { ok: false, code, reason } with a stable code:
++ * assertion.malformed | assertion.bad_signature | assertion.expired | assertion.not_yet_valid |
++ * assertion.wrong_issuer | assertion.wrong_audience | assertion.invalid_claims |
++ * assertion.actor_mismatch | assertion.replayed | assertion.no_key.
++ */
++async function verifySubjectAssertion(assertion, {
++ jwks, publicKey, audience, issuer, caller, now = Date.now(), clockSkewSec = CLOCK_SKEW_SEC, seen = null, log = null,
++} = {}) {
++ const fail = (code, reason) => ({ ok: false, code, reason });
++ const parts = typeof assertion === 'string' ? assertion.split('.') : [];
++ if (parts.length !== 3 || !parts[2]) return fail('assertion.malformed', 'not a signed JWT');
++ let header, claims;
++ try { header = JSON.parse(fromB64url(parts[0])); claims = JSON.parse(fromB64url(parts[1])); } catch { return fail('assertion.malformed', 'undecodable'); }
++ if (!header || header.alg !== 'ES256') return fail('assertion.malformed', `alg ${header && header.alg} not accepted`);
++ if (!claims || typeof claims !== 'object' || Array.isArray(claims)) return fail('assertion.malformed', 'the claims are not an object');
++
++ let keys;
++ try {
++ if (publicKey) keys = [{ kid: header.kid || null, key: typeof publicKey === 'string' ? crypto.createPublicKey(publicKey) : publicKey }];
++ else if (typeof jwks === 'string') keys = await ecJwksClient(jwks, { log }).keysForKid(header.kid);
++ else if (jwks && typeof jwks.keysForKid === 'function') keys = await jwks.keysForKid(header.kid);
++ else keys = ecKeysFromJwks(jwks);
++ } catch (err) {
++ try { (log && (log.warn || log.error) || (() => {})).call(log, `[openvibe-sdk] subject assertion not verified: ${(err && err.message) || err}`); } catch { /* never throw from logging */ }
++ return fail('assertion.no_key', 'the signing key is not loaded yet');
++ }
++ if (!keys.length) return fail('assertion.no_key', 'no ES256 verification key');
++
++ if (!verifyEs256(keys, header, `${parts[0]}.${parts[1]}`, fromB64url(parts[2]))) return fail('assertion.bad_signature', 'the signature does not verify');
++
++ const t = Math.floor(now / 1000);
++ if (typeof claims.exp !== 'number' || claims.exp + clockSkewSec < t) return fail('assertion.expired', 'the assertion has expired');
++ if (typeof claims.nbf === 'number' && claims.nbf - clockSkewSec > t) return fail('assertion.not_yet_valid', 'not valid yet');
++ if (typeof claims.iat !== 'number' || claims.iat - clockSkewSec > t) return fail('assertion.not_yet_valid', 'issued in the future');
++ if (issuer && claims.iss !== issuer) return fail('assertion.wrong_issuer', `issuer ${claims.iss}`);
++ if (audience) {
++ const aud = Array.isArray(claims.aud) ? claims.aud : claims.aud != null ? [claims.aud] : [];
++ const want = Array.isArray(audience) ? audience : [audience];
++ if (!want.some((a) => aud.includes(a))) return fail('assertion.wrong_audience', `not for ${want.join(', ')}`);
++ }
++ if (!USER_RE.test(String(claims.sub || ''))) return fail('assertion.invalid_claims', 'it names no person');
++ if (claims.act !== undefined && !SERVICE_RE.test(String(claims.act))) return fail('assertion.invalid_claims', 'it names no service');
++ if (claims.for !== undefined && (!Array.isArray(claims.for) || !claims.for.every((f) => SERVICE_RE.test(String(f))))) {
++ return fail('assertion.invalid_claims', 'it names no service that may carry it');
++ }
++ if (caller) {
++ // The services that may carry the assertion: the `for` list, or the minting service alone.
++ const may = Array.isArray(claims.for) && claims.for.length ? claims.for.map(String) : [String(claims.act || '')];
++ if (!may.includes(String(caller))) return fail('assertion.actor_mismatch', `it may not be carried by ${caller}`);
++ }
++ if (typeof claims.jti !== 'string' || !claims.jti) return fail('assertion.invalid_claims', 'it has no jti');
++ if (seen) {
++ if (seen instanceof Set) { if (seen.has(claims.jti)) return fail('assertion.replayed', 'this assertion was already used'); }
++ else if (seen instanceof Map) { if (seen.has(claims.jti)) return fail('assertion.replayed', 'this assertion was already used'); }
++ else if (typeof seen.has === 'function' && seen.has(claims.jti)) return fail('assertion.replayed', 'this assertion was already used');
++ }
++ return { ok: true, claims };
++}
++
++/**
++ * A verifier that remembers the jtis it has seen, so a replayed assertion is refused inside the
++ * replay window. One per service process:
++ *
++ * const verifier = createSubjectAssertionVerifier({ jwks, audience: 'openvibe.billing' });
++ * const r = await verifier.verify(assertion, { act: 'svc:live' });
++ */
++function createSubjectAssertionVerifier({ jwks, audience, issuer, clockSkewSec = CLOCK_SKEW_SEC, replayWindowMs = REPLAY_WINDOW_MS, now = () => Date.now(), log = null } = {}) {
++ const seen = new Map(); // jti -> the time it was seen
++ function prune() {
++ const t = now();
++ for (const [jti, at] of seen) if (t - at > replayWindowMs) seen.delete(jti);
++ }
++ return {
++ async verify(assertion, opts = {}) {
++ const r = await verifySubjectAssertion(assertion, { jwks, audience, issuer, clockSkewSec, log, ...opts });
++ if (!r.ok) return r;
++ prune();
++ const jti = r.claims.jti;
++ if (seen.has(jti)) return { ok: false, code: 'assertion.replayed', reason: 'this assertion was already used' };
++ seen.set(jti, now());
++ return r;
++ },
++ /** How many jtis are remembered (for /api/ready and tests). */
++ size() { prune(); return seen.size; },
++ seen,
++ };
++}
++
++module.exports = { requestSubjectAssertion, verifySubjectAssertion, createSubjectAssertionVerifier, createSubjectAssertionMinter, subjectAssertionHeader, ASSERTION_HEADER, ASSERTION_TTL_S, REPLAY_WINDOW_MS, CLOCK_SKEW_SEC };
ddiff --git a/src/auth/jwks.js b/src/auth/jwks.js
iindex 1258dee..15bbfe4 100644
---- a/src/auth/jwks.js
++++ b/src/auth/jwks.js
@@@ -26,19 +26,25 @@ const TTL_MS = 6 * 60 * 60 * 1000;
const MIN_REFETCH_MS = 30 * 1000;
const BACKOFF_MS = [1000, 2000, 4000, 8000, 16000, 32000, 64000, 128000, 300000];
--function keysFromJwks(doc) {
++/**
++ * The usable keys of a JWKS document. `kty` picks the family: 'RSA' (the session and service
++ * tokens, RS256) is the default, 'EC' (Network's subject-assertion key, ES256) is what
++ * ./subject-assertion.js asks for.
++ */
++function keysFromJwks(doc, { kty = 'RSA' } = {}) {
const out = [];
++ const alg = kty === 'EC' ? 'ES256' : 'RS256';
for (const jwk of (doc && Array.isArray(doc.keys) ? doc.keys : [])) {
-- if (!jwk || typeof jwk !== 'object' || jwk.kty !== 'RSA' || (jwk.use && jwk.use !== 'sig') || (jwk.alg && jwk.alg !== 'RS256')) continue;
++ if (!jwk || typeof jwk !== 'object' || jwk.kty !== kty || (jwk.use && jwk.use !== 'sig') || (jwk.alg && jwk.alg !== alg)) continue;
try { out.push({ kid: jwk.kid || null, key: crypto.createPublicKey({ key: jwk, format: 'jwk' }) }); } catch { /* skip unusable key */ }
}
-- if (!out.length && doc && typeof doc.public_key === 'string') {
++ if (!out.length && kty === 'RSA' && doc && typeof doc.public_key === 'string') {
try { out.push({ kid: null, key: crypto.createPublicKey(doc.public_key) }); } catch { /* skip */ }
}
return out;
}
--function createJwksClient(url, { fetch: fetchImpl = globalThis.fetch, log = null, ttlMs = TTL_MS, minRefetchMs = MIN_REFETCH_MS, timeoutMs = 5000, now = () => Date.now() } = {}) {
++function createJwksClient(url, { fetch: fetchImpl = globalThis.fetch, log = null, ttlMs = TTL_MS, minRefetchMs = MIN_REFETCH_MS, timeoutMs = 5000, now = () => Date.now(), kty = 'RSA' } = {}) {
const st = { keys: null, fetchedAt: 0, attemptAt: 0, kidAt: 0, failures: 0, nextTryAt: 0, lastError: null, inflight: null, timer: null, retry: null };
const warn = (msg) => { try { (log && (log.warn || log.error) || console.warn).call(log || console, msg); } catch { /* never throw from logging */ } };
const info = (msg) => { try { (log && (log.info || log.log) || (() => {})).call(log || console, msg); } catch { /* */ } };
@@@ -50,8 +56,8 @@ function createJwksClient(url, { fetch: fetchImpl = globalThis.fetch, log = null
try {
const res = await fetchImpl(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) });
if (!res.ok) throw new Error(`answered ${res.status}`);
-- const keys = keysFromJwks(await res.json());
-- if (!keys.length) throw new Error('no usable RS256 key in the document');
++ const keys = keysFromJwks(await res.json(), { kty });
++ if (!keys.length) throw new Error(`no usable ${kty === 'EC' ? 'ES256' : 'RS256'} key in the document`);
if (st.failures) info(`[openvibe-sdk] JWKS ${url} recovered after ${st.failures} failed fetch${st.failures === 1 ? '' : 'es'}`);
Object.assign(st, { keys, fetchedAt: now(), failures: 0, nextTryAt: 0, lastError: null });
return keys;
@@@ -119,11 +125,13 @@ function createJwksClient(url, { fetch: fetchImpl = globalThis.fetch, log = null
return api;
}
--// One client per URL for the process: verifyUserToken/verifyAppToken share it, and services read its status.
++// One client per URL and key family for the process: verifyUserToken/verifyAppToken share the RSA
++// one, the subject-assertion verifier (./subject-assertion.js) the EC one, and services read the status.
const clients = new Map();
function jwksClient(url, opts = {}) {
-- let c = clients.get(url);
-- if (!c) { c = createJwksClient(url, opts); clients.set(url, c); }
++ const key = `${url}|${opts.kty || 'RSA'}`;
++ let c = clients.get(key);
++ if (!c) { c = createJwksClient(url, opts); clients.set(key, c); }
return c;
}
/** Status of every JWKS client this process uses (for /api/ready). */
ddiff --git a/src/commerce.js b/src/commerce.js
iindex df7ff45..1de3401 100644
---- a/src/commerce.js
++++ b/src/commerce.js
@@@ -73,10 +73,10 @@ function createCommerceClient(config, { caps, fetchImpl = globalThis.fetch, toke
return clients.get(cap);
};
-- async function call(logical, method, path, { body, key, traceparent, retried = false } = {}) {
++ async function call(logical, method, path, { body, key, traceparent, headers: extraHeaders, retried = false } = {}) {
let auth;
try { auth = await tokensFor(logical).authHeaders(); } catch (e) { throw new CommerceError(`token (${capFor(logical)}): ${e.message}`); }
-- const headers = { Accept: 'application/json', ...auth };
++ const headers = { Accept: 'application/json', ...auth, ...(extraHeaders || {}) };
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (key) headers['Idempotency-Key'] = key;
if (traceparent) headers.traceparent = traceparent;
@@@ -86,7 +86,7 @@ function createCommerceClient(config, { caps, fetchImpl = globalThis.fetch, toke
} catch (e) {
throw new CommerceError(`Billing unreachable: ${e.message}`);
}
-- if (res.status === 401 && !retried) { tokensFor(logical).invalidate(); return await call(logical, method, path, { body, key, traceparent, retried: true }); }
++ if (res.status === 401 && !retried) { tokensFor(logical).invalidate(); return await call(logical, method, path, { body, key, traceparent, headers: extraHeaders, retried: true }); }
const data = await res.json().catch(() => null);
if (!res.ok) {
const code = (data && (data.code || data.error)) || `http_${res.status}`;
@@@ -97,30 +97,30 @@ function createCommerceClient(config, { caps, fetchImpl = globalThis.fetch, toke
return {
/** { intent, checkout_url }: kind purchase buys `bits` of credit; kind subscription is a checkout for `subject` to `creator` (at `tier`'s price, 1 by default). */
-- createIntent: async ({ provider, kind = 'purchase', subject, bits, creator, tier, autoRenew, successUrl, cancelUrl, key, traceparent }) => await call('intent', 'POST', '/api/v1/intents', {
++ createIntent: async ({ provider, kind = 'purchase', subject, bits, creator, tier, autoRenew, successUrl, cancelUrl, key, traceparent, headers }) => await call('intent', 'POST', '/api/v1/intents', {
body: {
provider, kind, subject: user(subject),
...(kind === 'subscription' ? { streamer: user(creator), auto_renew: !!autoRenew, ...(tier != null ? { tier } : {}) } : { bits }),
success_url: successUrl, cancel_url: cancelUrl,
},
-- key, traceparent,
++ key, traceparent, headers,
}),
/** { transaction }: credit of `from` to payable of `to`, tagged with the interaction. */
-- createTransfer: async ({ from, to, amount, kind, target, message, key, traceparent }) => await call('transfer', 'POST', '/api/v1/transfers', {
++ createTransfer: async ({ from, to, amount, kind, target, message, key, traceparent, headers }) => await call('transfer', 'POST', '/api/v1/transfers', {
body: { from: user(from), to: user(to), amount, kind, target, message: message || undefined },
-- key, traceparent,
++ key, traceparent, headers,
}),
refundTransfer: async ({ txnId, amount, reason, key }) => await call('transfer', 'POST', `/api/v1/transfers/${encodeURIComponent(txnId)}/refund`, { body: { amount, reason }, key }),
/** { subscription, entitlement, transaction }: one period paid from the member's credit (at `tier`'s price, 1 by default). */
-- subscribeWithCredit: async ({ subscriber, creator, tier, autoRenew, key, traceparent }) => await call('subscription', 'POST', '/api/v1/subscriptions', {
++ subscribeWithCredit: async ({ subscriber, creator, tier, autoRenew, key, traceparent, headers }) => await call('subscription', 'POST', '/api/v1/subscriptions', {
body: { subscriber: user(subscriber), streamer: user(creator), source: 'credit', auto_renew: autoRenew !== false, ...(tier != null ? { tier } : {}) },
-- key, traceparent,
++ key, traceparent, headers,
}),
-- cancelSubscription: async ({ id, key, traceparent }) => await call('subscription', 'POST', `/api/v1/subscriptions/${encodeURIComponent(id)}/cancel`, { body: {}, key, traceparent }),
-- getSubscription: async (id) => await call('entitlement', 'GET', `/api/v1/subscriptions/${encodeURIComponent(id)}`),
-- listSubscriptions: async ({ streamer, subscriber, status } = {}) => await call('entitlement', 'GET', `/api/v1/subscriptions${qs({ streamer, subscriber, status })}`),
++ cancelSubscription: async ({ id, key, traceparent, headers }) => await call('subscription', 'POST', `/api/v1/subscriptions/${encodeURIComponent(id)}/cancel`, { body: {}, key, traceparent, headers }),
++ getSubscription: async (id, { headers } = {}) => await call('entitlement', 'GET', `/api/v1/subscriptions/${encodeURIComponent(id)}`, { headers }),
++ listSubscriptions: async ({ streamer, subscriber, status } = {}, { headers } = {}) => await call('entitlement', 'GET', `/api/v1/subscriptions${qs({ streamer, subscriber, status })}`, { headers }),
/** { active, expires_at, subscription }: the authoritative answer. */
-- entitlement: async (subject, creator) => await call('entitlement', 'GET', `/api/v1/entitlements/${encodeURIComponent(subject)}${qs({ streamer: creator })}`),
++ entitlement: async (subject, creator, { headers } = {}) => await call('entitlement', 'GET', `/api/v1/entitlements/${encodeURIComponent(subject)}${qs({ streamer: creator })}`, { headers }),
rates: async () => await call(caps.rates ? 'rates' : 'entitlement', 'GET', '/api/v1/rates'),
/** { streamer, tiers: [{ tier, price_cents, status }], default_price_cents, min_price_cents, max_price_cents, max_tiers }: Billing prices a creator's tiers. */
listTiers: async (creator) => await call(caps.tier ? 'tier' : 'intent', 'GET', `/api/v1/tiers/${encodeURIComponent(creator)}`),
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 545a9fd..341e018 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.89.0';
++const SDK_VERSION = '0.90.0';
module.exports = {
createClient,
ddiff --git a/test/browser-entries.test.js b/test/browser-entries.test.js
iindex 7e86a4d..f811e1f 100644
---- a/test/browser-entries.test.js
++++ b/test/browser-entries.test.js
@@@ -76,7 +76,7 @@ run([
for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);
}
assert.deepEqual(problems, []);
-- assert.deepEqual(blocked.sort(), ['./account-data', './actor', './app-signin', './blog', './cache', './commerce', './consent', './cost', './coupons', './db', './deals', './delivery', './download', './events', './exchange', './food', './govern', './host', './identity', './inventory', './limits', './live', './news', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './resources', './reviews', './run', './runtime', './service', './share', './space', './sso', './storage', './telemetry', './testing', './tips', './trade', './usage', './valkey', './vibes', './video', './vip', './wiki', './work', './zone']);
++ assert.deepEqual(blocked.sort(), ['./account-data', './actor', './app-signin', './assertions', './blog', './cache', './commerce', './consent', './cost', './coupons', './db', './deals', './delivery', './download', './events', './exchange', './food', './govern', './host', './identity', './inventory', './limits', './live', './news', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './resources', './reviews', './run', './runtime', './service', './share', './space', './sso', './storage', './telemetry', './testing', './tips', './trade', './usage', './valkey', './vibes', './video', './vip', './wiki', './work', './zone']);
const rel = [...scanned].map((f) => path.relative(ROOT, f).split(path.sep).join('/')); // the same names on Windows
for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/bot.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {
assert.ok(rel.includes(must), `${must} is scanned`);
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex 5348b15..a36ceeb 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -68,7 +68,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
-- assert.equal(subpaths.length, 82); // 0.89.0 adds openvibe-sdk/exchange
++ assert.equal(subpaths.length, 83); // 0.89.0 adds openvibe-sdk/exchange and H1 adds openvibe-sdk/assertions
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
ddiff --git a/test/subject-assertion.test.js b/test/subject-assertion.test.js
nnew file mode 100644
iindex 0000000..e43d66e
---- /dev/null
++++ b/test/subject-assertion.test.js
@@@ -0,0 +1,209 @@
++'use strict';
++/**
++ * Subject assertions (ADR-003, the 2026-11-06 amendment; plan H1): asking Network to sign the
++ * person's own token, and checking the answer.
++ *
++ * A forged, expired, replayed, wrong-audience or wrong-service assertion is refused, and a caller
++ * without the person's token gets nothing.
++ */
++const assert = require('node:assert/strict');
++const crypto = require('node:crypto');
++const { run } = require('./helpers');
++const { requestSubjectAssertion, verifySubjectAssertion, createSubjectAssertionVerifier, createSubjectAssertionMinter } = require('../src/assertions');
++
++const PERSON = 'usr_01H8XYZABCDEFGHJKMNPQRRSTV';
++const OTHER = 'usr_01H8XYZABCDEFGHJKMNPQRRSTW';
++
++/** An EC (ES256) key pair, Network's assertion key in the test. */
++function keyPair() { return crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }); }
++
++/** The JWKS document Network publishes for its assertion key. */
++function jwks(pub, kid = 'ov-network-assertion-1') {
++ return { keys: [{ ...pub.export({ format: 'jwk' }), use: 'sig', alg: 'ES256', kid }] };
++}
++
++const b64url = (b) => Buffer.from(b).toString('base64url');
++
++/** Sign claims the way Network does (ES256, raw r||s). */
++function sign(claims, priv, kid = 'ov-network-assertion-1') {
++ const header = b64url(JSON.stringify({ alg: 'ES256', typ: 'JWT', kid }));
++ const body = b64url(JSON.stringify(claims));
++ const sig = crypto.sign('sha256', Buffer.from(`${header}.${body}`), { key: priv, dsaEncoding: 'ieee-p1363' });
++ return `${header}.${body}.${b64url(sig)}`;
++}
++
++/** The claims of a fresh assertion. */
++function claims(over = {}) {
++ const now = Math.floor(Date.now() / 1000);
++ return { iss: 'https://openvibe.network', sub: PERSON, aud: 'openvibe.billing', act: 'svc:live', jti: `as_${crypto.randomBytes(8).toString('hex')}`, iat: now, exp: now + 60, ...over };
++}
++
++run([
++ ['a good assertion verifies and carries the person, the service and the jti', async () => {
++ const kp = keyPair();
++ const c = claims();
++ const r = await verifySubjectAssertion(sign(c, kp.privateKey), { jwks: jwks(kp.publicKey), audience: 'openvibe.billing' });
++ assert.equal(r.ok, true);
++ assert.equal(r.claims.sub, PERSON);
++ assert.equal(r.claims.act, 'svc:live');
++ assert.equal(r.claims.jti, c.jti);
++ }],
++
++ ['a forged assertion (another key) is refused', async () => {
++ const mine = keyPair(), theirs = keyPair();
++ const r = await verifySubjectAssertion(sign(claims(), theirs.privateKey), { jwks: jwks(mine.publicKey), audience: 'openvibe.billing' });
++ assert.equal(r.ok, false);
++ assert.equal(r.code, 'assertion.bad_signature');
++ }],
++
++ ['a tampered claim (another person) does not verify', async () => {
++ const kp = keyPair();
++ const token = sign(claims(), kp.privateKey);
++ const [h, p, s] = token.split('.');
++ const body = JSON.parse(Buffer.from(p, 'base64url').toString('utf8'));
++ body.sub = OTHER;
++ const forged = `${h}.${b64url(JSON.stringify(body))}.${s}`;
++ const r = await verifySubjectAssertion(forged, { jwks: jwks(kp.publicKey), audience: 'openvibe.billing' });
++ assert.equal(r.ok, false);
++ assert.equal(r.code, 'assertion.bad_signature');
++ }],
++
++ ['an expired assertion is refused', async () => {
++ const kp = keyPair();
++ const now = Math.floor(Date.now() / 1000);
++ const r = await verifySubjectAssertion(sign(claims({ iat: now - 600, exp: now - 300 }), kp.privateKey), { jwks: jwks(kp.publicKey), audience: 'openvibe.billing' });
++ assert.equal(r.ok, false);
++ assert.equal(r.code, 'assertion.expired');
++ }],
++
++ ['an assertion for another audience is refused', async () => {
++ const kp = keyPair();
++ const r = await verifySubjectAssertion(sign(claims({ aud: 'openvibe.tips' }), kp.privateKey), { jwks: jwks(kp.publicKey), audience: 'openvibe.billing' });
++ assert.equal(r.ok, false);
++ assert.equal(r.code, 'assertion.wrong_audience');
++ }],
++
++ ['an assertion minted for another service is refused', async () => {
++ const kp = keyPair();
++ const r = await verifySubjectAssertion(sign(claims({ act: 'svc:vip' }), kp.privateKey), { jwks: jwks(kp.publicKey), audience: 'openvibe.billing', caller: 'svc:live' });
++ assert.equal(r.ok, false);
++ assert.equal(r.code, 'assertion.actor_mismatch');
++ }],
++
++ ['an assertion a chain may carry is accepted by the service it names', async () => {
++ const kp = keyPair();
++ // Live vouches for the person; Tips may carry it on to Billing.
++ const c = claims({ act: 'svc:live', for: ['svc:live', 'svc:tips'], aud: ['openvibe.tips', 'openvibe.billing'] });
++ const r = await verifySubjectAssertion(sign(c, kp.privateKey), { jwks: jwks(kp.publicKey), audience: 'openvibe.billing', caller: 'svc:tips' });
++ assert.equal(r.ok, true);
++ assert.equal(r.claims.sub, PERSON);
++ const other = await verifySubjectAssertion(sign(c, kp.privateKey), { jwks: jwks(kp.publicKey), audience: 'openvibe.billing', caller: 'svc:vip' });
++ assert.equal(other.ok, false);
++ assert.equal(other.code, 'assertion.actor_mismatch');
++ }],
++
++ ['an assertion naming no person is refused', async () => {
++ const kp = keyPair();
++ const r = await verifySubjectAssertion(sign(claims({ sub: 'not-a-subject' }), kp.privateKey), { jwks: jwks(kp.publicKey), audience: 'openvibe.billing' });
++ assert.equal(r.ok, false);
++ assert.equal(r.code, 'assertion.invalid_claims');
++ }],
++
++ ['a replayed assertion is refused the second time', async () => {
++ const kp = keyPair();
++ const v = createSubjectAssertionVerifier({ jwks: jwks(kp.publicKey), audience: 'openvibe.billing' });
++ const token = sign(claims(), kp.privateKey);
++ const first = await v.verify(token);
++ assert.equal(first.ok, true);
++ const second = await v.verify(token);
++ assert.equal(second.ok, false);
++ assert.equal(second.code, 'assertion.replayed');
++ }],
++
++ ['an alg=none assertion is refused', async () => {
++ const kp = keyPair();
++ const c = claims();
++ const token = `${b64url(JSON.stringify({ alg: 'none', typ: 'JWT' }))}.${b64url(JSON.stringify(c))}.`;
++ const r = await verifySubjectAssertion(token, { jwks: jwks(kp.publicKey), audience: 'openvibe.billing' });
++ assert.equal(r.ok, false);
++ assert.equal(r.code, 'assertion.malformed');
++ }],
++
++ ['no key at all is refused, not accepted', async () => {
++ const kp = keyPair();
++ const r = await verifySubjectAssertion(sign(claims(), kp.privateKey), { jwks: { keys: [] }, audience: 'openvibe.billing' });
++ assert.equal(r.ok, false);
++ assert.equal(r.code, 'assertion.no_key');
++ }],
++
++ ['asking for an assertion sends the person\'s own token and the caller\'s token', async () => {
++ let seen = null;
++ const fetchImpl = async (url, opts) => {
++ seen = { url, opts };
++ return { ok: true, status: 200, json: async () => ({ assertion: 'a.b.c', subject: PERSON, audience: 'openvibe.billing', jti: 'as_1', expires_in: 60 }) };
++ };
++ const out = await requestSubjectAssertion({
++ userToken: 'person-token', audience: 'openvibe.billing', internalUrl: 'http://127.0.0.1:4000', token: 'service-token', fetch: fetchImpl,
++ });
++ assert.equal(seen.url, 'http://127.0.0.1:4000/internal/subject-assertions');
++ assert.equal(seen.opts.headers.Authorization, 'Bearer service-token');
++ assert.deepEqual(JSON.parse(seen.opts.body), { user_token: 'person-token', audience: 'openvibe.billing' });
++ assert.equal(out.assertion, 'a.b.c');
++ assert.equal(out.subject, PERSON);
++ }],
++
++ ['a caller without the person\'s token gets nothing', async () => {
++ await assert.rejects(requestSubjectAssertion({ audience: 'openvibe.billing', token: 'service-token' }), /own access token is required/);
++ }],
++
++ ['a refusal from Network is an error, not an assertion', async () => {
++ const fetchImpl = async () => ({ ok: false, status: 401, json: async () => ({ code: 'assertion.no_user_token', detail: 'that token names no person' }) });
++ await assert.rejects(
++ requestSubjectAssertion({ userToken: 'x', audience: 'openvibe.billing', token: 'service-token', internalUrl: 'http://127.0.0.1:4000', fetch: fetchImpl }),
++ (err) => { assert.equal(err.status, 401); assert.match(err.message, /names no person/); return true; },
++ );
++ }],
++
++ ['a token client may bring its own token', async () => {
++ let seen = null;
++ const fetchImpl = async (url, opts) => { seen = opts; return { ok: true, status: 200, json: async () => ({ assertion: 'a.b.c' }) }; };
++ await requestSubjectAssertion({
++ userToken: 'person-token', audience: 'openvibe.billing', internalUrl: 'http://127.0.0.1:4000',
++ tokenClient: { getToken: async () => 'from-client' }, fetch: fetchImpl,
++ });
++ assert.equal(seen.headers.Authorization, 'Bearer from-client');
++ }],
++ ['the minter asks Network with the service token and the person token', async () => {
++ const seen = [];
++ const fetchImpl = async (url, opts) => {
++ seen.push({ url, opts });
++ if (url.endsWith('/oauth/token')) {
++ return { ok: true, status: 200, json: async () => ({ access_token: 'service-token', expires_in: 300 }) };
++ }
++ return { ok: true, status: 200, json: async () => ({ assertion: 'a.b.c', subject: PERSON, audience: 'openvibe.billing' }) };
++ };
++ const minter = createSubjectAssertionMinter({
++ internalUrl: 'http://127.0.0.1:4000', oauth: { clientId: 'live', clientSecret: 'secret' }, fetch: fetchImpl,
++ });
++ const assertion = await minter.mint({ userToken: 'person-token', subject: PERSON, audience: 'openvibe.billing' });
++ assert.equal(assertion, 'a.b.c');
++ const mint = seen.find((c) => c.url.endsWith('/internal/subject-assertions'));
++ assert.ok(mint, 'the mint route was called');
++ assert.equal(mint.opts.headers.Authorization, 'Bearer service-token');
++ assert.deepEqual(JSON.parse(mint.opts.body), { user_token: 'person-token', audience: 'openvibe.billing', subject: PERSON });
++ }],
++
++ ['the minter answers null (never throws) when the assertion cannot be had', async () => {
++ const noSecret = createSubjectAssertionMinter({ internalUrl: 'http://127.0.0.1:4000', oauth: { clientId: 'live' } });
++ assert.equal(await noSecret.mint({ userToken: 'person-token', audience: 'openvibe.billing' }), null);
++
++ const noToken = createSubjectAssertionMinter({ internalUrl: 'http://127.0.0.1:4000', oauth: { clientId: 'live', clientSecret: 's' } });
++ assert.equal(await noToken.mint({ audience: 'openvibe.billing' }), null);
++
++ const down = createSubjectAssertionMinter({
++ internalUrl: 'http://127.0.0.1:4000', oauth: { clientId: 'live', clientSecret: 's' },
++ fetch: async () => { throw new Error('ECONNREFUSED'); },
++ });
++ assert.equal(await down.mint({ userToken: 'person-token', audience: 'openvibe.billing' }), null);
++ }],
++]);
ddiff --git a/types/assertions.d.ts b/types/assertions.d.ts
nnew file mode 100644
iindex 0000000..3a20659
---- /dev/null
++++ b/types/assertions.d.ts
@@@ -0,0 +1,118 @@
++/**
++ * openvibe-sdk/assertions: subject assertions (ADR-003, the 2026-11-06 amendment; plan H1).
++ */
++import type { FetchLike } from './core';
++import type { JwksClient, JwksClientOptions } from './auth';
++/** The claims of a subject assertion (ADR-003, the 2026-11-06 amendment; plan H1). */
++export interface SubjectAssertionClaims {
++ /** The Network issuer. */
++ iss: string;
++ /** The person: usr_… */
++ sub: string;
++ /** The service the assertion is for (openvibe.billing). */
++ aud: string | string[];
++ /** The service the assertion was minted for (svc:live). */
++ act?: string;
++ /** Unique per assertion; a replayed one is refused. */
++ jti: string;
++ iat: number;
++ exp: number;
++ nbf?: number;
++}
++export type SubjectAssertionResult = { ok: true; claims: SubjectAssertionClaims } | { ok: false; code: string; reason: string };
++export interface RequestSubjectAssertionOptions {
++ /** The person's own Network access token (their session). Without it Network signs nothing. */
++ userToken: string;
++ /** The service the assertion is for (openvibe.billing), or a list when it travels a chain. */
++ audience: string | string[];
++ /** The services that may carry it (the minting service is always in it). */
++ for?: string | string[];
++ /** Where Network lives (public URL). */
++ network?: string;
++ /** Where Network's /internal lives (loopback, same host). */
++ internalUrl?: string;
++ /** The whole endpoint, when it is neither of the two above. */
++ url?: string;
++ /** The caller's own service token (Bearer). */
++ token?: string;
++ /** Or a client with getToken() (the SDK's createServiceTokenClient). */
++ tokenClient?: { getToken(ctx?: { audience?: string; scope?: string }): Promise<string> };
++ /** The person the caller says it acts for; Network checks it against the token. */
++ subject?: string;
++ fetch?: FetchLike;
++ timeoutMs?: number;
++ log?: JwksClientOptions['log'];
++}
++export interface SubjectAssertion {
++ /** The signed JWT, to be sent as X-OV-Subject-Assertion. */
++ assertion: string;
++ /** The person the assertion names. */
++ subject: string | null;
++ audience: string | string[];
++ jti: string | null;
++ expires_at: string | null;
++ expires_in: number;
++}
++/** Ask Network to sign the person's own token into an assertion for one audience. */
++export declare function requestSubjectAssertion(opts: RequestSubjectAssertionOptions): Promise<SubjectAssertion>;
++export interface VerifySubjectAssertionOptions {
++ /** Network's JWKS (a URL, a document or a client), or the EC public key. */
++ jwks?: { keys?: object[]; public_key?: string } | string | JwksClient;
++ publicKey?: string | object;
++ /** The audience this service answers for (openvibe.billing). */
++ audience?: string | string[];
++ issuer?: string;
++ /** The service presenting the assertion (svc:live): it must be one the assertion names. */
++ caller?: string;
++ clockSkewSec?: number;
++ now?: number;
++ /** A Map or Set of jtis already used: a replay is refused. */
++ seen?: Set<string> | Map<string, number> | { has(jti: string): boolean };
++ log?: JwksClientOptions['log'];
++}
++/** Check a subject assertion: signature, audience, expiry, the service it was minted for, the jti. */
++export declare function verifySubjectAssertion(assertion: string, opts?: VerifySubjectAssertionOptions): Promise<SubjectAssertionResult>;
++export interface SubjectAssertionVerifier {
++ verify(assertion: string, opts?: { caller?: string; seen?: Set<string> | Map<string, number> | { has(jti: string): boolean } }): Promise<SubjectAssertionResult>;
++ /** How many jtis are remembered (the replay window). */
++ size(): number;
++}
++/** A verifier that remembers the jtis it has seen, so a replayed assertion is refused. */
++export declare function createSubjectAssertionVerifier(opts: { jwks?: VerifySubjectAssertionOptions['jwks']; audience?: string | string[]; issuer?: string; clockSkewSec?: number; replayWindowMs?: number; log?: JwksClientOptions['log'] }): SubjectAssertionVerifier;
++/** The header a money call carries the assertion in. */
++export declare function subjectAssertionHeader(assertion: string): { 'x-ov-subject-assertion': string };
++export interface SubjectAssertionMinterOptions {
++ /** Where Network lives (public URL). */
++ network?: string | { internalUrl?: string; url?: string };
++ /** Where Network's /internal lives (loopback, same host). */
++ internalUrl?: string;
++ /** The whole endpoint, when it is neither of the two above. */
++ url?: string;
++ /** The caller's own OAuth client: the service token it presents to Network. */
++ oauth?: { clientId?: string; clientSecret?: string };
++ /** The audience of that service token (openvibe.network). */
++ audience?: string;
++ scope?: string | string[];
++ fetch?: FetchLike;
++ timeoutMs?: number;
++ log?: JwksClientOptions['log'];
++}
++export interface SubjectAssertionMinter {
++ /** The assertion for a person, or null when it cannot be had (never throws). */
++ mint(opts: { userToken?: string | null; subject?: string; audience: string | string[]; for?: string | string[] }): Promise<string | null>;
++ /** Where the assertions are asked for. */
++ endpoint: string;
++}
++/** The caller's own minter: ask Network to sign the person's token into an assertion. */
++export declare function createSubjectAssertionMinter(opts?: SubjectAssertionMinterOptions): SubjectAssertionMinter;
++
++
++
++/** The header a money call carries the assertion in. */
++export declare const ASSERTION_HEADER: string;
++/** How long an assertion lives (seconds). */
++export declare const ASSERTION_TTL_S: number;
++/** The default replay window (milliseconds). */
++export declare const REPLAY_WINDOW_MS: number;
++/** How much clock difference is forgiven (seconds). */
++export declare const CLOCK_SKEW_SEC: number;
ddiff --git a/types/commerce.d.ts b/types/commerce.d.ts
iindex 38b7da3..2563734 100644
---- a/types/commerce.d.ts
++++ b/types/commerce.d.ts
@@@ -26,16 +26,16 @@ export type CommerceOptions = {
tokenClients?: Record<string, CommerceTokenClient> | null;
timeoutMs?: number;
};
--export type CommerceCall = { key?: string; traceparent?: string };
++export type CommerceCall = { key?: string; traceparent?: string; /** Extra headers for the call (the subject assertion rides on them). */ headers?: Record<string, string> };
export type CommerceClient = {
createIntent(a: { provider: string; kind?: 'purchase' | 'subscription'; subject: string; bits?: number; creator?: string; tier?: number; autoRenew?: boolean; successUrl?: string; cancelUrl?: string } & CommerceCall): Promise<any>;
createTransfer(a: { from: string; to: string; amount: number; kind?: string; target?: unknown; message?: string } & CommerceCall): Promise<any>;
refundTransfer(a: { txnId: string; amount?: number; reason?: string; key?: string }): Promise<any>;
subscribeWithCredit(a: { subscriber: string; creator: string; tier?: number; autoRenew?: boolean } & CommerceCall): Promise<any>;
cancelSubscription(a: { id: string } & CommerceCall): Promise<any>;
-- getSubscription(id: string): Promise<any>;
-- listSubscriptions(a?: { streamer?: string; subscriber?: string; status?: string }): Promise<any>;
-- entitlement(subject: string, creator?: string): Promise<any>;
++ getSubscription(id: string, o?: { headers?: Record<string, string> }): Promise<any>;
++ listSubscriptions(a?: { streamer?: string; subscriber?: string; status?: string }, o?: { headers?: Record<string, string> }): Promise<any>;
++ entitlement(subject: string, creator?: string, o?: { headers?: Record<string, string> }): Promise<any>;
rates(): Promise<any>;
/** A creator's tier prices (Billing, contracts 0.201.0); uses caps.tier, else caps.intent. */
listTiers(creator: string): Promise<{ streamer: { type: 'user'; id: string }; tiers: CommerceTier[]; default_price_cents: number; min_price_cents: number; max_price_cents: number; max_tiers: number }>;