openvibers/openvibe.contracts Commits
v0.2.0: service principal tokens (verify, requireCapability guard with legacy-key compatibility and audit hook, caching client-credentials client), network.coins.transfer, coins capabilities act only for their own app_id
Changes
@@@ -2,7 +2,7 @@> Machine-readable contracts for the whole OpenVibe network.--**Status:** alpha, v0.1 (Wave 1). Schemas, manifests and helpers are real and tested; no service validates against them in production yet.++**Status:** alpha, v0.2 (Wave 1). Schemas, manifests and helpers are real and tested; no service validates against them in production yet.**Plan:** OpenVibe Development Roadmap, Wave 1 (implementation plan rev 3, §3.1 and §18.11).**License:** AGPL-3.0 (same as every OpenVibe service).@@@ -28,6 +28,20 @@ contracts.capabilities.check(tokenClaims, 'media.object.upload', { namespace: 'l// { allowed: false, code: 'capability.denied' | 'capability.namespace_denied' | 'capability.unknown', reason }```++Service principals (v0.2): a caller gets a short-lived token from Network with its OAuth client credentials, and a receiver guards a route with the capability it performs:++++```js++const tokens = contracts.serviceAuth.createTokenClient({ tokenUrl: `${NETWORK}/oauth/token`, clientId, clientSecret, audience: 'openvibe.network' });++await fetch(url, { headers: await tokens.authHeaders() });++++router.post('/coins/credit', contracts.serviceAuth.requireCapability('network.coins.credit', {++ publicKey, issuer, audience: 'openvibe.network',++ legacy: (req) => req.headers['x-internal-key'] === key, // old callers keep working during migration++}), handler);++```++++A request that presents a Bearer token is judged only on that token; a bad token is never rescued by a legacy header.++TypeScript types: `generated/typescript/index.d.ts` (`"types"` in package.json). All schemas in one file: `generated/json-schema/bundle.json`.## What's in v0.1@@@ -1,14 +1,93 @@{"_about": "Every contract this repository publishes. A contract id is stable forever; a breaking change is a new major (new file .v2.json, new catalog entry) while the old one is deprecated with a replacement and a window. Minor versions only add optional fields.","contracts": [-- { "id": "identity.subject-ref", "version": "1.0.0", "owner": "network", "schema": "identity/subject-ref.v1.json", "visibility": "public", "compatibility": "backward", "status": "active", "adr": "ADR-001" },-- { "id": "identity.legacy-identity-map", "version": "1.0.0", "owner": "network", "schema": "identity/legacy-identity-map.v1.json", "visibility": "first-party", "compatibility": "backward", "status": "active", "adr": "ADR-001" },-- { "id": "identity.service-token-claims", "version": "1.0.0", "owner": "network", "schema": "identity/service-token-claims.v1.json", "visibility": "public", "compatibility": "backward", "status": "active", "adr": "ADR-003" },-- { "id": "common.entity-ref", "version": "1.0.0", "owner": "contracts", "schema": "common/entity-ref.v1.json", "visibility": "public", "compatibility": "backward", "status": "active" },-- { "id": "errors.problem", "version": "1.0.0", "owner": "contracts", "schema": "errors/problem.v1.json", "visibility": "public", "compatibility": "backward", "status": "active", "adr": "ADR-002" },-- { "id": "registry.service-manifest", "version": "1.0.0", "owner": "network", "schema": "registry/service-manifest.v1.json", "visibility": "public", "compatibility": "backward", "status": "active" },-- { "id": "capabilities.capability", "version": "1.0.0", "owner": "network", "schema": "capabilities/capability.v1.json", "visibility": "public", "compatibility": "backward", "status": "active", "adr": "ADR-003" },-- { "id": "events.event-envelope", "version": "1.0.0", "owner": "events", "schema": "events/event-envelope.v1.json", "visibility": "public", "compatibility": "backward", "status": "active", "adr": "ADR-004" },-- { "id": "media.media-ref", "version": "1.0.0", "owner": "media", "schema": "media/media-ref.v1.json", "visibility": "public", "compatibility": "backward", "status": "active", "adr": "ADR-006" }++ {++ "id": "identity.subject-ref",++ "version": "1.0.0",++ "owner": "network",++ "schema": "identity/subject-ref.v1.json",++ "visibility": "public",++ "compatibility": "backward",++ "status": "active",++ "adr": "ADR-001"++ },++ {++ "id": "identity.legacy-identity-map",++ "version": "1.0.0",++ "owner": "network",++ "schema": "identity/legacy-identity-map.v1.json",++ "visibility": "first-party",++ "compatibility": "backward",++ "status": "active",++ "adr": "ADR-001"++ },++ {++ "id": "identity.service-token-claims",++ "version": "1.1.0",++ "owner": "network",++ "schema": "identity/service-token-claims.v1.json",++ "visibility": "public",++ "compatibility": "backward",++ "status": "active",++ "adr": "ADR-003"++ },++ {++ "id": "common.entity-ref",++ "version": "1.0.0",++ "owner": "contracts",++ "schema": "common/entity-ref.v1.json",++ "visibility": "public",++ "compatibility": "backward",++ "status": "active"++ },++ {++ "id": "errors.problem",++ "version": "1.0.0",++ "owner": "contracts",++ "schema": "errors/problem.v1.json",++ "visibility": "public",++ "compatibility": "backward",++ "status": "active",++ "adr": "ADR-002"++ },++ {++ "id": "registry.service-manifest",++ "version": "1.0.0",++ "owner": "network",++ "schema": "registry/service-manifest.v1.json",++ "visibility": "public",++ "compatibility": "backward",++ "status": "active"++ },++ {++ "id": "capabilities.capability",++ "version": "1.0.0",++ "owner": "network",++ "schema": "capabilities/capability.v1.json",++ "visibility": "public",++ "compatibility": "backward",++ "status": "active",++ "adr": "ADR-003"++ },++ {++ "id": "events.event-envelope",++ "version": "1.0.0",++ "owner": "events",++ "schema": "events/event-envelope.v1.json",++ "visibility": "public",++ "compatibility": "backward",++ "status": "active",++ "adr": "ADR-004"++ },++ {++ "id": "media.media-ref",++ "version": "1.0.0",++ "owner": "media",++ "schema": "media/media-ref.v1.json",++ "visibility": "public",++ "compatibility": "backward",++ "status": "active",++ "adr": "ADR-006"++ }]}@@@ -4,17 +4,66 @@"title": "ServiceTokenClaims","description": "Claims of a short-lived RS256 client-credentials token issued by OpenVibe.Network to a service or app principal. Replaces X-Internal-Key.","type": "object",-- "required": ["iss", "sub", "actor_type", "aud", "cap", "iat", "exp", "jti"],++ "required": [++ "iss",++ "sub",++ "actor_type",++ "aud",++ "cap",++ "iat",++ "exp",++ "jti"++ ],"additionalProperties": true,"properties": {-- "iss": { "const": "https://openvibe.network" },-- "sub": { "type": "string", "pattern": "^(svc:[a-z][a-z0-9-]{1,39}|app:app_[0-9A-HJKMNP-TV-Z]{26}|mod:mod_[0-9A-HJKMNP-TV-Z]{26})$" },-- "actor_type": { "enum": ["service", "app", "mod"] },-- "aud": { "type": "array", "minItems": 1, "items": { "type": "string", "pattern": "^[a-z0-9.-]+$" } },-- "cap": { "type": "array", "items": { "type": "string", "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_*]+)+$" }, "description": "Granted capability ids; a trailing .* grants a family." },-- "ns": { "type": "array", "items": { "type": "string" }, "description": "Namespace constraints, e.g. live.* or mod.example.*" },-- "iat": { "type": "integer" },-- "exp": { "type": "integer" },-- "jti": { "type": "string", "minLength": 8 }++ "iss": {++ "type": "string",++ "format": "uri",++ "description": "Issuer; https://openvibe.network in production. Receivers pass the issuer they expect to verifyServiceToken()."++ },++ "sub": {++ "type": "string",++ "pattern": "^(svc:[a-z][a-z0-9-]{1,39}|app:app_[0-9A-HJKMNP-TV-Z]{26}|mod:mod_[0-9A-HJKMNP-TV-Z]{26})$"++ },++ "actor_type": {++ "enum": [++ "service",++ "app",++ "mod"++ ]++ },++ "aud": {++ "type": "array",++ "minItems": 1,++ "items": {++ "type": "string",++ "pattern": "^[a-z0-9.-]+$"++ }++ },++ "cap": {++ "type": "array",++ "items": {++ "type": "string",++ "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_*]+)+$"++ },++ "description": "Granted capability ids; a trailing .* grants a family."++ },++ "ns": {++ "type": "array",++ "items": {++ "type": "string"++ },++ "description": "Namespace constraints, e.g. live.* or mod.example.*"++ },++ "iat": {++ "type": "integer"++ },++ "exp": {++ "type": "integer"++ },++ "jti": {++ "type": "string",++ "minLength": 8++ }}}ddeleted file mode 100644@@@ -1,12 +0,0 @@--{-- "iss": "https://evil.example",-- "sub": "svc:live",-- "actor_type": "service",-- "aud": [-- "openvibe.media"-- ],-- "cap": [],-- "iat": 1,-- "exp": 2,-- "jti": "tok_abcdef12"--}nnew file mode 100644@@@ -0,0 +1,12 @@++{++ "iss": "openvibe network",++ "sub": "svc:live",++ "actor_type": "service",++ "aud": [++ "openvibe.media"++ ],++ "cap": [],++ "iat": 1,++ "exp": 2,++ "jti": "tok_abcdef12"++}@@@ -162,7 +162,7 @@}},"identity.service-token-claims": {-- "version": "1.0.0",++ "version": "1.1.0","owner": "network","status": "active","schema": {@@@ -184,7 +184,9 @@"additionalProperties": true,"properties": {"iss": {-- "const": "https://openvibe.network"++ "type": "string",++ "format": "uri",++ "description": "Issuer; https://openvibe.network in production. Receivers pass the issuer they expect to verifyServiceToken()."},"sub": {"type": "string",@@@ -47,12 +47,15 @@ export interface LegacyIdentityMapEntry {metadata?: {};}--/** [email protected] (owner: network) */++/** [email protected] (owner: network) *//*** Claims of a short-lived RS256 client-credentials token issued by OpenVibe.Network to a service or app principal. Replaces X-Internal-Key.*/export interface ServiceTokenClaims {-- iss: "https://openvibe.network";++ /**++ * Issuer; https://openvibe.network in production. Receivers pass the issuer they expect to verifyServiceToken().++ */++ iss: string;sub: string;actor_type: "service" | "app" | "mod";/**@@@ -20,4 +20,5 @@ module.exports = {http: require('./lib/http'),capabilities: require('./lib/capabilities'),services: require('./lib/services'),++ serviceAuth: require('./lib/service-auth'),};nnew file mode 100644@@@ -0,0 +1,121 @@++'use strict';++/**++ * Service/app principal tokens (contract identity.service-token-claims@1, ADR-003).++ *++ * Receivers: verifyServiceToken() checks an RS256 client-credentials JWT from OpenVibe.Network++ * (signature, issuer, audience, expiry, claim shape) and requireCapability() turns that into an++ * Express guard that answers with problem+json. Callers: createTokenClient() fetches and caches a++ * token from Network's /oauth/token (grant_type=client_credentials).++ *++ * Node's crypto only; no JWT library, so the rules are exactly the ones written here.++ */++const crypto = require('crypto');++const { validate } = require('./registry');++const capabilities = require('./capabilities');++const http = require('./http');++++const b64url = (buf) => Buffer.from(buf).toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');++const fromB64url = (s) => Buffer.from(String(s).replace(/-/g, '+').replace(/_/g, '/'), 'base64');++++/** Sign claims (RS256). Used by the issuer (Network) and by tests. */++function signServiceToken(claims, privateKey, { kid } = {}) {++ const header = { alg: 'RS256', typ: 'JWT', ...(kid ? { kid } : {}) };++ const input = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(claims))}`;++ return `${input}.${b64url(crypto.sign('RSA-SHA256', Buffer.from(input), privateKey))}`;++}++++/**++ * Verify a service token. Returns { ok: true, claims } or { ok: false, code, reason } where code is a++ * stable problem code: token.malformed | token.bad_signature | token.expired | token.wrong_issuer |++ * token.wrong_audience | token.invalid_claims.++ */++function verifyServiceToken(token, { publicKey, issuer, audience, clockSkewSec = 30, now = Date.now() } = {}) {++ const fail = (code, reason) => ({ ok: false, code, reason });++ const parts = typeof token === 'string' ? token.split('.') : [];++ if (parts.length !== 3) return fail('token.malformed', 'not a JWT');++ let header, claims;++ try { header = JSON.parse(fromB64url(parts[0])); claims = JSON.parse(fromB64url(parts[1])); } catch { return fail('token.malformed', 'undecodable'); }++ if (header.alg !== 'RS256') return fail('token.malformed', `alg ${header.alg} not accepted`);++ let good = false;++ try { good = crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), publicKey, fromB64url(parts[2])); } catch { good = false; }++ if (!good) return fail('token.bad_signature', 'signature does not verify');++ const t = Math.floor(now / 1000);++ if (typeof claims.exp !== 'number' || claims.exp + clockSkewSec < t) return fail('token.expired', 'expired');++ if (typeof claims.iat === 'number' && claims.iat - clockSkewSec > t) return fail('token.expired', 'issued in the future');++ if (issuer && claims.iss !== issuer) return fail('token.wrong_issuer', `issuer ${claims.iss}`);++ const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];++ if (audience && !aud.includes(audience)) return fail('token.wrong_audience', `not for ${audience}`);++ const v = validate('identity.service-token-claims@1', claims);++ if (!v.valid) return fail('token.invalid_claims', v.errors.map(e => `${e.path} ${e.message}`).join('; '));++ return { ok: true, claims };++}++++/**++ * Express guard: `router.post('/coins/credit', requireCapability('network.coins.credit', opts), handler)`.++ * opts.publicKey | opts.getPublicKey(req), opts.issuer, opts.audience++ * opts.namespace(req) -> namespace to check against the token's `ns` (optional)++ * opts.legacy(req) -> true when the request authenticated the old way (X-Internal-Key); it is let++ * through as `req.principal = { legacy: true }` so both work during migration.++ * opts.onDecision({ req, capability, principal, allowed, code }) -> audit hook++ * A request that presents a Bearer token is judged ONLY on that token, never on a legacy header.++ */++function requireCapability(capability, opts = {}) {++ if (!capabilities.get(capability)) throw new Error(`requireCapability: unknown capability ${capability}`);++ return function capabilityGuard(req, res, next) {++ const ctx = req.ov || http.requestContext(req.headers);++ const auth = String(req.headers.authorization || '');++ const decide = (allowed, code, principal, detail) => {++ try { if (opts.onDecision) opts.onDecision({ req, capability, principal, allowed, code }); } catch { /* audit must not break the call */ }++ if (allowed) { req.principal = principal; return next(); }++ const status = code && code.startsWith('token.') ? 401 : 403;++ return http.sendProblem(res, status, code, { detail, ctx });++ };++ if (auth.startsWith('Bearer ')) {++ const publicKey = opts.getPublicKey ? opts.getPublicKey(req) : opts.publicKey;++ const r = verifyServiceToken(auth.slice(7).trim(), { publicKey, issuer: opts.issuer, audience: opts.audience });++ if (!r.ok) return decide(false, r.code, null, r.reason);++ const ns = opts.namespace ? opts.namespace(req) : undefined;++ const c = capabilities.check(r.claims, capability, { namespace: ns });++ return decide(c.allowed, c.code, { sub: r.claims.sub, cap: r.claims.cap, ns: r.claims.ns, jti: r.claims.jti }, c.reason);++ }++ if (opts.legacy && opts.legacy(req)) return decide(true, null, { legacy: true });++ return decide(false, 'capability.denied', null, 'no service token');++ };++}++++/**++ * Caller side: cached client-credentials tokens.++ * const tokens = createTokenClient({ tokenUrl, clientId, clientSecret, audience, scope });++ * const headers = await tokens.authHeaders(); // { Authorization: 'Bearer …' }++ * Refreshes 60 s before expiry; concurrent callers share one request. Throws on failure so the++ * caller can decide whether to fall back.++ */++function createTokenClient({ tokenUrl, clientId, clientSecret, audience, scope, fetchImpl = globalThis.fetch, timeoutMs = 5000 }) {++ let cached = null; // { token, exp }++ let inflight = null;++ async function fetchToken() {++ const body = new URLSearchParams({ grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret, audience });++ if (scope) body.set('scope', Array.isArray(scope) ? scope.join(' ') : scope);++ const res = await fetchImpl(tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, body, signal: AbortSignal.timeout(timeoutMs) });++ const json = await res.json().catch(() => null);++ if (!res.ok || !json || !json.access_token) {++ const err = new Error(`token endpoint ${res.status}: ${(json && (json.error_description || json.error)) || 'no token'}`);++ err.status = res.status;++ throw err;++ }++ cached = { token: json.access_token, exp: Date.now() + (Number(json.expires_in) || 300) * 1000 };++ return cached.token;++ }++ async function getToken() {++ if (cached && cached.exp - 60_000 > Date.now()) return cached.token;++ if (!inflight) inflight = fetchToken().finally(() => { inflight = null; });++ return inflight;++ }++ return {++ getToken,++ async authHeaders() { return { Authorization: `Bearer ${await getToken()}` }; },++ invalidate() { cached = null; },++ };++}++++module.exports = { signServiceToken, verifyServiceToken, requireCapability, createTokenClient };@@@ -4,12 +4,12 @@"owner": "network","status": "active","visibility": "internal",-- "description": "Credit OpenCoins (loyalty, never money) to a Network user. Idempotent by idempotency_key.",++ "description": "Credit OpenCoins (loyalty, never money) to a Network user. Idempotent by idempotency_key. A token may only act for its own app_id.","permissions": ["coins:write"],"resourceConstraints": [-- "none"++ "owner"],"quotaClass": "ledger-write","events": [],@@@ -4,12 +4,12 @@"owner": "network","status": "active","visibility": "internal",-- "description": "Debit OpenCoins; 409 insufficient_funds.",++ "description": "Debit OpenCoins; 409 insufficient_funds. A token may only act for its own app_id.","permissions": ["coins:write"],"resourceConstraints": [-- "none"++ "owner"],"quotaClass": "ledger-write","events": [],nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "id": "network.coins.transfer",++ "version": "1.0",++ "owner": "network",++ "status": "active",++ "visibility": "internal",++ "description": "Atomically move OpenCoins between two Network users. A token may only act for its own app_id.",++ "permissions": [++ "coins:write"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "ledger-write",++ "events": [],++ "implementedBy": [++ "POST /internal/coins/transfer"++ ]++}@@@ -12,5 +12,5 @@"eventsProduced": [],"eventsConsumed": [],"namespacesOwned": [],-- "notes": "Rooms, messages, DMs, calls, TTS and audio queues, moderation and presence — one identity, every conversation. Charter only; no runtime."++ "notes": "Rooms, messages, DMs, calls, TTS and audio queues, moderation and presence \u2014 one identity, every conversation. Charter only; no runtime."}@@@ -1,7 +1,7 @@{"version": "0.1.0","contractRanges": {-- "openvibe-contracts": "^0.1.0"++ "openvibe-contracts": "^0.2.0"},"id": "live","name": "OpenVibe.Live",@@@ -1,7 +1,7 @@{"version": "0.1.0","contractRanges": {-- "openvibe-contracts": "^0.1.0"++ "openvibe-contracts": "^0.2.0"},"id": "network","name": "OpenVibe.Network",@@@ -21,6 +21,7 @@"identity.subject.resolve","network.coins.credit","network.coins.debit",++ "network.coins.transfer","network.notifications.push"],"eventsProduced": [],@@@ -12,5 +12,5 @@"eventsProduced": [],"eventsConsumed": [],"namespacesOwned": [],-- "notes": "Wiki spaces with page trees, revisions, citations, media and discussion — editable together, source-backed. Charter only; no runtime."++ "notes": "Wiki spaces with page trees, revisions, citations, media and discussion \u2014 editable together, source-backed. Charter only; no runtime."}@@@ -1,15 +1,24 @@{"name": "openvibe-contracts",-- "version": "0.1.0",++ "version": "0.2.0","description": "Machine-readable contracts for the OpenVibe network: schemas, manifests, ids, error envelope, capability checks.","main": "index.js","types": "generated/typescript/index.d.ts",-- "files": ["index.js", "lib/", "contracts/", "manifests/", "compatibility/", "generated/"],++ "files": [++ "index.js",++ "lib/",++ "contracts/",++ "manifests/",++ "compatibility/",++ "generated/"++ ],"scripts": {"test": "node test/run.js","generate": "node scripts/generate.js"},-- "engines": { "node": ">=20" },++ "engines": {++ "node": ">=20"++ },"repository": "github:OpenVibers/OpenVibe.Contracts","license": "AGPL-3.0-only","dependencies": {@@@ -6,7 +6,7 @@* consumer of the same major version:* - a property removed, or its type changed* - a property newly required-- * - an enum/const value removed++ * - an enum value removed, or a const changed to another value* - additionalProperties tightened to false* - a catalog entry removed without a deprecation record* A breaking change belongs in a new major (<name>.v2.json) with the old file deprecated.@@@ -28,7 +28,8 @@ function compare(id, a, b, at) {const ta = JSON.stringify(a.type), tb = JSON.stringify(b.type);if (a.type !== undefined && ta !== tb) problems.push(`${id} ${at}: type ${ta} -> ${tb}`);for (const k of ['enum']) if (Array.isArray(a[k])) for (const v of a[k]) if (!(b[k] || []).includes(v)) problems.push(`${id} ${at}: enum value ${JSON.stringify(v)} removed`);-- if ('const' in a && JSON.stringify(a.const) !== JSON.stringify(b.const)) problems.push(`${id} ${at}: const changed`);++ // Dropping a const only widens what is accepted; changing it to another value breaks.++ if ('const' in a && 'const' in b && JSON.stringify(a.const) !== JSON.stringify(b.const)) problems.push(`${id} ${at}: const changed`);if (a.additionalProperties !== false && b.additionalProperties === false) problems.push(`${id} ${at}: additionalProperties tightened`);for (const r of b.required || []) if (!(a.required || []).includes(r)) problems.push(`${id} ${at}: "${r}" newly required`);for (const [k, v] of Object.entries(a.properties || {})) {@@@ -103,8 +103,62 @@ ok(!capabilities.check({ cap: ['media.*'], ns: ['*'] }, 'network.coins.debit').aok(!capabilities.grants(['media.object'], 'media.object.read'), 'a prefix without .* is not a family grant');ok(!capabilities.namespaceAllowed(['live.*'], 'livestream'), 'namespace wildcard does not match a longer sibling name');++// ── Service tokens: issue, verify, guard, client ─────────────────────────++(async () => {++const { serviceAuth } = contracts;++const kp = require('crypto').generateKeyPairSync('rsa', { modulusLength: 2048 });++const other = require('crypto').generateKeyPairSync('rsa', { modulusLength: 2048 });++const t0 = Math.floor(Date.now() / 1000);++const claims = { iss: 'https://openvibe.network', sub: 'svc:live', actor_type: 'service', aud: ['openvibe.network'], cap: ['network.coins.credit'], ns: ['live'], iat: t0, exp: t0 + 300, jti: 'jti_0123456789' };++const tok = serviceAuth.signServiceToken(claims, kp.privateKey);++const V = (t, o = {}) => serviceAuth.verifyServiceToken(t, { publicKey: kp.publicKey, issuer: 'https://openvibe.network', audience: 'openvibe.network', ...o });++ok(V(tok).ok && V(tok).claims.sub === 'svc:live', 'valid token verifies');++ok(V(serviceAuth.signServiceToken(claims, other.privateKey)).code === 'token.bad_signature', 'foreign key rejected');++ok(V(serviceAuth.signServiceToken({ ...claims, exp: t0 - 120 }, kp.privateKey)).code === 'token.expired', 'expired rejected');++ok(V(tok, { audience: 'openvibe.media' }).code === 'token.wrong_audience', 'wrong audience rejected');++ok(V(tok, { issuer: 'https://evil.example' }).code === 'token.wrong_issuer', 'wrong issuer rejected');++ok(V(serviceAuth.signServiceToken({ ...claims, sub: 'user:42' }, kp.privateKey)).code === 'token.invalid_claims', 'claims must match the contract');++const [h, p] = tok.split('.');++const none = `${Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url')}.${p}.`;++ok(V(none).code === 'token.malformed', 'alg none rejected');++ok(V(`${h}.${Buffer.from(JSON.stringify({ ...claims, cap: ['network.coins.debit'] })).toString('base64url')}.${tok.split('.')[2]}`).code === 'token.bad_signature', 'tampered claims rejected');++ok(V('nope').code === 'token.malformed', 'garbage rejected');++++const run = (guard, headers) => new Promise((resolve) => {++ const req = { headers, body: {} };++ const res = { headers: {}, setHeader(k, v) { this.headers[k.toLowerCase()] = v; }, end(b) { resolve({ status: this.statusCode, body: JSON.parse(b), req }); } };++ guard(req, res, () => resolve({ status: 200, req }));++});++const audit = [];++const opts = { publicKey: kp.publicKey, issuer: 'https://openvibe.network', audience: 'openvibe.network', legacy: (req) => req.headers['x-internal-key'] === 'k', onDecision: (d) => audit.push(d) };++let r = await run(serviceAuth.requireCapability('network.coins.credit', opts), { authorization: `Bearer ${tok}` });++ok(r.status === 200 && r.req.principal.sub === 'svc:live', 'granted capability passes with the principal attached');++r = await run(serviceAuth.requireCapability('network.coins.debit', opts), { authorization: `Bearer ${tok}` });++ok(r.status === 403 && r.body.code === 'capability.denied' && contracts.validate('errors.problem', r.body).valid, 'ungranted capability is a 403 problem');++r = await run(serviceAuth.requireCapability('network.coins.credit', opts), { authorization: 'Bearer garbage', 'x-internal-key': 'k' });++ok(r.status === 401 && r.body.code === 'token.malformed', 'a bad token is not rescued by a legacy key');++r = await run(serviceAuth.requireCapability('network.coins.credit', opts), { 'x-internal-key': 'k' });++ok(r.status === 200 && r.req.principal.legacy === true, 'legacy key still works in compatibility mode');++r = await run(serviceAuth.requireCapability('network.coins.credit', opts), {});++ok(r.status === 403, 'no credentials, no access');++r = await run(serviceAuth.requireCapability('media.object.upload', { ...opts, audience: 'openvibe.network', namespace: () => 'games.maps' }), { authorization: `Bearer ${serviceAuth.signServiceToken({ ...claims, cap: ['media.object.*'], ns: ['live.*'] }, kp.privateKey)}` });++ok(r.status === 403 && r.body.code === 'capability.namespace_denied', 'namespace constraint enforced');++ok(audit.length === 6 && audit.filter(a => a.allowed).length === 2, 'every decision reaches the audit hook');++assert.throws(() => serviceAuth.requireCapability('nope.nope.nope'), /unknown capability/);++++let calls = 0;++const fakeFetch = async (url, init) => { calls++; const b = new URLSearchParams(init.body); ok(b.get('grant_type') === 'client_credentials' && b.get('audience') === 'openvibe.network', 'client sends client_credentials'); return { ok: true, status: 200, json: async () => ({ access_token: `t${calls}`, expires_in: 300 }) }; };++const client = serviceAuth.createTokenClient({ tokenUrl: 'http://x/oauth/token', clientId: 'live', clientSecret: 's', audience: 'openvibe.network', fetchImpl: fakeFetch });++const [a1, a2] = await Promise.all([client.getToken(), client.getToken()]);++ok(a1 === 't1' && a2 === 't1' && calls === 1, 'concurrent callers share one fetch');++ok((await client.authHeaders()).Authorization === 'Bearer t1' && calls === 1, 'cached until near expiry');++client.invalidate(); await client.getToken(); ok(calls === 2, 'invalidate forces a refetch');++const failing = serviceAuth.createTokenClient({ tokenUrl: 'x', clientId: 'live', clientSecret: 'bad', audience: 'a', fetchImpl: async () => ({ ok: false, status: 401, json: async () => ({ error: 'invalid_client' }) }) });++await assert.rejects(failing.getToken(), /401: invalid_client/);++// ── Generated output and compatibility gate ──────────────────────────────execFileSync(process.execPath, [path.join(ROOT, 'scripts/generate.js'), '--check'], { stdio: 'inherit' });execFileSync(process.execPath, [path.join(ROOT, 'scripts/compat.js')], { stdio: 'inherit' });console.log(`openvibe-contracts: ${n} checks passed`);++})().catch((err) => { console.error(err); process.exit(1); });
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Machine-readable contracts for the whole OpenVibe network.
> Machine-readable contracts for the whole OpenVibe network.
-**Status:** alpha, v0.1 (Wave 1). Schemas, manifests and helpers are real and tested; no service validates against them in production yet.
+**Status:** alpha, v0.2 (Wave 1). Schemas, manifests and helpers are real and tested; no service validates against them in production yet.
**Plan:** OpenVibe Development Roadmap, Wave 1 (implementation plan rev 3, §3.1 and §18.11).
**Plan:** OpenVibe Development Roadmap, Wave 1 (implementation plan rev 3, §3.1 and §18.11).
**License:** AGPL-3.0 (same as every OpenVibe service).
**License:** AGPL-3.0 (same as every OpenVibe service).
@@ -28,6 +28,20 @@ contracts.capabilities.check(tokenClaims, 'media.object.upload', { namespace: 'l
@@ -28,6 +28,20 @@ contracts.capabilities.check(tokenClaims, 'media.object.upload', { namespace: 'l
// { allowed: false, code: 'capability.denied' | 'capability.namespace_denied' | 'capability.unknown', reason }
// { allowed: false, code: 'capability.denied' | 'capability.namespace_denied' | 'capability.unknown', reason }
```
```
+Service principals (v0.2): a caller gets a short-lived token from Network with its OAuth client credentials, and a receiver guards a route with the capability it performs:
+
+```js
+const tokens = contracts.serviceAuth.createTokenClient({ tokenUrl: `${NETWORK}/oauth/token`, clientId, clientSecret, audience: 'openvibe.network' });
+await fetch(url, { headers: await tokens.authHeaders() });
+
+router.post('/coins/credit', contracts.serviceAuth.requireCapability('network.coins.credit', {
+ publicKey, issuer, audience: 'openvibe.network',
+ legacy: (req) => req.headers['x-internal-key'] === key, // old callers keep working during migration
+}), handler);
+```
+
+A request that presents a Bearer token is judged only on that token; a bad token is never rescued by a legacy header.
+
TypeScript types: `generated/typescript/index.d.ts` (`"types"` in package.json). All schemas in one file: `generated/json-schema/bundle.json`.
TypeScript types: `generated/typescript/index.d.ts` (`"types"` in package.json). All schemas in one file: `generated/json-schema/bundle.json`.
## What's in v0.1
## What's in v0.1
@@ -1,14 +1,93 @@
@@ -1,14 +1,93 @@
{
{
"_about": "Every contract this repository publishes. A contract id is stable forever; a breaking change is a new major (new file .v2.json, new catalog entry) while the old one is deprecated with a replacement and a window. Minor versions only add optional fields.",
"_about": "Every contract this repository publishes. A contract id is stable forever; a breaking change is a new major (new file .v2.json, new catalog entry) while the old one is deprecated with a replacement and a window. Minor versions only add optional fields.",
"contracts": [
"contracts": [
- { "id": "identity.subject-ref", "version": "1.0.0", "owner": "network", "schema": "identity/subject-ref.v1.json", "visibility": "public", "compatibility": "backward", "status": "active", "adr": "ADR-001" },
- { "id": "identity.legacy-identity-map", "version": "1.0.0", "owner": "network", "schema": "identity/legacy-identity-map.v1.json", "visibility": "first-party", "compatibility": "backward", "status": "active", "adr": "ADR-001" },
- { "id": "identity.service-token-claims", "version": "1.0.0", "owner": "network", "schema": "identity/service-token-claims.v1.json", "visibility": "public", "compatibility": "backward", "status": "active", "adr": "ADR-003" },
- { "id": "common.entity-ref", "version": "1.0.0", "owner": "contracts", "schema": "common/entity-ref.v1.json", "visibility": "public", "compatibility": "backward", "status": "active" },
- { "id": "errors.problem", "version": "1.0.0", "owner": "contracts", "schema": "errors/problem.v1.json", "visibility": "public", "compatibility": "backward", "status": "active", "adr": "ADR-002" },
- { "id": "registry.service-manifest", "version": "1.0.0", "owner": "network", "schema": "registry/service-manifest.v1.json", "visibility": "public", "compatibility": "backward", "status": "active" },
- { "id": "capabilities.capability", "version": "1.0.0", "owner": "network", "schema": "capabilities/capability.v1.json", "visibility": "public", "compatibility": "backward", "status": "active", "adr": "ADR-003" },
- { "id": "events.event-envelope", "version": "1.0.0", "owner": "events", "schema": "events/event-envelope.v1.json", "visibility": "public", "compatibility": "backward", "status": "active", "adr": "ADR-004" },
- { "id": "media.media-ref", "version": "1.0.0", "owner": "media", "schema": "media/media-ref.v1.json", "visibility": "public", "compatibility": "backward", "status": "active", "adr": "ADR-006" }
+ {
+ "id": "identity.subject-ref",
+ "version": "1.0.0",
+ "owner": "network",
+ "schema": "identity/subject-ref.v1.json",
+ "visibility": "public",
+ "compatibility": "backward",
+ "status": "active",
+ "adr": "ADR-001"
+ },
+ {
+ "id": "identity.legacy-identity-map",
+ "version": "1.0.0",
+ "owner": "network",
+ "schema": "identity/legacy-identity-map.v1.json",
+ "visibility": "first-party",
+ "compatibility": "backward",
+ "status": "active",
+ "adr": "ADR-001"
+ },
+ {
+ "id": "identity.service-token-claims",
+ "version": "1.1.0",
+ "owner": "network",
+ "schema": "identity/service-token-claims.v1.json",
+ "visibility": "public",
+ "compatibility": "backward",
+ "status": "active",
+ "adr": "ADR-003"
+ },
+ {
+ "id": "common.entity-ref",
+ "version": "1.0.0",
+ "owner": "contracts",
+ "schema": "common/entity-ref.v1.json",
+ "visibility": "public",
+ "compatibility": "backward",
+ "status": "active"
+ },
+ {
+ "id": "errors.problem",
+ "version": "1.0.0",
+ "owner": "contracts",
+ "schema": "errors/problem.v1.json",
+ "visibility": "public",
+ "compatibility": "backward",
+ "status": "active",
+ "adr": "ADR-002"
+ },
+ {
+ "id": "registry.service-manifest",
+ "version": "1.0.0",
+ "owner": "network",
+ "schema": "registry/service-manifest.v1.json",
+ "visibility": "public",
+ "compatibility": "backward",
+ "status": "active"
+ },
+ {
+ "id": "capabilities.capability",
+ "version": "1.0.0",
+ "owner": "network",
+ "schema": "capabilities/capability.v1.json",
+ "visibility": "public",
+ "compatibility": "backward",
+ "status": "active",
+ "adr": "ADR-003"
+ },
+ {
+ "id": "events.event-envelope",
+ "version": "1.0.0",
+ "owner": "events",
+ "schema": "events/event-envelope.v1.json",
+ "visibility": "public",
+ "compatibility": "backward",
+ "status": "active",
+ "adr": "ADR-004"
+ },
+ {
+ "id": "media.media-ref",
+ "version": "1.0.0",
+ "owner": "media",
+ "schema": "media/media-ref.v1.json",
+ "visibility": "public",
+ "compatibility": "backward",
+ "status": "active",
+ "adr": "ADR-006"
+ }
]
]
}
}
@@ -4,17 +4,66 @@
@@ -4,17 +4,66 @@
"title": "ServiceTokenClaims",
"title": "ServiceTokenClaims",
"description": "Claims of a short-lived RS256 client-credentials token issued by OpenVibe.Network to a service or app principal. Replaces X-Internal-Key.",
"description": "Claims of a short-lived RS256 client-credentials token issued by OpenVibe.Network to a service or app principal. Replaces X-Internal-Key.",
"type": "object",
"type": "object",
- "required": ["iss", "sub", "actor_type", "aud", "cap", "iat", "exp", "jti"],
+ "required": [
+ "iss",
+ "sub",
+ "actor_type",
+ "aud",
+ "cap",
+ "iat",
+ "exp",
+ "jti"
+ ],
"additionalProperties": true,
"additionalProperties": true,
"properties": {
"properties": {
- "iss": { "const": "https://openvibe.network" },
- "sub": { "type": "string", "pattern": "^(svc:[a-z][a-z0-9-]{1,39}|app:app_[0-9A-HJKMNP-TV-Z]{26}|mod:mod_[0-9A-HJKMNP-TV-Z]{26})$" },
- "actor_type": { "enum": ["service", "app", "mod"] },
- "aud": { "type": "array", "minItems": 1, "items": { "type": "string", "pattern": "^[a-z0-9.-]+$" } },
- "cap": { "type": "array", "items": { "type": "string", "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_*]+)+$" }, "description": "Granted capability ids; a trailing .* grants a family." },
- "ns": { "type": "array", "items": { "type": "string" }, "description": "Namespace constraints, e.g. live.* or mod.example.*" },
- "iat": { "type": "integer" },
- "exp": { "type": "integer" },
- "jti": { "type": "string", "minLength": 8 }
+ "iss": {
+ "type": "string",
+ "format": "uri",
+ "description": "Issuer; https://openvibe.network in production. Receivers pass the issuer they expect to verifyServiceToken()."
+ },
+ "sub": {
+ "type": "string",
+ "pattern": "^(svc:[a-z][a-z0-9-]{1,39}|app:app_[0-9A-HJKMNP-TV-Z]{26}|mod:mod_[0-9A-HJKMNP-TV-Z]{26})$"
+ },
+ "actor_type": {
+ "enum": [
+ "service",
+ "app",
+ "mod"
+ ]
+ },
+ "aud": {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "type": "string",
+ "pattern": "^[a-z0-9.-]+$"
+ }
+ },
+ "cap": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_*]+)+$"
+ },
+ "description": "Granted capability ids; a trailing .* grants a family."
+ },
+ "ns": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Namespace constraints, e.g. live.* or mod.example.*"
+ },
+ "iat": {
+ "type": "integer"
+ },
+ "exp": {
+ "type": "integer"
+ },
+ "jti": {
+ "type": "string",
+ "minLength": 8
+ }
}
}
}
}
deleted file mode 100644
deleted file mode 100644
@@ -1,12 +0,0 @@
@@ -1,12 +0,0 @@
-{
- "iss": "https://evil.example",
- "sub": "svc:live",
- "actor_type": "service",
- "aud": [
- "openvibe.media"
- ],
- "cap": [],
- "iat": 1,
- "exp": 2,
- "jti": "tok_abcdef12"
-}
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+{
+ "iss": "openvibe network",
+ "sub": "svc:live",
+ "actor_type": "service",
+ "aud": [
+ "openvibe.media"
+ ],
+ "cap": [],
+ "iat": 1,
+ "exp": 2,
+ "jti": "tok_abcdef12"
+}
@@ -162,7 +162,7 @@
@@ -162,7 +162,7 @@
}
}
},
},
"identity.service-token-claims": {
"identity.service-token-claims": {
- "version": "1.0.0",
+ "version": "1.1.0",
"owner": "network",
"owner": "network",
"status": "active",
"status": "active",
"schema": {
"schema": {
@@ -184,7 +184,9 @@
@@ -184,7 +184,9 @@
"additionalProperties": true,
"additionalProperties": true,
"properties": {
"properties": {
"iss": {
"iss": {
- "const": "https://openvibe.network"
+ "type": "string",
+ "format": "uri",
+ "description": "Issuer; https://openvibe.network in production. Receivers pass the issuer they expect to verifyServiceToken()."
},
},
"sub": {
"sub": {
"type": "string",
"type": "string",
@@ -47,12 +47,15 @@ export interface LegacyIdentityMapEntry {
@@ -47,12 +47,15 @@ export interface LegacyIdentityMapEntry {
metadata?: {};
metadata?: {};
}
}
-/** [email protected] (owner: network) */
+/** [email protected] (owner: network) */
/**
/**
* Claims of a short-lived RS256 client-credentials token issued by OpenVibe.Network to a service or app principal. Replaces X-Internal-Key.
* Claims of a short-lived RS256 client-credentials token issued by OpenVibe.Network to a service or app principal. Replaces X-Internal-Key.
*/
*/
export interface ServiceTokenClaims {
export interface ServiceTokenClaims {
- iss: "https://openvibe.network";
+ /**
+ * Issuer; https://openvibe.network in production. Receivers pass the issuer they expect to verifyServiceToken().
+ */
+ iss: string;
sub: string;
sub: string;
actor_type: "service" | "app" | "mod";
actor_type: "service" | "app" | "mod";
/**
/**
@@ -20,4 +20,5 @@ module.exports = {
@@ -20,4 +20,5 @@ module.exports = {
http: require('./lib/http'),
http: require('./lib/http'),
capabilities: require('./lib/capabilities'),
capabilities: require('./lib/capabilities'),
services: require('./lib/services'),
services: require('./lib/services'),
+ serviceAuth: require('./lib/service-auth'),
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,121 @@
@@ -0,0 +1,121 @@
+'use strict';
+/**
+ * Service/app principal tokens (contract identity.service-token-claims@1, ADR-003).
+ *
+ * Receivers: verifyServiceToken() checks an RS256 client-credentials JWT from OpenVibe.Network
+ * (signature, issuer, audience, expiry, claim shape) and requireCapability() turns that into an
+ * Express guard that answers with problem+json. Callers: createTokenClient() fetches and caches a
+ * token from Network's /oauth/token (grant_type=client_credentials).
+ *
+ * Node's crypto only; no JWT library, so the rules are exactly the ones written here.
+ */
+const crypto = require('crypto');
+const { validate } = require('./registry');
+const capabilities = require('./capabilities');
+const http = require('./http');
+
+const b64url = (buf) => Buffer.from(buf).toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
+const fromB64url = (s) => Buffer.from(String(s).replace(/-/g, '+').replace(/_/g, '/'), 'base64');
+
+/** Sign claims (RS256). Used by the issuer (Network) and by tests. */
+function signServiceToken(claims, privateKey, { kid } = {}) {
+ const header = { alg: 'RS256', typ: 'JWT', ...(kid ? { kid } : {}) };
+ const input = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(claims))}`;
+ return `${input}.${b64url(crypto.sign('RSA-SHA256', Buffer.from(input), privateKey))}`;
+}
+
+/**
+ * Verify a service token. Returns { ok: true, claims } or { ok: false, code, reason } where code is a
+ * stable problem code: token.malformed | token.bad_signature | token.expired | token.wrong_issuer |
+ * token.wrong_audience | token.invalid_claims.
+ */
+function verifyServiceToken(token, { publicKey, issuer, audience, clockSkewSec = 30, now = Date.now() } = {}) {
+ const fail = (code, reason) => ({ ok: false, code, reason });
+ const parts = typeof token === 'string' ? token.split('.') : [];
+ if (parts.length !== 3) return fail('token.malformed', 'not a JWT');
+ let header, claims;
+ try { header = JSON.parse(fromB64url(parts[0])); claims = JSON.parse(fromB64url(parts[1])); } catch { return fail('token.malformed', 'undecodable'); }
+ if (header.alg !== 'RS256') return fail('token.malformed', `alg ${header.alg} not accepted`);
+ let good = false;
+ try { good = crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), publicKey, fromB64url(parts[2])); } catch { good = false; }
+ if (!good) return fail('token.bad_signature', 'signature does not verify');
+ const t = Math.floor(now / 1000);
+ if (typeof claims.exp !== 'number' || claims.exp + clockSkewSec < t) return fail('token.expired', 'expired');
+ if (typeof claims.iat === 'number' && claims.iat - clockSkewSec > t) return fail('token.expired', 'issued in the future');
+ if (issuer && claims.iss !== issuer) return fail('token.wrong_issuer', `issuer ${claims.iss}`);
+ const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
+ if (audience && !aud.includes(audience)) return fail('token.wrong_audience', `not for ${audience}`);
+ const v = validate('identity.service-token-claims@1', claims);
+ if (!v.valid) return fail('token.invalid_claims', v.errors.map(e => `${e.path} ${e.message}`).join('; '));
+ return { ok: true, claims };
+}
+
+/**
+ * Express guard: `router.post('/coins/credit', requireCapability('network.coins.credit', opts), handler)`.
+ * opts.publicKey | opts.getPublicKey(req), opts.issuer, opts.audience
+ * opts.namespace(req) -> namespace to check against the token's `ns` (optional)
+ * opts.legacy(req) -> true when the request authenticated the old way (X-Internal-Key); it is let
+ * through as `req.principal = { legacy: true }` so both work during migration.
+ * opts.onDecision({ req, capability, principal, allowed, code }) -> audit hook
+ * A request that presents a Bearer token is judged ONLY on that token, never on a legacy header.
+ */
+function requireCapability(capability, opts = {}) {
+ if (!capabilities.get(capability)) throw new Error(`requireCapability: unknown capability ${capability}`);
+ return function capabilityGuard(req, res, next) {
+ const ctx = req.ov || http.requestContext(req.headers);
+ const auth = String(req.headers.authorization || '');
+ const decide = (allowed, code, principal, detail) => {
+ try { if (opts.onDecision) opts.onDecision({ req, capability, principal, allowed, code }); } catch { /* audit must not break the call */ }
+ if (allowed) { req.principal = principal; return next(); }
+ const status = code && code.startsWith('token.') ? 401 : 403;
+ return http.sendProblem(res, status, code, { detail, ctx });
+ };
+ if (auth.startsWith('Bearer ')) {
+ const publicKey = opts.getPublicKey ? opts.getPublicKey(req) : opts.publicKey;
+ const r = verifyServiceToken(auth.slice(7).trim(), { publicKey, issuer: opts.issuer, audience: opts.audience });
+ if (!r.ok) return decide(false, r.code, null, r.reason);
+ const ns = opts.namespace ? opts.namespace(req) : undefined;
+ const c = capabilities.check(r.claims, capability, { namespace: ns });
+ return decide(c.allowed, c.code, { sub: r.claims.sub, cap: r.claims.cap, ns: r.claims.ns, jti: r.claims.jti }, c.reason);
+ }
+ if (opts.legacy && opts.legacy(req)) return decide(true, null, { legacy: true });
+ return decide(false, 'capability.denied', null, 'no service token');
+ };
+}
+
+/**
+ * Caller side: cached client-credentials tokens.
+ * const tokens = createTokenClient({ tokenUrl, clientId, clientSecret, audience, scope });
+ * const headers = await tokens.authHeaders(); // { Authorization: 'Bearer …' }
+ * Refreshes 60 s before expiry; concurrent callers share one request. Throws on failure so the
+ * caller can decide whether to fall back.
+ */
+function createTokenClient({ tokenUrl, clientId, clientSecret, audience, scope, fetchImpl = globalThis.fetch, timeoutMs = 5000 }) {
+ let cached = null; // { token, exp }
+ let inflight = null;
+ async function fetchToken() {
+ const body = new URLSearchParams({ grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret, audience });
+ if (scope) body.set('scope', Array.isArray(scope) ? scope.join(' ') : scope);
+ const res = await fetchImpl(tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, body, signal: AbortSignal.timeout(timeoutMs) });
+ const json = await res.json().catch(() => null);
+ if (!res.ok || !json || !json.access_token) {
+ const err = new Error(`token endpoint ${res.status}: ${(json && (json.error_description || json.error)) || 'no token'}`);
+ err.status = res.status;
+ throw err;
+ }
+ cached = { token: json.access_token, exp: Date.now() + (Number(json.expires_in) || 300) * 1000 };
+ return cached.token;
+ }
+ async function getToken() {
+ if (cached && cached.exp - 60_000 > Date.now()) return cached.token;
+ if (!inflight) inflight = fetchToken().finally(() => { inflight = null; });
+ return inflight;
+ }
+ return {
+ getToken,
+ async authHeaders() { return { Authorization: `Bearer ${await getToken()}` }; },
+ invalidate() { cached = null; },
+ };
+}
+
+module.exports = { signServiceToken, verifyServiceToken, requireCapability, createTokenClient };
@@ -4,12 +4,12 @@
@@ -4,12 +4,12 @@
"owner": "network",
"owner": "network",
"status": "active",
"status": "active",
"visibility": "internal",
"visibility": "internal",
- "description": "Credit OpenCoins (loyalty, never money) to a Network user. Idempotent by idempotency_key.",
+ "description": "Credit OpenCoins (loyalty, never money) to a Network user. Idempotent by idempotency_key. A token may only act for its own app_id.",
"permissions": [
"permissions": [
"coins:write"
"coins:write"
],
],
"resourceConstraints": [
"resourceConstraints": [
- "none"
+ "owner"
],
],
"quotaClass": "ledger-write",
"quotaClass": "ledger-write",
"events": [],
"events": [],
@@ -4,12 +4,12 @@
@@ -4,12 +4,12 @@
"owner": "network",
"owner": "network",
"status": "active",
"status": "active",
"visibility": "internal",
"visibility": "internal",
- "description": "Debit OpenCoins; 409 insufficient_funds.",
+ "description": "Debit OpenCoins; 409 insufficient_funds. A token may only act for its own app_id.",
"permissions": [
"permissions": [
"coins:write"
"coins:write"
],
],
"resourceConstraints": [
"resourceConstraints": [
- "none"
+ "owner"
],
],
"quotaClass": "ledger-write",
"quotaClass": "ledger-write",
"events": [],
"events": [],
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "id": "network.coins.transfer",
+ "version": "1.0",
+ "owner": "network",
+ "status": "active",
+ "visibility": "internal",
+ "description": "Atomically move OpenCoins between two Network users. A token may only act for its own app_id.",
+ "permissions": [
+ "coins:write"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "ledger-write",
+ "events": [],
+ "implementedBy": [
+ "POST /internal/coins/transfer"
+ ]
+}
@@ -12,5 +12,5 @@
@@ -12,5 +12,5 @@
"eventsProduced": [],
"eventsProduced": [],
"eventsConsumed": [],
"eventsConsumed": [],
"namespacesOwned": [],
"namespacesOwned": [],
- "notes": "Rooms, messages, DMs, calls, TTS and audio queues, moderation and presence — one identity, every conversation. Charter only; no runtime."
+ "notes": "Rooms, messages, DMs, calls, TTS and audio queues, moderation and presence \u2014 one identity, every conversation. Charter only; no runtime."
}
}
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
{
{
"version": "0.1.0",
"version": "0.1.0",
"contractRanges": {
"contractRanges": {
- "openvibe-contracts": "^0.1.0"
+ "openvibe-contracts": "^0.2.0"
},
},
"id": "live",
"id": "live",
"name": "OpenVibe.Live",
"name": "OpenVibe.Live",
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
{
{
"version": "0.1.0",
"version": "0.1.0",
"contractRanges": {
"contractRanges": {
- "openvibe-contracts": "^0.1.0"
+ "openvibe-contracts": "^0.2.0"
},
},
"id": "network",
"id": "network",
"name": "OpenVibe.Network",
"name": "OpenVibe.Network",
@@ -21,6 +21,7 @@
@@ -21,6 +21,7 @@
"identity.subject.resolve",
"identity.subject.resolve",
"network.coins.credit",
"network.coins.credit",
"network.coins.debit",
"network.coins.debit",
+ "network.coins.transfer",
"network.notifications.push"
"network.notifications.push"
],
],
"eventsProduced": [],
"eventsProduced": [],
@@ -12,5 +12,5 @@
@@ -12,5 +12,5 @@
"eventsProduced": [],
"eventsProduced": [],
"eventsConsumed": [],
"eventsConsumed": [],
"namespacesOwned": [],
"namespacesOwned": [],
- "notes": "Wiki spaces with page trees, revisions, citations, media and discussion — editable together, source-backed. Charter only; no runtime."
+ "notes": "Wiki spaces with page trees, revisions, citations, media and discussion \u2014 editable together, source-backed. Charter only; no runtime."
}
}
@@ -1,15 +1,24 @@
@@ -1,15 +1,24 @@
{
{
"name": "openvibe-contracts",
"name": "openvibe-contracts",
- "version": "0.1.0",
+ "version": "0.2.0",
"description": "Machine-readable contracts for the OpenVibe network: schemas, manifests, ids, error envelope, capability checks.",
"description": "Machine-readable contracts for the OpenVibe network: schemas, manifests, ids, error envelope, capability checks.",
"main": "index.js",
"main": "index.js",
"types": "generated/typescript/index.d.ts",
"types": "generated/typescript/index.d.ts",
- "files": ["index.js", "lib/", "contracts/", "manifests/", "compatibility/", "generated/"],
+ "files": [
+ "index.js",
+ "lib/",
+ "contracts/",
+ "manifests/",
+ "compatibility/",
+ "generated/"
+ ],
"scripts": {
"scripts": {
"test": "node test/run.js",
"test": "node test/run.js",
"generate": "node scripts/generate.js"
"generate": "node scripts/generate.js"
},
},
- "engines": { "node": ">=20" },
+ "engines": {
+ "node": ">=20"
+ },
"repository": "github:OpenVibers/OpenVibe.Contracts",
"repository": "github:OpenVibers/OpenVibe.Contracts",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
* consumer of the same major version:
* consumer of the same major version:
* - a property removed, or its type changed
* - a property removed, or its type changed
* - a property newly required
* - a property newly required
- * - an enum/const value removed
+ * - an enum value removed, or a const changed to another value
* - additionalProperties tightened to false
* - additionalProperties tightened to false
* - a catalog entry removed without a deprecation record
* - a catalog entry removed without a deprecation record
* A breaking change belongs in a new major (<name>.v2.json) with the old file deprecated.
* A breaking change belongs in a new major (<name>.v2.json) with the old file deprecated.
@@ -28,7 +28,8 @@ function compare(id, a, b, at) {
@@ -28,7 +28,8 @@ function compare(id, a, b, at) {
const ta = JSON.stringify(a.type), tb = JSON.stringify(b.type);
const ta = JSON.stringify(a.type), tb = JSON.stringify(b.type);
if (a.type !== undefined && ta !== tb) problems.push(`${id} ${at}: type ${ta} -> ${tb}`);
if (a.type !== undefined && ta !== tb) problems.push(`${id} ${at}: type ${ta} -> ${tb}`);
for (const k of ['enum']) if (Array.isArray(a[k])) for (const v of a[k]) if (!(b[k] || []).includes(v)) problems.push(`${id} ${at}: enum value ${JSON.stringify(v)} removed`);
for (const k of ['enum']) if (Array.isArray(a[k])) for (const v of a[k]) if (!(b[k] || []).includes(v)) problems.push(`${id} ${at}: enum value ${JSON.stringify(v)} removed`);
- if ('const' in a && JSON.stringify(a.const) !== JSON.stringify(b.const)) problems.push(`${id} ${at}: const changed`);
+ // Dropping a const only widens what is accepted; changing it to another value breaks.
+ if ('const' in a && 'const' in b && JSON.stringify(a.const) !== JSON.stringify(b.const)) problems.push(`${id} ${at}: const changed`);
if (a.additionalProperties !== false && b.additionalProperties === false) problems.push(`${id} ${at}: additionalProperties tightened`);
if (a.additionalProperties !== false && b.additionalProperties === false) problems.push(`${id} ${at}: additionalProperties tightened`);
for (const r of b.required || []) if (!(a.required || []).includes(r)) problems.push(`${id} ${at}: "${r}" newly required`);
for (const r of b.required || []) if (!(a.required || []).includes(r)) problems.push(`${id} ${at}: "${r}" newly required`);
for (const [k, v] of Object.entries(a.properties || {})) {
for (const [k, v] of Object.entries(a.properties || {})) {
@@ -103,8 +103,62 @@ ok(!capabilities.check({ cap: ['media.*'], ns: ['*'] }, 'network.coins.debit').a
@@ -103,8 +103,62 @@ ok(!capabilities.check({ cap: ['media.*'], ns: ['*'] }, 'network.coins.debit').a
ok(!capabilities.grants(['media.object'], 'media.object.read'), 'a prefix without .* is not a family grant');
ok(!capabilities.grants(['media.object'], 'media.object.read'), 'a prefix without .* is not a family grant');
ok(!capabilities.namespaceAllowed(['live.*'], 'livestream'), 'namespace wildcard does not match a longer sibling name');
ok(!capabilities.namespaceAllowed(['live.*'], 'livestream'), 'namespace wildcard does not match a longer sibling name');
+// ── Service tokens: issue, verify, guard, client ─────────────────────────
+(async () => {
+const { serviceAuth } = contracts;
+const kp = require('crypto').generateKeyPairSync('rsa', { modulusLength: 2048 });
+const other = require('crypto').generateKeyPairSync('rsa', { modulusLength: 2048 });
+const t0 = Math.floor(Date.now() / 1000);
+const claims = { iss: 'https://openvibe.network', sub: 'svc:live', actor_type: 'service', aud: ['openvibe.network'], cap: ['network.coins.credit'], ns: ['live'], iat: t0, exp: t0 + 300, jti: 'jti_0123456789' };
+const tok = serviceAuth.signServiceToken(claims, kp.privateKey);
+const V = (t, o = {}) => serviceAuth.verifyServiceToken(t, { publicKey: kp.publicKey, issuer: 'https://openvibe.network', audience: 'openvibe.network', ...o });
+ok(V(tok).ok && V(tok).claims.sub === 'svc:live', 'valid token verifies');
+ok(V(serviceAuth.signServiceToken(claims, other.privateKey)).code === 'token.bad_signature', 'foreign key rejected');
+ok(V(serviceAuth.signServiceToken({ ...claims, exp: t0 - 120 }, kp.privateKey)).code === 'token.expired', 'expired rejected');
+ok(V(tok, { audience: 'openvibe.media' }).code === 'token.wrong_audience', 'wrong audience rejected');
+ok(V(tok, { issuer: 'https://evil.example' }).code === 'token.wrong_issuer', 'wrong issuer rejected');
+ok(V(serviceAuth.signServiceToken({ ...claims, sub: 'user:42' }, kp.privateKey)).code === 'token.invalid_claims', 'claims must match the contract');
+const [h, p] = tok.split('.');
+const none = `${Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url')}.${p}.`;
+ok(V(none).code === 'token.malformed', 'alg none rejected');
+ok(V(`${h}.${Buffer.from(JSON.stringify({ ...claims, cap: ['network.coins.debit'] })).toString('base64url')}.${tok.split('.')[2]}`).code === 'token.bad_signature', 'tampered claims rejected');
+ok(V('nope').code === 'token.malformed', 'garbage rejected');
+
+const run = (guard, headers) => new Promise((resolve) => {
+ const req = { headers, body: {} };
+ const res = { headers: {}, setHeader(k, v) { this.headers[k.toLowerCase()] = v; }, end(b) { resolve({ status: this.statusCode, body: JSON.parse(b), req }); } };
+ guard(req, res, () => resolve({ status: 200, req }));
+});
+const audit = [];
+const opts = { publicKey: kp.publicKey, issuer: 'https://openvibe.network', audience: 'openvibe.network', legacy: (req) => req.headers['x-internal-key'] === 'k', onDecision: (d) => audit.push(d) };
+let r = await run(serviceAuth.requireCapability('network.coins.credit', opts), { authorization: `Bearer ${tok}` });
+ok(r.status === 200 && r.req.principal.sub === 'svc:live', 'granted capability passes with the principal attached');
+r = await run(serviceAuth.requireCapability('network.coins.debit', opts), { authorization: `Bearer ${tok}` });
+ok(r.status === 403 && r.body.code === 'capability.denied' && contracts.validate('errors.problem', r.body).valid, 'ungranted capability is a 403 problem');
+r = await run(serviceAuth.requireCapability('network.coins.credit', opts), { authorization: 'Bearer garbage', 'x-internal-key': 'k' });
+ok(r.status === 401 && r.body.code === 'token.malformed', 'a bad token is not rescued by a legacy key');
+r = await run(serviceAuth.requireCapability('network.coins.credit', opts), { 'x-internal-key': 'k' });
+ok(r.status === 200 && r.req.principal.legacy === true, 'legacy key still works in compatibility mode');
+r = await run(serviceAuth.requireCapability('network.coins.credit', opts), {});
+ok(r.status === 403, 'no credentials, no access');
+r = await run(serviceAuth.requireCapability('media.object.upload', { ...opts, audience: 'openvibe.network', namespace: () => 'games.maps' }), { authorization: `Bearer ${serviceAuth.signServiceToken({ ...claims, cap: ['media.object.*'], ns: ['live.*'] }, kp.privateKey)}` });
+ok(r.status === 403 && r.body.code === 'capability.namespace_denied', 'namespace constraint enforced');
+ok(audit.length === 6 && audit.filter(a => a.allowed).length === 2, 'every decision reaches the audit hook');
+assert.throws(() => serviceAuth.requireCapability('nope.nope.nope'), /unknown capability/);
+
+let calls = 0;
+const fakeFetch = async (url, init) => { calls++; const b = new URLSearchParams(init.body); ok(b.get('grant_type') === 'client_credentials' && b.get('audience') === 'openvibe.network', 'client sends client_credentials'); return { ok: true, status: 200, json: async () => ({ access_token: `t${calls}`, expires_in: 300 }) }; };
+const client = serviceAuth.createTokenClient({ tokenUrl: 'http://x/oauth/token', clientId: 'live', clientSecret: 's', audience: 'openvibe.network', fetchImpl: fakeFetch });
+const [a1, a2] = await Promise.all([client.getToken(), client.getToken()]);
+ok(a1 === 't1' && a2 === 't1' && calls === 1, 'concurrent callers share one fetch');
+ok((await client.authHeaders()).Authorization === 'Bearer t1' && calls === 1, 'cached until near expiry');
+client.invalidate(); await client.getToken(); ok(calls === 2, 'invalidate forces a refetch');
+const failing = serviceAuth.createTokenClient({ tokenUrl: 'x', clientId: 'live', clientSecret: 'bad', audience: 'a', fetchImpl: async () => ({ ok: false, status: 401, json: async () => ({ error: 'invalid_client' }) }) });
+await assert.rejects(failing.getToken(), /401: invalid_client/);
+
// ── Generated output and compatibility gate ──────────────────────────────
// ── Generated output and compatibility gate ──────────────────────────────
execFileSync(process.execPath, [path.join(ROOT, 'scripts/generate.js'), '--check'], { stdio: 'inherit' });
execFileSync(process.execPath, [path.join(ROOT, 'scripts/generate.js'), '--check'], { stdio: 'inherit' });
execFileSync(process.execPath, [path.join(ROOT, 'scripts/compat.js')], { stdio: 'inherit' });
execFileSync(process.execPath, [path.join(ROOT, 'scripts/compat.js')], { stdio: 'inherit' });
console.log(`openvibe-contracts: ${n} checks passed`);
console.log(`openvibe-contracts: ${n} checks passed`);
+})().catch((err) => { console.error(err); process.exit(1); });