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
Changes
@@@ -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" },@@@ -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",@@@ -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"++ ]++ }}-- }++ ]}nnew file mode 100644@@@ -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 filennew file mode 100644@@@ -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@@@ -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"]@@@ -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;@@@ -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}$/;@@@ -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 });nnew file mode 100644@@@ -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"++ ]++}nnew file mode 100644@@@ -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)"++ ]++}@@@ -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.*",@@@ -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",@@@ -123,6 +123,14 @@ const none = `${Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64urlok(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: {} };
@@ -11,7 +11,7 @@
@@ -11,7 +11,7 @@
"version": { "type": "string", "pattern": "^\\d+\\.\\d+$" },
"version": { "type": "string", "pattern": "^\\d+\\.\\d+$" },
"owner": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,39}$" },
"owner": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,39}$" },
"status": { "enum": ["planned", "active", "deprecated", "retired"] },
"status": { "enum": ["planned", "active", "deprecated", "retired"] },
- "visibility": { "enum": ["public", "first-party", "internal"] },
+ "visibility": { "enum": ["public", "partner", "first-party", "internal"] },
"description": { "type": "string" },
"description": { "type": "string" },
"inputSchema": { "type": "string" },
"inputSchema": { "type": "string" },
"outputSchema": { "type": "string" },
"outputSchema": { "type": "string" },
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
},
},
{
{
"id": "identity.service-token-claims",
"id": "identity.service-token-claims",
- "version": "1.1.0",
+ "version": "1.2.0",
"owner": "network",
"owner": "network",
"schema": "identity/service-token-claims.v1.json",
"schema": "identity/service-token-claims.v1.json",
"visibility": "public",
"visibility": "public",
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/identity/service-token-claims.v1.json",
"$id": "https://openvibe.network/contracts/identity/service-token-claims.v1.json",
"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. App tokens (actor_type app) also carry project_id and env; receivers refuse env=sandbox unless they opted in.",
"type": "object",
"type": "object",
"required": [
"required": [
"iss",
"iss",
@@ -64,6 +64,43 @@
@@ -64,6 +64,43 @@
"jti": {
"jti": {
"type": "string",
"type": "string",
"minLength": 8
"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"
+ ]
+ }
}
}
- }
+ ]
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -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
\ No newline at end of file
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -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
\ No newline at end of file
@@ -162,14 +162,14 @@
@@ -162,14 +162,14 @@
}
}
},
},
"identity.service-token-claims": {
"identity.service-token-claims": {
- "version": "1.1.0",
+ "version": "1.2.0",
"owner": "network",
"owner": "network",
"status": "active",
"status": "active",
"schema": {
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/identity/service-token-claims.v1.json",
"$id": "https://openvibe.network/contracts/identity/service-token-claims.v1.json",
"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. App tokens (actor_type app) also carry project_id and env; receivers refuse env=sandbox unless they opted in.",
"type": "object",
"type": "object",
"required": [
"required": [
"iss",
"iss",
@@ -231,8 +231,45 @@
@@ -231,8 +231,45 @@
"jti": {
"jti": {
"type": "string",
"type": "string",
"minLength": 8
"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": {
"common.entity-ref": {
@@ -498,6 +535,7 @@
@@ -498,6 +535,7 @@
"visibility": {
"visibility": {
"enum": [
"enum": [
"public",
"public",
+ "partner",
"first-party",
"first-party",
"internal"
"internal"
]
]
@@ -47,11 +47,13 @@ export interface LegacyIdentityMapEntry {
@@ -47,11 +47,13 @@ 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. 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().
* Issuer; https://openvibe.network in production. Receivers pass the issuer they expect to verifyServiceToken().
*/
*/
@@ -73,8 +75,20 @@ export interface ServiceTokenClaims {
@@ -73,8 +75,20 @@ export interface ServiceTokenClaims {
iat: number;
iat: number;
exp: number;
exp: number;
jti: string;
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;
[k: string]: unknown | undefined;
-}
+};
/** [email protected] (owner: contracts) */
/** [email protected] (owner: contracts) */
/**
/**
@@ -148,7 +162,7 @@ export interface Capability {
@@ -148,7 +162,7 @@ export interface Capability {
version: string;
version: string;
owner: string;
owner: string;
status: "planned" | "active" | "deprecated" | "retired";
status: "planned" | "active" | "deprecated" | "retired";
- visibility: "public" | "first-party" | "internal";
+ visibility: "public" | "partner" | "first-party" | "internal";
description?: string;
description?: string;
inputSchema?: string;
inputSchema?: string;
outputSchema?: string;
outputSchema?: string;
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
const crypto = require('crypto');
const crypto = require('crypto');
const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
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 SLUG_TYPES = new Set(['service', 'system']);
const ULID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/;
const ULID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/;
const SLUG_RE = /^[a-z][a-z0-9-]{1,39}$/;
const SLUG_RE = /^[a-z][a-z0-9-]{1,39}$/;
@@ -29,7 +29,7 @@ function signServiceToken(claims, privateKey, { kid } = {}) {
@@ -29,7 +29,7 @@ function signServiceToken(claims, privateKey, { kid } = {}) {
* stable problem code: token.malformed | token.bad_signature | token.expired | token.wrong_issuer |
* stable problem code: token.malformed | token.bad_signature | token.expired | token.wrong_issuer |
* token.wrong_audience | token.invalid_claims.
* 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 fail = (code, reason) => ({ ok: false, code, reason });
const parts = typeof token === 'string' ? token.split('.') : [];
const parts = typeof token === 'string' ? token.split('.') : [];
if (parts.length !== 3) return fail('token.malformed', 'not a JWT');
if (parts.length !== 3) return fail('token.malformed', 'not a JWT');
@@ -47,6 +47,8 @@ function verifyServiceToken(token, { publicKey, issuer, audience, clockSkewSec =
@@ -47,6 +47,8 @@ function verifyServiceToken(token, { publicKey, issuer, audience, clockSkewSec =
if (audience && !aud.includes(audience)) return fail('token.wrong_audience', `not for ${audience}`);
if (audience && !aud.includes(audience)) return fail('token.wrong_audience', `not for ${audience}`);
const v = validate('identity.service-token-claims@1', claims);
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('; '));
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 };
return { ok: true, claims };
}
}
@@ -72,7 +74,7 @@ function requireCapability(capability, opts = {}) {
@@ -72,7 +74,7 @@ function requireCapability(capability, opts = {}) {
};
};
if (auth.startsWith('Bearer ')) {
if (auth.startsWith('Bearer ')) {
const publicKey = opts.getPublicKey ? opts.getPublicKey(req) : opts.publicKey;
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);
if (!r.ok) return decide(false, r.code, null, r.reason);
const ns = opts.namespace ? opts.namespace(req) : undefined;
const ns = opts.namespace ? opts.namespace(req) : undefined;
const c = capabilities.check(r.claims, capability, { namespace: ns });
const c = capabilities.check(r.claims, capability, { namespace: ns });
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -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"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -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)"
+ ]
+}
@@ -25,9 +25,17 @@
@@ -25,9 +25,17 @@
"network.notifications.push",
"network.notifications.push",
"network.modules.read",
"network.modules.read",
"network.modules.write",
"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": [],
"eventsConsumed": [],
"namespacesOwned": [
"namespacesOwned": [
"identity.*",
"identity.*",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-contracts",
"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.",
"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",
@@ -123,6 +123,14 @@ const none = `${Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url
@@ -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(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(`${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');
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 run = (guard, headers) => new Promise((resolve) => {
const req = { headers, body: {} };
const req = { headers, body: {} };