openvibers/openvibe.network Commits

Media namespaces and grants, Network's side (roadmap WS-G task 2): an app token's ns is [project_id, app.<project_id>.*], so the grant names the project's Media namespaces (app.<project_id> production, app.<project_id>.sandbox, and children below them) while services keying tenancy by project id keep working; Media reads an older [project_id] the same way. The default sandbox allowance lists media.object.list and media.object.delete (Media's list and delete verbs), left out until the pinned openvibe-contracts defines them as public and active, like events.app.* before 0.28.0. Tests: developer-defaults (ns, the verbs with and without the catalog), developer-projects (ns); docs/developer-projects.md

OP OpenVibers committed 26 Sept 2026 · d057b43fde · 5 files, +33 −12


Changes

ddiff --git a/docs/developer-projects.md b/docs/developer-projects.md
iindex 4cc6b36c..2162e6fc 100644
---- a/docs/developer-projects.md
++++ b/docs/developer-projects.md
@@@ -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. The three `events.app.*` ids are defined from openvibe-contracts v0.28.0 (Network pins v0.30.1); 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.
++- **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`, `media.object.list`, `media.object.delete`, `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 (Network pins v0.30.1); with an older release they would be left out. `media.object.list` and `media.object.delete` (Media's list and delete verbs, WS-G task 2) are left out the same way until the pinned release defines them; until then a sandbox app's upload and read grants cover them at Media. 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.
@@@ -70,7 +70,7 @@ Tokens are RS256 and last 5 minutes. They are signed with Network's key (JWKS at
| `actor_type` | `app` |
| `aud` | `[audience]`, one audience per token |
| `cap` | approved grants for that audience ∩ allowance ∩ grantable now (or the requested `scope` subset) |
--| `ns` | `[project_id]`. Tenancy in other services is keyed by project id (ADR-014). |
++| `ns` | `[project_id, app.<project_id>.*]`. Tenancy in other services is keyed by project id (ADR-014). Media names the project's namespaces `app.<project_id>` (production) and `app.<project_id>.sandbox`, with children below them, and checks each verb (read, list, write, delete, transform) per namespace; it reads an older token's `[project_id]` as `app.<project_id>.*` (roadmap WS-G task 2). |
| `project_id` | `prj_<ULID>` (optional in the contract today; proposed as required for apps) |
| `env` | `sandbox` or `production` (same) |
| `on_behalf_of` | `usr_<ULID>` of the person who authorized the app (authorization code only) |
@@@ -94,7 +94,7 @@ 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]`.
++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, app.<project_id>.*]`.
Production apps still need staff: the project's environment policy (`sandbox+production`) and its allowance.
@@@ -192,7 +192,7 @@ Until the relay is turned on in an environment, nobody else receives these event
| Env var | Default | Meaning |
|---|---|---|
| `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. |
++| `DEV_SANDBOX_ALLOWANCE` | unset: `media.object.upload,media.object.read,media.object.list,media.object.delete,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 |
ddiff --git a/server/developer/policy.js b/server/developer/policy.js
iindex a26fbbff..c731d4b7 100644
---- a/server/developer/policy.js
++++ b/server/developer/policy.js
@@@ -55,10 +55,12 @@ 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 (events.app.* before openvibe-contracts
-- * v0.28.0) is left out until it does. Production apps never get these: their allowance is staff-set.
++ * v0.28.0; media.object.list and media.object.delete, Media's list and delete verbs, before the
++ * contracts release that adds them) 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',
++ 'media.object.upload', 'media.object.read', 'media.object.list', 'media.object.delete',
'events.app.publish', 'events.app.read', 'events.app.subscribe',
'tools.job.create', 'tools.job.read', 'tools.job.cancel', 'tools.tool.read', 'tools.tool.run',
]);
ddiff --git a/server/developer/tokens.js b/server/developer/tokens.js
iindex 7275df3e..dc5594a4 100644
---- a/server/developer/tokens.js
++++ b/server/developer/tokens.js
@@@ -10,8 +10,11 @@
*
* Tokens are RS256, 5 minutes, shaped by identity.service-token-claims@1:
* sub app:app_<ULID>, actor_type app, aud [audience], cap (approved grants ∩ allowance ∩ still
-- * grantable, for that audience), ns [project_id], project_id, env sandbox|production,
-- * on_behalf_of usr_<ULID> (authorization code only).
++ * grantable, for that audience), ns [project_id, app.<project_id>.*], project_id,
++ * env sandbox|production, on_behalf_of usr_<ULID> (authorization code only).
++ * The ns: services key a project's tenancy by its id (ADR-014), and Media names a project's
++ * namespaces app.<project_id> (production) and app.<project_id>.sandbox, with children below them
++ * (roadmap WS-G task 2), so the grant covers exactly the project's namespaces in both.
* A sandbox app gets a token only for an audience that opted in to sandbox tokens
* (DEV_SANDBOX_AUDIENCES); receivers also refuse env=sandbox unless they opted in.
* No refresh tokens: a revoked credential or app stops new tokens at once, and issued tokens end
@@@ -49,6 +52,11 @@ function effectiveGrants(db, app, project, audience, settings) {
.filter(c => allowance.has(c) && policy.isGrantable(c) && policy.audienceOf(c) === audience);
}
++/** The namespaces an app token names: its project, and the project's app.<project_id>.* namespaces. */
++function projectNamespaces(projectId) {
++ return [projectId, `app.${projectId}.*`];
++}
++
function mint({ app, project, audience, scope, limit, onBehalfOf, privateKey, issuer, settings, db }) {
const aud = String(audience || '').trim();
if (!aud || !/^[a-z0-9.-]+$/.test(aud)) return oauthError(400, 'invalid_request', 'audience is required');
@@@ -63,7 +71,7 @@ function mint({ app, project, audience, scope, limit, onBehalfOf, privateKey, is
if (!cap.length) return oauthError(400, 'invalid_scope', `no grants for audience ${aud}`);
const now = Math.floor(Date.now() / 1000);
const claims = {
-- iss: issuer, sub: `app:${app.id}`, actor_type: 'app', aud: [aud], cap, ns: [project.id],
++ iss: issuer, sub: `app:${app.id}`, actor_type: 'app', aud: [aud], cap, ns: projectNamespaces(project.id),
project_id: project.id, env: app.environment,
...(onBehalfOf ? { on_behalf_of: onBehalfOf } : {}),
iat: now, exp: now + TOKEN_TTL_S, jti: `tok_${crypto.randomBytes(12).toString('hex')}`,
@@@ -159,4 +167,4 @@ function issueCode(db, { app, project, user, redirectUri, scope, challenge }) {
return { code };
}
--module.exports = { isAppClient, handleTokenRequest, checkAuthorizeRequest, issueCode, effectiveGrants, TOKEN_TTL_S };
++module.exports = { isAppClient, handleTokenRequest, checkAuthorizeRequest, issueCode, effectiveGrants, projectNamespaces, TOKEN_TTL_S };
ddiff --git a/test/developer-defaults.test.js b/test/developer-defaults.test.js
iindex f8ce7112..079f41b2 100644
---- a/test/developer-defaults.test.js
++++ b/test/developer-defaults.test.js
@@@ -86,6 +86,17 @@ async function withoutCatalog(hidden, fn) {
assert.deepStrictEqual(policy.settings({}).sandboxAllowance.filter(id => id.startsWith('events.')), [],
'capabilities the installed contracts do not define are silently left out');
});
++ // Media's list and delete verbs (WS-G task 2) enter the default sandbox allowance once the pinned
++ // contracts define them as public and active; until then they are left out like any unknown id.
++ assert.ok(['media.object.list', 'media.object.delete'].every(id => policy.DEFAULT_SANDBOX_ALLOWANCE.includes(id)));
++ const MEDIA_VERBS = ['media.object.list', 'media.object.delete'].map(id => ({ id, version: '1.0', owner: 'media', status: 'active', visibility: 'public', description: 'test', permissions: [], resourceConstraints: ['namespace'], events: [], implementedBy: [] }));
++ await withoutCatalog(MEDIA_VERBS.map(c => c.id), () => withCatalog(MEDIA_VERBS, () => {
++ const media = policy.settings({}).sandboxAllowance.filter(id => id.startsWith('media.'));
++ assert.deepStrictEqual(media, ['media.object.delete', 'media.object.list', 'media.object.read', 'media.object.upload'], 'with the verbs in the catalog, sandbox apps may hold them');
++ }));
++ await withoutCatalog(['media.object.list', 'media.object.delete'], () => {
++ assert.deepStrictEqual(policy.settings({}).sandboxAllowance.filter(id => id.startsWith('media.')), ['media.object.read', 'media.object.upload'], 'without them, as before');
++ });
await new Promise(r => server.listen(0, '127.0.0.1', r));
const base = `http://127.0.0.1:${server.address().port}`;
@@@ -122,7 +133,7 @@ async function withoutCatalog(hidden, fn) {
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.deepStrictEqual(v.claims.ns, [P, `app.${P}.*`], 'the project, and its app.<project_id>.* namespaces (Media, WS-G task 2)');
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',
ddiff --git a/test/developer-projects.test.js b/test/developer-projects.test.js
iindex b2dcda74..fcd1448e 100644
---- a/test/developer-projects.test.js
++++ b/test/developer-projects.test.js
@@@ -178,7 +178,7 @@ const secretsSeen = [];
assert.deepStrictEqual(v.claims.cap, ['media.object.upload'], 'only the approved grant');
assert.strictEqual(v.claims.project_id, P);
assert.strictEqual(v.claims.env, 'sandbox');
-- assert.deepStrictEqual(v.claims.ns, [P]);
++ assert.deepStrictEqual(v.claims.ns, [P, `app.${P}.*`], 'the project and its app.<project_id>.* namespaces');
assert.strictEqual(v.claims.exp - v.claims.iat, 300);
assert.ok(validate('identity.service-token-claims@1', v.claims).valid);
r = await fetch(`${base}/api/v1/projects`, { headers: { authorization: `Bearer ${t.body.access_token}` } });