openvibers/openvibe.contracts Commits

v0.1.0: first contracts (subject, legacy identity map, service-token claims, entity ref, problem, service manifest, capability, event envelope, media ref)

OP OpenVibers committed 22 Sept 2026 · bae88d185a · 99 files, +2974 −6

Schemas are JSON Schema 2020-12 validated with Ajv. Each ships valid and invalid fixtures
and generated TypeScript types. Also included: 28 service manifests (7 running services,
21 charter placeholders), the first 9 capabilities mapped to the routes that implement them
today, ULID subject ids, request/problem envelope helpers, capability grant checks, and a
compatibility gate against the previous release tag. CI runs npm test.

Changes

ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
nnew file mode 100644
iindex 00000000..660473d5
---- /dev/null
++++ b/.github/workflows/ci.yml
@@@ -0,0 +1,19 @@
++name: contracts
++on:
++ push:
++ branches: [main]
++ tags: ['v*']
++ pull_request:
++jobs:
++ test:
++ runs-on: ubuntu-latest
++ steps:
++ - uses: actions/checkout@v4
++ with:
++ fetch-depth: 0 # compat.js diffs against the previous release tag
++ - uses: actions/setup-node@v4
++ with:
++ node-version: 22
++ cache: npm
++ - run: npm ci
++ - run: npm test
ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 00000000..c2658d7d
---- /dev/null
++++ b/.gitignore
@@@ -0,0 +1 @@
++node_modules/
ddiff --git a/README.md b/README.md
iindex d9419dbe..2d679e89 100644
---- a/README.md
++++ b/README.md
@@@ -2,10 +2,60 @@
> Machine-readable contracts for the whole OpenVibe network.
--**Status:** placeholder — planning only, no runnable code yet.
--**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.1 and §18.11.
++**Status:** alpha, v0.1 (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).
++## Using it
++
++Pin a release tag. It's a public repo, so no deploy key is needed:
++
++```json
++"dependencies": { "openvibe-contracts": "github:OpenVibers/OpenVibe.Contracts#v0.1.0" }
++```
++
++```js
++const contracts = require('openvibe-contracts');
++
++contracts.validate('identity.subject-ref@1', { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' }); // { valid, errors }
++contracts.ids.newId('user'); // 'usr_01J…' (ULID, time-sortable)
++contracts.ids.parseSubject('service:live'); // { type: 'service', id: 'live' }
++
++app.use(contracts.http.middleware()); // req.ov = { traceId, requestId, traceparent, idempotencyKey }
++contracts.http.sendProblem(res, 403, 'capability.denied', { detail: 'not granted', ctx: req.ov });
++
++contracts.capabilities.check(tokenClaims, 'media.object.upload', { namespace: 'live.files' });
++// { allowed: false, code: 'capability.denied' | 'capability.namespace_denied' | 'capability.unknown', reason }
++```
++
++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
++
++| Contract | Owner | Purpose |
++|---|---|---|
++| `identity.subject-ref` | network | `{ type: user\|guest\|service\|app\|mod\|system, id }`: the one actor reference; never a service-local integer |
++| `identity.legacy-identity-map` | network | row shape of `identity_legacy_map`: legacy id to subject, kept alongside legacy ids |
++| `identity.service-token-claims` | network | client-credentials token for service/app/mod principals (replaces `X-Internal-Key`) |
++| `common.entity-ref` | contracts | `{ service, type, id, revision?, label? }`: typed cross-service reference instead of a foreign key |
++| `errors.problem` | contracts | RFC 9457 problem details plus stable `code`, `request_id`, `trace_id` and a compatibility `error` field |
++| `registry.service-manifest` | network | what a service is, where it lives and what it offers |
++| `capabilities.capability` | network | an invokable action: permissions, resource constraints, quota class, events |
++| `events.event-envelope` | events | durable event shape for OpenVibe.Events (Wave 3) |
++| `media.media-ref` | media | `med_<ULID>` (Wave 4) or transitional `legacy:<app>:<kind>:<id>` |
++
++Manifests: `manifests/services/` (the 7 running services plus the 21 charter repos as `placeholder`), `manifests/capabilities/` (first set: Media upload/read, chat send, paste create, coins credit/debit, notifications push, subject resolve, Community post). Each active capability names the route that implements it today.
++
++**Ids.** Subjects use prefixed ULIDs: `usr_`, `gst_`, `app_`, `mod_`. Services and system actors use slugs (`live`, `media`). Events use `evt_` and Media objects `med_`.
++
++## Versioning and compatibility
++
++- A contract id is permanent. Minor versions only add optional fields.
++- A breaking change is a new file (`<name>.v2.json`) and catalog entry. The old one is listed in `compatibility/deprecations.json` with a replacement and a `removeAfter` date.
++- `scripts/compat.js` runs in `npm test` and CI. It fails on removed or retyped properties, newly required fields, removed enum values, tightened `additionalProperties`, or catalog removals without a deprecation, compared against the previous release tag.
++- `generated/` is checked in; `npm test` fails if it is stale (`npm run generate`).
++- Every contract ships valid and invalid fixtures under `fixtures/<id>/`.
++
## Purpose
The canonical, versioned source of every cross-service contract: subject identity, error envelope, service manifests, capability schemas, event envelopes and topics, user-module schemas, media/chat/community/billing/AI/games/jobs/release schemas, plus generated TypeScript types, JSON Schema and OpenAPI fragments. Everything else in the network migrates contract-by-contract instead of through copied prose.
ddiff --git a/STATUS.json b/STATUS.json
iindex 3a6e0dd9..ecd951f4 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -1,7 +1,9 @@
{
"repository": "OpenVibers/OpenVibe.Contracts",
-- "stage": "placeholder",
-- "code": false,
-- "plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
-- "updated": "2026-09-21"
++ "stage": "alpha",
++ "code": true,
++ "wave": "W1",
++ "dependsOn": ["OpenVibe.Network"],
++ "plan": "OpenVibe Development Roadmap (combined), Wave 1; implementation plan rev 3 section 3.1",
++ "updated": "2026-09-22"
}
ddiff --git a/compatibility/aliases.json b/compatibility/aliases.json
nnew file mode 100644
iindex 00000000..4a75bd8e
---- /dev/null
++++ b/compatibility/aliases.json
@@@ -0,0 +1 @@
++{ "_about": "Old identifier -> current contract id. Consumers resolve through contracts.resolve(id).", "aliases": {} }
ddiff --git a/compatibility/deprecations.json b/compatibility/deprecations.json
nnew file mode 100644
iindex 00000000..e259b074
---- /dev/null
++++ b/compatibility/deprecations.json
@@@ -0,0 +1 @@
++{ "_about": "Deprecated contracts: { id, replacement, since, removeAfter, reason }. A deprecated contract stays loadable until removeAfter.", "deprecations": [] }
ddiff --git a/contracts/capabilities/capability.v1.json b/contracts/capabilities/capability.v1.json
nnew file mode 100644
iindex 00000000..ab0b80d6
---- /dev/null
++++ b/contracts/capabilities/capability.v1.json
@@@ -0,0 +1,24 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/capabilities/capability.v1.json",
++ "title": "Capability",
++ "description": "An action a principal may invoke. Authorization is checked at the owner's boundary against the grant, never inferred from the caller's route.",
++ "type": "object",
++ "required": ["id", "version", "owner", "status", "visibility", "permissions", "resourceConstraints", "quotaClass", "events"],
++ "additionalProperties": false,
++ "properties": {
++ "id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+){2,}$" },
++ "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"] },
++ "description": { "type": "string" },
++ "inputSchema": { "type": "string" },
++ "outputSchema": { "type": "string" },
++ "permissions": { "type": "array", "items": { "type": "string" } },
++ "resourceConstraints": { "type": "array", "items": { "enum": ["namespace", "owner", "room_member", "space_member", "project", "none"] } },
++ "quotaClass": { "type": "string" },
++ "events": { "type": "array", "items": { "type": "string" } },
++ "implementedBy": { "type": "array", "items": { "type": "string" }, "description": "Current route(s) that perform this action today, e.g. 'POST /api/v1/:app/files'." }
++ }
++}
ddiff --git a/contracts/catalog.json b/contracts/catalog.json
nnew file mode 100644
iindex 00000000..fbdfd42a
---- /dev/null
++++ b/contracts/catalog.json
@@@ -0,0 +1,14 @@
++{
++ "_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" }
++ ]
++}
ddiff --git a/contracts/common/entity-ref.v1.json b/contracts/common/entity-ref.v1.json
nnew file mode 100644
iindex 00000000..6363f5f2
---- /dev/null
++++ b/contracts/common/entity-ref.v1.json
@@@ -0,0 +1,16 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/common/entity-ref.v1.json",
++ "title": "EntityRef",
++ "description": "Typed reference to another service's entity. Store this instead of a foreign key; resolve a display projection from the owner.",
++ "type": "object",
++ "required": ["service", "type", "id"],
++ "additionalProperties": false,
++ "properties": {
++ "service": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,39}$" },
++ "type": { "type": "string", "pattern": "^[a-z][a-z0-9_]{1,39}$" },
++ "id": { "type": "string", "minLength": 1, "maxLength": 128 },
++ "revision": { "type": "integer", "minimum": 0 },
++ "label": { "type": "string", "maxLength": 200, "description": "Cached display label. Never authoritative." }
++ }
++}
ddiff --git a/contracts/errors/problem.v1.json b/contracts/errors/problem.v1.json
nnew file mode 100644
iindex 00000000..cde0555a
---- /dev/null
++++ b/contracts/errors/problem.v1.json
@@@ -0,0 +1,21 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/errors/problem.v1.json",
++ "title": "Problem",
++ "description": "RFC 9457 problem details (application/problem+json) with a stable OpenVibe error code and trace identifiers. The legacy 'error' string is allowed during migration so existing clients keep working.",
++ "type": "object",
++ "required": ["type", "title", "status", "code"],
++ "additionalProperties": true,
++ "properties": {
++ "type": { "type": "string", "format": "uri-reference" },
++ "title": { "type": "string", "minLength": 1 },
++ "status": { "type": "integer", "minimum": 400, "maximum": 599 },
++ "detail": { "type": "string" },
++ "instance": { "type": "string", "format": "uri-reference" },
++ "code": { "type": "string", "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)*$" },
++ "request_id": { "type": "string" },
++ "trace_id": { "type": "string", "pattern": "^[0-9a-f]{32}$" },
++ "errors": { "type": "array", "items": { "type": "object", "required": ["message"], "properties": { "path": { "type": "string" }, "message": { "type": "string" } } } },
++ "error": { "type": "string", "description": "Deprecated compatibility field: same text as title/detail for clients that read {error}." }
++ }
++}
ddiff --git a/contracts/events/event-envelope.v1.json b/contracts/events/event-envelope.v1.json
nnew file mode 100644
iindex 00000000..38bb1f4f
---- /dev/null
++++ b/contracts/events/event-envelope.v1.json
@@@ -0,0 +1,25 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/event-envelope.v1.json",
++ "title": "EventEnvelope",
++ "description": "Durable platform event (OpenVibe.Events, Wave 3). Producers write it to an outbox in the same transaction as the domain change.",
++ "type": "object",
++ "required": ["event_id", "event_type", "version", "source", "actor", "timestamp", "subject", "payload"],
++ "additionalProperties": false,
++ "properties": {
++ "event_id": { "type": "string", "pattern": "^evt_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "trace_id": { "type": "string", "pattern": "^[0-9a-f]{32}$" },
++ "event_type": { "type": "string", "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+){2,}$" },
++ "version": { "type": "integer", "minimum": 1 },
++ "source": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,39}$" },
++ "actor": { "$ref": "../identity/subject-ref.v1.json" },
++ "timestamp": { "type": "string", "format": "date-time" },
++ "priority": { "enum": ["critical", "important", "low"], "default": "important" },
++ "visibility": { "enum": ["public", "subject", "internal"], "default": "internal" },
++ "subject": {
++ "type": "object", "required": ["type", "id"], "additionalProperties": false,
++ "properties": { "type": { "type": "string" }, "id": { "type": "string" }, "revision": { "type": "integer", "minimum": 0 } }
++ },
++ "payload": { "type": "object" }
++ }
++}
ddiff --git a/contracts/identity/legacy-identity-map.v1.json b/contracts/identity/legacy-identity-map.v1.json
nnew file mode 100644
iindex 00000000..c35d985b
---- /dev/null
++++ b/contracts/identity/legacy-identity-map.v1.json
@@@ -0,0 +1,18 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/identity/legacy-identity-map.v1.json",
++ "title": "LegacyIdentityMapEntry",
++ "description": "One row of identity_legacy_map: links a service-local id to its canonical subject. Kept alongside legacy ids, never replacing them.",
++ "type": "object",
++ "required": ["subject_id", "source_system", "source_type", "source_id"],
++ "additionalProperties": false,
++ "properties": {
++ "subject_id": { "type": "string", "pattern": "^(usr|gst|app|mod)_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "source_system": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,39}$", "description": "live, media, games, network, rs-companion, hobo, ..." },
++ "source_type": { "type": "string", "pattern": "^[a-z][a-z0-9_]{1,39}$", "description": "user, anon_user, player, steam, ..." },
++ "source_id": { "type": "string", "minLength": 1, "maxLength": 128 },
++ "first_seen_at": { "type": "string", "format": "date-time" },
++ "verified_at": { "type": ["string", "null"], "format": "date-time" },
++ "metadata": { "type": "object" }
++ }
++}
ddiff --git a/contracts/identity/service-token-claims.v1.json b/contracts/identity/service-token-claims.v1.json
nnew file mode 100644
iindex 00000000..974492a1
---- /dev/null
++++ b/contracts/identity/service-token-claims.v1.json
@@@ -0,0 +1,20 @@
++{
++ "$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.",
++ "type": "object",
++ "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 }
++ }
++}
ddiff --git a/contracts/identity/subject-ref.v1.json b/contracts/identity/subject-ref.v1.json
nnew file mode 100644
iindex 00000000..c5637b37
---- /dev/null
++++ b/contracts/identity/subject-ref.v1.json
@@@ -0,0 +1,101 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/identity/subject-ref.v1.json",
++ "title": "SubjectRef",
++ "description": "The one cross-platform reference to an actor. Issued by OpenVibe.Network. Never a service-local integer id.",
++ "oneOf": [
++ {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "user"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$"
++ }
++ },
++ "description": "UserSubject"
++ },
++ {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "guest"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^gst_[0-9A-HJKMNP-TV-Z]{26}$"
++ }
++ },
++ "description": "GuestSubject"
++ },
++ {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "app"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^app_[0-9A-HJKMNP-TV-Z]{26}$"
++ }
++ },
++ "description": "AppSubject"
++ },
++ {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "mod"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^mod_[0-9A-HJKMNP-TV-Z]{26}$"
++ }
++ },
++ "description": "ModSubject"
++ },
++ {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "enum": [
++ "service",
++ "system"
++ ]
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{1,39}$"
++ }
++ },
++ "description": "PrincipalSubject"
++ }
++ ]
++}
ddiff --git a/contracts/media/media-ref.v1.json b/contracts/media/media-ref.v1.json
nnew file mode 100644
iindex 00000000..17fde120
---- /dev/null
++++ b/contracts/media/media-ref.v1.json
@@@ -0,0 +1,14 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/media/media-ref.v1.json",
++ "title": "MediaRef",
++ "description": "Reference to a Media object. Canonical ids (med_<ULID>) arrive with the Wave 4 object model; until then legacy:<app>:<kind>:<id> addresses today's VOD/clip/file/paste/thumbnail rows.",
++ "type": "object",
++ "required": ["media_id"],
++ "additionalProperties": false,
++ "properties": {
++ "media_id": { "type": "string", "pattern": "^(med_[0-9A-HJKMNP-TV-Z]{26}|legacy:[a-z][a-z0-9-]{1,39}:(vod|clip|file|paste|thumbnail|avatar):[A-Za-z0-9._/-]{1,200})$" },
++ "role": { "type": "string", "pattern": "^[a-z][a-z0-9_]{1,39}$" },
++ "variant": { "type": "string", "pattern": "^[a-z0-9][a-z0-9_-]{0,39}$" }
++ }
++}
ddiff --git a/contracts/registry/service-manifest.v1.json b/contracts/registry/service-manifest.v1.json
nnew file mode 100644
iindex 00000000..63b11717
---- /dev/null
++++ b/contracts/registry/service-manifest.v1.json
@@@ -0,0 +1,26 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/registry/service-manifest.v1.json",
++ "title": "ServiceManifest",
++ "description": "What a service is, where it lives and what it offers. The ecosystem registry is built from these, not from hard-coded route maps.",
++ "type": "object",
++ "required": ["id", "version", "status", "repository", "domains", "capabilities", "eventsProduced", "eventsConsumed", "namespacesOwned"],
++ "additionalProperties": false,
++ "properties": {
++ "id": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,39}$" },
++ "name": { "type": "string" },
++ "version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z.-]+)?$" },
++ "status": { "enum": ["placeholder", "alpha", "beta", "stable", "degraded", "retired"] },
++ "repository": { "type": "string", "pattern": "^OpenVibers/[A-Za-z0-9._-]+$" },
++ "domains": { "type": "array", "items": { "type": "string" } },
++ "publicOrigin": { "type": "string", "format": "uri" },
++ "health": { "type": "string", "pattern": "^/" },
++ "ready": { "type": "string", "pattern": "^/" },
++ "capabilities": { "type": "array", "items": { "type": "string" } },
++ "eventsProduced": { "type": "array", "items": { "type": "string" } },
++ "eventsConsumed": { "type": "array", "items": { "type": "string" } },
++ "namespacesOwned": { "type": "array", "items": { "type": "string" } },
++ "contractRanges": { "type": "object", "additionalProperties": { "type": "string" } },
++ "notes": { "type": "string" }
++ }
++}
ddiff --git a/fixtures/capabilities.capability/invalid/two-segment-id.json b/fixtures/capabilities.capability/invalid/two-segment-id.json
nnew file mode 100644
iindex 00000000..15889373
---- /dev/null
++++ b/fixtures/capabilities.capability/invalid/two-segment-id.json
@@@ -0,0 +1,11 @@
++{
++ "id": "media.upload",
++ "version": "1.0",
++ "owner": "media",
++ "status": "active",
++ "visibility": "public",
++ "permissions": [],
++ "resourceConstraints": [],
++ "quotaClass": "x",
++ "events": []
++}
ddiff --git a/fixtures/capabilities.capability/valid/upload.json b/fixtures/capabilities.capability/valid/upload.json
nnew file mode 100644
iindex 00000000..f4dd13d8
---- /dev/null
++++ b/fixtures/capabilities.capability/valid/upload.json
@@@ -0,0 +1,23 @@
++{
++ "id": "media.object.upload",
++ "version": "1.0",
++ "owner": "media",
++ "status": "active",
++ "visibility": "public",
++ "description": "Upload a file object into an app namespace.",
++ "outputSchema": "media.media-ref@1",
++ "permissions": [
++ "media:write"
++ ],
++ "resourceConstraints": [
++ "namespace",
++ "owner"
++ ],
++ "quotaClass": "media-upload",
++ "events": [
++ "media.object.uploaded"
++ ],
++ "implementedBy": [
++ "POST /api/v1/:app/files"
++ ]
++}
ddiff --git a/fixtures/common.entity-ref/invalid/no-id.json b/fixtures/common.entity-ref/invalid/no-id.json
nnew file mode 100644
iindex 00000000..d23dec7b
---- /dev/null
++++ b/fixtures/common.entity-ref/invalid/no-id.json
@@@ -0,0 +1,4 @@
++{
++ "service": "media",
++ "type": "vod"
++}
ddiff --git a/fixtures/common.entity-ref/valid/vod-thread.json b/fixtures/common.entity-ref/valid/vod-thread.json
nnew file mode 100644
iindex 00000000..8f2cbf7b
---- /dev/null
++++ b/fixtures/common.entity-ref/valid/vod-thread.json
@@@ -0,0 +1,7 @@
++{
++ "service": "media",
++ "type": "vod",
++ "id": "1203",
++ "revision": 3,
++ "label": "Friday stream"
++}
ddiff --git a/fixtures/errors.problem/invalid/2xx-status.json b/fixtures/errors.problem/invalid/2xx-status.json
nnew file mode 100644
iindex 00000000..2befcf89
---- /dev/null
++++ b/fixtures/errors.problem/invalid/2xx-status.json
@@@ -0,0 +1,6 @@
++{
++ "type": "about:blank",
++ "title": "OK",
++ "status": 200,
++ "code": "ok"
++}
ddiff --git a/fixtures/errors.problem/invalid/bad-trace.json b/fixtures/errors.problem/invalid/bad-trace.json
nnew file mode 100644
iindex 00000000..12a0f8ec
---- /dev/null
++++ b/fixtures/errors.problem/invalid/bad-trace.json
@@@ -0,0 +1,7 @@
++{
++ "type": "about:blank",
++ "title": "x",
++ "status": 400,
++ "code": "x",
++ "trace_id": "nope"
++}
ddiff --git a/fixtures/errors.problem/invalid/no-code.json b/fixtures/errors.problem/invalid/no-code.json
nnew file mode 100644
iindex 00000000..5fff5322
---- /dev/null
++++ b/fixtures/errors.problem/invalid/no-code.json
@@@ -0,0 +1,5 @@
++{
++ "type": "about:blank",
++ "title": "Bad Request",
++ "status": 400
++}
ddiff --git a/fixtures/errors.problem/valid/denied.json b/fixtures/errors.problem/valid/denied.json
nnew file mode 100644
iindex 00000000..bc5c2a23
---- /dev/null
++++ b/fixtures/errors.problem/valid/denied.json
@@@ -0,0 +1,10 @@
++{
++ "type": "https://openvibe.network/problems/capability.denied",
++ "title": "Forbidden",
++ "status": 403,
++ "code": "capability.denied",
++ "detail": "media.object.upload not granted",
++ "request_id": "req_0123456789ab",
++ "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
++ "error": "media.object.upload not granted"
++}
ddiff --git a/fixtures/events.event-envelope/invalid/local-user-actor.json b/fixtures/events.event-envelope/invalid/local-user-actor.json
nnew file mode 100644
iindex 00000000..ceabd555
---- /dev/null
++++ b/fixtures/events.event-envelope/invalid/local-user-actor.json
@@@ -0,0 +1,16 @@
++{
++ "event_id": "evt_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "event_type": "media.vod.ready",
++ "version": 1,
++ "source": "media",
++ "actor": {
++ "type": "user",
++ "id": "42"
++ },
++ "timestamp": "2026-09-22T12:00:00Z",
++ "subject": {
++ "type": "vod",
++ "id": "1"
++ },
++ "payload": {}
++}
ddiff --git a/fixtures/events.event-envelope/invalid/two-part-type.json b/fixtures/events.event-envelope/invalid/two-part-type.json
nnew file mode 100644
iindex 00000000..e42d373d
---- /dev/null
++++ b/fixtures/events.event-envelope/invalid/two-part-type.json
@@@ -0,0 +1,16 @@
++{
++ "event_id": "evt_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "event_type": "vod.ready",
++ "version": 1,
++ "source": "media",
++ "actor": {
++ "type": "service",
++ "id": "media"
++ },
++ "timestamp": "2026-09-22T12:00:00Z",
++ "subject": {
++ "type": "vod",
++ "id": "1"
++ },
++ "payload": {}
++}
ddiff --git a/fixtures/events.event-envelope/valid/vod-ready.json b/fixtures/events.event-envelope/valid/vod-ready.json
nnew file mode 100644
iindex 00000000..2925dc64
---- /dev/null
++++ b/fixtures/events.event-envelope/valid/vod-ready.json
@@@ -0,0 +1,22 @@
++{
++ "event_id": "evt_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
++ "event_type": "media.vod.ready",
++ "version": 1,
++ "source": "media",
++ "actor": {
++ "type": "service",
++ "id": "media"
++ },
++ "timestamp": "2026-09-22T12:00:00Z",
++ "priority": "important",
++ "visibility": "internal",
++ "subject": {
++ "type": "vod",
++ "id": "1203",
++ "revision": 4
++ },
++ "payload": {
++ "duration": 3600
++ }
++}
ddiff --git a/fixtures/identity.legacy-identity-map/invalid/missing-source.json b/fixtures/identity.legacy-identity-map/invalid/missing-source.json
nnew file mode 100644
iindex 00000000..5639d5e1
---- /dev/null
++++ b/fixtures/identity.legacy-identity-map/invalid/missing-source.json
@@@ -0,0 +1,5 @@
++{
++ "subject_id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "source_system": "live",
++ "source_type": "user"
++}
ddiff --git a/fixtures/identity.legacy-identity-map/invalid/service-subject.json b/fixtures/identity.legacy-identity-map/invalid/service-subject.json
nnew file mode 100644
iindex 00000000..c3f82120
---- /dev/null
++++ b/fixtures/identity.legacy-identity-map/invalid/service-subject.json
@@@ -0,0 +1,6 @@
++{
++ "subject_id": "live",
++ "source_system": "live",
++ "source_type": "user",
++ "source_id": "1"
++}
ddiff --git a/fixtures/identity.legacy-identity-map/valid/live-user.json b/fixtures/identity.legacy-identity-map/valid/live-user.json
nnew file mode 100644
iindex 00000000..16c4f4fa
---- /dev/null
++++ b/fixtures/identity.legacy-identity-map/valid/live-user.json
@@@ -0,0 +1,8 @@
++{
++ "subject_id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "source_system": "live",
++ "source_type": "user",
++ "source_id": "1234",
++ "first_seen_at": "2026-09-22T00:00:00Z",
++ "verified_at": null
++}
ddiff --git a/fixtures/identity.service-token-claims/invalid/foreign-issuer.json b/fixtures/identity.service-token-claims/invalid/foreign-issuer.json
nnew file mode 100644
iindex 00000000..3d07048f
---- /dev/null
++++ b/fixtures/identity.service-token-claims/invalid/foreign-issuer.json
@@@ -0,0 +1,12 @@
++{
++ "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/no-audience.json b/fixtures/identity.service-token-claims/invalid/no-audience.json
nnew file mode 100644
iindex 00000000..669d9027
---- /dev/null
++++ b/fixtures/identity.service-token-claims/invalid/no-audience.json
@@@ -0,0 +1,10 @@
++{
++ "iss": "https://openvibe.network",
++ "sub": "svc:live",
++ "actor_type": "service",
++ "aud": [],
++ "cap": [],
++ "iat": 1,
++ "exp": 2,
++ "jti": "tok_abcdef12"
++}
ddiff --git a/fixtures/identity.service-token-claims/invalid/user-subject.json b/fixtures/identity.service-token-claims/invalid/user-subject.json
nnew file mode 100644
iindex 00000000..d4329e70
---- /dev/null
++++ b/fixtures/identity.service-token-claims/invalid/user-subject.json
@@@ -0,0 +1,12 @@
++{
++ "iss": "https://openvibe.network",
++ "sub": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "actor_type": "service",
++ "aud": [
++ "openvibe.media"
++ ],
++ "cap": [],
++ "iat": 1,
++ "exp": 2,
++ "jti": "tok_abcdef12"
++}
ddiff --git a/fixtures/identity.service-token-claims/valid/live-to-media.json b/fixtures/identity.service-token-claims/valid/live-to-media.json
nnew file mode 100644
iindex 00000000..438988ac
---- /dev/null
++++ b/fixtures/identity.service-token-claims/valid/live-to-media.json
@@@ -0,0 +1,18 @@
++{
++ "iss": "https://openvibe.network",
++ "sub": "svc:live",
++ "actor_type": "service",
++ "aud": [
++ "openvibe.media"
++ ],
++ "cap": [
++ "media.object.upload",
++ "media.object.read"
++ ],
++ "ns": [
++ "live.*"
++ ],
++ "iat": 1790000000,
++ "exp": 1790000300,
++ "jti": "tok_abcdef12"
++}
ddiff --git a/fixtures/identity.subject-ref/invalid/extra-field.json b/fixtures/identity.subject-ref/invalid/extra-field.json
nnew file mode 100644
iindex 00000000..877b934b
---- /dev/null
++++ b/fixtures/identity.subject-ref/invalid/extra-field.json
@@@ -0,0 +1,5 @@
++{
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "username": "x"
++}
ddiff --git a/fixtures/identity.subject-ref/invalid/integer-id.json b/fixtures/identity.subject-ref/invalid/integer-id.json
nnew file mode 100644
iindex 00000000..f337c567
---- /dev/null
++++ b/fixtures/identity.subject-ref/invalid/integer-id.json
@@@ -0,0 +1,4 @@
++{
++ "type": "user",
++ "id": 42
++}
ddiff --git a/fixtures/identity.subject-ref/invalid/live-local-id.json b/fixtures/identity.subject-ref/invalid/live-local-id.json
nnew file mode 100644
iindex 00000000..3553daa4
---- /dev/null
++++ b/fixtures/identity.subject-ref/invalid/live-local-id.json
@@@ -0,0 +1,4 @@
++{
++ "type": "user",
++ "id": "123"
++}
ddiff --git a/fixtures/identity.subject-ref/invalid/service-uppercase.json b/fixtures/identity.subject-ref/invalid/service-uppercase.json
nnew file mode 100644
iindex 00000000..a83c59ee
---- /dev/null
++++ b/fixtures/identity.subject-ref/invalid/service-uppercase.json
@@@ -0,0 +1,4 @@
++{
++ "type": "service",
++ "id": "Live"
++}
ddiff --git a/fixtures/identity.subject-ref/invalid/unknown-type.json b/fixtures/identity.subject-ref/invalid/unknown-type.json
nnew file mode 100644
iindex 00000000..bbde3b7c
---- /dev/null
++++ b/fixtures/identity.subject-ref/invalid/unknown-type.json
@@@ -0,0 +1,4 @@
++{
++ "type": "bot",
++ "id": "x1"
++}
ddiff --git a/fixtures/identity.subject-ref/invalid/wrong-prefix.json b/fixtures/identity.subject-ref/invalid/wrong-prefix.json
nnew file mode 100644
iindex 00000000..92948d20
---- /dev/null
++++ b/fixtures/identity.subject-ref/invalid/wrong-prefix.json
@@@ -0,0 +1,4 @@
++{
++ "type": "user",
++ "id": "gst_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++}
ddiff --git a/fixtures/identity.subject-ref/valid/guest.json b/fixtures/identity.subject-ref/valid/guest.json
nnew file mode 100644
iindex 00000000..ae9e983d
---- /dev/null
++++ b/fixtures/identity.subject-ref/valid/guest.json
@@@ -0,0 +1,4 @@
++{
++ "type": "guest",
++ "id": "gst_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++}
ddiff --git a/fixtures/identity.subject-ref/valid/service.json b/fixtures/identity.subject-ref/valid/service.json
nnew file mode 100644
iindex 00000000..06bda9e6
---- /dev/null
++++ b/fixtures/identity.subject-ref/valid/service.json
@@@ -0,0 +1,4 @@
++{
++ "type": "service",
++ "id": "live"
++}
ddiff --git a/fixtures/identity.subject-ref/valid/user.json b/fixtures/identity.subject-ref/valid/user.json
nnew file mode 100644
iindex 00000000..33f80ae0
---- /dev/null
++++ b/fixtures/identity.subject-ref/valid/user.json
@@@ -0,0 +1,4 @@
++{
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++}
ddiff --git a/fixtures/media.media-ref/invalid/bare-integer.json b/fixtures/media.media-ref/invalid/bare-integer.json
nnew file mode 100644
iindex 00000000..c00f78fc
---- /dev/null
++++ b/fixtures/media.media-ref/invalid/bare-integer.json
@@@ -0,0 +1,3 @@
++{
++ "media_id": "1203"
++}
ddiff --git a/fixtures/media.media-ref/invalid/unknown-kind.json b/fixtures/media.media-ref/invalid/unknown-kind.json
nnew file mode 100644
iindex 00000000..5f6c09d2
---- /dev/null
++++ b/fixtures/media.media-ref/invalid/unknown-kind.json
@@@ -0,0 +1,3 @@
++{
++ "media_id": "legacy:live:song:1"
++}
ddiff --git a/fixtures/media.media-ref/valid/canonical.json b/fixtures/media.media-ref/valid/canonical.json
nnew file mode 100644
iindex 00000000..8682c286
---- /dev/null
++++ b/fixtures/media.media-ref/valid/canonical.json
@@@ -0,0 +1,4 @@
++{
++ "media_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "role": "attachment"
++}
ddiff --git a/fixtures/media.media-ref/valid/legacy.json b/fixtures/media.media-ref/valid/legacy.json
nnew file mode 100644
iindex 00000000..177cae51
---- /dev/null
++++ b/fixtures/media.media-ref/valid/legacy.json
@@@ -0,0 +1,4 @@
++{
++ "media_id": "legacy:live:vod:1203",
++ "variant": "720p"
++}
ddiff --git a/fixtures/registry.service-manifest/invalid/bad-status.json b/fixtures/registry.service-manifest/invalid/bad-status.json
nnew file mode 100644
iindex 00000000..34327497
---- /dev/null
++++ b/fixtures/registry.service-manifest/invalid/bad-status.json
@@@ -0,0 +1,11 @@
++{
++ "id": "x1",
++ "version": "1.0.0",
++ "status": "live",
++ "repository": "OpenVibers/X",
++ "domains": [],
++ "capabilities": [],
++ "eventsProduced": [],
++ "eventsConsumed": [],
++ "namespacesOwned": []
++}
ddiff --git a/fixtures/registry.service-manifest/valid/media.json b/fixtures/registry.service-manifest/valid/media.json
nnew file mode 100644
iindex 00000000..00366525
---- /dev/null
++++ b/fixtures/registry.service-manifest/valid/media.json
@@@ -0,0 +1,24 @@
++{
++ "version": "0.1.0",
++ "contractRanges": {
++ "openvibe-contracts": "^0.1.0"
++ },
++ "id": "media",
++ "name": "OpenVibe.Media",
++ "status": "beta",
++ "repository": "OpenVibers/OpenVibe.Media",
++ "domains": [
++ "openvibe.media"
++ ],
++ "publicOrigin": "https://openvibe.media",
++ "health": "/healthz",
++ "capabilities": [
++ "media.object.upload",
++ "media.object.read"
++ ],
++ "eventsProduced": [],
++ "eventsConsumed": [],
++ "namespacesOwned": [
++ "media.*"
++ ]
++}
ddiff --git a/generated/json-schema/bundle.json b/generated/json-schema/bundle.json
nnew file mode 100644
iindex 00000000..b2ec7bba
---- /dev/null
++++ b/generated/json-schema/bundle.json
@@@ -0,0 +1,672 @@
++{
++ "_about": "Generated by scripts/generate.js. Do not edit.",
++ "contracts": {
++ "identity.subject-ref": {
++ "version": "1.0.0",
++ "owner": "network",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/identity/subject-ref.v1.json",
++ "title": "SubjectRef",
++ "description": "The one cross-platform reference to an actor. Issued by OpenVibe.Network. Never a service-local integer id.",
++ "oneOf": [
++ {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "user"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$"
++ }
++ },
++ "description": "UserSubject"
++ },
++ {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "guest"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^gst_[0-9A-HJKMNP-TV-Z]{26}$"
++ }
++ },
++ "description": "GuestSubject"
++ },
++ {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "app"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^app_[0-9A-HJKMNP-TV-Z]{26}$"
++ }
++ },
++ "description": "AppSubject"
++ },
++ {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "mod"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^mod_[0-9A-HJKMNP-TV-Z]{26}$"
++ }
++ },
++ "description": "ModSubject"
++ },
++ {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "enum": [
++ "service",
++ "system"
++ ]
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{1,39}$"
++ }
++ },
++ "description": "PrincipalSubject"
++ }
++ ]
++ }
++ },
++ "identity.legacy-identity-map": {
++ "version": "1.0.0",
++ "owner": "network",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/identity/legacy-identity-map.v1.json",
++ "title": "LegacyIdentityMapEntry",
++ "description": "One row of identity_legacy_map: links a service-local id to its canonical subject. Kept alongside legacy ids, never replacing them.",
++ "type": "object",
++ "required": [
++ "subject_id",
++ "source_system",
++ "source_type",
++ "source_id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "subject_id": {
++ "type": "string",
++ "pattern": "^(usr|gst|app|mod)_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "source_system": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{1,39}$",
++ "description": "live, media, games, network, rs-companion, hobo, ..."
++ },
++ "source_type": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]{1,39}$",
++ "description": "user, anon_user, player, steam, ..."
++ },
++ "source_id": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 128
++ },
++ "first_seen_at": {
++ "type": "string",
++ "format": "date-time"
++ },
++ "verified_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time"
++ },
++ "metadata": {
++ "type": "object"
++ }
++ }
++ }
++ },
++ "identity.service-token-claims": {
++ "version": "1.0.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.",
++ "type": "object",
++ "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
++ }
++ }
++ }
++ },
++ "common.entity-ref": {
++ "version": "1.0.0",
++ "owner": "contracts",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/common/entity-ref.v1.json",
++ "title": "EntityRef",
++ "description": "Typed reference to another service's entity. Store this instead of a foreign key; resolve a display projection from the owner.",
++ "type": "object",
++ "required": [
++ "service",
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "service": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{1,39}$"
++ },
++ "type": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]{1,39}$"
++ },
++ "id": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 128
++ },
++ "revision": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "label": {
++ "type": "string",
++ "maxLength": 200,
++ "description": "Cached display label. Never authoritative."
++ }
++ }
++ }
++ },
++ "errors.problem": {
++ "version": "1.0.0",
++ "owner": "contracts",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/errors/problem.v1.json",
++ "title": "Problem",
++ "description": "RFC 9457 problem details (application/problem+json) with a stable OpenVibe error code and trace identifiers. The legacy 'error' string is allowed during migration so existing clients keep working.",
++ "type": "object",
++ "required": [
++ "type",
++ "title",
++ "status",
++ "code"
++ ],
++ "additionalProperties": true,
++ "properties": {
++ "type": {
++ "type": "string",
++ "format": "uri-reference"
++ },
++ "title": {
++ "type": "string",
++ "minLength": 1
++ },
++ "status": {
++ "type": "integer",
++ "minimum": 400,
++ "maximum": 599
++ },
++ "detail": {
++ "type": "string"
++ },
++ "instance": {
++ "type": "string",
++ "format": "uri-reference"
++ },
++ "code": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)*$"
++ },
++ "request_id": {
++ "type": "string"
++ },
++ "trace_id": {
++ "type": "string",
++ "pattern": "^[0-9a-f]{32}$"
++ },
++ "errors": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "required": [
++ "message"
++ ],
++ "properties": {
++ "path": {
++ "type": "string"
++ },
++ "message": {
++ "type": "string"
++ }
++ }
++ }
++ },
++ "error": {
++ "type": "string",
++ "description": "Deprecated compatibility field: same text as title/detail for clients that read {error}."
++ }
++ }
++ }
++ },
++ "registry.service-manifest": {
++ "version": "1.0.0",
++ "owner": "network",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/registry/service-manifest.v1.json",
++ "title": "ServiceManifest",
++ "description": "What a service is, where it lives and what it offers. The ecosystem registry is built from these, not from hard-coded route maps.",
++ "type": "object",
++ "required": [
++ "id",
++ "version",
++ "status",
++ "repository",
++ "domains",
++ "capabilities",
++ "eventsProduced",
++ "eventsConsumed",
++ "namespacesOwned"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{1,39}$"
++ },
++ "name": {
++ "type": "string"
++ },
++ "version": {
++ "type": "string",
++ "pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z.-]+)?$"
++ },
++ "status": {
++ "enum": [
++ "placeholder",
++ "alpha",
++ "beta",
++ "stable",
++ "degraded",
++ "retired"
++ ]
++ },
++ "repository": {
++ "type": "string",
++ "pattern": "^OpenVibers/[A-Za-z0-9._-]+$"
++ },
++ "domains": {
++ "type": "array",
++ "items": {
++ "type": "string"
++ }
++ },
++ "publicOrigin": {
++ "type": "string",
++ "format": "uri"
++ },
++ "health": {
++ "type": "string",
++ "pattern": "^/"
++ },
++ "ready": {
++ "type": "string",
++ "pattern": "^/"
++ },
++ "capabilities": {
++ "type": "array",
++ "items": {
++ "type": "string"
++ }
++ },
++ "eventsProduced": {
++ "type": "array",
++ "items": {
++ "type": "string"
++ }
++ },
++ "eventsConsumed": {
++ "type": "array",
++ "items": {
++ "type": "string"
++ }
++ },
++ "namespacesOwned": {
++ "type": "array",
++ "items": {
++ "type": "string"
++ }
++ },
++ "contractRanges": {
++ "type": "object",
++ "additionalProperties": {
++ "type": "string"
++ }
++ },
++ "notes": {
++ "type": "string"
++ }
++ }
++ }
++ },
++ "capabilities.capability": {
++ "version": "1.0.0",
++ "owner": "network",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/capabilities/capability.v1.json",
++ "title": "Capability",
++ "description": "An action a principal may invoke. Authorization is checked at the owner's boundary against the grant, never inferred from the caller's route.",
++ "type": "object",
++ "required": [
++ "id",
++ "version",
++ "owner",
++ "status",
++ "visibility",
++ "permissions",
++ "resourceConstraints",
++ "quotaClass",
++ "events"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+){2,}$"
++ },
++ "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"
++ ]
++ },
++ "description": {
++ "type": "string"
++ },
++ "inputSchema": {
++ "type": "string"
++ },
++ "outputSchema": {
++ "type": "string"
++ },
++ "permissions": {
++ "type": "array",
++ "items": {
++ "type": "string"
++ }
++ },
++ "resourceConstraints": {
++ "type": "array",
++ "items": {
++ "enum": [
++ "namespace",
++ "owner",
++ "room_member",
++ "space_member",
++ "project",
++ "none"
++ ]
++ }
++ },
++ "quotaClass": {
++ "type": "string"
++ },
++ "events": {
++ "type": "array",
++ "items": {
++ "type": "string"
++ }
++ },
++ "implementedBy": {
++ "type": "array",
++ "items": {
++ "type": "string"
++ },
++ "description": "Current route(s) that perform this action today, e.g. 'POST /api/v1/:app/files'."
++ }
++ }
++ }
++ },
++ "events.event-envelope": {
++ "version": "1.0.0",
++ "owner": "events",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/event-envelope.v1.json",
++ "title": "EventEnvelope",
++ "description": "Durable platform event (OpenVibe.Events, Wave 3). Producers write it to an outbox in the same transaction as the domain change.",
++ "type": "object",
++ "required": [
++ "event_id",
++ "event_type",
++ "version",
++ "source",
++ "actor",
++ "timestamp",
++ "subject",
++ "payload"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "event_id": {
++ "type": "string",
++ "pattern": "^evt_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "trace_id": {
++ "type": "string",
++ "pattern": "^[0-9a-f]{32}$"
++ },
++ "event_type": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+){2,}$"
++ },
++ "version": {
++ "type": "integer",
++ "minimum": 1
++ },
++ "source": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{1,39}$"
++ },
++ "actor": {
++ "$ref": "../identity/subject-ref.v1.json"
++ },
++ "timestamp": {
++ "type": "string",
++ "format": "date-time"
++ },
++ "priority": {
++ "enum": [
++ "critical",
++ "important",
++ "low"
++ ],
++ "default": "important"
++ },
++ "visibility": {
++ "enum": [
++ "public",
++ "subject",
++ "internal"
++ ],
++ "default": "internal"
++ },
++ "subject": {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "type": "string"
++ },
++ "id": {
++ "type": "string"
++ },
++ "revision": {
++ "type": "integer",
++ "minimum": 0
++ }
++ }
++ },
++ "payload": {
++ "type": "object"
++ }
++ }
++ }
++ },
++ "media.media-ref": {
++ "version": "1.0.0",
++ "owner": "media",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/media/media-ref.v1.json",
++ "title": "MediaRef",
++ "description": "Reference to a Media object. Canonical ids (med_<ULID>) arrive with the Wave 4 object model; until then legacy:<app>:<kind>:<id> addresses today's VOD/clip/file/paste/thumbnail rows.",
++ "type": "object",
++ "required": [
++ "media_id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "media_id": {
++ "type": "string",
++ "pattern": "^(med_[0-9A-HJKMNP-TV-Z]{26}|legacy:[a-z][a-z0-9-]{1,39}:(vod|clip|file|paste|thumbnail|avatar):[A-Za-z0-9._/-]{1,200})$"
++ },
++ "role": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]{1,39}$"
++ },
++ "variant": {
++ "type": "string",
++ "pattern": "^[a-z0-9][a-z0-9_-]{0,39}$"
++ }
++ }
++ }
++ }
++ }
++}
ddiff --git a/generated/typescript/index.d.ts b/generated/typescript/index.d.ts
nnew file mode 100644
iindex 00000000..444e4189
---- /dev/null
++++ b/generated/typescript/index.d.ts
@@@ -0,0 +1,192 @@
++/* Generated by scripts/generate.js from contracts/. Do not edit. */
++/* eslint-disable */
++
++/** [email protected] (owner: network) */
++/**
++ * The one cross-platform reference to an actor. Issued by OpenVibe.Network. Never a service-local integer id.
++ */
++export type SubjectRef =
++ | {
++ type: "user";
++ id: string;
++ }
++ | {
++ type: "guest";
++ id: string;
++ }
++ | {
++ type: "app";
++ id: string;
++ }
++ | {
++ type: "mod";
++ id: string;
++ }
++ | {
++ type: "service" | "system";
++ id: string;
++ };
++
++/** [email protected] (owner: network) */
++/**
++ * One row of identity_legacy_map: links a service-local id to its canonical subject. Kept alongside legacy ids, never replacing them.
++ */
++export interface LegacyIdentityMapEntry {
++ subject_id: string;
++ /**
++ * live, media, games, network, rs-companion, hobo, ...
++ */
++ source_system: string;
++ /**
++ * user, anon_user, player, steam, ...
++ */
++ source_type: string;
++ source_id: string;
++ first_seen_at?: string;
++ verified_at?: string | null;
++ metadata?: {};
++}
++
++/** [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";
++ sub: string;
++ actor_type: "service" | "app" | "mod";
++ /**
++ * @minItems 1
++ */
++ aud: [string, ...string[]];
++ /**
++ * Granted capability ids; a trailing .* grants a family.
++ */
++ cap: string[];
++ /**
++ * Namespace constraints, e.g. live.* or mod.example.*
++ */
++ ns?: string[];
++ iat: number;
++ exp: number;
++ jti: string;
++ [k: string]: unknown | undefined;
++}
++
++/** [email protected] (owner: contracts) */
++/**
++ * Typed reference to another service's entity. Store this instead of a foreign key; resolve a display projection from the owner.
++ */
++export interface EntityRef {
++ service: string;
++ type: string;
++ id: string;
++ revision?: number;
++ /**
++ * Cached display label. Never authoritative.
++ */
++ label?: string;
++}
++
++/** [email protected] (owner: contracts) */
++/**
++ * RFC 9457 problem details (application/problem+json) with a stable OpenVibe error code and trace identifiers. The legacy 'error' string is allowed during migration so existing clients keep working.
++ */
++export interface Problem {
++ type: string;
++ title: string;
++ status: number;
++ detail?: string;
++ instance?: string;
++ code: string;
++ request_id?: string;
++ trace_id?: string;
++ errors?: {
++ path?: string;
++ message: string;
++ }[];
++ /**
++ * Deprecated compatibility field: same text as title/detail for clients that read {error}.
++ */
++ error?: string;
++ [k: string]: unknown | undefined;
++}
++
++/** [email protected] (owner: network) */
++/**
++ * What a service is, where it lives and what it offers. The ecosystem registry is built from these, not from hard-coded route maps.
++ */
++export interface ServiceManifest {
++ id: string;
++ name?: string;
++ version: string;
++ status: "placeholder" | "alpha" | "beta" | "stable" | "degraded" | "retired";
++ repository: string;
++ domains: string[];
++ publicOrigin?: string;
++ health?: string;
++ ready?: string;
++ capabilities: string[];
++ eventsProduced: string[];
++ eventsConsumed: string[];
++ namespacesOwned: string[];
++ contractRanges?: {
++ [k: string]: string | undefined;
++ };
++ notes?: string;
++}
++
++/** [email protected] (owner: network) */
++/**
++ * An action a principal may invoke. Authorization is checked at the owner's boundary against the grant, never inferred from the caller's route.
++ */
++export interface Capability {
++ id: string;
++ version: string;
++ owner: string;
++ status: "planned" | "active" | "deprecated" | "retired";
++ visibility: "public" | "first-party" | "internal";
++ description?: string;
++ inputSchema?: string;
++ outputSchema?: string;
++ permissions: string[];
++ resourceConstraints: ("namespace" | "owner" | "room_member" | "space_member" | "project" | "none")[];
++ quotaClass: string;
++ events: string[];
++ /**
++ * Current route(s) that perform this action today, e.g. 'POST /api/v1/:app/files'.
++ */
++ implementedBy?: string[];
++}
++
++/** [email protected] (owner: events) */
++/**
++ * Durable platform event (OpenVibe.Events, Wave 3). Producers write it to an outbox in the same transaction as the domain change.
++ */
++export interface EventEnvelope {
++ event_id: string;
++ trace_id?: string;
++ event_type: string;
++ version: number;
++ source: string;
++ actor: SubjectRef;
++ timestamp: string;
++ priority?: "critical" | "important" | "low";
++ visibility?: "public" | "subject" | "internal";
++ subject: {
++ type: string;
++ id: string;
++ revision?: number;
++ };
++ payload: {};
++}
++
++/** [email protected] (owner: media) */
++/**
++ * Reference to a Media object. Canonical ids (med_<ULID>) arrive with the Wave 4 object model; until then legacy:<app>:<kind>:<id> addresses today's VOD/clip/file/paste/thumbnail rows.
++ */
++export interface MediaRef {
++ media_id: string;
++ role?: string;
++ variant?: string;
++}
ddiff --git a/index.js b/index.js
nnew file mode 100644
iindex 00000000..bca7715b
---- /dev/null
++++ b/index.js
@@@ -0,0 +1,23 @@
++'use strict';
++/**
++ * openvibe-contracts — machine-readable contracts for the OpenVibe network.
++ *
++ * const contracts = require('openvibe-contracts');
++ * contracts.validate('identity.subject-ref@1', { type: 'user', id: 'usr_01J...' });
++ * contracts.ids.newId('user'); // 'usr_01J...'
++ * contracts.http.problem(403, 'capability.denied'); // RFC 9457 body
++ * contracts.capabilities.check(tokenClaims, 'media.object.read', { namespace: 'live' });
++ */
++const registry = require('./lib/registry');
++
++module.exports = {
++ catalog: registry.catalog,
++ resolve: registry.resolve,
++ validate: registry.validate,
++ assertValid: registry.assertValid,
++ schema: registry.schema,
++ ids: require('./lib/ids'),
++ http: require('./lib/http'),
++ capabilities: require('./lib/capabilities'),
++ services: require('./lib/services'),
++};
ddiff --git a/lib/capabilities.js b/lib/capabilities.js
nnew file mode 100644
iindex 00000000..3b17ac9c
---- /dev/null
++++ b/lib/capabilities.js
@@@ -0,0 +1,40 @@
++'use strict';
++/**
++ * Capability grants. A grant list comes from a service token's `cap` claim (or a user's role map).
++ * `media.object.*` grants every capability under media.object; nothing else is implied.
++ */
++const fs = require('fs');
++const path = require('path');
++
++const DIR = path.join(__dirname, '..', 'manifests', 'capabilities');
++const manifests = fs.readdirSync(DIR).filter(f => f.endsWith('.json')).sort()
++ .map(f => JSON.parse(fs.readFileSync(path.join(DIR, f), 'utf8')));
++const byId = new Map(manifests.map(c => [c.id, c]));
++
++function grants(granted, capabilityId) {
++ if (!Array.isArray(granted)) return false;
++ return granted.some(g => g === capabilityId || (g.endsWith('.*') && capabilityId.startsWith(g.slice(0, -1))));
++}
++
++/** Namespace constraint: a grant of ['live.*'] allows namespace 'live.vods' but not 'games.maps'. */
++function namespaceAllowed(allowed, namespace) {
++ if (!Array.isArray(allowed) || !allowed.length) return false;
++ return allowed.some(n => n === '*' || n === namespace || (n.endsWith('.*') && (namespace === n.slice(0, -2) || namespace.startsWith(n.slice(0, -1)))));
++}
++
++/**
++ * Decide one invocation. Returns { allowed, code, reason } — code is a stable problem code
++ * (capability.unknown | capability.denied | capability.namespace_denied) when denied.
++ */
++function check(claims, capabilityId, { namespace } = {}) {
++ const cap = byId.get(capabilityId);
++ if (!cap) return { allowed: false, code: 'capability.unknown', reason: `no capability ${capabilityId}` };
++ if (cap.status === 'retired') return { allowed: false, code: 'capability.unknown', reason: `${capabilityId} is retired` };
++ if (!grants(claims && claims.cap, capabilityId)) return { allowed: false, code: 'capability.denied', reason: `${capabilityId} not granted` };
++ if (cap.resourceConstraints.includes('namespace') && namespace !== undefined && !namespaceAllowed(claims.ns, namespace)) {
++ return { allowed: false, code: 'capability.namespace_denied', reason: `namespace ${namespace} not granted` };
++ }
++ return { allowed: true, code: null, reason: null };
++}
++
++module.exports = { manifests, get: (id) => byId.get(id), grants, namespaceAllowed, check };
ddiff --git a/lib/http.js b/lib/http.js
nnew file mode 100644
iindex 00000000..4dd68842
---- /dev/null
++++ b/lib/http.js
@@@ -0,0 +1,73 @@
++'use strict';
++/**
++ * Request/error envelope helpers (contract errors.problem@1).
++ * - W3C traceparent is accepted or started, and echoed with X-OpenVibe-Request-Id
++ * - problem() builds RFC 9457 bodies that keep the legacy { error } field for old clients
++ * Framework-agnostic: works with Express/Node (req, res) objects.
++ */
++const crypto = require('crypto');
++
++const TRACEPARENT_RE = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
++
++/** Parse a traceparent header; null if absent or malformed (all-zero ids are invalid per spec). */
++function parseTraceparent(value) {
++ const m = typeof value === 'string' && value.trim().toLowerCase().match(TRACEPARENT_RE);
++ if (!m || /^0+$/.test(m[1]) || /^0+$/.test(m[2])) return null;
++ return { traceId: m[1], parentId: m[2], flags: m[3] };
++}
++
++/** Context for an incoming request: continue the caller's trace or start one. */
++function requestContext(headers = {}) {
++ const get = (k) => headers[k] ?? headers[k.toLowerCase()];
++ const tp = parseTraceparent(get('traceparent'));
++ const traceId = tp ? tp.traceId : crypto.randomBytes(16).toString('hex');
++ const spanId = crypto.randomBytes(8).toString('hex');
++ const incoming = get('x-openvibe-request-id');
++ const requestId = typeof incoming === 'string' && /^[A-Za-z0-9._-]{8,128}$/.test(incoming) ? incoming : `req_${crypto.randomBytes(12).toString('hex')}`;
++ return { traceId, spanId, requestId, traceparent: `00-${traceId}-${spanId}-${tp ? tp.flags : '01'}`, idempotencyKey: get('idempotency-key') || null };
++}
++
++/** Headers to send on an outbound call so the next service joins the same trace. */
++function outboundHeaders(ctx) {
++ return ctx ? { traceparent: `00-${ctx.traceId}-${crypto.randomBytes(8).toString('hex')}-01`, 'X-OpenVibe-Request-Id': ctx.requestId } : {};
++}
++
++/** Express/Connect middleware: attaches req.ov and echoes the ids on the response. */
++function middleware() {
++ return function openvibeRequestContext(req, res, next) {
++ req.ov = requestContext(req.headers);
++ res.setHeader('X-OpenVibe-Request-Id', req.ov.requestId);
++ res.setHeader('traceparent', req.ov.traceparent);
++ next();
++ };
++}
++
++const TITLES = { 400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not Found', 409: 'Conflict', 413: 'Payload Too Large', 422: 'Unprocessable Content', 429: 'Too Many Requests', 500: 'Internal Server Error', 502: 'Bad Gateway', 503: 'Service Unavailable' };
++
++/**
++ * problem(403, 'capability.denied', { detail, ctx }) -> RFC 9457 body.
++ * `type` defaults to https://openvibe.network/problems/<code> so every code has a documentable URI.
++ */
++function problem(status, code, { title, detail, type, instance, ctx, errors, extra } = {}) {
++ const body = {
++ type: type || `https://openvibe.network/problems/${code}`,
++ title: title || TITLES[status] || 'Error',
++ status, code,
++ };
++ if (detail) body.detail = detail;
++ if (instance) body.instance = instance;
++ if (errors && errors.length) body.errors = errors;
++ if (ctx) { body.request_id = ctx.requestId; body.trace_id = ctx.traceId; }
++ body.error = detail || body.title; // compatibility with clients that read { error }
++ return Object.assign(body, extra);
++}
++
++function sendProblem(res, status, code, opts = {}) {
++ const body = problem(status, code, opts);
++ res.statusCode = status;
++ res.setHeader('Content-Type', 'application/problem+json');
++ res.end(JSON.stringify(body));
++ return body;
++}
++
++module.exports = { parseTraceparent, requestContext, outboundHeaders, middleware, problem, sendProblem };
ddiff --git a/lib/ids.js b/lib/ids.js
nnew file mode 100644
iindex 00000000..1d0c3ae8
---- /dev/null
++++ b/lib/ids.js
@@@ -0,0 +1,65 @@
++'use strict';
++/**
++ * Canonical identifiers. ULIDs (Crockford base32, 48-bit ms time + 80-bit randomness) sort by creation
++ * time, which keeps SQLite/Postgres indexes append-mostly.
++ */
++const crypto = require('crypto');
++
++const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
++const PREFIX = { user: 'usr', guest: 'gst', app: 'app', mod: 'mod', event: 'evt', media: 'med' };
++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}$/;
++const SUBJECT_TYPES = ['user', 'guest', 'service', 'app', 'mod', 'system'];
++
++function ulid(now = Date.now()) {
++ let time = '';
++ for (let t = now, i = 0; i < 10; i++, t = Math.floor(t / 32)) time = ALPHABET[t % 32] + time;
++ const bytes = crypto.randomBytes(16);
++ let rand = '';
++ for (let i = 0; i < 16; i++) rand += ALPHABET[bytes[i] % 32];
++ return time + rand;
++}
++
++/** New id for a kind that has a prefix: newId('user') -> 'usr_01J...'. */
++function newId(kind, now) {
++ const p = PREFIX[kind];
++ if (!p) throw new TypeError(`no id prefix for kind "${kind}"`);
++ return `${p}_${ulid(now)}`;
++}
++
++function isSubjectId(type, id) {
++ if (typeof id !== 'string') return false;
++ if (SLUG_TYPES.has(type)) return SLUG_RE.test(id);
++ const p = PREFIX[type];
++ return Boolean(p) && id.startsWith(p + '_') && ULID_RE.test(id.slice(p.length + 1));
++}
++
++/** 'user:usr_01J...' <-> { type, id }. Returns null for anything malformed. */
++function parseSubject(str) {
++ if (typeof str !== 'string') return null;
++ const i = str.indexOf(':');
++ if (i < 1) return null;
++ const type = str.slice(0, i);
++ const id = str.slice(i + 1);
++ return SUBJECT_TYPES.includes(type) && isSubjectId(type, id) ? { type, id } : null;
++}
++
++function formatSubject(ref) {
++ if (!ref || !isSubjectId(ref.type, ref.id)) throw new TypeError('invalid SubjectRef');
++ return `${ref.type}:${ref.id}`;
++}
++
++/** Service-token `sub` claim: svc:<slug> | app:app_<ULID> | mod:mod_<ULID>. */
++function principalSub(ref) {
++ if (ref.type === 'service' && SLUG_RE.test(ref.id)) return `svc:${ref.id}`;
++ if ((ref.type === 'app' || ref.type === 'mod') && isSubjectId(ref.type, ref.id)) return `${ref.type}:${ref.id}`;
++ throw new TypeError('principals are service, app or mod subjects');
++}
++
++/** Transitional Media reference for today's rows (Wave 4 introduces med_ ids). */
++function legacyMediaId(app, kind, id) {
++ return `legacy:${app}:${kind}:${id}`;
++}
++
++module.exports = { ulid, newId, isSubjectId, parseSubject, formatSubject, principalSub, legacyMediaId, SUBJECT_TYPES, PREFIX };
ddiff --git a/lib/registry.js b/lib/registry.js
nnew file mode 100644
iindex 00000000..d711b7f0
---- /dev/null
++++ b/lib/registry.js
@@@ -0,0 +1,53 @@
++'use strict';
++/** Loads the catalog and compiles every schema once (Ajv, JSON Schema 2020-12). */
++const fs = require('fs');
++const path = require('path');
++const Ajv2020 = require('ajv/dist/2020');
++const addFormats = require('ajv-formats');
++
++const ROOT = path.join(__dirname, '..');
++const CONTRACTS = path.join(ROOT, 'contracts');
++const catalog = JSON.parse(fs.readFileSync(path.join(CONTRACTS, 'catalog.json'), 'utf8')).contracts;
++const aliases = JSON.parse(fs.readFileSync(path.join(ROOT, 'compatibility/aliases.json'), 'utf8')).aliases;
++const deprecations = JSON.parse(fs.readFileSync(path.join(ROOT, 'compatibility/deprecations.json'), 'utf8')).deprecations;
++
++const ajv = new Ajv2020({ allErrors: true, strict: true, strictRequired: false, allowUnionTypes: true });
++addFormats(ajv);
++const byId = new Map();
++for (const entry of catalog) {
++ const schema = JSON.parse(fs.readFileSync(path.join(CONTRACTS, entry.schema), 'utf8'));
++ ajv.addSchema(schema);
++ byId.set(entry.id, { ...entry, $id: schema.$id, schemaObject: schema });
++}
++
++/** 'identity.subject-ref' | 'identity.subject-ref@1' | an alias -> catalog entry. */
++function resolve(ref) {
++ const [raw, major] = String(ref).split('@');
++ const id = aliases[raw] || raw;
++ const entry = byId.get(id);
++ if (!entry) throw new Error(`unknown contract "${ref}"`);
++ if (major && entry.version.split('.')[0] !== major) throw new Error(`contract "${id}" is v${entry.version}, not @${major}`);
++ return entry;
++}
++
++function validate(ref, value) {
++ const entry = resolve(ref);
++ const fn = ajv.getSchema(entry.$id);
++ const valid = fn(value);
++ return { valid, errors: valid ? [] : fn.errors.map(e => ({ path: e.instancePath || '/', message: e.message })) };
++}
++
++function assertValid(ref, value) {
++ const r = validate(ref, value);
++ if (!r.valid) {
++ const err = new Error(`${ref}: ${r.errors.map(e => `${e.path} ${e.message}`).join('; ')}`);
++ err.code = 'contract.invalid';
++ err.errors = r.errors;
++ throw err;
++ }
++ return value;
++}
++
++function schema(ref) { return resolve(ref).schemaObject; }
++
++module.exports = { catalog, aliases, deprecations, resolve, validate, assertValid, schema, ajv, ROOT };
ddiff --git a/lib/services.js b/lib/services.js
nnew file mode 100644
iindex 00000000..dadbf095
---- /dev/null
++++ b/lib/services.js
@@@ -0,0 +1,7 @@
++'use strict';
++const fs = require('fs');
++const path = require('path');
++const DIR = path.join(__dirname, '..', 'manifests', 'services');
++const manifests = fs.readdirSync(DIR).filter(f => f.endsWith('.json')).sort()
++ .map(f => JSON.parse(fs.readFileSync(path.join(DIR, f), 'utf8')));
++module.exports = { manifests, get: (id) => manifests.find(m => m.id === id) };
ddiff --git a/manifests/capabilities/chat.message.send.json b/manifests/capabilities/chat.message.send.json
nnew file mode 100644
iindex 00000000..92d04ab1
---- /dev/null
++++ b/manifests/capabilities/chat.message.send.json
@@@ -0,0 +1,21 @@
++{
++ "id": "chat.message.send",
++ "version": "1.0",
++ "owner": "live",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Send a chat message to a room the actor is a member of. Moves to OpenVibe.Chat in Wave 6.",
++ "permissions": [
++ "chat:write"
++ ],
++ "resourceConstraints": [
++ "room_member"
++ ],
++ "quotaClass": "chat-send",
++ "events": [
++ "chat.message.created"
++ ],
++ "implementedBy": [
++ "WS /ws/chat {type:\"chat\"}"
++ ]
++}
ddiff --git a/manifests/capabilities/community.paste.create.json b/manifests/capabilities/community.paste.create.json
nnew file mode 100644
iindex 00000000..2f8c3ac4
---- /dev/null
++++ b/manifests/capabilities/community.paste.create.json
@@@ -0,0 +1,22 @@
++{
++ "id": "community.paste.create",
++ "version": "1.0",
++ "owner": "live",
++ "status": "active",
++ "visibility": "public",
++ "description": "Create a paste. Authority moves to OpenVibe.Community in Wave 5.",
++ "outputSchema": "common.entity-ref@1",
++ "permissions": [
++ "community:write"
++ ],
++ "resourceConstraints": [
++ "owner"
++ ],
++ "quotaClass": "community-write",
++ "events": [
++ "community.paste.created"
++ ],
++ "implementedBy": [
++ "POST /api/pastes (Live; Community proxies)"
++ ]
++}
ddiff --git a/manifests/capabilities/community.post.create.json b/manifests/capabilities/community.post.create.json
nnew file mode 100644
iindex 00000000..d1bf776f
---- /dev/null
++++ b/manifests/capabilities/community.post.create.json
@@@ -0,0 +1,20 @@
++{
++ "id": "community.post.create",
++ "version": "1.0",
++ "owner": "community",
++ "status": "planned",
++ "visibility": "public",
++ "description": "Create a post in a thread or typed comment thread (Wave 5).",
++ "outputSchema": "common.entity-ref@1",
++ "permissions": [
++ "community:write"
++ ],
++ "resourceConstraints": [
++ "space_member"
++ ],
++ "quotaClass": "community-write",
++ "events": [
++ "community.post.created"
++ ],
++ "implementedBy": []
++}
ddiff --git a/manifests/capabilities/identity.subject.resolve.json b/manifests/capabilities/identity.subject.resolve.json
nnew file mode 100644
iindex 00000000..5905789d
---- /dev/null
++++ b/manifests/capabilities/identity.subject.resolve.json
@@@ -0,0 +1,19 @@
++{
++ "id": "identity.subject.resolve",
++ "version": "1.0",
++ "owner": "network",
++ "status": "planned",
++ "visibility": "first-party",
++ "description": "Resolve a subject (or a legacy service-local id) to its canonical SubjectRef and display projection.",
++ "inputSchema": "identity.legacy-identity-map@1",
++ "outputSchema": "identity.subject-ref@1",
++ "permissions": [
++ "identity:read"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "identity-read",
++ "events": [],
++ "implementedBy": []
++}
ddiff --git a/manifests/capabilities/media.object.read.json b/manifests/capabilities/media.object.read.json
nnew file mode 100644
iindex 00000000..fbf06057
---- /dev/null
++++ b/manifests/capabilities/media.object.read.json
@@@ -0,0 +1,21 @@
++{
++ "id": "media.object.read",
++ "version": "1.0",
++ "owner": "media",
++ "status": "active",
++ "visibility": "public",
++ "description": "Read object metadata / bytes in a namespace.",
++ "inputSchema": "media.media-ref@1",
++ "permissions": [
++ "media:read"
++ ],
++ "resourceConstraints": [
++ "namespace"
++ ],
++ "quotaClass": "media-read",
++ "events": [],
++ "implementedBy": [
++ "GET /api/v1/:app/files/:key",
++ "GET /f/:key"
++ ]
++}
ddiff --git a/manifests/capabilities/media.object.upload.json b/manifests/capabilities/media.object.upload.json
nnew file mode 100644
iindex 00000000..f4dd13d8
---- /dev/null
++++ b/manifests/capabilities/media.object.upload.json
@@@ -0,0 +1,23 @@
++{
++ "id": "media.object.upload",
++ "version": "1.0",
++ "owner": "media",
++ "status": "active",
++ "visibility": "public",
++ "description": "Upload a file object into an app namespace.",
++ "outputSchema": "media.media-ref@1",
++ "permissions": [
++ "media:write"
++ ],
++ "resourceConstraints": [
++ "namespace",
++ "owner"
++ ],
++ "quotaClass": "media-upload",
++ "events": [
++ "media.object.uploaded"
++ ],
++ "implementedBy": [
++ "POST /api/v1/:app/files"
++ ]
++}
ddiff --git a/manifests/capabilities/network.coins.credit.json b/manifests/capabilities/network.coins.credit.json
nnew file mode 100644
iindex 00000000..faf4dc2a
---- /dev/null
++++ b/manifests/capabilities/network.coins.credit.json
@@@ -0,0 +1,19 @@
++{
++ "id": "network.coins.credit",
++ "version": "1.0",
++ "owner": "network",
++ "status": "active",
++ "visibility": "internal",
++ "description": "Credit OpenCoins (loyalty, never money) to a Network user. Idempotent by idempotency_key.",
++ "permissions": [
++ "coins:write"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "ledger-write",
++ "events": [],
++ "implementedBy": [
++ "POST /internal/coins/credit"
++ ]
++}
ddiff --git a/manifests/capabilities/network.coins.debit.json b/manifests/capabilities/network.coins.debit.json
nnew file mode 100644
iindex 00000000..a5630ae0
---- /dev/null
++++ b/manifests/capabilities/network.coins.debit.json
@@@ -0,0 +1,19 @@
++{
++ "id": "network.coins.debit",
++ "version": "1.0",
++ "owner": "network",
++ "status": "active",
++ "visibility": "internal",
++ "description": "Debit OpenCoins; 409 insufficient_funds.",
++ "permissions": [
++ "coins:write"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "ledger-write",
++ "events": [],
++ "implementedBy": [
++ "POST /internal/coins/debit"
++ ]
++}
ddiff --git a/manifests/capabilities/network.notifications.push.json b/manifests/capabilities/network.notifications.push.json
nnew file mode 100644
iindex 00000000..67bae947
---- /dev/null
++++ b/manifests/capabilities/network.notifications.push.json
@@@ -0,0 +1,19 @@
++{
++ "id": "network.notifications.push",
++ "version": "1.0",
++ "owner": "network",
++ "status": "active",
++ "visibility": "internal",
++ "description": "Create a notification for Network users (ids already translated).",
++ "permissions": [
++ "notifications:write"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "notify",
++ "events": [],
++ "implementedBy": [
++ "POST /internal/notifications/*"
++ ]
++}
ddiff --git a/manifests/services/ai.json b/manifests/services/ai.json
nnew file mode 100644
iindex 00000000..05773aa2
---- /dev/null
++++ b/manifests/services/ai.json
@@@ -0,0 +1,16 @@
++{
++ "version": "0.1.0",
++ "contractRanges": {
++ "openvibe-contracts": "^0.1.0"
++ },
++ "id": "ai",
++ "name": "OpenVibe.AI",
++ "status": "placeholder",
++ "repository": "OpenVibers/OpenVibe.AI",
++ "domains": [],
++ "capabilities": [],
++ "eventsProduced": [],
++ "eventsConsumed": [],
++ "namespacesOwned": [],
++ "notes": "Providers, models, routing, prompt templates, workflows, runs, citations, cache and quotas for every product. Charter only; no runtime."
++}
ddiff --git a/manifests/services/billing.json b/manifests/services/billing.json
nnew file mode 100644
iindex 00000000..51b40cec
---- /dev/null
++++ b/manifests/services/billing.json
@@@ -0,0 +1,16 @@
++{
++ "version": "0.1.0",
++ "contractRanges": {
++ "openvibe-contracts": "^0.1.0"
++ },
++ "id": "billing",
++ "name": "OpenVibe.Billing",
++ "status": "placeholder",
++ "repository": "OpenVibers/OpenVibe.Billing",
++ "domains": [],
++ "capabilities": [],
++ "eventsProduced": [],
++ "eventsConsumed": [],
++ "namespacesOwned": [],
++ "notes": "The isolated money ledger: providers, receipts, subscriptions, entitlements, refunds and payouts. Charter only; no runtime."
++}
ddiff --git a/manifests/services/blog.json b/manifests/services/blog.json
nnew file mode 100644
iindex 00000000..639bd023
---- /dev/null
++++ b/manifests/services/blog.json
@@@ -0,0 +1,16 @@
++{
++ "version": "0.1.0",
++ "contractRanges": {
++ "openvibe-contracts": "^0.1.0"
++ },
++ "id": "blog",
++ "name": "OpenVibe.Blog",
++ "status": "placeholder",
++ "repository": "OpenVibers/OpenVibe.Blog",
++ "domains": [],
++ "capabilities": [],
++ "eventsProduced": [],
++ "eventsConsumed": [],
++ "namespacesOwned": [],
++ "notes": "The official OpenVibe blog and a blog for every member: drafts, revisions, series, scheduling, feeds. Charter only; no runtime."
++}
ddiff --git a/manifests/services/chat.json b/manifests/services/chat.json
nnew file mode 100644
iindex 00000000..b7764a59
---- /dev/null
++++ b/manifests/services/chat.json
@@@ -0,0 +1,16 @@
++{
++ "version": "0.1.0",
++ "contractRanges": {
++ "openvibe-contracts": "^0.1.0"
++ },
++ "id": "chat",
++ "name": "OpenVibe.Chat",
++ "status": "placeholder",
++ "repository": "OpenVibers/OpenVibe.Chat",
++ "domains": [],
++ "capabilities": [],
++ "eventsProduced": [],
++ "eventsConsumed": [],
++ "namespacesOwned": [],
++ "notes": "Rooms, messages, DMs, calls, TTS and audio queues, moderation and presence — one identity, every conversation. Charter only; no runtime."
++}
ddiff --git a/manifests/services/codes.json b/manifests/services/codes.json
nnew file mode 100644
iindex 00000000..f6c39ab5
---- /dev/null
++++ b/manifests/services/codes.json
@@@ -0,0 +1,16 @@
++{
++ "version": "0.1.0",
++ "contractRanges": {
++ "openvibe-contracts": "^0.1.0"
++ },
++ "id": "codes",
++ "name": "OpenVibe.Codes",
++ "status": "placeholder",
++ "repository": "OpenVibers/OpenVibe.Codes",
++ "domains": [],
++ "capabilities": [],
++ "eventsProduced": [],
++ "eventsConsumed": [],
++ "namespacesOwned": [],
++ "notes": "The developer portal: registry and contract explorers, credentials, playgrounds, examples, mod/app publishing, governance. Charter only; no runtime."
++}
ddiff --git a/manifests/services/community.json b/manifests/services/community.json
nnew file mode 100644
iindex 00000000..834788c2
---- /dev/null
++++ b/manifests/services/community.json
@@@ -0,0 +1,24 @@
++{
++ "version": "0.1.0",
++ "contractRanges": {
++ "openvibe-contracts": "^0.1.0"
++ },
++ "id": "community",
++ "name": "OpenVibe.Community",
++ "status": "alpha",
++ "repository": "OpenVibers/OpenVibe.Community",
++ "domains": [
++ "openvibe.community"
++ ],
++ "publicOrigin": "https://openvibe.community",
++ "health": "/api/health",
++ "capabilities": [
++ "community.post.create"
++ ],
++ "eventsProduced": [],
++ "eventsConsumed": [],
++ "namespacesOwned": [
++ "community.*"
++ ],
++ "ready": "/api/ready"
++}
ddiff --git a/manifests/services/contracts.json b/manifests/services/contracts.json
nnew file mode 100644
iindex 00000000..2d2f02fe
---- /dev/null
++++ b/manifests/services/contracts.json
@@@ -0,0 +1,16 @@
++{
++ "version": "0.1.0",
++ "contractRanges": {
++ "openvibe-contracts": "^0.1.0"
++ },
++ "id": "contracts",
++ "name": "OpenVibe.Contracts",
++ "status": "alpha",
++ "repository": "OpenVibers/OpenVibe.Contracts",
++ "domains": [],
++ "capabilities": [],
++ "eventsProduced": [],
++ "eventsConsumed": [],
++ "namespacesOwned": [],
++ "notes": "Machine-readable contracts for the whole OpenVibe network. Artifact repository; no runtime."
++}
ddiff --git a/manifests/services/coupons.json b/manifests/services/coupons.json
nnew file mode 100644
iindex 00000000..e4fe308f
---- /dev/null
++++ b/manifests/services/coupons.json
@@@ -0,0 +1,16 @@
++{
++ "version": "0.1.0",
++ "contractRanges": {
++ "openvibe-contracts": "^0.1.0"
++ },
++ "id": "coupons",
++ "name": "OpenVibe.Coupons",
++ "status": "placeholder",
++ "repository": "OpenVibers/OpenVibe.Coupons",
++ "domains": [],
++ "capabilities": [],
++ "eventsProduced": [],
++ "eventsConsumed": [],
++ "namespacesOwned": [],
++ "notes": "Coupon codes with merchant matching, restrictions, expiry and real-people validity reports. Charter only; no runtime."
++}
ddiff --git a/manifests/services/deals.json b/manifests/services/deals.json
nnew file mode 100644
iindex 00000000..b9d33a54
---- /dev/null
++++ b/manifests/services/deals.json
@@@ -0,0 +1,16 @@
++{
++ "version": "0.1.0",
++ "contractRanges": {
++ "openvibe-contracts": "^0.1.0"
++ },
++ "id": "deals",
++ "name": "OpenVibe.Deals",
++ "status": "placeholder",
++ "repository": "OpenVibers/OpenVibe.Deals",
++ "domains": [],
++ "capabilities": [],
++ "eventsProduced": [],
++ "eventsConsumed": [],
++ "namespacesOwned": [],
++ "notes": "Deals submitted and voted on by the community, with source, price and freshness always shown. Charter only; no runtime."
++}
ddiff --git a/manifests/services/events.json b/manifests/services/events.json
nnew file mode 100644
iindex 00000000..b900ff70
---- /dev/null
++++ b/manifests/services/events.json
@@@ -0,0 +1,16 @@
++{
++ "version": "0.1.0",
++ "contractRanges": {
++ "openvibe-contracts": "^0.1.0"
++ },
++ "id": "events",
++ "name": "OpenVibe.Events",
++ "status": "placeholder",
++ "repository": "OpenVibers/OpenVibe.Events",
++ "domains": [],
++ "capabilities": [],
++ "eventsProduced": [],
++ "eventsConsumed": [],
++ "namespacesOwned": [],
++ "notes": "Durable events, subscriptions, delivery, retry, dead letters and replay for the network. Charter only; no runtime."
++}
ddiff --git a/manifests/services/examples.json b/manifests/services/examples.json
nnew file mode 100644
iindex 00000000..4e6de951
---- /dev/null
++++ b/manifests/services/examples.json
@@@ -0,0 +1,16 @@
++{
++ "version": "0.1.0",
++ "contractRanges": {
++ "openvibe-contracts": "^0.1.0"
++ },
++ "id": "examples",
++ "name": "OpenVibe.Examples",
++ "status": "placeholder",
++ "repository": "OpenVibers/OpenVibe.Examples",
++ "domains": [],
++ "capabilities": [],
++ "eventsProduced": [],
++ "eventsConsumed": [],
++ "namespacesOwned": [],
++ "notes": "Executable public integration examples, tested in CI against the platform. Charter only; no runtime."
++}
ddiff --git a/manifests/services/games.json b/manifests/services/games.json
nnew file mode 100644
iindex 00000000..78604970
---- /dev/null
++++ b/manifests/services/games.json
@@@ -0,0 +1,22 @@
++{
++ "version": "0.1.0",
++ "contractRanges": {
++ "openvibe-contracts": "^0.1.0"
++ },
++ "id": "games",
++ "name": "OpenVibe.Games",
++ "status": "stable",
++ "repository": "OpenVibers/OpenVibe.Games",
++ "domains": [
++ "openvibe.games",
++ "play.openvibe.games"
++ ],
++ "publicOrigin": "https://openvibe.games",
++ "health": "/healthz",
++ "capabilities": [],
++ "eventsProduced": [],
++ "eventsConsumed": [],
++ "namespacesOwned": [
++ "games.*"
++ ]
++}
ddiff --git a/manifests/services/host.json b/manifests/services/host.json
nnew file mode 100644
iindex 00000000..f895d0f8
---- /dev/null
++++ b/manifests/services/host.json
@@@ -0,0 +1,16 @@
++{
++ "version": "0.1.0",
++ "contractRanges": {
++ "openvibe-contracts": "^0.1.0"
++ },
++ "id": "host",
++ "name": "OpenVibe.Host",
++ "status": "placeholder",
++ "repository": "OpenVibers/OpenVibe.Host",
++ "domains": [],
++ "capabilities": [],
++ "eventsProduced": [],
++ "eventsConsumed": [],
++ "namespacesOwned": [],
++ "notes": "The network's deployment/control plane first; then isolated hosting for community sites, bots and mods. Charter only; no runtime."
++}
ddiff --git a/manifests/services/live.json b/manifests/services/live.json
nnew file mode 100644
iindex 00000000..d665ac11
---- /dev/null
++++ b/manifests/services/live.json
@@@ -0,0 +1,27 @@
++{
++ "version": "0.1.0",
++ "contractRanges": {
++ "openvibe-contracts": "^0.1.0"
++ },
++ "id": "live",
++ "name": "OpenVibe.Live",
++ "status": "stable",
++ "repository": "OpenVibers/OpenVibe.Live",
++ "domains": [
++ "openvibe.live",
++ "ingest.openvibe.live"
++ ],
++ "publicOrigin": "https://openvibe.live",
++ "health": "/api/health",
++ "ready": "/api/ready",
++ "capabilities": [
++ "chat.message.send",
++ "community.paste.create"
++ ],
++ "eventsProduced": [],
++ "eventsConsumed": [],
++ "namespacesOwned": [
++ "live.*"
++ ],
++ "notes": "Still hosts chat, ingest, comments, money and AI authorities until Waves 5-13."
++}
ddiff --git a/manifests/services/media.json b/manifests/services/media.json
nnew file mode 100644
iindex 00000000..00366525
---- /dev/null
++++ b/manifests/services/media.json
@@@ -0,0 +1,24 @@
++{
++ "version": "0.1.0",
++ "contractRanges": {
++ "openvibe-contracts": "^0.1.0"
++ },
++ "id": "media",
++ "name": "OpenVibe.Media",
++ "status": "beta",
++ "repository": "OpenVibers/OpenVibe.Media",
++ "domains": [
++ "openvibe.media"
++ ],
++ "publicOrigin": "https://openvibe.media",
++ "health": "/healthz",
++ "capabilities": [
++ "media.object.upload",
++ "media.object.read"
++ ],
++ "eventsProduced": [],
++ "eventsConsumed": [],
++ "namespacesOwned": [
++ "media.*"
++ ]
++}
ddiff --git a/manifests/services/network.json b/manifests/services/network.json
nnew file mode 100644
iindex 00000000..310e914a
---- /dev/null
++++ b/manifests/services/network.json
@@@ -0,0 +1,34 @@
++{
++ "version": "0.1.0",
++ "contractRanges": {
++ "openvibe-contracts": "^0.1.0"
++ },
++ "id": "network",
++ "name": "OpenVibe.Network",
++ "status": "stable",
++ "repository": "OpenVibers/OpenVibe.Network",
++ "domains": [
++ "openvibe.network",
++ "auth.openvibe.network",
++ "api.openvibe.network",
++ "admin.openvibe.network",
++ "my.openvibe.network",
++ "themes.openvibe.network"
++ ],
++ "publicOrigin": "https://openvibe.network",
++ "health": "/api/health",
++ "capabilities": [
++ "identity.subject.resolve",
++ "network.coins.credit",
++ "network.coins.debit",
++ "network.notifications.push"
++ ],
++ "eventsProduced": [],
++ "eventsConsumed": [],
++ "namespacesOwned": [
++ "identity.*",
++ "network.*",
++ "theme.*",
++ "notification.*"
++ ]
++}
ddiff --git a/manifests/services/news.json b/manifests/services/news.json
nnew file mode 100644
iindex 00000000..927bd6c7
---- /dev/null
++++ b/manifests/services/news.json
@@@ -0,0 +1,16 @@
++{
++ "version": "0.1.0",
++ "contractRanges": {
++ "openvibe-contracts": "^0.1.0"
++ },
++ "id": "news",
++ "name": "OpenVibe.News",
++ "status": "placeholder",
++ "repository": "OpenVibers/OpenVibe.News",
++ "domains": [],
++ "capabilities": [],
++ "eventsProduced": [],
++ "eventsConsumed": [],
++ "namespacesOwned": [],
++ "notes": "Source-backed stories: clustered coverage, cited summaries, perspectives and timelines. Charter only; no runtime."
++}
ddiff --git a/manifests/services/openre.json b/manifests/services/openre.json
nnew file mode 100644
iindex 00000000..10cd1d3a
---- /dev/null
++++ b/manifests/services/openre.json
@@@ -0,0 +1,16 @@
++{
++ "version": "0.1.0",
++ "contractRanges": {
++ "openvibe-contracts": "^0.1.0"
++ },
++ "id": "openre",
++ "name": "OpenRe.Stream",
++ "status": "placeholder",
++ "repository": "OpenVibers/OpenRe.Stream",
++ "domains": [],
++ "capabilities": [],
++ "eventsProduced": [],
++ "eventsConsumed": [],
++ "namespacesOwned": [],
++ "notes": "Ingest and restream: stream definitions, keys, sessions, transport workers, outputs and output health. Charter only; no runtime."
++}
ddiff --git a/manifests/services/realtime.json b/manifests/services/realtime.json
nnew file mode 100644
iindex 00000000..eb96ecc9
---- /dev/null
++++ b/manifests/services/realtime.json
@@@ -0,0 +1,16 @@
++{
++ "version": "0.1.0",
++ "contractRanges": {
++ "openvibe-contracts": "^0.1.0"
++ },
++ "id": "realtime",
++ "name": "OpenVibe.Realtime",
++ "status": "placeholder",
++ "repository": "OpenVibers/OpenVibe.Realtime",
++ "domains": [],
++ "capabilities": [],
++ "eventsProduced": [],
++ "eventsConsumed": [],
++ "namespacesOwned": [],
++ "notes": "Browser delivery plane for events: authorised WS/SSE topics with cursors, resume and presence. Charter only; no runtime."
++}
ddiff --git a/manifests/services/reviews.json b/manifests/services/reviews.json
nnew file mode 100644
iindex 00000000..0c0e4442
---- /dev/null
++++ b/manifests/services/reviews.json
@@@ -0,0 +1,16 @@
++{
++ "version": "0.1.0",
++ "contractRanges": {
++ "openvibe-contracts": "^0.1.0"
++ },
++ "id": "reviews",
++ "name": "OpenVibe.Reviews",
++ "status": "placeholder",
++ "repository": "OpenVibers/OpenVibe.Reviews",
++ "domains": [],
++ "capabilities": [],
++ "eventsProduced": [],
++ "eventsConsumed": [],
++ "namespacesOwned": [],
++ "notes": "Review signals gathered across sources with provenance, entity resolution and honest aggregates. Charter only; no runtime."
++}
ddiff --git a/manifests/services/sdk.json b/manifests/services/sdk.json
nnew file mode 100644
iindex 00000000..981db55a
---- /dev/null
++++ b/manifests/services/sdk.json
@@@ -0,0 +1,16 @@
++{
++ "version": "0.1.0",
++ "contractRanges": {
++ "openvibe-contracts": "^0.1.0"
++ },
++ "id": "sdk",
++ "name": "OpenVibe.SDK",
++ "status": "placeholder",
++ "repository": "OpenVibers/OpenVibe.SDK",
++ "domains": [],
++ "capabilities": [],
++ "eventsProduced": [],
++ "eventsConsumed": [],
++ "namespacesOwned": [],
++ "notes": "Supported browser and server clients for the OpenVibe platform. Charter only; no runtime."
++}
ddiff --git a/manifests/services/shared.json b/manifests/services/shared.json
nnew file mode 100644
iindex 00000000..ecfa764e
---- /dev/null
++++ b/manifests/services/shared.json
@@@ -0,0 +1,16 @@
++{
++ "version": "0.1.0",
++ "contractRanges": {
++ "openvibe-contracts": "^0.1.0"
++ },
++ "id": "shared",
++ "name": "OpenVibe.Shared",
++ "status": "placeholder",
++ "repository": "OpenVibers/OpenVibe.Shared",
++ "domains": [],
++ "capabilities": [],
++ "eventsProduced": [],
++ "eventsConsumed": [],
++ "namespacesOwned": [],
++ "notes": "Versioned UI, chrome, SEO, legal and release-client packages every OpenVibe site renders. Charter only; no runtime."
++}
ddiff --git a/manifests/services/sites.json b/manifests/services/sites.json
nnew file mode 100644
iindex 00000000..5d3c453c
---- /dev/null
++++ b/manifests/services/sites.json
@@@ -0,0 +1,16 @@
++{
++ "version": "0.1.0",
++ "contractRanges": {
++ "openvibe-contracts": "^0.1.0"
++ },
++ "id": "sites",
++ "name": "OpenVibe.Sites",
++ "status": "stable",
++ "repository": "OpenVibers/OpenVibe.Sites",
++ "domains": [],
++ "capabilities": [],
++ "eventsProduced": [],
++ "eventsConsumed": [],
++ "namespacesOwned": [],
++ "notes": "Static placeholder generator; not a runtime."
++}
ddiff --git a/manifests/services/tips.json b/manifests/services/tips.json
nnew file mode 100644
iindex 00000000..0b1d386b
---- /dev/null
++++ b/manifests/services/tips.json
@@@ -0,0 +1,16 @@
++{
++ "version": "0.1.0",
++ "contractRanges": {
++ "openvibe-contracts": "^0.1.0"
++ },
++ "id": "tips",
++ "name": "OpenVibe.Tips",
++ "status": "placeholder",
++ "repository": "OpenVibers/OpenVibe.Tips",
++ "domains": [],
++ "capabilities": [],
++ "eventsProduced": [],
++ "eventsConsumed": [],
++ "namespacesOwned": [],
++ "notes": "Creator support: tips, goals, paid messages, TTS and media requests, overlays. Charter only; no runtime."
++}
ddiff --git a/manifests/services/tools.json b/manifests/services/tools.json
nnew file mode 100644
iindex 00000000..0519a2ab
---- /dev/null
++++ b/manifests/services/tools.json
@@@ -0,0 +1,21 @@
++{
++ "version": "0.1.0",
++ "contractRanges": {
++ "openvibe-contracts": "^0.1.0"
++ },
++ "id": "tools",
++ "name": "OpenVibe.Tools",
++ "status": "stable",
++ "repository": "OpenVibers/OpenVibe.Tools",
++ "domains": [
++ "openvibe.tools"
++ ],
++ "publicOrigin": "https://openvibe.tools",
++ "health": "/api/health",
++ "capabilities": [],
++ "eventsProduced": [],
++ "eventsConsumed": [],
++ "namespacesOwned": [
++ "tools.*"
++ ]
++}
ddiff --git a/manifests/services/trade.json b/manifests/services/trade.json
nnew file mode 100644
iindex 00000000..b6d17d94
---- /dev/null
++++ b/manifests/services/trade.json
@@@ -0,0 +1,16 @@
++{
++ "version": "0.1.0",
++ "contractRanges": {
++ "openvibe-contracts": "^0.1.0"
++ },
++ "id": "trade",
++ "name": "OpenVibe.Trade",
++ "status": "placeholder",
++ "repository": "OpenVibers/OpenVibe.Trade",
++ "domains": [],
++ "capabilities": [],
++ "eventsProduced": [],
++ "eventsConsumed": [],
++ "namespacesOwned": [],
++ "notes": "Informational watchlists, sourced market context and alerts. No custody, no order execution. Charter only; no runtime."
++}
ddiff --git a/manifests/services/vip.json b/manifests/services/vip.json
nnew file mode 100644
iindex 00000000..601dfdc5
---- /dev/null
++++ b/manifests/services/vip.json
@@@ -0,0 +1,16 @@
++{
++ "version": "0.1.0",
++ "contractRanges": {
++ "openvibe-contracts": "^0.1.0"
++ },
++ "id": "vip",
++ "name": "OpenVibe.VIP",
++ "status": "placeholder",
++ "repository": "OpenVibers/OpenVibe.VIP",
++ "domains": [],
++ "capabilities": [],
++ "eventsProduced": [],
++ "eventsConsumed": [],
++ "namespacesOwned": [],
++ "notes": "Memberships: plans, perks and benefits recognised across every OpenVibe site. Charter only; no runtime."
++}
ddiff --git a/manifests/services/wiki.json b/manifests/services/wiki.json
nnew file mode 100644
iindex 00000000..bce5bcae
---- /dev/null
++++ b/manifests/services/wiki.json
@@@ -0,0 +1,16 @@
++{
++ "version": "0.1.0",
++ "contractRanges": {
++ "openvibe-contracts": "^0.1.0"
++ },
++ "id": "wiki",
++ "name": "OpenVibe.Wiki",
++ "status": "placeholder",
++ "repository": "OpenVibers/OpenVibe.Wiki",
++ "domains": [],
++ "capabilities": [],
++ "eventsProduced": [],
++ "eventsConsumed": [],
++ "namespacesOwned": [],
++ "notes": "Wiki spaces with page trees, revisions, citations, media and discussion — editable together, source-backed. Charter only; no runtime."
++}
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 00000000..5087a0fd
---- /dev/null
++++ b/package-lock.json
@@@ -0,0 +1,290 @@
++{
++ "name": "openvibe-contracts",
++ "version": "0.1.0",
++ "lockfileVersion": 3,
++ "requires": true,
++ "packages": {
++ "": {
++ "name": "openvibe-contracts",
++ "version": "0.1.0",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "ajv": "^8.17.1",
++ "ajv-formats": "^3.0.1"
++ },
++ "devDependencies": {
++ "json-schema-to-typescript": "^15.0.4"
++ },
++ "engines": {
++ "node": ">=20"
++ }
++ },
++ "node_modules/@apidevtools/json-schema-ref-parser": {
++ "version": "11.9.3",
++ "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz",
++ "integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "@jsdevtools/ono": "^7.1.3",
++ "@types/json-schema": "^7.0.15",
++ "js-yaml": "^4.1.0"
++ },
++ "engines": {
++ "node": ">= 16"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/philsturgeon"
++ }
++ },
++ "node_modules/@jsdevtools/ono": {
++ "version": "7.1.3",
++ "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
++ "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
++ "dev": true,
++ "license": "MIT"
++ },
++ "node_modules/@types/json-schema": {
++ "version": "7.0.15",
++ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
++ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
++ "dev": true,
++ "license": "MIT"
++ },
++ "node_modules/@types/lodash": {
++ "version": "4.17.25",
++ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz",
++ "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==",
++ "dev": true,
++ "license": "MIT"
++ },
++ "node_modules/ajv": {
++ "version": "8.20.0",
++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
++ "license": "MIT",
++ "dependencies": {
++ "fast-deep-equal": "^3.1.3",
++ "fast-uri": "^3.0.1",
++ "json-schema-traverse": "^1.0.0",
++ "require-from-string": "^2.0.2"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/epoberezkin"
++ }
++ },
++ "node_modules/ajv-formats": {
++ "version": "3.0.1",
++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
++ "license": "MIT",
++ "dependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependenciesMeta": {
++ "ajv": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/argparse": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
++ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
++ "dev": true,
++ "license": "Python-2.0"
++ },
++ "node_modules/fast-deep-equal": {
++ "version": "3.1.3",
++ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
++ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
++ "license": "MIT"
++ },
++ "node_modules/fast-uri": {
++ "version": "3.1.8",
++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
++ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fastify"
++ },
++ {
++ "type": "opencollective",
++ "url": "https://opencollective.com/fastify"
++ }
++ ],
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/fdir": {
++ "version": "6.5.0",
++ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
++ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">=12.0.0"
++ },
++ "peerDependencies": {
++ "picomatch": "^3 || ^4"
++ },
++ "peerDependenciesMeta": {
++ "picomatch": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/is-extglob": {
++ "version": "2.1.1",
++ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
++ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/is-glob": {
++ "version": "4.0.3",
++ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
++ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "is-extglob": "^2.1.1"
++ },
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/js-yaml": {
++ "version": "4.3.2",
++ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
++ "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
++ "dev": true,
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/puzrin"
++ },
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/nodeca"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "argparse": "^2.0.1"
++ },
++ "bin": {
++ "js-yaml": "bin/js-yaml.js"
++ }
++ },
++ "node_modules/json-schema-to-typescript": {
++ "version": "15.0.4",
++ "resolved": "https://registry.npmjs.org/json-schema-to-typescript/-/json-schema-to-typescript-15.0.4.tgz",
++ "integrity": "sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "@apidevtools/json-schema-ref-parser": "^11.5.5",
++ "@types/json-schema": "^7.0.15",
++ "@types/lodash": "^4.17.7",
++ "is-glob": "^4.0.3",
++ "js-yaml": "^4.1.0",
++ "lodash": "^4.17.21",
++ "minimist": "^1.2.8",
++ "prettier": "^3.2.5",
++ "tinyglobby": "^0.2.9"
++ },
++ "bin": {
++ "json2ts": "dist/src/cli.js"
++ },
++ "engines": {
++ "node": ">=16.0.0"
++ }
++ },
++ "node_modules/json-schema-traverse": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
++ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
++ "license": "MIT"
++ },
++ "node_modules/lodash": {
++ "version": "4.18.1",
++ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
++ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
++ "dev": true,
++ "license": "MIT"
++ },
++ "node_modules/minimist": {
++ "version": "1.2.8",
++ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
++ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
++ "dev": true,
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/picomatch": {
++ "version": "4.0.7",
++ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
++ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">=12"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/jonschlinkert"
++ }
++ },
++ "node_modules/prettier": {
++ "version": "3.9.8",
++ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.8.tgz",
++ "integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==",
++ "dev": true,
++ "license": "MIT",
++ "bin": {
++ "prettier": "bin/prettier.cjs"
++ },
++ "engines": {
++ "node": ">=14"
++ },
++ "funding": {
++ "url": "https://github.com/prettier/prettier?sponsor=1"
++ }
++ },
++ "node_modules/require-from-string": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/tinyglobby": {
++ "version": "0.2.17",
++ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
++ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "fdir": "^6.5.0",
++ "picomatch": "^4.0.4"
++ },
++ "engines": {
++ "node": ">=12.0.0"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/SuperchupuDev"
++ }
++ }
++ }
++}
ddiff --git a/package.json b/package.json
nnew file mode 100644
iindex 00000000..93005bb3
---- /dev/null
++++ b/package.json
@@@ -0,0 +1,22 @@
++{
++ "name": "openvibe-contracts",
++ "version": "0.1.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/"],
++ "scripts": {
++ "test": "node test/run.js",
++ "generate": "node scripts/generate.js"
++ },
++ "engines": { "node": ">=20" },
++ "repository": "github:OpenVibers/OpenVibe.Contracts",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "ajv": "^8.17.1",
++ "ajv-formats": "^3.0.1"
++ },
++ "devDependencies": {
++ "json-schema-to-typescript": "^15.0.4"
++ }
++}
ddiff --git a/scripts/compat.js b/scripts/compat.js
nnew file mode 100644
iindex 00000000..812e7d47
---- /dev/null
++++ b/scripts/compat.js
@@@ -0,0 +1,52 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * Backward-compatibility gate: compares every contract with the same file at the previous release
++ * tag (or the ref given as argv[2]) and fails on changes that would break an existing producer or
++ * consumer of the same major version:
++ * - a property removed, or its type changed
++ * - a property newly required
++ * - an enum/const value removed
++ * - 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.
++ */
++const { execFileSync } = require('child_process');
++const fs = require('fs');
++const path = require('path');
++
++const ROOT = path.join(__dirname, '..');
++const git = (...a) => { try { return execFileSync('git', ['-C', ROOT, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return null; } };
++const base = process.argv[2] || git('describe', '--tags', '--abbrev=0', 'HEAD^');
++if (!base) { console.log('compat: no previous release tag, nothing to compare'); process.exit(0); }
++
++const readAt = (ref, rel) => { const s = git('show', `${ref}:${rel}`); return s ? JSON.parse(s) : null; };
++const problems = [];
++
++function compare(id, a, b, at) {
++ if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return;
++ 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`);
++ 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 || {})) {
++ if (!(b.properties || {})[k]) problems.push(`${id} ${at}: property "${k}" removed`);
++ else compare(id, v, b.properties[k], `${at}.${k}`);
++ }
++ if (a.items && b.items) compare(id, a.items, b.items, `${at}[]`);
++ (a.oneOf || []).forEach((s, i) => compare(id, s, (b.oneOf || [])[i], `${at}|${i}`));
++}
++
++const oldCat = readAt(base, 'contracts/catalog.json');
++const newCat = JSON.parse(fs.readFileSync(path.join(ROOT, 'contracts/catalog.json'), 'utf8'));
++const deprecated = new Set(JSON.parse(fs.readFileSync(path.join(ROOT, 'compatibility/deprecations.json'), 'utf8')).deprecations.map(d => d.id));
++for (const entry of (oldCat ? oldCat.contracts : [])) {
++ const now = newCat.contracts.find(c => c.id === entry.id);
++ if (!now) { if (!deprecated.has(entry.id)) problems.push(`${entry.id}: removed from the catalog without a deprecation`); continue; }
++ if (now.schema !== entry.schema) continue; // new major file; old one must still be listed separately
++ compare(entry.id, readAt(base, `contracts/${entry.schema}`), JSON.parse(fs.readFileSync(path.join(ROOT, 'contracts', entry.schema), 'utf8')), '');
++}
++if (problems.length) { console.error(`compat vs ${base}: ${problems.length} breaking change(s)\n ${problems.join('\n ')}`); process.exit(1); }
++console.log(`compat vs ${base}: no breaking changes`);
ddiff --git a/scripts/generate.js b/scripts/generate.js
nnew file mode 100644
iindex 00000000..526403e3
---- /dev/null
++++ b/scripts/generate.js
@@@ -0,0 +1,45 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * Regenerates generated/ from contracts/:
++ * generated/json-schema/bundle.json every schema keyed by contract id (for registries and docs)
++ * generated/typescript/index.d.ts types for every contract
++ * `--check` fails if the committed output is stale (used by CI).
++ */
++const fs = require('fs');
++const path = require('path');
++const { compile } = require('json-schema-to-typescript');
++
++const ROOT = path.join(__dirname, '..');
++const catalog = JSON.parse(fs.readFileSync(path.join(ROOT, 'contracts/catalog.json'), 'utf8')).contracts;
++const CHECK = process.argv.includes('--check');
++
++(async () => {
++ const bundle = { _about: 'Generated by scripts/generate.js. Do not edit.', contracts: {} };
++ let dts = '/* Generated by scripts/generate.js from contracts/. Do not edit. */\n/* eslint-disable */\n';
++ for (const entry of catalog) {
++ const file = path.join(ROOT, 'contracts', entry.schema);
++ const schema = JSON.parse(fs.readFileSync(file, 'utf8'));
++ bundle.contracts[entry.id] = { version: entry.version, owner: entry.owner, status: entry.status, schema };
++ const ts = await compile(schema, schema.title, {
++ bannerComment: '', cwd: path.dirname(file), declareExternallyReferenced: false,
++ additionalProperties: false, strictIndexSignatures: true, format: true,
++ $refOptions: { resolve: { http: false } },
++ });
++ dts += `\n/** ${entry.id}@${entry.version} (owner: ${entry.owner}) */\n${ts}`;
++ }
++ const out = {
++ 'generated/json-schema/bundle.json': JSON.stringify(bundle, null, 2) + '\n',
++ 'generated/typescript/index.d.ts': dts,
++ };
++ let stale = [];
++ for (const [rel, body] of Object.entries(out)) {
++ const p = path.join(ROOT, rel);
++ const prev = fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : null;
++ if (prev !== body) {
++ if (CHECK) stale.push(rel); else { fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, body); }
++ }
++ }
++ if (stale.length) { console.error(`stale generated files (run npm run generate): ${stale.join(', ')}`); process.exit(1); }
++ console.log(CHECK ? 'generated/: up to date' : 'generated/: written');
++})().catch(e => { console.error(e); process.exit(1); });
ddiff --git a/test/run.js b/test/run.js
nnew file mode 100644
iindex 00000000..fa2fcd5b
---- /dev/null
++++ b/test/run.js
@@@ -0,0 +1,110 @@
++'use strict';
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { execFileSync } = require('child_process');
++const contracts = require('..');
++const { ids, http, capabilities, services } = contracts;
++
++const ROOT = path.join(__dirname, '..');
++const BASE = 'https://openvibe.network/contracts/';
++let n = 0;
++const ok = (cond, msg) => { assert.ok(cond, msg); n++; };
++
++// ── Catalog integrity ────────────────────────────────────────────────────
++const files = [];
++(function walk(d) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.isDirectory()) walk(p); else if (/\.v\d+\.json$/.test(e.name)) files.push(path.relative(path.join(ROOT, 'contracts'), p)); } })(path.join(ROOT, 'contracts'));
++for (const f of files) ok(contracts.catalog.some(c => c.schema === f), `${f} is in catalog.json`);
++for (const c of contracts.catalog) {
++ for (const k of ['id', 'version', 'owner', 'schema', 'visibility', 'compatibility', 'status']) ok(c[k], `${c.id} has ${k}`);
++ const s = contracts.schema(c.id);
++ ok(s.$id === BASE + c.schema, `${c.id} $id matches its path`);
++ ok(c.schema.endsWith(`.v${c.version.split('.')[0]}.json`), `${c.id} file major matches version ${c.version}`);
++ ok(s.title && s.description, `${c.id} has title and description`);
++ ok(/^\d+\.\d+\.\d+$/.test(c.version), `${c.id} semver`);
++}
++for (const d of contracts.catalog.length ? JSON.parse(fs.readFileSync(path.join(ROOT, 'compatibility/deprecations.json'), 'utf8')).deprecations : []) {
++ ok(d.replacement && contracts.resolve(d.replacement), `deprecation ${d.id} names an existing replacement`);
++ ok(d.removeAfter, `deprecation ${d.id} has a window`);
++}
++
++// ── Fixtures: every contract has examples that pass and counter-examples that fail ──
++for (const c of contracts.catalog) {
++ for (const kind of ['valid', 'invalid']) {
++ const dir = path.join(ROOT, 'fixtures', c.id, kind);
++ const list = fs.existsSync(dir) ? fs.readdirSync(dir).filter(f => f.endsWith('.json')) : [];
++ ok(list.length > 0, `${c.id} has ${kind} fixtures`);
++ for (const f of list) {
++ const r = contracts.validate(c.id, JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')));
++ ok(r.valid === (kind === 'valid'), `${c.id} ${kind}/${f}: ${r.valid ? 'passed' : JSON.stringify(r.errors)}`);
++ }
++ }
++}
++assert.throws(() => contracts.resolve('identity.subject-ref@2'), /not @2/);
++assert.throws(() => contracts.assertValid('identity.subject-ref', { type: 'user', id: '1' }), e => e.code === 'contract.invalid');
++
++// ── Manifests ────────────────────────────────────────────────────────────
++const serviceIds = new Set(services.manifests.map(m => m.id));
++for (const m of services.manifests) {
++ const r = contracts.validate('registry.service-manifest@1', m);
++ ok(r.valid, `service manifest ${m.id}: ${JSON.stringify(r.errors)}`);
++ for (const cap of m.capabilities) ok(capabilities.get(cap) && capabilities.get(cap).owner === m.id, `${m.id} lists ${cap}, which exists and is owned by it`);
++}
++for (const cap of capabilities.manifests) {
++ const r = contracts.validate('capabilities.capability@1', cap);
++ ok(r.valid, `capability ${cap.id}: ${JSON.stringify(r.errors)}`);
++ ok(serviceIds.has(cap.owner), `${cap.id} owner ${cap.owner} is a registered service`);
++ ok(services.get(cap.owner).capabilities.includes(cap.id), `${cap.owner} manifest lists ${cap.id}`);
++ for (const k of ['inputSchema', 'outputSchema']) if (cap[k]) ok(contracts.resolve(cap[k]), `${cap.id} ${k} resolves`);
++ ok(cap.status !== 'active' || cap.implementedBy.length > 0, `active ${cap.id} names the route that implements it`);
++}
++
++// ── Ids ──────────────────────────────────────────────────────────────────
++for (const kind of ['user', 'guest', 'app', 'mod']) {
++ const id = ids.newId(kind);
++ ok(contracts.validate('identity.subject-ref', { type: kind, id }).valid, `newId(${kind}) is a valid subject id`);
++ ok(ids.formatSubject(ids.parseSubject(`${kind}:${id}`)) === `${kind}:${id}`, `${kind} round-trips`);
++}
++ok(ids.newId('user', 1000) < ids.newId('user', 2000), 'ulids sort by time');
++ok(ids.parseSubject('user:42') === null && ids.parseSubject('user') === null && ids.parseSubject('svc:live') === null, 'malformed subjects are rejected');
++ok(ids.principalSub({ type: 'service', id: 'live' }) === 'svc:live', 'service principal sub');
++assert.throws(() => ids.principalSub({ type: 'user', id: ids.newId('user') }), /principals/);
++ok(contracts.validate('media.media-ref', { media_id: ids.legacyMediaId('live', 'vod', 42) }).valid, 'legacyMediaId is a valid MediaRef');
++ok(contracts.validate('events.event-envelope', { event_id: ids.newId('event'), event_type: 'network.user.created', version: 1, source: 'network', actor: { type: 'system', id: 'network' }, timestamp: new Date().toISOString(), subject: { type: 'user', id: ids.newId('user') }, payload: {} }).valid, 'newId(event) builds a valid envelope');
++
++// ── Envelope helpers ─────────────────────────────────────────────────────
++const tp = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01';
++ok(http.parseTraceparent(tp).traceId === '4bf92f3577b34da6a3ce929d0e0e4736', 'traceparent parsed');
++ok(http.parseTraceparent('00-00000000000000000000000000000000-00f067aa0ba902b7-01') === null, 'all-zero trace id rejected');
++const ctx = http.requestContext({ traceparent: tp, 'x-openvibe-request-id': 'req_abcdefgh', 'idempotency-key': 'k1' });
++ok(ctx.traceId === '4bf92f3577b34da6a3ce929d0e0e4736' && ctx.requestId === 'req_abcdefgh' && ctx.idempotencyKey === 'k1', 'context continues the caller trace');
++ok(http.parseTraceparent(ctx.traceparent).traceId === ctx.traceId, 'context traceparent is well-formed');
++ok(http.requestContext({ 'x-openvibe-request-id': 'bad id!' }).requestId.startsWith('req_'), 'unsafe request id replaced');
++ok(http.parseTraceparent(http.outboundHeaders(ctx).traceparent).traceId === ctx.traceId, 'outbound headers keep the trace');
++const body = http.problem(403, 'capability.denied', { detail: 'media.object.upload not granted', ctx });
++ok(contracts.validate('errors.problem@1', body).valid && body.error === body.detail, 'problem() output is a valid Problem with the legacy error field');
++const res = { headers: {}, setHeader(k, v) { this.headers[k.toLowerCase()] = v; }, end(b) { this.body = b; } };
++http.sendProblem(res, 404, 'subject.not_found');
++ok(res.statusCode === 404 && res.headers['content-type'] === 'application/problem+json' && JSON.parse(res.body).code === 'subject.not_found', 'sendProblem writes problem+json');
++const req = { headers: {} };
++http.middleware()(req, res, () => {});
++ok(req.ov && res.headers['x-openvibe-request-id'] === req.ov.requestId, 'middleware attaches and echoes the context');
++
++// ── Capability checks (denial cases first) ───────────────────────────────
++const live = { sub: 'svc:live', cap: ['media.object.upload', 'media.object.read'], ns: ['live.*'] };
++ok(capabilities.check(live, 'media.object.upload', { namespace: 'live.files' }).allowed, 'granted capability in granted namespace');
++ok(capabilities.check(live, 'media.object.upload', { namespace: 'games.maps' }).code === 'capability.namespace_denied', 'other namespace denied');
++ok(capabilities.check(live, 'network.coins.credit').code === 'capability.denied', 'ungranted capability denied');
++ok(capabilities.check({ cap: [] }, 'chat.message.send').code === 'capability.denied', 'empty grant denied');
++ok(capabilities.check(null, 'chat.message.send').code === 'capability.denied', 'no claims denied');
++ok(capabilities.check(live, 'media.bucket.delete').code === 'capability.unknown', 'unknown capability denied');
++ok(capabilities.check({ cap: ['media.object.*'], ns: ['live'] }, 'media.object.read', { namespace: 'live' }).allowed, 'family grant + exact namespace');
++ok(!capabilities.check({ cap: ['media.*'], ns: ['*'] }, 'network.coins.debit').allowed, 'a family grant never crosses owners');
++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');
++
++// ── 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`);