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

OP OpenVibers committed 22 Sept 2026 · 8c7d04310e · 19 files, +402 −49

identity.service-token-claims 1.1.0 widens iss from a constant to any URI; receivers pass the issuer they expect.
compat.js treats dropping a const as widening (changing it to another value still breaks).

Changes

ddiff --git a/README.md b/README.md
iindex 2d679e89..696d9d27 100644
---- a/README.md
++++ b/README.md
@@@ -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
ddiff --git a/contracts/catalog.json b/contracts/catalog.json
iindex fbdfd42a..0e01c120 100644
---- a/contracts/catalog.json
++++ b/contracts/catalog.json
@@@ -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"
++ }
]
}
ddiff --git a/contracts/identity/service-token-claims.v1.json b/contracts/identity/service-token-claims.v1.json
iindex 974492a1..deed3645 100644
---- a/contracts/identity/service-token-claims.v1.json
++++ b/contracts/identity/service-token-claims.v1.json
@@@ -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
++ }
}
}
ddiff --git a/fixtures/identity.service-token-claims/invalid/foreign-issuer.json b/fixtures/identity.service-token-claims/invalid/foreign-issuer.json
ddeleted file mode 100644
iindex 3d07048f..00000000
---- a/fixtures/identity.service-token-claims/invalid/foreign-issuer.json
++++ /dev/null
@@@ -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"
--}
ddiff --git a/fixtures/identity.service-token-claims/invalid/non-uri-issuer.json b/fixtures/identity.service-token-claims/invalid/non-uri-issuer.json
nnew file mode 100644
iindex 00000000..6d9735c6
---- /dev/null
++++ b/fixtures/identity.service-token-claims/invalid/non-uri-issuer.json
@@@ -0,0 +1,12 @@
++{
++ "iss": "openvibe network",
++ "sub": "svc:live",
++ "actor_type": "service",
++ "aud": [
++ "openvibe.media"
++ ],
++ "cap": [],
++ "iat": 1,
++ "exp": 2,
++ "jti": "tok_abcdef12"
++}
ddiff --git a/generated/json-schema/bundle.json b/generated/json-schema/bundle.json
iindex b2ec7bba..93e80dcb 100644
---- a/generated/json-schema/bundle.json
++++ b/generated/json-schema/bundle.json
@@@ -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",
ddiff --git a/generated/typescript/index.d.ts b/generated/typescript/index.d.ts
iindex 444e4189..e4bc30da 100644
---- a/generated/typescript/index.d.ts
++++ b/generated/typescript/index.d.ts
@@@ -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";
/**
ddiff --git a/index.js b/index.js
iindex bca7715b..8e351030 100644
---- a/index.js
++++ b/index.js
@@@ -20,4 +20,5 @@ module.exports = {
http: require('./lib/http'),
capabilities: require('./lib/capabilities'),
services: require('./lib/services'),
++ serviceAuth: require('./lib/service-auth'),
};
ddiff --git a/lib/service-auth.js b/lib/service-auth.js
nnew file mode 100644
iindex 00000000..9d2ed916
---- /dev/null
++++ b/lib/service-auth.js
@@@ -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 };
ddiff --git a/manifests/capabilities/network.coins.credit.json b/manifests/capabilities/network.coins.credit.json
iindex faf4dc2a..e8c12454 100644
---- a/manifests/capabilities/network.coins.credit.json
++++ b/manifests/capabilities/network.coins.credit.json
@@@ -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": [],
ddiff --git a/manifests/capabilities/network.coins.debit.json b/manifests/capabilities/network.coins.debit.json
iindex a5630ae0..859444b8 100644
---- a/manifests/capabilities/network.coins.debit.json
++++ b/manifests/capabilities/network.coins.debit.json
@@@ -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": [],
ddiff --git a/manifests/capabilities/network.coins.transfer.json b/manifests/capabilities/network.coins.transfer.json
nnew file mode 100644
iindex 00000000..cedefe44
---- /dev/null
++++ b/manifests/capabilities/network.coins.transfer.json
@@@ -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"
++ ]
++}
ddiff --git a/manifests/services/chat.json b/manifests/services/chat.json
iindex b7764a59..995fec3f 100644
---- a/manifests/services/chat.json
++++ b/manifests/services/chat.json
@@@ -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."
}
ddiff --git a/manifests/services/live.json b/manifests/services/live.json
iindex d665ac11..a146a381 100644
---- a/manifests/services/live.json
++++ b/manifests/services/live.json
@@@ -1,7 +1,7 @@
{
"version": "0.1.0",
"contractRanges": {
-- "openvibe-contracts": "^0.1.0"
++ "openvibe-contracts": "^0.2.0"
},
"id": "live",
"name": "OpenVibe.Live",
ddiff --git a/manifests/services/network.json b/manifests/services/network.json
iindex 310e914a..81ff1aed 100644
---- a/manifests/services/network.json
++++ b/manifests/services/network.json
@@@ -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": [],
ddiff --git a/manifests/services/wiki.json b/manifests/services/wiki.json
iindex bce5bcae..cdb7a33e 100644
---- a/manifests/services/wiki.json
++++ b/manifests/services/wiki.json
@@@ -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."
}
ddiff --git a/package.json b/package.json
iindex 93005bb3..04f3d9c3 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/scripts/compat.js b/scripts/compat.js
iindex 812e7d47..bdcdf93e 100644
---- a/scripts/compat.js
++++ b/scripts/compat.js
@@@ -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 || {})) {
ddiff --git a/test/run.js b/test/run.js
iindex fa2fcd5b..24d38cb2 100644
---- a/test/run.js
++++ b/test/run.js
@@@ -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.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); });