openvibers/openvibe.network Commits

Developer projects work without staff in sandbox: DEV_SANDBOX_AUDIENCES defaults to openvibe.media, openvibe.events, openvibe.tools; sandbox allowance (DEV_SANDBOX_ALLOWANCE, default media.object.upload/read, events.app.publish/read/subscribe, tools.job.create/read/cancel, public capabilities only) for sandbox apps; production allowance stays staff-set. dev_audit events relayed to OpenVibe.Events through an openvibe-sdk v0.2.2 outbox when OV_EVENTS_INTERNAL_URL is set (backfilled, self-signed svc:network token)

OP OpenVibers committed 23 Sept 2026 · a46f60963f · 13 files, +555 −56


Changes

ddiff --git a/.env.example b/.env.example
iindex 0076ae0d..9706796e 100644
---- a/.env.example
++++ b/.env.example
@@@ -21,14 +21,23 @@ SETUP_TOKEN=change-me-to-a-second-random-secret
BOOTSTRAP_PROFILE=local-dev
# Developer projects (docs/developer-projects.md)
--# Audiences that accept sandbox app tokens, comma-separated (e.g. openvibe.media). Empty = none.
--DEV_SANDBOX_AUDIENCES=
++# Audiences that accept sandbox app tokens, comma-separated. Leave it UNSET for the code default
++# (openvibe.media,openvibe.events,openvibe.tools); set it to an empty value to accept none.
++# DEV_SANDBOX_AUDIENCES=openvibe.media,openvibe.events,openvibe.tools
++# Public capabilities every project's SANDBOX apps may hold without staff, comma-separated. Leave it
++# UNSET for the code default (media.object.upload/read, events.app.publish/read/subscribe,
++# tools.job.create/read/cancel; ids the installed contracts do not define are skipped); empty = none.
++# Production apps only ever use the staff-set project allowance.
++# DEV_SANDBOX_ALLOWANCE=media.object.upload,media.object.read,events.app.publish,events.app.read,events.app.subscribe,tools.job.create,tools.job.read,tools.job.cancel
# Seconds a rotated client secret keeps working (0..604800; default 86400)
DEV_CREDENTIAL_OVERLAP_S=86400
# Public capabilities every new project's allowance starts with, comma-separated. Empty = staff grant each one.
DEV_DEFAULT_ALLOWANCE=
DEV_MAX_PROJECTS_PER_OWNER=10
DEV_MAX_APPS_PER_PROJECT=20
++# OpenVibe.Events base URL for relaying developer-project events (network.app.*, network.credential.*,
++# network.grant.changed). Unset = no relay (events stay in dev_audit and are backfilled once it is set).
++# OV_EVENTS_INTERNAL_URL=http://127.0.0.1:4300
# Admin account auto-created on first run
ADMIN_USERNAME=admin
ddiff --git a/README.md b/README.md
iindex bff1c63d..b09f95e1 100644
---- a/README.md
++++ b/README.md
@@@ -16,7 +16,8 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
- **Theme Catalog** — Shared theme system with ~30 built-in themes and community submissions. Theme preferences sync across all services.
- **Admin Panel** — email configuration, user management (role changes, bans), broadcast notifications, system health dashboard, and audit log.
- **Internal API** — Server-to-server endpoints for token verification, user lookup, notification push, account linking, and audit logging. Protected by `X-Internal-Key`.
--- **Developer projects (foundation)** — projects, members, apps (OAuth clients with `app_` subjects), hashed client secrets with rotation and revocation, capability grants within a staff-set allowance, recorded quotas and an append-only audit at `/api/v1/projects`. Bearer user tokens only; no portal UI yet (that is OpenVibe.Codes). See [docs/developer-projects.md](docs/developer-projects.md).
++- **Developer projects (foundation)** — projects, members, apps (OAuth clients with `app_` subjects), hashed client secrets with rotation and revocation, capability grants within a staff-set allowance (sandbox apps also get a default sandbox allowance of public Media, Events-app and Tools-job capabilities, so a new project works without staff), recorded quotas and an append-only audit at `/api/v1/projects`. Its events can be relayed to OpenVibe.Events through an outbox when `OV_EVENTS_INTERNAL_URL` is set. Bearer user tokens only; no portal UI yet (that is OpenVibe.Codes). See [docs/developer-projects.md](docs/developer-projects.md).
++- **Public discovery** — `/.well-known/openvibe`, `/api/v1/registry/*` and `/contracts/*.json` (services, capabilities, contract schemas) answer any origin with `Access-Control-Allow-Origin: *`, preflight included, so a browser app can discover the platform directly. Every other route keeps the first-party CORS allow-list (`server/public-cors.js`).
- **OpenCoins Wallet** — Network-wide currency. User balance/history at `/api/coins/*`; atomic credit/debit/transfer for services at `/internal/coins/*` with idempotency-key dedupe.
---
ddiff --git a/docs/developer-projects.md b/docs/developer-projects.md
iindex d054e743..b8c1d7f8 100644
---- a/docs/developer-projects.md
++++ b/docs/developer-projects.md
@@@ -2,7 +2,7 @@
Roadmap Wave 20 foundation. The binding decision is ADR-014 in OpenVibe.Contracts. Network owns developer projects: who may build on OpenVibe, which apps they run, what those apps may do, and how much they may use. OpenVibe.Codes (the portal, the next step) is a client of this API. It never enforces grants or quotas itself.
--What exists today: the data model, the `/api/v1/projects` API, app tokens from `/oauth/token`, the sandbox check in Network's own capability guard, and the audit. What does not exist yet: a UI (Codes), a consent screen that lists capabilities, refresh tokens for apps, an Events relay for the audit's event rows, service-side reads of projects (`network.project.read`, proposed), and quota enforcement in any owning service.
++What exists today: the data model, the `/api/v1/projects` API, app tokens from `/oauth/token`, the sandbox check in Network's own capability guard, defaults that let a new project's sandbox apps work without staff (sandbox audiences and a sandbox allowance), the audit, and a relay of the audit's events to OpenVibe.Events (off until `OV_EVENTS_INTERNAL_URL` is set). What does not exist yet: a UI (Codes), a consent screen that lists capabilities, refresh tokens for apps, and service-side reads of projects (`network.project.read`, proposed). Quotas are enforced by the owning services, not here.
## Model
@@@ -43,8 +43,9 @@ 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.
--- **A grant never exceeds the 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. Token issuance intersects approved grants with the current allowance and with the current grantability.
++- **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.
++- **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.
@@@ -81,10 +82,21 @@ No refresh tokens are issued to apps. A confidential app asks again with its sec
The sandbox check works in two layers.
--1. **Issuance.** A sandbox app gets a token only for an audience listed in `DEV_SANDBOX_AUDIENCES`, which is empty by default. Any other audience gets `400 invalid_target`. 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.
--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.
++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.
++
++### A new project without staff
++
++With the defaults above, this works with no staff action:
++
++1. Create a project (`POST /api/v1/projects`). You are the owner.
++2. Create a sandbox confidential app (`POST /:project/apps` with `environment: sandbox`). The secret is shown once.
++3. Request grants from the sandbox allowance (`POST /:project/apps/:app/grants`). An owner's or admin's request is approved at once. A developer's request waits for an owner or admin, who can approve it without staff.
++4. `POST /oauth/token` with `grant_type=client_credentials` and `audience=openvibe.media` (or `openvibe.events`, `openvibe.tools`). The token has `env: sandbox`, `project_id` and `ns: [project_id]`.
++
++Production apps still need staff: the project's environment policy (`sandbox+production`) and its allowance.
### Revocation
@@@ -156,7 +168,17 @@ Topic, with subject and payload:
- `network.credential.revoked`: subject `{ type: 'app', id }`, payload `{ project_id, credential_id }`
- `network.grant.changed`: subject `{ type: 'app', id }`, payload `{ project_id, capability, audience, from, to }`
--Network has no Events producer yet: no outbox, no Events client. Each event is written as a validated `events.event-envelope@1` in the same transaction as the change, inside its `dev_audit` row (`event_type`, `event`). This matches the outbox semantics. **TODO:** when Network gets an Events client, relay those rows in id order, keyed by `event_id`, instead of adding a second write path. Until then nobody else receives these events. Services must not assume they do.
++Each event is written as a validated `events.event-envelope@1` (source `network`, visibility `internal`) in the same transaction as the change, inside its `dev_audit` row (`event_type`, `event`).
++
++**Relay to OpenVibe.Events** (`server/developer/event-relay.js`, openvibe-sdk v0.2.2 `createOutbox` and `createEventsClient`):
++
++- It runs only when `OV_EVENTS_INTERNAL_URL` is set (production: `http://127.0.0.1:4300`). When it is unset, nothing is sent and no outbox table is created.
++- When the relay is on, `audit()` also enqueues the envelope into `network_event_outbox` in the same transaction as the audit row. If the enqueue fails, no audit row and no change are written.
++- On start it backfills every `dev_audit` event that is not in the outbox yet (`INSERT OR IGNORE` by `event_id`, in `dev_audit` id order). Events written while the relay was off are delivered too.
++- Rows are published in id order and at least once. Events answers a repeated `event_id` as a duplicate. Sent rows are kept (never pruned), so a later backfill cannot republish an event that Events has already forgotten.
++- Authentication: Network is the issuer, so it signs its own 5-minute service token: `sub svc:network`, `actor_type service`, `aud [openvibe.events]`, `cap [events.event.publish]`, shaped by `identity.service-token-claims@1`. Events allows the `network` source to publish `network.*`.
++
++Until the relay is turned on in an environment, nobody else receives these events there.
## Secrets
@@@ -169,7 +191,9 @@ Network has no Events producer yet: no outbox, no Events client. Each event is w
| Env var | Default | Meaning |
|---|---|---|
--| `DEV_SANDBOX_AUDIENCES` | empty | Audiences that accept sandbox tokens (comma list) |
++| `DEV_SANDBOX_AUDIENCES` | unset: `openvibe.media,openvibe.events,openvibe.tools` | Audiences that accept sandbox tokens (comma list). Unset means the code default; an empty value means none. |
++| `DEV_SANDBOX_ALLOWANCE` | unset: `media.object.upload,media.object.read,events.app.publish,events.app.read,events.app.subscribe,tools.job.create,tools.job.read,tools.job.cancel` | Public capabilities sandbox apps may hold without staff (comma list, filtered to public + active capabilities of the installed catalog). Unset means the code default; an empty value means none. |
++| `OV_EVENTS_INTERNAL_URL` | unset (relay off) | OpenVibe.Events base URL for relaying developer-project events |
| `DEV_CREDENTIAL_OVERLAP_S` | 86400 | Default rotation overlap (0–604800) |
| `DEV_DEFAULT_ALLOWANCE` | empty | Public capabilities a new project starts with |
| `DEV_MAX_PROJECTS_PER_OWNER` | 10 | Active projects per owner |
@@@ -179,6 +203,17 @@ Network has no Events producer yet: no outbox, no Events client. Each event is w
- **Consent screen.** The account chooser shows the app's name, not the capabilities it asks for. Codes and Network need a consent step before third-party production apps go live.
- **Service-side project reads.** `network.project.read` (proposed) and `GET /internal/projects/:id` let owning services enforce quotas and check tenancy.
--- **Events relay.** See the TODO in [Events](#events).
- **PowerChat as the first project** (ADR-014 migration). This is not done. PowerChat's existing OAuth client is unchanged.
--- **Receivers.** Media, Events and other services must adopt the `env` check (contracts proposal) and key tenancy by `project_id` before they opt in to 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.
++
++## Public discovery and CORS
++
++`/.well-known/openvibe`, `/api/v1/registry` with everything under it, and `/contracts/<domain>/<name>.v<N>.json` answer any origin (`server/public-cors.js`):
++
++- `Access-Control-Allow-Origin: *`, never with credentials
++- preflight `204` with `GET, HEAD, OPTIONS` and the request headers `traceparent`, `X-OpenVibe-Request-Id`, `Authorization` and `Content-Type`
++- `X-OpenVibe-Request-Id` and `traceparent` exposed
++- `Cross-Origin-Resource-Policy: cross-origin`
++
++A browser app can discover services, capabilities and schemas without a server of its own. Caching is unchanged (`public, max-age=…`). Every other route, including this projects API, `/api/auth/*` and `/oauth/*`, keeps the first-party CORS allow-list.
ddiff --git a/package-lock.json b/package-lock.json
iindex 4e76797e..f63215ac 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -21,6 +21,7 @@
"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-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",
"web-push": "^3.6.7"
@@@ -1479,6 +1480,15 @@
"node": ">=20"
}
},
++ "node_modules/openvibe-sdk": {
++ "version": "0.2.2",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
++ "integrity": "sha512-kDifUp3EcmnTWzElAmaCZnX659WErcnm4+qVEoAiYQWX/w/1p4TWvoHbUPsrX65TSROVR3tiyVkl801n/0yyCw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=20"
++ }
++ },
"node_modules/openvibe-shared": {
"version": "1.3.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
ddiff --git a/package.json b/package.json
iindex 36403679..6fe799f4 100644
---- a/package.json
++++ b/package.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-network",
"version": "1.0.0",
-- "description": "OpenVibe.Network \u2014 Identity, SSO, Accounts, Themes, Notifications, and OpenCoins Wallet",
++ "description": "OpenVibe.Network — Identity, SSO, Accounts, Themes, Notifications, and OpenCoins Wallet",
"main": "server/index.js",
"scripts": {
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
-- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/chrome.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/registry-ecosystem.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/observability.test.js && node test/status.test.js",
++ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/chrome.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/registry-ecosystem.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
},
@@@ -24,6 +24,7 @@
"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-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",
"web-push": "^3.6.7"
ddiff --git a/server/config.js b/server/config.js
iindex 5847cb35..1e8c809b 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -30,9 +30,14 @@ module.exports = {
// Developer projects (server/developer, ADR-014)
developer: {
-- // Audiences that accept sandbox app tokens (comma list, e.g. openvibe.media). Empty = none:
-- // sandbox apps then get no tokens, and every receiver refuses env=sandbox.
-- sandboxAudiences: process.env.DEV_SANDBOX_AUDIENCES || '',
++ // Audiences that accept sandbox app tokens (comma list). Unset = the code default
++ // (openvibe.media, openvibe.events, openvibe.tools: server/developer/policy.js); set to an
++ // empty value = none, so sandbox apps get no tokens at all.
++ sandboxAudiences: process.env.DEV_SANDBOX_AUDIENCES,
++ // Public capabilities every project's SANDBOX apps may hold without staff (comma list).
++ // Unset = the code default (media.object.*, events.app.*, tools.job.*); empty = none.
++ // Production apps only ever use the staff-set project allowance.
++ sandboxAllowance: process.env.DEV_SANDBOX_ALLOWANCE,
// How long a rotated client secret keeps working (seconds, 0..604800).
credentialOverlapS: process.env.DEV_CREDENTIAL_OVERLAP_S || 86400,
// Capabilities every new project's allowance starts with (public ones only; comma list).
@@@ -41,6 +46,10 @@ module.exports = {
maxAppsPerProject: process.env.DEV_MAX_APPS_PER_PROJECT || 20,
},
++ // OpenVibe.Events base URL for Network's own events (developer projects). Unset = no relay:
++ // events stay in dev_audit and are backfilled when it is set. Production: http://127.0.0.1:4300
++ eventsInternalUrl: process.env.OV_EVENTS_INTERNAL_URL || '',
++
// Database
db: {
path: process.env.DB_PATH || './data/network.db',
ddiff --git a/server/developer/event-relay.js b/server/developer/event-relay.js
nnew file mode 100644
iindex 00000000..f249bc33
---- /dev/null
++++ b/server/developer/event-relay.js
@@@ -0,0 +1,103 @@
++'use strict';
++/**
++ * Relay of Network's developer-project events to OpenVibe.Events (ADR-004 outbox semantics).
++ *
++ * store.audit() writes each network.app.* / network.credential.* / network.grant.changed envelope
++ * into its dev_audit row. When the relay is on (OV_EVENTS_INTERNAL_URL is set), the same
++ * transaction also enqueues the envelope into `network_event_outbox` (openvibe-sdk createOutbox),
++ * and the relay publishes due rows in id order. On start it backfills every dev_audit event that is
++ * not in the outbox yet (INSERT OR IGNORE by event_id, in dev_audit id order), so events written
++ * while the relay was off are delivered too. Delivery is at least once; Events answers a repeated
++ * event_id as a duplicate. Sent rows are kept (never pruned), so a backfill can never republish an
++ * event after Events has forgotten its id.
++ *
++ * Authentication: Network is the token issuer, so it signs its own 5-minute service token
++ * (sub svc:network, aud openvibe.events, cap events.event.publish; identity.service-token-claims@1)
++ * with its private key instead of asking itself for one.
++ */
++const crypto = require('crypto');
++const { serviceAuth, assertValid } = require('openvibe-contracts');
++const { createClient } = require('openvibe-sdk/core');
++const { createEventsClient, createOutbox } = require('openvibe-sdk/events');
++
++const TABLE = 'network_event_outbox';
++const TOKEN_TTL_S = 300;
++const outboxes = new WeakMap(); // db -> outbox, only while the relay is configured
++
++/** The outbox registered for this database, or null (relay off). store.audit() enqueues into it. */
++function outboxFor(db) {
++ return outboxes.get(db) || null;
++}
++
++/** A self-signed Network service token for publishing to Events, cached until a minute before exp. */
++function createSelfTokenSource({ privateKey, issuer }) {
++ let cached = null;
++ return {
++ getToken() {
++ const now = Math.floor(Date.now() / 1000);
++ if (cached && cached.exp - 60 > now) return cached.token;
++ const claims = {
++ iss: issuer, sub: 'svc:network', actor_type: 'service', aud: ['openvibe.events'], cap: ['events.event.publish'],
++ iat: now, exp: now + TOKEN_TTL_S, jti: `tok_${crypto.randomBytes(12).toString('hex')}`,
++ };
++ assertValid('identity.service-token-claims@1', claims);
++ cached = { token: serviceAuth.signServiceToken(claims, privateKey), exp: claims.exp };
++ return cached.token;
++ },
++ invalidate() { cached = null; },
++ };
++}
++
++/** Copy dev_audit events that are not in the outbox yet, oldest first. Returns how many were added. */
++function backfill(db) {
++ const rows = db.prepare(`SELECT id, event FROM dev_audit WHERE event IS NOT NULL
++ AND json_extract(event, '$.event_id') NOT IN (SELECT event_id FROM ${TABLE}) ORDER BY id`).all();
++ const insert = db.prepare(`INSERT OR IGNORE INTO ${TABLE} (event_id, envelope, traceparent, created_at, next_attempt_at) VALUES (?, ?, NULL, ?, 0)`);
++ let added = 0;
++ db.transaction(() => {
++ for (const r of rows) {
++ const env = JSON.parse(r.event);
++ added += insert.run(env.event_id, r.event, Date.now()).changes;
++ }
++ })();
++ return added;
++}
++
++/**
++ * Start the relay for `db` when `eventsUrl` is set; returns the outbox (or null when off).
++ * fetch/intervalMs are injectable for tests.
++ */
++function startRelay(db, { eventsUrl, privateKey, issuer, fetch: fetchImpl, intervalMs = 2000, log = console, autoStart = true } = {}) {
++ if (!eventsUrl) return null;
++ if (!privateKey || !String(privateKey).includes('BEGIN')) {
++ log.warn('[events-relay] OV_EVENTS_INTERNAL_URL is set but Network has no RS256 private key; relay stays off');
++ return null;
++ }
++ const tokens = createSelfTokenSource({ privateKey, issuer });
++ const client = createClient({
++ network: issuer,
++ baseUrls: { events: String(eventsUrl).replace(/\/+$/, '') },
++ tokenProvider: tokens,
++ retries: 1,
++ ...(fetchImpl ? { fetch: fetchImpl } : {}),
++ });
++ const events = createEventsClient(client, { source: 'network' });
++ const outbox = createOutbox(db, {
++ events, table: TABLE, intervalMs,
++ onError: (err) => log.warn(`[events-relay] publish failed: ${String((err && err.message) || err).slice(0, 200)}`),
++ });
++ outbox.ensureSchema();
++ outboxes.set(db, outbox);
++ const added = backfill(db);
++ if (added) log.log(`[events-relay] backfilled ${added} developer-project event(s) from dev_audit`);
++ if (autoStart) outbox.start();
++ return outbox;
++}
++
++function stopRelay(db) {
++ const outbox = outboxes.get(db);
++ outboxes.delete(db);
++ return outbox ? outbox.stop() : Promise.resolve();
++}
++
++module.exports = { startRelay, stopRelay, outboxFor, backfill, createSelfTokenSource, TABLE };
ddiff --git a/server/developer/policy.js b/server/developer/policy.js
iindex a258cdc2..8a805b4b 100644
---- a/server/developer/policy.js
++++ b/server/developer/policy.js
@@@ -43,20 +43,58 @@ function grantableCatalog() {
}));
}
--/** Developer settings with defaults (config.developer is optional so tests can omit it). */
++/**
++ * Audiences that accept sandbox app tokens when DEV_SANDBOX_AUDIENCES is unset. Each of these keeps
++ * sandbox traffic apart from real data: Media in a sandbox tenant, Events with env-marked events,
++ * Tools with sandbox jobs. openvibe.network is deliberately not one of them.
++ */
++const DEFAULT_SANDBOX_AUDIENCES = Object.freeze(['openvibe.media', 'openvibe.events', 'openvibe.tools']);
++
++/**
++ * 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.
++ */
++const DEFAULT_SANDBOX_ALLOWANCE = Object.freeze([
++ 'media.object.upload', 'media.object.read',
++ 'events.app.publish', 'events.app.read', 'events.app.subscribe',
++ 'tools.job.create', 'tools.job.read', 'tools.job.cancel',
++]);
++
++/** Public + grantable only (the rule for every allowance that is not set by staff by hand). */
++const publicOnly = (ids) => [...new Set(ids)].filter(id => grantability(id).grantable && DEFAULT_ALLOWANCE_VISIBILITIES.has(capabilities.get(id).visibility)).sort();
++
++/**
++ * Developer settings with defaults (config.developer is optional so tests can omit it).
++ * For DEV_SANDBOX_AUDIENCES and DEV_SANDBOX_ALLOWANCE, unset (undefined/null) means the code
++ * default above; set to an empty string means none.
++ */
function settings(config) {
const d = (config && config.developer) || {};
const list = (v) => (Array.isArray(v) ? v : String(v || '').split(',')).map(s => String(s).trim()).filter(Boolean);
++ const orDefault = (v, dflt) => (v === undefined || v === null ? dflt : v);
const num = (v, dflt, min, max) => { const n = Number(v); return Number.isFinite(n) ? Math.min(max, Math.max(min, Math.floor(n))) : dflt; };
return {
-- sandboxAudiences: new Set(list(d.sandboxAudiences)),
++ sandboxAudiences: new Set(list(orDefault(d.sandboxAudiences, DEFAULT_SANDBOX_AUDIENCES))),
++ sandboxAllowance: publicOnly(list(orDefault(d.sandboxAllowance, DEFAULT_SANDBOX_ALLOWANCE))),
credentialOverlapS: num(d.credentialOverlapS, 86400, 0, 7 * 86400),
-- defaultAllowance: list(d.defaultAllowance).filter(id => grantability(id).grantable && DEFAULT_ALLOWANCE_VISIBILITIES.has(capabilities.get(id).visibility)),
++ defaultAllowance: publicOnly(list(d.defaultAllowance)),
maxProjectsPerOwner: num(d.maxProjectsPerOwner, 10, 1, 1000),
maxAppsPerProject: num(d.maxAppsPerProject, 20, 1, 1000),
};
}
++/**
++ * The capabilities one app may hold: the project's staff-set allowance, plus the sandbox allowance
++ * for a sandbox app. `settings` is settings(config).
++ */
++function allowanceFor(project, app, s) {
++ const out = new Set(JSON.parse((project && project.allowance) || '[]'));
++ if (app && app.environment === 'sandbox') for (const id of (s || settings()).sandboxAllowance) out.add(id);
++ return out;
++}
++
/**
* Receiver-side environment check. Tokens without `env` (first-party service principals) and
* `env: production` pass; `env: sandbox` passes only when this audience opted in.
@@@ -75,6 +113,6 @@ function unverifiedClaims(token) {
}
module.exports = {
-- GRANTABLE_VISIBILITIES, ENVIRONMENTS, ENVIRONMENT_POLICIES,
-- grantability, isGrantable, audienceOf, grantableCatalog, settings, environmentDecision, unverifiedClaims,
++ GRANTABLE_VISIBILITIES, ENVIRONMENTS, ENVIRONMENT_POLICIES, DEFAULT_SANDBOX_AUDIENCES, DEFAULT_SANDBOX_ALLOWANCE,
++ grantability, isGrantable, audienceOf, grantableCatalog, settings, allowanceFor, environmentDecision, unverifiedClaims,
};
ddiff --git a/server/developer/routes.js b/server/developer/routes.js
iindex ecb50074..17c2d8be 100644
---- a/server/developer/routes.js
++++ b/server/developer/routes.js
@@@ -73,13 +73,17 @@ function router() {
} catch (err) { send(req, res, err); }
};
-- r.get('/catalog', (req, res) => res.json({ capabilities: policy.grantableCatalog(), rule: 'only active capabilities with visibility public (or partner, by staff allowance) are grantable to apps' }));
++ r.get('/catalog', (req, res) => res.json({
++ capabilities: policy.grantableCatalog(),
++ sandbox_allowance: policy.settings(req.app.locals.config).sandboxAllowance,
++ rule: 'only active capabilities with visibility public (or partner, by staff allowance) are grantable to apps; sandbox apps may hold sandbox_allowance without staff, production apps only the staff-set project allowance',
++ }));
r.post('/', handle((db, a, req, o) => store.createProject(db, a, req.body || {}, o), 201));
-- r.get('/', handle((db, a, req) => ({ projects: store.listProjects(db, a, { all: req.query.all === '1' }) })));
-- r.get('/:project', handle((db, a, req) => {
++ r.get('/', handle((db, a, req, o) => ({ projects: store.listProjects(db, a, { all: req.query.all === '1', settings: o.settings }) })));
++ r.get('/:project', handle((db, a, req, o) => {
const { project, role } = store.access(db, a, req.params.project, { allowArchived: true });
-- return store.projectView(db, project, role);
++ return store.projectView(db, project, role, o.settings);
}));
r.patch('/:project', handle((db, a, req, o) => store.renameProject(db, a, req.params.project, req.body || {}, o)));
r.post('/:project/archive', handle((db, a, req, o) => store.archiveProject(db, a, req.params.project, o)));
ddiff --git a/server/developer/store.js b/server/developer/store.js
iindex 09133612..b0f9ee57 100644
---- a/server/developer/store.js
++++ b/server/developer/store.js
@@@ -14,15 +14,15 @@
* it, shrinking the allowance revokes what falls outside, and token issuance intersects again.
* - Quotas are recorded and exposed here; the owning service enforces them.
* - dev_audit is append-only (triggers refuse UPDATE/DELETE). Rows that are platform events carry an
-- * event envelope (events.event-envelope@1).
-- * TODO(events): Network has no Events producer yet. When it gets one, relay dev_audit rows with an
-- * event envelope to OpenVibe.Events in id order (outbox semantics, keyed by event_id) instead of
-- * adding a second write path.
++ * event envelope (events.event-envelope@1). When OV_EVENTS_INTERNAL_URL is set, the same
++ * transaction enqueues the envelope into network_event_outbox and ./event-relay.js publishes it
++ * to OpenVibe.Events (openvibe-sdk outbox; events written while the relay was off are backfilled).
*/
const crypto = require('crypto');
const { ids, validate } = require('openvibe-contracts');
const subjects = require('../identity/subjects');
const policy = require('./policy');
++const eventRelay = require('./event-relay');
const ROLES = ['viewer', 'developer', 'admin', 'owner'];
const RANK = { viewer: 1, developer: 2, admin: 3, owner: 4 };
@@@ -162,10 +162,17 @@ function audit(db, { projectId, actor, action, target, detail, ctx, event }) {
const v = validate('events.event-envelope@1', envelope);
if (!v.valid) throw new Error(`bad event envelope: ${v.errors.map(e => `${e.path} ${e.message}`).join('; ')}`);
}
-- db.prepare(`INSERT INTO dev_audit (at, project_id, actor, action, target, detail, request_id, event_type, event)
-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
-- .run(nowIso(), projectId || null, actor, action, target || null, JSON.stringify(detail || {}), (ctx && ctx.requestId) || null,
-- envelope ? envelope.event_type : null, envelope ? JSON.stringify(envelope) : null);
++ // One transaction (a savepoint when the caller already has one): the audit row and, when the
++ // Events relay is on, its outbox row exist together or not at all.
++ const outbox = envelope ? eventRelay.outboxFor(db) : null;
++ db.transaction(() => {
++ db.prepare(`INSERT INTO dev_audit (at, project_id, actor, action, target, detail, request_id, event_type, event)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
++ .run(nowIso(), projectId || null, actor, action, target || null, JSON.stringify(detail || {}), (ctx && ctx.requestId) || null,
++ envelope ? envelope.event_type : null, envelope ? JSON.stringify(envelope) : null);
++ if (outbox) outbox.enqueue(envelope, { traceparent: ctx && ctx.traceparent });
++ })();
++ if (outbox) outbox.kick();
}
function listAudit(db, projectId, { before, limit = 50 } = {}) {
@@@ -216,11 +223,13 @@ const cleanName = (v, field = 'name') => {
return s;
};
--function projectView(db, p, role) {
++function projectView(db, p, role, settings) {
return {
id: p.id, name: p.name, owner: { type: 'user', id: p.owner_subject }, role: role || null,
environment_policy: p.environment_policy, environments: policy.ENVIRONMENT_POLICIES[p.environment_policy],
allowance: JSON.parse(p.allowance || '[]'),
++ // Held by sandbox apps without a staff decision (DEV_SANDBOX_ALLOWANCE); production apps use `allowance` only.
++ sandbox_allowance: (settings || policy.settings()).sandboxAllowance,
created_at: p.created_at, archived_at: p.archived_at || null,
counts: {
members: db.prepare('SELECT COUNT(*) AS n FROM dev_project_members WHERE project_id = ?').get(p.id).n,
@@@ -242,39 +251,39 @@ function createProject(db, actor, { name }, { settings, ctx }) {
.run(id, actor.subject, 'owner', t, actor.label);
audit(db, { projectId: id, actor: actor.label, action: 'project.created', target: id, detail: { name: n, allowance: settings.defaultAllowance }, ctx });
})();
-- return projectView(db, db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(id), 'owner');
++ return projectView(db, db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(id), 'owner', settings);
}
--function listProjects(db, actor, { all = false } = {}) {
++function listProjects(db, actor, { all = false, settings } = {}) {
const rows = all && actor.staff
? db.prepare('SELECT p.*, m.role FROM dev_projects p LEFT JOIN dev_project_members m ON m.project_id = p.id AND m.subject_id = ? ORDER BY p.id DESC').all(actor.subject)
: db.prepare('SELECT p.*, m.role FROM dev_projects p JOIN dev_project_members m ON m.project_id = p.id WHERE m.subject_id = ? ORDER BY p.id DESC').all(actor.subject);
-- return rows.map(r => projectView(db, r, r.role));
++ return rows.map(r => projectView(db, r, r.role, settings));
}
--function renameProject(db, actor, projectId, { name }, { ctx }) {
++function renameProject(db, actor, projectId, { name }, { ctx, settings }) {
const { project, role } = access(db, actor, projectId, { need: 'admin', staffOk: false });
const n = cleanName(name);
db.transaction(() => {
db.prepare('UPDATE dev_projects SET name = ? WHERE id = ?').run(n, project.id);
audit(db, { projectId: project.id, actor: actor.label, action: 'project.renamed', target: project.id, detail: { from: project.name, to: n }, ctx });
})();
-- return projectView(db, db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(project.id), role);
++ return projectView(db, db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(project.id), role, settings);
}
/** Archive: irreversible. Every app of the project is revoked (credentials included). */
--function archiveProject(db, actor, projectId, { ctx }) {
++function archiveProject(db, actor, projectId, { ctx, settings }) {
const { project, role } = access(db, actor, projectId, { need: 'owner', staffOk: true });
db.transaction(() => {
for (const app of db.prepare('SELECT * FROM dev_apps WHERE project_id = ? AND revoked_at IS NULL').all(project.id)) revokeAppTx(db, actor, project, app, ctx, 'project archived');
db.prepare('UPDATE dev_projects SET archived_at = ?, archived_by = ? WHERE id = ?').run(nowIso(), actor.label, project.id);
audit(db, { projectId: project.id, actor: actor.label, action: 'project.archived', target: project.id, ctx });
})();
-- return projectView(db, db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(project.id), role);
++ return projectView(db, db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(project.id), role, settings);
}
/** Staff: the capabilities this project's apps may hold. Grants outside the new allowance are revoked. */
--function setAllowance(db, actor, projectId, { capabilities: list }, { ctx }) {
++function setAllowance(db, actor, projectId, { capabilities: list }, { ctx, settings }) {
if (!actor.staff) fail(403, 'project.staff_only', 'only staff set a project allowance');
const { project } = access(db, actor, projectId);
if (!Array.isArray(list) || list.length > 200) fail(422, 'project.invalid', 'capabilities must be an array (at most 200)');
@@@ -287,9 +296,11 @@ function setAllowance(db, actor, projectId, { capabilities: list }, { ctx }) {
db.transaction(() => {
db.prepare('UPDATE dev_projects SET allowance = ? WHERE id = ?').run(JSON.stringify(wanted), project.id);
audit(db, { projectId: project.id, actor: actor.label, action: 'project.allowance_set', target: project.id, detail: { from: JSON.parse(project.allowance), to: wanted }, ctx });
-- const rows = db.prepare(`SELECT g.*, a.project_id FROM dev_grants g JOIN dev_apps a ON a.id = g.app_id
++ const rows = db.prepare(`SELECT g.*, a.project_id, a.environment FROM dev_grants g JOIN dev_apps a ON a.id = g.app_id
WHERE a.project_id = ? AND g.status IN ('approved', 'requested')`).all(project.id);
-- for (const g of rows.filter(r => !wanted.includes(r.capability))) {
++ // A sandbox app's grant that is still inside the sandbox allowance survives a staff change.
++ const updated = { ...project, allowance: JSON.stringify(wanted) };
++ for (const g of rows.filter(r => !policy.allowanceFor(updated, { environment: r.environment }, settings).has(r.capability))) {
const next = g.status === 'approved' ? 'revoked' : 'denied';
db.prepare('UPDATE dev_grants SET status = ?, decided_by = ?, decided_at = ? WHERE app_id = ? AND capability = ?').run(next, actor.label, nowIso(), g.app_id, g.capability);
grantEvent(db, actor, project.id, g.app_id, g.capability, g.audience, g.status, next, ctx, 'outside allowance');
@@@ -602,15 +613,16 @@ function listGrants(db, actor, projectId, appId) {
return db.prepare('SELECT * FROM dev_grants WHERE app_id = ? ORDER BY capability').all(app.id).map(grantView);
}
--function withinAllowance(project, capability) {
-- return JSON.parse(project.allowance || '[]').includes(capability);
++/** Inside the project's allowance, or (sandbox apps) the sandbox allowance. */
++function withinAllowance(project, app, capability, settings) {
++ return policy.allowanceFor(project, app, settings).has(capability);
}
/**
* Request a capability for an app (developer+). Owners and admins get it approved at once when it
* is inside the allowance; otherwise it waits for an owner/admin.
*/
--function requestGrant(db, actor, projectId, appId, { capability }, { ctx }) {
++function requestGrant(db, actor, projectId, appId, { capability }, { ctx, settings }) {
const { project, role } = access(db, actor, projectId, { need: 'developer', staffOk: false });
const app = loadApp(db, project.id, appId);
if (app.revoked_at) fail(409, 'app.revoked', 'app is revoked');
@@@ -619,7 +631,7 @@ function requestGrant(db, actor, projectId, appId, { capability }, { ctx }) {
const audience = policy.audienceOf(capability);
const existing = db.prepare('SELECT * FROM dev_grants WHERE app_id = ? AND capability = ?').get(app.id, capability);
if (existing && ['approved', 'requested'].includes(existing.status)) return grantView(existing);
-- const approveNow = RANK[role] >= RANK.admin && withinAllowance(project, capability);
++ const approveNow = RANK[role] >= RANK.admin && withinAllowance(project, app, capability, settings);
const status = approveNow ? 'approved' : 'requested';
const t = nowIso();
db.transaction(() => {
@@@ -634,7 +646,7 @@ function requestGrant(db, actor, projectId, appId, { capability }, { ctx }) {
return grantView(db.prepare('SELECT * FROM dev_grants WHERE app_id = ? AND capability = ?').get(app.id, capability));
}
--function decideGrant(db, actor, projectId, appId, capability, decision, { ctx }) {
++function decideGrant(db, actor, projectId, appId, capability, decision, { ctx, settings }) {
const { project } = access(db, actor, projectId, { need: 'admin', staffOk: decision === 'revoked', allowArchived: decision === 'revoked' });
const app = loadApp(db, project.id, appId);
const g = db.prepare('SELECT * FROM dev_grants WHERE app_id = ? AND capability = ?').get(app.id, String(capability));
@@@ -643,7 +655,9 @@ function decideGrant(db, actor, projectId, appId, capability, decision, { ctx })
if (app.revoked_at) fail(409, 'app.revoked', 'app is revoked');
const ok = policy.grantability(g.capability);
if (!ok.grantable) fail(403, ok.code, ok.reason);
-- if (!withinAllowance(project, g.capability)) fail(403, 'grant.beyond_allowance', `${g.capability} is not in this project's allowance (staff set it)`);
++ if (!withinAllowance(project, app, g.capability, settings)) {
++ fail(403, 'grant.beyond_allowance', `${g.capability} is not in this project's ${app.environment === 'sandbox' ? 'allowance or the sandbox allowance' : 'allowance (staff set it for production apps)'}`);
++ }
if (g.status === 'approved') return grantView(g);
} else if (decision === 'denied') {
if (g.status !== 'requested') fail(409, 'grant.not_pending', `grant is ${g.status}`);
ddiff --git a/server/developer/tokens.js b/server/developer/tokens.js
iindex 0140e386..7275df3e 100644
---- a/server/developer/tokens.js
++++ b/server/developer/tokens.js
@@@ -41,9 +41,9 @@ function usableApp(db, clientId) {
return { app, project };
}
--/** Capabilities the app holds for an audience right now. */
--function effectiveGrants(db, app, project, audience) {
-- const allowance = new Set(JSON.parse(project.allowance || '[]'));
++/** Capabilities the app holds for an audience right now (sandbox apps: allowance ∪ sandbox allowance). */
++function effectiveGrants(db, app, project, audience, settings) {
++ const allowance = policy.allowanceFor(project, app, settings);
return db.prepare("SELECT capability FROM dev_grants WHERE app_id = ? AND audience = ? AND status = 'approved' ORDER BY capability")
.all(app.id, audience).map(r => r.capability)
.filter(c => allowance.has(c) && policy.isGrantable(c) && policy.audienceOf(c) === audience);
@@@ -55,7 +55,7 @@ function mint({ app, project, audience, scope, limit, onBehalfOf, privateKey, is
if (app.environment === 'sandbox' && !settings.sandboxAudiences.has(aud)) {
return oauthError(400, 'invalid_target', `${aud} does not accept sandbox tokens`);
}
-- const held = effectiveGrants(db, app, project, aud).filter(c => !limit || limit.includes(c));
++ const held = effectiveGrants(db, app, project, aud, settings).filter(c => !limit || limit.includes(c));
const wanted = scope ? String(scope).split(/\s+/).filter(Boolean) : null;
const missing = wanted ? wanted.filter(w => !held.includes(w)) : [];
if (missing.length) return oauthError(400, 'invalid_scope', `not granted: ${missing.join(' ')}`);
ddiff --git a/test/developer-defaults.test.js b/test/developer-defaults.test.js
nnew file mode 100644
iindex 00000000..4a69d2f7
---- /dev/null
++++ b/test/developer-defaults.test.js
@@@ -0,0 +1,273 @@
++'use strict';
++// Developer-project defaults that let a new project work without staff (Wave 20 exit criterion),
++// and the relay of developer-project events to OpenVibe.Events.
++// - DEV_SANDBOX_AUDIENCES defaults to openvibe.media, openvibe.events, openvibe.tools
++// - sandbox apps may hold the sandbox allowance (public capabilities only) without staff;
++// production apps still need the staff-set allowance
++// - events.app.* join the sandbox allowance as soon as the installed contracts know them
++// - dev_audit envelopes are relayed to Events through an outbox (OV_EVENTS_INTERNAL_URL), backfilled
++// node test/developer-defaults.test.js
++const assert = require('assert');
++const crypto = require('crypto');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const express = require('express');
++const jwt = require('jsonwebtoken');
++const contracts = require('openvibe-contracts');
++const { serviceAuth, validate } = contracts;
++const { initDb } = require('../server/db/database');
++const policy = require('../server/developer/policy');
++const relay = require('../server/developer/event-relay');
++
++const quiet = { log() {}, warn() {}, error() {} };
++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-devdefaults-'));
++const db = initDb(path.join(dir, 'network.db'));
++db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES (10, 'owner', 'x', 'user'), (11, 'dev', 'x', 'user'), (14, 'staff', 'x', 'admin')`).run();
++
++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++const ISSUER = 'https://openvibe.network';
++const app = express();
++app.use(express.json());
++app.use(express.urlencoded({ extended: true }));
++app.locals.db = db;
++// No developer settings at all: the code defaults apply (as with DEV_SANDBOX_* unset).
++app.locals.config = { baseUrl: ISSUER, loginUrl: ISSUER, internalKey: 'legacy-key', jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };
++app.locals.privateKey = keys.privateKey;
++app.locals.publicKey = keys.publicKey;
++app.use('/oauth', require('../server/auth/oauth-routes'));
++app.use('/api/v1/projects', require('../server/developer/routes').router());
++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: [],
++}));
++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;
++ const origGet = caps.get;
++ const origManifests = caps.manifests;
++ caps.get = (id) => extra.find(c => c.id === id) || origGet.call(caps, id);
++ caps.manifests = [...origManifests, ...extra];
++ 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(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');
++ assert.deepStrictEqual(s.sandboxAllowance, []);
++ s = policy.settings({ developer: { sandboxAudiences: 'openvibe.media', sandboxAllowance: 'media.object.upload,network.coins.credit,identity.subject.resolve,events.event.publish,nope.x.y' } });
++ assert.deepStrictEqual([...s.sandboxAudiences], ['openvibe.media']);
++ assert.deepStrictEqual(s.sandboxAllowance, ['media.object.upload'], 'first-party, internal and unknown capabilities never enter the sandbox allowance');
++ await withCatalog([PARTNER], () => {
++ assert.ok(policy.isGrantable('media.partner.test'), 'partner is grantable by hand');
++ 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 new Promise(r => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const api = async (who, method, p, body) => {
++ const r = await fetch(`${base}/api/v1/projects${p}`, { method, headers: { authorization: `Bearer ${T[who]}`, ...(body ? { 'content-type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined });
++ const text = await r.text();
++ return { status: r.status, text, body: text ? JSON.parse(text) : null };
++ };
++ const cc = (appId, secret, audience, extra = {}) => fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
++ body: new URLSearchParams({ grant_type: 'client_credentials', client_id: appId, client_secret: secret, audience, ...extra }) }).then(async r => ({ status: r.status, body: await r.json() }));
++ const verify = (t, audience) => serviceAuth.verifyServiceToken(t, { publicKey: keys.publicKey, issuer: ISSUER, audience, acceptSandbox: true });
++
++ // ── Events written before the relay starts (backfilled later) ──
++ let r = await api('owner', 'POST', '', { name: 'Walkthrough' });
++ assert.strictEqual(r.status, 201, r.text);
++ const P = r.body.id;
++ assert.deepStrictEqual(r.body.allowance, [], 'no staff allowance');
++ assert.deepStrictEqual(r.body.sandbox_allowance, policy.settings({}).sandboxAllowance, 'the project shows what its sandbox apps may hold');
++ r = await api('owner', 'GET', '/catalog');
++ assert.deepStrictEqual(r.body.sandbox_allowance, policy.settings({}).sandboxAllowance);
++
++ // ── The walkthrough, with no staff action: sandbox app, grant, token ──
++ r = await api('owner', 'POST', `/${P}/apps`, { name: 'examples', environment: 'sandbox', type: 'confidential' });
++ assert.strictEqual(r.status, 201, r.text);
++ const A = r.body.id;
++ const secret = r.body.credential.client_secret;
++ for (const c of ['media.object.upload', 'media.object.read', 'tools.job.create']) {
++ r = await api('owner', 'POST', `/${P}/apps/${A}/grants`, { capability: c });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.body.status, 'approved', `${c}: an owner's request inside the sandbox allowance is approved at once`);
++ }
++ let t = await cc(A, secret, 'openvibe.media');
++ assert.strictEqual(t.status, 200, JSON.stringify(t.body));
++ let v = verify(t.body.access_token, 'openvibe.media');
++ assert.ok(v.ok, v.reason);
++ assert.strictEqual(v.claims.env, 'sandbox');
++ assert.deepStrictEqual(v.claims.ns, [P]);
++ assert.strictEqual(v.claims.project_id, P);
++ assert.deepStrictEqual(v.claims.cap, ['media.object.read', 'media.object.upload']);
++ assert.strictEqual(serviceAuth.verifyServiceToken(t.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.media' }).code, 'token.sandbox_refused',
++ 'receivers still refuse it unless they opt in');
++ t = await cc(A, secret, 'openvibe.tools');
++ assert.strictEqual(t.status, 200, JSON.stringify(t.body));
++ assert.deepStrictEqual(verify(t.body.access_token, 'openvibe.tools').claims.cap, ['tools.job.create']);
++ t = await cc(A, secret, 'openvibe.network');
++ assert.strictEqual(t.body.error, 'invalid_target', 'Network is not a default sandbox audience');
++
++ // A developer's request waits; an owner approves it inside the sandbox allowance.
++ r = await api('owner', 'POST', `/${P}/members`, { username: 'dev', role: 'developer' });
++ assert.strictEqual(r.status, 201, r.text);
++ r = await api('dev', 'POST', `/${P}/apps/${A}/grants`, { capability: 'tools.job.read' });
++ assert.strictEqual(r.body.status, 'requested');
++ r = await api('owner', 'POST', `/${P}/apps/${A}/grants/tools.job.read/approve`);
++ assert.strictEqual(r.status, 200, r.text); assert.strictEqual(r.body.status, 'approved');
++ // Outside the sandbox allowance (a public capability staff did not allow): still refused.
++ r = await api('owner', 'POST', `/${P}/apps/${A}/grants`, { capability: 'games.mod.read' });
++ assert.strictEqual(r.body.status, 'requested');
++ r = await api('owner', 'POST', `/${P}/apps/${A}/grants/games.mod.read/approve`);
++ assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'grant.beyond_allowance');
++ r = await api('owner', 'POST', `/${P}/apps/${A}/grants`, { capability: 'network.coins.credit' });
++ assert.strictEqual(r.body.code, 'grant.not_grantable');
++
++ // Staff changing the project allowance does not take the sandbox allowance away.
++ r = await api('staff', 'PUT', `/${P}/allowance`, { capabilities: ['games.mod.read'] });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(!r.body.trimmed.some(x => x.app_id === A && x.capability.startsWith('media.')), JSON.stringify(r.body.trimmed));
++ assert.strictEqual((await cc(A, secret, 'openvibe.media')).status, 200);
++
++ // ── Production apps still need the staff-set allowance ──
++ r = await api('staff', 'PUT', `/${P}/environment-policy`, { environment_policy: 'sandbox+production' });
++ assert.strictEqual(r.status, 200);
++ r = await api('owner', 'POST', `/${P}/apps`, { name: 'prod', environment: 'production', type: 'confidential' });
++ assert.strictEqual(r.status, 201, r.text);
++ const C = r.body.id;
++ const prodSecret = r.body.credential.client_secret;
++ r = await api('owner', 'POST', `/${P}/apps/${C}/grants`, { capability: 'media.object.upload' });
++ assert.strictEqual(r.body.status, 'requested', 'no sandbox allowance for production apps');
++ r = await api('owner', 'POST', `/${P}/apps/${C}/grants/media.object.upload/approve`);
++ assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'grant.beyond_allowance');
++ assert.strictEqual((await cc(C, prodSecret, 'openvibe.media')).body.error, 'invalid_scope');
++ r = await api('staff', 'PUT', `/${P}/allowance`, { capabilities: ['media.object.upload'] });
++ r = await api('owner', 'POST', `/${P}/apps/${C}/grants/media.object.upload/approve`);
++ assert.strictEqual(r.status, 200, r.text);
++ t = await cc(C, prodSecret, 'openvibe.media');
++ assert.strictEqual(t.status, 200);
++ assert.strictEqual(verify(t.body.access_token, 'openvibe.media').claims.env, 'production');
++ // A production app's grant outside the new allowance is trimmed as before.
++ r = await api('staff', 'PUT', `/${P}/allowance`, { capabilities: [] });
++ 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 () => {
++ 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);
++ assert.strictEqual(r.body.status, 'approved', c);
++ assert.strictEqual(r.body.audience, 'openvibe.events');
++ }
++ t = await cc(A, secret, 'openvibe.events');
++ assert.strictEqual(t.status, 200, JSON.stringify(t.body));
++ v = verify(t.body.access_token, 'openvibe.events');
++ assert.deepStrictEqual(v.claims.cap, ['events.app.publish', 'events.app.read', 'events.app.subscribe']);
++ 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');
++ });
++ // 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);
++ assert.strictEqual(relay.outboxFor(db), null);
++ assert.strictEqual(db.prepare("SELECT name FROM sqlite_master WHERE name = 'network_event_outbox'").get(), undefined);
++
++ // ── Relay on: a fake Events receives every envelope once, in order, with a Network service token ──
++ const received = [];
++ const stored = new Map();
++ const tokensSeen = [];
++ const events = http.createServer((req, res) => {
++ let body = '';
++ req.on('data', c => { body += c; });
++ req.on('end', () => {
++ tokensSeen.push(String(req.headers.authorization || '').replace(/^Bearer /, ''));
++ const parsed = JSON.parse(body);
++ const list = parsed.events || [parsed];
++ const results = list.map(e => {
++ received.push(e);
++ const dup = stored.has(e.event_id);
++ if (!dup) stored.set(e.event_id, stored.size + 1);
++ return { event_id: e.event_id, seq: stored.get(e.event_id), duplicate: dup };
++ });
++ res.writeHead(201, { 'content-type': 'application/json' });
++ res.end(JSON.stringify(parsed.events ? { results } : results[0]));
++ });
++ });
++ await new Promise(res => events.listen(0, '127.0.0.1', res));
++ const eventsUrl = `http://127.0.0.1:${events.address().port}`;
++ const before = db.prepare('SELECT event FROM dev_audit WHERE event IS NOT NULL ORDER BY id').all().map(x => JSON.parse(x.event).event_id);
++ assert.ok(before.length >= 5, 'the walkthrough produced events');
++ let outbox = relay.startRelay(db, { eventsUrl, privateKey: keys.privateKey, issuer: ISSUER, autoStart: false, log: quiet });
++ assert.ok(outbox);
++ assert.strictEqual(outbox.pending(), before.length, 'events written while the relay was off are backfilled');
++ // A new event now goes into the outbox in the same transaction as its audit row.
++ r = await api('owner', 'POST', `/${P}/apps`, { name: 'later', environment: 'sandbox', type: 'confidential' });
++ assert.strictEqual(r.status, 201);
++ assert.strictEqual(outbox.pending(), before.length + 1);
++ let flushed = await outbox.flush();
++ assert.strictEqual(flushed.sent, before.length + 1, JSON.stringify(flushed));
++ const all = db.prepare('SELECT event FROM dev_audit WHERE event IS NOT NULL ORDER BY id').all().map(x => JSON.parse(x.event));
++ assert.deepStrictEqual(received.map(e => e.event_id), all.map(e => e.event_id), 'published in dev_audit id order');
++ for (const e of received) {
++ assert.ok(validate('events.event-envelope@1', e).valid, JSON.stringify(e));
++ assert.strictEqual(e.source, 'network');
++ }
++ assert.ok(received.some(e => e.event_type === 'network.app.created' && e.subject.id === r.body.id));
++ for (const tok of new Set(tokensSeen)) {
++ const tv = serviceAuth.verifyServiceToken(tok, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.events' });
++ assert.ok(tv.ok, tv.reason);
++ assert.strictEqual(tv.claims.sub, 'svc:network');
++ assert.deepStrictEqual(tv.claims.cap, ['events.event.publish']);
++ assert.ok(tv.claims.exp - tv.claims.iat <= 300);
++ assert.ok(validate('identity.service-token-claims@1', tv.claims).valid);
++ }
++ // Duplicates are harmless: a republished row is answered as a duplicate and stored once.
++ db.prepare(`UPDATE ${relay.TABLE} SET sent_at = NULL WHERE id = (SELECT MAX(id) FROM ${relay.TABLE})`).run();
++ flushed = await outbox.flush();
++ assert.strictEqual(flushed.sent, 1);
++ assert.strictEqual(stored.size, all.length, 'Events stored each event once');
++ // A restart backfills nothing new and sends nothing twice.
++ await relay.stopRelay(db);
++ outbox = relay.startRelay(db, { eventsUrl, privateKey: keys.privateKey, issuer: ISSUER, autoStart: false, log: quiet });
++ assert.strictEqual(outbox.pending(), 0);
++ const n = received.length;
++ await outbox.flush();
++ assert.strictEqual(received.length, n);
++ // The audit and the outbox row are one transaction: a failing enqueue leaves no audit row.
++ const auditCount = db.prepare('SELECT COUNT(*) AS n FROM dev_audit').get().n;
++ const origEnqueue = outbox.enqueue;
++ outbox.enqueue = () => { throw new Error('boom'); };
++ r = await api('owner', 'POST', `/${P}/apps`, { name: 'atomic', environment: 'sandbox', type: 'confidential' });
++ assert.strictEqual(r.status, 500);
++ assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM dev_audit').get().n, auditCount, 'no audit row without its outbox row');
++ outbox.enqueue = origEnqueue;
++ await relay.stopRelay(db);
++
++ server.close(); events.close();
++ console.log('developer defaults and events relay: all checks passed');
++})().catch(err => { console.error(err); process.exit(1); });
ddiff --git a/test/developer-projects.test.js b/test/developer-projects.test.js
iindex 9462900a..f46af298 100644
---- a/test/developer-projects.test.js
++++ b/test/developer-projects.test.js
@@@ -36,7 +36,9 @@ app.locals.db = db;
app.locals.config = {
baseUrl: ISSUER, loginUrl: ISSUER, internalKey: 'legacy-key',
jwt: { issuer: ISSUER, accessTokenExpiry: '1h' },
-- developer: { sandboxAudiences: 'openvibe.media', credentialOverlapS: 60 },
++ // No sandbox allowance here, so every grant goes through the staff-set allowance (the defaults
++ // are covered by test/developer-defaults.test.js).
++ developer: { sandboxAudiences: 'openvibe.media', sandboxAllowance: '', credentialOverlapS: 60 },
};
app.locals.privateKey = keys.privateKey;
app.locals.publicKey = keys.publicKey;