openvibers/openvibe.ai Commits
Namespaces are the token's ns claim, nothing else: the fallback for service tokens without ns (AI_NS_FALLBACK) and the open-rule lever (AI_NS_REQUIRED=false) are retired (shims C-22 and C-23, roadmap WS-O task 2). Every caller's token names its namespaces: Live's grant lists live.*, network.site_copy and media.analyze; news and blog list their own; Network mints network.*. Checked in production on 2026-09-28, where this week's runs came only from those services and none of the settings was set. A token without ns now runs nothing. test/auth.test.js: ns-less and empty-ns service tokens are refused even in their own namespace, Live's named namespaces decide, and the retired settings have no effect. README, .env.example and docs/migration.md follow. npm test passes.
Changes
@@@ -18,11 +18,8 @@ AI_DB_PATH=./data/ai.dbOV_NETWORK_URL=https://openvibe.networkOV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000#OV_NETWORK_ISSUER=https://openvibe.network--# Namespaces fail closed: a token with no `ns` claim may only run its fallback namespaces. A service--# token (svc:<id>) without ns gets its entry here (service=ns|ns,...), else <id>.*; `none` = no fallback.--#AI_NS_FALLBACK=live=live.*|network.site_copy--# Rollback lever only: false restores the old rule (a token without ns may run every namespace).--#AI_NS_REQUIRED=true++# Namespaces fail closed: a token runs only the namespaces its `ns` claim (its Network grant) names; a token++# without `ns` runs nothing. (AI_NS_FALLBACK and AI_NS_REQUIRED were retired on 2026-09-28.)#OV_NETWORK_PUBLIC_KEY=# ── Operator console (/console; README "Operator console") ──@@@ -82,11 +82,9 @@ The capability ids and the `ai` service manifest are released in openvibe-contradrafts stay in `docs/capabilities-proposal/`); `server/auth.js` checks them with the contracts grantrule (exact id or `family.*`). A token's `ns` claim limits whichworkflow namespaces it may run (`live.*`, `wiki.*`, …), and namespaces fail closed: a token with--no `ns` runs nothing outside a documented fallback. A first-party service token (`svc:<id>`) without--`ns` gets its `AI_NS_FALLBACK` entry (default `live=live.*|network.site_copy`, because Network grants--Live's `ai.run.create` with no namespaces), else `<id>.*`; app and module tokens without `ns` get--nothing. `AI_NS_FALLBACK=none` removes the fallback; `AI_NS_REQUIRED=false` is a rollback lever to--the old rule (no `ns` = every namespace).++no `ns` runs nothing. Every caller's Network grant names its namespaces (Live: `live.*`,++`network.site_copy`, `media.analyze`). The fallback for service tokens without `ns` (`AI_NS_FALLBACK`)++and its rollback lever (`AI_NS_REQUIRED`) were retired on 2026-09-28 (shims C-22 and C-23).## Operator console@@@ -199,8 +197,8 @@ stub joins every route as a last resort outside production only (`AI_STUB_FALLBAgates only this call, never whole runs.`overview.source` says which (`local_model`, `paid`, `extractive`), and `gaps` lists what is missing (no local-- model, no speech-to-text, no budget). The output is not cached: callers keep it. Live may run it by the default-- `AI_NS_FALLBACK`.++ model, no speech-to-text, no budget). The output is not cached: callers keep it. Live's grant names++ `media.analyze`.- **Direct operations** — `ai.chat|generate|summarize|classify|extract|enrich|embed`.## Local model@@@ -81,8 +81,8 @@ structured workflow, point Live's call at it, and the passthrough key stays foralready imported and applies changed settings once. Review `import_holds`.4. **Grant Live a token**: Network client `live` needs `ai.run.create` (and `ai.run.read`) foraudience `openvibe.ai`, namespaces `live.*` and `network.site_copy` (the latter only while Live still-- answers `/internal/ai/site-copy`). Namespaces fail closed; a grant without namespaces is covered-- by the default `AI_NS_FALLBACK` (`live=live.*|network.site_copy`).++ answers `/internal/ai/site-copy`). Namespaces fail closed: a grant without namespaces runs nothing++ (the `AI_NS_FALLBACK` that once covered it was retired on 2026-09-28).5. **Apply `docs/live-patch.diff`** to Live (`git apply`), deploy, then set in `/etc/openvibe/live.env`:```AI_SERVICE=remote@@@ -40,7 +40,7 @@ function runsRouter({ runs, registry, auth, config, log = console }) {const has = (req) => (id) => auth.principalHas(req, id);function checkNamespace(req, workflowKey) {-- if (!namespaceAllowed(req.principal, workflowKey, config.namespaces)) throw new AiError(403, 'capability.namespace_denied', `this token may not run ${workflowKey}`);++ if (!namespaceAllowed(req.principal, workflowKey)) throw new AiError(403, 'capability.namespace_denied', `this token may not run ${workflowKey}`);}async function createAndRespond(req, res, body, waitMs) {@@@ -8,12 +8,11 @@* ship, allows() decides with the contracts' own grant rule (the exact id, or a `family.*` grant);* once contracts knows an id, contracts decides.*-- * Namespaces fail closed: a token may only run workflows inside the namespaces it holds (the same-- * matching as contracts' namespaceAllowed: 'live.*' allows 'live.translate'). The `ns` claim decides-- * when it is non-empty. A first-party service token WITHOUT one (Network grants Live's ai.run.create-- * with no namespaces) falls back to its entry in AI_NS_FALLBACK, else to its own `<service>.*`-- * (svc:news -> news.*). App and module tokens without `ns` run nothing. AI_NS_REQUIRED=false restores-- * the old rule (no `ns` = every namespace) and exists only as a rollback lever.++ * Namespaces fail closed: a token may only run workflows inside the namespaces its `ns` claim holds++ * (the same matching as contracts' namespaceAllowed: 'live.*' allows 'live.translate'). A token++ * without one runs nothing. Every caller's Network grant names its namespaces; the fallback for++ * ns-less service tokens (AI_NS_FALLBACK) and the open-rule lever (AI_NS_REQUIRED=false), shims++ * C-22 and C-23, were retired on 2026-09-28 once no token without `ns` was left.*/const crypto = require('crypto');const { serviceAuth, capabilities, http } = require('openvibe-contracts');@@@ -90,22 +89,13 @@ function allows(claims, id) {return c;}--const DEFAULT_NAMESPACES = Object.freeze({ required: true, fallback: Object.freeze({}), derive: true });----/** The namespaces a principal may run: its `ns` claim, or (service tokens only) the documented fallback. */--function effectiveNamespaces(principal, nsConfig = DEFAULT_NAMESPACES) {-- const ns = principal && Array.isArray(principal.ns) ? principal.ns.filter(n => typeof n === 'string' && n) : [];-- if (ns.length) return ns;-- const m = /^svc:([a-z][a-z0-9-]{1,39})$/.exec(String((principal && principal.sub) || ''));-- if (!m || !nsConfig.derive) return [];-- const listed = nsConfig.fallback && Object.prototype.hasOwnProperty.call(nsConfig.fallback, m[1]) ? nsConfig.fallback[m[1]] : null;-- return listed && listed.length ? [...listed] : [`${m[1]}.*`];++/** The namespaces a principal may run: its `ns` claim, nothing else. */++function effectiveNamespaces(principal) {++ return principal && Array.isArray(principal.ns) ? principal.ns.filter(n => typeof n === 'string' && n) : [];}--function namespaceAllowed(principal, workflowKey, nsConfig = DEFAULT_NAMESPACES) {-- const ns = principal && Array.isArray(principal.ns) ? principal.ns : [];-- if (!ns.length && nsConfig.required === false) return true; // AI_NS_REQUIRED=false: the old, open rule-- return capabilities.namespaceAllowed(effectiveNamespaces(principal, nsConfig), workflowKey);++function namespaceAllowed(principal, workflowKey) {++ return capabilities.namespaceAllowed(effectiveNamespaces(principal), workflowKey);}function bearer(req) {@@@ -24,26 +24,6 @@ const bool = (v, d) => (v == null || v === '' ? d : /^(1|true|yes|on)$/i.test(Stconst list = (v, d) => (v == null || v === '' ? d : String(v).split(',').map(s => s.trim()).filter(Boolean));const trimUrl = (v) => String(v || '').trim().replace(/\/+$/, '');--/**-- * AI_NS_FALLBACK: namespaces for a service token that carries no `ns` claim, as-- * `service=ns|ns,service=ns`. A service not listed gets `<service>.*`; `none` turns the fallback off-- * (an ns-less token then runs nothing). The default covers Live, whose Network grant has no-- * namespaces: its own workflows, plus network.site_copy for its /internal/ai/site-copy fallback and-- * media.analyze for its VOD and clip overviews.-- */--const DEFAULT_NS_FALLBACK = 'live=live.*|network.site_copy|media.analyze';--function nsFallback(v) {-- const raw = v == null || v === '' ? DEFAULT_NS_FALLBACK : String(v).trim();-- if (/^(none|off|0|false)$/i.test(raw)) return { derive: false, fallback: {} };-- const fallback = {};-- for (const entry of raw.split(',').map(s => s.trim()).filter(Boolean)) {-- const m = /^([a-z][a-z0-9-]{1,39})\s*=\s*(.+)$/.exec(entry);-- if (!m) throw new Error(`AI_NS_FALLBACK: "${entry}" is not service=ns|ns`);-- fallback[m[1]] = m[2].split('|').map(s => s.trim()).filter(Boolean);-- }-- return { derive: true, fallback };--}--/** Roles Live's llm.js routes by (one route per role; see server/workflows/seed.js). */const LIVE_ROLES = ['chat', 'vision', 'director', 'summary', 'legacy'];@@@ -78,9 +58,6 @@ function load(env = process.env) {credentialsKey: String(env.AI_CREDENTIALS_KEY || '').trim(),networkPublicKey: env.OV_NETWORK_PUBLIC_KEY ? env.OV_NETWORK_PUBLIC_KEY.replace(/\\n/g, '\n') : null,audience: 'openvibe.ai',-- // Namespaces fail closed (server/auth.js): no `ns` claim, no namespaced run, except the-- // documented service fallback. AI_NS_REQUIRED=false is a rollback lever to the old open rule.-- namespaces: { required: bool(env.AI_NS_REQUIRED, true), ...nsFallback(env.AI_NS_FALLBACK) },dbPath: env.AI_DB_PATH || './data/ai.db',@@@ -5,7 +5,7 @@* media.analyze local-first analysis of a VOD, clip or audio file (server/workflows/media-analysis.js): FFmpeg* signals, scenes, local speech-to-text, highlights with their evidence, and an overview from a local* model, or extractive; a paid provider only when the caller allows it and a budget covers it.-- * Media owns media; Live calls it for its VOD and clip overviews (AI_NS_FALLBACK default).++ * Media owns media; Live calls it for its VOD and clip overviews (its grant names media.analyze).*/const { MEDIA_REF } = require('./common-schemas');@@@ -63,53 +63,32 @@ t.test('a namespace-limited token may only run workflows in its namespaces', asyassert.strictEqual(r.body.code, 'capability.namespace_denied');});--t.test('namespaces fail closed: a token without ns runs nothing outside its own fallback', async () => {++t.test('namespaces fail closed: a token without ns runs nothing (C-22/C-23 retired)', async () => {const run = (tok, workflow, input) => request(h.base, 'POST', '/api/v1/runs', { tok, body: { workflow, input } });const denied = (r, what) => { assert.strictEqual(r.status, 403, what); assert.strictEqual(r.body.code, 'capability.namespace_denied', what); };-- // A service with no ns claim (or an empty one) is held to <service>.*.-- denied(await run(token('games', ['ai.run.create'], { ns: null }), 'ai.generate', { prompt: 'hi' }), 'svc:games without ns -> ai.generate');++ // No ns claim, or an empty one: nothing, not even the service's own namespace.++ denied(await run(token('games', ['ai.run.create'], { ns: null }), 'games.generate_lore', {}), 'svc:games without ns -> games.*');denied(await run(token('games', ['ai.run.create'], { ns: [] }), 'wiki.generate_page', { title: 'x', sources: [{ source_type: 'web.page' }] }), 'svc:games with ns [] -> wiki.*');-- assert.notStrictEqual((await run(token('games', ['ai.run.create'], { ns: null }), 'games.generate_lore', {})).status, 403, 'svc:games -> games.*');-- // Live (Network grants it no namespaces): live.*, network.site_copy and media.analyze by the default AI_NS_FALLBACK, nothing else.-- const live = token('live', ['ai.run.create'], { ns: null });++ const bare = token('live', ['ai.run.create'], { ns: null });++ denied(await run(bare, 'live.translate', { text: 'bonjour tout le monde', from: 'fr', to: 'en' }), 'svc:live without ns -> live.*');++ denied(await run(bare, 'network.site_copy', { sites: [], links: [] }), 'svc:live without ns -> network.site_copy');++ // Live's Network grant names its namespaces: live.*, network.site_copy and media.analyze, nothing else.++ const live = token('live', ['ai.run.create'], { ns: ['live.*', 'network.site_copy', 'media.analyze'] });assert.strictEqual((await request(h.base, 'POST', '/api/v1/runs?wait=3000', { tok: live, body: { workflow: 'live.translate', input: { text: 'bonjour tout le monde', from: 'fr', to: 'en' } } })).status, 201);assert.notStrictEqual((await run(live, 'network.site_copy', { sites: [], links: [] })).status, 403, 'svc:live -> network.site_copy');-- denied(await run(live, 'ai.generate', { prompt: 'hi' }), 'svc:live without ns -> ai.generate');-- denied(await run(live, 'network.other', {}), 'svc:live without ns -> network.other');++ denied(await run(live, 'ai.generate', { prompt: 'hi' }), 'svc:live -> ai.generate');++ denied(await run(live, 'network.other', {}), 'svc:live -> network.other');denied(await request(h.base, 'POST', '/api/v1/generate', { tok: live, body: { prompt: 'hi' } }), 'direct operations are ai.* runs');-- // App and module tokens have no fallback.const app = token('x', ['ai.run.create'], { ns: null, sub: 'app:app_01HZZZZZZZZZZZZZZZZZZZZZZZ', actorType: 'app', extra: { project_id: 'prj_01HZZZZZZZZZZZZZZZZZZZZZZZ', env: 'production' } });denied(await run(app, 'ai.generate', { prompt: 'hi' }), 'app token without ns');-- // An explicit ns claim always decides (Network's own token: network.*).const network = token('network', ['ai.run.create'], { ns: ['network.*'] });denied(await run(network, 'live.translate', { text: 'hola', from: 'es', to: 'en' }), 'svc:network ns network.* -> live.*');--});----t.test('AI_NS_FALLBACK and AI_NS_REQUIRED', () => {++ // The retired settings do nothing any more.const { load } = require('../server/config');const { namespaceAllowed } = require('../server/auth');-- const svc = (id, ns) => ({ sub: `svc:${id}`, ns });-- const d = load({}).namespaces;-- assert.deepStrictEqual(d, { required: true, derive: true, fallback: { live: ['live.*', 'network.site_copy', 'media.analyze'] } });-- assert.strictEqual(namespaceAllowed(svc('live', []), 'live.translate', d), true);-- assert.strictEqual(namespaceAllowed(svc('live', []), 'network.site_copy', d), true);-- assert.strictEqual(namespaceAllowed(svc('live', []), 'media.analyze', d), true, 'Live\'s VOD and clip overviews (WS-O task 5)');-- assert.strictEqual(namespaceAllowed(svc('live', []), 'media.other', d), false, 'that one workflow, not media.*');-- assert.strictEqual(namespaceAllowed(svc('live', []), 'ai.generate', d), false);-- assert.strictEqual(namespaceAllowed(svc('news', undefined), 'news.summarize_story', d), true);-- assert.strictEqual(namespaceAllowed({ sub: 'svc:live' }, 'wiki.generate_page', d), false);-- assert.strictEqual(namespaceAllowed(null, 'ai.generate', d), false);-- const strict = load({ AI_NS_FALLBACK: 'none' }).namespaces;-- assert.strictEqual(namespaceAllowed(svc('live', []), 'live.translate', strict), false, 'none: no fallback at all');-- assert.strictEqual(namespaceAllowed(svc('live', ['live.*']), 'live.translate', strict), true);-- const custom = load({ AI_NS_FALLBACK: 'live=live.*, news=news.summarize_story|ai.summarize' }).namespaces;-- assert.strictEqual(namespaceAllowed(svc('live', []), 'network.site_copy', custom), false);-- assert.strictEqual(namespaceAllowed(svc('news', []), 'ai.summarize', custom), true);-- assert.strictEqual(namespaceAllowed(svc('news', []), 'news.compare_perspectives', custom), false);-- assert.throws(() => load({ AI_NS_FALLBACK: 'live' }), /AI_NS_FALLBACK/);-- const open = load({ AI_NS_REQUIRED: 'false' }).namespaces;-- assert.strictEqual(namespaceAllowed(svc('games', []), 'ai.generate', open), true, 'rollback lever: the old open rule');-- assert.strictEqual(namespaceAllowed(svc('games', ['games.*']), 'ai.generate', open), false, 'an ns claim still decides');++ assert.strictEqual(load({ AI_NS_REQUIRED: 'false', AI_NS_FALLBACK: 'games=ai.*' }).namespaces, undefined);++ assert.strictEqual(namespaceAllowed({ sub: 'svc:games', ns: [] }, 'ai.generate'), false);++ assert.strictEqual(namespaceAllowed(null, 'ai.generate'), false);});t.test('provider responses never contain a secret value', async () => {@@@ -23,7 +23,7 @@ const silent = { log() {}, warn() {}, error(...a) { if (process.env.DEBUG) conso/*** Namespaces fail closed, so test tokens hold every namespace ('*') unless a test says otherwise;-- * `ns: null` leaves the claim out (what Network issues for a grant without namespaces).++ * `ns: null` leaves the claim out (such a token runs nothing: namespaces fail closed).*/function token(slug, cap, { aud = 'openvibe.ai', ns = ['*'], exp = Math.floor(Date.now() / 1000) + 300, key = privateKey, sub, actorType = 'service', extra = {} } = {}) {const now = Math.floor(Date.now() / 1000);
@@ -18,11 +18,8 @@ AI_DB_PATH=./data/ai.db
@@ -18,11 +18,8 @@ AI_DB_PATH=./data/ai.db
OV_NETWORK_URL=https://openvibe.network
OV_NETWORK_URL=https://openvibe.network
OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
#OV_NETWORK_ISSUER=https://openvibe.network
#OV_NETWORK_ISSUER=https://openvibe.network
-# Namespaces fail closed: a token with no `ns` claim may only run its fallback namespaces. A service
-# token (svc:<id>) without ns gets its entry here (service=ns|ns,...), else <id>.*; `none` = no fallback.
-#AI_NS_FALLBACK=live=live.*|network.site_copy
-# Rollback lever only: false restores the old rule (a token without ns may run every namespace).
-#AI_NS_REQUIRED=true
+# Namespaces fail closed: a token runs only the namespaces its `ns` claim (its Network grant) names; a token
+# without `ns` runs nothing. (AI_NS_FALLBACK and AI_NS_REQUIRED were retired on 2026-09-28.)
#OV_NETWORK_PUBLIC_KEY=
#OV_NETWORK_PUBLIC_KEY=
# ── Operator console (/console; README "Operator console") ──
# ── Operator console (/console; README "Operator console") ──
@@ -82,11 +82,9 @@ The capability ids and the `ai` service manifest are released in openvibe-contra
@@ -82,11 +82,9 @@ The capability ids and the `ai` service manifest are released in openvibe-contra
drafts stay in `docs/capabilities-proposal/`); `server/auth.js` checks them with the contracts grant
drafts stay in `docs/capabilities-proposal/`); `server/auth.js` checks them with the contracts grant
rule (exact id or `family.*`). A token's `ns` claim limits which
rule (exact id or `family.*`). A token's `ns` claim limits which
workflow namespaces it may run (`live.*`, `wiki.*`, …), and namespaces fail closed: a token with
workflow namespaces it may run (`live.*`, `wiki.*`, …), and namespaces fail closed: a token with
-no `ns` runs nothing outside a documented fallback. A first-party service token (`svc:<id>`) without
-`ns` gets its `AI_NS_FALLBACK` entry (default `live=live.*|network.site_copy`, because Network grants
-Live's `ai.run.create` with no namespaces), else `<id>.*`; app and module tokens without `ns` get
-nothing. `AI_NS_FALLBACK=none` removes the fallback; `AI_NS_REQUIRED=false` is a rollback lever to
-the old rule (no `ns` = every namespace).
+no `ns` runs nothing. Every caller's Network grant names its namespaces (Live: `live.*`,
+`network.site_copy`, `media.analyze`). The fallback for service tokens without `ns` (`AI_NS_FALLBACK`)
+and its rollback lever (`AI_NS_REQUIRED`) were retired on 2026-09-28 (shims C-22 and C-23).
## Operator console
## Operator console
@@ -199,8 +197,8 @@ stub joins every route as a last resort outside production only (`AI_STUB_FALLBA
@@ -199,8 +197,8 @@ stub joins every route as a last resort outside production only (`AI_STUB_FALLBA
gates only this call, never whole runs.
gates only this call, never whole runs.
`overview.source` says which (`local_model`, `paid`, `extractive`), and `gaps` lists what is missing (no local
`overview.source` says which (`local_model`, `paid`, `extractive`), and `gaps` lists what is missing (no local
- model, no speech-to-text, no budget). The output is not cached: callers keep it. Live may run it by the default
- `AI_NS_FALLBACK`.
+ model, no speech-to-text, no budget). The output is not cached: callers keep it. Live's grant names
+ `media.analyze`.
- **Direct operations** — `ai.chat|generate|summarize|classify|extract|enrich|embed`.
- **Direct operations** — `ai.chat|generate|summarize|classify|extract|enrich|embed`.
## Local model
## Local model
@@ -81,8 +81,8 @@ structured workflow, point Live's call at it, and the passthrough key stays for
@@ -81,8 +81,8 @@ structured workflow, point Live's call at it, and the passthrough key stays for
already imported and applies changed settings once. Review `import_holds`.
already imported and applies changed settings once. Review `import_holds`.
4. **Grant Live a token**: Network client `live` needs `ai.run.create` (and `ai.run.read`) for
4. **Grant Live a token**: Network client `live` needs `ai.run.create` (and `ai.run.read`) for
audience `openvibe.ai`, namespaces `live.*` and `network.site_copy` (the latter only while Live still
audience `openvibe.ai`, namespaces `live.*` and `network.site_copy` (the latter only while Live still
- answers `/internal/ai/site-copy`). Namespaces fail closed; a grant without namespaces is covered
- by the default `AI_NS_FALLBACK` (`live=live.*|network.site_copy`).
+ answers `/internal/ai/site-copy`). Namespaces fail closed: a grant without namespaces runs nothing
+ (the `AI_NS_FALLBACK` that once covered it was retired on 2026-09-28).
5. **Apply `docs/live-patch.diff`** to Live (`git apply`), deploy, then set in `/etc/openvibe/live.env`:
5. **Apply `docs/live-patch.diff`** to Live (`git apply`), deploy, then set in `/etc/openvibe/live.env`:
```
```
AI_SERVICE=remote
AI_SERVICE=remote
@@ -40,7 +40,7 @@ function runsRouter({ runs, registry, auth, config, log = console }) {
@@ -40,7 +40,7 @@ function runsRouter({ runs, registry, auth, config, log = console }) {
const has = (req) => (id) => auth.principalHas(req, id);
const has = (req) => (id) => auth.principalHas(req, id);
function checkNamespace(req, workflowKey) {
function checkNamespace(req, workflowKey) {
- if (!namespaceAllowed(req.principal, workflowKey, config.namespaces)) throw new AiError(403, 'capability.namespace_denied', `this token may not run ${workflowKey}`);
+ if (!namespaceAllowed(req.principal, workflowKey)) throw new AiError(403, 'capability.namespace_denied', `this token may not run ${workflowKey}`);
}
}
async function createAndRespond(req, res, body, waitMs) {
async function createAndRespond(req, res, body, waitMs) {
@@ -8,12 +8,11 @@
@@ -8,12 +8,11 @@
* ship, allows() decides with the contracts' own grant rule (the exact id, or a `family.*` grant);
* ship, allows() decides with the contracts' own grant rule (the exact id, or a `family.*` grant);
* once contracts knows an id, contracts decides.
* once contracts knows an id, contracts decides.
*
*
- * Namespaces fail closed: a token may only run workflows inside the namespaces it holds (the same
- * matching as contracts' namespaceAllowed: 'live.*' allows 'live.translate'). The `ns` claim decides
- * when it is non-empty. A first-party service token WITHOUT one (Network grants Live's ai.run.create
- * with no namespaces) falls back to its entry in AI_NS_FALLBACK, else to its own `<service>.*`
- * (svc:news -> news.*). App and module tokens without `ns` run nothing. AI_NS_REQUIRED=false restores
- * the old rule (no `ns` = every namespace) and exists only as a rollback lever.
+ * Namespaces fail closed: a token may only run workflows inside the namespaces its `ns` claim holds
+ * (the same matching as contracts' namespaceAllowed: 'live.*' allows 'live.translate'). A token
+ * without one runs nothing. Every caller's Network grant names its namespaces; the fallback for
+ * ns-less service tokens (AI_NS_FALLBACK) and the open-rule lever (AI_NS_REQUIRED=false), shims
+ * C-22 and C-23, were retired on 2026-09-28 once no token without `ns` was left.
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
const { serviceAuth, capabilities, http } = require('openvibe-contracts');
const { serviceAuth, capabilities, http } = require('openvibe-contracts');
@@ -90,22 +89,13 @@ function allows(claims, id) {
@@ -90,22 +89,13 @@ function allows(claims, id) {
return c;
return c;
}
}
-const DEFAULT_NAMESPACES = Object.freeze({ required: true, fallback: Object.freeze({}), derive: true });
-
-/** The namespaces a principal may run: its `ns` claim, or (service tokens only) the documented fallback. */
-function effectiveNamespaces(principal, nsConfig = DEFAULT_NAMESPACES) {
- const ns = principal && Array.isArray(principal.ns) ? principal.ns.filter(n => typeof n === 'string' && n) : [];
- if (ns.length) return ns;
- const m = /^svc:([a-z][a-z0-9-]{1,39})$/.exec(String((principal && principal.sub) || ''));
- if (!m || !nsConfig.derive) return [];
- const listed = nsConfig.fallback && Object.prototype.hasOwnProperty.call(nsConfig.fallback, m[1]) ? nsConfig.fallback[m[1]] : null;
- return listed && listed.length ? [...listed] : [`${m[1]}.*`];
+/** The namespaces a principal may run: its `ns` claim, nothing else. */
+function effectiveNamespaces(principal) {
+ return principal && Array.isArray(principal.ns) ? principal.ns.filter(n => typeof n === 'string' && n) : [];
}
}
-function namespaceAllowed(principal, workflowKey, nsConfig = DEFAULT_NAMESPACES) {
- const ns = principal && Array.isArray(principal.ns) ? principal.ns : [];
- if (!ns.length && nsConfig.required === false) return true; // AI_NS_REQUIRED=false: the old, open rule
- return capabilities.namespaceAllowed(effectiveNamespaces(principal, nsConfig), workflowKey);
+function namespaceAllowed(principal, workflowKey) {
+ return capabilities.namespaceAllowed(effectiveNamespaces(principal), workflowKey);
}
}
function bearer(req) {
function bearer(req) {
@@ -24,26 +24,6 @@ const bool = (v, d) => (v == null || v === '' ? d : /^(1|true|yes|on)$/i.test(St
@@ -24,26 +24,6 @@ const bool = (v, d) => (v == null || v === '' ? d : /^(1|true|yes|on)$/i.test(St
const list = (v, d) => (v == null || v === '' ? d : String(v).split(',').map(s => s.trim()).filter(Boolean));
const list = (v, d) => (v == null || v === '' ? d : String(v).split(',').map(s => s.trim()).filter(Boolean));
const trimUrl = (v) => String(v || '').trim().replace(/\/+$/, '');
const trimUrl = (v) => String(v || '').trim().replace(/\/+$/, '');
-/**
- * AI_NS_FALLBACK: namespaces for a service token that carries no `ns` claim, as
- * `service=ns|ns,service=ns`. A service not listed gets `<service>.*`; `none` turns the fallback off
- * (an ns-less token then runs nothing). The default covers Live, whose Network grant has no
- * namespaces: its own workflows, plus network.site_copy for its /internal/ai/site-copy fallback and
- * media.analyze for its VOD and clip overviews.
- */
-const DEFAULT_NS_FALLBACK = 'live=live.*|network.site_copy|media.analyze';
-function nsFallback(v) {
- const raw = v == null || v === '' ? DEFAULT_NS_FALLBACK : String(v).trim();
- if (/^(none|off|0|false)$/i.test(raw)) return { derive: false, fallback: {} };
- const fallback = {};
- for (const entry of raw.split(',').map(s => s.trim()).filter(Boolean)) {
- const m = /^([a-z][a-z0-9-]{1,39})\s*=\s*(.+)$/.exec(entry);
- if (!m) throw new Error(`AI_NS_FALLBACK: "${entry}" is not service=ns|ns`);
- fallback[m[1]] = m[2].split('|').map(s => s.trim()).filter(Boolean);
- }
- return { derive: true, fallback };
-}
-
/** Roles Live's llm.js routes by (one route per role; see server/workflows/seed.js). */
/** Roles Live's llm.js routes by (one route per role; see server/workflows/seed.js). */
const LIVE_ROLES = ['chat', 'vision', 'director', 'summary', 'legacy'];
const LIVE_ROLES = ['chat', 'vision', 'director', 'summary', 'legacy'];
@@ -78,9 +58,6 @@ function load(env = process.env) {
@@ -78,9 +58,6 @@ function load(env = process.env) {
credentialsKey: String(env.AI_CREDENTIALS_KEY || '').trim(),
credentialsKey: String(env.AI_CREDENTIALS_KEY || '').trim(),
networkPublicKey: env.OV_NETWORK_PUBLIC_KEY ? env.OV_NETWORK_PUBLIC_KEY.replace(/\\n/g, '\n') : null,
networkPublicKey: env.OV_NETWORK_PUBLIC_KEY ? env.OV_NETWORK_PUBLIC_KEY.replace(/\\n/g, '\n') : null,
audience: 'openvibe.ai',
audience: 'openvibe.ai',
- // Namespaces fail closed (server/auth.js): no `ns` claim, no namespaced run, except the
- // documented service fallback. AI_NS_REQUIRED=false is a rollback lever to the old open rule.
- namespaces: { required: bool(env.AI_NS_REQUIRED, true), ...nsFallback(env.AI_NS_FALLBACK) },
dbPath: env.AI_DB_PATH || './data/ai.db',
dbPath: env.AI_DB_PATH || './data/ai.db',
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
* media.analyze local-first analysis of a VOD, clip or audio file (server/workflows/media-analysis.js): FFmpeg
* media.analyze local-first analysis of a VOD, clip or audio file (server/workflows/media-analysis.js): FFmpeg
* signals, scenes, local speech-to-text, highlights with their evidence, and an overview from a local
* signals, scenes, local speech-to-text, highlights with their evidence, and an overview from a local
* model, or extractive; a paid provider only when the caller allows it and a budget covers it.
* model, or extractive; a paid provider only when the caller allows it and a budget covers it.
- * Media owns media; Live calls it for its VOD and clip overviews (AI_NS_FALLBACK default).
+ * Media owns media; Live calls it for its VOD and clip overviews (its grant names media.analyze).
*/
*/
const { MEDIA_REF } = require('./common-schemas');
const { MEDIA_REF } = require('./common-schemas');
@@ -63,53 +63,32 @@ t.test('a namespace-limited token may only run workflows in its namespaces', asy
@@ -63,53 +63,32 @@ t.test('a namespace-limited token may only run workflows in its namespaces', asy
assert.strictEqual(r.body.code, 'capability.namespace_denied');
assert.strictEqual(r.body.code, 'capability.namespace_denied');
});
});
-t.test('namespaces fail closed: a token without ns runs nothing outside its own fallback', async () => {
+t.test('namespaces fail closed: a token without ns runs nothing (C-22/C-23 retired)', async () => {
const run = (tok, workflow, input) => request(h.base, 'POST', '/api/v1/runs', { tok, body: { workflow, input } });
const run = (tok, workflow, input) => request(h.base, 'POST', '/api/v1/runs', { tok, body: { workflow, input } });
const denied = (r, what) => { assert.strictEqual(r.status, 403, what); assert.strictEqual(r.body.code, 'capability.namespace_denied', what); };
const denied = (r, what) => { assert.strictEqual(r.status, 403, what); assert.strictEqual(r.body.code, 'capability.namespace_denied', what); };
- // A service with no ns claim (or an empty one) is held to <service>.*.
- denied(await run(token('games', ['ai.run.create'], { ns: null }), 'ai.generate', { prompt: 'hi' }), 'svc:games without ns -> ai.generate');
+ // No ns claim, or an empty one: nothing, not even the service's own namespace.
+ denied(await run(token('games', ['ai.run.create'], { ns: null }), 'games.generate_lore', {}), 'svc:games without ns -> games.*');
denied(await run(token('games', ['ai.run.create'], { ns: [] }), 'wiki.generate_page', { title: 'x', sources: [{ source_type: 'web.page' }] }), 'svc:games with ns [] -> wiki.*');
denied(await run(token('games', ['ai.run.create'], { ns: [] }), 'wiki.generate_page', { title: 'x', sources: [{ source_type: 'web.page' }] }), 'svc:games with ns [] -> wiki.*');
- assert.notStrictEqual((await run(token('games', ['ai.run.create'], { ns: null }), 'games.generate_lore', {})).status, 403, 'svc:games -> games.*');
- // Live (Network grants it no namespaces): live.*, network.site_copy and media.analyze by the default AI_NS_FALLBACK, nothing else.
- const live = token('live', ['ai.run.create'], { ns: null });
+ const bare = token('live', ['ai.run.create'], { ns: null });
+ denied(await run(bare, 'live.translate', { text: 'bonjour tout le monde', from: 'fr', to: 'en' }), 'svc:live without ns -> live.*');
+ denied(await run(bare, 'network.site_copy', { sites: [], links: [] }), 'svc:live without ns -> network.site_copy');
+ // Live's Network grant names its namespaces: live.*, network.site_copy and media.analyze, nothing else.
+ const live = token('live', ['ai.run.create'], { ns: ['live.*', 'network.site_copy', 'media.analyze'] });
assert.strictEqual((await request(h.base, 'POST', '/api/v1/runs?wait=3000', { tok: live, body: { workflow: 'live.translate', input: { text: 'bonjour tout le monde', from: 'fr', to: 'en' } } })).status, 201);
assert.strictEqual((await request(h.base, 'POST', '/api/v1/runs?wait=3000', { tok: live, body: { workflow: 'live.translate', input: { text: 'bonjour tout le monde', from: 'fr', to: 'en' } } })).status, 201);
assert.notStrictEqual((await run(live, 'network.site_copy', { sites: [], links: [] })).status, 403, 'svc:live -> network.site_copy');
assert.notStrictEqual((await run(live, 'network.site_copy', { sites: [], links: [] })).status, 403, 'svc:live -> network.site_copy');
- denied(await run(live, 'ai.generate', { prompt: 'hi' }), 'svc:live without ns -> ai.generate');
- denied(await run(live, 'network.other', {}), 'svc:live without ns -> network.other');
+ denied(await run(live, 'ai.generate', { prompt: 'hi' }), 'svc:live -> ai.generate');
+ denied(await run(live, 'network.other', {}), 'svc:live -> network.other');
denied(await request(h.base, 'POST', '/api/v1/generate', { tok: live, body: { prompt: 'hi' } }), 'direct operations are ai.* runs');
denied(await request(h.base, 'POST', '/api/v1/generate', { tok: live, body: { prompt: 'hi' } }), 'direct operations are ai.* runs');
- // App and module tokens have no fallback.
const app = token('x', ['ai.run.create'], { ns: null, sub: 'app:app_01HZZZZZZZZZZZZZZZZZZZZZZZ', actorType: 'app', extra: { project_id: 'prj_01HZZZZZZZZZZZZZZZZZZZZZZZ', env: 'production' } });
const app = token('x', ['ai.run.create'], { ns: null, sub: 'app:app_01HZZZZZZZZZZZZZZZZZZZZZZZ', actorType: 'app', extra: { project_id: 'prj_01HZZZZZZZZZZZZZZZZZZZZZZZ', env: 'production' } });
denied(await run(app, 'ai.generate', { prompt: 'hi' }), 'app token without ns');
denied(await run(app, 'ai.generate', { prompt: 'hi' }), 'app token without ns');
- // An explicit ns claim always decides (Network's own token: network.*).
const network = token('network', ['ai.run.create'], { ns: ['network.*'] });
const network = token('network', ['ai.run.create'], { ns: ['network.*'] });
denied(await run(network, 'live.translate', { text: 'hola', from: 'es', to: 'en' }), 'svc:network ns network.* -> live.*');
denied(await run(network, 'live.translate', { text: 'hola', from: 'es', to: 'en' }), 'svc:network ns network.* -> live.*');
-});
-
-t.test('AI_NS_FALLBACK and AI_NS_REQUIRED', () => {
+ // The retired settings do nothing any more.
const { load } = require('../server/config');
const { load } = require('../server/config');
const { namespaceAllowed } = require('../server/auth');
const { namespaceAllowed } = require('../server/auth');
- const svc = (id, ns) => ({ sub: `svc:${id}`, ns });
- const d = load({}).namespaces;
- assert.deepStrictEqual(d, { required: true, derive: true, fallback: { live: ['live.*', 'network.site_copy', 'media.analyze'] } });
- assert.strictEqual(namespaceAllowed(svc('live', []), 'live.translate', d), true);
- assert.strictEqual(namespaceAllowed(svc('live', []), 'network.site_copy', d), true);
- assert.strictEqual(namespaceAllowed(svc('live', []), 'media.analyze', d), true, 'Live\'s VOD and clip overviews (WS-O task 5)');
- assert.strictEqual(namespaceAllowed(svc('live', []), 'media.other', d), false, 'that one workflow, not media.*');
- assert.strictEqual(namespaceAllowed(svc('live', []), 'ai.generate', d), false);
- assert.strictEqual(namespaceAllowed(svc('news', undefined), 'news.summarize_story', d), true);
- assert.strictEqual(namespaceAllowed({ sub: 'svc:live' }, 'wiki.generate_page', d), false);
- assert.strictEqual(namespaceAllowed(null, 'ai.generate', d), false);
- const strict = load({ AI_NS_FALLBACK: 'none' }).namespaces;
- assert.strictEqual(namespaceAllowed(svc('live', []), 'live.translate', strict), false, 'none: no fallback at all');
- assert.strictEqual(namespaceAllowed(svc('live', ['live.*']), 'live.translate', strict), true);
- const custom = load({ AI_NS_FALLBACK: 'live=live.*, news=news.summarize_story|ai.summarize' }).namespaces;
- assert.strictEqual(namespaceAllowed(svc('live', []), 'network.site_copy', custom), false);
- assert.strictEqual(namespaceAllowed(svc('news', []), 'ai.summarize', custom), true);
- assert.strictEqual(namespaceAllowed(svc('news', []), 'news.compare_perspectives', custom), false);
- assert.throws(() => load({ AI_NS_FALLBACK: 'live' }), /AI_NS_FALLBACK/);
- const open = load({ AI_NS_REQUIRED: 'false' }).namespaces;
- assert.strictEqual(namespaceAllowed(svc('games', []), 'ai.generate', open), true, 'rollback lever: the old open rule');
- assert.strictEqual(namespaceAllowed(svc('games', ['games.*']), 'ai.generate', open), false, 'an ns claim still decides');
+ assert.strictEqual(load({ AI_NS_REQUIRED: 'false', AI_NS_FALLBACK: 'games=ai.*' }).namespaces, undefined);
+ assert.strictEqual(namespaceAllowed({ sub: 'svc:games', ns: [] }, 'ai.generate'), false);
+ assert.strictEqual(namespaceAllowed(null, 'ai.generate'), false);
});
});
t.test('provider responses never contain a secret value', async () => {
t.test('provider responses never contain a secret value', async () => {
@@ -23,7 +23,7 @@ const silent = { log() {}, warn() {}, error(...a) { if (process.env.DEBUG) conso
@@ -23,7 +23,7 @@ const silent = { log() {}, warn() {}, error(...a) { if (process.env.DEBUG) conso
/**
/**
* Namespaces fail closed, so test tokens hold every namespace ('*') unless a test says otherwise;
* Namespaces fail closed, so test tokens hold every namespace ('*') unless a test says otherwise;
- * `ns: null` leaves the claim out (what Network issues for a grant without namespaces).
+ * `ns: null` leaves the claim out (such a token runs nothing: namespaces fail closed).
*/
*/
function token(slug, cap, { aud = 'openvibe.ai', ns = ['*'], exp = Math.floor(Date.now() / 1000) + 300, key = privateKey, sub, actorType = 'service', extra = {} } = {}) {
function token(slug, cap, { aud = 'openvibe.ai', ns = ['*'], exp = Math.floor(Date.now() / 1000) + 300, key = privateKey, sub, actorType = 'service', extra = {} } = {}) {
const now = Math.floor(Date.now() / 1000);
const now = Math.floor(Date.now() / 1000);