openvibers/openvibe.ai Commits
AI: developer apps run the six direct operations under ai.app.run (plan T6, ADR-014) (#22)
Changes
@@@ -50,9 +50,11 @@ owns publication truth: Wiki, Blog, News, Live, … decide what to publish.Implemented here (the service manifest's `capabilities`, audience `openvibe.ai`; routes under[API](#api)): `ai.run.create`, `ai.run.read`, `ai.workflow.manage`, `ai.provider.manage`,--`ai.usage.read`, `ai.credential.manage` (`/api/v1/credentials/:subject`) and--`ai.quota.attribution.manage` (per-streamer quotas). A token's `ns` claim limits which workflow--namespaces it may run.++`ai.usage.read`, `ai.credential.manage` (`/api/v1/credentials/:subject`),++`ai.quota.attribution.manage` (per-streamer quotas) and `ai.app.run` (developer apps, ADR-014; a++public capability proposed in `docs/capabilities-proposal/` until openvibe-contracts registers it —++`server/auth.js` enforces it through its PROPOSED fallback until then). A service token's `ns`++claim limits which workflow namespaces it may run; an app token is fixed to its project's.Called elsewhere, as the service principal `ai`: `events.event.publish` (Events, the outbox relay)and `network.modules.read` / `network.modules.write` for `ai.preferences` and `ai.usage_summary`@@@ -99,6 +101,7 @@ idempotent replay):|---|---|| `POST /api/v1/runs` (`?wait=ms`, `Idempotency-Key`), `POST /runs/:id/cancel`, `POST /runs/:id/retry`, `POST /runs/:id/citations` | `ai.run.create` || `POST /api/v1/{chat,generate,summarize,classify,extract,enrich,embed}` — each a run of workflow `ai.<op>` | `ai.run.create` |++| `POST /api/v1/{chat,generate,summarize,classify,extract,embed}` (no `enrich`), `GET /runs/:id` (the app's own runs) | `ai.app.run` for a developer-app token ([Developer apps](#developer-apps)) || `GET /api/v1/runs`, `GET /runs/:id` (+ citations, request-log metadata), `GET /runs/:id/citations` | `ai.run.read` (own runs) || `GET /api/v1/workflows\|templates\|routes[/:key]` | `ai.run.create`, `ai.workflow.manage` or `ai.usage.read` || `POST /api/v1/{templates,workflows,routes}/:key/versions`, `…/versions/:v/status` | `ai.workflow.manage` |@@@ -137,6 +140,58 @@ no `ns` runs nothing. Every caller's Network grant names its namespaces (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).++## Developer apps++++Roadmap Wave 20, ADR-014. A developer app gets a token from OpenVibe.Network (`POST /oauth/token`,++`grant_type=client_credentials` with its `app_<ULID>` client, `audience=openvibe.ai`): `sub++app:app_<ULID>`, `project_id prj_<ULID>`, `env sandbox|production`, ns `[project_id,++app.<project_id>.*]`, five minutes. AI accepts one capability for it, `ai.app.run` (public;++proposed in `docs/capabilities-proposal/ai.app.run.json` until openvibe-contracts registers it —++until then `server/auth.js` enforces it through its PROPOSED fallback, as Events did before++`events.app.*` shipped):++++| Route | What |++|---|---|++| `POST /api/v1/{chat,generate,summarize,classify,extract,embed}` | the direct operations; `enrich` is first-party only |++| `GET /api/v1/runs/:id` | the app's own runs (404 for another project's) |++++Every other route refuses an app token (403 `capability.denied`), whatever its grant carries. An++app's runs are its **project's**: the run's requester is `project:prj_<ULID>` and its attribution is++the project EntityRef `{service: 'network', type: 'project', id: 'prj_<ULID>'}`, so two apps of one++project share their runs, and the project is the budget unit — the free allowance's subject is the++attribution (`network:project:prj_<ULID>`), the tier budgets ([Providers](#providers)) name the++project (`project:prj_<ULID>`), and a quota can cap it on the service scope id `project:prj_<ULID>`.++++**Billing.** Every run's `platform.usage-sample@1` readings carry the app's `project`++(`prj_<ULID>`) with subject `project:prj_<ULID>`, so Billing bills the project's use: the project's++bounded free allowance is claimed first (`free_allowance_used` on the reading), the rest is priced++under the project's tier budget (`AI_GOVERN_TIERS`, keyed `project:prj_<ULID>`). **Free and local++capacity only** (the stub, the local model, a free server or a pooled subscription — never a metered++provider) for every sandbox run, and for every app run while no tier budget governs its project++(`AI_GOVERN_TIERS` off): metered capacity is only ever spent under a project budget.++++**Workflows.** An app runs the six direct operations (the fixed `ai.*` workflows) and nothing else:++`POST /api/v1/runs`, which takes a workflow by key, stays first-party, and the workflow check refuses++anything outside `ai.*` and the app's own `app.<project_key>.*` (`project_key` = `p` + the project's++ULID in lowercase, Events' rule for `app.<project_key>.<name>` event types). An app never reads another++project's runs and never uses a person's BYO provider key: the `byok` provider profile is never++offered to an app run, and `credential` on its request is refused.++++```bash++AI=https://ai.openvibe.network++APP=app_01JAB… # the app's client id; SECRET is its client secret++TOKEN=$(curl -s -X POST https://openvibe.network/oauth/token \++ -d grant_type=client_credentials -d client_id=$APP -d client_secret=$SECRET \++ -d audience=openvibe.ai | jq -r .access_token)++curl -s -X POST "$AI/api/v1/chat" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \++ -d '{ "messages": [ { "role": "user", "content": "hello" } ] }' | jq .run.provenance++curl -s "$AI/api/v1/runs/$RUN" -H "Authorization: Bearer $TOKEN" | jq .run.status++```++++A sandbox token is issued only for an audience that opted in (Network's `DEV_SANDBOX_AUDIENCES`);++AI takes one on these routes alone, and every other route refuses `env: sandbox` with 401++`token.sandbox_refused`, as before.++## Operator console`/console` on ai.openvibe.network (roadmap WS-O task 4) is a server-rendered staff console: no@@@ -332,6 +387,7 @@ file beside it, change `--model`/`--alias` and `AI_LOCAL_LLM_MODEL`, and restart| Media fetched only from allow-listed https OpenVibe hosts; DNS answers and every redirect hop re-checked (a public hop never reaches internal Media); size caps | `test/ssrf.test.js` || The shared compiled-schema cache (caller-supplied schemas) is an LRU bounded by count and bytes, in Ajv too | `test/schemas.test.js` || Token and capability denial, namespaces, no secret values in any response, runs private to the requester | `test/auth.test.js` |++| Developer apps: `ai.app.run` on the six direct operations and `GET /runs/:id`, 403 on every other route, project-scoped runs, readings and free allowance per project, govern tiers keyed by the project, sandbox and ungoverned runs free/local capacity only, a byok profile and `credential` never selected | `test/app-access.test.js` || Operator console: SSO + PKCE, staff-only (anonymous → sign-in, non-staff 403), the staff → AI capability map, CSRF on every write, no script/secret on any page, noindex/robots, failed-run filters, audit rows from API and console changes | `test/console.test.js` || Public price/latency page: 200 without auth, rows from seeded stats and rate cards, the empty state, no caller ids, own-key providers, URLs or secrets | `test/stats-page.test.js` || The OpenAI Responses adapter (`kind: responses`) with a stubbed fetch: request shape, output and usage parsing, `text.format`, error mapping, the key never in an error | `test/provider-responses.test.js` |@@@ -346,7 +402,9 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:- **Auth.** Every API route needs a Network service token for audience `openvibe.ai`, with the route'scapability and, for runs, a namespace its `ns` claim allows. Runs are private to their requester.-- The operator console is Network staff only, signed in with SSO and PKCE, with CSRF on every write.++ A developer-app token is accepted only on `ai.app.run`'s routes ([Developer apps](#developer-apps)),++ where it acts as its project; every other route refuses it. The operator console is Network staff++ only, signed in with SSO and PKCE, with CSRF on every write.- **Secrets.** Shared provider keys are `env:NAME` references, never values; a person's own key isAES-256-GCM encrypted with `AI_CREDENTIALS_KEY`, bound to its owner and subject, and never returned(a four-character hint only). No response, log or console page carries a secret value.nnew file mode 100644@@@ -0,0 +1,32 @@++{++ "id": "ai.app.run",++ "version": "1.0",++ "owner": "ai",++ "status": "active",++ "visibility": "public",++ "description": "Run the AI operations as a developer app (ADR-014): POST /api/v1/chat, /generate, /summarize, /classify, /extract, /embed and GET /api/v1/runs/:id for the app's own runs. The app token (sub app:app_<ULID>, project_id prj_<ULID>, env sandbox|production, audience openvibe.ai) runs as its project: every run is metered (platform.usage-sample@1 to OpenVibe.Billing, subject project:prj_<ULID>) against the project's bounded free allowance, and metered capacity is spent only under the project's tier budget; a sandbox run, and every run no tier budget governs, uses only free and local capacity. An app runs only these operations (never a workflow by key), never reads another project's runs, and never uses a person's BYO provider key.",++ "permissions": [++ "ai:run"++ ],++ "resourceConstraints": [++ "namespace",++ "project",++ "owner"++ ],++ "quotaClass": "ai-run",++ "events": [++ "ai.run.queued",++ "ai.run.succeeded",++ "ai.run.failed",++ "ai.run.cached"++ ],++ "implementedBy": [++ "POST /api/v1/chat",++ "POST /api/v1/generate",++ "POST /api/v1/summarize",++ "POST /api/v1/classify",++ "POST /api/v1/extract",++ "POST /api/v1/embed",++ "GET /api/v1/runs/:id"++ ]++}@@@ -20,6 +20,7 @@"note": "called by other services at 127.0.0.1:4700; ai.openvibe.network serves the OpenVibe.Sites placeholder page"},"capabilities": [++ "ai.app.run","ai.credential.manage","ai.provider.manage","ai.quota.attribution.manage",@@@ -14,25 +14,49 @@* POST /api/v1/{chat,generate,summarize,classify,extract,enrich,embed}* direct operations: a run of workflow ai.<op>, waited for*++ * Developer apps (ADR-014, ai.app.run): POST /api/v1/{chat,generate,summarize,classify,extract,embed}++ * and GET /api/v1/runs/:id also take an app token; the run is the app's project's (see auth.appOrCap,++ * server/apps.js). Every other route here is first-party only.++ ** Responses: 201 when a new run finished (or was served from cache) within the wait, 202 while it* is still queued/running (poll GET /runs/:id), 200 for an idempotent replay. Errors are* problem+json; a quota refusal or a full run queue (queue.full) is 429 with Retry-After.*/const express = require('express');const { AiError, sendError } = require('../util');--const { CAPS, namespaceAllowed } = require('../auth');++const { CAPS, namespaceAllowed, appOrCap } = require('../auth');++const { appMayRun, appAttribution, DIRECT_OPS } = require('../apps');function runsRouter({ runs, registry, auth, config, log = console }) {const r = express.Router();const create = auth.requireCap(CAPS.runCreate);const read = auth.requireCap(CAPS.runRead, CAPS.runCreate, CAPS.usageRead);++ // The routes a developer app may use: the six direct operations and GET /runs/:id.++ const createApp = auth.appOrCap(CAPS.appRun, CAPS.runCreate);++ const readApp = auth.appOrCap(CAPS.appRun, CAPS.runRead, CAPS.runCreate, CAPS.usageRead);const has = (req) => (id) => auth.principalHas(req, id);function checkNamespace(req, workflowKey) {++ if (req.principal.kind === 'app') {++ if (!appMayRun(req.principal, workflowKey)) throw new AiError(403, 'capability.namespace_denied', `an app may only run its own app.${req.principal.projectKey}.* namespace, not ${workflowKey}`);++ return;++ }if (!namespaceAllowed(req.principal, workflowKey)) throw new AiError(403, 'capability.namespace_denied', `this token may not run ${workflowKey}`);}++ /**++ * An app's run is its project's: the attribution is the project, and an on_behalf_of SubjectRef is++ * refused. A person's own provider key is refused here (the direct operations read it off the raw++ * body) and by the pool for every app run (server/providers), so ai.app.run never uses a BYO key.++ */++ function appBody(principal, body, raw) {++ if (raw && raw.credential != null) throw new AiError(403, 'capability.denied', "an app run never uses a person's own provider key");++ if (body.on_behalf_of != null) throw new AiError(403, 'capability.denied', 'an app run is attributed to its project, never to a person');++ return { ...body, attribution: appAttribution(principal) };++ }++async function createAndRespond(req, res, body, waitMs) {++ if (req.principal.kind === 'app') body = appBody(req.principal, body, req.body);checkNamespace(req, String(body.workflow || ''));const idem = body.idempotency_key || req.ov.idempotencyKey || undefined;const created = await runs.create({ ...body, idempotency_key: idem }, req.principal, { trace: req.ov.traceId, requestId: req.ov.requestId });@@@ -63,7 +87,7 @@ function runsRouter({ runs, registry, auth, config, log = console }) {return run;}-- r.get('/api/v1/runs/:id', read, async (req, res) => {++ r.get('/api/v1/runs/:id', readApp, async (req, res) => {try {const run = await runs.explained(await owned(req));res.json({ run, citations: await runs.citations(run.id), requests: await runs.requestsFor(run.id) });@@@ -105,8 +129,10 @@ function runsRouter({ runs, registry, auth, config, log = console }) {});// Direct operations — each is a run of workflow ai.<op>, waited for (default: the max wait).++ // The six ai.app.run names (chat, generate, summarize, classify, extract, embed) take an app token;++ // enrich is first-party only, like every other run route.for (const op of ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed']) {-- r.post(`/api/v1/${op}`, create, async (req, res) => {++ r.post(`/api/v1/${op}`, DIRECT_OPS.includes(`ai.${op}`) ? createApp : create, async (req, res) => {try {const body = req.body || {};const { idempotency_key: idem, target, attribution, on_behalf_of: obo, options, ...input } = body;nnew file mode 100644@@@ -0,0 +1,72 @@++'use strict';++/**++ * Developer apps (ADR-014; capability ai.app.run, docs/capabilities-proposal/).++ *++ * An app token from OpenVibe.Network has sub `app:app_<ULID>`, actor_type `app`, `project_id`++ * `prj_<ULID>`, `env` sandbox|production and ns [project_id, app.<project_id>.*] (OpenVibe.Network++ * server/developer/tokens.js). Here it becomes an app principal whose subject is its PROJECT:++ *++ * project_key 'p' + the project's ULID in lowercase prj_01JAB… -> p01jab…++ * attribution { service: 'network', type: 'project', id: 'prj_…' } the project the spend belongs to++ * namespaces app.<project_key>.* the only workflow namespace the app may run++ *++ * project_key follows OpenVibe.Events' developer-app rule (p + the lowercased ULID), so an app's AI++ * workflows are `app.<project_key>.*` beside its event types `app.<project_key>.<name>`.++ *++ * The direct operations ai.app.run names (ai.chat | ai.generate | ai.summarize | ai.classify |++ * ai.extract | ai.embed) are fixed workflows, not namespace lookups: they are the operations the++ * capability grants by route. Everything else an app runs must be inside its own namespace.++ */++const capabilities = require('openvibe-contracts').capabilities;++++const ULID = '[0-9A-HJKMNP-TV-Z]{26}';++const APP_SUB_RE = new RegExp(`^app:app_(${ULID})$`);++const PROJECT_RE = new RegExp(`^prj_(${ULID})$`);++const ENVS = ['sandbox', 'production'];++/** The operations ai.app.run grants by route, as workflow keys (POST /api/v1/<op> is a run of ai.<op>). */++const DIRECT_OPS = Object.freeze(['chat', 'generate', 'summarize', 'classify', 'extract', 'embed'].map((op) => `ai.${op}`));++++/** 'prj_01JAB…' -> 'p01jab…' (null for anything that is not a project id). */++function projectKey(projectId) {++ const m = PROJECT_RE.exec(String(projectId || ''));++ return m ? `p${m[1].toLowerCase()}` : null;++}++++/** The workflow namespaces an app may run: its own, nothing else. */++function namespacesOf(key) { return [`app.${key}.*`]; }++++/** The EntityRef an app's runs are attributed to: its project (Network's entity). */++function appAttribution(principal) { return { service: 'network', type: 'project', id: principal.projectId }; }++++/**++ * The app principal for verified token claims, or { error } when the claims are not a usable app++ * token (no project, unknown env, project outside the token's namespaces).++ */++function appPrincipal(claims) {++ const m = APP_SUB_RE.exec(String(claims && claims.sub));++ if (!m) return { error: 'not an app token' };++ if (claims.actor_type && claims.actor_type !== 'app') return { error: 'actor_type must be app' };++ if (!PROJECT_RE.test(String(claims.project_id || ''))) return { error: 'app token without a project_id' };++ const env = claims.env === undefined ? 'production' : claims.env;++ if (!ENVS.includes(env)) return { error: `unknown env ${claims.env}` };++ if (Array.isArray(claims.ns) && claims.ns.length && !claims.ns.includes(claims.project_id)) {++ return { error: 'project_id is not in the token namespaces' };++ }++ return {++ sub: claims.sub,++ appId: `app_${m[1]}`,++ projectId: claims.project_id,++ projectKey: projectKey(claims.project_id),++ env,++ onBehalfOf: typeof claims.on_behalf_of === 'string' ? claims.on_behalf_of : null,++ };++}++++/** May this app principal run `workflowKey`? Only the direct operations and its own app.<project_key>.*. */++function appMayRun(principal, workflowKey) {++ const key = String(workflowKey || '');++ if (DIRECT_OPS.includes(key)) return true;++ return capabilities.namespaceAllowed(namespacesOf(principal.projectKey), key);++}++++module.exports = { projectKey, appPrincipal, appAttribution, appMayRun, namespacesOf, DIRECT_OPS, ENVS };@@@ -7,20 +7,27 @@* exponential backoff, and a rotation honoured on an unknown kid. OV_NETWORK_PUBLIC_KEY still pins a* PEM, which skips the fetch entirely.*-- * Capabilities ai.run.create / ai.run.read / ai.workflow.manage / ai.provider.manage / ai.usage.read-- * are proposed in docs/capabilities-proposal/ and are not in openvibe-contracts yet; ai.credential.manage-- * and ai.quota.attribution.manage ship in contracts (0.73.0 / 0.75.0). Until the five do, allows()-- * decides with the contracts' own grant rule (the exact id, or a `family.*` grant); once contracts-- * knows an id, contracts decides.++ * Capability ids are decided by openvibe-contracts when it knows them (the release named below);++ * until then allows() decides with the contracts' own grant rule (the exact id, or a `family.*`++ * grant), but only for the ids PROPOSED names — a proposal this service enforces ahead of the++ * release that registers it (docs/capabilities-proposal/, the pattern OpenVibe.Events used before++ * events.app.* shipped). ai.app.run (T6 step 10: developer apps, ADR-014) needs exactly that++ * fallback until openvibe-contracts knows the id.*-- * 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++ * Namespaces fail closed: a service 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.++ *++ * Developer apps (ADR-014): an app token (sub app:app_<ULID>, project_id, env sandbox|production)++ * is refused on every first-party route whatever ids it was granted, and judged only on ai.app.run++ * on the routes that take it (appOrCap below). Only there is a sandbox app token accepted; the++ * routes that do not take one refuse env=sandbox with token.sandbox_refused, as before.*/const { serviceAuth, capabilities, http } = require('openvibe-contracts');const { jwksClient, jwksStatus } = require('openvibe-sdk/auth');++const apps = require('./apps');const CAPS = Object.freeze({runCreate: 'ai.run.create',@@@ -30,8 +37,12 @@ const CAPS = Object.freeze({usageRead: 'ai.usage.read',credentialManage: 'ai.credential.manage',quotaAttributionManage: 'ai.quota.attribution.manage',++ appRun: 'ai.app.run',});++/** Ids enforced here before the pinned openvibe-contracts release knows them (docs/capabilities-proposal/). */++const PROPOSED = Object.freeze(['ai.app.run']);++/*** The Network signing keys, through the SDK's process-wide JWKS client (one per URL). A pinned* OV_NETWORK_PUBLIC_KEY PEM skips the fetch entirely; /api/ready reports either as ready.@@@ -53,7 +64,12 @@ function hasCap(claims, id) {function allows(claims, id) {if (!hasCap(claims, id)) return { allowed: false, code: 'capability.denied', reason: `${id} not granted` };-- if (!capabilities.get(id)) return { allowed: true, code: null, reason: null }; // not in contracts yet: local rule decided++ if (!capabilities.get(id)) {++ // Not in the pinned contracts release yet: only a PROPOSED id is enforced (by the same exact-id-or-family++ // grant rule hasCap applied); any other unknown id is refused, never silently allowed.++ if (!PROPOSED.includes(id)) return { allowed: false, code: 'capability.denied', reason: `${id} is not a capability this release knows` };++ return { allowed: true, code: null, reason: null };++ }const c = capabilities.check(claims, id);return c;}@@@ -72,6 +88,11 @@ function bearer(req) {return h.startsWith('Bearer ') ? h.slice(7).trim() : null;}++/** Is this token an app principal? (actor_type app and the app:app_<ULID> subject are both required.) */++function isAppClaims(claims) {++ return Boolean(claims) && claims.actor_type === 'app' && /^app:app_/.test(String(claims.sub));++}++/** svc:live -> { type: 'service', id: 'live' }; app:/mod: principals keep their type. */function principalSubject(sub) {const m = /^(svc|app|mod):(.+)$/.exec(String(sub || ''));@@@ -87,10 +108,11 @@ function createAuth({ config, log = console }) {/*** A Network service/app token: the key from the SDK's JWKS client (or the pinned PEM), every rule-- * from openvibe-contracts' verifyServiceToken (identity.service-token-claims@1, env: sandbox refused).++ * from openvibe-contracts' verifyServiceToken (identity.service-token-claims@1). env: sandbox is++ * refused unless the caller opted in (only the routes that take ai.app.run do).*/-- async function verify(token) {-- const check = (publicKey) => serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.issuer, audience: config.audience });++ async function verify(token, { acceptSandbox = false } = {}) {++ const check = (publicKey) => serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.issuer, audience: config.audience, acceptSandbox });if (config.networkPublicKey) return check(config.networkPublicKey);let keys;try { keys = await jwksClient(config.networkJwksUrl, { log }).keysForKid(headerKid(token)); } catch (err) {@@@ -109,7 +131,8 @@ function createAuth({ config, log = console }) {}/**-- * Express guard. `anyOf` lists capability ids; one granted is enough.++ * Express guard for first-party service routes. `anyOf` lists capability ids; one granted is++ * enough. An app token is refused whatever ids it was granted (only appOrCap takes one).* Sets req.principal = { sub, subject: {type,id}, cap, ns, jti }.*/function requireCap(...anyOf) {@@@ -120,6 +143,7 @@ function createAuth({ config, log = console }) {if (!token) return http.sendProblem(res, 401, 'token.missing', { detail: 'a service token (audience openvibe.ai) is required', ctx });const r = await verify(token);if (!r.ok) return http.sendProblem(res, r.code === 'token.unavailable' ? 503 : 401, r.code, { detail: r.reason, ctx });++ if (isAppClaims(r.claims)) return http.sendProblem(res, 403, 'capability.denied', { detail: 'app tokens are not accepted on this route', ctx });let decision = null;for (const id of anyOf) {decision = allows(r.claims, id);@@@ -136,10 +160,44 @@ function createAuth({ config, log = console }) {};}++ /**++ * A guard for the routes a developer app may use (ADR-014; ai.app.run). A first-party service++ * token is judged on anyOf exactly as requireCap; an app token (actor_type app) is judged only on++ * appCap, and becomes req.principal whose SUBJECT IS ITS PROJECT, so every run, quota, attribution++ * and free allowance of the app is the project's. A sandbox app token is accepted here and++ * nowhere else. Sets req.principal.kind = 'app' with appId, projectId, projectKey, env and the++ * app's namespaces.++ */++ function appOrCap(appCap, ...anyOf) {++ const serviceGuard = requireCap(...anyOf);++ return async function appOrCapGuard(req, res, next) {++ const ctx = req.ov;++ const token = bearer(req);++ if (!token) return serviceGuard(req, res, next); // token.missing, with requireCap's wording++ try {++ const r = await verify(token, { acceptSandbox: true });++ if (!r.ok || !isAppClaims(r.claims)) return await serviceGuard(req, res, next);++ const app = apps.appPrincipal(r.claims);++ if (app.error) return http.sendProblem(res, 401, 'token.invalid_claims', { detail: app.error, ctx });++ const decision = allows(r.claims, appCap);++ if (!decision.allowed) return http.sendProblem(res, 403, decision.code, { detail: decision.reason, ctx });++ req.principal = {++ kind: 'app', sub: r.claims.sub, subject: { type: 'project', id: app.projectId },++ appId: app.appId, projectId: app.projectId, projectKey: app.projectKey, env: app.env, onBehalfOf: app.onBehalfOf,++ cap: r.claims.cap, ns: apps.namespacesOf(app.projectKey), jti: r.claims.jti, claims: r.claims,++ };++ return next();++ } catch (err) {++ log.error(`[auth] ${req.method} ${req.path}: ${(err && err.stack) || err}`);++ if (!res.headersSent) http.sendProblem(res, 500, 'internal.error', { detail: 'internal error', ctx });++ }++ };++ }++/** Does the authenticated principal also hold `id`? (for owner-or-admin checks) */function principalHas(req, id) { return Boolean(req.principal && allows(req.principal.claims, id).allowed); }-- return { verify, requireCap, principalHas };++ return { verify, requireCap, appOrCap, principalHas };}--module.exports = { CAPS, createNetworkKeys, createAuth, hasCap, allows, namespaceAllowed, effectiveNamespaces, principalSubject, bearer };++module.exports = { CAPS, PROPOSED, createNetworkKeys, createAuth, hasCap, allows, isAppClaims, namespaceAllowed, effectiveNamespaces, principalSubject, bearer };@@@ -12,7 +12,8 @@const RUN_STATUSES = ['queued', 'running', 'succeeded', 'failed', 'cancelled', 'cached'];const KEY_RE = /^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)*$/;--const REQUESTER_RE = /^(service|app|mod|user):[A-Za-z0-9_.:-]{1,120}$/;++// project: a developer app's runs (ADR-014) are their project's, so that is how they are filtered.++const REQUESTER_RE = /^(service|app|mod|user|project):[A-Za-z0-9_.:-]{1,120}$/;const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;const CODE_RE = /^[a-z][a-z0-9_.-]{1,80}$/;const RUN_ID_RE = /^run_[0-9A-HJKMNP-TV-Z]{26}$/;@@@ -159,6 +159,18 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.nofunction isFreeProvider(p) {return p.kind === 'stub' || isLocalProvider(p) || Boolean(p.metadata && p.metadata.paid === false) || isPrepaidProvider(p);}++ /**++ * May a developer app's run (ADR-014) use this provider? Never a person's byok key, and a free-only run++ * (ctx.freeOnly: a sandbox run, or any app run no tier budget governs) is limited to free and local++ * capacity: the stub, the local model, a free server or a pooled subscription. A provider that fails++ * this is not offered to the planner at all.++ */++ function appOffered(ctx, p) {++ if (!p) return true; // an unknown provider keeps its place; skipReason reports it, as before++ if (ctx.appRun && billingProfile(p) === 'byok') return false;++ if (ctx.freeOnly && !isFreeProvider(p)) return false;++ return true;++ }const offerId = (provider, model) => (model ? `${provider}:${model}` : provider);const splitOfferId = (id) => {const i = String(id).indexOf(':');@@@ -182,7 +194,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no* health (placement_state error rate + the breaker); hysteresis through the route's current placement.* The stub is never a placement candidate (synthetic output must not win on price) — it is appended last.*/-- async function poolCandidates(route, features, operation) {++ async function poolCandidates(route, features, operation, ctx = {}) {const stats = await statsByProvider();const offers = [];const rateCards = [];@@@ -217,6 +229,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noconst scope = route.constraints && route.constraints.provider_scope;for (const p of await registry.listProviders()) {if (p.key === 'stub' || !p.capabilities.some(f => features.includes(f))) continue;++ if (!appOffered(ctx, p)) continue;if (scope === 'local' && !isLocalProvider(p)) continue;if (scope === 'paid' && isFreeProvider(p)) continue;if (operation === 'embed' || operation === 'transcribe') {@@@ -235,6 +248,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noif (!pin || !pin.provider || pin.provider === 'stub') continue;const p = await registry.getProvider(pin.provider);if (!p) continue;++ if (!appOffered(ctx, p)) continue;if (scope === 'local' && !isLocalProvider(p)) continue;if (scope === 'paid' && isFreeProvider(p)) continue;const caps = route.capability && !p.capabilities.includes(route.capability) ? [...p.capabilities, route.capability] : p.capabilities;@@@ -284,23 +298,33 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noreturn { order: order.map(splitOfferId), explain, placement: result, rateCards, states };}-- /** A pinned route: [primary, ...fallbacks], exactly as before. */-- async function pinnedCandidates(route, features) {++ /**++ * A pinned route: [primary, ...fallbacks], exactly as before. A provider an app run may not use++ * (a byok key, or anything metered for a free-only run) leaves the order with its++ * reason in explain.candidates; every other candidate keeps its place, as before.++ */++ async function pinnedCandidates(route, features, ctx = {}) {const list = [route.primary, ...(route.fallbacks || [])].filter(c => c && c.provider);const explain = { objective: null, reasons: ['pinned route: the primary, then its fallbacks'], selected: null, candidates: [] };++ const order = [];for (const c of list) {const entry = await adapter(c.provider);const p = entry && entry.record;const why = await skipReason(p, entry && entry.adapter, features);-- explain.candidates.push({ provider: c.provider, model: c.model || (p && p.default_model) || null, eligible: !why, excluded_reason: why, cost: null, latency: null });++ const offered = appOffered(ctx, p);++ explain.candidates.push({++ provider: c.provider, model: c.model || (p && p.default_model) || null, eligible: offered && !why,++ excluded_reason: offered ? why : (p && billingProfile(p) === 'byok' ? 'byok' : 'free_only'), cost: null, latency: null,++ });++ if (offered) order.push({ provider: c.provider, model: c.model || null });}-- return { order: list.map(c => ({ provider: c.provider, model: c.model || null })), explain };++ return { order, explain };}-- async function candidates(route, features, operation) {++ async function candidates(route, features, operation, ctx = {}) {const { order, explain, placement: result = null, ...rest } = route.capability && operation-- ? await poolCandidates(route, features, operation)-- : await pinnedCandidates(route, features);++ ? await poolCandidates(route, features, operation, ctx)++ : await pinnedCandidates(route, features, ctx);if (config.stubFallback && !(route.constraints && route.constraints.provider_scope)&& !order.some(c => c.provider === 'stub') && await registry.getProvider('stub')) {order.push({ provider: 'stub', model: null });@@@ -371,7 +395,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noif (req.image) features.push('vision');if (req.json) features.push('json');const tried = [];-- const { order: list, explain } = await candidates(route, features, operation);++ const { order: list, explain } = await candidates(route, features, operation, ctx);for (let i = 0; i < list.length; i++) {const c = list[i];const entry = await adapter(c.provider);@@@ -207,7 +207,7 @@ function createQuotas(db, { clock = { now: () => Date.now() }, registry, freeAllcost: Math.max(0, (Number(f.cost) || 0) - (Number(f.usd) || 0)), freeAllowanceUsed: Number(f.tokens) || 0 });}}-- await usageSamples.record(db, { runId, workflowKey: ctx.workflowKey, requester: `${ctx.requesterType}:${ctx.requesterId}`, at, traceId, readings });++ await usageSamples.record(db, { runId, workflowKey: ctx.workflowKey, requester: `${ctx.requesterType}:${ctx.requesterId}`, project: ctx.projectId || null, at, traceId, readings });}await recordStats(day, attempts);});@@@ -154,6 +154,9 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowif (!v.valid) throw new AiError(422, 'input.invalid', `input does not match ${wf.key} v${wf.version}`, { errors: v.errors });const inputHash = sha256(inputJson);const requester = principal.subject;++ // A developer app (ADR-014): the requester IS its project, the run is metered with the project's++ // id on every Billing reading, and a sandbox run may use free/local capacity only (server/providers).++ const app = principal.kind === 'app' ? { projectId: principal.projectId, sandbox: principal.env === 'sandbox' } : null;const idem = body.idempotency_key != null ? String(body.idempotency_key) : null;if (idem !== null && !/^[A-Za-z0-9._:-]{8,128}$/.test(idem)) throw new AiError(422, 'input.invalid', 'idempotency_key must be 8-128 of [A-Za-z0-9._:-]');@@@ -180,6 +183,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowconst ctx = {requesterType: requester.type, requesterId: requester.id,actorKey: subjectKey(body.on_behalf_of), targetKey: entityKey(body.target), attributionKey: entityKey(body.attribution), workflowKey: wf.key,++ ...(app ? { appRun: true, projectId: app.projectId, sandbox: app.sandbox } : {}),};const now = iso(clock.now());const id = newRunId();@@@ -216,7 +220,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now// The reused output carries the grounding it was produced with.await db.prepare('UPDATE runs SET grounding = (SELECT grounding FROM runs WHERE id = ?) WHERE id = ?').run(hit.run_id, id);// A cache hit is still a served run: its zero reading (no attempt, no tokens, no cost) goes to Billing with it.-- await usageSamples.record(db, { runId: id, workflowKey: wf.key, requester: `${requester.type}:${requester.id}`, at: now, traceId: trace,++ await usageSamples.record(db, { runId: id, workflowKey: wf.key, requester: `${requester.type}:${requester.id}`, project: ctx.projectId || null, at: now, traceId: trace,readings: [{ attempt: 0, kind: 'in', model: hit.model_key || null, quantity: 0, cost: 0, freeAllowanceUsed: 0 }] });await events.runChanged(await getRow(id));});@@@ -310,6 +314,10 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now}const credential = opts.credential_subject ? await credentials.forRun(row.requester_id, opts.credential_subject) : null;const r = await engine.execute(wf, row, input, { signal: controller.signal, logRequest, debugRaw: Boolean(config.debugRawLog && opts.debug), inputHash: row.input_hash, preferences,++ // A developer app's run (ADR-014): the provider pool excludes a person's byok key, and the run is++ // limited to free and local capacity when it is a sandbox run or when no tier budget governs its++ // project (AI_GOVERN_TIERS off): metered capacity is only ever spent under a project budget.++ appRun: Boolean(ctx.appRun), freeOnly: Boolean(ctx.appRun && (ctx.sandbox || !(tiers && tiers.enabled))),...(credential ? { credential, role: input && typeof input.role === 'string' ? input.role : null } : {}) });if (controller.signal.aborted) throw new AiError(409, 'run.cancelled', 'cancelled');// The status, its citations and the ai.run.succeeded event commit together.@@@ -14,7 +14,8 @@* The relay (openvibe-sdk createPgOutbox) posts each row with AI's service token. If Billing is down, has no* grant yet, or is not deployed, the row waits and is retried with backoff, across restarts. Only Billing* refusing the reading itself (400, 409, 413, 422) marks a row rejected. A reading carries ids, the workflow,-- * the requester, tokens and cost. It never carries the input, the output or a prompt. Readings are queued once++ * the requester, tokens and cost — and, for a developer app's run (ADR-014), the `project` (prj_<ULID>) its++ * spend is billed to. It never carries the input, the output or a prompt. Readings are queued once* init() ran, unless USAGE_SAMPLES=off. The relay runs while OV_BILLING_INTERNAL_URL and OV_OAUTH_CLIENT_SECRET* are set. Runs on a person's own key, and runs that called no provider and were not a cache hit, are not* accounted and have no reading. free_allowance_used (§2.1.8) is the share the provider free allowance covered@@@ -37,11 +38,13 @@ let relay = null; let on = false; let pruneTimer = null; let lastError = null; lconst keyOf = (runId, attempt, kind) => `ai:${runId}:${attempt}:${kind}`;const meter = createMeter();/** One reading: an attempt's tokens of one kind, under its rate-card metric and the metric's leading-word unit. */--function sampleOf({ runId, attempt, kind, workflowKey, requester, provider = null, model = null, quantity = 0, cost = 0, freeAllowanceUsed = 0, at, traceId = null }) {++function sampleOf({ runId, attempt, kind, workflowKey, requester, project = null, provider = null, model = null, quantity = 0, cost = 0, freeAllowanceUsed = 0, at, traceId = null }) {const key = keyOf(runId, attempt, kind);const s = { id: key, idempotency_key: key, service: 'ai', subject: requester, resource: rates.metricFor(kind, model || null), operation: workflowKey,quantity: Math.max(0, Number(quantity) || 0), unit: UNIT[kind], at: new Date(at).toISOString(),cost_estimate: Math.max(0, Number(cost) || 0), source: 'ai.runs' };++ // A developer app's run (ADR-014): the reading names the project Billing bills it to.++ if (project) s.project = project;if (provider && provider !== 'mixed') s.provider = provider;// §2.1.8: the tokens the provider free allowance covered of this reading's quantity (server/free-allowance.js); absent at 0.const free = Math.min(s.quantity, Math.max(0, Math.floor(Number(freeAllowanceUsed) || 0)));@@@ -58,12 +61,13 @@ function sampleFromUsage(e, fields) {* per-kind descriptors (quota.account builds them from the free allowance's byKind): { attempt, kind,* provider, model, quantity, cost, freeAllowanceUsed }. Writes them all, links their keys on the run, and* returns the keys. A second call for the same keys changes nothing (ON CONFLICT DO NOTHING).++ * `project` (prj_<ULID>) names the developer app's project on every reading (ADR-014); null otherwise.*/--async function record(db, { runId, workflowKey, requester, at, traceId = null, readings = [] }) {++async function record(db, { runId, workflowKey, requester, project = null, at, traceId = null, readings = [] }) {if (!on) return [];const ids = [];for (const r of readings) {-- const fields = { runId, attempt: r.attempt, kind: r.kind, workflowKey, requester, provider: r.provider, model: r.model, quantity: r.quantity, cost: r.cost, freeAllowanceUsed: r.freeAllowanceUsed, at, traceId };++ const fields = { runId, attempt: r.attempt, kind: r.kind, workflowKey, requester, project, provider: r.provider, model: r.model, quantity: r.quantity, cost: r.cost, freeAllowanceUsed: r.freeAllowanceUsed, at, traceId };const key = keyOf(runId, r.attempt, r.kind);const e = await meter({ subject: requester, quantity: Math.max(0, Number(r.quantity) || 0), key });const s = e ? sampleFromUsage(e, fields) : sampleOf(fields);nnew file mode 100644@@@ -0,0 +1,210 @@++'use strict';++// T6 step 10: developer-app access (ADR-014; capability ai.app.run, docs/capabilities-proposal/).++// An app token (sub app:app_<ULID>, project_id prj_<ULID>, env, audience openvibe.ai, ns as Network++// issues it) holding ai.app.run may use the six direct operations and GET /api/v1/runs/:id; every++// other route refuses it (403). Its runs are its PROJECT's: requester and attribution are the++// project, the free allowance is per project, the govern tier map is keyed by the project, every++// Billing reading carries the project, a sandbox or ungoverned run sees free/local capacity only, and a person's++// BYO key is never selected.++const assert = require('assert');++const contracts = require('openvibe-contracts');++const { boot, request, token, suite, seamServer, ALL } = require('./helpers');++const usageSamples = require('../server/usage-samples');++const apps = require('../server/apps');++++const t = suite('app-access');++const ULID_A = '01JAB2C3D4E5F6G7H8J9K0MNPQ';++const ULID_B = '01JAB2C3D4E5F6G7H8J9K0MNPR';++const PRJ_A = `prj_${ULID_A}`;++const PRJ_B = `prj_${ULID_B}`;++const KEY_A = apps.projectKey(PRJ_A);++const service = token('live', ALL);++++/** A Network-shaped app token: sub app:app_<ULID>, actor_type app, project_id, env, ns (Network's own shape). */++function appToken(projectId, cap = ['ai.app.run'], { env = 'production' } = {}) {++ return token('app', cap, { sub: `app:app_${projectId.slice(4)}`, actorType: 'app', ns: [projectId, `app.${projectId}.*`], extra: { project_id: projectId, env } });++}++const appA = appToken(PRJ_A);++const appB = appToken(PRJ_B);++const sandboxA = appToken(PRJ_A, ['ai.app.run'], { env: 'sandbox' });++++const CAPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'embed'];++/** The three provers one pinned default.chat route holds: a byok pool (first), a paid meter, a free server. */++async function seedProviders(h, seam) {++ const mk = (body) => request(h.base, 'POST', '/api/v1/providers', { tok: service, body: { kind: 'http', base_url: seam.url, auth_mode: 'none', capabilities: CAPS, ...body } });++ assert.strictEqual((await mk({ key: 'byokpool', auth_mode: 'bearer', secret_ref: 'env:BYOK_TEST_KEY', billing_profile: 'byok', default_model: 'byok-m' })).status, 201);++ assert.strictEqual((await mk({ key: 'paid', default_model: 'paid-m' })).status, 201);++ assert.strictEqual((await mk({ key: 'freecap', billing_profile: 'free', default_model: 'freecap-m' })).status, 201);++ // The paid model's 100-token-a-day free allowance, so a project's free use is observable.++ assert.strictEqual((await request(h.base, 'POST', '/api/v1/models', { tok: service, body: {++ provider_key: 'paid', model_key: 'paid-m', type: 'chat', cost: { in_per_mtok: 1000, out_per_mtok: 1000, free_allowance: 100, reset_period: 'day' } } })).status, 201);++ assert.strictEqual((await request(h.base, 'POST', '/api/v1/routes/default.chat/versions', { tok: service, body: {++ primary: { provider: 'byokpool', model: 'byok-m' }, fallbacks: [{ provider: 'paid', model: 'paid-m' }, { provider: 'freecap', model: 'freecap-m' }] } })).status, 201);++}++++let h; let seam;++t.test('boot with the seam providers', async () => {++ seam = await seamServer(() => ({ text: 'ok', usage: { input: 1000, output: 1000 } }));++ // The tier budgets govern these projects (an open free tier), so a production app run may reach the paid meter.++ h = await boot({ env: { AI_STUB_FALLBACK: 'false', BYOK_TEST_KEY: 'sk-test-byok-value', AI_GOVERN_TIERS: '1',++ AI_GOVERN_POLICY_JSON: JSON.stringify({ 'ai-token': { free: {} }, 'ai-usd': { free: {} } }) } });++ await seedProviders(h, seam);++});++++let chatRun; let genRun;++t.test('an app token with ai.app.run chats, generates and reads its own run', async () => {++ const chat = await request(h.base, 'POST', '/api/v1/chat', { tok: appA, body: { messages: [{ role: 'user', content: 'hello' }] } });++ assert.strictEqual(chat.status, 201, chat.text);++ chatRun = chat.body.run;++ assert.strictEqual(chatRun.provenance.provider, 'paid', 'the byok pool is never an app run\'s provider');++ assert.ok(chatRun.explain && Array.isArray(chatRun.explain.candidates), 'explain stays on every run response');++++ const gen = await request(h.base, 'POST', '/api/v1/generate', { tok: appA, body: { prompt: 'hello there', options: { cache: false } } });++ assert.strictEqual(gen.status, 201, gen.text);++ genRun = gen.body.run;++ assert.strictEqual(genRun.provenance.provider, 'paid');++++ const got = await request(h.base, 'GET', `/api/v1/runs/${genRun.id}`, { tok: appA });++ assert.strictEqual(got.status, 200, got.text);++ assert.strictEqual(got.body.run.id, genRun.id);++ assert.deepStrictEqual([got.body.run.requester.type, got.body.run.requester.id], ['project', PRJ_A], 'the run is the project\'s');++ assert.deepStrictEqual(got.body.run.attribution, { service: 'network', type: 'project', id: PRJ_A }, 'attributed to the project');++});++++t.test('another project never reads an app run (404)', async () => {++ assert.strictEqual((await request(h.base, 'GET', `/api/v1/runs/${genRun.id}`, { tok: appB })).status, 404);++});++++t.test('an app token without ai.app.run is refused on the app routes', async () => {++ const noCap = appToken(PRJ_A, ['ai.run.read']);++ const post = await request(h.base, 'POST', '/api/v1/generate', { tok: noCap, body: { prompt: 'x' } });++ assert.deepStrictEqual([post.status, post.body.code], [403, 'capability.denied']);++ const get = await request(h.base, 'GET', `/api/v1/runs/${genRun.id}`, { tok: noCap });++ assert.deepStrictEqual([get.status, get.body.code], [403, 'capability.denied']);++});++++t.test('every other route is first-party only: an app token gets 403', async () => {++ const routes = [++ ['POST', '/api/v1/runs', { workflow: 'ai.generate', input: { prompt: 'x' } }],++ ['GET', '/api/v1/runs', undefined],++ ['GET', `/api/v1/runs/${genRun.id}/citations`, undefined],++ ['POST', `/api/v1/runs/${genRun.id}/cancel`, undefined],++ ['POST', '/api/v1/enrich', { record: {}, instructions: 'x' }],++ ['GET', '/api/v1/providers', undefined],++ ];++ for (const [method, path, body] of routes) {++ const r = await request(h.base, method, path, { tok: appA, body });++ assert.strictEqual(r.status, 403, `${method} ${path}: ${r.text}`);++ assert.strictEqual(r.body.code, 'capability.denied', `${method} ${path}`);++ }++ // A sandbox app token is accepted on the app routes only: the door refuses it here, as before.++ const sb = await request(h.base, 'POST', '/api/v1/runs', { tok: sandboxA, body: { workflow: 'ai.generate', input: { prompt: 'x' } } });++ assert.deepStrictEqual([sb.status, sb.body.code], [401, 'token.sandbox_refused']);++});++++t.test('an app may only run its own app.<project_key>.* namespace', async () => {++ const principal = { projectKey: KEY_A };++ assert.strictEqual(KEY_A, `p${ULID_A.toLowerCase()}`, 'project_key follows Events\' p+lowercase-ULID rule');++ assert.ok(apps.appMayRun(principal, `app.${KEY_A}.draft`), 'its own namespace');++ assert.ok(apps.appMayRun(principal, `app.${KEY_A}`), 'the namespace itself');++ assert.ok(apps.appMayRun(principal, 'ai.chat'), 'the direct operations ai.app.run grants by route');++ assert.ok(!apps.appMayRun(principal, 'live.translate'));++ assert.ok(!apps.appMayRun(principal, 'network.site_copy'));++ assert.ok(!apps.appMayRun(principal, `app.${apps.projectKey(PRJ_B)}.draft`), "another project's namespace");++ // The route that would take an arbitrary workflow (POST /api/v1/runs) is first-party only.++ const r = await request(h.base, 'POST', '/api/v1/runs', { tok: appA, body: { workflow: 'live.translate', input: { text: 'bonjour', from: 'fr', to: 'en' } } });++ assert.strictEqual(r.status, 403);++});++++t.test("a person's BYO key is never selected for an app run", async () => {++ // The byok pool record resolves and is the pinned route's primary: a service run uses it.++ const svc = await request(h.base, 'POST', '/api/v1/generate', { tok: service, body: { prompt: 'service work', options: { cache: false } } });++ assert.strictEqual(svc.status, 201, svc.text);++ assert.strictEqual(svc.body.run.provenance.provider, 'byokpool', 'a service run may use the pooled key');++ // An app may not send one, and never lands on the byok pool.++ const denied = await request(h.base, 'POST', '/api/v1/generate', { tok: appA, body: { prompt: 'x', credential: { subject: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' } } });++ assert.deepStrictEqual([denied.status, denied.body.code], [403, 'capability.denied']);++ for (const run of [chatRun.id, genRun.id]) {++ const providers = (await h.db.prepare('SELECT DISTINCT provider_key FROM requests WHERE run_id = ?').all(run)).map((r) => r.provider_key);++ assert.deepStrictEqual(providers, ['paid'], `${run}: only the paid provider, never byokpool`);++ }++});++++let sandboxRun;++t.test('a sandbox app run uses free and local capacity only', async () => {++ const r = await request(h.base, 'POST', '/api/v1/generate', { tok: sandboxA, body: { prompt: 'sandbox work', options: { cache: false } } });++ assert.strictEqual(r.status, 201, r.text);++ sandboxRun = r.body.run;++ assert.strictEqual(sandboxRun.provenance.provider, 'freecap', 'the paid provider and the byok pool were never candidates');++ const providers = (await h.db.prepare('SELECT DISTINCT provider_key FROM requests WHERE run_id = ?').all(sandboxRun.id)).map((x) => x.provider_key);++ assert.deepStrictEqual(providers, ['freecap']);++ const why = Object.fromEntries(sandboxRun.explain.candidates.map((c) => [c.provider, c.excluded_reason]));++ assert.strictEqual(why.paid, 'free_only');++ assert.strictEqual(why.byokpool, 'byok');++});++++const readings = async (runId) => (await h.db.prepare('SELECT envelope FROM usage_sample_outbox ORDER BY id').all())++ .map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope))++ .filter((s) => s.idempotency_key.startsWith(`ai:${runId}:`));++const freeRows = async (projectId) => (await h.db.prepare('SELECT metric, free_used FROM free_allowance_usage WHERE subject = ? ORDER BY metric').all(`network:project:${projectId}`))++ .map((r) => [r.metric, Number(r.free_used)]);++++t.test('every reading carries the project; the free allowance is per project', async () => {++ for (const runId of [chatRun.id, genRun.id, sandboxRun.id]) {++ const samples = await readings(runId);++ assert.ok(samples.length >= 3, `${runId}: in, cached and out readings`);++ for (const s of samples) {++ assert.ok(contracts.validate('platform.usage-sample@1', s).valid, JSON.stringify(s));++ assert.strictEqual(s.project, PRJ_A, `${s.idempotency_key}: the project`);++ assert.strictEqual(s.subject, `project:${PRJ_A}`, `${s.idempotency_key}: billed as the project`);++ }++ }++ // Project A's first paid run claimed the paid model's 100-token free allowance, per kind; nothing after it did.++ assert.deepStrictEqual(await freeRows(PRJ_A), [['input-tokens:paid-m', 100], ['output-tokens:paid-m', 100]]);++ // Project B has its own allowance: its first run claims 100 again.++ const b = await request(h.base, 'POST', '/api/v1/generate', { tok: appB, body: { prompt: 'project b work', options: { cache: false } } });++ assert.strictEqual(b.status, 201, b.text);++ assert.deepStrictEqual(await freeRows(PRJ_B), [['input-tokens:paid-m', 100], ['output-tokens:paid-m', 100]]);++ assert.deepStrictEqual(await freeRows(PRJ_A), [['input-tokens:paid-m', 100], ['output-tokens:paid-m', 100]], 'never shared');++});++++// The govern tier budgets are looked up per PROJECT (server/govern.js project = requester): mapping++// project:<prj_…> to staff admits only that project's app runs; the free tier's 0 budget refuses the rest.++let h2; let seam2;++t.test('the govern tier map is keyed by the app\'s project', async () => {++ seam2 = await seamServer(() => ({ text: 'ok', usage: { input: 10, output: 10 } }));++ h2 = await boot({ env: {++ AI_STUB_FALLBACK: 'false', AI_GOVERN_TIERS: '1', AI_GOVERN_HOLD_TOKENS: '1',++ AI_GOVERN_POLICY_JSON: JSON.stringify({ 'ai-token': { free: { day: 0 }, staff: {} }, 'ai-usd': { free: { day: 0.5 }, staff: {} } }),++ AI_GOVERN_TIER_MAP: `project:${PRJ_A}=staff`,++ } });++ await seedProviders(h2, seam2);++ const a = await request(h2.base, 'POST', '/api/v1/generate', { tok: appA, body: { prompt: 'staff project', options: { cache: false } } });++ assert.strictEqual(a.status, 201, `project A is on staff: ${a.text}`);++ const b = await request(h2.base, 'POST', '/api/v1/generate', { tok: appB, body: { prompt: 'free project', options: { cache: false } } });++ assert.deepStrictEqual([b.status, b.body.code], [429, 'govern.exceeded'], 'project B is on the free tier with a 0 budget');++});++++// Without tier budgets (AI_GOVERN_TIERS off) nothing bounds a project's metered spend, so every app run,++// production too, is limited to free and local capacity; a first-party run keeps the whole pool.++let h3; let seam3;++t.test('an ungoverned app run uses free and local capacity only', async () => {++ seam3 = await seamServer(() => ({ text: 'ok', usage: { input: 10, output: 10 } }));++ h3 = await boot({ env: { AI_STUB_FALLBACK: 'false', BYOK_TEST_KEY: 'sk-test-byok-value' } });++ await seedProviders(h3, seam3);++ const r = await request(h3.base, 'POST', '/api/v1/generate', { tok: appA, body: { prompt: 'ungoverned', options: { cache: false } } });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.body.run.provenance.provider, 'freecap');++ const why = Object.fromEntries(r.body.run.explain.candidates.map((c) => [c.provider, c.excluded_reason]));++ assert.deepStrictEqual([why.byokpool, why.paid], ['byok', 'free_only']);++ const svc = await request(h3.base, 'POST', '/api/v1/generate', { tok: service, body: { prompt: 'service work', options: { cache: false } } });++ assert.strictEqual(svc.body.run.provenance.provider, 'byokpool', 'a first-party run is unaffected');++});++++t.test('shutdown', async () => {++ usageSamples._reset(); await h.stop(); await seam.close();++ await h2.stop(); await seam2.close();++ await h3.stop(); await seam3.close();++});++++t.run();@@@ -79,8 +79,12 @@ t.test('namespaces fail closed: a token without ns runs nothing (C-22/C-23 retirdenied(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');++ // A developer app token is refused on every first-party route whatever ids it carries: POST /api/v1/runs++ // is not one of ai.app.run's routes (the app routes are tested in app-access.test.js).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');++ const appRuns = await run(app, 'ai.generate', { prompt: 'hi' });++ assert.strictEqual(appRuns.status, 403, 'app token on POST /api/v1/runs');++ assert.strictEqual(appRuns.body.code, 'capability.denied', 'app tokens are never first-party callers');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.*');// The retired settings do nothing any more.
@@ -50,9 +50,11 @@ owns publication truth: Wiki, Blog, News, Live, … decide what to publish.
@@ -50,9 +50,11 @@ owns publication truth: Wiki, Blog, News, Live, … decide what to publish.
Implemented here (the service manifest's `capabilities`, audience `openvibe.ai`; routes under
Implemented here (the service manifest's `capabilities`, audience `openvibe.ai`; routes under
[API](#api)): `ai.run.create`, `ai.run.read`, `ai.workflow.manage`, `ai.provider.manage`,
[API](#api)): `ai.run.create`, `ai.run.read`, `ai.workflow.manage`, `ai.provider.manage`,
-`ai.usage.read`, `ai.credential.manage` (`/api/v1/credentials/:subject`) and
-`ai.quota.attribution.manage` (per-streamer quotas). A token's `ns` claim limits which workflow
-namespaces it may run.
+`ai.usage.read`, `ai.credential.manage` (`/api/v1/credentials/:subject`),
+`ai.quota.attribution.manage` (per-streamer quotas) and `ai.app.run` (developer apps, ADR-014; a
+public capability proposed in `docs/capabilities-proposal/` until openvibe-contracts registers it —
+`server/auth.js` enforces it through its PROPOSED fallback until then). A service token's `ns`
+claim limits which workflow namespaces it may run; an app token is fixed to its project's.
Called elsewhere, as the service principal `ai`: `events.event.publish` (Events, the outbox relay)
Called elsewhere, as the service principal `ai`: `events.event.publish` (Events, the outbox relay)
and `network.modules.read` / `network.modules.write` for `ai.preferences` and `ai.usage_summary`
and `network.modules.read` / `network.modules.write` for `ai.preferences` and `ai.usage_summary`
@@ -99,6 +101,7 @@ idempotent replay):
@@ -99,6 +101,7 @@ idempotent replay):
|---|---|
|---|---|
| `POST /api/v1/runs` (`?wait=ms`, `Idempotency-Key`), `POST /runs/:id/cancel`, `POST /runs/:id/retry`, `POST /runs/:id/citations` | `ai.run.create` |
| `POST /api/v1/runs` (`?wait=ms`, `Idempotency-Key`), `POST /runs/:id/cancel`, `POST /runs/:id/retry`, `POST /runs/:id/citations` | `ai.run.create` |
| `POST /api/v1/{chat,generate,summarize,classify,extract,enrich,embed}` — each a run of workflow `ai.<op>` | `ai.run.create` |
| `POST /api/v1/{chat,generate,summarize,classify,extract,enrich,embed}` — each a run of workflow `ai.<op>` | `ai.run.create` |
+| `POST /api/v1/{chat,generate,summarize,classify,extract,embed}` (no `enrich`), `GET /runs/:id` (the app's own runs) | `ai.app.run` for a developer-app token ([Developer apps](#developer-apps)) |
| `GET /api/v1/runs`, `GET /runs/:id` (+ citations, request-log metadata), `GET /runs/:id/citations` | `ai.run.read` (own runs) |
| `GET /api/v1/runs`, `GET /runs/:id` (+ citations, request-log metadata), `GET /runs/:id/citations` | `ai.run.read` (own runs) |
| `GET /api/v1/workflows\|templates\|routes[/:key]` | `ai.run.create`, `ai.workflow.manage` or `ai.usage.read` |
| `GET /api/v1/workflows\|templates\|routes[/:key]` | `ai.run.create`, `ai.workflow.manage` or `ai.usage.read` |
| `POST /api/v1/{templates,workflows,routes}/:key/versions`, `…/versions/:v/status` | `ai.workflow.manage` |
| `POST /api/v1/{templates,workflows,routes}/:key/versions`, `…/versions/:v/status` | `ai.workflow.manage` |
@@ -137,6 +140,58 @@ no `ns` runs nothing. Every caller's Network grant names its namespaces (Live: `
@@ -137,6 +140,58 @@ no `ns` runs nothing. Every caller's Network grant names its namespaces (Live: `
`network.site_copy`, `media.analyze`). The fallback for service tokens without `ns` (`AI_NS_FALLBACK`)
`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).
and its rollback lever (`AI_NS_REQUIRED`) were retired on 2026-09-28 (shims C-22 and C-23).
+## Developer apps
+
+Roadmap Wave 20, ADR-014. A developer app gets a token from OpenVibe.Network (`POST /oauth/token`,
+`grant_type=client_credentials` with its `app_<ULID>` client, `audience=openvibe.ai`): `sub
+app:app_<ULID>`, `project_id prj_<ULID>`, `env sandbox|production`, ns `[project_id,
+app.<project_id>.*]`, five minutes. AI accepts one capability for it, `ai.app.run` (public;
+proposed in `docs/capabilities-proposal/ai.app.run.json` until openvibe-contracts registers it —
+until then `server/auth.js` enforces it through its PROPOSED fallback, as Events did before
+`events.app.*` shipped):
+
+| Route | What |
+|---|---|
+| `POST /api/v1/{chat,generate,summarize,classify,extract,embed}` | the direct operations; `enrich` is first-party only |
+| `GET /api/v1/runs/:id` | the app's own runs (404 for another project's) |
+
+Every other route refuses an app token (403 `capability.denied`), whatever its grant carries. An
+app's runs are its **project's**: the run's requester is `project:prj_<ULID>` and its attribution is
+the project EntityRef `{service: 'network', type: 'project', id: 'prj_<ULID>'}`, so two apps of one
+project share their runs, and the project is the budget unit — the free allowance's subject is the
+attribution (`network:project:prj_<ULID>`), the tier budgets ([Providers](#providers)) name the
+project (`project:prj_<ULID>`), and a quota can cap it on the service scope id `project:prj_<ULID>`.
+
+**Billing.** Every run's `platform.usage-sample@1` readings carry the app's `project`
+(`prj_<ULID>`) with subject `project:prj_<ULID>`, so Billing bills the project's use: the project's
+bounded free allowance is claimed first (`free_allowance_used` on the reading), the rest is priced
+under the project's tier budget (`AI_GOVERN_TIERS`, keyed `project:prj_<ULID>`). **Free and local
+capacity only** (the stub, the local model, a free server or a pooled subscription — never a metered
+provider) for every sandbox run, and for every app run while no tier budget governs its project
+(`AI_GOVERN_TIERS` off): metered capacity is only ever spent under a project budget.
+
+**Workflows.** An app runs the six direct operations (the fixed `ai.*` workflows) and nothing else:
+`POST /api/v1/runs`, which takes a workflow by key, stays first-party, and the workflow check refuses
+anything outside `ai.*` and the app's own `app.<project_key>.*` (`project_key` = `p` + the project's
+ULID in lowercase, Events' rule for `app.<project_key>.<name>` event types). An app never reads another
+project's runs and never uses a person's BYO provider key: the `byok` provider profile is never
+offered to an app run, and `credential` on its request is refused.
+
+```bash
+AI=https://ai.openvibe.network
+APP=app_01JAB… # the app's client id; SECRET is its client secret
+TOKEN=$(curl -s -X POST https://openvibe.network/oauth/token \
+ -d grant_type=client_credentials -d client_id=$APP -d client_secret=$SECRET \
+ -d audience=openvibe.ai | jq -r .access_token)
+curl -s -X POST "$AI/api/v1/chat" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
+ -d '{ "messages": [ { "role": "user", "content": "hello" } ] }' | jq .run.provenance
+curl -s "$AI/api/v1/runs/$RUN" -H "Authorization: Bearer $TOKEN" | jq .run.status
+```
+
+A sandbox token is issued only for an audience that opted in (Network's `DEV_SANDBOX_AUDIENCES`);
+AI takes one on these routes alone, and every other route refuses `env: sandbox` with 401
+`token.sandbox_refused`, as before.
+
## Operator console
## Operator console
`/console` on ai.openvibe.network (roadmap WS-O task 4) is a server-rendered staff console: no
`/console` on ai.openvibe.network (roadmap WS-O task 4) is a server-rendered staff console: no
@@ -332,6 +387,7 @@ file beside it, change `--model`/`--alias` and `AI_LOCAL_LLM_MODEL`, and restart
@@ -332,6 +387,7 @@ file beside it, change `--model`/`--alias` and `AI_LOCAL_LLM_MODEL`, and restart
| Media fetched only from allow-listed https OpenVibe hosts; DNS answers and every redirect hop re-checked (a public hop never reaches internal Media); size caps | `test/ssrf.test.js` |
| Media fetched only from allow-listed https OpenVibe hosts; DNS answers and every redirect hop re-checked (a public hop never reaches internal Media); size caps | `test/ssrf.test.js` |
| The shared compiled-schema cache (caller-supplied schemas) is an LRU bounded by count and bytes, in Ajv too | `test/schemas.test.js` |
| The shared compiled-schema cache (caller-supplied schemas) is an LRU bounded by count and bytes, in Ajv too | `test/schemas.test.js` |
| Token and capability denial, namespaces, no secret values in any response, runs private to the requester | `test/auth.test.js` |
| Token and capability denial, namespaces, no secret values in any response, runs private to the requester | `test/auth.test.js` |
+| Developer apps: `ai.app.run` on the six direct operations and `GET /runs/:id`, 403 on every other route, project-scoped runs, readings and free allowance per project, govern tiers keyed by the project, sandbox and ungoverned runs free/local capacity only, a byok profile and `credential` never selected | `test/app-access.test.js` |
| Operator console: SSO + PKCE, staff-only (anonymous → sign-in, non-staff 403), the staff → AI capability map, CSRF on every write, no script/secret on any page, noindex/robots, failed-run filters, audit rows from API and console changes | `test/console.test.js` |
| Operator console: SSO + PKCE, staff-only (anonymous → sign-in, non-staff 403), the staff → AI capability map, CSRF on every write, no script/secret on any page, noindex/robots, failed-run filters, audit rows from API and console changes | `test/console.test.js` |
| Public price/latency page: 200 without auth, rows from seeded stats and rate cards, the empty state, no caller ids, own-key providers, URLs or secrets | `test/stats-page.test.js` |
| Public price/latency page: 200 without auth, rows from seeded stats and rate cards, the empty state, no caller ids, own-key providers, URLs or secrets | `test/stats-page.test.js` |
| The OpenAI Responses adapter (`kind: responses`) with a stubbed fetch: request shape, output and usage parsing, `text.format`, error mapping, the key never in an error | `test/provider-responses.test.js` |
| The OpenAI Responses adapter (`kind: responses`) with a stubbed fetch: request shape, output and usage parsing, `text.format`, error mapping, the key never in an error | `test/provider-responses.test.js` |
@@ -346,7 +402,9 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:
@@ -346,7 +402,9 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:
- **Auth.** Every API route needs a Network service token for audience `openvibe.ai`, with the route's
- **Auth.** Every API route needs a Network service token for audience `openvibe.ai`, with the route's
capability and, for runs, a namespace its `ns` claim allows. Runs are private to their requester.
capability and, for runs, a namespace its `ns` claim allows. Runs are private to their requester.
- The operator console is Network staff only, signed in with SSO and PKCE, with CSRF on every write.
+ A developer-app token is accepted only on `ai.app.run`'s routes ([Developer apps](#developer-apps)),
+ where it acts as its project; every other route refuses it. The operator console is Network staff
+ only, signed in with SSO and PKCE, with CSRF on every write.
- **Secrets.** Shared provider keys are `env:NAME` references, never values; a person's own key is
- **Secrets.** Shared provider keys are `env:NAME` references, never values; a person's own key is
AES-256-GCM encrypted with `AI_CREDENTIALS_KEY`, bound to its owner and subject, and never returned
AES-256-GCM encrypted with `AI_CREDENTIALS_KEY`, bound to its owner and subject, and never returned
(a four-character hint only). No response, log or console page carries a secret value.
(a four-character hint only). No response, log or console page carries a secret value.
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+{
+ "id": "ai.app.run",
+ "version": "1.0",
+ "owner": "ai",
+ "status": "active",
+ "visibility": "public",
+ "description": "Run the AI operations as a developer app (ADR-014): POST /api/v1/chat, /generate, /summarize, /classify, /extract, /embed and GET /api/v1/runs/:id for the app's own runs. The app token (sub app:app_<ULID>, project_id prj_<ULID>, env sandbox|production, audience openvibe.ai) runs as its project: every run is metered (platform.usage-sample@1 to OpenVibe.Billing, subject project:prj_<ULID>) against the project's bounded free allowance, and metered capacity is spent only under the project's tier budget; a sandbox run, and every run no tier budget governs, uses only free and local capacity. An app runs only these operations (never a workflow by key), never reads another project's runs, and never uses a person's BYO provider key.",
+ "permissions": [
+ "ai:run"
+ ],
+ "resourceConstraints": [
+ "namespace",
+ "project",
+ "owner"
+ ],
+ "quotaClass": "ai-run",
+ "events": [
+ "ai.run.queued",
+ "ai.run.succeeded",
+ "ai.run.failed",
+ "ai.run.cached"
+ ],
+ "implementedBy": [
+ "POST /api/v1/chat",
+ "POST /api/v1/generate",
+ "POST /api/v1/summarize",
+ "POST /api/v1/classify",
+ "POST /api/v1/extract",
+ "POST /api/v1/embed",
+ "GET /api/v1/runs/:id"
+ ]
+}
@@ -20,6 +20,7 @@
@@ -20,6 +20,7 @@
"note": "called by other services at 127.0.0.1:4700; ai.openvibe.network serves the OpenVibe.Sites placeholder page"
"note": "called by other services at 127.0.0.1:4700; ai.openvibe.network serves the OpenVibe.Sites placeholder page"
},
},
"capabilities": [
"capabilities": [
+ "ai.app.run",
"ai.credential.manage",
"ai.credential.manage",
"ai.provider.manage",
"ai.provider.manage",
"ai.quota.attribution.manage",
"ai.quota.attribution.manage",
@@ -14,25 +14,49 @@
@@ -14,25 +14,49 @@
* POST /api/v1/{chat,generate,summarize,classify,extract,enrich,embed}
* POST /api/v1/{chat,generate,summarize,classify,extract,enrich,embed}
* direct operations: a run of workflow ai.<op>, waited for
* direct operations: a run of workflow ai.<op>, waited for
*
*
+ * Developer apps (ADR-014, ai.app.run): POST /api/v1/{chat,generate,summarize,classify,extract,embed}
+ * and GET /api/v1/runs/:id also take an app token; the run is the app's project's (see auth.appOrCap,
+ * server/apps.js). Every other route here is first-party only.
+ *
* Responses: 201 when a new run finished (or was served from cache) within the wait, 202 while it
* Responses: 201 when a new run finished (or was served from cache) within the wait, 202 while it
* is still queued/running (poll GET /runs/:id), 200 for an idempotent replay. Errors are
* is still queued/running (poll GET /runs/:id), 200 for an idempotent replay. Errors are
* problem+json; a quota refusal or a full run queue (queue.full) is 429 with Retry-After.
* problem+json; a quota refusal or a full run queue (queue.full) is 429 with Retry-After.
*/
*/
const express = require('express');
const express = require('express');
const { AiError, sendError } = require('../util');
const { AiError, sendError } = require('../util');
-const { CAPS, namespaceAllowed } = require('../auth');
+const { CAPS, namespaceAllowed, appOrCap } = require('../auth');
+const { appMayRun, appAttribution, DIRECT_OPS } = require('../apps');
function runsRouter({ runs, registry, auth, config, log = console }) {
function runsRouter({ runs, registry, auth, config, log = console }) {
const r = express.Router();
const r = express.Router();
const create = auth.requireCap(CAPS.runCreate);
const create = auth.requireCap(CAPS.runCreate);
const read = auth.requireCap(CAPS.runRead, CAPS.runCreate, CAPS.usageRead);
const read = auth.requireCap(CAPS.runRead, CAPS.runCreate, CAPS.usageRead);
+ // The routes a developer app may use: the six direct operations and GET /runs/:id.
+ const createApp = auth.appOrCap(CAPS.appRun, CAPS.runCreate);
+ const readApp = auth.appOrCap(CAPS.appRun, CAPS.runRead, CAPS.runCreate, CAPS.usageRead);
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 (req.principal.kind === 'app') {
+ if (!appMayRun(req.principal, workflowKey)) throw new AiError(403, 'capability.namespace_denied', `an app may only run its own app.${req.principal.projectKey}.* namespace, not ${workflowKey}`);
+ return;
+ }
if (!namespaceAllowed(req.principal, workflowKey)) 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}`);
}
}
+ /**
+ * An app's run is its project's: the attribution is the project, and an on_behalf_of SubjectRef is
+ * refused. A person's own provider key is refused here (the direct operations read it off the raw
+ * body) and by the pool for every app run (server/providers), so ai.app.run never uses a BYO key.
+ */
+ function appBody(principal, body, raw) {
+ if (raw && raw.credential != null) throw new AiError(403, 'capability.denied', "an app run never uses a person's own provider key");
+ if (body.on_behalf_of != null) throw new AiError(403, 'capability.denied', 'an app run is attributed to its project, never to a person');
+ return { ...body, attribution: appAttribution(principal) };
+ }
+
async function createAndRespond(req, res, body, waitMs) {
async function createAndRespond(req, res, body, waitMs) {
+ if (req.principal.kind === 'app') body = appBody(req.principal, body, req.body);
checkNamespace(req, String(body.workflow || ''));
checkNamespace(req, String(body.workflow || ''));
const idem = body.idempotency_key || req.ov.idempotencyKey || undefined;
const idem = body.idempotency_key || req.ov.idempotencyKey || undefined;
const created = await runs.create({ ...body, idempotency_key: idem }, req.principal, { trace: req.ov.traceId, requestId: req.ov.requestId });
const created = await runs.create({ ...body, idempotency_key: idem }, req.principal, { trace: req.ov.traceId, requestId: req.ov.requestId });
@@ -63,7 +87,7 @@ function runsRouter({ runs, registry, auth, config, log = console }) {
@@ -63,7 +87,7 @@ function runsRouter({ runs, registry, auth, config, log = console }) {
return run;
return run;
}
}
- r.get('/api/v1/runs/:id', read, async (req, res) => {
+ r.get('/api/v1/runs/:id', readApp, async (req, res) => {
try {
try {
const run = await runs.explained(await owned(req));
const run = await runs.explained(await owned(req));
res.json({ run, citations: await runs.citations(run.id), requests: await runs.requestsFor(run.id) });
res.json({ run, citations: await runs.citations(run.id), requests: await runs.requestsFor(run.id) });
@@ -105,8 +129,10 @@ function runsRouter({ runs, registry, auth, config, log = console }) {
@@ -105,8 +129,10 @@ function runsRouter({ runs, registry, auth, config, log = console }) {
});
});
// Direct operations — each is a run of workflow ai.<op>, waited for (default: the max wait).
// Direct operations — each is a run of workflow ai.<op>, waited for (default: the max wait).
+ // The six ai.app.run names (chat, generate, summarize, classify, extract, embed) take an app token;
+ // enrich is first-party only, like every other run route.
for (const op of ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed']) {
for (const op of ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed']) {
- r.post(`/api/v1/${op}`, create, async (req, res) => {
+ r.post(`/api/v1/${op}`, DIRECT_OPS.includes(`ai.${op}`) ? createApp : create, async (req, res) => {
try {
try {
const body = req.body || {};
const body = req.body || {};
const { idempotency_key: idem, target, attribution, on_behalf_of: obo, options, ...input } = body;
const { idempotency_key: idem, target, attribution, on_behalf_of: obo, options, ...input } = body;
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
+'use strict';
+/**
+ * Developer apps (ADR-014; capability ai.app.run, docs/capabilities-proposal/).
+ *
+ * An app token from OpenVibe.Network has sub `app:app_<ULID>`, actor_type `app`, `project_id`
+ * `prj_<ULID>`, `env` sandbox|production and ns [project_id, app.<project_id>.*] (OpenVibe.Network
+ * server/developer/tokens.js). Here it becomes an app principal whose subject is its PROJECT:
+ *
+ * project_key 'p' + the project's ULID in lowercase prj_01JAB… -> p01jab…
+ * attribution { service: 'network', type: 'project', id: 'prj_…' } the project the spend belongs to
+ * namespaces app.<project_key>.* the only workflow namespace the app may run
+ *
+ * project_key follows OpenVibe.Events' developer-app rule (p + the lowercased ULID), so an app's AI
+ * workflows are `app.<project_key>.*` beside its event types `app.<project_key>.<name>`.
+ *
+ * The direct operations ai.app.run names (ai.chat | ai.generate | ai.summarize | ai.classify |
+ * ai.extract | ai.embed) are fixed workflows, not namespace lookups: they are the operations the
+ * capability grants by route. Everything else an app runs must be inside its own namespace.
+ */
+const capabilities = require('openvibe-contracts').capabilities;
+
+const ULID = '[0-9A-HJKMNP-TV-Z]{26}';
+const APP_SUB_RE = new RegExp(`^app:app_(${ULID})$`);
+const PROJECT_RE = new RegExp(`^prj_(${ULID})$`);
+const ENVS = ['sandbox', 'production'];
+/** The operations ai.app.run grants by route, as workflow keys (POST /api/v1/<op> is a run of ai.<op>). */
+const DIRECT_OPS = Object.freeze(['chat', 'generate', 'summarize', 'classify', 'extract', 'embed'].map((op) => `ai.${op}`));
+
+/** 'prj_01JAB…' -> 'p01jab…' (null for anything that is not a project id). */
+function projectKey(projectId) {
+ const m = PROJECT_RE.exec(String(projectId || ''));
+ return m ? `p${m[1].toLowerCase()}` : null;
+}
+
+/** The workflow namespaces an app may run: its own, nothing else. */
+function namespacesOf(key) { return [`app.${key}.*`]; }
+
+/** The EntityRef an app's runs are attributed to: its project (Network's entity). */
+function appAttribution(principal) { return { service: 'network', type: 'project', id: principal.projectId }; }
+
+/**
+ * The app principal for verified token claims, or { error } when the claims are not a usable app
+ * token (no project, unknown env, project outside the token's namespaces).
+ */
+function appPrincipal(claims) {
+ const m = APP_SUB_RE.exec(String(claims && claims.sub));
+ if (!m) return { error: 'not an app token' };
+ if (claims.actor_type && claims.actor_type !== 'app') return { error: 'actor_type must be app' };
+ if (!PROJECT_RE.test(String(claims.project_id || ''))) return { error: 'app token without a project_id' };
+ const env = claims.env === undefined ? 'production' : claims.env;
+ if (!ENVS.includes(env)) return { error: `unknown env ${claims.env}` };
+ if (Array.isArray(claims.ns) && claims.ns.length && !claims.ns.includes(claims.project_id)) {
+ return { error: 'project_id is not in the token namespaces' };
+ }
+ return {
+ sub: claims.sub,
+ appId: `app_${m[1]}`,
+ projectId: claims.project_id,
+ projectKey: projectKey(claims.project_id),
+ env,
+ onBehalfOf: typeof claims.on_behalf_of === 'string' ? claims.on_behalf_of : null,
+ };
+}
+
+/** May this app principal run `workflowKey`? Only the direct operations and its own app.<project_key>.*. */
+function appMayRun(principal, workflowKey) {
+ const key = String(workflowKey || '');
+ if (DIRECT_OPS.includes(key)) return true;
+ return capabilities.namespaceAllowed(namespacesOf(principal.projectKey), key);
+}
+
+module.exports = { projectKey, appPrincipal, appAttribution, appMayRun, namespacesOf, DIRECT_OPS, ENVS };
@@ -7,20 +7,27 @@
@@ -7,20 +7,27 @@
* exponential backoff, and a rotation honoured on an unknown kid. OV_NETWORK_PUBLIC_KEY still pins a
* exponential backoff, and a rotation honoured on an unknown kid. OV_NETWORK_PUBLIC_KEY still pins a
* PEM, which skips the fetch entirely.
* PEM, which skips the fetch entirely.
*
*
- * Capabilities ai.run.create / ai.run.read / ai.workflow.manage / ai.provider.manage / ai.usage.read
- * are proposed in docs/capabilities-proposal/ and are not in openvibe-contracts yet; ai.credential.manage
- * and ai.quota.attribution.manage ship in contracts (0.73.0 / 0.75.0). Until the five do, allows()
- * decides with the contracts' own grant rule (the exact id, or a `family.*` grant); once contracts
- * knows an id, contracts decides.
+ * Capability ids are decided by openvibe-contracts when it knows them (the release named below);
+ * until then allows() decides with the contracts' own grant rule (the exact id, or a `family.*`
+ * grant), but only for the ids PROPOSED names — a proposal this service enforces ahead of the
+ * release that registers it (docs/capabilities-proposal/, the pattern OpenVibe.Events used before
+ * events.app.* shipped). ai.app.run (T6 step 10: developer apps, ADR-014) needs exactly that
+ * fallback until openvibe-contracts knows the id.
*
*
- * 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
+ * Namespaces fail closed: a service 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.
* C-22 and C-23, were retired on 2026-09-28 once no token without `ns` was left.
+ *
+ * Developer apps (ADR-014): an app token (sub app:app_<ULID>, project_id, env sandbox|production)
+ * is refused on every first-party route whatever ids it was granted, and judged only on ai.app.run
+ * on the routes that take it (appOrCap below). Only there is a sandbox app token accepted; the
+ * routes that do not take one refuse env=sandbox with token.sandbox_refused, as before.
*/
*/
const { serviceAuth, capabilities, http } = require('openvibe-contracts');
const { serviceAuth, capabilities, http } = require('openvibe-contracts');
const { jwksClient, jwksStatus } = require('openvibe-sdk/auth');
const { jwksClient, jwksStatus } = require('openvibe-sdk/auth');
+const apps = require('./apps');
const CAPS = Object.freeze({
const CAPS = Object.freeze({
runCreate: 'ai.run.create',
runCreate: 'ai.run.create',
@@ -30,8 +37,12 @@ const CAPS = Object.freeze({
@@ -30,8 +37,12 @@ const CAPS = Object.freeze({
usageRead: 'ai.usage.read',
usageRead: 'ai.usage.read',
credentialManage: 'ai.credential.manage',
credentialManage: 'ai.credential.manage',
quotaAttributionManage: 'ai.quota.attribution.manage',
quotaAttributionManage: 'ai.quota.attribution.manage',
+ appRun: 'ai.app.run',
});
});
+/** Ids enforced here before the pinned openvibe-contracts release knows them (docs/capabilities-proposal/). */
+const PROPOSED = Object.freeze(['ai.app.run']);
+
/**
/**
* The Network signing keys, through the SDK's process-wide JWKS client (one per URL). A pinned
* The Network signing keys, through the SDK's process-wide JWKS client (one per URL). A pinned
* OV_NETWORK_PUBLIC_KEY PEM skips the fetch entirely; /api/ready reports either as ready.
* OV_NETWORK_PUBLIC_KEY PEM skips the fetch entirely; /api/ready reports either as ready.
@@ -53,7 +64,12 @@ function hasCap(claims, id) {
@@ -53,7 +64,12 @@ function hasCap(claims, id) {
function allows(claims, id) {
function allows(claims, id) {
if (!hasCap(claims, id)) return { allowed: false, code: 'capability.denied', reason: `${id} not granted` };
if (!hasCap(claims, id)) return { allowed: false, code: 'capability.denied', reason: `${id} not granted` };
- if (!capabilities.get(id)) return { allowed: true, code: null, reason: null }; // not in contracts yet: local rule decided
+ if (!capabilities.get(id)) {
+ // Not in the pinned contracts release yet: only a PROPOSED id is enforced (by the same exact-id-or-family
+ // grant rule hasCap applied); any other unknown id is refused, never silently allowed.
+ if (!PROPOSED.includes(id)) return { allowed: false, code: 'capability.denied', reason: `${id} is not a capability this release knows` };
+ return { allowed: true, code: null, reason: null };
+ }
const c = capabilities.check(claims, id);
const c = capabilities.check(claims, id);
return c;
return c;
}
}
@@ -72,6 +88,11 @@ function bearer(req) {
@@ -72,6 +88,11 @@ function bearer(req) {
return h.startsWith('Bearer ') ? h.slice(7).trim() : null;
return h.startsWith('Bearer ') ? h.slice(7).trim() : null;
}
}
+/** Is this token an app principal? (actor_type app and the app:app_<ULID> subject are both required.) */
+function isAppClaims(claims) {
+ return Boolean(claims) && claims.actor_type === 'app' && /^app:app_/.test(String(claims.sub));
+}
+
/** svc:live -> { type: 'service', id: 'live' }; app:/mod: principals keep their type. */
/** svc:live -> { type: 'service', id: 'live' }; app:/mod: principals keep their type. */
function principalSubject(sub) {
function principalSubject(sub) {
const m = /^(svc|app|mod):(.+)$/.exec(String(sub || ''));
const m = /^(svc|app|mod):(.+)$/.exec(String(sub || ''));
@@ -87,10 +108,11 @@ function createAuth({ config, log = console }) {
@@ -87,10 +108,11 @@ function createAuth({ config, log = console }) {
/**
/**
* A Network service/app token: the key from the SDK's JWKS client (or the pinned PEM), every rule
* A Network service/app token: the key from the SDK's JWKS client (or the pinned PEM), every rule
- * from openvibe-contracts' verifyServiceToken (identity.service-token-claims@1, env: sandbox refused).
+ * from openvibe-contracts' verifyServiceToken (identity.service-token-claims@1). env: sandbox is
+ * refused unless the caller opted in (only the routes that take ai.app.run do).
*/
*/
- async function verify(token) {
- const check = (publicKey) => serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.issuer, audience: config.audience });
+ async function verify(token, { acceptSandbox = false } = {}) {
+ const check = (publicKey) => serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.issuer, audience: config.audience, acceptSandbox });
if (config.networkPublicKey) return check(config.networkPublicKey);
if (config.networkPublicKey) return check(config.networkPublicKey);
let keys;
let keys;
try { keys = await jwksClient(config.networkJwksUrl, { log }).keysForKid(headerKid(token)); } catch (err) {
try { keys = await jwksClient(config.networkJwksUrl, { log }).keysForKid(headerKid(token)); } catch (err) {
@@ -109,7 +131,8 @@ function createAuth({ config, log = console }) {
@@ -109,7 +131,8 @@ function createAuth({ config, log = console }) {
}
}
/**
/**
- * Express guard. `anyOf` lists capability ids; one granted is enough.
+ * Express guard for first-party service routes. `anyOf` lists capability ids; one granted is
+ * enough. An app token is refused whatever ids it was granted (only appOrCap takes one).
* Sets req.principal = { sub, subject: {type,id}, cap, ns, jti }.
* Sets req.principal = { sub, subject: {type,id}, cap, ns, jti }.
*/
*/
function requireCap(...anyOf) {
function requireCap(...anyOf) {
@@ -120,6 +143,7 @@ function createAuth({ config, log = console }) {
@@ -120,6 +143,7 @@ function createAuth({ config, log = console }) {
if (!token) return http.sendProblem(res, 401, 'token.missing', { detail: 'a service token (audience openvibe.ai) is required', ctx });
if (!token) return http.sendProblem(res, 401, 'token.missing', { detail: 'a service token (audience openvibe.ai) is required', ctx });
const r = await verify(token);
const r = await verify(token);
if (!r.ok) return http.sendProblem(res, r.code === 'token.unavailable' ? 503 : 401, r.code, { detail: r.reason, ctx });
if (!r.ok) return http.sendProblem(res, r.code === 'token.unavailable' ? 503 : 401, r.code, { detail: r.reason, ctx });
+ if (isAppClaims(r.claims)) return http.sendProblem(res, 403, 'capability.denied', { detail: 'app tokens are not accepted on this route', ctx });
let decision = null;
let decision = null;
for (const id of anyOf) {
for (const id of anyOf) {
decision = allows(r.claims, id);
decision = allows(r.claims, id);
@@ -136,10 +160,44 @@ function createAuth({ config, log = console }) {
@@ -136,10 +160,44 @@ function createAuth({ config, log = console }) {
};
};
}
}
+ /**
+ * A guard for the routes a developer app may use (ADR-014; ai.app.run). A first-party service
+ * token is judged on anyOf exactly as requireCap; an app token (actor_type app) is judged only on
+ * appCap, and becomes req.principal whose SUBJECT IS ITS PROJECT, so every run, quota, attribution
+ * and free allowance of the app is the project's. A sandbox app token is accepted here and
+ * nowhere else. Sets req.principal.kind = 'app' with appId, projectId, projectKey, env and the
+ * app's namespaces.
+ */
+ function appOrCap(appCap, ...anyOf) {
+ const serviceGuard = requireCap(...anyOf);
+ return async function appOrCapGuard(req, res, next) {
+ const ctx = req.ov;
+ const token = bearer(req);
+ if (!token) return serviceGuard(req, res, next); // token.missing, with requireCap's wording
+ try {
+ const r = await verify(token, { acceptSandbox: true });
+ if (!r.ok || !isAppClaims(r.claims)) return await serviceGuard(req, res, next);
+ const app = apps.appPrincipal(r.claims);
+ if (app.error) return http.sendProblem(res, 401, 'token.invalid_claims', { detail: app.error, ctx });
+ const decision = allows(r.claims, appCap);
+ if (!decision.allowed) return http.sendProblem(res, 403, decision.code, { detail: decision.reason, ctx });
+ req.principal = {
+ kind: 'app', sub: r.claims.sub, subject: { type: 'project', id: app.projectId },
+ appId: app.appId, projectId: app.projectId, projectKey: app.projectKey, env: app.env, onBehalfOf: app.onBehalfOf,
+ cap: r.claims.cap, ns: apps.namespacesOf(app.projectKey), jti: r.claims.jti, claims: r.claims,
+ };
+ return next();
+ } catch (err) {
+ log.error(`[auth] ${req.method} ${req.path}: ${(err && err.stack) || err}`);
+ if (!res.headersSent) http.sendProblem(res, 500, 'internal.error', { detail: 'internal error', ctx });
+ }
+ };
+ }
+
/** Does the authenticated principal also hold `id`? (for owner-or-admin checks) */
/** Does the authenticated principal also hold `id`? (for owner-or-admin checks) */
function principalHas(req, id) { return Boolean(req.principal && allows(req.principal.claims, id).allowed); }
function principalHas(req, id) { return Boolean(req.principal && allows(req.principal.claims, id).allowed); }
- return { verify, requireCap, principalHas };
+ return { verify, requireCap, appOrCap, principalHas };
}
}
-module.exports = { CAPS, createNetworkKeys, createAuth, hasCap, allows, namespaceAllowed, effectiveNamespaces, principalSubject, bearer };
+module.exports = { CAPS, PROPOSED, createNetworkKeys, createAuth, hasCap, allows, isAppClaims, namespaceAllowed, effectiveNamespaces, principalSubject, bearer };
@@ -12,7 +12,8 @@
@@ -12,7 +12,8 @@
const RUN_STATUSES = ['queued', 'running', 'succeeded', 'failed', 'cancelled', 'cached'];
const RUN_STATUSES = ['queued', 'running', 'succeeded', 'failed', 'cancelled', 'cached'];
const KEY_RE = /^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)*$/;
const KEY_RE = /^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)*$/;
-const REQUESTER_RE = /^(service|app|mod|user):[A-Za-z0-9_.:-]{1,120}$/;
+// project: a developer app's runs (ADR-014) are their project's, so that is how they are filtered.
+const REQUESTER_RE = /^(service|app|mod|user|project):[A-Za-z0-9_.:-]{1,120}$/;
const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;
const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;
const CODE_RE = /^[a-z][a-z0-9_.-]{1,80}$/;
const CODE_RE = /^[a-z][a-z0-9_.-]{1,80}$/;
const RUN_ID_RE = /^run_[0-9A-HJKMNP-TV-Z]{26}$/;
const RUN_ID_RE = /^run_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -159,6 +159,18 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -159,6 +159,18 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
function isFreeProvider(p) {
function isFreeProvider(p) {
return p.kind === 'stub' || isLocalProvider(p) || Boolean(p.metadata && p.metadata.paid === false) || isPrepaidProvider(p);
return p.kind === 'stub' || isLocalProvider(p) || Boolean(p.metadata && p.metadata.paid === false) || isPrepaidProvider(p);
}
}
+ /**
+ * May a developer app's run (ADR-014) use this provider? Never a person's byok key, and a free-only run
+ * (ctx.freeOnly: a sandbox run, or any app run no tier budget governs) is limited to free and local
+ * capacity: the stub, the local model, a free server or a pooled subscription. A provider that fails
+ * this is not offered to the planner at all.
+ */
+ function appOffered(ctx, p) {
+ if (!p) return true; // an unknown provider keeps its place; skipReason reports it, as before
+ if (ctx.appRun && billingProfile(p) === 'byok') return false;
+ if (ctx.freeOnly && !isFreeProvider(p)) return false;
+ return true;
+ }
const offerId = (provider, model) => (model ? `${provider}:${model}` : provider);
const offerId = (provider, model) => (model ? `${provider}:${model}` : provider);
const splitOfferId = (id) => {
const splitOfferId = (id) => {
const i = String(id).indexOf(':');
const i = String(id).indexOf(':');
@@ -182,7 +194,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -182,7 +194,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
* health (placement_state error rate + the breaker); hysteresis through the route's current placement.
* health (placement_state error rate + the breaker); hysteresis through the route's current placement.
* The stub is never a placement candidate (synthetic output must not win on price) — it is appended last.
* The stub is never a placement candidate (synthetic output must not win on price) — it is appended last.
*/
*/
- async function poolCandidates(route, features, operation) {
+ async function poolCandidates(route, features, operation, ctx = {}) {
const stats = await statsByProvider();
const stats = await statsByProvider();
const offers = [];
const offers = [];
const rateCards = [];
const rateCards = [];
@@ -217,6 +229,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -217,6 +229,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const scope = route.constraints && route.constraints.provider_scope;
const scope = route.constraints && route.constraints.provider_scope;
for (const p of await registry.listProviders()) {
for (const p of await registry.listProviders()) {
if (p.key === 'stub' || !p.capabilities.some(f => features.includes(f))) continue;
if (p.key === 'stub' || !p.capabilities.some(f => features.includes(f))) continue;
+ if (!appOffered(ctx, p)) continue;
if (scope === 'local' && !isLocalProvider(p)) continue;
if (scope === 'local' && !isLocalProvider(p)) continue;
if (scope === 'paid' && isFreeProvider(p)) continue;
if (scope === 'paid' && isFreeProvider(p)) continue;
if (operation === 'embed' || operation === 'transcribe') {
if (operation === 'embed' || operation === 'transcribe') {
@@ -235,6 +248,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -235,6 +248,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
if (!pin || !pin.provider || pin.provider === 'stub') continue;
if (!pin || !pin.provider || pin.provider === 'stub') continue;
const p = await registry.getProvider(pin.provider);
const p = await registry.getProvider(pin.provider);
if (!p) continue;
if (!p) continue;
+ if (!appOffered(ctx, p)) continue;
if (scope === 'local' && !isLocalProvider(p)) continue;
if (scope === 'local' && !isLocalProvider(p)) continue;
if (scope === 'paid' && isFreeProvider(p)) continue;
if (scope === 'paid' && isFreeProvider(p)) continue;
const caps = route.capability && !p.capabilities.includes(route.capability) ? [...p.capabilities, route.capability] : p.capabilities;
const caps = route.capability && !p.capabilities.includes(route.capability) ? [...p.capabilities, route.capability] : p.capabilities;
@@ -284,23 +298,33 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -284,23 +298,33 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
return { order: order.map(splitOfferId), explain, placement: result, rateCards, states };
return { order: order.map(splitOfferId), explain, placement: result, rateCards, states };
}
}
- /** A pinned route: [primary, ...fallbacks], exactly as before. */
- async function pinnedCandidates(route, features) {
+ /**
+ * A pinned route: [primary, ...fallbacks], exactly as before. A provider an app run may not use
+ * (a byok key, or anything metered for a free-only run) leaves the order with its
+ * reason in explain.candidates; every other candidate keeps its place, as before.
+ */
+ async function pinnedCandidates(route, features, ctx = {}) {
const list = [route.primary, ...(route.fallbacks || [])].filter(c => c && c.provider);
const list = [route.primary, ...(route.fallbacks || [])].filter(c => c && c.provider);
const explain = { objective: null, reasons: ['pinned route: the primary, then its fallbacks'], selected: null, candidates: [] };
const explain = { objective: null, reasons: ['pinned route: the primary, then its fallbacks'], selected: null, candidates: [] };
+ const order = [];
for (const c of list) {
for (const c of list) {
const entry = await adapter(c.provider);
const entry = await adapter(c.provider);
const p = entry && entry.record;
const p = entry && entry.record;
const why = await skipReason(p, entry && entry.adapter, features);
const why = await skipReason(p, entry && entry.adapter, features);
- explain.candidates.push({ provider: c.provider, model: c.model || (p && p.default_model) || null, eligible: !why, excluded_reason: why, cost: null, latency: null });
+ const offered = appOffered(ctx, p);
+ explain.candidates.push({
+ provider: c.provider, model: c.model || (p && p.default_model) || null, eligible: offered && !why,
+ excluded_reason: offered ? why : (p && billingProfile(p) === 'byok' ? 'byok' : 'free_only'), cost: null, latency: null,
+ });
+ if (offered) order.push({ provider: c.provider, model: c.model || null });
}
}
- return { order: list.map(c => ({ provider: c.provider, model: c.model || null })), explain };
+ return { order, explain };
}
}
- async function candidates(route, features, operation) {
+ async function candidates(route, features, operation, ctx = {}) {
const { order, explain, placement: result = null, ...rest } = route.capability && operation
const { order, explain, placement: result = null, ...rest } = route.capability && operation
- ? await poolCandidates(route, features, operation)
- : await pinnedCandidates(route, features);
+ ? await poolCandidates(route, features, operation, ctx)
+ : await pinnedCandidates(route, features, ctx);
if (config.stubFallback && !(route.constraints && route.constraints.provider_scope)
if (config.stubFallback && !(route.constraints && route.constraints.provider_scope)
&& !order.some(c => c.provider === 'stub') && await registry.getProvider('stub')) {
&& !order.some(c => c.provider === 'stub') && await registry.getProvider('stub')) {
order.push({ provider: 'stub', model: null });
order.push({ provider: 'stub', model: null });
@@ -371,7 +395,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -371,7 +395,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
if (req.image) features.push('vision');
if (req.image) features.push('vision');
if (req.json) features.push('json');
if (req.json) features.push('json');
const tried = [];
const tried = [];
- const { order: list, explain } = await candidates(route, features, operation);
+ const { order: list, explain } = await candidates(route, features, operation, ctx);
for (let i = 0; i < list.length; i++) {
for (let i = 0; i < list.length; i++) {
const c = list[i];
const c = list[i];
const entry = await adapter(c.provider);
const entry = await adapter(c.provider);
@@ -207,7 +207,7 @@ function createQuotas(db, { clock = { now: () => Date.now() }, registry, freeAll
@@ -207,7 +207,7 @@ function createQuotas(db, { clock = { now: () => Date.now() }, registry, freeAll
cost: Math.max(0, (Number(f.cost) || 0) - (Number(f.usd) || 0)), freeAllowanceUsed: Number(f.tokens) || 0 });
cost: Math.max(0, (Number(f.cost) || 0) - (Number(f.usd) || 0)), freeAllowanceUsed: Number(f.tokens) || 0 });
}
}
}
}
- await usageSamples.record(db, { runId, workflowKey: ctx.workflowKey, requester: `${ctx.requesterType}:${ctx.requesterId}`, at, traceId, readings });
+ await usageSamples.record(db, { runId, workflowKey: ctx.workflowKey, requester: `${ctx.requesterType}:${ctx.requesterId}`, project: ctx.projectId || null, at, traceId, readings });
}
}
await recordStats(day, attempts);
await recordStats(day, attempts);
});
});
@@ -154,6 +154,9 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -154,6 +154,9 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
if (!v.valid) throw new AiError(422, 'input.invalid', `input does not match ${wf.key} v${wf.version}`, { errors: v.errors });
if (!v.valid) throw new AiError(422, 'input.invalid', `input does not match ${wf.key} v${wf.version}`, { errors: v.errors });
const inputHash = sha256(inputJson);
const inputHash = sha256(inputJson);
const requester = principal.subject;
const requester = principal.subject;
+ // A developer app (ADR-014): the requester IS its project, the run is metered with the project's
+ // id on every Billing reading, and a sandbox run may use free/local capacity only (server/providers).
+ const app = principal.kind === 'app' ? { projectId: principal.projectId, sandbox: principal.env === 'sandbox' } : null;
const idem = body.idempotency_key != null ? String(body.idempotency_key) : null;
const idem = body.idempotency_key != null ? String(body.idempotency_key) : null;
if (idem !== null && !/^[A-Za-z0-9._:-]{8,128}$/.test(idem)) throw new AiError(422, 'input.invalid', 'idempotency_key must be 8-128 of [A-Za-z0-9._:-]');
if (idem !== null && !/^[A-Za-z0-9._:-]{8,128}$/.test(idem)) throw new AiError(422, 'input.invalid', 'idempotency_key must be 8-128 of [A-Za-z0-9._:-]');
@@ -180,6 +183,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -180,6 +183,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
const ctx = {
const ctx = {
requesterType: requester.type, requesterId: requester.id,
requesterType: requester.type, requesterId: requester.id,
actorKey: subjectKey(body.on_behalf_of), targetKey: entityKey(body.target), attributionKey: entityKey(body.attribution), workflowKey: wf.key,
actorKey: subjectKey(body.on_behalf_of), targetKey: entityKey(body.target), attributionKey: entityKey(body.attribution), workflowKey: wf.key,
+ ...(app ? { appRun: true, projectId: app.projectId, sandbox: app.sandbox } : {}),
};
};
const now = iso(clock.now());
const now = iso(clock.now());
const id = newRunId();
const id = newRunId();
@@ -216,7 +220,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -216,7 +220,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
// The reused output carries the grounding it was produced with.
// The reused output carries the grounding it was produced with.
await db.prepare('UPDATE runs SET grounding = (SELECT grounding FROM runs WHERE id = ?) WHERE id = ?').run(hit.run_id, id);
await db.prepare('UPDATE runs SET grounding = (SELECT grounding FROM runs WHERE id = ?) WHERE id = ?').run(hit.run_id, id);
// A cache hit is still a served run: its zero reading (no attempt, no tokens, no cost) goes to Billing with it.
// A cache hit is still a served run: its zero reading (no attempt, no tokens, no cost) goes to Billing with it.
- await usageSamples.record(db, { runId: id, workflowKey: wf.key, requester: `${requester.type}:${requester.id}`, at: now, traceId: trace,
+ await usageSamples.record(db, { runId: id, workflowKey: wf.key, requester: `${requester.type}:${requester.id}`, project: ctx.projectId || null, at: now, traceId: trace,
readings: [{ attempt: 0, kind: 'in', model: hit.model_key || null, quantity: 0, cost: 0, freeAllowanceUsed: 0 }] });
readings: [{ attempt: 0, kind: 'in', model: hit.model_key || null, quantity: 0, cost: 0, freeAllowanceUsed: 0 }] });
await events.runChanged(await getRow(id));
await events.runChanged(await getRow(id));
});
});
@@ -310,6 +314,10 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -310,6 +314,10 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
}
}
const credential = opts.credential_subject ? await credentials.forRun(row.requester_id, opts.credential_subject) : null;
const credential = opts.credential_subject ? await credentials.forRun(row.requester_id, opts.credential_subject) : null;
const r = await engine.execute(wf, row, input, { signal: controller.signal, logRequest, debugRaw: Boolean(config.debugRawLog && opts.debug), inputHash: row.input_hash, preferences,
const r = await engine.execute(wf, row, input, { signal: controller.signal, logRequest, debugRaw: Boolean(config.debugRawLog && opts.debug), inputHash: row.input_hash, preferences,
+ // A developer app's run (ADR-014): the provider pool excludes a person's byok key, and the run is
+ // limited to free and local capacity when it is a sandbox run or when no tier budget governs its
+ // project (AI_GOVERN_TIERS off): metered capacity is only ever spent under a project budget.
+ appRun: Boolean(ctx.appRun), freeOnly: Boolean(ctx.appRun && (ctx.sandbox || !(tiers && tiers.enabled))),
...(credential ? { credential, role: input && typeof input.role === 'string' ? input.role : null } : {}) });
...(credential ? { credential, role: input && typeof input.role === 'string' ? input.role : null } : {}) });
if (controller.signal.aborted) throw new AiError(409, 'run.cancelled', 'cancelled');
if (controller.signal.aborted) throw new AiError(409, 'run.cancelled', 'cancelled');
// The status, its citations and the ai.run.succeeded event commit together.
// The status, its citations and the ai.run.succeeded event commit together.
@@ -14,7 +14,8 @@
@@ -14,7 +14,8 @@
* The relay (openvibe-sdk createPgOutbox) posts each row with AI's service token. If Billing is down, has no
* The relay (openvibe-sdk createPgOutbox) posts each row with AI's service token. If Billing is down, has no
* grant yet, or is not deployed, the row waits and is retried with backoff, across restarts. Only Billing
* grant yet, or is not deployed, the row waits and is retried with backoff, across restarts. Only Billing
* refusing the reading itself (400, 409, 413, 422) marks a row rejected. A reading carries ids, the workflow,
* refusing the reading itself (400, 409, 413, 422) marks a row rejected. A reading carries ids, the workflow,
- * the requester, tokens and cost. It never carries the input, the output or a prompt. Readings are queued once
+ * the requester, tokens and cost — and, for a developer app's run (ADR-014), the `project` (prj_<ULID>) its
+ * spend is billed to. It never carries the input, the output or a prompt. Readings are queued once
* init() ran, unless USAGE_SAMPLES=off. The relay runs while OV_BILLING_INTERNAL_URL and OV_OAUTH_CLIENT_SECRET
* init() ran, unless USAGE_SAMPLES=off. The relay runs while OV_BILLING_INTERNAL_URL and OV_OAUTH_CLIENT_SECRET
* are set. Runs on a person's own key, and runs that called no provider and were not a cache hit, are not
* are set. Runs on a person's own key, and runs that called no provider and were not a cache hit, are not
* accounted and have no reading. free_allowance_used (§2.1.8) is the share the provider free allowance covered
* accounted and have no reading. free_allowance_used (§2.1.8) is the share the provider free allowance covered
@@ -37,11 +38,13 @@ let relay = null; let on = false; let pruneTimer = null; let lastError = null; l
@@ -37,11 +38,13 @@ let relay = null; let on = false; let pruneTimer = null; let lastError = null; l
const keyOf = (runId, attempt, kind) => `ai:${runId}:${attempt}:${kind}`;
const keyOf = (runId, attempt, kind) => `ai:${runId}:${attempt}:${kind}`;
const meter = createMeter();
const meter = createMeter();
/** One reading: an attempt's tokens of one kind, under its rate-card metric and the metric's leading-word unit. */
/** One reading: an attempt's tokens of one kind, under its rate-card metric and the metric's leading-word unit. */
-function sampleOf({ runId, attempt, kind, workflowKey, requester, provider = null, model = null, quantity = 0, cost = 0, freeAllowanceUsed = 0, at, traceId = null }) {
+function sampleOf({ runId, attempt, kind, workflowKey, requester, project = null, provider = null, model = null, quantity = 0, cost = 0, freeAllowanceUsed = 0, at, traceId = null }) {
const key = keyOf(runId, attempt, kind);
const key = keyOf(runId, attempt, kind);
const s = { id: key, idempotency_key: key, service: 'ai', subject: requester, resource: rates.metricFor(kind, model || null), operation: workflowKey,
const s = { id: key, idempotency_key: key, service: 'ai', subject: requester, resource: rates.metricFor(kind, model || null), operation: workflowKey,
quantity: Math.max(0, Number(quantity) || 0), unit: UNIT[kind], at: new Date(at).toISOString(),
quantity: Math.max(0, Number(quantity) || 0), unit: UNIT[kind], at: new Date(at).toISOString(),
cost_estimate: Math.max(0, Number(cost) || 0), source: 'ai.runs' };
cost_estimate: Math.max(0, Number(cost) || 0), source: 'ai.runs' };
+ // A developer app's run (ADR-014): the reading names the project Billing bills it to.
+ if (project) s.project = project;
if (provider && provider !== 'mixed') s.provider = provider;
if (provider && provider !== 'mixed') s.provider = provider;
// §2.1.8: the tokens the provider free allowance covered of this reading's quantity (server/free-allowance.js); absent at 0.
// §2.1.8: the tokens the provider free allowance covered of this reading's quantity (server/free-allowance.js); absent at 0.
const free = Math.min(s.quantity, Math.max(0, Math.floor(Number(freeAllowanceUsed) || 0)));
const free = Math.min(s.quantity, Math.max(0, Math.floor(Number(freeAllowanceUsed) || 0)));
@@ -58,12 +61,13 @@ function sampleFromUsage(e, fields) {
@@ -58,12 +61,13 @@ function sampleFromUsage(e, fields) {
* per-kind descriptors (quota.account builds them from the free allowance's byKind): { attempt, kind,
* per-kind descriptors (quota.account builds them from the free allowance's byKind): { attempt, kind,
* provider, model, quantity, cost, freeAllowanceUsed }. Writes them all, links their keys on the run, and
* provider, model, quantity, cost, freeAllowanceUsed }. Writes them all, links their keys on the run, and
* returns the keys. A second call for the same keys changes nothing (ON CONFLICT DO NOTHING).
* returns the keys. A second call for the same keys changes nothing (ON CONFLICT DO NOTHING).
+ * `project` (prj_<ULID>) names the developer app's project on every reading (ADR-014); null otherwise.
*/
*/
-async function record(db, { runId, workflowKey, requester, at, traceId = null, readings = [] }) {
+async function record(db, { runId, workflowKey, requester, project = null, at, traceId = null, readings = [] }) {
if (!on) return [];
if (!on) return [];
const ids = [];
const ids = [];
for (const r of readings) {
for (const r of readings) {
- const fields = { runId, attempt: r.attempt, kind: r.kind, workflowKey, requester, provider: r.provider, model: r.model, quantity: r.quantity, cost: r.cost, freeAllowanceUsed: r.freeAllowanceUsed, at, traceId };
+ const fields = { runId, attempt: r.attempt, kind: r.kind, workflowKey, requester, project, provider: r.provider, model: r.model, quantity: r.quantity, cost: r.cost, freeAllowanceUsed: r.freeAllowanceUsed, at, traceId };
const key = keyOf(runId, r.attempt, r.kind);
const key = keyOf(runId, r.attempt, r.kind);
const e = await meter({ subject: requester, quantity: Math.max(0, Number(r.quantity) || 0), key });
const e = await meter({ subject: requester, quantity: Math.max(0, Number(r.quantity) || 0), key });
const s = e ? sampleFromUsage(e, fields) : sampleOf(fields);
const s = e ? sampleFromUsage(e, fields) : sampleOf(fields);
new file mode 100644
new file mode 100644
@@ -0,0 +1,210 @@
@@ -0,0 +1,210 @@
+'use strict';
+// T6 step 10: developer-app access (ADR-014; capability ai.app.run, docs/capabilities-proposal/).
+// An app token (sub app:app_<ULID>, project_id prj_<ULID>, env, audience openvibe.ai, ns as Network
+// issues it) holding ai.app.run may use the six direct operations and GET /api/v1/runs/:id; every
+// other route refuses it (403). Its runs are its PROJECT's: requester and attribution are the
+// project, the free allowance is per project, the govern tier map is keyed by the project, every
+// Billing reading carries the project, a sandbox or ungoverned run sees free/local capacity only, and a person's
+// BYO key is never selected.
+const assert = require('assert');
+const contracts = require('openvibe-contracts');
+const { boot, request, token, suite, seamServer, ALL } = require('./helpers');
+const usageSamples = require('../server/usage-samples');
+const apps = require('../server/apps');
+
+const t = suite('app-access');
+const ULID_A = '01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const ULID_B = '01JAB2C3D4E5F6G7H8J9K0MNPR';
+const PRJ_A = `prj_${ULID_A}`;
+const PRJ_B = `prj_${ULID_B}`;
+const KEY_A = apps.projectKey(PRJ_A);
+const service = token('live', ALL);
+
+/** A Network-shaped app token: sub app:app_<ULID>, actor_type app, project_id, env, ns (Network's own shape). */
+function appToken(projectId, cap = ['ai.app.run'], { env = 'production' } = {}) {
+ return token('app', cap, { sub: `app:app_${projectId.slice(4)}`, actorType: 'app', ns: [projectId, `app.${projectId}.*`], extra: { project_id: projectId, env } });
+}
+const appA = appToken(PRJ_A);
+const appB = appToken(PRJ_B);
+const sandboxA = appToken(PRJ_A, ['ai.app.run'], { env: 'sandbox' });
+
+const CAPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'embed'];
+/** The three provers one pinned default.chat route holds: a byok pool (first), a paid meter, a free server. */
+async function seedProviders(h, seam) {
+ const mk = (body) => request(h.base, 'POST', '/api/v1/providers', { tok: service, body: { kind: 'http', base_url: seam.url, auth_mode: 'none', capabilities: CAPS, ...body } });
+ assert.strictEqual((await mk({ key: 'byokpool', auth_mode: 'bearer', secret_ref: 'env:BYOK_TEST_KEY', billing_profile: 'byok', default_model: 'byok-m' })).status, 201);
+ assert.strictEqual((await mk({ key: 'paid', default_model: 'paid-m' })).status, 201);
+ assert.strictEqual((await mk({ key: 'freecap', billing_profile: 'free', default_model: 'freecap-m' })).status, 201);
+ // The paid model's 100-token-a-day free allowance, so a project's free use is observable.
+ assert.strictEqual((await request(h.base, 'POST', '/api/v1/models', { tok: service, body: {
+ provider_key: 'paid', model_key: 'paid-m', type: 'chat', cost: { in_per_mtok: 1000, out_per_mtok: 1000, free_allowance: 100, reset_period: 'day' } } })).status, 201);
+ assert.strictEqual((await request(h.base, 'POST', '/api/v1/routes/default.chat/versions', { tok: service, body: {
+ primary: { provider: 'byokpool', model: 'byok-m' }, fallbacks: [{ provider: 'paid', model: 'paid-m' }, { provider: 'freecap', model: 'freecap-m' }] } })).status, 201);
+}
+
+let h; let seam;
+t.test('boot with the seam providers', async () => {
+ seam = await seamServer(() => ({ text: 'ok', usage: { input: 1000, output: 1000 } }));
+ // The tier budgets govern these projects (an open free tier), so a production app run may reach the paid meter.
+ h = await boot({ env: { AI_STUB_FALLBACK: 'false', BYOK_TEST_KEY: 'sk-test-byok-value', AI_GOVERN_TIERS: '1',
+ AI_GOVERN_POLICY_JSON: JSON.stringify({ 'ai-token': { free: {} }, 'ai-usd': { free: {} } }) } });
+ await seedProviders(h, seam);
+});
+
+let chatRun; let genRun;
+t.test('an app token with ai.app.run chats, generates and reads its own run', async () => {
+ const chat = await request(h.base, 'POST', '/api/v1/chat', { tok: appA, body: { messages: [{ role: 'user', content: 'hello' }] } });
+ assert.strictEqual(chat.status, 201, chat.text);
+ chatRun = chat.body.run;
+ assert.strictEqual(chatRun.provenance.provider, 'paid', 'the byok pool is never an app run\'s provider');
+ assert.ok(chatRun.explain && Array.isArray(chatRun.explain.candidates), 'explain stays on every run response');
+
+ const gen = await request(h.base, 'POST', '/api/v1/generate', { tok: appA, body: { prompt: 'hello there', options: { cache: false } } });
+ assert.strictEqual(gen.status, 201, gen.text);
+ genRun = gen.body.run;
+ assert.strictEqual(genRun.provenance.provider, 'paid');
+
+ const got = await request(h.base, 'GET', `/api/v1/runs/${genRun.id}`, { tok: appA });
+ assert.strictEqual(got.status, 200, got.text);
+ assert.strictEqual(got.body.run.id, genRun.id);
+ assert.deepStrictEqual([got.body.run.requester.type, got.body.run.requester.id], ['project', PRJ_A], 'the run is the project\'s');
+ assert.deepStrictEqual(got.body.run.attribution, { service: 'network', type: 'project', id: PRJ_A }, 'attributed to the project');
+});
+
+t.test('another project never reads an app run (404)', async () => {
+ assert.strictEqual((await request(h.base, 'GET', `/api/v1/runs/${genRun.id}`, { tok: appB })).status, 404);
+});
+
+t.test('an app token without ai.app.run is refused on the app routes', async () => {
+ const noCap = appToken(PRJ_A, ['ai.run.read']);
+ const post = await request(h.base, 'POST', '/api/v1/generate', { tok: noCap, body: { prompt: 'x' } });
+ assert.deepStrictEqual([post.status, post.body.code], [403, 'capability.denied']);
+ const get = await request(h.base, 'GET', `/api/v1/runs/${genRun.id}`, { tok: noCap });
+ assert.deepStrictEqual([get.status, get.body.code], [403, 'capability.denied']);
+});
+
+t.test('every other route is first-party only: an app token gets 403', async () => {
+ const routes = [
+ ['POST', '/api/v1/runs', { workflow: 'ai.generate', input: { prompt: 'x' } }],
+ ['GET', '/api/v1/runs', undefined],
+ ['GET', `/api/v1/runs/${genRun.id}/citations`, undefined],
+ ['POST', `/api/v1/runs/${genRun.id}/cancel`, undefined],
+ ['POST', '/api/v1/enrich', { record: {}, instructions: 'x' }],
+ ['GET', '/api/v1/providers', undefined],
+ ];
+ for (const [method, path, body] of routes) {
+ const r = await request(h.base, method, path, { tok: appA, body });
+ assert.strictEqual(r.status, 403, `${method} ${path}: ${r.text}`);
+ assert.strictEqual(r.body.code, 'capability.denied', `${method} ${path}`);
+ }
+ // A sandbox app token is accepted on the app routes only: the door refuses it here, as before.
+ const sb = await request(h.base, 'POST', '/api/v1/runs', { tok: sandboxA, body: { workflow: 'ai.generate', input: { prompt: 'x' } } });
+ assert.deepStrictEqual([sb.status, sb.body.code], [401, 'token.sandbox_refused']);
+});
+
+t.test('an app may only run its own app.<project_key>.* namespace', async () => {
+ const principal = { projectKey: KEY_A };
+ assert.strictEqual(KEY_A, `p${ULID_A.toLowerCase()}`, 'project_key follows Events\' p+lowercase-ULID rule');
+ assert.ok(apps.appMayRun(principal, `app.${KEY_A}.draft`), 'its own namespace');
+ assert.ok(apps.appMayRun(principal, `app.${KEY_A}`), 'the namespace itself');
+ assert.ok(apps.appMayRun(principal, 'ai.chat'), 'the direct operations ai.app.run grants by route');
+ assert.ok(!apps.appMayRun(principal, 'live.translate'));
+ assert.ok(!apps.appMayRun(principal, 'network.site_copy'));
+ assert.ok(!apps.appMayRun(principal, `app.${apps.projectKey(PRJ_B)}.draft`), "another project's namespace");
+ // The route that would take an arbitrary workflow (POST /api/v1/runs) is first-party only.
+ const r = await request(h.base, 'POST', '/api/v1/runs', { tok: appA, body: { workflow: 'live.translate', input: { text: 'bonjour', from: 'fr', to: 'en' } } });
+ assert.strictEqual(r.status, 403);
+});
+
+t.test("a person's BYO key is never selected for an app run", async () => {
+ // The byok pool record resolves and is the pinned route's primary: a service run uses it.
+ const svc = await request(h.base, 'POST', '/api/v1/generate', { tok: service, body: { prompt: 'service work', options: { cache: false } } });
+ assert.strictEqual(svc.status, 201, svc.text);
+ assert.strictEqual(svc.body.run.provenance.provider, 'byokpool', 'a service run may use the pooled key');
+ // An app may not send one, and never lands on the byok pool.
+ const denied = await request(h.base, 'POST', '/api/v1/generate', { tok: appA, body: { prompt: 'x', credential: { subject: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' } } });
+ assert.deepStrictEqual([denied.status, denied.body.code], [403, 'capability.denied']);
+ for (const run of [chatRun.id, genRun.id]) {
+ const providers = (await h.db.prepare('SELECT DISTINCT provider_key FROM requests WHERE run_id = ?').all(run)).map((r) => r.provider_key);
+ assert.deepStrictEqual(providers, ['paid'], `${run}: only the paid provider, never byokpool`);
+ }
+});
+
+let sandboxRun;
+t.test('a sandbox app run uses free and local capacity only', async () => {
+ const r = await request(h.base, 'POST', '/api/v1/generate', { tok: sandboxA, body: { prompt: 'sandbox work', options: { cache: false } } });
+ assert.strictEqual(r.status, 201, r.text);
+ sandboxRun = r.body.run;
+ assert.strictEqual(sandboxRun.provenance.provider, 'freecap', 'the paid provider and the byok pool were never candidates');
+ const providers = (await h.db.prepare('SELECT DISTINCT provider_key FROM requests WHERE run_id = ?').all(sandboxRun.id)).map((x) => x.provider_key);
+ assert.deepStrictEqual(providers, ['freecap']);
+ const why = Object.fromEntries(sandboxRun.explain.candidates.map((c) => [c.provider, c.excluded_reason]));
+ assert.strictEqual(why.paid, 'free_only');
+ assert.strictEqual(why.byokpool, 'byok');
+});
+
+const readings = async (runId) => (await h.db.prepare('SELECT envelope FROM usage_sample_outbox ORDER BY id').all())
+ .map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope))
+ .filter((s) => s.idempotency_key.startsWith(`ai:${runId}:`));
+const freeRows = async (projectId) => (await h.db.prepare('SELECT metric, free_used FROM free_allowance_usage WHERE subject = ? ORDER BY metric').all(`network:project:${projectId}`))
+ .map((r) => [r.metric, Number(r.free_used)]);
+
+t.test('every reading carries the project; the free allowance is per project', async () => {
+ for (const runId of [chatRun.id, genRun.id, sandboxRun.id]) {
+ const samples = await readings(runId);
+ assert.ok(samples.length >= 3, `${runId}: in, cached and out readings`);
+ for (const s of samples) {
+ assert.ok(contracts.validate('platform.usage-sample@1', s).valid, JSON.stringify(s));
+ assert.strictEqual(s.project, PRJ_A, `${s.idempotency_key}: the project`);
+ assert.strictEqual(s.subject, `project:${PRJ_A}`, `${s.idempotency_key}: billed as the project`);
+ }
+ }
+ // Project A's first paid run claimed the paid model's 100-token free allowance, per kind; nothing after it did.
+ assert.deepStrictEqual(await freeRows(PRJ_A), [['input-tokens:paid-m', 100], ['output-tokens:paid-m', 100]]);
+ // Project B has its own allowance: its first run claims 100 again.
+ const b = await request(h.base, 'POST', '/api/v1/generate', { tok: appB, body: { prompt: 'project b work', options: { cache: false } } });
+ assert.strictEqual(b.status, 201, b.text);
+ assert.deepStrictEqual(await freeRows(PRJ_B), [['input-tokens:paid-m', 100], ['output-tokens:paid-m', 100]]);
+ assert.deepStrictEqual(await freeRows(PRJ_A), [['input-tokens:paid-m', 100], ['output-tokens:paid-m', 100]], 'never shared');
+});
+
+// The govern tier budgets are looked up per PROJECT (server/govern.js project = requester): mapping
+// project:<prj_…> to staff admits only that project's app runs; the free tier's 0 budget refuses the rest.
+let h2; let seam2;
+t.test('the govern tier map is keyed by the app\'s project', async () => {
+ seam2 = await seamServer(() => ({ text: 'ok', usage: { input: 10, output: 10 } }));
+ h2 = await boot({ env: {
+ AI_STUB_FALLBACK: 'false', AI_GOVERN_TIERS: '1', AI_GOVERN_HOLD_TOKENS: '1',
+ AI_GOVERN_POLICY_JSON: JSON.stringify({ 'ai-token': { free: { day: 0 }, staff: {} }, 'ai-usd': { free: { day: 0.5 }, staff: {} } }),
+ AI_GOVERN_TIER_MAP: `project:${PRJ_A}=staff`,
+ } });
+ await seedProviders(h2, seam2);
+ const a = await request(h2.base, 'POST', '/api/v1/generate', { tok: appA, body: { prompt: 'staff project', options: { cache: false } } });
+ assert.strictEqual(a.status, 201, `project A is on staff: ${a.text}`);
+ const b = await request(h2.base, 'POST', '/api/v1/generate', { tok: appB, body: { prompt: 'free project', options: { cache: false } } });
+ assert.deepStrictEqual([b.status, b.body.code], [429, 'govern.exceeded'], 'project B is on the free tier with a 0 budget');
+});
+
+// Without tier budgets (AI_GOVERN_TIERS off) nothing bounds a project's metered spend, so every app run,
+// production too, is limited to free and local capacity; a first-party run keeps the whole pool.
+let h3; let seam3;
+t.test('an ungoverned app run uses free and local capacity only', async () => {
+ seam3 = await seamServer(() => ({ text: 'ok', usage: { input: 10, output: 10 } }));
+ h3 = await boot({ env: { AI_STUB_FALLBACK: 'false', BYOK_TEST_KEY: 'sk-test-byok-value' } });
+ await seedProviders(h3, seam3);
+ const r = await request(h3.base, 'POST', '/api/v1/generate', { tok: appA, body: { prompt: 'ungoverned', options: { cache: false } } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.body.run.provenance.provider, 'freecap');
+ const why = Object.fromEntries(r.body.run.explain.candidates.map((c) => [c.provider, c.excluded_reason]));
+ assert.deepStrictEqual([why.byokpool, why.paid], ['byok', 'free_only']);
+ const svc = await request(h3.base, 'POST', '/api/v1/generate', { tok: service, body: { prompt: 'service work', options: { cache: false } } });
+ assert.strictEqual(svc.body.run.provenance.provider, 'byokpool', 'a first-party run is unaffected');
+});
+
+t.test('shutdown', async () => {
+ usageSamples._reset(); await h.stop(); await seam.close();
+ await h2.stop(); await seam2.close();
+ await h3.stop(); await seam3.close();
+});
+
+t.run();
@@ -79,8 +79,12 @@ t.test('namespaces fail closed: a token without ns runs nothing (C-22/C-23 retir
@@ -79,8 +79,12 @@ t.test('namespaces fail closed: a token without ns runs nothing (C-22/C-23 retir
denied(await run(live, 'ai.generate', { prompt: 'hi' }), 'svc:live -> ai.generate');
denied(await run(live, 'ai.generate', { prompt: 'hi' }), 'svc:live -> ai.generate');
denied(await run(live, 'network.other', {}), 'svc:live -> network.other');
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');
+ // A developer app token is refused on every first-party route whatever ids it carries: POST /api/v1/runs
+ // is not one of ai.app.run's routes (the app routes are tested in app-access.test.js).
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');
+ const appRuns = await run(app, 'ai.generate', { prompt: 'hi' });
+ assert.strictEqual(appRuns.status, 403, 'app token on POST /api/v1/runs');
+ assert.strictEqual(appRuns.body.code, 'capability.denied', 'app tokens are never first-party callers');
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.*');
// The retired settings do nothing any more.
// The retired settings do nothing any more.