openvibers/openvibe.codes Commits
openvibe-contracts tag v0.27.0 (codes manifest, codes.release.manage|read, codes.app-manifest@1 released): app manifests validate with the released contract (compiled-in proposal and the proposals dropped), release API guarded with requireCapability (reads stay public; a presented token must hold codes.release.read), contracts check blocking in CI; trust tiers per ADR-013 (unreviewed, reviewed, first-party) with an idempotent boot migration of stored values and history; Events playground on the public events.app.publish (app.<project_key>.* types, source app-<ULID>); docs show the pinned tag next to the package version
Changes
@@@ -15,8 +15,6 @@ jobs:- run: npm ci --no-audit --no-fund- run: for f in $(find server public/js test -name '*.js'); do node --check "$f"; done- run: npm test-- # Codes' service manifest, its capabilities and codes.app-manifest@1 are proposals (docs/) until-- # the lead releases them in openvibe-contracts; until then the check reports the placeholder-- # manifest's range and the unreleased contract, so it does not fail the build.++ # Blocking: the codes manifest, codes.release.manage|read and codes.app-manifest@1 are released++ # (openvibe-contracts tag v0.27.0).- run: npx openvibe-contracts-check --service codes --src server-- continue-on-error: true@@@ -14,7 +14,7 @@ A place where a developer outside the network goes from an OpenVibe account to a## Owns- **Release metadata** keyed to Network app ids: validated app and mod manifests, releases (`draft → published → deprecated → revoked`), an append-only release log.--- **Trust tiers** (`untrusted`, `verified`, `trusted`, `platform-maintained`) — **metadata only**; a tier never grants, allows or bypasses anything.++- **Trust tiers** per ADR-013 (`unreviewed`, `reviewed`, `first-party`) — **metadata only**: a tier changes defaults and discovery, never a grant check. Databases written with the earlier four names are migrated at boot (untrusted→unreviewed, verified/trusted→reviewed, platform-maintained→first-party), idempotently.- **Playground run logs** (who ran what, outcome, stage, problem code; never a credential).- The events `codes.app.published`, `codes.app.deprecated`, `codes.app.revoked` (through the openvibe-sdk transactional outbox).- The generated reference (rendered from the pinned packages at boot; nothing hand-written that can drift) and the portal's pages.@@@ -34,10 +34,10 @@ A place where a developer outside the network goes from an OpenVibe account to a| OAuth helper | `/oauth`, `/oauth/test-callback` | Explains authorization code + PKCE (S256) for apps, builds a test authorize URL, and a callback that shows `code`/`state`/`error` and **never exchanges** the code. || Webhook tools | `/tools/webhooks` | Verify `X-OpenVibe-Signature: sha256=<HMAC>` against a raw body (in the browser with Web Crypto, or on the server without JavaScript, constant-time, secret dropped); generate a signed sample delivery for any event type the contracts catalog lists. || Docs | `/docs/*` | Contracts (field tables, fixtures as examples, raw schema), capability catalog (grantable ones highlighted), event types, SDK reference from its `.d.ts` files, ADRs. Every page states the versions it was generated from. The service registry is read live from Network, with health as Network reports it. |--| Playgrounds | `/projects/:project/apps/:app/playground` | Media upload (sandbox) and Events publish, run **as the app** with its own token (from its secret typed for that one request, or a pasted token). Refused — with the missing grant named, and nothing requested or called — unless Network lists the grant as approved. Sandbox apps only. |--| Manifests | `/manifests/validate`, `/projects/:project/apps/:app/releases/new` | Validate `mods.mod-manifest@1` and app manifests with openvibe-contracts; create, publish, deprecate and revoke releases. |++| Playgrounds | `/projects/:project/apps/:app/playground` | Media upload (sandbox, `media.object.upload`) and Events publish (`events.app.publish`: types `app.<project_key>.*`, source `app-<app ULID>`), run **as the app** with its own token (from its secret typed for that one request, or a pasted token). Refused — with the missing grant named, and nothing requested or called — unless Network lists the grant as approved. Sandbox apps only. |++| Manifests | `/manifests/validate`, `/projects/:project/apps/:app/releases/new` | Validate `codes.app-manifest@1` and `mods.mod-manifest@1` with openvibe-contracts; create, publish, deprecate and revoke releases. || Policy | `/policy/*` | Proposal process and the decision record; compatibility and deprecation policy rendered from ADR-002 and ADR-016 as published; licensing read from package metadata; transparency (what Codes stores and does not). |--| API | `/api/v1/*` | Public release reads, manifest validation, docs versions; release management by an app with its own token (`codes.release.manage`, proposed). RFC 9457 problems. |++| API | `/api/v1/*` | Public release reads (`codes.release.read`: no token needed; a presented token must hold it), manifest validation, docs versions; release management by an app with its own token (`codes.release.manage`). Guarded with openvibe-contracts `requireCapability`. RFC 9457 problems. || Machine | `/api/health`, `/api/ready`, `/release.json`, `/metrics` | Readiness is truthful (db and docs required; Network, JWKS, OAuth client and events relay reported as optional checks). `/metrics` answers loopback callers only. |Everything is server-rendered and usable without JavaScript. The only scripts of Codes' own are optional: in-browser webhook verification and a copy button.@@@ -45,9 +45,9 @@ Everything is server-rendered and usable without JavaScript. The only scripts of## Depends on- **OpenVibe.Network** — SSO (OAuth client `codes`, PKCE S256), JWKS, `/api/v1/projects` (called server-side with the person's Network access token), the registry (`/api/v1/registry/services`, `/.well-known/openvibe`), client-credentials tokens (Codes' own for the events relay; the app's own in playgrounds).--- **OpenVibe.Events** — the outbox relay publishes `codes.app.*` with Codes' service token (`events.event.publish`); the Events playground calls it with the app's token.++- **OpenVibe.Events** — the outbox relay publishes `codes.app.*` with Codes' service token (`events.event.publish`); the Events playground calls it with the app's token (`events.app.publish`).- **OpenVibe.Media** — the Media playground uploads with the app's token into the project's namespace.--- **openvibe-contracts v0.26.0**, **openvibe-sdk v0.2.2**, **openvibe-shared v1.3.0** (pinned tag tarballs).++- **openvibe-contracts** tag v0.27.0 (its package.json says 0.28.0; the docs show both), **openvibe-sdk v0.2.2**, **openvibe-shared v1.3.0** (pinned tag tarballs).No path in Codes sends or accepts a shared loopback key (tested by grep and at runtime).@@@ -58,9 +58,9 @@ In Network (`server/identity/principals.js` / `server/db/database.js`, as for co- OAuth client `codes`, name `OpenVibe.Codes`, redirect URI `https://openvibe.codes/auth/callback`.- Service grant `['codes', 'events.event.publish', 'openvibe.events', []]` (the outbox relay).--In OpenVibe.Contracts (next release): the service manifest `docs/service-manifest-proposal.json`, the capabilities in `docs/capabilities-proposal/`, and the contract `codes.app-manifest@1` from `docs/contracts-proposal/`.++The Codes service manifest, `codes.release.manage|read` and `codes.app-manifest@1` are released in openvibe-contracts (tag v0.27.0); the CI contracts check is blocking.--For the playgrounds to succeed end to end (not Codes' code; configuration elsewhere): Network `DEV_SANDBOX_AUDIENCES` including `openvibe.media` (and `openvibe.events`), a staff-set allowance containing `media.object.upload` for the project, and a Media tenant keyed by the project id. `events.event.publish` is `internal` in contracts v0.26.0, so no app can publish events until Events publishes a public capability; the playground says so.++For the playgrounds to succeed end to end (not Codes' code; configuration elsewhere): Network `DEV_SANDBOX_AUDIENCES` including `openvibe.media` and `openvibe.events`, a staff-set allowance containing `media.object.upload` and `events.app.publish` for the project, a Media tenant keyed by the project id, and OpenVibe.Events serving `events.app.publish` for app tokens.## Configuration@@@ -84,7 +84,7 @@ fnm exec --using=22.22.1 npm run dev # http://localhost:4900npx openvibe-contracts-check --service codes --src server```--Tests: secrets never persisted or re-displayed; Network errors surfaced honestly (404/403/409/422/503, unreachable, expired session refresh); scope editor offers only grantable capabilities and refuses forged requests before Network sees them; generated docs match the pinned versions (every contract, capability, event type, SDK module); webhook verification (tampering, prefixes, lengths, constant-time); playgrounds refuse without the grant and call nothing; manifest validation; release lifecycle and events; no internal key anywhere; PKCE sign-in; readiness, release.json, loopback metrics; proposals valid against the contract schemas.++Tests: ADR-013 trust-tier migration; secrets never persisted or re-displayed; Network errors surfaced honestly (404/403/409/422/503, unreachable, expired session refresh); scope editor offers only grantable capabilities and refuses forged requests before Network sees them; generated docs match the pinned versions (every contract, capability, event type, SDK module); webhook verification (tampering, prefixes, lengths, constant-time); playgrounds refuse without the grant and call nothing; manifest validation; release lifecycle and events; no internal key anywhere; PKCE sign-in; readiness, release.json, loopback metrics; the released codes manifest matching the code.## Acceptance (must be true before "done")@@@ -95,7 +95,7 @@ Tests: secrets never persisted or re-displayed; Network errors surfaced honestly## Launch rule--This repository does not make the product real, and the domain keeps its placeholder page on [OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of the following exist (plan §12.12): an owning runtime with health/readiness and observability (**done**); canonical identity/auth integration (**done in code; the OAuth client is not registered**); server-rendered public routes useful without JavaScript (**done**); real persistence and end-to-end workflows (**persistence done; end-to-end blocked on the items above**); capability and event registration against OpenVibe.Contracts (**proposed, not released**); a migration/seed strategy (none needed: no data is imported; raw old developer keys are never imported), a security review and sitemap/robots behaviour (**sitemap and robots done**); acceptance tests proving the advertised functionality (**against stand-ins**). Roadmap binding: do not launch Codes as a developer portal while these paths are mocked.++This repository does not make the product real, and the domain keeps its placeholder page on [OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of the following exist (plan §12.12): an owning runtime with health/readiness and observability (**done**); canonical identity/auth integration (**done in code; the OAuth client is not registered**); server-rendered public routes useful without JavaScript (**done**); real persistence and end-to-end workflows (**persistence done; end-to-end blocked on the items above**); capability and event registration against OpenVibe.Contracts (**released**); a migration/seed strategy (none needed: no data is imported; raw old developer keys are never imported), a security review and sitemap/robots behaviour (**sitemap and robots done**); acceptance tests proving the advertised functionality (**against stand-ins**). Roadmap binding: do not launch Codes as a developer portal while these paths are mocked.### Threat notes@@@ -8,7 +8,7 @@"port": 4900,"domain": "openvibe.codes","packages": [-- "openvibe-contracts v0.26.0",++ "openvibe-contracts tag v0.27.0 (package.json 0.28.0)","openvibe-sdk v0.2.2","openvibe-shared v1.3.0"],@@@ -18,25 +18,25 @@"Projects, members, apps, credentials (rotate, revoke), grants (request, approve, deny, revoke), quotas and audit over Network's /api/v1/projects API with the person's own token; Network's errors shown as Network answered them","Client secrets shown once, in the create/rotate response only, with no-store; never stored or logged (tested)","Scope editor offering only capabilities apps can be granted (public, or partner when in the allowance)",-- "Docs generated at boot from openvibe-contracts v0.26.0 and openvibe-sdk v0.2.2: contracts with field tables and fixtures, capability catalog, event types, SDK reference; service registry read live from Network with its health",++ "Docs generated at boot from the pinned openvibe-contracts and openvibe-sdk: contracts with field tables and fixtures, capability catalog, event types, SDK reference; service registry read live from Network with its health","OAuth callback helper and a test callback that never exchanges the code","Webhook signature verifier (server or in-browser) and signed sample deliveries for catalogued event types",-- "Playgrounds (Media upload, Events publish) that run with the app's own token and refuse without the grant",-- "App and mod manifest validation; release metadata (draft, publish, deprecate, revoke) with codes.app.* events through the SDK outbox; trust tiers as staff-set metadata",-- "Policy pages rendered from the published ADRs; licensing read from package metadata"++ "Playgrounds (Media upload with media.object.upload, Events publish with events.app.publish) that run with the app's own token and refuse without the grant",++ "App and mod manifest validation; release metadata (draft, publish, deprecate, revoke) with codes.app.* events through the SDK outbox; trust tiers per ADR-013 (unreviewed, reviewed, first-party) as staff-set metadata, older names migrated at boot",++ "Policy pages rendered from the published ADRs; licensing read from package metadata",++ "Release API guarded by openvibe-contracts requireCapability (codes.release.manage, codes.release.read)"],"notYet": ["Not deployed; the Network OAuth client `codes` and the events.event.publish grant are not registered",-- "The Events playground cannot run for any app: events.event.publish is internal in openvibe-contracts v0.26.0 and Network never grants it to apps",++ "The Events playground needs Network to accept sandbox tokens for openvibe.events, events.app.publish in the project's allowance, and Events serving app tokens","The Media playground needs Network to accept sandbox tokens for openvibe.media (DEV_SANDBOX_AUDIENCES) and Media to have a tenant per project id; neither exists yet",-- "codes.release.manage and codes.release.read are proposals; until Contracts releases them no app can hold the release-management grant","No usage, trace or error dashboards: no service reports per-project usage yet","Billing sandbox, Chat bot and Realtime playgrounds; CLI and scaffolding; templates; project data export","Consent screen for third-party production apps (a Network gap)"],-- "capabilitiesRegistered": false,-- "serviceManifestRegistered": false,++ "capabilitiesRegistered": true,++ "serviceManifestRegistered": true,"deployed": false,"placeholderInSites": true,-- "updated": "2026-09-22"++ "updated": "2026-09-23"}ddeleted file mode 100644@@@ -1,19 +0,0 @@--{-- "id": "codes.release.manage",-- "version": "1.0",-- "owner": "codes",-- "status": "active",-- "visibility": "public",-- "description": "An app manages release metadata for ITSELF with its own token (audience openvibe.codes): create a release from a validated app or mod manifest, publish, deprecate (with a reason and an optional replacement) and revoke it. The token's sub must be app:<the app in the path>; people manage releases in the portal with their project role instead. Trust tiers are not part of this capability (staff metadata only).",-- "inputSchema": "codes.app-manifest@1",-- "permissions": ["codes:releases:write"],-- "resourceConstraints": ["owner", "project"],-- "quotaClass": "codes-release-write",-- "events": ["codes.app.published", "codes.app.deprecated", "codes.app.revoked"],-- "implementedBy": [-- "POST /api/v1/apps/:app/releases",-- "POST /api/v1/releases/:id/publish",-- "POST /api/v1/releases/:id/deprecate",-- "POST /api/v1/releases/:id/revoke"-- ]--}ddeleted file mode 100644@@@ -1,18 +0,0 @@--{-- "id": "codes.release.read",-- "version": "1.0",-- "owner": "codes",-- "status": "active",-- "visibility": "public",-- "description": "Read public release metadata: an app's published, deprecated and revoked releases with their manifests, compatibility ranges and the app's trust tier (metadata only; it never grants anything). Drafts are never returned. Public; no token needed.",-- "outputSchema": "codes.app-manifest@1",-- "permissions": ["codes:releases:read"],-- "resourceConstraints": ["none"],-- "quotaClass": "codes-read",-- "events": [],-- "implementedBy": [-- "GET /api/v1/apps/:app/releases",-- "GET /api/v1/apps/:app/trust",-- "GET /api/v1/releases/:id"-- ]--}ddeleted file mode 100644@@@ -1,17 +0,0 @@--# Contracts proposal: codes.app-manifest@1----`codes/app-manifest.v1.json` is the schema OpenVibe.Codes validates app manifests with. Until--OpenVibe.Contracts publishes it, Codes compiles this file into openvibe-contracts' own validator--(so its `$ref` to `identity/subject-ref.v1.json` resolves to the released schema); once a--contracts release contains `codes.app-manifest`, Codes uses `contracts.validate('codes.app-manifest@1')`--instead, with no code change.----Proposed catalog entry:----```json--{ "id": "codes.app-manifest", "version": "1.0.0", "owner": "codes", "schema": "codes/app-manifest.v1.json",-- "visibility": "public", "compatibility": "backward", "status": "active", "adr": "ADR-014" }--```----Fixtures to add with it: the template `server/domain/manifests.js` produces (valid) and one with an--`internal` capability or a missing `project_id` (invalid).ddeleted file mode 100644@@@ -1,68 +0,0 @@--{-- "$schema": "https://json-schema.org/draft/2020-12/schema",-- "$id": "https://openvibe.network/contracts/codes/app-manifest.v1.json",-- "title": "AppManifest",-- "description": "PROPOSAL (OpenVibe.Codes, roadmap Wave 20): an app release as the platform knows it. The app is a Network developer app (ADR-014): its id is the Network app id, its capabilities are only a request (the grants in Network are the authority), and trust tiers are metadata that never change a grant check. OpenVibe.Codes validates with this schema, loaded into openvibe-contracts' validator, until Contracts publishes it as codes.app-manifest@1.",-- "type": "object",-- "required": ["id", "name", "version", "publisher", "project_id", "capabilities", "compatibility"],-- "additionalProperties": false,-- "properties": {-- "id": {-- "type": "string",-- "pattern": "^app_[0-9A-HJKMNP-TV-Z]{26}$",-- "description": "The Network app id (its client_id). Its principal subject is app:<id>."-- },-- "name": { "type": "string", "minLength": 1, "maxLength": 80 },-- "version": {-- "type": "string",-- "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$",-- "description": "Semantic version of this release."-- },-- "description": { "type": "string", "maxLength": 1000 },-- "publisher": {-- "$ref": "../identity/subject-ref.v1.json",-- "description": "Who publishes the release: the app itself or a member of its project."-- },-- "project_id": {-- "type": "string",-- "pattern": "^prj_[0-9A-HJKMNP-TV-Z]{26}$",-- "description": "The Network project that owns the app. Tenancy in other services is keyed by it (ADR-014)."-- },-- "environment": { "enum": ["sandbox", "production"], "description": "The app's environment. Apps never change environment." },-- "capabilities": {-- "description": "Capability ids the app asks for (3+ segments). Only active public (or partner, by staff allowance) capabilities can ever be granted to apps.",-- "type": "array",-- "maxItems": 64,-- "uniqueItems": true,-- "items": { "type": "string", "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+){2,}$" }-- },-- "events": {-- "type": "object",-- "additionalProperties": false,-- "properties": {-- "consumes": {-- "description": "Event types (or a trailing .* family) the app subscribes to.",-- "type": "array", "maxItems": 64, "uniqueItems": true,-- "items": { "type": "string", "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)*(\\.\\*)?$" }-- }-- }-- },-- "redirect_uris": {-- "description": "Informational copy of the redirect URIs registered in Network (Network's list is the authority).",-- "type": "array", "maxItems": 10, "uniqueItems": true,-- "items": { "type": "string", "maxLength": 500 }-- },-- "homepage": { "type": "string", "pattern": "^https://[^\\s]{1,2000}$" },-- "repository": { "type": "string", "pattern": "^https://[^\\s]{1,2000}$" },-- "license": { "type": "string", "minLength": 1, "maxLength": 100, "description": "SPDX license expression." },-- "compatibility": {-- "type": "object",-- "required": ["contracts"],-- "additionalProperties": false,-- "properties": {-- "contracts": { "type": "string", "minLength": 1, "maxLength": 100, "description": "Semver range of openvibe-contracts releases it was built against." },-- "sdk": { "type": "string", "minLength": 1, "maxLength": 100, "description": "Semver range of openvibe-sdk releases it uses." }-- }-- }-- }--}ddeleted file mode 100644@@@ -1,30 +0,0 @@--{-- "version": "0.1.0",-- "contractRanges": {-- "openvibe-contracts": ">=0.26.0 <1.0.0"-- },-- "id": "codes",-- "name": "OpenVibe.Codes",-- "status": "alpha",-- "repository": "OpenVibers/OpenVibe.Codes",-- "domains": [-- "openvibe.codes"-- ],-- "publicOrigin": "https://openvibe.codes",-- "health": "/api/health",-- "ready": "/api/ready",-- "capabilities": [-- "codes.release.read",-- "codes.release.manage"-- ],-- "eventsProduced": [-- "codes.app.published",-- "codes.app.deprecated",-- "codes.app.revoked"-- ],-- "eventsConsumed": [],-- "namespacesOwned": [-- "codes.*"-- ],-- "notes": "Port 4900, unit openvibe-codes.service, state /var/lib/openvibe-codes. The developer portal (ADR-014): projects, members, apps, credentials, grants and quotas are OWNED BY NETWORK and shown through Network's /api/v1/projects API with the signed-in person's own token; Codes stores none of them and never a secret. Codes owns only manifests, releases (+ append-only release_log), trust tiers (metadata) and playground run logs in its own SQLite. Consumes OpenVibe.Network (SSO with PKCE S256 as OAuth client codes, JWKS, the projects API, the registry, client-credentials) and publishes through OpenVibe.Events (events.event.publish). Playgrounds call Events and Media with the APP's own token only. Docs are generated at boot from the pinned openvibe-contracts and openvibe-sdk."--}@@@ -16,7 +16,7 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.26.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.27.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"},@@@ -1023,9 +1023,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.26.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.26.0",-- "integrity": "sha512-yigHjNzLIyK578V0Weqv2zL6z8PO8ce3HRXPa/61n1XK5Y7pTi28qHMMiO6stboQjTziN1+8wMeByoCF1sGCnw==",++ "version": "0.28.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.27.0",++ "integrity": "sha512-WGPIp/dtQ0M4BaZkqoChK9FV0li9bnR93f4DchTtFE6d+oX+C+oSm9gDKGVHNZ9PbFI2zlsEo3Jbf2858tpchA==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -27,7 +27,7 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.26.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.27.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"},@@@ -70,7 +70,7 @@ CREATE TRIGGER IF NOT EXISTS release_log_append_only_delete BEFORE DELETE ON relCREATE TABLE IF NOT EXISTS trust (app_id TEXT PRIMARY KEY,-- tier TEXT NOT NULL CHECK (tier IN ('untrusted', 'verified', 'trusted', 'platform-maintained')),++ tier TEXT NOT NULL CHECK (tier IN ('unreviewed', 'reviewed', 'first-party')),note TEXT NOT NULL DEFAULT '',set_by TEXT NOT NULL,set_at TEXT NOT NULL@@@ -105,6 +105,38 @@ CREATE INDEX IF NOT EXISTS idx_runs_app ON playground_runs(app_id, at);CREATE INDEX IF NOT EXISTS idx_runs_actor ON playground_runs(actor, at);`;++/**++ * ADR-013 is binding for trust tiers: unreviewed, reviewed, first-party. Databases created before++ * that used the roadmap's four names; map them (untrusted→unreviewed, verified|trusted→reviewed,++ * platform-maintained→first-party) and rebuild `trust` with the new CHECK. Idempotent: a database++ * already on ADR-013 names is left untouched. Tiers stay metadata; no grant depends on them.++ */++const TIER_MAP = { untrusted: 'unreviewed', verified: 'reviewed', trusted: 'reviewed', 'platform-maintained': 'first-party' };++function migrateTrustTiers(db) {++ const row = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'trust'").get();++ const oldCheck = row && /'untrusted'/.test(row.sql);++ const stale = db.prepare(`SELECT COUNT(*) AS n FROM trust_history WHERE from_tier IN (${Object.keys(TIER_MAP).map(() => '?').join(', ')}) OR to_tier IN (${Object.keys(TIER_MAP).map(() => '?').join(', ')})`)++ .get(...Object.keys(TIER_MAP), ...Object.keys(TIER_MAP)).n;++ if (!oldCheck && !stale) return { migrated: false };++ const mapSql = (col) => `CASE ${col} ${Object.entries(TIER_MAP).map(([a, b]) => `WHEN '${a}' THEN '${b}'`).join(' ')} ELSE ${col} END`;++ db.transaction(() => {++ if (oldCheck) {++ db.exec(`CREATE TABLE trust_adr013 (++ app_id TEXT PRIMARY KEY,++ tier TEXT NOT NULL CHECK (tier IN ('unreviewed', 'reviewed', 'first-party')),++ note TEXT NOT NULL DEFAULT '',++ set_by TEXT NOT NULL,++ set_at TEXT NOT NULL++ );++ INSERT INTO trust_adr013 (app_id, tier, note, set_by, set_at) SELECT app_id, ${mapSql('tier')}, note, set_by, set_at FROM trust;++ DROP TABLE trust;++ ALTER TABLE trust_adr013 RENAME TO trust;`);++ }++ db.exec(`UPDATE trust_history SET from_tier = ${mapSql('from_tier')}, to_tier = ${mapSql('to_tier')}`);++ })();++ return { migrated: true };++}++function openStore(dbPath, { now = () => Date.now() } = {}) {if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });const db = new Database(dbPath);@@@ -112,6 +144,7 @@ function openStore(dbPath, { now = () => Date.now() } = {}) {db.pragma('foreign_keys = ON');db.pragma('busy_timeout = 5000');db.exec(SCHEMA);++ migrateTrustTiers(db);return {db,now,@@@ -121,4 +154,4 @@ function openStore(dbPath, { now = () => Date.now() } = {}) {};}--module.exports = { openStore, TABLES };++module.exports = { openStore, TABLES, TIER_MAP, migrateTrustTiers };@@@ -18,6 +18,13 @@ const CONTRACTS_ROOT = path.dirname(require.resolve('openvibe-contracts/package.const SDK_ROOT = path.dirname(require.resolve('openvibe-sdk/package.json'));const contractsPkg = require('openvibe-contracts/package.json');const sdkPkg = require('openvibe-sdk/package.json');++const ownPkg = require('../../package.json');++++/** The git tag this release pins a package to (from package.json's tarball URL), or null. */++function pinnedTag(dep) {++ const m = String((ownPkg.dependencies || {})[dep] || '').match(/refs\/tags\/(v[0-9A-Za-z.-]+)$/);++ return m ? m[1] : null;++}/** Visibilities an app may ever be granted (Network's grantability rule, ADR-014). */const GRANTABLE_VISIBILITIES = new Set(['public', 'partner']);@@@ -159,6 +166,10 @@ function generate({ now = () => Date.now() } = {}) {return {generatedAt: new Date(now()).toISOString(),contractsVersion: contractsPkg.version,++ // The tag may differ from the version the package declares (the contracts tag v0.27.0 ships++ // package.json 0.28.0); pages show both rather than pick one.++ contractsTag: pinnedTag('openvibe-contracts') || `v${contractsPkg.version}`,++ sdkTag: pinnedTag('openvibe-sdk') || `v${sdkPkg.version}`,contractsLicense: contractsPkg.license,sdkVersion: sdkPkg.version,sdkLicense: sdkPkg.license,@@@ -175,4 +186,4 @@ function generate({ now = () => Date.now() } = {}) {};}--module.exports = { generate, fieldRows, GRANTABLE_VISIBILITIES, CONTRACTS_ROOT, SDK_ROOT };++module.exports = { generate, fieldRows, pinnedTag, GRANTABLE_VISIBILITIES, CONTRACTS_ROOT, SDK_ROOT };@@@ -3,47 +3,21 @@/*** Manifest validation with openvibe-contracts.*-- * mod mods.mod-manifest@1 from the pinned openvibe-contracts (contracts.validate)-- * app codes.app-manifest@1 — published by Contracts once released; until then the proposal in-- * docs/contracts-proposal/codes/app-manifest.v1.json, compiled into openvibe-contracts' own-- * Ajv instance so its $refs resolve to the released identity schemas++ * mod mods.mod-manifest@1++ * app codes.app-manifest@1** Schema errors come first; then checks the schema cannot express, against the pinned catalog:* every requested capability must exist and be grantable to apps (active + public/partner), ranges* must parse, consumed events should be produced by someone. With an app context (a release), the* manifest must also describe THAT app: same id, project and environment.*/--const fs = require('fs');--const path = require('path');const contracts = require('openvibe-contracts');--const contractsRegistry = require('openvibe-contracts/lib/registry');const { satisfiesRange } = require('openvibe-sdk/core');const { GRANTABLE_VISIBILITIES } = require('../docs/generate');const contractsVersion = require('openvibe-contracts/package.json').version;const sdkVersion = require('openvibe-sdk/package.json').version;--const APP_SCHEMA_FILE = path.join(__dirname, '..', '..', 'docs', 'contracts-proposal', 'codes', 'app-manifest.v1.json');----let appValidator = null;--let appSchemaSource = null;--function appSchema() {-- if (appValidator) return appValidator;-- try {-- const entry = contracts.resolve('codes.app-manifest@1');-- appSchemaSource = { released: true, id: entry.id, version: entry.version };-- appValidator = (value) => contracts.validate('codes.app-manifest@1', value);-- } catch {-- const schema = JSON.parse(fs.readFileSync(APP_SCHEMA_FILE, 'utf8'));-- const { ajv } = contractsRegistry;-- const fn = ajv.getSchema(schema.$id) || (ajv.addSchema(schema), ajv.getSchema(schema.$id));-- appSchemaSource = { released: false, id: 'codes.app-manifest', version: '1.0.0 (proposal)' };-- appValidator = (value) => {-- const valid = fn(value);-- return { valid, errors: valid ? [] : fn.errors.map((e) => ({ path: e.instancePath || '/', message: e.message })) };-- };-- }-- return appValidator;--}++const SCHEMAS = { app: 'codes.app-manifest@1', mod: 'mods.mod-manifest@1' };function rangeError(range) {try { satisfiesRange('0.0.0', range); return null; } catch (err) { return err.message; }@@@ -80,7 +54,7 @@ function template(kind, { appId, projectId, environment, name, subject } = {}) {/*** validate(kind, manifest, { app, viewerSubject, eventTypes })* app: the Network app view (id, project_id, environment, grants) when validating for a release-- * → { valid, errors: [{ path, message }], warnings: [{ path, message }], schema: { id, version, released } }++ * → { valid, errors: [{ path, message }], warnings: [{ path, message }], schema: { id, version } }*/function validate(kind, manifest, { app = null, viewerSubject = null, eventTypes = null } = {}) {const errors = [];@@@ -89,17 +63,10 @@ function validate(kind, manifest, { app = null, viewerSubject = null, eventTypesif (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {return { valid: false, errors: [{ path: '/', message: 'a manifest is a JSON object' }], warnings, schema: null };}-- let schema;-- if (kind === 'mod') {-- const r = contracts.validate('mods.mod-manifest@1', manifest);-- errors.push(...r.errors);-- const e = contracts.resolve('mods.mod-manifest@1');-- schema = { id: e.id, version: e.version, released: true };-- } else {-- const r = appSchema()(manifest);-- errors.push(...r.errors);-- schema = appSchemaSource;-- }++ const r = contracts.validate(SCHEMAS[kind], manifest);++ errors.push(...r.errors);++ const entry = contracts.resolve(SCHEMAS[kind]);++ const schema = { id: entry.id, version: entry.version };// Requested capabilities: must exist in the pinned catalog and be grantable to apps.const requested = kind === 'mod'@@@ -164,4 +131,4 @@ function parse(text) {try { return { manifest: JSON.parse(s) }; } catch (err) { return { error: `not valid JSON: ${err.message.slice(0, 200)}` }; }}--module.exports = { validate, parse, template, appSchemaInfo: () => (appSchema(), appSchemaSource), APP_SCHEMA_FILE };++module.exports = { validate, parse, template, SCHEMAS };@@@ -3,7 +3,9 @@/*** Playgrounds that cannot exceed the project's grants (ADR-014 acceptance).*-- * events publish one test event needs events.event.publish (audience openvibe.events)++ * events publish one test event needs events.app.publish (audience openvibe.events;++ * type app.<project_key>.…,++ * source app-<app ulid>)* media upload one small file (sandbox) needs media.object.upload (audience openvibe.media,* namespace = project id)*@@@ -33,7 +35,7 @@ const contractsVersion = require('openvibe-contracts/package.json').version;const KINDS = {// `needs`: the capability the APP must hold (owned by Events / Media; Codes guards nothing with it).-- events: { needs: 'events.event.publish', audience: 'openvibe.events', label: 'Events: publish a test event' },++ events: { needs: 'events.app.publish', audience: 'openvibe.events', label: 'Events: publish a test event' },media: { needs: 'media.object.upload', audience: 'openvibe.media', label: 'Media: upload a file to the sandbox' },};@@@ -43,6 +45,14 @@ function scrub(text, secrets) {return s.replace(/ovsec_[A-Za-z0-9_-]+/g, '[redacted]').replace(/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, '[redacted token]');}++/**++ * What events.app.publish allows an app (openvibe-contracts): types app.<project_key>.<name>[…]++ * where project_key is 'p' + the project's ULID in lowercase, and source 'app-' + the app's ULID++ * in lowercase.++ */++const projectKey = (projectId) => `p${String(projectId).replace(/^prj_/, '').toLowerCase()}`;++const appSource = (appId) => `app-${String(appId).replace(/^app_/, '').toLowerCase()}`;++/** Why a capability is not granted, from the pinned catalog. */function grantAdvice(capability) {const cap = contracts.capabilities.get(capability);@@@ -92,11 +102,14 @@ function createPlayground({ store, config, network, keys, fetchImpl, log = consotry { payload = JSON.parse(raw); } catch (err) { return { error: `payload is not JSON: ${err.message.slice(0, 120)}` }; }if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return { error: 'payload must be a JSON object' };}++ const eventType = String(input.event_type || '').trim();++ const prefix = `app.${projectKey(input.projectId)}.`;++ if (!eventType.startsWith(prefix) || eventType.length === prefix.length) return { error: `this app may publish only event types starting with ${prefix} (events.app.publish)` };const envelope = {event_id: contracts.ids.newId('event', store.now()),-- event_type: String(input.event_type || '').trim(),++ event_type: eventType,version: 1,-- source: String(input.source || '').trim(),++ source: appSource(input.appId),actor: { type: 'app', id: input.appId },timestamp: new Date(store.now()).toISOString(),visibility: 'internal',@@@ -160,7 +173,7 @@ function createPlayground({ store, config, network, keys, fetchImpl, log = consolet envelope = null;if (kind === 'events') {-- const e = eventInput({ ...input, appId: app.id });++ const e = eventInput({ ...input, appId: app.id, projectId: app.project_id });if (e.error) return finish({ outcome: 'refused', stage: 'input', code: 'playground.invalid_input', detail: e.error });envelope = e.envelope;} else {@@@ -197,7 +210,7 @@ function createPlayground({ store, config, network, keys, fetchImpl, log = conso}}-- return { run, precheck, runsFor, grantAdvice, KINDS };++ return { run, precheck, runsFor, grantAdvice, KINDS, projectKey, appSource };}--module.exports = { createPlayground, KINDS, grantAdvice, scrub };++module.exports = { createPlayground, KINDS, grantAdvice, scrub, projectKey, appSource };@@@ -1,14 +1,14 @@'use strict';/**-- * Trust tiers (roadmap §15.17): untrusted, verified, trusted, platform-maintained.++ * Trust tiers (ADR-013, binding): unreviewed, reviewed, first-party.*-- * METADATA ONLY. "Actual authority comes from grants. Trust may affect default quotas/review-- * requirements, never bypass policy." Nothing in Codes (or anywhere) reads a tier to allow an-- * action; the tier is shown next to releases and carried in codes.app.* event payloads.-- * Every app starts untrusted; Codes staff change it, with a note, and every change is kept.++ * METADATA ONLY. "A tier changes defaults and discovery, but never the grant check." Nothing in++ * Codes (or anywhere) reads a tier to allow an action; the tier is shown next to releases and++ * carried in codes.app.* event payloads. Every app starts unreviewed; Codes staff change it, with++ * a note, and every change is kept. Older names are migrated at boot (server/db.js).*/--const TIERS = ['untrusted', 'verified', 'trusted', 'platform-maintained'];++const TIERS = ['unreviewed', 'reviewed', 'first-party'];const APP_ID_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;function createTrust({ store }) {@@@ -16,7 +16,7 @@ function createTrust({ store }) {function get(appId) {const r = db.prepare('SELECT * FROM trust WHERE app_id = ?').get(String(appId));-- return r ? { tier: r.tier, note: r.note, set_by: r.set_by, set_at: r.set_at } : { tier: 'untrusted', note: '', set_by: null, set_at: null };++ return r ? { tier: r.tier, note: r.note, set_by: r.set_by, set_at: r.set_at } : { tier: 'unreviewed', note: '', set_by: null, set_at: null };}function set({ appId, tier, note, actor }) {@@@ -5,35 +5,34 @@** GET /docs/versions the pinned versions the docs were generated from* POST /manifests/validate { kind: app|mod, manifest } → { valid, errors, warnings }++ *++ * codes.release.read (public; no token needed — a token that IS presented must hold it):* GET /apps/:app/releases public releases of an app (never drafts) + trust tier-- * GET /apps/:app/trust the app's trust tier (metadata only)++ * GET /apps/:app/trust the app's trust tier (metadata only, ADR-013)* GET /releases/:id one public release with its manifest*-- * An app managing its OWN releases with its own token (proposed capability codes.release.manage,-- * audience openvibe.codes — inert until Contracts releases it and Network can grant it):++ * codes.release.manage — an app managing its OWN releases with its own token:* POST /apps/:app/releases { kind, manifest, notes, publish } → draft (or published)* POST /releases/:id/publish | /deprecate { reason, replacement } | /revoke { reason }*-- * No route accepts a shared loopback key of any kind. Tokens are verified offline against Network's-- * key: issuer, audience openvibe.codes, expiry, sub = app:<the app in the path>, and the capability.++ * Guards are openvibe-contracts requireCapability() (audience openvibe.codes, issuer, expiry, claim++ * shape, the capability; sandbox app tokens accepted, release records carry the environment), then++ * sub = app:<the app in the path>. No route accepts a shared loopback key of any kind.*/const express = require('express');const { asyncRouter } = require('./router');const rateLimit = require('express-rate-limit');--const { http } = require('openvibe-contracts');++const { http, serviceAuth } = require('openvibe-contracts');const manifests = require('../domain/manifests');const { ReleaseError } = require('../domain/releases');--const { verifyJwt } = require('../auth/keys');const APP_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;const REL_RE = /^rel_[0-9A-HJKMNP-TV-Z]{26}$/;--// Proposed in docs/capabilities-proposal/codes.release.manage.json. Checked by hand against the verified--// token's `cap` because openvibe-contracts' requireCapability() refuses capabilities it does not know--// yet; once a contracts release carries it, switch these routes to requireCapability(MANAGE, …).const MANAGE = 'codes.release.manage';++const READ = 'codes.release.read';function createApi(ctx) {-- const { config, docs, releases, trust, keys, store } = ctx;++ const { config, docs, releases, trust, keys } = ctx;const r = asyncRouter();r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });const json = express.json({ limit: '96kb' });@@@ -41,7 +40,7 @@ function createApi(ctx) {const problem = (req, res, status, code, detail, extra) => http.sendProblem(res, status, code, { detail, ctx: req.ov, extra });r.get('/docs/versions', (req, res) => {-- res.json({ contracts: docs.contractsVersion, sdk: docs.sdkVersion, generated_at: docs.generatedAt, contracts_count: docs.contracts.length, capabilities_count: docs.capabilities.length });++ res.json({ contracts: docs.contractsVersion, contracts_tag: docs.contractsTag, sdk: docs.sdkVersion, sdk_tag: docs.sdkTag, generated_at: docs.generatedAt, contracts_count: docs.contracts.length, capabilities_count: docs.capabilities.length });});r.post('/manifests/validate', limiter, json, (req, res) => {@@@ -52,17 +51,28 @@ function createApi(ctx) {res.status(v.valid ? 200 : 422).json({ ...v, contracts_version: docs.contractsVersion });});-- r.get('/apps/:app/releases', (req, res) => {++ // Guards (the Network key is fetched before the synchronous guard runs).++ const guardOpts = { getPublicKey: () => keys.get(), issuer: config.networkIssuer, audience: 'openvibe.codes', acceptSandbox: true };++ const loadKey = (req, res, next) => { keys.ensure().then(() => next(), () => next()); };++ const manageGuard = serviceAuth.requireCapability('codes.release.manage', guardOpts);++ const readGuard = serviceAuth.requireCapability('codes.release.read', guardOpts);++ const bearer = (req) => String(req.headers.authorization || '').startsWith('Bearer ');++ // Public reads: anonymous callers pass; a presented token must be valid and hold codes.release.read.++ const readAccess = [loadKey, (req, res, next) => (bearer(req) ? readGuard(req, res, next) : next())];++ const manageAccess = [loadKey, (req, res, next) => (bearer(req) ? manageGuard(req, res, next)++ : problem(req, res, 401, 'auth.required', 'send Authorization: Bearer <app token for audience openvibe.codes>'))];++++ r.get('/apps/:app/releases', ...readAccess, (req, res) => {if (!APP_RE.test(req.params.app)) return problem(req, res, 404, 'app.not_found', 'not an app id');res.set('Cache-Control', 'public, max-age=60');res.json({ app_id: req.params.app, trust: trust.get(req.params.app), releases: releases.listForApp(req.params.app) });});-- r.get('/apps/:app/trust', (req, res) => {++ r.get('/apps/:app/trust', ...readAccess, (req, res) => {if (!APP_RE.test(req.params.app)) return problem(req, res, 404, 'app.not_found', 'not an app id');res.set('Cache-Control', 'public, max-age=60');res.json({ app_id: req.params.app, ...trust.get(req.params.app), note_on_authority: 'metadata only; grants in OpenVibe.Network are the authority' });});-- r.get('/releases/:id', (req, res) => {++ r.get('/releases/:id', ...readAccess, (req, res) => {const rel = REL_RE.test(req.params.id) ? releases.get(req.params.id) : null;if (!rel || rel.status === 'draft') return problem(req, res, 404, 'release.not_found', 'no such release');res.set('Cache-Control', 'public, max-age=60');@@@ -71,15 +81,10 @@ function createApi(ctx) {});// ── App token: an app manages its own releases ──────────-- async function appPrincipal(req, res, appId) {-- const h = String(req.headers.authorization || '');-- if (!h.startsWith('Bearer ')) { problem(req, res, 401, 'auth.required', 'send Authorization: Bearer <app token for audience openvibe.codes>'); return null; }-- await keys.ensure();-- const v = verifyJwt(h.slice(7).trim(), { publicKey: keys.get(), issuer: config.networkIssuer, audience: 'openvibe.codes', now: store.now() });-- if (!v.ok) { problem(req, res, 401, v.expired ? 'token.expired' : 'token.invalid', v.reason); return null; }-- const c = v.claims;-- if (typeof c.sub !== 'string' || !c.sub.startsWith('app:')) { problem(req, res, 403, 'capability.denied', 'only app tokens manage releases here; people use the portal'); return null; }-- if (!Array.isArray(c.cap) || !c.cap.includes(MANAGE)) { problem(req, res, 403, 'capability.denied', `${MANAGE} not granted`); return null; }++ /** After manageGuard: the verified token's app, project and environment. */++ function appPrincipal(req, res, appId) {++ const c = claimsOf(req);++ if (!c || typeof c.sub !== 'string' || !c.sub.startsWith('app:')) { problem(req, res, 403, 'capability.denied', 'only app tokens manage releases here; people use the portal'); return null; }const self = c.sub.slice(4);if (appId && self !== appId) { problem(req, res, 403, 'release.forbidden', 'an app manages only its own releases'); return null; }return {@@@ -87,15 +92,21 @@ function createApi(ctx) {app: { id: self, project_id: typeof c.project_id === 'string' ? c.project_id : (Array.isArray(c.ns) ? c.ns[0] : null), environment: c.env === 'production' ? 'production' : 'sandbox', revoked_at: null },};}++ // requireCapability already verified this token (signature, issuer, audience, expiry, claims);++ // read the claims it does not copy onto req.principal (project_id, env).++ function claimsOf(req) {++ if (!req.principal || !req.principal.sub) return null;++ try { return JSON.parse(Buffer.from(String(req.headers.authorization).slice(7).trim().split('.')[1], 'base64url').toString('utf8')); } catch { return null; }++ }const fail = (req, res, err) => {if (err instanceof ReleaseError) return problem(req, res, err.status, err.code, err.detail, err.validation ? { validation: err.validation } : undefined);ctx.log.error('[Codes] api error:', err && err.message ? err.message.slice(0, 200) : err);return problem(req, res, 500, 'internal.error', 'Internal error');};-- r.post('/apps/:app/releases', limiter, json, async (req, res) => {++ r.post('/apps/:app/releases', limiter, ...manageAccess, json, (req, res) => {if (!APP_RE.test(req.params.app)) return problem(req, res, 404, 'app.not_found', 'not an app id');-- const p = await appPrincipal(req, res, req.params.app);++ const p = appPrincipal(req, res, req.params.app);if (!p) return;if (!p.app.project_id) return problem(req, res, 403, 'token.invalid_claims', 'the token names no project');const b = req.body || {};@@@ -109,10 +120,10 @@ function createApi(ctx) {});for (const action of ['publish', 'deprecate', 'revoke']) {-- r.post(`/releases/:id/${action}`, limiter, json, async (req, res) => {++ r.post(`/releases/:id/${action}`, limiter, ...manageAccess, json, (req, res) => {const rel = REL_RE.test(req.params.id) ? releases.get(req.params.id) : null;if (!rel) return problem(req, res, 404, 'release.not_found', 'no such release');-- const p = await appPrincipal(req, res, rel.app_id);++ const p = appPrincipal(req, res, rel.app_id);if (!p) return;const b = req.body || {};try {@@@ -134,4 +145,4 @@ function createApi(ctx) {return r;}--module.exports = { createApi, MANAGE };++module.exports = { createApi, MANAGE, READ };@@@ -23,9 +23,10 @@ function createDocsRoutes(ctx) {const r = asyncRouter();const PUBLIC_CACHE = 'public, max-age=300';++ const tagNote = (tag, version) => (tag !== `v${version}` ? html` (tag ${tag})` : '');const versions = () => html`<p class="versions">Generated at ${time(docs.generatedAt)} from--<a href="https://github.com/OpenVibers/OpenVibe.Contracts/tree/v${docs.contractsVersion}"><code>openvibe-contracts v${docs.contractsVersion}</code></a> and--<a href="https://github.com/OpenVibers/OpenVibe.SDK/tree/v${docs.sdkVersion}"><code>openvibe-sdk v${docs.sdkVersion}</code></a>.</p>`;++<a href="https://github.com/OpenVibers/OpenVibe.Contracts/tree/${docs.contractsTag}"><code>openvibe-contracts v${docs.contractsVersion}</code></a>${tagNote(docs.contractsTag, docs.contractsVersion)} and++<a href="https://github.com/OpenVibers/OpenVibe.SDK/tree/${docs.sdkTag}"><code>openvibe-sdk v${docs.sdkVersion}</code></a>${tagNote(docs.sdkTag, docs.sdkVersion)}.</p>`;const page = (req, res, o, status = 200) => send(res, status, { index: true, cache: PUBLIC_CACHE, viewer: req.viewer, config, path: req.originalUrl, ...o });r.get('/', (req, res) => {@@@ -45,8 +46,8 @@ function createDocsRoutes(ctx) {<li><a href="/policy/rfc"><strong>Decisions</strong></a><span>${docs.adrs.length} architecture decision records</span></li></ul><h2>Install</h2>--<pre><code>npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v${docs.sdkVersion}--npm install https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v${docs.contractsVersion}</code></pre>++<pre><code>npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/${docs.sdkTag}++npm install https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/${docs.contractsTag}</code></pre><p>The SDK is ${docs.sdkLicense}; the contracts are ${docs.contractsLicense}. The SDK release is tested against contracts <code>${docs.sdkContractsRange || 'n/a'}</code>.</p>`,});});@@@ -147,7 +147,7 @@ ${Array.isArray(status.notYet) ? html`<h3>Not yet</h3><ul>${status.notYet.map((wcrumbs: [{ label: 'Apps' }, { label: req.params.app }],body: html`<h1>${list[0] ? list[0].name : 'App'} <small><code>${req.params.app}</code></small></h1><p>Trust tier: ${badge(t.tier)} ${t.note ? html`<span class="muted">— ${t.note}</span>` : ''}</p>--<p class="muted small">Trust tiers are metadata. They never grant anything: what an app may do comes only from its grants in OpenVibe.Network.</p>++<p class="muted small">Trust tiers (<a href="/docs/adr/ADR-013">ADR-013</a>: unreviewed, reviewed, first-party) are metadata. They change defaults and discovery, never a grant check: what an app may do comes only from its grants in OpenVibe.Network.</p><h2>Releases</h2>${table(['Version', 'Kind', 'Environment', 'Status', 'Published', 'Compatibility'], list.map((x) => [html`<a href="/releases/${x.id}">${x.version}</a>`, x.kind, x.environment, statusBadge(x.status), time(x.published_at),@@@ -204,7 +204,7 @@ ${signedIn ? releaseActions(req, rel) : ''}page(req, res, {title: 'Staff: trust tiers', crumbs: [{ label: 'Staff' }],body: html`<h1>Trust tiers</h1>${req.query.done ? notice('Saved.', 'ok') : ''}--<p>Metadata only: a tier never grants, allows or bypasses anything. Grants and allowances are set in Network.</p>++<p>Tiers follow <a href="/docs/adr/ADR-013">ADR-013</a>: <code>unreviewed</code> (the default), <code>reviewed</code>, <code>first-party</code>. Metadata only: a tier never grants, allows or bypasses anything. Grants and allowances are set in Network.</p><form method="post" action="/staff/trust" class="stack">${csrfField(csrfToken(config, req.viewer))}<label>App id <input name="app_id" required pattern="app_[0-9A-HJKMNP-TV-Z]{26}"></label><label>Tier <select name="tier">${trust.TIERS.map((t) => html`<option>${t}</option>`)}</select></label>@@@ -461,10 +461,9 @@ ${manage ? html`<h2>Revoke this app</h2><form method="post" action="${base}/revo<p>Calls here run <strong>as the app</strong>, with its own token, against the real services: the playground can do exactly what this app is granted and nothing more. Codes' own credentials are never used. Sandbox apps only.</p>${result ? runResult(result) : ''}<h2>${playground.KINDS.events.label}</h2>--<p class="muted small">Needs <code>events.event.publish</code> (audience <code>openvibe.events</code>). Endpoint: <code>${config.playground.eventsUrl}</code>.</p>++<p class="muted small">Needs <code>events.app.publish</code> (audience <code>openvibe.events</code>). This app may publish types starting with <code>app.${playground.projectKey(project.id)}.</code> with source <code>${playground.appSource(app.id)}</code>; sandbox events reach sandbox subscriptions only. Endpoint: <code>${config.playground.eventsUrl}</code>.</p>${pre(events)}${events.ok ? html`<form method="post" action="${base}/playground/events" class="stack">${csrfField(c)}--<label>Event type <input name="event_type" required value="${values.event_type || ''}" placeholder="myapp.thing.happened"></label>--<label>Source <input name="source" required value="${values.source || ''}" placeholder="myapp"></label>++<label>Event type <input name="event_type" required value="${values.event_type || `app.${playground.projectKey(project.id)}.test.ping`}"></label><label>Subject type <input name="subject_type" value="${values.subject_type || 'test'}"></label><label>Subject id <input name="subject_id" value="${values.subject_id || 'test-1'}"></label><label>Payload (JSON object) <textarea name="payload" rows="4" spellcheck="false">${values.payload || '{}'}</textarea></label>@@@ -497,10 +496,10 @@ ${r.result ? html`<pre><code>${JSON.stringify(r.result, null, 2)}</code></pre>`const out = await playground.run({actor: `user:${req.viewer.subject}`, app: got.data, kind: 'events',credential: { type: b.credential_type === 'access_token' ? 'access_token' : 'client_secret', value: typeof b.credential === 'string' ? b.credential.trim() : '' },-- input: { event_type: b.event_type, source: b.source, subject_type: b.subject_type, subject_id: b.subject_id, payload: b.payload },++ input: { event_type: b.event_type, subject_type: b.subject_type, subject_id: b.subject_id, payload: b.payload },});// The credential is NOT passed back: the form comes back with it empty.-- return playgroundPage(req, res, { result: out, values: { event_type: b.event_type, source: b.source, subject_type: b.subject_type, subject_id: b.subject_id, payload: b.payload }, status: statusFor(out) });++ return playgroundPage(req, res, { result: out, values: { event_type: b.event_type, subject_type: b.subject_type, subject_id: b.subject_id, payload: b.payload }, status: statusFor(out) });});r.post('/:project/apps/:app/playground/media', idParams, async (req, res) => {@@@ -134,14 +134,12 @@ ${v.parsed && v.parsed.note ? html`<dt>Body</dt><dd>${v.parsed.note}</dd>` : ''}});// ── Manifest validator ──────────────────────────────────-- const info = manifests.appSchemaInfo();const validatorPage = (req, res, { status = 200, kind = 'app', text = null, result = null, parseError = null } = {}) => send(res, status, {viewer: req.viewer, config, path: '/manifests/validate', title: 'Manifest validator', index: true,crumbs: [{ label: 'Manifests' }],body: html`<h1>Manifest validator</h1><p>Checks an app manifest or a mod manifest with <code>openvibe-contracts v${docs.contractsVersion}</code>: the JSON Schema first, then that every requested capability exists and can be granted to apps, that version ranges parse, and that consumed events are produced by someone. Nothing is stored. To publish a release, open your app's page.</p>--<ul class="plain small muted"><li>mod: <a href="/docs/contracts/mods.mod-manifest"><code>mods.mod-manifest@1</code></a> (released)</li>--<li>app: <code>${info.id}@1</code> ${info.released ? '(released)' : html`— a proposal Codes validates with until Contracts releases it (<a href="https://github.com/OpenVibers/OpenVibe.Codes/blob/main/docs/contracts-proposal/codes/app-manifest.v1.json">schema</a>)`}</li></ul>++<ul class="plain small muted"><li>app: <a href="/docs/contracts/codes.app-manifest"><code>codes.app-manifest@1</code></a></li><li>mod: <a href="/docs/contracts/mods.mod-manifest"><code>mods.mod-manifest@1</code></a></li></ul><form method="post" action="/manifests/validate" class="stack"><label>Kind <select name="kind"><option value="app"${kind === 'app' ? raw(' selected') : ''}>app</option><option value="mod"${kind === 'mod' ? raw(' selected') : ''}>mod</option></select></label><label>Manifest (JSON) <textarea name="manifest" rows="18" spellcheck="false">${text != null ? text : JSON.stringify(manifests.template(kind), null, 2)}</textarea></label>@@@ -1,50 +1,49 @@'use strict';/**-- * The proposals Codes ships validate against the released contract schemas, and they describe what-- * the code actually does: the events the outbox can produce and the capability the API checks.++ * The released contracts (openvibe-contracts tag v0.27.0) describe what the code does: the codes++ * service manifest's events are exactly what the outbox can produce, its capabilities are the ones++ * the API guards with requireCapability, and the release API answers with the contracts' problems.*/const assert = require('assert');const fs = require('fs');const path = require('path');const contracts = require('openvibe-contracts');const { EVENT_TYPES } = require('../server/events/outbox');--const { MANAGE } = require('../server/http/api');++const { MANAGE, READ } = require('../server/http/api');const { check, done } = require('./helpers/boot');--const DOCS = path.join(__dirname, '..', 'docs');--(async () => {-- const caps = fs.readdirSync(path.join(DOCS, 'capabilities-proposal')).map((f) => JSON.parse(fs.readFileSync(path.join(DOCS, 'capabilities-proposal', f), 'utf8')));-- const service = JSON.parse(fs.readFileSync(path.join(DOCS, 'service-manifest-proposal.json'), 'utf8'));++ const manifest = contracts.services.get('codes');-- await check('capability proposals validate as capabilities.capability@1 and are owned by codes', async () => {-- assert.ok(caps.length >= 2);-- for (const c of caps) {-- const v = contracts.validate('capabilities.capability@1', c);-- assert.ok(v.valid, `${c.id}: ${JSON.stringify(v.errors)}`);-- assert.strictEqual(c.owner, 'codes');-- assert.ok(c.id.startsWith('codes.'));-- assert.ok(!contracts.capabilities.get(c.id), `${c.id} is already released; drop the proposal`);-- }++ await check('the codes service manifest is released as alpha, with this code\'s events', async () => {++ assert.ok(manifest);++ assert.strictEqual(manifest.status, 'alpha');++ assert.deepStrictEqual(manifest.domains, ['openvibe.codes']);++ assert.deepStrictEqual([...manifest.eventsProduced].sort(), [...EVENT_TYPES].sort());++ for (const e of manifest.eventsProduced) assert.strictEqual(e.split('.').length, 3, `${e} has three segments`);++ const range = manifest.contractRanges['openvibe-contracts'];++ assert.ok(require('openvibe-sdk/core').satisfiesRange(require('openvibe-contracts/package.json').version, range));});-- await check('the service manifest proposal validates and matches the code', async () => {-- const v = contracts.validate('registry.service-manifest@1', service);-- assert.ok(v.valid, JSON.stringify(v.errors));-- assert.strictEqual(service.id, 'codes');-- assert.deepStrictEqual([...service.eventsProduced].sort(), [...EVENT_TYPES].sort());-- assert.deepStrictEqual([...service.capabilities].sort(), caps.map((c) => c.id).sort());-- assert.ok(service.capabilities.includes(MANAGE));-- for (const e of service.eventsProduced) assert.strictEqual(e.split('.').length, 3, `${e} has three segments`);-- const range = service.contractRanges['openvibe-contracts'];-- assert.ok(require('openvibe-sdk/core').satisfiesRange(require('openvibe-contracts/package.json').version, range));++ await check('its capabilities exist, are owned by codes, and are the ones the API guards', async () => {++ assert.deepStrictEqual([...manifest.capabilities].sort(), [MANAGE, READ].sort());++ for (const id of manifest.capabilities) {++ const c = contracts.capabilities.get(id);++ assert.ok(c, id);++ assert.strictEqual(c.owner, 'codes');++ assert.strictEqual(c.status, 'active');++ }++ const api = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'api.js'), 'utf8');++ assert.match(api, /requireCapability\('codes\.release\.manage'/);++ assert.match(api, /requireCapability\('codes\.release\.read'/);});-- await check('the app manifest proposal is a valid JSON Schema the contracts validator compiles', async () => {-- const schema = JSON.parse(fs.readFileSync(path.join(DOCS, 'contracts-proposal', 'codes', 'app-manifest.v1.json'), 'utf8'));-- assert.strictEqual(schema.$id, 'https://openvibe.network/contracts/codes/app-manifest.v1.json');-- const info = require('../server/domain/manifests').appSchemaInfo();-- assert.strictEqual(info.id, 'codes.app-manifest');++ await check('no proposal left behind: everything proposed is released', async () => {++ const docs = path.join(__dirname, '..', 'docs');++ assert.ok(!fs.existsSync(path.join(docs, 'capabilities-proposal')));++ assert.ok(!fs.existsSync(path.join(docs, 'service-manifest-proposal.json')));++ assert.ok(!fs.existsSync(path.join(docs, 'contracts-proposal')));++ assert.ok(contracts.resolve('codes.app-manifest@1'));});done();@@@ -17,24 +17,29 @@ const sdkPkg = require('openvibe-sdk/package.json');(async () => {const t = await boot();-- await check('package.json pins exactly the installed contracts, SDK and shared releases', async () => {-- const tag = (dep) => (pkg.dependencies[dep].match(/refs\/tags\/v([0-9.]+)$/) || [])[1];-- assert.strictEqual(tag('openvibe-contracts'), contractsVersion);-- assert.strictEqual(tag('openvibe-sdk'), sdkPkg.version);-- assert.strictEqual(tag('openvibe-shared'), require('openvibe-shared/package.json').version);-- assert.strictEqual(contractsVersion, '0.26.0');++ await check('package.json pins the contracts, SDK and shared tags that are installed', async () => {++ const tag = (dep) => (pkg.dependencies[dep].match(/refs\/tags\/(v[0-9.]+)$/) || [])[1];++ assert.strictEqual(tag('openvibe-contracts'), 'v0.27.0');++ assert.strictEqual(tag('openvibe-sdk'), `v${sdkPkg.version}`);++ assert.strictEqual(tag('openvibe-shared'), `v${require('openvibe-shared/package.json').version}`);assert.strictEqual(sdkPkg.version, '0.2.2');++ // The contracts tag v0.27.0 carries the released codes manifest and codes.app-manifest@1.++ assert.ok(contracts.services.get('codes') && contracts.services.get('codes').status === 'alpha');++ assert.ok(contracts.resolve('codes.app-manifest@1'));});await check('every docs page states the versions it was generated from', async () => {for (const p of ['/docs', '/docs/contracts', '/docs/capabilities', '/docs/events', '/docs/sdk', '/docs/contracts/errors.problem', '/docs/sdk/core']) {const r = await t.get(p);assert.strictEqual(r.status, 200, p);-- assert.ok(r.text.includes(`openvibe-contracts v${contractsVersion}`), `${p} names the contracts version`);++ assert.ok(r.text.includes(`openvibe-contracts v${contractsVersion}`), `${p} names the installed contracts version`);++ assert.ok(r.text.includes('OpenVibe.Contracts/tree/v0.27.0'), `${p} links the pinned tag`);++ if (contractsVersion !== '0.27.0') assert.ok(r.text.includes('(tag v0.27.0)'), `${p} says the tag differs from the package version`);assert.ok(r.text.includes(`openvibe-sdk v${sdkPkg.version}`), `${p} names the SDK version`);}const v = (await t.get('/api/v1/docs/versions')).json();assert.strictEqual(v.contracts, contractsVersion);++ assert.strictEqual(v.contracts_tag, 'v0.27.0');assert.strictEqual(v.sdk, sdkPkg.version);});@@@ -306,9 +306,20 @@ async function startEvents(network) {if (req.url === '/api/v1/events' && req.method === 'POST') {const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.events', acceptSandbox: true });if (!v.ok) return json(401, { type: 'x', title: 'Unauthorized', status: 401, code: v.code, detail: v.reason });-- if (!v.claims.cap.includes('events.event.publish')) return json(403, { type: 'x', title: 'Forbidden', status: 403, code: 'capability.denied', detail: 'events.event.publish not granted' });++ const isApp = String(v.claims.sub).startsWith('app:');++ const needed = isApp ? 'events.app.publish' : 'events.event.publish';++ if (!v.claims.cap.includes(needed)) return json(403, { type: 'x', title: 'Forbidden', status: 403, code: 'capability.denied', detail: `${needed} not granted` });const body = JSON.parse(raw.toString('utf8'));const events = body.events || [body];++ if (isApp) {++ // events.app.publish: app.<project_key>.* types, source app-<lowercase app ULID>.++ const key = `p${v.claims.project_id.replace(/^prj_/, '').toLowerCase()}`;++ const src = `app-${v.claims.sub.replace(/^app:app_/, '').toLowerCase()}`;++ for (const e of events) {++ if (!e.event_type.startsWith(`app.${key}.`)) return json(403, { type: 'x', title: 'Forbidden', status: 403, code: 'events.type_not_allowed', detail: `apps publish app.${key}.* only` });++ if (e.source !== src) return json(403, { type: 'x', title: 'Forbidden', status: 403, code: 'events.source_mismatch', detail: `source must be ${src}` });++ }++ }const results = events.map((e) => { published.push({ event: e, sub: v.claims.sub }); return { event_id: e.event_id, seq: ++seq, duplicate: false }; });return json(201, body.events ? { results } : results[0]);}@@@ -72,12 +72,14 @@ const PRJ = 'prj_01JABCDEFGHJKMNPQRSTVWXYZ0';assert.ok(ungranted.warnings.some((w) => /not granted to this app yet/.test(w.message)));});-- await check('the app manifest schema is loaded into openvibe-contracts\' validator (proposal until released)', async () => {-- const info = manifests.appSchemaInfo();-- assert.strictEqual(info.id, 'codes.app-manifest');-- assert.strictEqual(info.released, false);++ await check('app manifests validate as the released codes.app-manifest@1, fixtures included', async () => {++ assert.strictEqual(manifests.SCHEMAS.app, 'codes.app-manifest@1');++ const dir = path.join(path.dirname(require.resolve('openvibe-contracts/package.json')), 'fixtures', 'codes.app-manifest');++ for (const f of fs.readdirSync(path.join(dir, 'valid'))) assert.deepStrictEqual(manifests.validate('app', read(path.join(dir, 'valid', f))).errors.filter((e) => !/not in openvibe-contracts|never granted/.test(e.message)), [], f);++ for (const f of fs.readdirSync(path.join(dir, 'invalid'))) assert.strictEqual(manifests.validate('app', read(path.join(dir, 'invalid', f))).valid, false, f);const v = manifests.validate('app', { ...manifests.template('app'), publisher: { type: 'nobody', id: 'x' } });assert.ok(v.errors.some((e) => e.path.startsWith('/publisher')), 'the $ref to identity.subject-ref resolves');++ assert.strictEqual(v.schema.id, 'codes.app-manifest');});const t = await boot();@@@ -27,7 +27,7 @@ function files(dir) {await check('no mention in server/, public/, deploy/, docs/*.json, .env.example, package.json or CI', async () => {const targets = [...files(path.join(ROOT, 'server')), ...files(path.join(ROOT, 'public')), ...files(path.join(ROOT, 'deploy')),-- ...files(path.join(ROOT, 'docs')).filter((f) => f.endsWith('.json')),++ ...(fs.existsSync(path.join(ROOT, 'docs')) ? files(path.join(ROOT, 'docs')).filter((f) => f.endsWith('.json')) : []),path.join(ROOT, '.env.example'), path.join(ROOT, 'package.json'), path.join(ROOT, '.github', 'workflows', 'ci.yml'),].filter((f) => fs.existsSync(f));assert.ok(targets.length > 20);@@@ -7,7 +7,7 @@* - with the grant: the app's own token (from its secret, or pasted) is used through the SDK* - a pasted token for another app, another audience or without the capability is refused* - the Network refusing the token request surfaces as Network said it-- * - events.event.publish is internal in contracts v0.26.0: the page says it can never be granted++ * - the Events playground uses events.app.publish: app.<project_key>.* types, source app-<ulid>*/const assert = require('assert');const { boot, check, done } = require('./helpers/boot');@@@ -18,6 +18,7 @@ const { boot, check, done } = require('./helpers/boot');const projectId = await t.project(owner, 'Play');const app = await t.app(owner, projectId);const base = `/projects/${projectId}/apps/${app.id}`;++ const key = `p${projectId.replace(/^prj_/, '').toLowerCase()}`;const media = (fields, file = { name: 'a.txt', content: 'hello' }) => t.get(`${base}/playground/media`, { as: owner, multipart: { fields, file } });const outside = () => ({token: t.network.tokenRequests.filter((x) => x.client_id === app.id).length,@@@ -37,11 +38,12 @@ const { boot, check, done } = require('./helpers/boot');assert.ok(!page.text.includes(`action="${base}/playground/media"`), 'no upload form without the grant');});-- await check('the events playground explains that events.event.publish is never granted to apps', async () => {++ await check('without events.app.publish: refused, the grant named, nothing requested or called', async () => {const before = outside();-- const r = await t.get(`${base}/playground/events`, { as: owner, form: { event_type: 'play.thing.done', source: 'play', credential_type: 'client_secret', credential: app.secret } });++ const r = await t.get(`${base}/playground/events`, { as: owner, form: { event_type: `app.${key}.test.ping`, credential_type: 'client_secret', credential: app.secret } });assert.strictEqual(r.status, 403);-- assert.match(r.text, /events\.event\.publish is internal in openvibe-contracts 0\.26\.0/);++ assert.match(r.text, /does not hold events\.app\.publish/);++ assert.match(r.text, /Request events\.app\.publish on the app page/);assert.deepStrictEqual(outside(), before);});@@@ -146,23 +148,27 @@ const { boot, check, done } = require('./helpers/boot');assert.match(r.text, /project\.not_found/);});-- await check('the events path publishes through the SDK when an app does hold the grant (future public capability)', async () => {-- // No app can hold events.event.publish today (internal); exercise the path with an app view-- // as Network would report it once a publish capability is grantable.-- const view = { id: app.id, project_id: projectId, environment: 'sandbox', grants: ['events.event.publish'], revoked_at: null };-- const token = t.network.mintApp(app.id, 'openvibe.events', ['events.event.publish']);-- const out = await t.ctx.playground.run({-- actor: `user:${owner.subject}`, app: view, kind: 'events', credential: { type: 'access_token', value: token },-- input: { event_type: 'play.thing.done', source: 'play', payload: '{"n":1}' },-- });-- assert.strictEqual(out.outcome, 'ok', JSON.stringify(out));-- assert.strictEqual(t.events.published.length, 1);-- assert.strictEqual(t.events.published[0].sub, `app:${app.id}`);-- assert.strictEqual(t.events.published[0].event.event_type, 'play.thing.done');-- const bad = await t.ctx.playground.run({ actor: `user:${owner.subject}`, app: view, kind: 'events', credential: { type: 'access_token', value: token }, input: { event_type: 'two.segments', source: 'play' } });-- assert.strictEqual(bad.outcome, 'refused');-- assert.strictEqual(bad.stage, 'input');++ await check('with events.app.publish: publishes as the app, with its source and project-scoped type', async () => {++ await t.grant(owner, projectId, app.id, 'events.app.publish');++ const r = await t.get(`${base}/playground/events`, { as: owner, form: { event_type: `app.${key}.test.ping`, payload: '{"n":1}', credential_type: 'client_secret', credential: app.secret } });++ assert.strictEqual(r.status, 200, r.text.slice(0, 600));++ assert.match(r.text, /Done\./);assert.strictEqual(t.events.published.length, 1);++ const e = t.events.published[0];++ assert.strictEqual(e.sub, `app:${app.id}`);++ assert.strictEqual(e.event.source, `app-${app.id.replace(/^app_/, '').toLowerCase()}`);++ assert.deepStrictEqual(e.event.actor, { type: 'app', id: app.id });++ const tok = t.network.tokenRequests.filter((x) => x.client_id === app.id).pop();++ assert.strictEqual(tok.audience, 'openvibe.events');++ assert.strictEqual(tok.scope, 'events.app.publish');++ });++++ await check('an event type outside the project\'s app.<project_key>.* space is refused locally', async () => {++ const before = outside();++ const r = await t.get(`${base}/playground/events`, { as: owner, form: { event_type: 'network.app.created', credential_type: 'client_secret', credential: app.secret } });++ assert.strictEqual(r.status, 422);++ assert.match(r.text, new RegExp(`starting with app\\.${key}\\.`));++ assert.deepStrictEqual(outside(), before);});await t.close();@@@ -76,7 +76,7 @@ const { boot, check, done } = require('./helpers/boot');assert.deepStrictEqual(e.actor, { type: 'user', id: owner.subject });assert.strictEqual(e.payload.release_id, rel1);assert.strictEqual(e.payload.project_id, projectId);-- assert.strictEqual(e.payload.trust_tier, 'untrusted');++ assert.strictEqual(e.payload.trust_tier, 'unreviewed');assert.strictEqual(contracts.validate('events.event-envelope@1', e).valid, true);const pub = await t.get(`/api/v1/apps/${app.id}/releases`);assert.strictEqual(pub.json().releases[0].status, 'published');@@@ -123,14 +123,18 @@ const { boot, check, done } = require('./helpers/boot');});await check('trust tiers: staff only, noted, recorded, and they change no permission', async () => {-- const denied = await t.get('/staff/trust', { as: owner, form: { app_id: app.id, tier: 'trusted', note: 'me' } });++ const denied = await t.get('/staff/trust', { as: owner, form: { app_id: app.id, tier: 'reviewed', note: 'me' } });assert.strictEqual(denied.status, 403);-- const ok = await t.get('/staff/trust', { as: staff, form: { app_id: app.id, tier: 'verified', note: 'Reviewed the source' } });++ const old = await t.get('/staff/trust', { as: staff, form: { app_id: app.id, tier: 'verified', note: 'pre-ADR-013 name' } });++ assert.strictEqual(old.status, 422, 'only ADR-013 tiers are accepted');++ const page = await t.get('/staff', { as: staff });++ assert.deepStrictEqual([...page.text.matchAll(/<option>([^<]+)<\/option>/g)].map((m) => m[1]), ['unreviewed', 'reviewed', 'first-party']);++ const ok = await t.get('/staff/trust', { as: staff, form: { app_id: app.id, tier: 'reviewed', note: 'Reviewed the source' } });assert.strictEqual(ok.status, 303);const trust = (await t.get(`/api/v1/apps/${app.id}/trust`)).json();-- assert.strictEqual(trust.tier, 'verified');++ assert.strictEqual(trust.tier, 'reviewed');assert.match(trust.note_on_authority, /metadata only/);-- // A verified app is still refused by the playground without the grant.++ // A reviewed app is still refused by the playground without the grant.const r = await t.get(`${base}/playground/media`, { as: owner, multipart: { fields: { credential_type: 'client_secret', credential: app.secret }, file: { name: 'x', content: 'x' } } });assert.strictEqual(r.status, 403);// And a viewer still cannot publish.@@@ -170,12 +174,25 @@ const { boot, check, done } = require('./helpers/boot');const other = await t.app(owner, projectId, { name: 'Other' });const otherTok = t.network.mintApp(other.id, 'openvibe.codes', ['codes.release.manage']);const r4 = await t.get(`/api/v1/apps/${app.id}/releases`, { headers: { authorization: `Bearer ${otherTok}` }, json: body });++ assert.strictEqual(r4.json().code, 'release.forbidden');assert.strictEqual(r4.status, 403);assert.strictEqual(r4.headers.get('content-type'), 'application/problem+json');const userTok = t.network.userToken(owner);assert.strictEqual((await t.get(`/api/v1/apps/${app.id}/releases`, { headers: { authorization: `Bearer ${userTok}` }, json: body })).status, 401);});++ await check('public reads need no token; a presented token must hold codes.release.read', async () => {++ assert.strictEqual((await t.get(`/api/v1/apps/${app.id}/releases`)).status, 200);++ const withRead = t.network.mintApp(app.id, 'openvibe.codes', ['codes.release.read']);++ assert.strictEqual((await t.get(`/api/v1/apps/${app.id}/releases`, { headers: { authorization: `Bearer ${withRead}` } })).status, 200);++ const without = t.network.mintApp(app.id, 'openvibe.codes', ['codes.release.manage']);++ const r = await t.get(`/api/v1/apps/${app.id}/trust`, { headers: { authorization: `Bearer ${without}` } });++ assert.strictEqual(r.status, 403);++ assert.strictEqual(r.json().code, 'capability.denied');++ const forged = await t.get(`/api/v1/apps/${app.id}/releases`, { headers: { authorization: 'Bearer not.a.jwt' } });++ assert.strictEqual(forged.status, 401);++ });++await t.close();done();})();nnew file mode 100644@@@ -0,0 +1,68 @@++'use strict';++/**++ * ADR-013 trust tiers (unreviewed, reviewed, first-party): databases written with the roadmap's++ * earlier names are migrated at boot — untrusted→unreviewed, verified|trusted→reviewed,++ * platform-maintained→first-party — including the history, and the CHECK constraint is rebuilt.++ * Running it again changes nothing. Tiers stay metadata.++ */++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const Database = require('better-sqlite3');++const { openStore } = require('../server/db');++const { createTrust } = require('../server/domain/trust');++const { check, done } = require('./helpers/boot');++++const A = (n) => `app_01JABCDEFGHJKMNPQRSTVWXYZ${n}`;++++(async () => {++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-codes-trust-'));++ const file = path.join(dir, 'codes.db');++ // A database as the first Codes build created it.++ const old = new Database(file);++ old.exec(`CREATE TABLE trust (++ app_id TEXT PRIMARY KEY,++ tier TEXT NOT NULL CHECK (tier IN ('untrusted', 'verified', 'trusted', 'platform-maintained')),++ note TEXT NOT NULL DEFAULT '', set_by TEXT NOT NULL, set_at TEXT NOT NULL);++ CREATE TABLE trust_history (id INTEGER PRIMARY KEY AUTOINCREMENT, app_id TEXT NOT NULL, from_tier TEXT NOT NULL, to_tier TEXT NOT NULL, note TEXT NOT NULL DEFAULT '', set_by TEXT NOT NULL, set_at TEXT NOT NULL);`);++ const ins = old.prepare('INSERT INTO trust VALUES (?, ?, ?, ?, ?)');++ ins.run(A(0), 'untrusted', 'n0', 'user:x', '2026-09-22T00:00:00Z');++ ins.run(A(1), 'verified', 'n1', 'user:x', '2026-09-22T00:00:00Z');++ ins.run(A(2), 'trusted', 'n2', 'user:x', '2026-09-22T00:00:00Z');++ ins.run(A(3), 'platform-maintained', 'n3', 'user:x', '2026-09-22T00:00:00Z');++ old.prepare('INSERT INTO trust_history (app_id, from_tier, to_tier, note, set_by, set_at) VALUES (?, ?, ?, ?, ?, ?)').run(A(3), 'trusted', 'platform-maintained', 'n', 'user:x', '2026-09-22T00:00:00Z');++ old.close();++++ await check('old tier names are mapped and the CHECK only allows ADR-013 tiers', async () => {++ const store = openStore(file);++ const tiers = store.db.prepare('SELECT app_id, tier, note FROM trust ORDER BY app_id').all();++ assert.deepStrictEqual(tiers.map((r) => r.tier), ['unreviewed', 'reviewed', 'reviewed', 'first-party']);++ assert.deepStrictEqual(tiers.map((r) => r.note), ['n0', 'n1', 'n2', 'n3'], 'notes kept');++ assert.deepStrictEqual(store.db.prepare('SELECT from_tier, to_tier FROM trust_history').get(), { from_tier: 'reviewed', to_tier: 'first-party' });++ assert.throws(() => store.db.prepare("UPDATE trust SET tier = 'verified' WHERE app_id = ?").run(A(0)), /CHECK/);++ store.close();++ });++++ await check('running it again changes nothing', async () => {++ const store = openStore(file);++ const sql = store.db.prepare("SELECT sql FROM sqlite_master WHERE name = 'trust'").get().sql;++ assert.ok(!/untrusted/.test(sql));++ assert.strictEqual(store.db.prepare('SELECT COUNT(*) AS n FROM trust').get().n, 4);++ const trust = createTrust({ store });++ assert.strictEqual(trust.get(A(0)).tier, 'unreviewed');++ assert.strictEqual(trust.get('app_01JZZZZZZZZZZZZZZZZZZZZZZZ').tier, 'unreviewed', 'the default is unreviewed');++ assert.deepStrictEqual(trust.TIERS, ['unreviewed', 'reviewed', 'first-party']);++ assert.throws(() => trust.set({ appId: A(0), tier: 'trusted', note: 'x', actor: { staff: true, label: 'user:x' } }), /tier is one of/);++ store.close();++ });++++ await check('a fresh database is created on ADR-013 tiers', async () => {++ const store = openStore(path.join(dir, 'fresh.db'));++ assert.match(store.db.prepare("SELECT sql FROM sqlite_master WHERE name = 'trust'").get().sql, /'unreviewed', 'reviewed', 'first-party'/);++ store.close();++ });++++ fs.rmSync(dir, { recursive: true, force: true });++ done();++})();
@@ -15,8 +15,6 @@ jobs:
@@ -15,8 +15,6 @@ jobs:
- run: npm ci --no-audit --no-fund
- run: npm ci --no-audit --no-fund
- run: for f in $(find server public/js test -name '*.js'); do node --check "$f"; done
- run: for f in $(find server public/js test -name '*.js'); do node --check "$f"; done
- run: npm test
- run: npm test
- # Codes' service manifest, its capabilities and codes.app-manifest@1 are proposals (docs/) until
- # the lead releases them in openvibe-contracts; until then the check reports the placeholder
- # manifest's range and the unreleased contract, so it does not fail the build.
+ # Blocking: the codes manifest, codes.release.manage|read and codes.app-manifest@1 are released
+ # (openvibe-contracts tag v0.27.0).
- run: npx openvibe-contracts-check --service codes --src server
- run: npx openvibe-contracts-check --service codes --src server
- continue-on-error: true
@@ -14,7 +14,7 @@ A place where a developer outside the network goes from an OpenVibe account to a
@@ -14,7 +14,7 @@ A place where a developer outside the network goes from an OpenVibe account to a
## Owns
## Owns
- **Release metadata** keyed to Network app ids: validated app and mod manifests, releases (`draft → published → deprecated → revoked`), an append-only release log.
- **Release metadata** keyed to Network app ids: validated app and mod manifests, releases (`draft → published → deprecated → revoked`), an append-only release log.
-- **Trust tiers** (`untrusted`, `verified`, `trusted`, `platform-maintained`) — **metadata only**; a tier never grants, allows or bypasses anything.
+- **Trust tiers** per ADR-013 (`unreviewed`, `reviewed`, `first-party`) — **metadata only**: a tier changes defaults and discovery, never a grant check. Databases written with the earlier four names are migrated at boot (untrusted→unreviewed, verified/trusted→reviewed, platform-maintained→first-party), idempotently.
- **Playground run logs** (who ran what, outcome, stage, problem code; never a credential).
- **Playground run logs** (who ran what, outcome, stage, problem code; never a credential).
- The events `codes.app.published`, `codes.app.deprecated`, `codes.app.revoked` (through the openvibe-sdk transactional outbox).
- The events `codes.app.published`, `codes.app.deprecated`, `codes.app.revoked` (through the openvibe-sdk transactional outbox).
- The generated reference (rendered from the pinned packages at boot; nothing hand-written that can drift) and the portal's pages.
- The generated reference (rendered from the pinned packages at boot; nothing hand-written that can drift) and the portal's pages.
@@ -34,10 +34,10 @@ A place where a developer outside the network goes from an OpenVibe account to a
@@ -34,10 +34,10 @@ A place where a developer outside the network goes from an OpenVibe account to a
| OAuth helper | `/oauth`, `/oauth/test-callback` | Explains authorization code + PKCE (S256) for apps, builds a test authorize URL, and a callback that shows `code`/`state`/`error` and **never exchanges** the code. |
| OAuth helper | `/oauth`, `/oauth/test-callback` | Explains authorization code + PKCE (S256) for apps, builds a test authorize URL, and a callback that shows `code`/`state`/`error` and **never exchanges** the code. |
| Webhook tools | `/tools/webhooks` | Verify `X-OpenVibe-Signature: sha256=<HMAC>` against a raw body (in the browser with Web Crypto, or on the server without JavaScript, constant-time, secret dropped); generate a signed sample delivery for any event type the contracts catalog lists. |
| Webhook tools | `/tools/webhooks` | Verify `X-OpenVibe-Signature: sha256=<HMAC>` against a raw body (in the browser with Web Crypto, or on the server without JavaScript, constant-time, secret dropped); generate a signed sample delivery for any event type the contracts catalog lists. |
| Docs | `/docs/*` | Contracts (field tables, fixtures as examples, raw schema), capability catalog (grantable ones highlighted), event types, SDK reference from its `.d.ts` files, ADRs. Every page states the versions it was generated from. The service registry is read live from Network, with health as Network reports it. |
| Docs | `/docs/*` | Contracts (field tables, fixtures as examples, raw schema), capability catalog (grantable ones highlighted), event types, SDK reference from its `.d.ts` files, ADRs. Every page states the versions it was generated from. The service registry is read live from Network, with health as Network reports it. |
-| Playgrounds | `/projects/:project/apps/:app/playground` | Media upload (sandbox) and Events publish, run **as the app** with its own token (from its secret typed for that one request, or a pasted token). Refused — with the missing grant named, and nothing requested or called — unless Network lists the grant as approved. Sandbox apps only. |
-| Manifests | `/manifests/validate`, `/projects/:project/apps/:app/releases/new` | Validate `mods.mod-manifest@1` and app manifests with openvibe-contracts; create, publish, deprecate and revoke releases. |
+| Playgrounds | `/projects/:project/apps/:app/playground` | Media upload (sandbox, `media.object.upload`) and Events publish (`events.app.publish`: types `app.<project_key>.*`, source `app-<app ULID>`), run **as the app** with its own token (from its secret typed for that one request, or a pasted token). Refused — with the missing grant named, and nothing requested or called — unless Network lists the grant as approved. Sandbox apps only. |
+| Manifests | `/manifests/validate`, `/projects/:project/apps/:app/releases/new` | Validate `codes.app-manifest@1` and `mods.mod-manifest@1` with openvibe-contracts; create, publish, deprecate and revoke releases. |
| Policy | `/policy/*` | Proposal process and the decision record; compatibility and deprecation policy rendered from ADR-002 and ADR-016 as published; licensing read from package metadata; transparency (what Codes stores and does not). |
| Policy | `/policy/*` | Proposal process and the decision record; compatibility and deprecation policy rendered from ADR-002 and ADR-016 as published; licensing read from package metadata; transparency (what Codes stores and does not). |
-| API | `/api/v1/*` | Public release reads, manifest validation, docs versions; release management by an app with its own token (`codes.release.manage`, proposed). RFC 9457 problems. |
+| API | `/api/v1/*` | Public release reads (`codes.release.read`: no token needed; a presented token must hold it), manifest validation, docs versions; release management by an app with its own token (`codes.release.manage`). Guarded with openvibe-contracts `requireCapability`. RFC 9457 problems. |
| Machine | `/api/health`, `/api/ready`, `/release.json`, `/metrics` | Readiness is truthful (db and docs required; Network, JWKS, OAuth client and events relay reported as optional checks). `/metrics` answers loopback callers only. |
| Machine | `/api/health`, `/api/ready`, `/release.json`, `/metrics` | Readiness is truthful (db and docs required; Network, JWKS, OAuth client and events relay reported as optional checks). `/metrics` answers loopback callers only. |
Everything is server-rendered and usable without JavaScript. The only scripts of Codes' own are optional: in-browser webhook verification and a copy button.
Everything is server-rendered and usable without JavaScript. The only scripts of Codes' own are optional: in-browser webhook verification and a copy button.
@@ -45,9 +45,9 @@ Everything is server-rendered and usable without JavaScript. The only scripts of
@@ -45,9 +45,9 @@ Everything is server-rendered and usable without JavaScript. The only scripts of
## Depends on
## Depends on
- **OpenVibe.Network** — SSO (OAuth client `codes`, PKCE S256), JWKS, `/api/v1/projects` (called server-side with the person's Network access token), the registry (`/api/v1/registry/services`, `/.well-known/openvibe`), client-credentials tokens (Codes' own for the events relay; the app's own in playgrounds).
- **OpenVibe.Network** — SSO (OAuth client `codes`, PKCE S256), JWKS, `/api/v1/projects` (called server-side with the person's Network access token), the registry (`/api/v1/registry/services`, `/.well-known/openvibe`), client-credentials tokens (Codes' own for the events relay; the app's own in playgrounds).
-- **OpenVibe.Events** — the outbox relay publishes `codes.app.*` with Codes' service token (`events.event.publish`); the Events playground calls it with the app's token.
+- **OpenVibe.Events** — the outbox relay publishes `codes.app.*` with Codes' service token (`events.event.publish`); the Events playground calls it with the app's token (`events.app.publish`).
- **OpenVibe.Media** — the Media playground uploads with the app's token into the project's namespace.
- **OpenVibe.Media** — the Media playground uploads with the app's token into the project's namespace.
-- **openvibe-contracts v0.26.0**, **openvibe-sdk v0.2.2**, **openvibe-shared v1.3.0** (pinned tag tarballs).
+- **openvibe-contracts** tag v0.27.0 (its package.json says 0.28.0; the docs show both), **openvibe-sdk v0.2.2**, **openvibe-shared v1.3.0** (pinned tag tarballs).
No path in Codes sends or accepts a shared loopback key (tested by grep and at runtime).
No path in Codes sends or accepts a shared loopback key (tested by grep and at runtime).
@@ -58,9 +58,9 @@ In Network (`server/identity/principals.js` / `server/db/database.js`, as for co
@@ -58,9 +58,9 @@ In Network (`server/identity/principals.js` / `server/db/database.js`, as for co
- OAuth client `codes`, name `OpenVibe.Codes`, redirect URI `https://openvibe.codes/auth/callback`.
- OAuth client `codes`, name `OpenVibe.Codes`, redirect URI `https://openvibe.codes/auth/callback`.
- Service grant `['codes', 'events.event.publish', 'openvibe.events', []]` (the outbox relay).
- Service grant `['codes', 'events.event.publish', 'openvibe.events', []]` (the outbox relay).
-In OpenVibe.Contracts (next release): the service manifest `docs/service-manifest-proposal.json`, the capabilities in `docs/capabilities-proposal/`, and the contract `codes.app-manifest@1` from `docs/contracts-proposal/`.
+The Codes service manifest, `codes.release.manage|read` and `codes.app-manifest@1` are released in openvibe-contracts (tag v0.27.0); the CI contracts check is blocking.
-For the playgrounds to succeed end to end (not Codes' code; configuration elsewhere): Network `DEV_SANDBOX_AUDIENCES` including `openvibe.media` (and `openvibe.events`), a staff-set allowance containing `media.object.upload` for the project, and a Media tenant keyed by the project id. `events.event.publish` is `internal` in contracts v0.26.0, so no app can publish events until Events publishes a public capability; the playground says so.
+For the playgrounds to succeed end to end (not Codes' code; configuration elsewhere): Network `DEV_SANDBOX_AUDIENCES` including `openvibe.media` and `openvibe.events`, a staff-set allowance containing `media.object.upload` and `events.app.publish` for the project, a Media tenant keyed by the project id, and OpenVibe.Events serving `events.app.publish` for app tokens.
## Configuration
## Configuration
@@ -84,7 +84,7 @@ fnm exec --using=22.22.1 npm run dev # http://localhost:4900
@@ -84,7 +84,7 @@ fnm exec --using=22.22.1 npm run dev # http://localhost:4900
npx openvibe-contracts-check --service codes --src server
npx openvibe-contracts-check --service codes --src server
```
```
-Tests: secrets never persisted or re-displayed; Network errors surfaced honestly (404/403/409/422/503, unreachable, expired session refresh); scope editor offers only grantable capabilities and refuses forged requests before Network sees them; generated docs match the pinned versions (every contract, capability, event type, SDK module); webhook verification (tampering, prefixes, lengths, constant-time); playgrounds refuse without the grant and call nothing; manifest validation; release lifecycle and events; no internal key anywhere; PKCE sign-in; readiness, release.json, loopback metrics; proposals valid against the contract schemas.
+Tests: ADR-013 trust-tier migration; secrets never persisted or re-displayed; Network errors surfaced honestly (404/403/409/422/503, unreachable, expired session refresh); scope editor offers only grantable capabilities and refuses forged requests before Network sees them; generated docs match the pinned versions (every contract, capability, event type, SDK module); webhook verification (tampering, prefixes, lengths, constant-time); playgrounds refuse without the grant and call nothing; manifest validation; release lifecycle and events; no internal key anywhere; PKCE sign-in; readiness, release.json, loopback metrics; the released codes manifest matching the code.
## Acceptance (must be true before "done")
## Acceptance (must be true before "done")
@@ -95,7 +95,7 @@ Tests: secrets never persisted or re-displayed; Network errors surfaced honestly
@@ -95,7 +95,7 @@ Tests: secrets never persisted or re-displayed; Network errors surfaced honestly
## Launch rule
## Launch rule
-This repository does not make the product real, and the domain keeps its placeholder page on [OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of the following exist (plan §12.12): an owning runtime with health/readiness and observability (**done**); canonical identity/auth integration (**done in code; the OAuth client is not registered**); server-rendered public routes useful without JavaScript (**done**); real persistence and end-to-end workflows (**persistence done; end-to-end blocked on the items above**); capability and event registration against OpenVibe.Contracts (**proposed, not released**); a migration/seed strategy (none needed: no data is imported; raw old developer keys are never imported), a security review and sitemap/robots behaviour (**sitemap and robots done**); acceptance tests proving the advertised functionality (**against stand-ins**). Roadmap binding: do not launch Codes as a developer portal while these paths are mocked.
+This repository does not make the product real, and the domain keeps its placeholder page on [OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of the following exist (plan §12.12): an owning runtime with health/readiness and observability (**done**); canonical identity/auth integration (**done in code; the OAuth client is not registered**); server-rendered public routes useful without JavaScript (**done**); real persistence and end-to-end workflows (**persistence done; end-to-end blocked on the items above**); capability and event registration against OpenVibe.Contracts (**released**); a migration/seed strategy (none needed: no data is imported; raw old developer keys are never imported), a security review and sitemap/robots behaviour (**sitemap and robots done**); acceptance tests proving the advertised functionality (**against stand-ins**). Roadmap binding: do not launch Codes as a developer portal while these paths are mocked.
### Threat notes
### Threat notes
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
"port": 4900,
"port": 4900,
"domain": "openvibe.codes",
"domain": "openvibe.codes",
"packages": [
"packages": [
- "openvibe-contracts v0.26.0",
+ "openvibe-contracts tag v0.27.0 (package.json 0.28.0)",
"openvibe-sdk v0.2.2",
"openvibe-sdk v0.2.2",
"openvibe-shared v1.3.0"
"openvibe-shared v1.3.0"
],
],
@@ -18,25 +18,25 @@
@@ -18,25 +18,25 @@
"Projects, members, apps, credentials (rotate, revoke), grants (request, approve, deny, revoke), quotas and audit over Network's /api/v1/projects API with the person's own token; Network's errors shown as Network answered them",
"Projects, members, apps, credentials (rotate, revoke), grants (request, approve, deny, revoke), quotas and audit over Network's /api/v1/projects API with the person's own token; Network's errors shown as Network answered them",
"Client secrets shown once, in the create/rotate response only, with no-store; never stored or logged (tested)",
"Client secrets shown once, in the create/rotate response only, with no-store; never stored or logged (tested)",
"Scope editor offering only capabilities apps can be granted (public, or partner when in the allowance)",
"Scope editor offering only capabilities apps can be granted (public, or partner when in the allowance)",
- "Docs generated at boot from openvibe-contracts v0.26.0 and openvibe-sdk v0.2.2: contracts with field tables and fixtures, capability catalog, event types, SDK reference; service registry read live from Network with its health",
+ "Docs generated at boot from the pinned openvibe-contracts and openvibe-sdk: contracts with field tables and fixtures, capability catalog, event types, SDK reference; service registry read live from Network with its health",
"OAuth callback helper and a test callback that never exchanges the code",
"OAuth callback helper and a test callback that never exchanges the code",
"Webhook signature verifier (server or in-browser) and signed sample deliveries for catalogued event types",
"Webhook signature verifier (server or in-browser) and signed sample deliveries for catalogued event types",
- "Playgrounds (Media upload, Events publish) that run with the app's own token and refuse without the grant",
- "App and mod manifest validation; release metadata (draft, publish, deprecate, revoke) with codes.app.* events through the SDK outbox; trust tiers as staff-set metadata",
- "Policy pages rendered from the published ADRs; licensing read from package metadata"
+ "Playgrounds (Media upload with media.object.upload, Events publish with events.app.publish) that run with the app's own token and refuse without the grant",
+ "App and mod manifest validation; release metadata (draft, publish, deprecate, revoke) with codes.app.* events through the SDK outbox; trust tiers per ADR-013 (unreviewed, reviewed, first-party) as staff-set metadata, older names migrated at boot",
+ "Policy pages rendered from the published ADRs; licensing read from package metadata",
+ "Release API guarded by openvibe-contracts requireCapability (codes.release.manage, codes.release.read)"
],
],
"notYet": [
"notYet": [
"Not deployed; the Network OAuth client `codes` and the events.event.publish grant are not registered",
"Not deployed; the Network OAuth client `codes` and the events.event.publish grant are not registered",
- "The Events playground cannot run for any app: events.event.publish is internal in openvibe-contracts v0.26.0 and Network never grants it to apps",
+ "The Events playground needs Network to accept sandbox tokens for openvibe.events, events.app.publish in the project's allowance, and Events serving app tokens",
"The Media playground needs Network to accept sandbox tokens for openvibe.media (DEV_SANDBOX_AUDIENCES) and Media to have a tenant per project id; neither exists yet",
"The Media playground needs Network to accept sandbox tokens for openvibe.media (DEV_SANDBOX_AUDIENCES) and Media to have a tenant per project id; neither exists yet",
- "codes.release.manage and codes.release.read are proposals; until Contracts releases them no app can hold the release-management grant",
"No usage, trace or error dashboards: no service reports per-project usage yet",
"No usage, trace or error dashboards: no service reports per-project usage yet",
"Billing sandbox, Chat bot and Realtime playgrounds; CLI and scaffolding; templates; project data export",
"Billing sandbox, Chat bot and Realtime playgrounds; CLI and scaffolding; templates; project data export",
"Consent screen for third-party production apps (a Network gap)"
"Consent screen for third-party production apps (a Network gap)"
],
],
- "capabilitiesRegistered": false,
- "serviceManifestRegistered": false,
+ "capabilitiesRegistered": true,
+ "serviceManifestRegistered": true,
"deployed": false,
"deployed": false,
"placeholderInSites": true,
"placeholderInSites": true,
- "updated": "2026-09-22"
+ "updated": "2026-09-23"
}
}
deleted file mode 100644
deleted file mode 100644
@@ -1,19 +0,0 @@
@@ -1,19 +0,0 @@
-{
- "id": "codes.release.manage",
- "version": "1.0",
- "owner": "codes",
- "status": "active",
- "visibility": "public",
- "description": "An app manages release metadata for ITSELF with its own token (audience openvibe.codes): create a release from a validated app or mod manifest, publish, deprecate (with a reason and an optional replacement) and revoke it. The token's sub must be app:<the app in the path>; people manage releases in the portal with their project role instead. Trust tiers are not part of this capability (staff metadata only).",
- "inputSchema": "codes.app-manifest@1",
- "permissions": ["codes:releases:write"],
- "resourceConstraints": ["owner", "project"],
- "quotaClass": "codes-release-write",
- "events": ["codes.app.published", "codes.app.deprecated", "codes.app.revoked"],
- "implementedBy": [
- "POST /api/v1/apps/:app/releases",
- "POST /api/v1/releases/:id/publish",
- "POST /api/v1/releases/:id/deprecate",
- "POST /api/v1/releases/:id/revoke"
- ]
-}
deleted file mode 100644
deleted file mode 100644
@@ -1,18 +0,0 @@
@@ -1,18 +0,0 @@
-{
- "id": "codes.release.read",
- "version": "1.0",
- "owner": "codes",
- "status": "active",
- "visibility": "public",
- "description": "Read public release metadata: an app's published, deprecated and revoked releases with their manifests, compatibility ranges and the app's trust tier (metadata only; it never grants anything). Drafts are never returned. Public; no token needed.",
- "outputSchema": "codes.app-manifest@1",
- "permissions": ["codes:releases:read"],
- "resourceConstraints": ["none"],
- "quotaClass": "codes-read",
- "events": [],
- "implementedBy": [
- "GET /api/v1/apps/:app/releases",
- "GET /api/v1/apps/:app/trust",
- "GET /api/v1/releases/:id"
- ]
-}
deleted file mode 100644
deleted file mode 100644
@@ -1,17 +0,0 @@
@@ -1,17 +0,0 @@
-# Contracts proposal: codes.app-manifest@1
-
-`codes/app-manifest.v1.json` is the schema OpenVibe.Codes validates app manifests with. Until
-OpenVibe.Contracts publishes it, Codes compiles this file into openvibe-contracts' own validator
-(so its `$ref` to `identity/subject-ref.v1.json` resolves to the released schema); once a
-contracts release contains `codes.app-manifest`, Codes uses `contracts.validate('codes.app-manifest@1')`
-instead, with no code change.
-
-Proposed catalog entry:
-
-```json
-{ "id": "codes.app-manifest", "version": "1.0.0", "owner": "codes", "schema": "codes/app-manifest.v1.json",
- "visibility": "public", "compatibility": "backward", "status": "active", "adr": "ADR-014" }
-```
-
-Fixtures to add with it: the template `server/domain/manifests.js` produces (valid) and one with an
-`internal` capability or a missing `project_id` (invalid).
deleted file mode 100644
deleted file mode 100644
@@ -1,68 +0,0 @@
@@ -1,68 +0,0 @@
-{
- "$schema": "https://json-schema.org/draft/2020-12/schema",
- "$id": "https://openvibe.network/contracts/codes/app-manifest.v1.json",
- "title": "AppManifest",
- "description": "PROPOSAL (OpenVibe.Codes, roadmap Wave 20): an app release as the platform knows it. The app is a Network developer app (ADR-014): its id is the Network app id, its capabilities are only a request (the grants in Network are the authority), and trust tiers are metadata that never change a grant check. OpenVibe.Codes validates with this schema, loaded into openvibe-contracts' validator, until Contracts publishes it as codes.app-manifest@1.",
- "type": "object",
- "required": ["id", "name", "version", "publisher", "project_id", "capabilities", "compatibility"],
- "additionalProperties": false,
- "properties": {
- "id": {
- "type": "string",
- "pattern": "^app_[0-9A-HJKMNP-TV-Z]{26}$",
- "description": "The Network app id (its client_id). Its principal subject is app:<id>."
- },
- "name": { "type": "string", "minLength": 1, "maxLength": 80 },
- "version": {
- "type": "string",
- "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$",
- "description": "Semantic version of this release."
- },
- "description": { "type": "string", "maxLength": 1000 },
- "publisher": {
- "$ref": "../identity/subject-ref.v1.json",
- "description": "Who publishes the release: the app itself or a member of its project."
- },
- "project_id": {
- "type": "string",
- "pattern": "^prj_[0-9A-HJKMNP-TV-Z]{26}$",
- "description": "The Network project that owns the app. Tenancy in other services is keyed by it (ADR-014)."
- },
- "environment": { "enum": ["sandbox", "production"], "description": "The app's environment. Apps never change environment." },
- "capabilities": {
- "description": "Capability ids the app asks for (3+ segments). Only active public (or partner, by staff allowance) capabilities can ever be granted to apps.",
- "type": "array",
- "maxItems": 64,
- "uniqueItems": true,
- "items": { "type": "string", "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+){2,}$" }
- },
- "events": {
- "type": "object",
- "additionalProperties": false,
- "properties": {
- "consumes": {
- "description": "Event types (or a trailing .* family) the app subscribes to.",
- "type": "array", "maxItems": 64, "uniqueItems": true,
- "items": { "type": "string", "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)*(\\.\\*)?$" }
- }
- }
- },
- "redirect_uris": {
- "description": "Informational copy of the redirect URIs registered in Network (Network's list is the authority).",
- "type": "array", "maxItems": 10, "uniqueItems": true,
- "items": { "type": "string", "maxLength": 500 }
- },
- "homepage": { "type": "string", "pattern": "^https://[^\\s]{1,2000}$" },
- "repository": { "type": "string", "pattern": "^https://[^\\s]{1,2000}$" },
- "license": { "type": "string", "minLength": 1, "maxLength": 100, "description": "SPDX license expression." },
- "compatibility": {
- "type": "object",
- "required": ["contracts"],
- "additionalProperties": false,
- "properties": {
- "contracts": { "type": "string", "minLength": 1, "maxLength": 100, "description": "Semver range of openvibe-contracts releases it was built against." },
- "sdk": { "type": "string", "minLength": 1, "maxLength": 100, "description": "Semver range of openvibe-sdk releases it uses." }
- }
- }
- }
-}
deleted file mode 100644
deleted file mode 100644
@@ -1,30 +0,0 @@
@@ -1,30 +0,0 @@
-{
- "version": "0.1.0",
- "contractRanges": {
- "openvibe-contracts": ">=0.26.0 <1.0.0"
- },
- "id": "codes",
- "name": "OpenVibe.Codes",
- "status": "alpha",
- "repository": "OpenVibers/OpenVibe.Codes",
- "domains": [
- "openvibe.codes"
- ],
- "publicOrigin": "https://openvibe.codes",
- "health": "/api/health",
- "ready": "/api/ready",
- "capabilities": [
- "codes.release.read",
- "codes.release.manage"
- ],
- "eventsProduced": [
- "codes.app.published",
- "codes.app.deprecated",
- "codes.app.revoked"
- ],
- "eventsConsumed": [],
- "namespacesOwned": [
- "codes.*"
- ],
- "notes": "Port 4900, unit openvibe-codes.service, state /var/lib/openvibe-codes. The developer portal (ADR-014): projects, members, apps, credentials, grants and quotas are OWNED BY NETWORK and shown through Network's /api/v1/projects API with the signed-in person's own token; Codes stores none of them and never a secret. Codes owns only manifests, releases (+ append-only release_log), trust tiers (metadata) and playground run logs in its own SQLite. Consumes OpenVibe.Network (SSO with PKCE S256 as OAuth client codes, JWKS, the projects API, the registry, client-credentials) and publishes through OpenVibe.Events (events.event.publish). Playgrounds call Events and Media with the APP's own token only. Docs are generated at boot from the pinned openvibe-contracts and openvibe-sdk."
-}
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.26.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.27.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
},
},
@@ -1023,9 +1023,9 @@
@@ -1023,9 +1023,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.26.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.26.0",
- "integrity": "sha512-yigHjNzLIyK578V0Weqv2zL6z8PO8ce3HRXPa/61n1XK5Y7pTi28qHMMiO6stboQjTziN1+8wMeByoCF1sGCnw==",
+ "version": "0.28.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.27.0",
+ "integrity": "sha512-WGPIp/dtQ0M4BaZkqoChK9FV0li9bnR93f4DchTtFE6d+oX+C+oSm9gDKGVHNZ9PbFI2zlsEo3Jbf2858tpchA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -27,7 +27,7 @@
@@ -27,7 +27,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.26.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.27.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
},
},
@@ -70,7 +70,7 @@ CREATE TRIGGER IF NOT EXISTS release_log_append_only_delete BEFORE DELETE ON rel
@@ -70,7 +70,7 @@ CREATE TRIGGER IF NOT EXISTS release_log_append_only_delete BEFORE DELETE ON rel
CREATE TABLE IF NOT EXISTS trust (
CREATE TABLE IF NOT EXISTS trust (
app_id TEXT PRIMARY KEY,
app_id TEXT PRIMARY KEY,
- tier TEXT NOT NULL CHECK (tier IN ('untrusted', 'verified', 'trusted', 'platform-maintained')),
+ tier TEXT NOT NULL CHECK (tier IN ('unreviewed', 'reviewed', 'first-party')),
note TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
set_by TEXT NOT NULL,
set_by TEXT NOT NULL,
set_at TEXT NOT NULL
set_at TEXT NOT NULL
@@ -105,6 +105,38 @@ CREATE INDEX IF NOT EXISTS idx_runs_app ON playground_runs(app_id, at);
@@ -105,6 +105,38 @@ CREATE INDEX IF NOT EXISTS idx_runs_app ON playground_runs(app_id, at);
CREATE INDEX IF NOT EXISTS idx_runs_actor ON playground_runs(actor, at);
CREATE INDEX IF NOT EXISTS idx_runs_actor ON playground_runs(actor, at);
`;
`;
+/**
+ * ADR-013 is binding for trust tiers: unreviewed, reviewed, first-party. Databases created before
+ * that used the roadmap's four names; map them (untrusted→unreviewed, verified|trusted→reviewed,
+ * platform-maintained→first-party) and rebuild `trust` with the new CHECK. Idempotent: a database
+ * already on ADR-013 names is left untouched. Tiers stay metadata; no grant depends on them.
+ */
+const TIER_MAP = { untrusted: 'unreviewed', verified: 'reviewed', trusted: 'reviewed', 'platform-maintained': 'first-party' };
+function migrateTrustTiers(db) {
+ const row = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'trust'").get();
+ const oldCheck = row && /'untrusted'/.test(row.sql);
+ const stale = db.prepare(`SELECT COUNT(*) AS n FROM trust_history WHERE from_tier IN (${Object.keys(TIER_MAP).map(() => '?').join(', ')}) OR to_tier IN (${Object.keys(TIER_MAP).map(() => '?').join(', ')})`)
+ .get(...Object.keys(TIER_MAP), ...Object.keys(TIER_MAP)).n;
+ if (!oldCheck && !stale) return { migrated: false };
+ const mapSql = (col) => `CASE ${col} ${Object.entries(TIER_MAP).map(([a, b]) => `WHEN '${a}' THEN '${b}'`).join(' ')} ELSE ${col} END`;
+ db.transaction(() => {
+ if (oldCheck) {
+ db.exec(`CREATE TABLE trust_adr013 (
+ app_id TEXT PRIMARY KEY,
+ tier TEXT NOT NULL CHECK (tier IN ('unreviewed', 'reviewed', 'first-party')),
+ note TEXT NOT NULL DEFAULT '',
+ set_by TEXT NOT NULL,
+ set_at TEXT NOT NULL
+ );
+ INSERT INTO trust_adr013 (app_id, tier, note, set_by, set_at) SELECT app_id, ${mapSql('tier')}, note, set_by, set_at FROM trust;
+ DROP TABLE trust;
+ ALTER TABLE trust_adr013 RENAME TO trust;`);
+ }
+ db.exec(`UPDATE trust_history SET from_tier = ${mapSql('from_tier')}, to_tier = ${mapSql('to_tier')}`);
+ })();
+ return { migrated: true };
+}
+
function openStore(dbPath, { now = () => Date.now() } = {}) {
function openStore(dbPath, { now = () => Date.now() } = {}) {
if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });
if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });
const db = new Database(dbPath);
const db = new Database(dbPath);
@@ -112,6 +144,7 @@ function openStore(dbPath, { now = () => Date.now() } = {}) {
@@ -112,6 +144,7 @@ function openStore(dbPath, { now = () => Date.now() } = {}) {
db.pragma('foreign_keys = ON');
db.pragma('foreign_keys = ON');
db.pragma('busy_timeout = 5000');
db.pragma('busy_timeout = 5000');
db.exec(SCHEMA);
db.exec(SCHEMA);
+ migrateTrustTiers(db);
return {
return {
db,
db,
now,
now,
@@ -121,4 +154,4 @@ function openStore(dbPath, { now = () => Date.now() } = {}) {
@@ -121,4 +154,4 @@ function openStore(dbPath, { now = () => Date.now() } = {}) {
};
};
}
}
-module.exports = { openStore, TABLES };
+module.exports = { openStore, TABLES, TIER_MAP, migrateTrustTiers };
@@ -18,6 +18,13 @@ const CONTRACTS_ROOT = path.dirname(require.resolve('openvibe-contracts/package.
@@ -18,6 +18,13 @@ const CONTRACTS_ROOT = path.dirname(require.resolve('openvibe-contracts/package.
const SDK_ROOT = path.dirname(require.resolve('openvibe-sdk/package.json'));
const SDK_ROOT = path.dirname(require.resolve('openvibe-sdk/package.json'));
const contractsPkg = require('openvibe-contracts/package.json');
const contractsPkg = require('openvibe-contracts/package.json');
const sdkPkg = require('openvibe-sdk/package.json');
const sdkPkg = require('openvibe-sdk/package.json');
+const ownPkg = require('../../package.json');
+
+/** The git tag this release pins a package to (from package.json's tarball URL), or null. */
+function pinnedTag(dep) {
+ const m = String((ownPkg.dependencies || {})[dep] || '').match(/refs\/tags\/(v[0-9A-Za-z.-]+)$/);
+ return m ? m[1] : null;
+}
/** Visibilities an app may ever be granted (Network's grantability rule, ADR-014). */
/** Visibilities an app may ever be granted (Network's grantability rule, ADR-014). */
const GRANTABLE_VISIBILITIES = new Set(['public', 'partner']);
const GRANTABLE_VISIBILITIES = new Set(['public', 'partner']);
@@ -159,6 +166,10 @@ function generate({ now = () => Date.now() } = {}) {
@@ -159,6 +166,10 @@ function generate({ now = () => Date.now() } = {}) {
return {
return {
generatedAt: new Date(now()).toISOString(),
generatedAt: new Date(now()).toISOString(),
contractsVersion: contractsPkg.version,
contractsVersion: contractsPkg.version,
+ // The tag may differ from the version the package declares (the contracts tag v0.27.0 ships
+ // package.json 0.28.0); pages show both rather than pick one.
+ contractsTag: pinnedTag('openvibe-contracts') || `v${contractsPkg.version}`,
+ sdkTag: pinnedTag('openvibe-sdk') || `v${sdkPkg.version}`,
contractsLicense: contractsPkg.license,
contractsLicense: contractsPkg.license,
sdkVersion: sdkPkg.version,
sdkVersion: sdkPkg.version,
sdkLicense: sdkPkg.license,
sdkLicense: sdkPkg.license,
@@ -175,4 +186,4 @@ function generate({ now = () => Date.now() } = {}) {
@@ -175,4 +186,4 @@ function generate({ now = () => Date.now() } = {}) {
};
};
}
}
-module.exports = { generate, fieldRows, GRANTABLE_VISIBILITIES, CONTRACTS_ROOT, SDK_ROOT };
+module.exports = { generate, fieldRows, pinnedTag, GRANTABLE_VISIBILITIES, CONTRACTS_ROOT, SDK_ROOT };
@@ -3,47 +3,21 @@
@@ -3,47 +3,21 @@
/**
/**
* Manifest validation with openvibe-contracts.
* Manifest validation with openvibe-contracts.
*
*
- * mod mods.mod-manifest@1 from the pinned openvibe-contracts (contracts.validate)
- * app codes.app-manifest@1 — published by Contracts once released; until then the proposal in
- * docs/contracts-proposal/codes/app-manifest.v1.json, compiled into openvibe-contracts' own
- * Ajv instance so its $refs resolve to the released identity schemas
+ * mod mods.mod-manifest@1
+ * app codes.app-manifest@1
*
*
* Schema errors come first; then checks the schema cannot express, against the pinned catalog:
* Schema errors come first; then checks the schema cannot express, against the pinned catalog:
* every requested capability must exist and be grantable to apps (active + public/partner), ranges
* every requested capability must exist and be grantable to apps (active + public/partner), ranges
* must parse, consumed events should be produced by someone. With an app context (a release), the
* must parse, consumed events should be produced by someone. With an app context (a release), the
* manifest must also describe THAT app: same id, project and environment.
* manifest must also describe THAT app: same id, project and environment.
*/
*/
-const fs = require('fs');
-const path = require('path');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
-const contractsRegistry = require('openvibe-contracts/lib/registry');
const { satisfiesRange } = require('openvibe-sdk/core');
const { satisfiesRange } = require('openvibe-sdk/core');
const { GRANTABLE_VISIBILITIES } = require('../docs/generate');
const { GRANTABLE_VISIBILITIES } = require('../docs/generate');
const contractsVersion = require('openvibe-contracts/package.json').version;
const contractsVersion = require('openvibe-contracts/package.json').version;
const sdkVersion = require('openvibe-sdk/package.json').version;
const sdkVersion = require('openvibe-sdk/package.json').version;
-const APP_SCHEMA_FILE = path.join(__dirname, '..', '..', 'docs', 'contracts-proposal', 'codes', 'app-manifest.v1.json');
-
-let appValidator = null;
-let appSchemaSource = null;
-function appSchema() {
- if (appValidator) return appValidator;
- try {
- const entry = contracts.resolve('codes.app-manifest@1');
- appSchemaSource = { released: true, id: entry.id, version: entry.version };
- appValidator = (value) => contracts.validate('codes.app-manifest@1', value);
- } catch {
- const schema = JSON.parse(fs.readFileSync(APP_SCHEMA_FILE, 'utf8'));
- const { ajv } = contractsRegistry;
- const fn = ajv.getSchema(schema.$id) || (ajv.addSchema(schema), ajv.getSchema(schema.$id));
- appSchemaSource = { released: false, id: 'codes.app-manifest', version: '1.0.0 (proposal)' };
- appValidator = (value) => {
- const valid = fn(value);
- return { valid, errors: valid ? [] : fn.errors.map((e) => ({ path: e.instancePath || '/', message: e.message })) };
- };
- }
- return appValidator;
-}
+const SCHEMAS = { app: 'codes.app-manifest@1', mod: 'mods.mod-manifest@1' };
function rangeError(range) {
function rangeError(range) {
try { satisfiesRange('0.0.0', range); return null; } catch (err) { return err.message; }
try { satisfiesRange('0.0.0', range); return null; } catch (err) { return err.message; }
@@ -80,7 +54,7 @@ function template(kind, { appId, projectId, environment, name, subject } = {}) {
@@ -80,7 +54,7 @@ function template(kind, { appId, projectId, environment, name, subject } = {}) {
/**
/**
* validate(kind, manifest, { app, viewerSubject, eventTypes })
* validate(kind, manifest, { app, viewerSubject, eventTypes })
* app: the Network app view (id, project_id, environment, grants) when validating for a release
* app: the Network app view (id, project_id, environment, grants) when validating for a release
- * → { valid, errors: [{ path, message }], warnings: [{ path, message }], schema: { id, version, released } }
+ * → { valid, errors: [{ path, message }], warnings: [{ path, message }], schema: { id, version } }
*/
*/
function validate(kind, manifest, { app = null, viewerSubject = null, eventTypes = null } = {}) {
function validate(kind, manifest, { app = null, viewerSubject = null, eventTypes = null } = {}) {
const errors = [];
const errors = [];
@@ -89,17 +63,10 @@ function validate(kind, manifest, { app = null, viewerSubject = null, eventTypes
@@ -89,17 +63,10 @@ function validate(kind, manifest, { app = null, viewerSubject = null, eventTypes
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
return { valid: false, errors: [{ path: '/', message: 'a manifest is a JSON object' }], warnings, schema: null };
return { valid: false, errors: [{ path: '/', message: 'a manifest is a JSON object' }], warnings, schema: null };
}
}
- let schema;
- if (kind === 'mod') {
- const r = contracts.validate('mods.mod-manifest@1', manifest);
- errors.push(...r.errors);
- const e = contracts.resolve('mods.mod-manifest@1');
- schema = { id: e.id, version: e.version, released: true };
- } else {
- const r = appSchema()(manifest);
- errors.push(...r.errors);
- schema = appSchemaSource;
- }
+ const r = contracts.validate(SCHEMAS[kind], manifest);
+ errors.push(...r.errors);
+ const entry = contracts.resolve(SCHEMAS[kind]);
+ const schema = { id: entry.id, version: entry.version };
// Requested capabilities: must exist in the pinned catalog and be grantable to apps.
// Requested capabilities: must exist in the pinned catalog and be grantable to apps.
const requested = kind === 'mod'
const requested = kind === 'mod'
@@ -164,4 +131,4 @@ function parse(text) {
@@ -164,4 +131,4 @@ function parse(text) {
try { return { manifest: JSON.parse(s) }; } catch (err) { return { error: `not valid JSON: ${err.message.slice(0, 200)}` }; }
try { return { manifest: JSON.parse(s) }; } catch (err) { return { error: `not valid JSON: ${err.message.slice(0, 200)}` }; }
}
}
-module.exports = { validate, parse, template, appSchemaInfo: () => (appSchema(), appSchemaSource), APP_SCHEMA_FILE };
+module.exports = { validate, parse, template, SCHEMAS };
@@ -3,7 +3,9 @@
@@ -3,7 +3,9 @@
/**
/**
* Playgrounds that cannot exceed the project's grants (ADR-014 acceptance).
* Playgrounds that cannot exceed the project's grants (ADR-014 acceptance).
*
*
- * events publish one test event needs events.event.publish (audience openvibe.events)
+ * events publish one test event needs events.app.publish (audience openvibe.events;
+ * type app.<project_key>.…,
+ * source app-<app ulid>)
* media upload one small file (sandbox) needs media.object.upload (audience openvibe.media,
* media upload one small file (sandbox) needs media.object.upload (audience openvibe.media,
* namespace = project id)
* namespace = project id)
*
*
@@ -33,7 +35,7 @@ const contractsVersion = require('openvibe-contracts/package.json').version;
@@ -33,7 +35,7 @@ const contractsVersion = require('openvibe-contracts/package.json').version;
const KINDS = {
const KINDS = {
// `needs`: the capability the APP must hold (owned by Events / Media; Codes guards nothing with it).
// `needs`: the capability the APP must hold (owned by Events / Media; Codes guards nothing with it).
- events: { needs: 'events.event.publish', audience: 'openvibe.events', label: 'Events: publish a test event' },
+ events: { needs: 'events.app.publish', audience: 'openvibe.events', label: 'Events: publish a test event' },
media: { needs: 'media.object.upload', audience: 'openvibe.media', label: 'Media: upload a file to the sandbox' },
media: { needs: 'media.object.upload', audience: 'openvibe.media', label: 'Media: upload a file to the sandbox' },
};
};
@@ -43,6 +45,14 @@ function scrub(text, secrets) {
@@ -43,6 +45,14 @@ function scrub(text, secrets) {
return s.replace(/ovsec_[A-Za-z0-9_-]+/g, '[redacted]').replace(/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, '[redacted token]');
return s.replace(/ovsec_[A-Za-z0-9_-]+/g, '[redacted]').replace(/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, '[redacted token]');
}
}
+/**
+ * What events.app.publish allows an app (openvibe-contracts): types app.<project_key>.<name>[…]
+ * where project_key is 'p' + the project's ULID in lowercase, and source 'app-' + the app's ULID
+ * in lowercase.
+ */
+const projectKey = (projectId) => `p${String(projectId).replace(/^prj_/, '').toLowerCase()}`;
+const appSource = (appId) => `app-${String(appId).replace(/^app_/, '').toLowerCase()}`;
+
/** Why a capability is not granted, from the pinned catalog. */
/** Why a capability is not granted, from the pinned catalog. */
function grantAdvice(capability) {
function grantAdvice(capability) {
const cap = contracts.capabilities.get(capability);
const cap = contracts.capabilities.get(capability);
@@ -92,11 +102,14 @@ function createPlayground({ store, config, network, keys, fetchImpl, log = conso
@@ -92,11 +102,14 @@ function createPlayground({ store, config, network, keys, fetchImpl, log = conso
try { payload = JSON.parse(raw); } catch (err) { return { error: `payload is not JSON: ${err.message.slice(0, 120)}` }; }
try { payload = JSON.parse(raw); } catch (err) { return { error: `payload is not JSON: ${err.message.slice(0, 120)}` }; }
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return { error: 'payload must be a JSON object' };
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return { error: 'payload must be a JSON object' };
}
}
+ const eventType = String(input.event_type || '').trim();
+ const prefix = `app.${projectKey(input.projectId)}.`;
+ if (!eventType.startsWith(prefix) || eventType.length === prefix.length) return { error: `this app may publish only event types starting with ${prefix} (events.app.publish)` };
const envelope = {
const envelope = {
event_id: contracts.ids.newId('event', store.now()),
event_id: contracts.ids.newId('event', store.now()),
- event_type: String(input.event_type || '').trim(),
+ event_type: eventType,
version: 1,
version: 1,
- source: String(input.source || '').trim(),
+ source: appSource(input.appId),
actor: { type: 'app', id: input.appId },
actor: { type: 'app', id: input.appId },
timestamp: new Date(store.now()).toISOString(),
timestamp: new Date(store.now()).toISOString(),
visibility: 'internal',
visibility: 'internal',
@@ -160,7 +173,7 @@ function createPlayground({ store, config, network, keys, fetchImpl, log = conso
@@ -160,7 +173,7 @@ function createPlayground({ store, config, network, keys, fetchImpl, log = conso
let envelope = null;
let envelope = null;
if (kind === 'events') {
if (kind === 'events') {
- const e = eventInput({ ...input, appId: app.id });
+ const e = eventInput({ ...input, appId: app.id, projectId: app.project_id });
if (e.error) return finish({ outcome: 'refused', stage: 'input', code: 'playground.invalid_input', detail: e.error });
if (e.error) return finish({ outcome: 'refused', stage: 'input', code: 'playground.invalid_input', detail: e.error });
envelope = e.envelope;
envelope = e.envelope;
} else {
} else {
@@ -197,7 +210,7 @@ function createPlayground({ store, config, network, keys, fetchImpl, log = conso
@@ -197,7 +210,7 @@ function createPlayground({ store, config, network, keys, fetchImpl, log = conso
}
}
}
}
- return { run, precheck, runsFor, grantAdvice, KINDS };
+ return { run, precheck, runsFor, grantAdvice, KINDS, projectKey, appSource };
}
}
-module.exports = { createPlayground, KINDS, grantAdvice, scrub };
+module.exports = { createPlayground, KINDS, grantAdvice, scrub, projectKey, appSource };
@@ -1,14 +1,14 @@
@@ -1,14 +1,14 @@
'use strict';
'use strict';
/**
/**
- * Trust tiers (roadmap §15.17): untrusted, verified, trusted, platform-maintained.
+ * Trust tiers (ADR-013, binding): unreviewed, reviewed, first-party.
*
*
- * METADATA ONLY. "Actual authority comes from grants. Trust may affect default quotas/review
- * requirements, never bypass policy." Nothing in Codes (or anywhere) reads a tier to allow an
- * action; the tier is shown next to releases and carried in codes.app.* event payloads.
- * Every app starts untrusted; Codes staff change it, with a note, and every change is kept.
+ * METADATA ONLY. "A tier changes defaults and discovery, but never the grant check." Nothing in
+ * Codes (or anywhere) reads a tier to allow an action; the tier is shown next to releases and
+ * carried in codes.app.* event payloads. Every app starts unreviewed; Codes staff change it, with
+ * a note, and every change is kept. Older names are migrated at boot (server/db.js).
*/
*/
-const TIERS = ['untrusted', 'verified', 'trusted', 'platform-maintained'];
+const TIERS = ['unreviewed', 'reviewed', 'first-party'];
const APP_ID_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;
const APP_ID_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;
function createTrust({ store }) {
function createTrust({ store }) {
@@ -16,7 +16,7 @@ function createTrust({ store }) {
@@ -16,7 +16,7 @@ function createTrust({ store }) {
function get(appId) {
function get(appId) {
const r = db.prepare('SELECT * FROM trust WHERE app_id = ?').get(String(appId));
const r = db.prepare('SELECT * FROM trust WHERE app_id = ?').get(String(appId));
- return r ? { tier: r.tier, note: r.note, set_by: r.set_by, set_at: r.set_at } : { tier: 'untrusted', note: '', set_by: null, set_at: null };
+ return r ? { tier: r.tier, note: r.note, set_by: r.set_by, set_at: r.set_at } : { tier: 'unreviewed', note: '', set_by: null, set_at: null };
}
}
function set({ appId, tier, note, actor }) {
function set({ appId, tier, note, actor }) {
@@ -5,35 +5,34 @@
@@ -5,35 +5,34 @@
*
*
* GET /docs/versions the pinned versions the docs were generated from
* GET /docs/versions the pinned versions the docs were generated from
* POST /manifests/validate { kind: app|mod, manifest } → { valid, errors, warnings }
* POST /manifests/validate { kind: app|mod, manifest } → { valid, errors, warnings }
+ *
+ * codes.release.read (public; no token needed — a token that IS presented must hold it):
* GET /apps/:app/releases public releases of an app (never drafts) + trust tier
* GET /apps/:app/releases public releases of an app (never drafts) + trust tier
- * GET /apps/:app/trust the app's trust tier (metadata only)
+ * GET /apps/:app/trust the app's trust tier (metadata only, ADR-013)
* GET /releases/:id one public release with its manifest
* GET /releases/:id one public release with its manifest
*
*
- * An app managing its OWN releases with its own token (proposed capability codes.release.manage,
- * audience openvibe.codes — inert until Contracts releases it and Network can grant it):
+ * codes.release.manage — an app managing its OWN releases with its own token:
* POST /apps/:app/releases { kind, manifest, notes, publish } → draft (or published)
* POST /apps/:app/releases { kind, manifest, notes, publish } → draft (or published)
* POST /releases/:id/publish | /deprecate { reason, replacement } | /revoke { reason }
* POST /releases/:id/publish | /deprecate { reason, replacement } | /revoke { reason }
*
*
- * No route accepts a shared loopback key of any kind. Tokens are verified offline against Network's
- * key: issuer, audience openvibe.codes, expiry, sub = app:<the app in the path>, and the capability.
+ * Guards are openvibe-contracts requireCapability() (audience openvibe.codes, issuer, expiry, claim
+ * shape, the capability; sandbox app tokens accepted, release records carry the environment), then
+ * sub = app:<the app in the path>. No route accepts a shared loopback key of any kind.
*/
*/
const express = require('express');
const express = require('express');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const rateLimit = require('express-rate-limit');
const rateLimit = require('express-rate-limit');
-const { http } = require('openvibe-contracts');
+const { http, serviceAuth } = require('openvibe-contracts');
const manifests = require('../domain/manifests');
const manifests = require('../domain/manifests');
const { ReleaseError } = require('../domain/releases');
const { ReleaseError } = require('../domain/releases');
-const { verifyJwt } = require('../auth/keys');
const APP_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;
const APP_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;
const REL_RE = /^rel_[0-9A-HJKMNP-TV-Z]{26}$/;
const REL_RE = /^rel_[0-9A-HJKMNP-TV-Z]{26}$/;
-// Proposed in docs/capabilities-proposal/codes.release.manage.json. Checked by hand against the verified
-// token's `cap` because openvibe-contracts' requireCapability() refuses capabilities it does not know
-// yet; once a contracts release carries it, switch these routes to requireCapability(MANAGE, …).
const MANAGE = 'codes.release.manage';
const MANAGE = 'codes.release.manage';
+const READ = 'codes.release.read';
function createApi(ctx) {
function createApi(ctx) {
- const { config, docs, releases, trust, keys, store } = ctx;
+ const { config, docs, releases, trust, keys } = ctx;
const r = asyncRouter();
const r = asyncRouter();
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
const json = express.json({ limit: '96kb' });
const json = express.json({ limit: '96kb' });
@@ -41,7 +40,7 @@ function createApi(ctx) {
@@ -41,7 +40,7 @@ function createApi(ctx) {
const problem = (req, res, status, code, detail, extra) => http.sendProblem(res, status, code, { detail, ctx: req.ov, extra });
const problem = (req, res, status, code, detail, extra) => http.sendProblem(res, status, code, { detail, ctx: req.ov, extra });
r.get('/docs/versions', (req, res) => {
r.get('/docs/versions', (req, res) => {
- res.json({ contracts: docs.contractsVersion, sdk: docs.sdkVersion, generated_at: docs.generatedAt, contracts_count: docs.contracts.length, capabilities_count: docs.capabilities.length });
+ res.json({ contracts: docs.contractsVersion, contracts_tag: docs.contractsTag, sdk: docs.sdkVersion, sdk_tag: docs.sdkTag, generated_at: docs.generatedAt, contracts_count: docs.contracts.length, capabilities_count: docs.capabilities.length });
});
});
r.post('/manifests/validate', limiter, json, (req, res) => {
r.post('/manifests/validate', limiter, json, (req, res) => {
@@ -52,17 +51,28 @@ function createApi(ctx) {
@@ -52,17 +51,28 @@ function createApi(ctx) {
res.status(v.valid ? 200 : 422).json({ ...v, contracts_version: docs.contractsVersion });
res.status(v.valid ? 200 : 422).json({ ...v, contracts_version: docs.contractsVersion });
});
});
- r.get('/apps/:app/releases', (req, res) => {
+ // Guards (the Network key is fetched before the synchronous guard runs).
+ const guardOpts = { getPublicKey: () => keys.get(), issuer: config.networkIssuer, audience: 'openvibe.codes', acceptSandbox: true };
+ const loadKey = (req, res, next) => { keys.ensure().then(() => next(), () => next()); };
+ const manageGuard = serviceAuth.requireCapability('codes.release.manage', guardOpts);
+ const readGuard = serviceAuth.requireCapability('codes.release.read', guardOpts);
+ const bearer = (req) => String(req.headers.authorization || '').startsWith('Bearer ');
+ // Public reads: anonymous callers pass; a presented token must be valid and hold codes.release.read.
+ const readAccess = [loadKey, (req, res, next) => (bearer(req) ? readGuard(req, res, next) : next())];
+ const manageAccess = [loadKey, (req, res, next) => (bearer(req) ? manageGuard(req, res, next)
+ : problem(req, res, 401, 'auth.required', 'send Authorization: Bearer <app token for audience openvibe.codes>'))];
+
+ r.get('/apps/:app/releases', ...readAccess, (req, res) => {
if (!APP_RE.test(req.params.app)) return problem(req, res, 404, 'app.not_found', 'not an app id');
if (!APP_RE.test(req.params.app)) return problem(req, res, 404, 'app.not_found', 'not an app id');
res.set('Cache-Control', 'public, max-age=60');
res.set('Cache-Control', 'public, max-age=60');
res.json({ app_id: req.params.app, trust: trust.get(req.params.app), releases: releases.listForApp(req.params.app) });
res.json({ app_id: req.params.app, trust: trust.get(req.params.app), releases: releases.listForApp(req.params.app) });
});
});
- r.get('/apps/:app/trust', (req, res) => {
+ r.get('/apps/:app/trust', ...readAccess, (req, res) => {
if (!APP_RE.test(req.params.app)) return problem(req, res, 404, 'app.not_found', 'not an app id');
if (!APP_RE.test(req.params.app)) return problem(req, res, 404, 'app.not_found', 'not an app id');
res.set('Cache-Control', 'public, max-age=60');
res.set('Cache-Control', 'public, max-age=60');
res.json({ app_id: req.params.app, ...trust.get(req.params.app), note_on_authority: 'metadata only; grants in OpenVibe.Network are the authority' });
res.json({ app_id: req.params.app, ...trust.get(req.params.app), note_on_authority: 'metadata only; grants in OpenVibe.Network are the authority' });
});
});
- r.get('/releases/:id', (req, res) => {
+ r.get('/releases/:id', ...readAccess, (req, res) => {
const rel = REL_RE.test(req.params.id) ? releases.get(req.params.id) : null;
const rel = REL_RE.test(req.params.id) ? releases.get(req.params.id) : null;
if (!rel || rel.status === 'draft') return problem(req, res, 404, 'release.not_found', 'no such release');
if (!rel || rel.status === 'draft') return problem(req, res, 404, 'release.not_found', 'no such release');
res.set('Cache-Control', 'public, max-age=60');
res.set('Cache-Control', 'public, max-age=60');
@@ -71,15 +81,10 @@ function createApi(ctx) {
@@ -71,15 +81,10 @@ function createApi(ctx) {
});
});
// ── App token: an app manages its own releases ──────────
// ── App token: an app manages its own releases ──────────
- async function appPrincipal(req, res, appId) {
- const h = String(req.headers.authorization || '');
- if (!h.startsWith('Bearer ')) { problem(req, res, 401, 'auth.required', 'send Authorization: Bearer <app token for audience openvibe.codes>'); return null; }
- await keys.ensure();
- const v = verifyJwt(h.slice(7).trim(), { publicKey: keys.get(), issuer: config.networkIssuer, audience: 'openvibe.codes', now: store.now() });
- if (!v.ok) { problem(req, res, 401, v.expired ? 'token.expired' : 'token.invalid', v.reason); return null; }
- const c = v.claims;
- if (typeof c.sub !== 'string' || !c.sub.startsWith('app:')) { problem(req, res, 403, 'capability.denied', 'only app tokens manage releases here; people use the portal'); return null; }
- if (!Array.isArray(c.cap) || !c.cap.includes(MANAGE)) { problem(req, res, 403, 'capability.denied', `${MANAGE} not granted`); return null; }
+ /** After manageGuard: the verified token's app, project and environment. */
+ function appPrincipal(req, res, appId) {
+ const c = claimsOf(req);
+ if (!c || typeof c.sub !== 'string' || !c.sub.startsWith('app:')) { problem(req, res, 403, 'capability.denied', 'only app tokens manage releases here; people use the portal'); return null; }
const self = c.sub.slice(4);
const self = c.sub.slice(4);
if (appId && self !== appId) { problem(req, res, 403, 'release.forbidden', 'an app manages only its own releases'); return null; }
if (appId && self !== appId) { problem(req, res, 403, 'release.forbidden', 'an app manages only its own releases'); return null; }
return {
return {
@@ -87,15 +92,21 @@ function createApi(ctx) {
@@ -87,15 +92,21 @@ function createApi(ctx) {
app: { id: self, project_id: typeof c.project_id === 'string' ? c.project_id : (Array.isArray(c.ns) ? c.ns[0] : null), environment: c.env === 'production' ? 'production' : 'sandbox', revoked_at: null },
app: { id: self, project_id: typeof c.project_id === 'string' ? c.project_id : (Array.isArray(c.ns) ? c.ns[0] : null), environment: c.env === 'production' ? 'production' : 'sandbox', revoked_at: null },
};
};
}
}
+ // requireCapability already verified this token (signature, issuer, audience, expiry, claims);
+ // read the claims it does not copy onto req.principal (project_id, env).
+ function claimsOf(req) {
+ if (!req.principal || !req.principal.sub) return null;
+ try { return JSON.parse(Buffer.from(String(req.headers.authorization).slice(7).trim().split('.')[1], 'base64url').toString('utf8')); } catch { return null; }
+ }
const fail = (req, res, err) => {
const fail = (req, res, err) => {
if (err instanceof ReleaseError) return problem(req, res, err.status, err.code, err.detail, err.validation ? { validation: err.validation } : undefined);
if (err instanceof ReleaseError) return problem(req, res, err.status, err.code, err.detail, err.validation ? { validation: err.validation } : undefined);
ctx.log.error('[Codes] api error:', err && err.message ? err.message.slice(0, 200) : err);
ctx.log.error('[Codes] api error:', err && err.message ? err.message.slice(0, 200) : err);
return problem(req, res, 500, 'internal.error', 'Internal error');
return problem(req, res, 500, 'internal.error', 'Internal error');
};
};
- r.post('/apps/:app/releases', limiter, json, async (req, res) => {
+ r.post('/apps/:app/releases', limiter, ...manageAccess, json, (req, res) => {
if (!APP_RE.test(req.params.app)) return problem(req, res, 404, 'app.not_found', 'not an app id');
if (!APP_RE.test(req.params.app)) return problem(req, res, 404, 'app.not_found', 'not an app id');
- const p = await appPrincipal(req, res, req.params.app);
+ const p = appPrincipal(req, res, req.params.app);
if (!p) return;
if (!p) return;
if (!p.app.project_id) return problem(req, res, 403, 'token.invalid_claims', 'the token names no project');
if (!p.app.project_id) return problem(req, res, 403, 'token.invalid_claims', 'the token names no project');
const b = req.body || {};
const b = req.body || {};
@@ -109,10 +120,10 @@ function createApi(ctx) {
@@ -109,10 +120,10 @@ function createApi(ctx) {
});
});
for (const action of ['publish', 'deprecate', 'revoke']) {
for (const action of ['publish', 'deprecate', 'revoke']) {
- r.post(`/releases/:id/${action}`, limiter, json, async (req, res) => {
+ r.post(`/releases/:id/${action}`, limiter, ...manageAccess, json, (req, res) => {
const rel = REL_RE.test(req.params.id) ? releases.get(req.params.id) : null;
const rel = REL_RE.test(req.params.id) ? releases.get(req.params.id) : null;
if (!rel) return problem(req, res, 404, 'release.not_found', 'no such release');
if (!rel) return problem(req, res, 404, 'release.not_found', 'no such release');
- const p = await appPrincipal(req, res, rel.app_id);
+ const p = appPrincipal(req, res, rel.app_id);
if (!p) return;
if (!p) return;
const b = req.body || {};
const b = req.body || {};
try {
try {
@@ -134,4 +145,4 @@ function createApi(ctx) {
@@ -134,4 +145,4 @@ function createApi(ctx) {
return r;
return r;
}
}
-module.exports = { createApi, MANAGE };
+module.exports = { createApi, MANAGE, READ };
@@ -23,9 +23,10 @@ function createDocsRoutes(ctx) {
@@ -23,9 +23,10 @@ function createDocsRoutes(ctx) {
const r = asyncRouter();
const r = asyncRouter();
const PUBLIC_CACHE = 'public, max-age=300';
const PUBLIC_CACHE = 'public, max-age=300';
+ const tagNote = (tag, version) => (tag !== `v${version}` ? html` (tag ${tag})` : '');
const versions = () => html`<p class="versions">Generated at ${time(docs.generatedAt)} from
const versions = () => html`<p class="versions">Generated at ${time(docs.generatedAt)} from
-<a href="https://github.com/OpenVibers/OpenVibe.Contracts/tree/v${docs.contractsVersion}"><code>openvibe-contracts v${docs.contractsVersion}</code></a> and
-<a href="https://github.com/OpenVibers/OpenVibe.SDK/tree/v${docs.sdkVersion}"><code>openvibe-sdk v${docs.sdkVersion}</code></a>.</p>`;
+<a href="https://github.com/OpenVibers/OpenVibe.Contracts/tree/${docs.contractsTag}"><code>openvibe-contracts v${docs.contractsVersion}</code></a>${tagNote(docs.contractsTag, docs.contractsVersion)} and
+<a href="https://github.com/OpenVibers/OpenVibe.SDK/tree/${docs.sdkTag}"><code>openvibe-sdk v${docs.sdkVersion}</code></a>${tagNote(docs.sdkTag, docs.sdkVersion)}.</p>`;
const page = (req, res, o, status = 200) => send(res, status, { index: true, cache: PUBLIC_CACHE, viewer: req.viewer, config, path: req.originalUrl, ...o });
const page = (req, res, o, status = 200) => send(res, status, { index: true, cache: PUBLIC_CACHE, viewer: req.viewer, config, path: req.originalUrl, ...o });
r.get('/', (req, res) => {
r.get('/', (req, res) => {
@@ -45,8 +46,8 @@ function createDocsRoutes(ctx) {
@@ -45,8 +46,8 @@ function createDocsRoutes(ctx) {
<li><a href="/policy/rfc"><strong>Decisions</strong></a><span>${docs.adrs.length} architecture decision records</span></li>
<li><a href="/policy/rfc"><strong>Decisions</strong></a><span>${docs.adrs.length} architecture decision records</span></li>
</ul>
</ul>
<h2>Install</h2>
<h2>Install</h2>
-<pre><code>npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v${docs.sdkVersion}
-npm install https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v${docs.contractsVersion}</code></pre>
+<pre><code>npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/${docs.sdkTag}
+npm install https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/${docs.contractsTag}</code></pre>
<p>The SDK is ${docs.sdkLicense}; the contracts are ${docs.contractsLicense}. The SDK release is tested against contracts <code>${docs.sdkContractsRange || 'n/a'}</code>.</p>`,
<p>The SDK is ${docs.sdkLicense}; the contracts are ${docs.contractsLicense}. The SDK release is tested against contracts <code>${docs.sdkContractsRange || 'n/a'}</code>.</p>`,
});
});
});
});
@@ -147,7 +147,7 @@ ${Array.isArray(status.notYet) ? html`<h3>Not yet</h3><ul>${status.notYet.map((w
@@ -147,7 +147,7 @@ ${Array.isArray(status.notYet) ? html`<h3>Not yet</h3><ul>${status.notYet.map((w
crumbs: [{ label: 'Apps' }, { label: req.params.app }],
crumbs: [{ label: 'Apps' }, { label: req.params.app }],
body: html`<h1>${list[0] ? list[0].name : 'App'} <small><code>${req.params.app}</code></small></h1>
body: html`<h1>${list[0] ? list[0].name : 'App'} <small><code>${req.params.app}</code></small></h1>
<p>Trust tier: ${badge(t.tier)} ${t.note ? html`<span class="muted">— ${t.note}</span>` : ''}</p>
<p>Trust tier: ${badge(t.tier)} ${t.note ? html`<span class="muted">— ${t.note}</span>` : ''}</p>
-<p class="muted small">Trust tiers are metadata. They never grant anything: what an app may do comes only from its grants in OpenVibe.Network.</p>
+<p class="muted small">Trust tiers (<a href="/docs/adr/ADR-013">ADR-013</a>: unreviewed, reviewed, first-party) are metadata. They change defaults and discovery, never a grant check: what an app may do comes only from its grants in OpenVibe.Network.</p>
<h2>Releases</h2>
<h2>Releases</h2>
${table(['Version', 'Kind', 'Environment', 'Status', 'Published', 'Compatibility'], list.map((x) => [
${table(['Version', 'Kind', 'Environment', 'Status', 'Published', 'Compatibility'], list.map((x) => [
html`<a href="/releases/${x.id}">${x.version}</a>`, x.kind, x.environment, statusBadge(x.status), time(x.published_at),
html`<a href="/releases/${x.id}">${x.version}</a>`, x.kind, x.environment, statusBadge(x.status), time(x.published_at),
@@ -204,7 +204,7 @@ ${signedIn ? releaseActions(req, rel) : ''}
@@ -204,7 +204,7 @@ ${signedIn ? releaseActions(req, rel) : ''}
page(req, res, {
page(req, res, {
title: 'Staff: trust tiers', crumbs: [{ label: 'Staff' }],
title: 'Staff: trust tiers', crumbs: [{ label: 'Staff' }],
body: html`<h1>Trust tiers</h1>${req.query.done ? notice('Saved.', 'ok') : ''}
body: html`<h1>Trust tiers</h1>${req.query.done ? notice('Saved.', 'ok') : ''}
-<p>Metadata only: a tier never grants, allows or bypasses anything. Grants and allowances are set in Network.</p>
+<p>Tiers follow <a href="/docs/adr/ADR-013">ADR-013</a>: <code>unreviewed</code> (the default), <code>reviewed</code>, <code>first-party</code>. Metadata only: a tier never grants, allows or bypasses anything. Grants and allowances are set in Network.</p>
<form method="post" action="/staff/trust" class="stack">${csrfField(csrfToken(config, req.viewer))}
<form method="post" action="/staff/trust" class="stack">${csrfField(csrfToken(config, req.viewer))}
<label>App id <input name="app_id" required pattern="app_[0-9A-HJKMNP-TV-Z]{26}"></label>
<label>App id <input name="app_id" required pattern="app_[0-9A-HJKMNP-TV-Z]{26}"></label>
<label>Tier <select name="tier">${trust.TIERS.map((t) => html`<option>${t}</option>`)}</select></label>
<label>Tier <select name="tier">${trust.TIERS.map((t) => html`<option>${t}</option>`)}</select></label>
@@ -461,10 +461,9 @@ ${manage ? html`<h2>Revoke this app</h2><form method="post" action="${base}/revo
@@ -461,10 +461,9 @@ ${manage ? html`<h2>Revoke this app</h2><form method="post" action="${base}/revo
<p>Calls here run <strong>as the app</strong>, with its own token, against the real services: the playground can do exactly what this app is granted and nothing more. Codes' own credentials are never used. Sandbox apps only.</p>
<p>Calls here run <strong>as the app</strong>, with its own token, against the real services: the playground can do exactly what this app is granted and nothing more. Codes' own credentials are never used. Sandbox apps only.</p>
${result ? runResult(result) : ''}
${result ? runResult(result) : ''}
<h2>${playground.KINDS.events.label}</h2>
<h2>${playground.KINDS.events.label}</h2>
-<p class="muted small">Needs <code>events.event.publish</code> (audience <code>openvibe.events</code>). Endpoint: <code>${config.playground.eventsUrl}</code>.</p>
+<p class="muted small">Needs <code>events.app.publish</code> (audience <code>openvibe.events</code>). This app may publish types starting with <code>app.${playground.projectKey(project.id)}.</code> with source <code>${playground.appSource(app.id)}</code>; sandbox events reach sandbox subscriptions only. Endpoint: <code>${config.playground.eventsUrl}</code>.</p>
${pre(events)}${events.ok ? html`<form method="post" action="${base}/playground/events" class="stack">${csrfField(c)}
${pre(events)}${events.ok ? html`<form method="post" action="${base}/playground/events" class="stack">${csrfField(c)}
-<label>Event type <input name="event_type" required value="${values.event_type || ''}" placeholder="myapp.thing.happened"></label>
-<label>Source <input name="source" required value="${values.source || ''}" placeholder="myapp"></label>
+<label>Event type <input name="event_type" required value="${values.event_type || `app.${playground.projectKey(project.id)}.test.ping`}"></label>
<label>Subject type <input name="subject_type" value="${values.subject_type || 'test'}"></label>
<label>Subject type <input name="subject_type" value="${values.subject_type || 'test'}"></label>
<label>Subject id <input name="subject_id" value="${values.subject_id || 'test-1'}"></label>
<label>Subject id <input name="subject_id" value="${values.subject_id || 'test-1'}"></label>
<label>Payload (JSON object) <textarea name="payload" rows="4" spellcheck="false">${values.payload || '{}'}</textarea></label>
<label>Payload (JSON object) <textarea name="payload" rows="4" spellcheck="false">${values.payload || '{}'}</textarea></label>
@@ -497,10 +496,10 @@ ${r.result ? html`<pre><code>${JSON.stringify(r.result, null, 2)}</code></pre>`
@@ -497,10 +496,10 @@ ${r.result ? html`<pre><code>${JSON.stringify(r.result, null, 2)}</code></pre>`
const out = await playground.run({
const out = await playground.run({
actor: `user:${req.viewer.subject}`, app: got.data, kind: 'events',
actor: `user:${req.viewer.subject}`, app: got.data, kind: 'events',
credential: { type: b.credential_type === 'access_token' ? 'access_token' : 'client_secret', value: typeof b.credential === 'string' ? b.credential.trim() : '' },
credential: { type: b.credential_type === 'access_token' ? 'access_token' : 'client_secret', value: typeof b.credential === 'string' ? b.credential.trim() : '' },
- input: { event_type: b.event_type, source: b.source, subject_type: b.subject_type, subject_id: b.subject_id, payload: b.payload },
+ input: { event_type: b.event_type, subject_type: b.subject_type, subject_id: b.subject_id, payload: b.payload },
});
});
// The credential is NOT passed back: the form comes back with it empty.
// The credential is NOT passed back: the form comes back with it empty.
- return playgroundPage(req, res, { result: out, values: { event_type: b.event_type, source: b.source, subject_type: b.subject_type, subject_id: b.subject_id, payload: b.payload }, status: statusFor(out) });
+ return playgroundPage(req, res, { result: out, values: { event_type: b.event_type, subject_type: b.subject_type, subject_id: b.subject_id, payload: b.payload }, status: statusFor(out) });
});
});
r.post('/:project/apps/:app/playground/media', idParams, async (req, res) => {
r.post('/:project/apps/:app/playground/media', idParams, async (req, res) => {
@@ -134,14 +134,12 @@ ${v.parsed && v.parsed.note ? html`<dt>Body</dt><dd>${v.parsed.note}</dd>` : ''}
@@ -134,14 +134,12 @@ ${v.parsed && v.parsed.note ? html`<dt>Body</dt><dd>${v.parsed.note}</dd>` : ''}
});
});
// ── Manifest validator ──────────────────────────────────
// ── Manifest validator ──────────────────────────────────
- const info = manifests.appSchemaInfo();
const validatorPage = (req, res, { status = 200, kind = 'app', text = null, result = null, parseError = null } = {}) => send(res, status, {
const validatorPage = (req, res, { status = 200, kind = 'app', text = null, result = null, parseError = null } = {}) => send(res, status, {
viewer: req.viewer, config, path: '/manifests/validate', title: 'Manifest validator', index: true,
viewer: req.viewer, config, path: '/manifests/validate', title: 'Manifest validator', index: true,
crumbs: [{ label: 'Manifests' }],
crumbs: [{ label: 'Manifests' }],
body: html`<h1>Manifest validator</h1>
body: html`<h1>Manifest validator</h1>
<p>Checks an app manifest or a mod manifest with <code>openvibe-contracts v${docs.contractsVersion}</code>: the JSON Schema first, then that every requested capability exists and can be granted to apps, that version ranges parse, and that consumed events are produced by someone. Nothing is stored. To publish a release, open your app's page.</p>
<p>Checks an app manifest or a mod manifest with <code>openvibe-contracts v${docs.contractsVersion}</code>: the JSON Schema first, then that every requested capability exists and can be granted to apps, that version ranges parse, and that consumed events are produced by someone. Nothing is stored. To publish a release, open your app's page.</p>
-<ul class="plain small muted"><li>mod: <a href="/docs/contracts/mods.mod-manifest"><code>mods.mod-manifest@1</code></a> (released)</li>
-<li>app: <code>${info.id}@1</code> ${info.released ? '(released)' : html`— a proposal Codes validates with until Contracts releases it (<a href="https://github.com/OpenVibers/OpenVibe.Codes/blob/main/docs/contracts-proposal/codes/app-manifest.v1.json">schema</a>)`}</li></ul>
+<ul class="plain small muted"><li>app: <a href="/docs/contracts/codes.app-manifest"><code>codes.app-manifest@1</code></a></li><li>mod: <a href="/docs/contracts/mods.mod-manifest"><code>mods.mod-manifest@1</code></a></li></ul>
<form method="post" action="/manifests/validate" class="stack">
<form method="post" action="/manifests/validate" class="stack">
<label>Kind <select name="kind"><option value="app"${kind === 'app' ? raw(' selected') : ''}>app</option><option value="mod"${kind === 'mod' ? raw(' selected') : ''}>mod</option></select></label>
<label>Kind <select name="kind"><option value="app"${kind === 'app' ? raw(' selected') : ''}>app</option><option value="mod"${kind === 'mod' ? raw(' selected') : ''}>mod</option></select></label>
<label>Manifest (JSON) <textarea name="manifest" rows="18" spellcheck="false">${text != null ? text : JSON.stringify(manifests.template(kind), null, 2)}</textarea></label>
<label>Manifest (JSON) <textarea name="manifest" rows="18" spellcheck="false">${text != null ? text : JSON.stringify(manifests.template(kind), null, 2)}</textarea></label>
@@ -1,50 +1,49 @@
@@ -1,50 +1,49 @@
'use strict';
'use strict';
/**
/**
- * The proposals Codes ships validate against the released contract schemas, and they describe what
- * the code actually does: the events the outbox can produce and the capability the API checks.
+ * The released contracts (openvibe-contracts tag v0.27.0) describe what the code does: the codes
+ * service manifest's events are exactly what the outbox can produce, its capabilities are the ones
+ * the API guards with requireCapability, and the release API answers with the contracts' problems.
*/
*/
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const { EVENT_TYPES } = require('../server/events/outbox');
const { EVENT_TYPES } = require('../server/events/outbox');
-const { MANAGE } = require('../server/http/api');
+const { MANAGE, READ } = require('../server/http/api');
const { check, done } = require('./helpers/boot');
const { check, done } = require('./helpers/boot');
-const DOCS = path.join(__dirname, '..', 'docs');
-
(async () => {
(async () => {
- const caps = fs.readdirSync(path.join(DOCS, 'capabilities-proposal')).map((f) => JSON.parse(fs.readFileSync(path.join(DOCS, 'capabilities-proposal', f), 'utf8')));
- const service = JSON.parse(fs.readFileSync(path.join(DOCS, 'service-manifest-proposal.json'), 'utf8'));
+ const manifest = contracts.services.get('codes');
- await check('capability proposals validate as capabilities.capability@1 and are owned by codes', async () => {
- assert.ok(caps.length >= 2);
- for (const c of caps) {
- const v = contracts.validate('capabilities.capability@1', c);
- assert.ok(v.valid, `${c.id}: ${JSON.stringify(v.errors)}`);
- assert.strictEqual(c.owner, 'codes');
- assert.ok(c.id.startsWith('codes.'));
- assert.ok(!contracts.capabilities.get(c.id), `${c.id} is already released; drop the proposal`);
- }
+ await check('the codes service manifest is released as alpha, with this code\'s events', async () => {
+ assert.ok(manifest);
+ assert.strictEqual(manifest.status, 'alpha');
+ assert.deepStrictEqual(manifest.domains, ['openvibe.codes']);
+ assert.deepStrictEqual([...manifest.eventsProduced].sort(), [...EVENT_TYPES].sort());
+ for (const e of manifest.eventsProduced) assert.strictEqual(e.split('.').length, 3, `${e} has three segments`);
+ const range = manifest.contractRanges['openvibe-contracts'];
+ assert.ok(require('openvibe-sdk/core').satisfiesRange(require('openvibe-contracts/package.json').version, range));
});
});
- await check('the service manifest proposal validates and matches the code', async () => {
- const v = contracts.validate('registry.service-manifest@1', service);
- assert.ok(v.valid, JSON.stringify(v.errors));
- assert.strictEqual(service.id, 'codes');
- assert.deepStrictEqual([...service.eventsProduced].sort(), [...EVENT_TYPES].sort());
- assert.deepStrictEqual([...service.capabilities].sort(), caps.map((c) => c.id).sort());
- assert.ok(service.capabilities.includes(MANAGE));
- for (const e of service.eventsProduced) assert.strictEqual(e.split('.').length, 3, `${e} has three segments`);
- const range = service.contractRanges['openvibe-contracts'];
- assert.ok(require('openvibe-sdk/core').satisfiesRange(require('openvibe-contracts/package.json').version, range));
+ await check('its capabilities exist, are owned by codes, and are the ones the API guards', async () => {
+ assert.deepStrictEqual([...manifest.capabilities].sort(), [MANAGE, READ].sort());
+ for (const id of manifest.capabilities) {
+ const c = contracts.capabilities.get(id);
+ assert.ok(c, id);
+ assert.strictEqual(c.owner, 'codes');
+ assert.strictEqual(c.status, 'active');
+ }
+ const api = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'api.js'), 'utf8');
+ assert.match(api, /requireCapability\('codes\.release\.manage'/);
+ assert.match(api, /requireCapability\('codes\.release\.read'/);
});
});
- await check('the app manifest proposal is a valid JSON Schema the contracts validator compiles', async () => {
- const schema = JSON.parse(fs.readFileSync(path.join(DOCS, 'contracts-proposal', 'codes', 'app-manifest.v1.json'), 'utf8'));
- assert.strictEqual(schema.$id, 'https://openvibe.network/contracts/codes/app-manifest.v1.json');
- const info = require('../server/domain/manifests').appSchemaInfo();
- assert.strictEqual(info.id, 'codes.app-manifest');
+ await check('no proposal left behind: everything proposed is released', async () => {
+ const docs = path.join(__dirname, '..', 'docs');
+ assert.ok(!fs.existsSync(path.join(docs, 'capabilities-proposal')));
+ assert.ok(!fs.existsSync(path.join(docs, 'service-manifest-proposal.json')));
+ assert.ok(!fs.existsSync(path.join(docs, 'contracts-proposal')));
+ assert.ok(contracts.resolve('codes.app-manifest@1'));
});
});
done();
done();
@@ -17,24 +17,29 @@ const sdkPkg = require('openvibe-sdk/package.json');
@@ -17,24 +17,29 @@ const sdkPkg = require('openvibe-sdk/package.json');
(async () => {
(async () => {
const t = await boot();
const t = await boot();
- await check('package.json pins exactly the installed contracts, SDK and shared releases', async () => {
- const tag = (dep) => (pkg.dependencies[dep].match(/refs\/tags\/v([0-9.]+)$/) || [])[1];
- assert.strictEqual(tag('openvibe-contracts'), contractsVersion);
- assert.strictEqual(tag('openvibe-sdk'), sdkPkg.version);
- assert.strictEqual(tag('openvibe-shared'), require('openvibe-shared/package.json').version);
- assert.strictEqual(contractsVersion, '0.26.0');
+ await check('package.json pins the contracts, SDK and shared tags that are installed', async () => {
+ const tag = (dep) => (pkg.dependencies[dep].match(/refs\/tags\/(v[0-9.]+)$/) || [])[1];
+ assert.strictEqual(tag('openvibe-contracts'), 'v0.27.0');
+ assert.strictEqual(tag('openvibe-sdk'), `v${sdkPkg.version}`);
+ assert.strictEqual(tag('openvibe-shared'), `v${require('openvibe-shared/package.json').version}`);
assert.strictEqual(sdkPkg.version, '0.2.2');
assert.strictEqual(sdkPkg.version, '0.2.2');
+ // The contracts tag v0.27.0 carries the released codes manifest and codes.app-manifest@1.
+ assert.ok(contracts.services.get('codes') && contracts.services.get('codes').status === 'alpha');
+ assert.ok(contracts.resolve('codes.app-manifest@1'));
});
});
await check('every docs page states the versions it was generated from', async () => {
await check('every docs page states the versions it was generated from', async () => {
for (const p of ['/docs', '/docs/contracts', '/docs/capabilities', '/docs/events', '/docs/sdk', '/docs/contracts/errors.problem', '/docs/sdk/core']) {
for (const p of ['/docs', '/docs/contracts', '/docs/capabilities', '/docs/events', '/docs/sdk', '/docs/contracts/errors.problem', '/docs/sdk/core']) {
const r = await t.get(p);
const r = await t.get(p);
assert.strictEqual(r.status, 200, p);
assert.strictEqual(r.status, 200, p);
- assert.ok(r.text.includes(`openvibe-contracts v${contractsVersion}`), `${p} names the contracts version`);
+ assert.ok(r.text.includes(`openvibe-contracts v${contractsVersion}`), `${p} names the installed contracts version`);
+ assert.ok(r.text.includes('OpenVibe.Contracts/tree/v0.27.0'), `${p} links the pinned tag`);
+ if (contractsVersion !== '0.27.0') assert.ok(r.text.includes('(tag v0.27.0)'), `${p} says the tag differs from the package version`);
assert.ok(r.text.includes(`openvibe-sdk v${sdkPkg.version}`), `${p} names the SDK version`);
assert.ok(r.text.includes(`openvibe-sdk v${sdkPkg.version}`), `${p} names the SDK version`);
}
}
const v = (await t.get('/api/v1/docs/versions')).json();
const v = (await t.get('/api/v1/docs/versions')).json();
assert.strictEqual(v.contracts, contractsVersion);
assert.strictEqual(v.contracts, contractsVersion);
+ assert.strictEqual(v.contracts_tag, 'v0.27.0');
assert.strictEqual(v.sdk, sdkPkg.version);
assert.strictEqual(v.sdk, sdkPkg.version);
});
});
@@ -306,9 +306,20 @@ async function startEvents(network) {
@@ -306,9 +306,20 @@ async function startEvents(network) {
if (req.url === '/api/v1/events' && req.method === 'POST') {
if (req.url === '/api/v1/events' && req.method === 'POST') {
const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.events', acceptSandbox: true });
const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.events', acceptSandbox: true });
if (!v.ok) return json(401, { type: 'x', title: 'Unauthorized', status: 401, code: v.code, detail: v.reason });
if (!v.ok) return json(401, { type: 'x', title: 'Unauthorized', status: 401, code: v.code, detail: v.reason });
- if (!v.claims.cap.includes('events.event.publish')) return json(403, { type: 'x', title: 'Forbidden', status: 403, code: 'capability.denied', detail: 'events.event.publish not granted' });
+ const isApp = String(v.claims.sub).startsWith('app:');
+ const needed = isApp ? 'events.app.publish' : 'events.event.publish';
+ if (!v.claims.cap.includes(needed)) return json(403, { type: 'x', title: 'Forbidden', status: 403, code: 'capability.denied', detail: `${needed} not granted` });
const body = JSON.parse(raw.toString('utf8'));
const body = JSON.parse(raw.toString('utf8'));
const events = body.events || [body];
const events = body.events || [body];
+ if (isApp) {
+ // events.app.publish: app.<project_key>.* types, source app-<lowercase app ULID>.
+ const key = `p${v.claims.project_id.replace(/^prj_/, '').toLowerCase()}`;
+ const src = `app-${v.claims.sub.replace(/^app:app_/, '').toLowerCase()}`;
+ for (const e of events) {
+ if (!e.event_type.startsWith(`app.${key}.`)) return json(403, { type: 'x', title: 'Forbidden', status: 403, code: 'events.type_not_allowed', detail: `apps publish app.${key}.* only` });
+ if (e.source !== src) return json(403, { type: 'x', title: 'Forbidden', status: 403, code: 'events.source_mismatch', detail: `source must be ${src}` });
+ }
+ }
const results = events.map((e) => { published.push({ event: e, sub: v.claims.sub }); return { event_id: e.event_id, seq: ++seq, duplicate: false }; });
const results = events.map((e) => { published.push({ event: e, sub: v.claims.sub }); return { event_id: e.event_id, seq: ++seq, duplicate: false }; });
return json(201, body.events ? { results } : results[0]);
return json(201, body.events ? { results } : results[0]);
}
}
@@ -72,12 +72,14 @@ const PRJ = 'prj_01JABCDEFGHJKMNPQRSTVWXYZ0';
@@ -72,12 +72,14 @@ const PRJ = 'prj_01JABCDEFGHJKMNPQRSTVWXYZ0';
assert.ok(ungranted.warnings.some((w) => /not granted to this app yet/.test(w.message)));
assert.ok(ungranted.warnings.some((w) => /not granted to this app yet/.test(w.message)));
});
});
- await check('the app manifest schema is loaded into openvibe-contracts\' validator (proposal until released)', async () => {
- const info = manifests.appSchemaInfo();
- assert.strictEqual(info.id, 'codes.app-manifest');
- assert.strictEqual(info.released, false);
+ await check('app manifests validate as the released codes.app-manifest@1, fixtures included', async () => {
+ assert.strictEqual(manifests.SCHEMAS.app, 'codes.app-manifest@1');
+ const dir = path.join(path.dirname(require.resolve('openvibe-contracts/package.json')), 'fixtures', 'codes.app-manifest');
+ for (const f of fs.readdirSync(path.join(dir, 'valid'))) assert.deepStrictEqual(manifests.validate('app', read(path.join(dir, 'valid', f))).errors.filter((e) => !/not in openvibe-contracts|never granted/.test(e.message)), [], f);
+ for (const f of fs.readdirSync(path.join(dir, 'invalid'))) assert.strictEqual(manifests.validate('app', read(path.join(dir, 'invalid', f))).valid, false, f);
const v = manifests.validate('app', { ...manifests.template('app'), publisher: { type: 'nobody', id: 'x' } });
const v = manifests.validate('app', { ...manifests.template('app'), publisher: { type: 'nobody', id: 'x' } });
assert.ok(v.errors.some((e) => e.path.startsWith('/publisher')), 'the $ref to identity.subject-ref resolves');
assert.ok(v.errors.some((e) => e.path.startsWith('/publisher')), 'the $ref to identity.subject-ref resolves');
+ assert.strictEqual(v.schema.id, 'codes.app-manifest');
});
});
const t = await boot();
const t = await boot();
@@ -27,7 +27,7 @@ function files(dir) {
@@ -27,7 +27,7 @@ function files(dir) {
await check('no mention in server/, public/, deploy/, docs/*.json, .env.example, package.json or CI', async () => {
await check('no mention in server/, public/, deploy/, docs/*.json, .env.example, package.json or CI', async () => {
const targets = [
const targets = [
...files(path.join(ROOT, 'server')), ...files(path.join(ROOT, 'public')), ...files(path.join(ROOT, 'deploy')),
...files(path.join(ROOT, 'server')), ...files(path.join(ROOT, 'public')), ...files(path.join(ROOT, 'deploy')),
- ...files(path.join(ROOT, 'docs')).filter((f) => f.endsWith('.json')),
+ ...(fs.existsSync(path.join(ROOT, 'docs')) ? files(path.join(ROOT, 'docs')).filter((f) => f.endsWith('.json')) : []),
path.join(ROOT, '.env.example'), path.join(ROOT, 'package.json'), path.join(ROOT, '.github', 'workflows', 'ci.yml'),
path.join(ROOT, '.env.example'), path.join(ROOT, 'package.json'), path.join(ROOT, '.github', 'workflows', 'ci.yml'),
].filter((f) => fs.existsSync(f));
].filter((f) => fs.existsSync(f));
assert.ok(targets.length > 20);
assert.ok(targets.length > 20);
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
* - with the grant: the app's own token (from its secret, or pasted) is used through the SDK
* - with the grant: the app's own token (from its secret, or pasted) is used through the SDK
* - a pasted token for another app, another audience or without the capability is refused
* - a pasted token for another app, another audience or without the capability is refused
* - the Network refusing the token request surfaces as Network said it
* - the Network refusing the token request surfaces as Network said it
- * - events.event.publish is internal in contracts v0.26.0: the page says it can never be granted
+ * - the Events playground uses events.app.publish: app.<project_key>.* types, source app-<ulid>
*/
*/
const assert = require('assert');
const assert = require('assert');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
@@ -18,6 +18,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -18,6 +18,7 @@ const { boot, check, done } = require('./helpers/boot');
const projectId = await t.project(owner, 'Play');
const projectId = await t.project(owner, 'Play');
const app = await t.app(owner, projectId);
const app = await t.app(owner, projectId);
const base = `/projects/${projectId}/apps/${app.id}`;
const base = `/projects/${projectId}/apps/${app.id}`;
+ const key = `p${projectId.replace(/^prj_/, '').toLowerCase()}`;
const media = (fields, file = { name: 'a.txt', content: 'hello' }) => t.get(`${base}/playground/media`, { as: owner, multipart: { fields, file } });
const media = (fields, file = { name: 'a.txt', content: 'hello' }) => t.get(`${base}/playground/media`, { as: owner, multipart: { fields, file } });
const outside = () => ({
const outside = () => ({
token: t.network.tokenRequests.filter((x) => x.client_id === app.id).length,
token: t.network.tokenRequests.filter((x) => x.client_id === app.id).length,
@@ -37,11 +38,12 @@ const { boot, check, done } = require('./helpers/boot');
@@ -37,11 +38,12 @@ const { boot, check, done } = require('./helpers/boot');
assert.ok(!page.text.includes(`action="${base}/playground/media"`), 'no upload form without the grant');
assert.ok(!page.text.includes(`action="${base}/playground/media"`), 'no upload form without the grant');
});
});
- await check('the events playground explains that events.event.publish is never granted to apps', async () => {
+ await check('without events.app.publish: refused, the grant named, nothing requested or called', async () => {
const before = outside();
const before = outside();
- const r = await t.get(`${base}/playground/events`, { as: owner, form: { event_type: 'play.thing.done', source: 'play', credential_type: 'client_secret', credential: app.secret } });
+ const r = await t.get(`${base}/playground/events`, { as: owner, form: { event_type: `app.${key}.test.ping`, credential_type: 'client_secret', credential: app.secret } });
assert.strictEqual(r.status, 403);
assert.strictEqual(r.status, 403);
- assert.match(r.text, /events\.event\.publish is internal in openvibe-contracts 0\.26\.0/);
+ assert.match(r.text, /does not hold events\.app\.publish/);
+ assert.match(r.text, /Request events\.app\.publish on the app page/);
assert.deepStrictEqual(outside(), before);
assert.deepStrictEqual(outside(), before);
});
});
@@ -146,23 +148,27 @@ const { boot, check, done } = require('./helpers/boot');
@@ -146,23 +148,27 @@ const { boot, check, done } = require('./helpers/boot');
assert.match(r.text, /project\.not_found/);
assert.match(r.text, /project\.not_found/);
});
});
- await check('the events path publishes through the SDK when an app does hold the grant (future public capability)', async () => {
- // No app can hold events.event.publish today (internal); exercise the path with an app view
- // as Network would report it once a publish capability is grantable.
- const view = { id: app.id, project_id: projectId, environment: 'sandbox', grants: ['events.event.publish'], revoked_at: null };
- const token = t.network.mintApp(app.id, 'openvibe.events', ['events.event.publish']);
- const out = await t.ctx.playground.run({
- actor: `user:${owner.subject}`, app: view, kind: 'events', credential: { type: 'access_token', value: token },
- input: { event_type: 'play.thing.done', source: 'play', payload: '{"n":1}' },
- });
- assert.strictEqual(out.outcome, 'ok', JSON.stringify(out));
- assert.strictEqual(t.events.published.length, 1);
- assert.strictEqual(t.events.published[0].sub, `app:${app.id}`);
- assert.strictEqual(t.events.published[0].event.event_type, 'play.thing.done');
- const bad = await t.ctx.playground.run({ actor: `user:${owner.subject}`, app: view, kind: 'events', credential: { type: 'access_token', value: token }, input: { event_type: 'two.segments', source: 'play' } });
- assert.strictEqual(bad.outcome, 'refused');
- assert.strictEqual(bad.stage, 'input');
+ await check('with events.app.publish: publishes as the app, with its source and project-scoped type', async () => {
+ await t.grant(owner, projectId, app.id, 'events.app.publish');
+ const r = await t.get(`${base}/playground/events`, { as: owner, form: { event_type: `app.${key}.test.ping`, payload: '{"n":1}', credential_type: 'client_secret', credential: app.secret } });
+ assert.strictEqual(r.status, 200, r.text.slice(0, 600));
+ assert.match(r.text, /Done\./);
assert.strictEqual(t.events.published.length, 1);
assert.strictEqual(t.events.published.length, 1);
+ const e = t.events.published[0];
+ assert.strictEqual(e.sub, `app:${app.id}`);
+ assert.strictEqual(e.event.source, `app-${app.id.replace(/^app_/, '').toLowerCase()}`);
+ assert.deepStrictEqual(e.event.actor, { type: 'app', id: app.id });
+ const tok = t.network.tokenRequests.filter((x) => x.client_id === app.id).pop();
+ assert.strictEqual(tok.audience, 'openvibe.events');
+ assert.strictEqual(tok.scope, 'events.app.publish');
+ });
+
+ await check('an event type outside the project\'s app.<project_key>.* space is refused locally', async () => {
+ const before = outside();
+ const r = await t.get(`${base}/playground/events`, { as: owner, form: { event_type: 'network.app.created', credential_type: 'client_secret', credential: app.secret } });
+ assert.strictEqual(r.status, 422);
+ assert.match(r.text, new RegExp(`starting with app\\.${key}\\.`));
+ assert.deepStrictEqual(outside(), before);
});
});
await t.close();
await t.close();
@@ -76,7 +76,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -76,7 +76,7 @@ const { boot, check, done } = require('./helpers/boot');
assert.deepStrictEqual(e.actor, { type: 'user', id: owner.subject });
assert.deepStrictEqual(e.actor, { type: 'user', id: owner.subject });
assert.strictEqual(e.payload.release_id, rel1);
assert.strictEqual(e.payload.release_id, rel1);
assert.strictEqual(e.payload.project_id, projectId);
assert.strictEqual(e.payload.project_id, projectId);
- assert.strictEqual(e.payload.trust_tier, 'untrusted');
+ assert.strictEqual(e.payload.trust_tier, 'unreviewed');
assert.strictEqual(contracts.validate('events.event-envelope@1', e).valid, true);
assert.strictEqual(contracts.validate('events.event-envelope@1', e).valid, true);
const pub = await t.get(`/api/v1/apps/${app.id}/releases`);
const pub = await t.get(`/api/v1/apps/${app.id}/releases`);
assert.strictEqual(pub.json().releases[0].status, 'published');
assert.strictEqual(pub.json().releases[0].status, 'published');
@@ -123,14 +123,18 @@ const { boot, check, done } = require('./helpers/boot');
@@ -123,14 +123,18 @@ const { boot, check, done } = require('./helpers/boot');
});
});
await check('trust tiers: staff only, noted, recorded, and they change no permission', async () => {
await check('trust tiers: staff only, noted, recorded, and they change no permission', async () => {
- const denied = await t.get('/staff/trust', { as: owner, form: { app_id: app.id, tier: 'trusted', note: 'me' } });
+ const denied = await t.get('/staff/trust', { as: owner, form: { app_id: app.id, tier: 'reviewed', note: 'me' } });
assert.strictEqual(denied.status, 403);
assert.strictEqual(denied.status, 403);
- const ok = await t.get('/staff/trust', { as: staff, form: { app_id: app.id, tier: 'verified', note: 'Reviewed the source' } });
+ const old = await t.get('/staff/trust', { as: staff, form: { app_id: app.id, tier: 'verified', note: 'pre-ADR-013 name' } });
+ assert.strictEqual(old.status, 422, 'only ADR-013 tiers are accepted');
+ const page = await t.get('/staff', { as: staff });
+ assert.deepStrictEqual([...page.text.matchAll(/<option>([^<]+)<\/option>/g)].map((m) => m[1]), ['unreviewed', 'reviewed', 'first-party']);
+ const ok = await t.get('/staff/trust', { as: staff, form: { app_id: app.id, tier: 'reviewed', note: 'Reviewed the source' } });
assert.strictEqual(ok.status, 303);
assert.strictEqual(ok.status, 303);
const trust = (await t.get(`/api/v1/apps/${app.id}/trust`)).json();
const trust = (await t.get(`/api/v1/apps/${app.id}/trust`)).json();
- assert.strictEqual(trust.tier, 'verified');
+ assert.strictEqual(trust.tier, 'reviewed');
assert.match(trust.note_on_authority, /metadata only/);
assert.match(trust.note_on_authority, /metadata only/);
- // A verified app is still refused by the playground without the grant.
+ // A reviewed app is still refused by the playground without the grant.
const r = await t.get(`${base}/playground/media`, { as: owner, multipart: { fields: { credential_type: 'client_secret', credential: app.secret }, file: { name: 'x', content: 'x' } } });
const r = await t.get(`${base}/playground/media`, { as: owner, multipart: { fields: { credential_type: 'client_secret', credential: app.secret }, file: { name: 'x', content: 'x' } } });
assert.strictEqual(r.status, 403);
assert.strictEqual(r.status, 403);
// And a viewer still cannot publish.
// And a viewer still cannot publish.
@@ -170,12 +174,25 @@ const { boot, check, done } = require('./helpers/boot');
@@ -170,12 +174,25 @@ const { boot, check, done } = require('./helpers/boot');
const other = await t.app(owner, projectId, { name: 'Other' });
const other = await t.app(owner, projectId, { name: 'Other' });
const otherTok = t.network.mintApp(other.id, 'openvibe.codes', ['codes.release.manage']);
const otherTok = t.network.mintApp(other.id, 'openvibe.codes', ['codes.release.manage']);
const r4 = await t.get(`/api/v1/apps/${app.id}/releases`, { headers: { authorization: `Bearer ${otherTok}` }, json: body });
const r4 = await t.get(`/api/v1/apps/${app.id}/releases`, { headers: { authorization: `Bearer ${otherTok}` }, json: body });
+ assert.strictEqual(r4.json().code, 'release.forbidden');
assert.strictEqual(r4.status, 403);
assert.strictEqual(r4.status, 403);
assert.strictEqual(r4.headers.get('content-type'), 'application/problem+json');
assert.strictEqual(r4.headers.get('content-type'), 'application/problem+json');
const userTok = t.network.userToken(owner);
const userTok = t.network.userToken(owner);
assert.strictEqual((await t.get(`/api/v1/apps/${app.id}/releases`, { headers: { authorization: `Bearer ${userTok}` }, json: body })).status, 401);
assert.strictEqual((await t.get(`/api/v1/apps/${app.id}/releases`, { headers: { authorization: `Bearer ${userTok}` }, json: body })).status, 401);
});
});
+ await check('public reads need no token; a presented token must hold codes.release.read', async () => {
+ assert.strictEqual((await t.get(`/api/v1/apps/${app.id}/releases`)).status, 200);
+ const withRead = t.network.mintApp(app.id, 'openvibe.codes', ['codes.release.read']);
+ assert.strictEqual((await t.get(`/api/v1/apps/${app.id}/releases`, { headers: { authorization: `Bearer ${withRead}` } })).status, 200);
+ const without = t.network.mintApp(app.id, 'openvibe.codes', ['codes.release.manage']);
+ const r = await t.get(`/api/v1/apps/${app.id}/trust`, { headers: { authorization: `Bearer ${without}` } });
+ assert.strictEqual(r.status, 403);
+ assert.strictEqual(r.json().code, 'capability.denied');
+ const forged = await t.get(`/api/v1/apps/${app.id}/releases`, { headers: { authorization: 'Bearer not.a.jwt' } });
+ assert.strictEqual(forged.status, 401);
+ });
+
await t.close();
await t.close();
done();
done();
})();
})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+'use strict';
+/**
+ * ADR-013 trust tiers (unreviewed, reviewed, first-party): databases written with the roadmap's
+ * earlier names are migrated at boot — untrusted→unreviewed, verified|trusted→reviewed,
+ * platform-maintained→first-party — including the history, and the CHECK constraint is rebuilt.
+ * Running it again changes nothing. Tiers stay metadata.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const Database = require('better-sqlite3');
+const { openStore } = require('../server/db');
+const { createTrust } = require('../server/domain/trust');
+const { check, done } = require('./helpers/boot');
+
+const A = (n) => `app_01JABCDEFGHJKMNPQRSTVWXYZ${n}`;
+
+(async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-codes-trust-'));
+ const file = path.join(dir, 'codes.db');
+ // A database as the first Codes build created it.
+ const old = new Database(file);
+ old.exec(`CREATE TABLE trust (
+ app_id TEXT PRIMARY KEY,
+ tier TEXT NOT NULL CHECK (tier IN ('untrusted', 'verified', 'trusted', 'platform-maintained')),
+ note TEXT NOT NULL DEFAULT '', set_by TEXT NOT NULL, set_at TEXT NOT NULL);
+ CREATE TABLE trust_history (id INTEGER PRIMARY KEY AUTOINCREMENT, app_id TEXT NOT NULL, from_tier TEXT NOT NULL, to_tier TEXT NOT NULL, note TEXT NOT NULL DEFAULT '', set_by TEXT NOT NULL, set_at TEXT NOT NULL);`);
+ const ins = old.prepare('INSERT INTO trust VALUES (?, ?, ?, ?, ?)');
+ ins.run(A(0), 'untrusted', 'n0', 'user:x', '2026-09-22T00:00:00Z');
+ ins.run(A(1), 'verified', 'n1', 'user:x', '2026-09-22T00:00:00Z');
+ ins.run(A(2), 'trusted', 'n2', 'user:x', '2026-09-22T00:00:00Z');
+ ins.run(A(3), 'platform-maintained', 'n3', 'user:x', '2026-09-22T00:00:00Z');
+ old.prepare('INSERT INTO trust_history (app_id, from_tier, to_tier, note, set_by, set_at) VALUES (?, ?, ?, ?, ?, ?)').run(A(3), 'trusted', 'platform-maintained', 'n', 'user:x', '2026-09-22T00:00:00Z');
+ old.close();
+
+ await check('old tier names are mapped and the CHECK only allows ADR-013 tiers', async () => {
+ const store = openStore(file);
+ const tiers = store.db.prepare('SELECT app_id, tier, note FROM trust ORDER BY app_id').all();
+ assert.deepStrictEqual(tiers.map((r) => r.tier), ['unreviewed', 'reviewed', 'reviewed', 'first-party']);
+ assert.deepStrictEqual(tiers.map((r) => r.note), ['n0', 'n1', 'n2', 'n3'], 'notes kept');
+ assert.deepStrictEqual(store.db.prepare('SELECT from_tier, to_tier FROM trust_history').get(), { from_tier: 'reviewed', to_tier: 'first-party' });
+ assert.throws(() => store.db.prepare("UPDATE trust SET tier = 'verified' WHERE app_id = ?").run(A(0)), /CHECK/);
+ store.close();
+ });
+
+ await check('running it again changes nothing', async () => {
+ const store = openStore(file);
+ const sql = store.db.prepare("SELECT sql FROM sqlite_master WHERE name = 'trust'").get().sql;
+ assert.ok(!/untrusted/.test(sql));
+ assert.strictEqual(store.db.prepare('SELECT COUNT(*) AS n FROM trust').get().n, 4);
+ const trust = createTrust({ store });
+ assert.strictEqual(trust.get(A(0)).tier, 'unreviewed');
+ assert.strictEqual(trust.get('app_01JZZZZZZZZZZZZZZZZZZZZZZZ').tier, 'unreviewed', 'the default is unreviewed');
+ assert.deepStrictEqual(trust.TIERS, ['unreviewed', 'reviewed', 'first-party']);
+ assert.throws(() => trust.set({ appId: A(0), tier: 'trusted', note: 'x', actor: { staff: true, label: 'user:x' } }), /tier is one of/);
+ store.close();
+ });
+
+ await check('a fresh database is created on ADR-013 tiers', async () => {
+ const store = openStore(path.join(dir, 'fresh.db'));
+ assert.match(store.db.prepare("SELECT sql FROM sqlite_master WHERE name = 'trust'").get().sql, /'unreviewed', 'reviewed', 'first-party'/);
+ store.close();
+ });
+
+ fs.rmSync(dir, { recursive: true, force: true });
+ done();
+})();