openvibers/openvibe.ai Commits

AI: developer apps run the six direct operations under ai.app.run (plan T6, ADR-014) (#22)

OP OpenVibers committed 7 Oct 2026 · 9710889d76 · 13 files, +538 −40

All work is complete and verified. Final summary:

## Changes (file:line)

**New**
- `server/apps.js:1-72` — developer-app principals from token claims: `projectKey` (`p`+lowercased ULID, Events' rule), `appPrincipal` (sub `app:app_<ULID>`, `project_id`, `env`, ns check), `appAttribution` (`{service:'network',type:'project',id}`), `appMayRun` (only `ai.chat|generate|summarize|classify|extract|embed` + `app.<project_key>.*`), `DIRECT_OPS`.
- `docs/capabilities-proposal/ai.app.run.json` — the capability proposal, the text above verbatim (public, `project`/`namespace`/`owner` constraints, the six ops + `GET /runs/:id`).
- `test/app-access.test.js:1-191` — 11 end-to-end tests (list below).

**Modified**
- `server/auth.js:40` `CAPS.appRun`; `:44` explicit `PROPOSED = ['ai.app.run']`; `:65-75` `allows()` only falls back for PROPOSED ids, still by the contracts grant rule; `:92-94` `isAppClaims`; `:114` `verify(token, {acceptSandbox})`; `:147` `requireCap` refuses app tokens 403 on every first-party route; `:171-197` new `appOrCap` guard (app tokens judged on `ai.app.run`, sandbox accepted only here, subject = the project).
- `server/api/runs.js:35-36` app guards on the six direct ops + `GET /runs/:id` (`enrich`, POST `/runs`, list, cancel, retry, citations stay first-party); `:39-45` namespace check for apps; `:52-62` app body refuses `credential`/`on_behalf_of` and forces the project attribution; `:90`, `:135`.
- `server/runs.js:159,186` app ctx (`appRun`, `projectId`, `sandbox`); `:223` and `server/quota.js:210` readings carry the project; `server/runs.js:319` sandbox/byok flags reach the pool.
- `server/providers/index.js:167-174` `appOffered` (never a `byok` profile; sandbox = `isFreeProvider` only); `:231,250` pool filters; `:305-321` pinned route drops unoffered providers, explain shows `sandbox`/`byok`; `:323,397` ctx threaded through.
- `server/usage-samples.js:41,47,66,70` readings carry `project` (`prj_…`) on the `platform.usage-sample@1`.
- `server/console/queries.js:15-16` run filter accepts `project:` requesters.
- `docs/capabilities-proposal/service-manifest.ai.json:23` lists `ai.app.run`.
- `README.md:54` capability list; `:104` API table row; `:143-192` "Developer apps" (token, routes, project budgets/free allowance/billing, sandbox, namespaces, BYO, curl); `:389` acceptance row; `:404` security bullet. `.env.example`: no new knobs.
- `test/auth.test.js:82-88` — the old expectation (app token with a first-party cap → `namespace_denied`) now asserts the new policy: app tokens are refused `capability.denied` on first-party routes.

## Tests
`nice -n 15 ov test --no-cache` on the new test plus every test covering the touched files (21 files, incl. auth, proposals, free-allowance, usage-sample, rate-card, govern-tiers, providers, provider-{router,billing,fabric,responses}, placement, breaker, fallback, runs, quota, queue, credentials, attribution-quotas, cache, console): **all pass** (75 s; app-access 11/11, incl. chat/generate/read, 403 without the capability, 403 on first-party routes incl. `enrich` and POST /runs, other project 404, sandbox → free provider with paid/byok excluded, readings carry `prj_…`, free allowance per project, `app.<project_key>.*` rule, byok pool never selected, govern tier map keyed by `project:prj_…`). `npx openvibe-contracts-check --service ai --src server` → ok. Not committed or pushed.

## For Opus
- **Attribution literal.** `project:<prj_…>` isn't an EntityRef in this repo, so: the run's **requester

Changes

ddiff --git a/README.md b/README.md
iindex 6a52935..514f57f 100644
---- a/README.md
++++ b/README.md
@@@ -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's
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
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.
ddiff --git a/docs/capabilities-proposal/ai.app.run.json b/docs/capabilities-proposal/ai.app.run.json
nnew file mode 100644
iindex 0000000..cfe7eb6
---- /dev/null
++++ b/docs/capabilities-proposal/ai.app.run.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/service-manifest.ai.json b/docs/capabilities-proposal/service-manifest.ai.json
iindex 9f4e3f6..a30dda3 100644
---- a/docs/capabilities-proposal/service-manifest.ai.json
++++ b/docs/capabilities-proposal/service-manifest.ai.json
@@@ -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",
ddiff --git a/server/api/runs.js b/server/api/runs.js
iindex b2e50db..6625b7c 100644
---- a/server/api/runs.js
++++ b/server/api/runs.js
@@@ -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;
ddiff --git a/server/apps.js b/server/apps.js
nnew file mode 100644
iindex 0000000..06343ff
---- /dev/null
++++ b/server/apps.js
@@@ -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 };
ddiff --git a/server/auth.js b/server/auth.js
iindex ca5ebcd..c14eeab 100644
---- a/server/auth.js
++++ b/server/auth.js
@@@ -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 };
ddiff --git a/server/console/queries.js b/server/console/queries.js
iindex e69f13b..465cff8 100644
---- a/server/console/queries.js
++++ b/server/console/queries.js
@@@ -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}$/;
ddiff --git a/server/providers/index.js b/server/providers/index.js
iindex a63f7e4..8e0ffdd 100644
---- a/server/providers/index.js
++++ b/server/providers/index.js
@@@ -159,6 +159,18 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
function 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.no
const 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.no
if (!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.no
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 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.no
if (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);
ddiff --git a/server/quota.js b/server/quota.js
iindex 9952a46..18fc558 100644
---- a/server/quota.js
++++ b/server/quota.js
@@@ -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 });
}
}
-- 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);
});
ddiff --git a/server/runs.js b/server/runs.js
iindex abc085f..b499d42 100644
---- a/server/runs.js
++++ b/server/runs.js
@@@ -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 });
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 = { now
const 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.
ddiff --git a/server/usage-samples.js b/server/usage-samples.js
iindex 9892cb8..8e7801e 100644
---- a/server/usage-samples.js
++++ b/server/usage-samples.js
@@@ -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; l
const 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);
ddiff --git a/test/app-access.test.js b/test/app-access.test.js
nnew file mode 100644
iindex 0000000..e58efa3
---- /dev/null
++++ b/test/app-access.test.js
@@@ -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();
ddiff --git a/test/auth.test.js b/test/auth.test.js
iindex 222b3e5..8528fb4 100644
---- a/test/auth.test.js
++++ b/test/auth.test.js
@@@ -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, '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.