openvibers/openvibe.contracts Commits

v0.26.0: developer projects (ADR-014) — app token claims project_id/env/on_behalf_of (identity.service-token-claims 1.2.0), sandbox tokens refused unless acceptSandbox, prj id prefix, partner capability visibility, network.project.read|manage (planned), Network eventsProduced

OP OpenVibers committed 23 Sept 2026 · fba77042ad · 14 files, +214 −18


Changes

ddiff --git a/contracts/capabilities/capability.v1.json b/contracts/capabilities/capability.v1.json
iindex ab0b80d6..9e4bb191 100644
---- a/contracts/capabilities/capability.v1.json
++++ b/contracts/capabilities/capability.v1.json
@@@ -11,7 +11,7 @@
"version": { "type": "string", "pattern": "^\\d+\\.\\d+$" },
"owner": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,39}$" },
"status": { "enum": ["planned", "active", "deprecated", "retired"] },
-- "visibility": { "enum": ["public", "first-party", "internal"] },
++ "visibility": { "enum": ["public", "partner", "first-party", "internal"] },
"description": { "type": "string" },
"inputSchema": { "type": "string" },
"outputSchema": { "type": "string" },
ddiff --git a/contracts/catalog.json b/contracts/catalog.json
iindex aa2cf6ee..6e38e6f5 100644
---- a/contracts/catalog.json
++++ b/contracts/catalog.json
@@@ -23,7 +23,7 @@
},
{
"id": "identity.service-token-claims",
-- "version": "1.1.0",
++ "version": "1.2.0",
"owner": "network",
"schema": "identity/service-token-claims.v1.json",
"visibility": "public",
ddiff --git a/contracts/identity/service-token-claims.v1.json b/contracts/identity/service-token-claims.v1.json
iindex deed3645..d647fe76 100644
---- a/contracts/identity/service-token-claims.v1.json
++++ b/contracts/identity/service-token-claims.v1.json
@@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/identity/service-token-claims.v1.json",
"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. App tokens (actor_type app) also carry project_id and env; receivers refuse env=sandbox unless they opted in.",
"type": "object",
"required": [
"iss",
@@@ -64,6 +64,43 @@
"jti": {
"type": "string",
"minLength": 8
++ },
++ "project_id": {
++ "type": "string",
++ "pattern": "^prj_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "Developer project of an app principal (ADR-014). Services key tenancy by it. Absent on first-party service tokens."
++ },
++ "env": {
++ "enum": [
++ "sandbox",
++ "production"
++ ],
++ "description": "Environment of an app principal. A receiver MUST refuse env=sandbox (401 token.sandbox_refused) unless it opted in to sandbox tokens. Absent on first-party service tokens, which are production."
++ },
++ "on_behalf_of": {
++ "type": "string",
++ "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The person who authorized an app through the authorization-code flow. Absent on client_credentials tokens."
++ }
++ },
++ "allOf": [
++ {
++ "if": {
++ "properties": {
++ "actor_type": {
++ "const": "app"
++ }
++ },
++ "required": [
++ "actor_type"
++ ]
++ },
++ "then": {
++ "required": [
++ "project_id",
++ "env"
++ ]
++ }
}
-- }
++ ]
}
ddiff --git a/fixtures/identity.service-token-claims/invalid/app-without-project.json b/fixtures/identity.service-token-claims/invalid/app-without-project.json
nnew file mode 100644
iindex 00000000..f5abf0e1
---- /dev/null
++++ b/fixtures/identity.service-token-claims/invalid/app-without-project.json
@@@ -0,0 +1,20 @@
++{
++ "iss": "https://openvibe.network",
++ "sub": "app:app_01J0000000000000000000000Z",
++ "actor_type": "app",
++ "aud": [
++ "openvibe.media"
++ ],
++ "cap": [
++ "media.object.upload",
++ "media.object.read"
++ ],
++ "ns": [
++ "live.*"
++ ],
++ "iat": 1790000000,
++ "exp": 1790000300,
++ "jti": "tok_abcdef12",
++ "env": "sandbox",
++ "on_behalf_of": "usr_01J0000000000000000000000Z"
++}
\\ No newline at end of file
ddiff --git a/fixtures/identity.service-token-claims/valid/app-sandbox.json b/fixtures/identity.service-token-claims/valid/app-sandbox.json
nnew file mode 100644
iindex 00000000..1da9f74b
---- /dev/null
++++ b/fixtures/identity.service-token-claims/valid/app-sandbox.json
@@@ -0,0 +1,21 @@
++{
++ "iss": "https://openvibe.network",
++ "sub": "app:app_01J0000000000000000000000Z",
++ "actor_type": "app",
++ "aud": [
++ "openvibe.media"
++ ],
++ "cap": [
++ "media.object.upload",
++ "media.object.read"
++ ],
++ "ns": [
++ "live.*"
++ ],
++ "iat": 1790000000,
++ "exp": 1790000300,
++ "jti": "tok_abcdef12",
++ "project_id": "prj_01J0000000000000000000000Z",
++ "env": "sandbox",
++ "on_behalf_of": "usr_01J0000000000000000000000Z"
++}
\\ No newline at end of file
ddiff --git a/generated/json-schema/bundle.json b/generated/json-schema/bundle.json
iindex 8a417aa2..26c39fde 100644
---- a/generated/json-schema/bundle.json
++++ b/generated/json-schema/bundle.json
@@@ -162,14 +162,14 @@
}
},
"identity.service-token-claims": {
-- "version": "1.1.0",
++ "version": "1.2.0",
"owner": "network",
"status": "active",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/identity/service-token-claims.v1.json",
"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. App tokens (actor_type app) also carry project_id and env; receivers refuse env=sandbox unless they opted in.",
"type": "object",
"required": [
"iss",
@@@ -231,8 +231,45 @@
"jti": {
"type": "string",
"minLength": 8
++ },
++ "project_id": {
++ "type": "string",
++ "pattern": "^prj_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "Developer project of an app principal (ADR-014). Services key tenancy by it. Absent on first-party service tokens."
++ },
++ "env": {
++ "enum": [
++ "sandbox",
++ "production"
++ ],
++ "description": "Environment of an app principal. A receiver MUST refuse env=sandbox (401 token.sandbox_refused) unless it opted in to sandbox tokens. Absent on first-party service tokens, which are production."
++ },
++ "on_behalf_of": {
++ "type": "string",
++ "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The person who authorized an app through the authorization-code flow. Absent on client_credentials tokens."
}
-- }
++ },
++ "allOf": [
++ {
++ "if": {
++ "properties": {
++ "actor_type": {
++ "const": "app"
++ }
++ },
++ "required": [
++ "actor_type"
++ ]
++ },
++ "then": {
++ "required": [
++ "project_id",
++ "env"
++ ]
++ }
++ }
++ ]
}
},
"common.entity-ref": {
@@@ -498,6 +535,7 @@
"visibility": {
"enum": [
"public",
++ "partner",
"first-party",
"internal"
]
ddiff --git a/generated/typescript/index.d.ts b/generated/typescript/index.d.ts
iindex ff476899..2782980f 100644
---- a/generated/typescript/index.d.ts
++++ b/generated/typescript/index.d.ts
@@@ -47,11 +47,13 @@ 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.
++ * Claims of a short-lived RS256 client-credentials token issued by OpenVibe.Network to a service or app principal. Replaces X-Internal-Key. App tokens (actor_type app) also carry project_id and env; receivers refuse env=sandbox unless they opted in.
*/
--export interface ServiceTokenClaims {
++export type ServiceTokenClaims = {
++ [k: string]: unknown | undefined;
++} & {
/**
* Issuer; https://openvibe.network in production. Receivers pass the issuer they expect to verifyServiceToken().
*/
@@@ -73,8 +75,20 @@ export interface ServiceTokenClaims {
iat: number;
exp: number;
jti: string;
++ /**
++ * Developer project of an app principal (ADR-014). Services key tenancy by it. Absent on first-party service tokens.
++ */
++ project_id?: string;
++ /**
++ * Environment of an app principal. A receiver MUST refuse env=sandbox (401 token.sandbox_refused) unless it opted in to sandbox tokens. Absent on first-party service tokens, which are production.
++ */
++ env?: "sandbox" | "production";
++ /**
++ * The person who authorized an app through the authorization-code flow. Absent on client_credentials tokens.
++ */
++ on_behalf_of?: string;
[k: string]: unknown | undefined;
--}
++};
/** [email protected] (owner: contracts) */
/**
@@@ -148,7 +162,7 @@ export interface Capability {
version: string;
owner: string;
status: "planned" | "active" | "deprecated" | "retired";
-- visibility: "public" | "first-party" | "internal";
++ visibility: "public" | "partner" | "first-party" | "internal";
description?: string;
inputSchema?: string;
outputSchema?: string;
ddiff --git a/lib/ids.js b/lib/ids.js
iindex 1d0c3ae8..1d0eb7f8 100644
---- a/lib/ids.js
++++ b/lib/ids.js
@@@ -6,7 +6,7 @@
const crypto = require('crypto');
const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
--const PREFIX = { user: 'usr', guest: 'gst', app: 'app', mod: 'mod', event: 'evt', media: 'med' };
++const PREFIX = { user: 'usr', guest: 'gst', app: 'app', mod: 'mod', event: 'evt', media: 'med', project: 'prj' };
const SLUG_TYPES = new Set(['service', 'system']);
const ULID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/;
const SLUG_RE = /^[a-z][a-z0-9-]{1,39}$/;
ddiff --git a/lib/service-auth.js b/lib/service-auth.js
iindex 9d2ed916..321d1cec 100644
---- a/lib/service-auth.js
++++ b/lib/service-auth.js
@@@ -29,7 +29,7 @@ function signServiceToken(claims, privateKey, { kid } = {}) {
* 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() } = {}) {
++function verifyServiceToken(token, { publicKey, issuer, audience, clockSkewSec = 30, now = Date.now(), acceptSandbox = false } = {}) {
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');
@@@ -47,6 +47,8 @@ function verifyServiceToken(token, { publicKey, issuer, audience, clockSkewSec =
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('; '));
++ // Developer apps (ADR-014): a sandbox token is refused unless this receiver opted in.
++ if (claims.env === 'sandbox' && !acceptSandbox) return fail('token.sandbox_refused', 'sandbox tokens are not accepted here');
return { ok: true, claims };
}
@@@ -72,7 +74,7 @@ function requireCapability(capability, opts = {}) {
};
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 });
++ const r = verifyServiceToken(auth.slice(7).trim(), { publicKey, issuer: opts.issuer, audience: opts.audience, acceptSandbox: !!opts.acceptSandbox });
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 });
ddiff --git a/manifests/capabilities/network.project.manage.json b/manifests/capabilities/network.project.manage.json
nnew file mode 100644
iindex 00000000..157494df
---- /dev/null
++++ b/manifests/capabilities/network.project.manage.json
@@@ -0,0 +1,29 @@
++{
++ "id": "network.project.manage",
++ "version": "1.0",
++ "owner": "network",
++ "status": "planned",
++ "visibility": "public",
++ "description": "Manage developer projects on behalf of a signed-in member: projects, members, apps, credential rotation and revocation, grant requests and approvals within the project allowance. Today the API is used with the member's own Network access token and authorized by project role; this capability is what a delegated client (Codes CLI, SDK) will hold. Allowance, environment policy and quotas stay staff-only.",
++ "permissions": [
++ "projects:write"
++ ],
++ "resourceConstraints": [
++ "project"
++ ],
++ "quotaClass": "projects-write",
++ "events": [
++ "network.app.created",
++ "network.app.revoked",
++ "network.credential.rotated",
++ "network.credential.revoked",
++ "network.grant.changed"
++ ],
++ "implementedBy": [
++ "POST /api/v1/projects",
++ "POST /api/v1/projects/:project/apps",
++ "POST /api/v1/projects/:project/apps/:app/credentials/rotate",
++ "POST /api/v1/projects/:project/apps/:app/credentials/:credential/revoke",
++ "POST /api/v1/projects/:project/apps/:app/grants"
++ ]
++}
ddiff --git a/manifests/capabilities/network.project.read.json b/manifests/capabilities/network.project.read.json
nnew file mode 100644
iindex 00000000..dbf38bdb
---- /dev/null
++++ b/manifests/capabilities/network.project.read.json
@@@ -0,0 +1,19 @@
++{
++ "id": "network.project.read",
++ "version": "1.0",
++ "owner": "network",
++ "status": "planned",
++ "visibility": "first-party",
++ "description": "Read a developer project as an owning service: its apps and their revocation state, approved grants, environment policy and quotas. Services use it to key tenancy by project_id and to enforce the quotas Network records (ADR-014). Never grantable to apps.",
++ "permissions": [
++ "projects:read"
++ ],
++ "resourceConstraints": [
++ "project"
++ ],
++ "quotaClass": "projects-read",
++ "events": [],
++ "implementedBy": [
++ "GET /internal/projects/:project_id (planned)"
++ ]
++}
ddiff --git a/manifests/services/network.json b/manifests/services/network.json
iindex 510cb38d..c35695c6 100644
---- a/manifests/services/network.json
++++ b/manifests/services/network.json
@@@ -25,9 +25,17 @@
"network.notifications.push",
"network.modules.read",
"network.modules.write",
-- "mods.grant.manage"
++ "mods.grant.manage",
++ "network.project.read",
++ "network.project.manage"
++ ],
++ "eventsProduced": [
++ "network.app.created",
++ "network.app.revoked",
++ "network.credential.rotated",
++ "network.credential.revoked",
++ "network.grant.changed"
],
-- "eventsProduced": [],
"eventsConsumed": [],
"namespacesOwned": [
"identity.*",
ddiff --git a/package.json b/package.json
iindex 86be7387..380ec056 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-contracts",
-- "version": "0.25.0",
++ "version": "0.26.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",
ddiff --git a/test/run.js b/test/run.js
iindex fd7fbee0..dc00f839 100644
---- a/test/run.js
++++ b/test/run.js
@@@ -123,6 +123,14 @@ const none = `${Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url
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');
++// Developer apps (ADR-014): sandbox tokens are refused unless the receiver opts in.
++const appClaims = { ...claims, sub: 'app:app_01J0000000000000000000000Z', actor_type: 'app', project_id: 'prj_01J0000000000000000000000Z', env: 'sandbox' };
++const sandboxTok = serviceAuth.signServiceToken(appClaims, kp.privateKey);
++ok(V(sandboxTok).code === 'token.sandbox_refused', 'sandbox app token refused by default');
++ok(V(sandboxTok, { acceptSandbox: true }).ok, 'sandbox app token accepted when the receiver opts in');
++ok(V(serviceAuth.signServiceToken({ ...appClaims, env: 'production' }, kp.privateKey)).ok, 'production app token accepted');
++ok(V(serviceAuth.signServiceToken((({ project_id, ...rest }) => rest)(appClaims), kp.privateKey)).code === 'token.invalid_claims', 'an app token needs project_id');
++ok(contracts.ids.newId('project').startsWith('prj_'), 'project ids use the prj prefix');
const run = (guard, headers) => new Promise((resolve) => {
const req = { headers, body: {} };