openvibers/openvibe.network Commits

Codes C3: Codes holds the consent grant for AI, and a run token that names the person (#431)

OP OpenVibers committed 14 days ago · 9065b84e20 · 7 files, +355 −5

* Codes C3: Codes holds the consent grant for AI, and a run token that names the person

Codes stores a person's own provider key in OpenVibe.AI for the people it builds for (ai.credential.manage,
the grant Live and Chat hold for their own people) and its harnesses run through AI with a short-lived run
token (ai.run.create for AI's own workflows: ai.messages for Claude Code, ai.complete for the OpenAI-compatible
harnesses).

The token endpoint grows a run token: with on_behalf_of (a usr_ subject) the token names the person it acts for
and may live up to two hours (ttl). Only a service that holds the consent grant for that audience may mint one,
so the token says whose key and whose budget a run is, and nobody else's.

* Codes C3: a run token needs the person's consent, an audit row and a limit

The consent grant (ai.credential.manage) let any service holding it mint a run token for any person: a
compromised or buggy Codes could spend every person's quota and run their stored keys. Three things now
stand between a service and a person's key:

- The person must have signed in to THAT service: a live refresh-token family of theirs in oauth_tokens
  for (client_id = the service, user_id = the person), not revoked and not expired. A sign-out or a
  revocation ends it, and without one the request is refused 403 consent_required. The subject is mapped
  to the person the way Network does it elsewhere: users.subject_id is the usr_ subject, the integer id
  is the key.
- Every minted run token leaves an audit row (the action, the client, the person, the ttl and the jti,
  never the token) and a row in run_token_mints, the ledger the limit counts.
- At most 120 run tokens an hour for one (service, person); past that the request answers 429 with the
  seconds until the window has room, in the body and in Retry-After.

* Codes C3: the run token ledger forgets what leaves the window

The ledger (run_token_mints) is the limit's memory, not the trail: the audit row keeps every mint, so
what leaves the hour is no longer counted and is dropped. Without that the table would grow with every
run of every person forever.

Changes

