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.

OP OpenVibers committed 28 Sept 2026 · 5b73bb9215 · 9 files, +36 −95


Changes

ddiff --git a/.env.example b/.env.example
iindex 6262a7a..ff609db 100644
---- a/.env.example
++++ b/.env.example
@@@ -18,11 +18,8 @@ AI_DB_PATH=./data/ai.db
OV_NETWORK_URL=https://openvibe.network
OV_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") ──
ddiff --git a/README.md b/README.md
iindex cdadfe3..195aed8 100644
---- a/README.md
++++ b/README.md
@@@ -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
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
--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_FALLBA
gates 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
ddiff --git a/docs/migration.md b/docs/migration.md
iindex 08fa444..5cdd821 100644
---- a/docs/migration.md
++++ b/docs/migration.md
@@@ -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`.
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
-- 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
ddiff --git a/server/api/runs.js b/server/api/runs.js
iindex 825d6e7..fdcd6ae 100644
---- a/server/api/runs.js
++++ b/server/api/runs.js
@@@ -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) {
ddiff --git a/server/auth.js b/server/auth.js
iindex 2363d81..3f4eaf8 100644
---- a/server/auth.js
++++ b/server/auth.js
@@@ -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) {
ddiff --git a/server/config.js b/server/config.js
iindex 32c4237..27922ac 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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 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',
ddiff --git a/server/workflows/media.js b/server/workflows/media.js
iindex 4a9f0b7..dcbf02c 100644
---- a/server/workflows/media.js
++++ b/server/workflows/media.js
@@@ -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');
ddiff --git a/test/auth.test.js b/test/auth.test.js
iindex 037ade0..f0a5186 100644
---- a/test/auth.test.js
++++ b/test/auth.test.js
@@@ -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');
});
--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 () => {
ddiff --git a/test/helpers.js b/test/helpers.js
iindex 92ff475..232fbfb 100644
---- a/test/helpers.js
++++ b/test/helpers.js
@@@ -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);