openvibers/openvibe.network Commits
openvibe-contracts v0.28.0: events.app.publish|read|subscribe are real catalog entries, so the default sandbox allowance carries them; tests follow codes leaving placeholder; docs: Events acts on the relayed revocations
Changes
@@@ -44,7 +44,7 @@ A new project is `sandbox` only. Staff switch it to `sandbox+production`, which- The only grants that exist are capabilities in the openvibe-contracts catalog, at the version Network has installed.- **The grantability rule.** The contracts `visibility` enum is `public | first-party | internal`. Only `active` capabilities with visibility `public` may be granted to apps. `first-party` and `internal` capabilities are never grantable: staff cannot add them to an allowance, members cannot request them, and token issuance filters them out again. The code also accepts a proposed `partner` visibility. Staff may put a `partner` capability in one project's allowance by hand, but it never comes from the default allowance. `partner` is not in the enum yet; see `docs/contracts-proposal/`.- **The allowance** is the set of grantable capabilities one project's apps may hold. Only staff set it. `DEV_DEFAULT_ALLOWANCE` seeds new projects, with public capabilities only. It is empty by default, so staff decide each project's **production** apps.--- **The sandbox allowance** is added to the allowance for **sandbox apps only**, in every project, without a staff decision. It comes from `DEV_SANDBOX_ALLOWANCE`. When that is unset, the code default is `media.object.upload`, `media.object.read`, `events.app.publish`, `events.app.read`, `events.app.subscribe`, `tools.job.create`, `tools.job.read` and `tools.job.cancel`. Only `public` + `active` capabilities of the installed openvibe-contracts catalog count. `partner`, `first-party`, `internal` and unknown ids are dropped. With openvibe-contracts v0.26.0 installed, the three `events.app.*` ids are not defined yet, so they are left out; they join the sandbox allowance on the first boot with v0.27.0 (not tagged at the time of writing). The project view and `GET /catalog` show it as `sandbox_allowance`. Production apps never use it.++- **The sandbox allowance** is added to the allowance for **sandbox apps only**, in every project, without a staff decision. It comes from `DEV_SANDBOX_ALLOWANCE`. When that is unset, the code default is `media.object.upload`, `media.object.read`, `events.app.publish`, `events.app.read`, `events.app.subscribe`, `tools.job.create`, `tools.job.read` and `tools.job.cancel`. Only `public` + `active` capabilities of the installed openvibe-contracts catalog count. `partner`, `first-party`, `internal` and unknown ids are dropped. The three `events.app.*` ids are defined from openvibe-contracts v0.28.0, which Network pins; with an older release they would be left out. The project view and `GET /catalog` show it as `sandbox_allowance`. Production apps never use it.- **A grant never exceeds the allowance** (for a sandbox app: allowance ∪ sandbox allowance). It is checked in three places. Approval refuses with `403 grant.beyond_allowance`. Shrinking the allowance revokes approved grants, and denies requested ones, that fall outside it. A sandbox app's grant that is still inside the sandbox allowance is kept. Token issuance intersects approved grants with the current allowance and with the current grantability.- A developer's grant request waits in `requested`. When an owner or admin makes the request and the capability is inside the allowance, it is approved at once.- `GET /api/v1/projects/catalog` lists what could ever be granted.@@@ -83,7 +83,7 @@ No refresh tokens are issued to apps. A confidential app asks again with its secThe sandbox check works in two layers.1. **Issuance.** A sandbox app gets a token only for an audience listed in `DEV_SANDBOX_AUDIENCES`. When that is unset, the code default is `openvibe.media`, `openvibe.events` and `openvibe.tools`. Set it to an empty value to turn sandbox tokens off. Any other audience gets `400 invalid_target`. `openvibe.network` is not in the default. This layer protects every receiver today, including ones whose openvibe-contracts `verifyServiceToken` does not know about `env` yet.--2. **Receivers.** A receiver refuses `env: sandbox` with `401 token.sandbox_refused` unless it opted in. Network does this in its own capability guard (`server/identity/principals.js`) and accepts sandbox only if `openvibe.network` is in `DEV_SANDBOX_AUDIENCES`. `server/developer/policy.js` exports `environmentDecision(claims, { acceptSandbox })` for the check. The proposal adds the same check to openvibe-contracts `verifyServiceToken` / `requireCapability` as an `acceptSandbox` option that defaults to false.++2. **Receivers.** A receiver refuses `env: sandbox` with `401 token.sandbox_refused` unless it opted in. Network does this in its own capability guard (`server/identity/principals.js`) and accepts sandbox only if `openvibe.network` is in `DEV_SANDBOX_AUDIENCES`. `server/developer/policy.js` exports `environmentDecision(claims, { acceptSandbox })` for the check. openvibe-contracts (≥ 0.26.0) `verifyServiceToken` / `requireCapability` do the same with an `acceptSandbox` option that defaults to false.An audience opts in when it can keep sandbox traffic apart from real data: for example, test-flagged money in Billing (ADR-012 rule 9), or a sandbox tenant in Media. First-party service tokens carry no `env` and are treated as production. The three default audiences must accept sandbox tokens themselves. Issuance working does not mean they do. Media (sandbox tenants keyed by project id) and Events (env-marked app events) take that on in the same Wave 20 step. Tools must accept `env: sandbox` on `/api/v1/jobs`; until it does, a sandbox app's Tools token is refused with `401 token.sandbox_refused` there.@@@ -205,7 +205,7 @@ Until the relay is turned on in an environment, nobody else receives these event- **Service-side project reads.** `network.project.read` (proposed) and `GET /internal/projects/:id` let owning services enforce quotas and check tenancy.- **PowerChat as the first project** (ADR-014 migration). This is not done. PowerChat's existing OAuth client is unchanged.- **Receivers.** Every default sandbox audience must refuse `env: sandbox` except on routes that keep sandbox data apart, and must key tenancy by `project_id`. Media and Events do this in Wave 20. Tools does not yet (see [Sandbox tokens](#sandbox-tokens)).--- **Revocation fan-out.** The relayed `network.app.revoked` event is the signal receivers can use to disable an app's subscriptions or tenants early. A receiver that does not act on it only stops the app when its issued tokens expire (within 5 minutes); state the app created there (for example a webhook subscription) is untouched by Network.++- **Revocation fan-out.** The relayed `network.app.revoked` event is the signal receivers can use to disable an app's subscriptions or tenants early. OpenVibe.Events acts on it (and on `network.grant.changed` withdrawing `events.app.subscribe`): it disables the app's subscriptions and refuses its tokens issued before the revocation. That needs this relay switched on. A receiver that does not act on it only stops the app when its issued tokens expire (within 5 minutes); state the app created there (for example a Media object) is untouched by Network.## Public discovery and CORS@@@ -20,7 +20,7 @@"helmet": "^8.1.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "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.28.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","uuid": "^11.1.0",@@@ -1465,9 +1465,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.28.0",++ "integrity": "sha512-cOOEmIhcjDmnWbo4jsz6kYOKT+Kc+aitYoc4w/EJOKg5uvJDt10vctjKB3IIl53UXeuDOhbckP53yBdBeewC1A==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -23,7 +23,7 @@"helmet": "^8.1.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "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.28.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","uuid": "^11.1.0",@@@ -53,8 +53,8 @@ const DEFAULT_SANDBOX_AUDIENCES = Object.freeze(['openvibe.media', 'openvibe.eve/*** Capabilities every project's SANDBOX apps may hold without a staff decision when* DEV_SANDBOX_ALLOWANCE is unset. Only public, active capabilities of the installed contracts-- * catalog count: an id the catalog does not know yet (events.app.* before openvibe-contracts-- * v0.27.0) is left out until it does. Production apps never get these: their allowance is staff-set.++ * catalog count: an id the catalog does not know (events.app.* before openvibe-contracts++ * v0.28.0) is left out until it does. Production apps never get these: their allowance is staff-set.*/const DEFAULT_SANDBOX_ALLOWANCE = Object.freeze(['media.object.upload', 'media.object.read',@@@ -42,12 +42,9 @@ const server = http.createServer(app);const userToken = (id) => jwt.sign({ sub: id, id }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });const T = { owner: userToken(10), dev: userToken(11), staff: userToken(14) };--// The three app-scoped Events capabilities as openvibe-contracts v0.27.0 defines them. v0.26.0 (installed)--// does not know them yet; withV027() adds them to the loaded catalog for the duration of fn.--const EVENTS_APP = ['events.app.publish', 'events.app.read', 'events.app.subscribe'].map(id => ({-- id, version: '1.0', owner: 'events', status: 'active', visibility: 'public', description: `${id} (test copy of the v0.27.0 manifest)`,-- permissions: ['events:app'], resourceConstraints: ['owner'], quotaClass: 'events-app', events: [], implementedBy: [],--}));++// The three app-scoped Events capabilities (openvibe-contracts >= 0.28.0, installed). withoutCatalog()++// hides them, to show what a release that does not define them does.++const EVENTS_APP_IDS = ['events.app.publish', 'events.app.read', 'events.app.subscribe'];const PARTNER = { id: 'media.partner.test', version: '1.0', owner: 'media', status: 'active', visibility: 'partner', description: 'test', permissions: [], resourceConstraints: [], events: [], implementedBy: [] };async function withCatalog(extra, fn) {const caps = contracts.capabilities;@@@ -57,14 +54,22 @@ async function withCatalog(extra, fn) {caps.manifests = [...origManifests, ...extra];try { return await fn(); } finally { caps.get = origGet; caps.manifests = origManifests; }}++async function withoutCatalog(hidden, fn) {++ const caps = contracts.capabilities;++ const origGet = caps.get;++ const origManifests = caps.manifests;++ caps.get = (id) => (hidden.includes(id) ? undefined : origGet.call(caps, id));++ caps.manifests = origManifests.filter(c => !hidden.includes(c.id));++ try { return await fn(); } finally { caps.get = origGet; caps.manifests = origManifests; }++}(async () => {// ── Settings: defaults, env overrides, and the public-only rule ──let s = policy.settings({});assert.deepStrictEqual([...s.sandboxAudiences].sort(), ['openvibe.events', 'openvibe.media', 'openvibe.tools']);assert.ok(!s.sandboxAudiences.has('openvibe.network'), 'Network itself never defaults to accepting sandbox tokens');-- assert.deepStrictEqual(s.sandboxAllowance, ['media.object.read', 'media.object.upload', 'tools.job.cancel', 'tools.job.create', 'tools.job.read'],-- 'events.app.* are silently left out while the installed contracts do not define them');++ assert.deepStrictEqual(s.sandboxAllowance, ['events.app.publish', 'events.app.read', 'events.app.subscribe',++ 'media.object.read', 'media.object.upload', 'tools.job.cancel', 'tools.job.create', 'tools.job.read'], 'the default sandbox allowance');assert.deepStrictEqual(policy.DEFAULT_SANDBOX_ALLOWANCE.filter(id => id.startsWith('events.')), ['events.app.publish', 'events.app.read', 'events.app.subscribe']);s = policy.settings({ developer: { sandboxAudiences: '', sandboxAllowance: '' } });assert.strictEqual(s.sandboxAudiences.size, 0, 'set-but-empty means none');@@@ -77,9 +82,9 @@ async function withCatalog(extra, fn) {assert.deepStrictEqual(policy.settings({ developer: { sandboxAllowance: 'media.partner.test,media.object.read' } }).sandboxAllowance, ['media.object.read'],'partner capabilities never come from the sandbox allowance');});-- await withCatalog(EVENTS_APP, () => {-- assert.deepStrictEqual(policy.settings({}).sandboxAllowance.filter(id => id.startsWith('events.')), ['events.app.publish', 'events.app.read', 'events.app.subscribe'],-- 'with contracts v0.27.0 the app-scoped Events capabilities are in the default sandbox allowance');++ await withoutCatalog(EVENTS_APP_IDS, () => {++ assert.deepStrictEqual(policy.settings({}).sandboxAllowance.filter(id => id.startsWith('events.')), [],++ 'capabilities the installed contracts do not define are silently left out');});await new Promise(r => server.listen(0, '127.0.0.1', r));@@@ -172,8 +177,8 @@ async function withCatalog(extra, fn) {assert.ok(r.body.trimmed.some(x => x.app_id === C && x.capability === 'media.object.upload'));assert.ok(!r.body.trimmed.some(x => x.app_id === A && x.capability === 'media.object.upload'));-- // ── contracts v0.27.0: events.app.* in sandbox without staff ──-- await withCatalog(EVENTS_APP, async () => {++ // ── events.app.* in sandbox without staff ──++ {for (const c of ['events.app.publish', 'events.app.read', 'events.app.subscribe']) {r = await api('owner', 'POST', `/${P}/apps/${A}/grants`, { capability: c });assert.strictEqual(r.status, 201, r.text);@@@ -187,10 +192,12 @@ async function withCatalog(extra, fn) {assert.strictEqual(v.claims.env, 'sandbox');r = await api('owner', 'POST', `/${P}/apps/${C}/grants`, { capability: 'events.app.publish' });assert.strictEqual(r.body.status, 'requested', 'production still needs staff');++ }++ // Without the capabilities in the catalog they fall out of tokens again (grantability is re-checked).++ await withoutCatalog(EVENTS_APP_IDS, async () => {++ t = await cc(A, secret, 'openvibe.events');++ assert.strictEqual(t.body.error, 'invalid_scope');});-- // Back on v0.26.0 the unknown capabilities fall out of tokens again (grantability is re-checked).-- t = await cc(A, secret, 'openvibe.events');-- assert.strictEqual(t.body.error, 'invalid_scope');// ── Relay off: nothing is sent ──assert.strictEqual(relay.startRelay(db, { eventsUrl: '', privateKey: keys.privateKey, issuer: ISSUER, fetch: () => { throw new Error('no fetch when off'); } }), null);@@@ -30,7 +30,7 @@ const { createEcosystemRegistry } = require('../server/registry/ecosystem');assert.strictEqual(byId.network.runtime.status, 'up');assert.ok(byId.network.runtime.checked_at, 'every health value says when it was checked');assert.strictEqual(byId.live.runtime.status, 'down', 'unreachable is reported, not hidden');-- assert.strictEqual(byId.codes.runtime.status, 'not-running', 'placeholders are never shown as running');++ assert.strictEqual(byId.realtime.runtime.status, 'not-running', 'placeholders are never shown as running');assert.ok(r.body.services.every(s => contracts.validate('registry.service-manifest@1', Object.fromEntries(Object.entries(s).filter(([k]) => k !== 'runtime'))).valid));r = await get('/api/v1/registry/services?status=placeholder');assert.ok(r.body.services.length >= 2 && r.body.services.every(s => s.status === 'placeholder'));@@@ -72,8 +72,8 @@ const rel = (release) => [200, { service: 'x', release, released_at: '2026-09-22assert.ok(by.tools.release_error);assert.strictEqual(by.events.basis, 'ready-legacy');assert.strictEqual(by.live.status, 'down', 'unreachable is down');-- assert.strictEqual(by.codes.status, 'not-running');-- assert.strictEqual(by.codes.label, 'not running (placeholder)');++ assert.strictEqual(by.realtime.status, 'not-running');++ assert.strictEqual(by.realtime.label, 'not running (placeholder)');assert.strictEqual(by.contracts.status, 'not-running');assert.strictEqual(by.contracts.label, 'not running (no runtime)');for (const s of r.body.services) assert.ok(s.checked_at, `${s.id} says when it was checked`);@@@ -106,7 +106,7 @@ const rel = (release) => [200, { service: 'x', release, released_at: '2026-09-22assert.ok(html.includes('<meta name="robots" content="noindex, nofollow">'));assert.ok(html.includes('id="svc-media"') && /id="svc-media"[\s\S]*?Degraded/.test(html));assert.ok(/id="svc-community"[\s\S]*?Down[\s\S]*?SQLITE_CANTOPEN/.test(html));-- assert.ok(/id="svc-codes"[\s\S]*?not running \(placeholder\)/.test(html));++ assert.ok(/id="svc-realtime"[\s\S]*?not running \(placeholder\)/.test(html));assert.ok(/id="svc-tools"[\s\S]*?liveness only/.test(html));assert.ok(html.includes('<noscript>'), 'navigation without JavaScript');assert.ok(html.includes('SLO categories (proposals)'));
@@ -44,7 +44,7 @@ A new project is `sandbox` only. Staff switch it to `sandbox+production`, which
@@ -44,7 +44,7 @@ A new project is `sandbox` only. Staff switch it to `sandbox+production`, which
- The only grants that exist are capabilities in the openvibe-contracts catalog, at the version Network has installed.
- The only grants that exist are capabilities in the openvibe-contracts catalog, at the version Network has installed.
- **The grantability rule.** The contracts `visibility` enum is `public | first-party | internal`. Only `active` capabilities with visibility `public` may be granted to apps. `first-party` and `internal` capabilities are never grantable: staff cannot add them to an allowance, members cannot request them, and token issuance filters them out again. The code also accepts a proposed `partner` visibility. Staff may put a `partner` capability in one project's allowance by hand, but it never comes from the default allowance. `partner` is not in the enum yet; see `docs/contracts-proposal/`.
- **The grantability rule.** The contracts `visibility` enum is `public | first-party | internal`. Only `active` capabilities with visibility `public` may be granted to apps. `first-party` and `internal` capabilities are never grantable: staff cannot add them to an allowance, members cannot request them, and token issuance filters them out again. The code also accepts a proposed `partner` visibility. Staff may put a `partner` capability in one project's allowance by hand, but it never comes from the default allowance. `partner` is not in the enum yet; see `docs/contracts-proposal/`.
- **The allowance** is the set of grantable capabilities one project's apps may hold. Only staff set it. `DEV_DEFAULT_ALLOWANCE` seeds new projects, with public capabilities only. It is empty by default, so staff decide each project's **production** apps.
- **The allowance** is the set of grantable capabilities one project's apps may hold. Only staff set it. `DEV_DEFAULT_ALLOWANCE` seeds new projects, with public capabilities only. It is empty by default, so staff decide each project's **production** apps.
-- **The sandbox allowance** is added to the allowance for **sandbox apps only**, in every project, without a staff decision. It comes from `DEV_SANDBOX_ALLOWANCE`. When that is unset, the code default is `media.object.upload`, `media.object.read`, `events.app.publish`, `events.app.read`, `events.app.subscribe`, `tools.job.create`, `tools.job.read` and `tools.job.cancel`. Only `public` + `active` capabilities of the installed openvibe-contracts catalog count. `partner`, `first-party`, `internal` and unknown ids are dropped. With openvibe-contracts v0.26.0 installed, the three `events.app.*` ids are not defined yet, so they are left out; they join the sandbox allowance on the first boot with v0.27.0 (not tagged at the time of writing). The project view and `GET /catalog` show it as `sandbox_allowance`. Production apps never use it.
+- **The sandbox allowance** is added to the allowance for **sandbox apps only**, in every project, without a staff decision. It comes from `DEV_SANDBOX_ALLOWANCE`. When that is unset, the code default is `media.object.upload`, `media.object.read`, `events.app.publish`, `events.app.read`, `events.app.subscribe`, `tools.job.create`, `tools.job.read` and `tools.job.cancel`. Only `public` + `active` capabilities of the installed openvibe-contracts catalog count. `partner`, `first-party`, `internal` and unknown ids are dropped. The three `events.app.*` ids are defined from openvibe-contracts v0.28.0, which Network pins; with an older release they would be left out. The project view and `GET /catalog` show it as `sandbox_allowance`. Production apps never use it.
- **A grant never exceeds the allowance** (for a sandbox app: allowance ∪ sandbox allowance). It is checked in three places. Approval refuses with `403 grant.beyond_allowance`. Shrinking the allowance revokes approved grants, and denies requested ones, that fall outside it. A sandbox app's grant that is still inside the sandbox allowance is kept. Token issuance intersects approved grants with the current allowance and with the current grantability.
- **A grant never exceeds the allowance** (for a sandbox app: allowance ∪ sandbox allowance). It is checked in three places. Approval refuses with `403 grant.beyond_allowance`. Shrinking the allowance revokes approved grants, and denies requested ones, that fall outside it. A sandbox app's grant that is still inside the sandbox allowance is kept. Token issuance intersects approved grants with the current allowance and with the current grantability.
- A developer's grant request waits in `requested`. When an owner or admin makes the request and the capability is inside the allowance, it is approved at once.
- A developer's grant request waits in `requested`. When an owner or admin makes the request and the capability is inside the allowance, it is approved at once.
- `GET /api/v1/projects/catalog` lists what could ever be granted.
- `GET /api/v1/projects/catalog` lists what could ever be granted.
@@ -83,7 +83,7 @@ No refresh tokens are issued to apps. A confidential app asks again with its sec
@@ -83,7 +83,7 @@ No refresh tokens are issued to apps. A confidential app asks again with its sec
The sandbox check works in two layers.
The sandbox check works in two layers.
1. **Issuance.** A sandbox app gets a token only for an audience listed in `DEV_SANDBOX_AUDIENCES`. When that is unset, the code default is `openvibe.media`, `openvibe.events` and `openvibe.tools`. Set it to an empty value to turn sandbox tokens off. Any other audience gets `400 invalid_target`. `openvibe.network` is not in the default. This layer protects every receiver today, including ones whose openvibe-contracts `verifyServiceToken` does not know about `env` yet.
1. **Issuance.** A sandbox app gets a token only for an audience listed in `DEV_SANDBOX_AUDIENCES`. When that is unset, the code default is `openvibe.media`, `openvibe.events` and `openvibe.tools`. Set it to an empty value to turn sandbox tokens off. Any other audience gets `400 invalid_target`. `openvibe.network` is not in the default. This layer protects every receiver today, including ones whose openvibe-contracts `verifyServiceToken` does not know about `env` yet.
-2. **Receivers.** A receiver refuses `env: sandbox` with `401 token.sandbox_refused` unless it opted in. Network does this in its own capability guard (`server/identity/principals.js`) and accepts sandbox only if `openvibe.network` is in `DEV_SANDBOX_AUDIENCES`. `server/developer/policy.js` exports `environmentDecision(claims, { acceptSandbox })` for the check. The proposal adds the same check to openvibe-contracts `verifyServiceToken` / `requireCapability` as an `acceptSandbox` option that defaults to false.
+2. **Receivers.** A receiver refuses `env: sandbox` with `401 token.sandbox_refused` unless it opted in. Network does this in its own capability guard (`server/identity/principals.js`) and accepts sandbox only if `openvibe.network` is in `DEV_SANDBOX_AUDIENCES`. `server/developer/policy.js` exports `environmentDecision(claims, { acceptSandbox })` for the check. openvibe-contracts (≥ 0.26.0) `verifyServiceToken` / `requireCapability` do the same with an `acceptSandbox` option that defaults to false.
An audience opts in when it can keep sandbox traffic apart from real data: for example, test-flagged money in Billing (ADR-012 rule 9), or a sandbox tenant in Media. First-party service tokens carry no `env` and are treated as production. The three default audiences must accept sandbox tokens themselves. Issuance working does not mean they do. Media (sandbox tenants keyed by project id) and Events (env-marked app events) take that on in the same Wave 20 step. Tools must accept `env: sandbox` on `/api/v1/jobs`; until it does, a sandbox app's Tools token is refused with `401 token.sandbox_refused` there.
An audience opts in when it can keep sandbox traffic apart from real data: for example, test-flagged money in Billing (ADR-012 rule 9), or a sandbox tenant in Media. First-party service tokens carry no `env` and are treated as production. The three default audiences must accept sandbox tokens themselves. Issuance working does not mean they do. Media (sandbox tenants keyed by project id) and Events (env-marked app events) take that on in the same Wave 20 step. Tools must accept `env: sandbox` on `/api/v1/jobs`; until it does, a sandbox app's Tools token is refused with `401 token.sandbox_refused` there.
@@ -205,7 +205,7 @@ Until the relay is turned on in an environment, nobody else receives these event
@@ -205,7 +205,7 @@ Until the relay is turned on in an environment, nobody else receives these event
- **Service-side project reads.** `network.project.read` (proposed) and `GET /internal/projects/:id` let owning services enforce quotas and check tenancy.
- **Service-side project reads.** `network.project.read` (proposed) and `GET /internal/projects/:id` let owning services enforce quotas and check tenancy.
- **PowerChat as the first project** (ADR-014 migration). This is not done. PowerChat's existing OAuth client is unchanged.
- **PowerChat as the first project** (ADR-014 migration). This is not done. PowerChat's existing OAuth client is unchanged.
- **Receivers.** Every default sandbox audience must refuse `env: sandbox` except on routes that keep sandbox data apart, and must key tenancy by `project_id`. Media and Events do this in Wave 20. Tools does not yet (see [Sandbox tokens](#sandbox-tokens)).
- **Receivers.** Every default sandbox audience must refuse `env: sandbox` except on routes that keep sandbox data apart, and must key tenancy by `project_id`. Media and Events do this in Wave 20. Tools does not yet (see [Sandbox tokens](#sandbox-tokens)).
-- **Revocation fan-out.** The relayed `network.app.revoked` event is the signal receivers can use to disable an app's subscriptions or tenants early. A receiver that does not act on it only stops the app when its issued tokens expire (within 5 minutes); state the app created there (for example a webhook subscription) is untouched by Network.
+- **Revocation fan-out.** The relayed `network.app.revoked` event is the signal receivers can use to disable an app's subscriptions or tenants early. OpenVibe.Events acts on it (and on `network.grant.changed` withdrawing `events.app.subscribe`): it disables the app's subscriptions and refuses its tokens issued before the revocation. That needs this relay switched on. A receiver that does not act on it only stops the app when its issued tokens expire (within 5 minutes); state the app created there (for example a Media object) is untouched by Network.
## Public discovery and CORS
## Public discovery and CORS
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "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.28.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",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
@@ -1465,9 +1465,9 @@
@@ -1465,9 +1465,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.28.0",
+ "integrity": "sha512-cOOEmIhcjDmnWbo4jsz6kYOKT+Kc+aitYoc4w/EJOKg5uvJDt10vctjKB3IIl53UXeuDOhbckP53yBdBeewC1A==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "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.28.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",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
@@ -53,8 +53,8 @@ const DEFAULT_SANDBOX_AUDIENCES = Object.freeze(['openvibe.media', 'openvibe.eve
@@ -53,8 +53,8 @@ const DEFAULT_SANDBOX_AUDIENCES = Object.freeze(['openvibe.media', 'openvibe.eve
/**
/**
* Capabilities every project's SANDBOX apps may hold without a staff decision when
* Capabilities every project's SANDBOX apps may hold without a staff decision when
* DEV_SANDBOX_ALLOWANCE is unset. Only public, active capabilities of the installed contracts
* DEV_SANDBOX_ALLOWANCE is unset. Only public, active capabilities of the installed contracts
- * catalog count: an id the catalog does not know yet (events.app.* before openvibe-contracts
- * v0.27.0) is left out until it does. Production apps never get these: their allowance is staff-set.
+ * catalog count: an id the catalog does not know (events.app.* before openvibe-contracts
+ * v0.28.0) is left out until it does. Production apps never get these: their allowance is staff-set.
*/
*/
const DEFAULT_SANDBOX_ALLOWANCE = Object.freeze([
const DEFAULT_SANDBOX_ALLOWANCE = Object.freeze([
'media.object.upload', 'media.object.read',
'media.object.upload', 'media.object.read',
@@ -42,12 +42,9 @@ const server = http.createServer(app);
@@ -42,12 +42,9 @@ const server = http.createServer(app);
const userToken = (id) => jwt.sign({ sub: id, id }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
const userToken = (id) => jwt.sign({ sub: id, id }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
const T = { owner: userToken(10), dev: userToken(11), staff: userToken(14) };
const T = { owner: userToken(10), dev: userToken(11), staff: userToken(14) };
-// The three app-scoped Events capabilities as openvibe-contracts v0.27.0 defines them. v0.26.0 (installed)
-// does not know them yet; withV027() adds them to the loaded catalog for the duration of fn.
-const EVENTS_APP = ['events.app.publish', 'events.app.read', 'events.app.subscribe'].map(id => ({
- id, version: '1.0', owner: 'events', status: 'active', visibility: 'public', description: `${id} (test copy of the v0.27.0 manifest)`,
- permissions: ['events:app'], resourceConstraints: ['owner'], quotaClass: 'events-app', events: [], implementedBy: [],
-}));
+// The three app-scoped Events capabilities (openvibe-contracts >= 0.28.0, installed). withoutCatalog()
+// hides them, to show what a release that does not define them does.
+const EVENTS_APP_IDS = ['events.app.publish', 'events.app.read', 'events.app.subscribe'];
const PARTNER = { id: 'media.partner.test', version: '1.0', owner: 'media', status: 'active', visibility: 'partner', description: 'test', permissions: [], resourceConstraints: [], events: [], implementedBy: [] };
const PARTNER = { id: 'media.partner.test', version: '1.0', owner: 'media', status: 'active', visibility: 'partner', description: 'test', permissions: [], resourceConstraints: [], events: [], implementedBy: [] };
async function withCatalog(extra, fn) {
async function withCatalog(extra, fn) {
const caps = contracts.capabilities;
const caps = contracts.capabilities;
@@ -57,14 +54,22 @@ async function withCatalog(extra, fn) {
@@ -57,14 +54,22 @@ async function withCatalog(extra, fn) {
caps.manifests = [...origManifests, ...extra];
caps.manifests = [...origManifests, ...extra];
try { return await fn(); } finally { caps.get = origGet; caps.manifests = origManifests; }
try { return await fn(); } finally { caps.get = origGet; caps.manifests = origManifests; }
}
}
+async function withoutCatalog(hidden, fn) {
+ const caps = contracts.capabilities;
+ const origGet = caps.get;
+ const origManifests = caps.manifests;
+ caps.get = (id) => (hidden.includes(id) ? undefined : origGet.call(caps, id));
+ caps.manifests = origManifests.filter(c => !hidden.includes(c.id));
+ try { return await fn(); } finally { caps.get = origGet; caps.manifests = origManifests; }
+}
(async () => {
(async () => {
// ── Settings: defaults, env overrides, and the public-only rule ──
// ── Settings: defaults, env overrides, and the public-only rule ──
let s = policy.settings({});
let s = policy.settings({});
assert.deepStrictEqual([...s.sandboxAudiences].sort(), ['openvibe.events', 'openvibe.media', 'openvibe.tools']);
assert.deepStrictEqual([...s.sandboxAudiences].sort(), ['openvibe.events', 'openvibe.media', 'openvibe.tools']);
assert.ok(!s.sandboxAudiences.has('openvibe.network'), 'Network itself never defaults to accepting sandbox tokens');
assert.ok(!s.sandboxAudiences.has('openvibe.network'), 'Network itself never defaults to accepting sandbox tokens');
- assert.deepStrictEqual(s.sandboxAllowance, ['media.object.read', 'media.object.upload', 'tools.job.cancel', 'tools.job.create', 'tools.job.read'],
- 'events.app.* are silently left out while the installed contracts do not define them');
+ assert.deepStrictEqual(s.sandboxAllowance, ['events.app.publish', 'events.app.read', 'events.app.subscribe',
+ 'media.object.read', 'media.object.upload', 'tools.job.cancel', 'tools.job.create', 'tools.job.read'], 'the default sandbox allowance');
assert.deepStrictEqual(policy.DEFAULT_SANDBOX_ALLOWANCE.filter(id => id.startsWith('events.')), ['events.app.publish', 'events.app.read', 'events.app.subscribe']);
assert.deepStrictEqual(policy.DEFAULT_SANDBOX_ALLOWANCE.filter(id => id.startsWith('events.')), ['events.app.publish', 'events.app.read', 'events.app.subscribe']);
s = policy.settings({ developer: { sandboxAudiences: '', sandboxAllowance: '' } });
s = policy.settings({ developer: { sandboxAudiences: '', sandboxAllowance: '' } });
assert.strictEqual(s.sandboxAudiences.size, 0, 'set-but-empty means none');
assert.strictEqual(s.sandboxAudiences.size, 0, 'set-but-empty means none');
@@ -77,9 +82,9 @@ async function withCatalog(extra, fn) {
@@ -77,9 +82,9 @@ async function withCatalog(extra, fn) {
assert.deepStrictEqual(policy.settings({ developer: { sandboxAllowance: 'media.partner.test,media.object.read' } }).sandboxAllowance, ['media.object.read'],
assert.deepStrictEqual(policy.settings({ developer: { sandboxAllowance: 'media.partner.test,media.object.read' } }).sandboxAllowance, ['media.object.read'],
'partner capabilities never come from the sandbox allowance');
'partner capabilities never come from the sandbox allowance');
});
});
- await withCatalog(EVENTS_APP, () => {
- assert.deepStrictEqual(policy.settings({}).sandboxAllowance.filter(id => id.startsWith('events.')), ['events.app.publish', 'events.app.read', 'events.app.subscribe'],
- 'with contracts v0.27.0 the app-scoped Events capabilities are in the default sandbox allowance');
+ await withoutCatalog(EVENTS_APP_IDS, () => {
+ assert.deepStrictEqual(policy.settings({}).sandboxAllowance.filter(id => id.startsWith('events.')), [],
+ 'capabilities the installed contracts do not define are silently left out');
});
});
await new Promise(r => server.listen(0, '127.0.0.1', r));
await new Promise(r => server.listen(0, '127.0.0.1', r));
@@ -172,8 +177,8 @@ async function withCatalog(extra, fn) {
@@ -172,8 +177,8 @@ async function withCatalog(extra, fn) {
assert.ok(r.body.trimmed.some(x => x.app_id === C && x.capability === 'media.object.upload'));
assert.ok(r.body.trimmed.some(x => x.app_id === C && x.capability === 'media.object.upload'));
assert.ok(!r.body.trimmed.some(x => x.app_id === A && x.capability === 'media.object.upload'));
assert.ok(!r.body.trimmed.some(x => x.app_id === A && x.capability === 'media.object.upload'));
- // ── contracts v0.27.0: events.app.* in sandbox without staff ──
- await withCatalog(EVENTS_APP, async () => {
+ // ── events.app.* in sandbox without staff ──
+ {
for (const c of ['events.app.publish', 'events.app.read', 'events.app.subscribe']) {
for (const c of ['events.app.publish', 'events.app.read', 'events.app.subscribe']) {
r = await api('owner', 'POST', `/${P}/apps/${A}/grants`, { capability: c });
r = await api('owner', 'POST', `/${P}/apps/${A}/grants`, { capability: c });
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.status, 201, r.text);
@@ -187,10 +192,12 @@ async function withCatalog(extra, fn) {
@@ -187,10 +192,12 @@ async function withCatalog(extra, fn) {
assert.strictEqual(v.claims.env, 'sandbox');
assert.strictEqual(v.claims.env, 'sandbox');
r = await api('owner', 'POST', `/${P}/apps/${C}/grants`, { capability: 'events.app.publish' });
r = await api('owner', 'POST', `/${P}/apps/${C}/grants`, { capability: 'events.app.publish' });
assert.strictEqual(r.body.status, 'requested', 'production still needs staff');
assert.strictEqual(r.body.status, 'requested', 'production still needs staff');
+ }
+ // Without the capabilities in the catalog they fall out of tokens again (grantability is re-checked).
+ await withoutCatalog(EVENTS_APP_IDS, async () => {
+ t = await cc(A, secret, 'openvibe.events');
+ assert.strictEqual(t.body.error, 'invalid_scope');
});
});
- // Back on v0.26.0 the unknown capabilities fall out of tokens again (grantability is re-checked).
- t = await cc(A, secret, 'openvibe.events');
- assert.strictEqual(t.body.error, 'invalid_scope');
// ── Relay off: nothing is sent ──
// ── Relay off: nothing is sent ──
assert.strictEqual(relay.startRelay(db, { eventsUrl: '', privateKey: keys.privateKey, issuer: ISSUER, fetch: () => { throw new Error('no fetch when off'); } }), null);
assert.strictEqual(relay.startRelay(db, { eventsUrl: '', privateKey: keys.privateKey, issuer: ISSUER, fetch: () => { throw new Error('no fetch when off'); } }), null);
@@ -30,7 +30,7 @@ const { createEcosystemRegistry } = require('../server/registry/ecosystem');
@@ -30,7 +30,7 @@ const { createEcosystemRegistry } = require('../server/registry/ecosystem');
assert.strictEqual(byId.network.runtime.status, 'up');
assert.strictEqual(byId.network.runtime.status, 'up');
assert.ok(byId.network.runtime.checked_at, 'every health value says when it was checked');
assert.ok(byId.network.runtime.checked_at, 'every health value says when it was checked');
assert.strictEqual(byId.live.runtime.status, 'down', 'unreachable is reported, not hidden');
assert.strictEqual(byId.live.runtime.status, 'down', 'unreachable is reported, not hidden');
- assert.strictEqual(byId.codes.runtime.status, 'not-running', 'placeholders are never shown as running');
+ assert.strictEqual(byId.realtime.runtime.status, 'not-running', 'placeholders are never shown as running');
assert.ok(r.body.services.every(s => contracts.validate('registry.service-manifest@1', Object.fromEntries(Object.entries(s).filter(([k]) => k !== 'runtime'))).valid));
assert.ok(r.body.services.every(s => contracts.validate('registry.service-manifest@1', Object.fromEntries(Object.entries(s).filter(([k]) => k !== 'runtime'))).valid));
r = await get('/api/v1/registry/services?status=placeholder');
r = await get('/api/v1/registry/services?status=placeholder');
assert.ok(r.body.services.length >= 2 && r.body.services.every(s => s.status === 'placeholder'));
assert.ok(r.body.services.length >= 2 && r.body.services.every(s => s.status === 'placeholder'));
@@ -72,8 +72,8 @@ const rel = (release) => [200, { service: 'x', release, released_at: '2026-09-22
@@ -72,8 +72,8 @@ const rel = (release) => [200, { service: 'x', release, released_at: '2026-09-22
assert.ok(by.tools.release_error);
assert.ok(by.tools.release_error);
assert.strictEqual(by.events.basis, 'ready-legacy');
assert.strictEqual(by.events.basis, 'ready-legacy');
assert.strictEqual(by.live.status, 'down', 'unreachable is down');
assert.strictEqual(by.live.status, 'down', 'unreachable is down');
- assert.strictEqual(by.codes.status, 'not-running');
- assert.strictEqual(by.codes.label, 'not running (placeholder)');
+ assert.strictEqual(by.realtime.status, 'not-running');
+ assert.strictEqual(by.realtime.label, 'not running (placeholder)');
assert.strictEqual(by.contracts.status, 'not-running');
assert.strictEqual(by.contracts.status, 'not-running');
assert.strictEqual(by.contracts.label, 'not running (no runtime)');
assert.strictEqual(by.contracts.label, 'not running (no runtime)');
for (const s of r.body.services) assert.ok(s.checked_at, `${s.id} says when it was checked`);
for (const s of r.body.services) assert.ok(s.checked_at, `${s.id} says when it was checked`);
@@ -106,7 +106,7 @@ const rel = (release) => [200, { service: 'x', release, released_at: '2026-09-22
@@ -106,7 +106,7 @@ const rel = (release) => [200, { service: 'x', release, released_at: '2026-09-22
assert.ok(html.includes('<meta name="robots" content="noindex, nofollow">'));
assert.ok(html.includes('<meta name="robots" content="noindex, nofollow">'));
assert.ok(html.includes('id="svc-media"') && /id="svc-media"[\s\S]*?Degraded/.test(html));
assert.ok(html.includes('id="svc-media"') && /id="svc-media"[\s\S]*?Degraded/.test(html));
assert.ok(/id="svc-community"[\s\S]*?Down[\s\S]*?SQLITE_CANTOPEN/.test(html));
assert.ok(/id="svc-community"[\s\S]*?Down[\s\S]*?SQLITE_CANTOPEN/.test(html));
- assert.ok(/id="svc-codes"[\s\S]*?not running \(placeholder\)/.test(html));
+ assert.ok(/id="svc-realtime"[\s\S]*?not running \(placeholder\)/.test(html));
assert.ok(/id="svc-tools"[\s\S]*?liveness only/.test(html));
assert.ok(/id="svc-tools"[\s\S]*?liveness only/.test(html));
assert.ok(html.includes('<noscript>'), 'navigation without JavaScript');
assert.ok(html.includes('<noscript>'), 'navigation without JavaScript');
assert.ok(html.includes('SLO categories (proposals)'));
assert.ok(html.includes('SLO categories (proposals)'));