openvibers/openvibe.network Commits
Codes C3: Codes holds the consent grant for AI, and a run token that names the person (#431)
Changes
nnew file mode 100644@@@ -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);@@@ -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);}@@@ -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, privatereturn { 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). */nnew file mode 100644@@@ -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,++};@@@ -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.nnew file mode 100644@@@ -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); });@@@ -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 twiceassert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
new file mode 100644
new file mode 100644
@@ -0,0 +1,14 @@
@@ -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);
@@ -273,8 +273,12 @@ router.post('/token', async (req, res) => {
@@ -273,8 +273,12 @@ router.post('/token', async (req, res) => {
clientId: client_id, clientSecret: client_secret, audience: req.body.audience, scope: req.body.scope,
clientId: client_id, clientSecret: client_secret, audience: req.body.audience, scope: req.body.scope,
privateKey: req.app.locals.privateKey, issuer: config.jwt.issuer,
privateKey: req.app.locals.privateKey, issuer: config.jwt.issuer,
agent: req.body.agent, settings: devPolicy.settings(config), ctx: req.ov,
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');
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);
return res.status(out.status).json(out.body);
}
}
@@ -9,12 +9,21 @@
@@ -9,12 +9,21 @@
*
*
* Every internal route takes a service token and nothing
* Every internal route takes a service token and nothing
* else. principal_usage counts, per caller and route, each decision.
* 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 crypto = require('crypto');
const { serviceAuth, capabilities, assertValid, http } = require('openvibe-contracts');
const { serviceAuth, capabilities, assertValid, http } = require('openvibe-contracts');
+const runTokens = require('./run-tokens');
const TOKEN_TTL_S = 300;
const TOKEN_TTL_S = 300;
const SELF_AUDIENCE = 'openvibe.network';
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'];
const CHAT_NAMESPACES = ['chat.preferences', 'chat.tts_defaults', 'chat.dm_settings', 'chat.presence_prefs'];
@@ -429,6 +438,12 @@ const DEFAULT_GRANTS = [
@@ -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
// 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.
// AI member: OpenAI, Anthropic or any OpenAI-compatible endpoint), stored in OpenVibe.AI with Chat as its owner.
['chat', 'ai.credential.manage', 'openvibe.ai', []],
['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.
// 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', []],
['live', 'ai.quota.attribution.manage', 'openvibe.ai', []],
// Wave 3: producers publish to OpenVibe.Events (their own source only, enforced by Events).
// Wave 3: producers publish to OpenVibe.Events (their own source only, enforced by Events).
@@ -727,8 +742,17 @@ function sameSecret(a, b) {
@@ -727,8 +742,17 @@ function sameSecret(a, b) {
/**
/**
* grant_type=client_credentials. Returns { status, body } (OAuth error shapes on failure).
* 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.
* `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 || ''));
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 (!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' } };
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
@@ -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(' ')}` } };
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}` } };
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 now = Math.floor(Date.now() / 1000);
const claims = {
const claims = {
iss: issuer, sub: `svc:${client.client_id}`, actor_type: 'service', aud: [aud],
iss: issuer, sub: `svc:${client.client_id}`, actor_type: 'service', aud: [aud],
cap: chosen.map(g => g.capability),
cap: chosen.map(g => g.capability),
ns: [...new Set(chosen.flatMap(g => g.namespaces))],
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);
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). */
/** Audit hook for requireCapability: one counter row per (principal, route, auth, outcome). */
new file mode 100644
new file mode 100644
@@ -0,0 +1,95 @@
@@ -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,
+};
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
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.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
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.
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
new file mode 100644
new file mode 100644
@@ -0,0 +1,170 @@
@@ -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); });
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
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');
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
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');
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');