ddiff --git a/migrations/0030_run_tokens.sql b/migrations/0030_run_tokens.sql
nnew file mode 100644
iindex 00000000..326119e9
---- /dev/null
++++ b/migrations/0030_run_tokens.sql
@@@ -0,0 +1,14 @@
++-- phase: expand
++-- The ledger of minted run tokens (Codes C3): one row per run token, so the limit on minting counts what
++-- was really minted and a run can be traced afterwards. The token itself is never stored here (only its
++-- jti), and the audit row that says who minted it for whom lives in audit_log.
++CREATE TABLE run_token_mints (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ client_id text COLLATE "C" NOT NULL,
++ subject text COLLATE "C" NOT NULL,
++ user_id bigint,
++ jti text COLLATE "C",
++ minted_at bigint NOT NULL
++);
++
++CREATE INDEX idx_run_token_mints_window ON run_token_mints(client_id, subject, minted_at);
ddiff --git a/server/auth/oauth-routes.js b/server/auth/oauth-routes.js
iindex bb25c318..c02b6053 100644
---- a/server/auth/oauth-routes.js
++++ b/server/auth/oauth-routes.js
@@@ -273,8 +273,12 @@ router.post('/token', async (req, res) => {
clientId: client_id, clientSecret: client_secret, audience: req.body.audience, scope: req.body.scope,
privateKey: req.app.locals.privateKey, issuer: config.jwt.issuer,
agent: req.body.agent, settings: devPolicy.settings(config), ctx: req.ov,
++ // A run token (Codes C3): the person it acts for, and how long it may live.
++ onBehalfOf: req.body.on_behalf_of, ttl: req.body.ttl,
});
res.set('Cache-Control', 'no-store');
++ // A refused rate limit says when to come back (the seconds until the window has room).
++ if (out.body && out.body.retry_after) res.set('Retry-After', String(out.body.retry_after));
return res.status(out.status).json(out.body);
}
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex f2fdff5a..bc798dd1 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -9,12 +9,21 @@
*
* Every internal route takes a service token and nothing
* else. principal_usage counts, per caller and route, each decision.
++ *
++ * A run token (Codes C3): a client_credentials token that names the person it acts for (`on_behalf_of`, a usr_
++ * subject) and may live up to two hours (`ttl`). Only a service that holds the consent grant for that audience
++ * (ai.credential.manage — the grant that lets it store that person's own key in OpenVibe.AI) may mint one.
*/
const crypto = require('crypto');
const { serviceAuth, capabilities, assertValid, http } = require('openvibe-contracts');
++const runTokens = require('./run-tokens');
const TOKEN_TTL_S = 300;
const SELF_AUDIENCE = 'openvibe.network';
++/** A run token (Codes C3) may live up to two hours: a coding attempt with a harness on the person's own machine. */
++const MAX_RUN_TOKEN_TTL_S = 7200;
++const MIN_RUN_TOKEN_TTL_S = 60;
++const USER_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const CHAT_NAMESPACES = ['chat.preferences', 'chat.tts_defaults', 'chat.dm_settings', 'chat.presence_prefs'];
@@@ -429,6 +438,12 @@ const DEFAULT_GRANTS = [
// A person's own provider key for the AI members they set up in Chat rooms (OpenVibe.Chat, a model of their own per
// AI member: OpenAI, Anthropic or any OpenAI-compatible endpoint), stored in OpenVibe.AI with Chat as its owner.
['chat', 'ai.credential.manage', 'openvibe.ai', []],
++ // Codes (Codes C3): it holds the consent of the people it builds for, so it stores their own provider key in
++ // OpenVibe.AI (the grant above's twin for Codes), and their harnesses run through AI with a short-lived run token
++ // that names them (ai.run.create for AI's own workflows: ai.messages for Claude Code, ai.complete for the
++ // OpenAI-compatible harnesses). Nothing else of AI's.
++ ['codes', 'ai.credential.manage', 'openvibe.ai', []],
++ ['codes', 'ai.run.create', 'openvibe.ai', ['ai.messages', 'ai.complete']],
// A streamer's daily AI-viewer budget is an AI quota on their attribution (live:user:<id>), which Live sets.
['live', 'ai.quota.attribution.manage', 'openvibe.ai', []],
// Wave 3: producers publish to OpenVibe.Events (their own source only, enforced by Events).
@@@ -727,8 +742,17 @@ function sameSecret(a, b) {
/**
* grant_type=client_credentials. Returns { status, body } (OAuth error shapes on failure).
* `scope` (space-separated capability ids) narrows the token; omitted = every grant for the audience.
++ *
++ * A run token (Codes C3): with `on_behalf_of` (a usr_ subject) the token names the person it acts for, and may live
++ * up to two hours (`ttl`, at most MAX_RUN_TOKEN_TTL_S). Only a service that holds the consent grant for that audience
++ * (ai.credential.manage — the grant that lets it store that person's key in OpenVibe.AI) may mint one: the token says
++ * whose key and whose budget the run is, so the receiver can charge the person and no one else.
++ *
++ * The grant alone is not enough: the person must have signed in to THAT service (server/identity/run-tokens.js), and
++ * the service must be within its limit for that person. A service that is compromised, or a bug in one, cannot speak
++ * for a person who never signed in to it.
*/
--async function issueToken(db, { clientId, clientSecret, audience, scope, privateKey, issuer, agent, settings, ctx }) {
++async function issueToken(db, { clientId, clientSecret, audience, scope, privateKey, issuer, agent, settings, ctx, onBehalfOf = null, ttl = null }) {
const client = await db.prepare('SELECT client_id, client_secret FROM oauth_clients WHERE client_id = ?').get(String(clientId || ''));
if (!client || !sameSecret(client.client_secret, clientSecret)) return { status: 401, body: { error: 'invalid_client', error_description: 'Invalid client credentials' } };
if (!/^[a-z][a-z0-9-]{1,39}$/.test(client.client_id)) return { status: 400, body: { error: 'unauthorized_client', error_description: 'client id is not a service principal' } };
@@@ -746,15 +770,58 @@ async function issueToken(db, { clientId, clientSecret, audience, scope, private
return { status: 400, body: { error: 'invalid_scope', error_description: `not granted: ${wanted.filter(w => !chosen.find(g => g.capability === w)).join(' ')}` } };
}
if (!chosen.length) return { status: 400, body: { error: 'invalid_scope', error_description: `no grants for audience ${aud}` } };
++ // A run token: the person it acts for, and how long it may live.
++ let person = null;
++ let personUserId = null;
++ let ttlS = TOKEN_TTL_S;
++ if (onBehalfOf !== null && onBehalfOf !== undefined && onBehalfOf !== '') {
++ person = String(onBehalfOf).trim();
++ if (!USER_RE.test(person)) return { status: 400, body: { error: 'invalid_request', error_description: 'on_behalf_of must be a usr_ subject' } };
++ // The consent grant, at this audience: the same one that lets the service store that person's key.
++ if (!grants.find((g) => g.capability === 'ai.credential.manage')) {
++ return { status: 403, body: { error: 'invalid_scope', error_description: 'not consented to act for a person' } };
++ }
++ // The person must have signed in to THIS service: a live refresh-token family of theirs for this client.
++ // Without one the service may not speak for them (a compromised service could otherwise spend every
++ // person's quota and run their stored keys).
++ const consent = await runTokens.consentFor(db, client.client_id, person);
++ if (!consent.ok) {
++ return { status: 403, body: { error: 'consent_required', error_description: 'that person has not signed in to this service' } };
++ }
++ personUserId = consent.userId;
++ // At most 120 run tokens an hour for one (service, person); past that the request is refused.
++ const limit = await runTokens.mintLimit(db, { clientId: client.client_id, subject: person });
++ if (!limit.ok) {
++ return { status: 429, body: { error: 'rate_limited', error_description: 'too many run tokens for this person', retry_after: limit.retryAfter } };
++ }
++ const asked = ttl === null || ttl === undefined || ttl === '' ? TOKEN_TTL_S : Number(ttl);
++ if (!Number.isFinite(asked) || asked < MIN_RUN_TOKEN_TTL_S || asked > MAX_RUN_TOKEN_TTL_S) {
++ return { status: 400, body: { error: 'invalid_request', error_description: `ttl must be ${MIN_RUN_TOKEN_TTL_S}-${MAX_RUN_TOKEN_TTL_S} seconds` } };
++ }
++ ttlS = Math.floor(asked);
++ } else if (ttl !== null && ttl !== undefined && ttl !== '') {
++ return { status: 400, body: { error: 'invalid_request', error_description: 'ttl is for a token that acts for a person (on_behalf_of)' } };
++ }
const now = Math.floor(Date.now() / 1000);
const claims = {
iss: issuer, sub: `svc:${client.client_id}`, actor_type: 'service', aud: [aud],
cap: chosen.map(g => g.capability),
ns: [...new Set(chosen.flatMap(g => g.namespaces))],
-- iat: now, exp: now + TOKEN_TTL_S, jti: `tok_${crypto.randomBytes(12).toString('hex')}`,
++ ...(person ? { on_behalf_of: person } : {}),
++ iat: now, exp: now + ttlS, jti: `tok_${crypto.randomBytes(12).toString('hex')}`,
};
assertValid('identity.service-token-claims@1', claims);
-- return { status: 200, body: { access_token: serviceAuth.signServiceToken(claims, privateKey), token_type: 'Bearer', expires_in: TOKEN_TTL_S, scope: claims.cap.join(' ') } };
++ const token = serviceAuth.signServiceToken(claims, privateKey);
++ if (person) {
++ // The trace of the mint: the audit row (never the token) and the ledger the limit counts. A mint that
++ // cannot be traced is not handed out.
++ try {
++ await runTokens.recordMint(db, { clientId: client.client_id, subject: person, userId: personUserId, jti: claims.jti, ttl: ttlS });
++ } catch (e) {
++ return { status: 503, body: { error: 'server_error', error_description: 'the run token could not be recorded' } };
++ }
++ }
++ return { status: 200, body: { access_token: token, token_type: 'Bearer', expires_in: ttlS, scope: claims.cap.join(' ') } };
}
/** Audit hook for requireCapability: one counter row per (principal, route, auth, outcome). */
ddiff --git a/server/identity/run-tokens.js b/server/identity/run-tokens.js
nnew file mode 100644
iindex 00000000..67625814
---- /dev/null
++++ b/server/identity/run-tokens.js
@@@ -0,0 +1,95 @@
++'use strict';
++/**
++ * Run tokens (Codes C3): the consent, the audit trail and the limit behind a token that names a person.
++ *
++ * A run token is a service token that acts for a person (`on_behalf_of`). Minting one for somebody is only
++ * safe when three things hold, and the caller (server/identity/principals.js issueToken) checks them in
++ * this order:
++ *
++ * 1. The service holds the consent grant for the audience (ai.credential.manage). That is the grant that
++ * lets it store the person's own provider key in OpenVibe.AI.
++ * 2. The person signed in to THAT service: a live refresh-token family in oauth_tokens for
++ * (client_id = the service, user_id = the person). A sign-out, a revoked family or an expired token
++ * ends it, so a service that is compromised cannot mint for a person who never signed in to it, and a
++ * person who signed out of Codes stops Codes from speaking for them.
++ * 3. The service is within its limit for that person: at most RUN_TOKEN_LIMIT_PER_HOUR run tokens an hour
++ * for one (client, person) pair. Past that the request answers 429 with the seconds until the oldest
++ * one leaves the window.
++ *
++ * Every minted run token leaves an audit row — the action, the client, the person, the ttl and the jti, and
++ * never the token itself — and a row in run_token_mints, the ledger the limit counts.
++ */
++
++/** Run tokens one service may mint for one person in an hour. */
++const RUN_TOKEN_LIMIT_PER_HOUR = 120;
++/** The window the limit counts in (ms). */
++const RUN_TOKEN_WINDOW_MS = 60 * 60 * 1000;
++/** The audit action every minted run token leaves. */
++const RUN_TOKEN_AUDIT_ACTION = 'run_token_issued';
++
++/** 'YYYY-MM-DD HH:MM:SS' (UTC) or an ISO string -> ms; NaN when it is not a time. */
++function at(value) {
++ if (!value) return NaN;
++ const text = String(value);
++ const t = Date.parse(/[zZ]|[+-]\d\d:?\d\d$/.test(text) ? text : text.replace(' ', 'T') + 'Z');
++ return Number.isFinite(t) ? t : NaN;
++}
++
++/**
++ * The person behind a usr_ subject: their row in users (the subject is users.subject_id; the integer id
++ * stays the key). → the row, or null when there is no such person.
++ */
++async function personRow(db, subject) {
++ return await db.prepare('SELECT id, subject_id FROM users WHERE subject_id = ?').get(String(subject || ''));
++}
++
++/**
++ * Has this person signed in to this service? A live refresh token of theirs for that client: not revoked,
++ * not expired. The token itself is a hash in the row; nothing here reads it.
++ * → { ok: true, userId } when they have, { ok: false, reason } when they have not.
++ */
++async function consentFor(db, clientId, subject) {
++ const user = await personRow(db, subject);
++ if (!user) return { ok: false, reason: 'no_person' };
++ const rows = await db.prepare('SELECT expires_at FROM oauth_tokens WHERE client_id = ? AND user_id = ? AND revoked = 0')
++ .all(String(clientId || ''), user.id);
++ const now = Date.now();
++ const live = rows.find((r) => { const t = at(r.expires_at); return Number.isFinite(t) && t > now; });
++ return live ? { ok: true, userId: user.id } : { ok: false, reason: 'no_consent', userId: user.id };
++}
++
++/**
++ * May this service mint another run token for this person right now?
++ * → { ok: true, used } while there is room, { ok: false, retryAfter } once the limit is reached (the seconds
++ * until the oldest mint of the window leaves it).
++ */
++async function mintLimit(db, { clientId, subject, now = Date.now() } = {}) {
++ const rows = await db.prepare('SELECT minted_at FROM run_token_mints WHERE client_id = ? AND subject = ? AND minted_at > ? ORDER BY minted_at ASC')
++ .all(String(clientId || ''), String(subject || ''), now - RUN_TOKEN_WINDOW_MS);
++ if (rows.length < RUN_TOKEN_LIMIT_PER_HOUR) return { ok: true, used: rows.length };
++ const oldest = Number(rows[0] && rows[0].minted_at) || now;
++ const retryAfter = Math.max(1, Math.ceil((oldest + RUN_TOKEN_WINDOW_MS - now) / 1000));
++ return { ok: false, retryAfter, used: rows.length };
++}
++
++/**
++ * The trace of one minted run token: the audit row (who, for whom, how long, which jti — never the token)
++ * and the ledger row the limit counts. Both in one transaction, so a token that was minted is always traced.
++ */
++async function recordMint(db, { clientId, subject, userId = null, jti = null, ttl = null, now = Date.now() } = {}) {
++ await db.tx(async () => {
++ await db.prepare('INSERT INTO run_token_mints (client_id, subject, user_id, jti, minted_at) VALUES (?, ?, ?, ?, ?)')
++ .run(String(clientId || ''), String(subject || ''), userId, jti, Math.floor(now));
++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)')
++ .run(userId, RUN_TOKEN_AUDIT_ACTION, JSON.stringify({ client_id: clientId, subject, ttl_seconds: ttl, jti }));
++ // The ledger is the limit's memory, not the trail (the audit row is): what leaves the window is no longer
++ // counted, so it is dropped. The audit row keeps it.
++ await db.prepare('DELETE FROM run_token_mints WHERE client_id = ? AND subject = ? AND minted_at <= ?')
++ .run(String(clientId || ''), String(subject || ''), Math.floor(now) - RUN_TOKEN_WINDOW_MS);
++ });
++}
++
++module.exports = {
++ RUN_TOKEN_LIMIT_PER_HOUR, RUN_TOKEN_WINDOW_MS, RUN_TOKEN_AUDIT_ACTION,
++ personRow, consentFor, mintLimit, recordMint,
++};
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex d17da76c..ff14b906 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand']]);
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
ddiff --git a/test/run-tokens.test.js b/test/run-tokens.test.js
nnew file mode 100644
iindex 00000000..fd62cc40
---- /dev/null
++++ b/test/run-tokens.test.js
@@@ -0,0 +1,170 @@
++'use strict';
++// Run tokens (Codes C3): a client_credentials token that names the person it acts for (on_behalf_of) and may live
++// up to two hours (ttl). Only a service that holds the consent grant for that audience (ai.credential.manage — the
++// grant that lets it store that person's own key in OpenVibe.AI) may mint one; Codes holds it for the people it
++// builds for, and its harnesses run through AI with such a token. The person must have signed in to THAT service
++// (a live refresh-token family of theirs for that client), every mint is audited, and a service may mint at most
++// 120 run tokens an hour for one person.
++// node test/run-tokens.test.js
++const assert = require('assert');
++const crypto = require('crypto');
++const http = require('http');
++const express = require('express');
++const { serviceAuth, validate } = require('openvibe-contracts');
++const { getDb } = require('../server/db/database');
++const principals = require('../server/identity/principals');
++const runTokens = require('../server/identity/run-tokens');
++
++(async () => {
++ const log = console.log; console.log = () => {};
++ const db = getDb();
++ console.log = log;
++ for (const id of ['codes', 'vision']) {
++ await db.prepare("UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?").run(`${id}-secret`, id);
++ }
++ await principals.ensureSchema(db);
++
++ // ── The people ──
++ // The subject is users.subject_id; the integer id is the key (server/identity/subjects.js).
++ const OWNER = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1';
++ const NEVER = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A2';
++ const REVOKED = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';
++ const ELSEWHERE = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A4';
++ const BURST = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A5';
++ const people = {};
++ for (const [name, subject] of [['owner', OWNER], ['never', NEVER], ['revoked', REVOKED], ['elsewhere', ELSEWHERE], ['burst', BURST]]) {
++ await db.prepare('INSERT INTO users (id, username, password_hash, subject_id) VALUES (?, ?, ?, ?)').run(100 + Object.keys(people).length, `rt_${name}`, 'x', subject);
++ people[name] = await db.prepare('SELECT id, subject_id FROM users WHERE subject_id = ?').get(subject);
++ }
++ // A live Codes session (the person signed in to Codes), a revoked family, and a live session of another
++ // service only. The token itself is a hash; the family and the expiry are what matter.
++ const live = (clientId, userId) => db.prepare('INSERT INTO oauth_tokens (token, client_id, user_id, scope, expires_at, family_id, generation, revoked) VALUES (?, ?, ?, ?, ?, ?, 0, ?)')
++ .run(`sha256:${crypto.randomBytes(24).toString('hex')}`, clientId, userId, 'profile theme', new Date(Date.now() + 86400e3).toISOString(), `fam_${clientId}_${userId}`, 0);
++ await live('codes', people.owner.id);
++ await live('codes', people.burst.id);
++ await live('codes', people.revoked.id);
++ await db.prepare('UPDATE oauth_tokens SET revoked = 1, revoked_reason = ? WHERE client_id = ? AND user_id = ?').run('reuse', 'codes', people.revoked.id);
++ await live('vision', people.elsewhere.id);
++
++ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++ const ISSUER = 'https://openvibe.network';
++ const app = express();
++ app.use(express.json());
++ app.use(express.urlencoded({ extended: true }));
++ app.locals.db = db;
++ app.locals.config = { jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };
++ app.locals.privateKey = keys.privateKey;
++ app.locals.publicKey = keys.publicKey;
++ app.use('/oauth', require('../server/auth/oauth-routes'));
++ const server = http.createServer(app);
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const token = (form) => fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', ...form }) })
++ .then(async (r) => ({ status: r.status, body: await r.json().catch(() => null), headers: r.headers }));
++ const claimsOf = (body) => {
++ const c = serviceAuth.verifyServiceToken(body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.ai' });
++ assert.ok(c.ok, c.reason);
++ return c.claims;
++ };
++ const runToken = (onBehalfOf, extra = {}) => token({ client_id: 'codes', client_secret: 'codes-secret', audience: 'openvibe.ai', scope: 'ai.run.create', on_behalf_of: onBehalfOf, ttl: '7200', ...extra });
++
++ try {
++ // ── The grants Codes holds for OpenVibe.AI (Codes C3) ──
++ const has = (svc, cap, aud) => principals.DEFAULT_GRANTS.some(([s, c, a]) => s === svc && c === cap && a === aud);
++ assert.ok(has('codes', 'ai.credential.manage', 'openvibe.ai'), 'Codes holds the consent grant for AI');
++ assert.ok(has('codes', 'ai.run.create', 'openvibe.ai'), 'Codes runs AI workflows for its harnesses');
++ const codesGrants = await principals.grantsFor(db, 'codes', 'openvibe.ai');
++ assert.deepStrictEqual(codesGrants.map((g) => g.capability).sort(), ['ai.credential.manage', 'ai.run.create']);
++
++ // ── A run token: the person it acts for, and how long it lives ──
++ let t = await runToken(OWNER);
++ assert.strictEqual(t.status, 200, JSON.stringify(t.body));
++ assert.strictEqual(t.body.expires_in, 7200);
++ const run = claimsOf(t.body);
++ assert.strictEqual(run.sub, 'svc:codes');
++ assert.strictEqual(run.on_behalf_of, OWNER, 'the token names the person it acts for');
++ assert.strictEqual(run.exp - run.iat, 7200, 'and lives two hours');
++ assert.deepStrictEqual(run.cap, ['ai.run.create']);
++ assert.deepStrictEqual(run.ns, ['ai.messages', 'ai.complete'], 'AI\'s own workflows only');
++ assert.ok(validate('identity.service-token-claims@1', run).valid, 'the claims are a service token\'s');
++
++ // ── Without a person it is an ordinary five-minute service token ──
++ t = await token({ client_id: 'codes', client_secret: 'codes-secret', audience: 'openvibe.ai', scope: 'ai.run.create' });
++ assert.strictEqual(t.status, 200, JSON.stringify(t.body));
++ assert.strictEqual(t.body.expires_in, 300);
++ assert.strictEqual(claimsOf(t.body).on_behalf_of, undefined, 'no person, no on_behalf_of');
++
++ // ── Only a service with the consent grant may mint one ──
++ t = await token({ client_id: 'vision', client_secret: 'vision-secret', audience: 'openvibe.ai', scope: 'ai.run.create', on_behalf_of: OWNER });
++ assert.strictEqual(t.status, 403, 'a service without the consent grant may not act for a person');
++ assert.strictEqual(t.body.error, 'invalid_scope');
++
++ // ── The person must have signed in to THAT service ──
++ t = await runToken(NEVER);
++ assert.strictEqual(t.status, 403, 'a person who never signed in to Codes is not spoken for');
++ assert.strictEqual(t.body.error, 'consent_required');
++ assert.strictEqual(t.body.error_description, 'that person has not signed in to this service');
++ t = await runToken(REVOKED);
++ assert.strictEqual(t.status, 403, 'a revoked session is not consent');
++ assert.strictEqual(t.body.error, 'consent_required');
++ t = await runToken(ELSEWHERE);
++ assert.strictEqual(t.status, 403, 'a session of another service is not consent for this one');
++ assert.strictEqual(t.body.error, 'consent_required');
++
++ // ── The refusals ──
++ t = await token({ client_id: 'codes', client_secret: 'codes-secret', audience: 'openvibe.ai', scope: 'ai.run.create', on_behalf_of: 'not-a-subject' });
++ assert.strictEqual(t.status, 400, 'the person must be a usr_ subject');
++ assert.strictEqual(t.body.error, 'invalid_request');
++ t = await token({ client_id: 'codes', client_secret: 'codes-secret', audience: 'openvibe.ai', scope: 'ai.run.create', on_behalf_of: OWNER, ttl: '99999' });
++ assert.strictEqual(t.status, 400, 'a run token lives at most two hours');
++ assert.strictEqual(t.body.error, 'invalid_request');
++ t = await token({ client_id: 'codes', client_secret: 'codes-secret', audience: 'openvibe.ai', scope: 'ai.run.create', ttl: '7200' });
++ assert.strictEqual(t.status, 400, 'a lifetime without a person is refused');
++ t = await token({ client_id: 'codes', client_secret: 'codes-secret', audience: 'openvibe.ai', scope: 'ai.run.create', on_behalf_of: OWNER, ttl: '7200', agent: 'agt_x' });
++ assert.strictEqual(t.status, 400, 'an agent token is minted by its own route');
++
++ // ── Every mint is audited: the action, the client, the person, the ttl and the jti — never the token ──
++ const audits = await db.prepare("SELECT * FROM audit_log WHERE action = ? ORDER BY id").all(runTokens.RUN_TOKEN_AUDIT_ACTION);
++ assert.ok(audits.length >= 1, 'the mint left an audit row');
++ const audit = audits[audits.length - 1];
++ const details = JSON.parse(audit.details);
++ assert.strictEqual(audit.user_id, people.owner.id, 'the audit row names the person');
++ assert.strictEqual(details.client_id, 'codes');
++ assert.strictEqual(details.subject, OWNER);
++ assert.strictEqual(details.ttl_seconds, 7200);
++ assert.ok(details.jti && details.jti.startsWith('tok_'), 'the audit row names the token\'s jti');
++ assert.ok(!JSON.stringify(audit).includes(run.access_token), 'the audit row never holds the token');
++ assert.ok(!JSON.stringify(audit).includes(String(run.access_token).split('.')[1]), 'nor any part of it');
++ const mints = await db.prepare('SELECT * FROM run_token_mints WHERE client_id = ? AND subject = ?').all('codes', OWNER);
++ assert.strictEqual(mints.length, 1, 'the ledger has one row for the mint');
++ assert.strictEqual(mints[0].jti, details.jti);
++ // The ledger is the limit's memory, not the trail: what leaves the window is dropped.
++ await db.prepare('INSERT INTO run_token_mints (client_id, subject, user_id, jti, minted_at) VALUES (?, ?, ?, ?, ?)')
++ .run('codes', OWNER, people.owner.id, 'tok_old', Math.floor(Date.now() / 1000) - 7200);
++ await runToken(OWNER);
++ const kept = await db.prepare('SELECT * FROM run_token_mints WHERE client_id = ? AND subject = ?').all('codes', OWNER);
++ assert.ok(!kept.find((r) => r.jti === 'tok_old'), 'a mint outside the window is dropped from the ledger');
++ const trail = await db.prepare('SELECT COUNT(*) AS n FROM audit_log WHERE action = ? AND details LIKE ?').get(runTokens.RUN_TOKEN_AUDIT_ACTION, `%${OWNER}%`);
++ assert.ok(Number(trail.n) >= 2, 'the audit trail keeps every mint, dropped or not');
++
++ // ── At most 120 run tokens an hour for one (service, person) ──
++ for (let i = 0; i < runTokens.RUN_TOKEN_LIMIT_PER_HOUR; i++) {
++ const r = await runToken(BURST);
++ assert.strictEqual(r.status, 200, `mint ${i + 1}: ${JSON.stringify(r.body)}`);
++ }
++ t = await runToken(BURST);
++ assert.strictEqual(t.status, 429, 'the 121st run token of the hour is refused');
++ assert.strictEqual(t.body.error, 'rate_limited');
++ assert.ok(t.body.retry_after > 0 && t.body.retry_after <= 3600, 'it says when to come back');
++ assert.strictEqual(t.headers.get('retry-after'), String(t.body.retry_after));
++ const audited = await db.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE action = ? AND details LIKE ?").get(runTokens.RUN_TOKEN_AUDIT_ACTION, `%${BURST}%`);
++ assert.strictEqual(Number(audited.n), runTokens.RUN_TOKEN_LIMIT_PER_HOUR, 'every minted token left its audit row');
++ // Another person is not affected by that person's limit.
++ t = await runToken(OWNER);
++ assert.strictEqual(t.status, 200, 'the limit is per service and person');
++
++ console.log('run-tokens: ok');
++ } finally {
++ server.close();
++ }
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex 7030bc27..ec67d2c2 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand']]);
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');