openvibers/openvibe.network Commits
Issue agent tokens and publish confirmation decision events
Changes
@@@ -5,8 +5,9 @@ merge `4ec36e1`) and **lane B step 3 built** (`GET /internal/projects/:project_ionly: `server/internal/routes.js:490`; PR #28, merge `e422342`) and **slice 3 built** (delegated grants and modes:`migrations/0017_agent_grants.sql`, the ceiling in `server/developer/agents.js`), **slices 4–5 built** (confirmations,owner side, and budgets: `0018`, `0019`) and **slices 6–7 built** (`GET /internal/agents/:agent` and the four--`/internal/confirmations` routes, `server/internal/routes.js`, with the pin at `openvibe-contracts` v0.90.0); slices 8–9--are still unbuilt (design, §8).++`/internal/confirmations` routes, `server/internal/routes.js`, with the pin at `openvibe-contracts` v0.90.0) and **slices++8–9 built** (agent tokens, `server/developer/agent-tokens.js`; `network.confirmation.changed@1` decision events from++`server/developer/confirmations.js`); the owner notification of slice 9 still waits on an openvibe-shared type (§8).Plan T2, "Projects and grants: projects as the ownership boundary for every resource;`agt_` principals, delegated grants with modes, sensitive capabilities, confirmation requests, budgets (WS-Z2)".Pinned version: `openvibe-contracts` **v0.90.0** (`package.json:33`, since slice 7); every contract claim below was@@@ -348,7 +349,18 @@ An agent token never carries a namespace its host's own token for that audiencenamespaces as well as capabilities.**Fail closed by construction:** a confirm-mode capability is never in `cap`. A receiver that does not know agents sees--only `cap`, so it can never perform a sensitive action without a confirmation; it simply refuses it.++only `cap`, so it can never perform a sensitive action without a confirmation; it simply refuses it. Issuance consumes no++confirmation: `cap_confirm` says the agent may *ask*, and each use is one approval the owning service spends once++(`consume`, §5 step 4, under the agent row's `FOR UPDATE`).++++**Refusals** (built, slice 8; OAuth errors, after the host's own credentials passed): `400 invalid_grant` (the host++binding above, one message, also an unknown or malformed `agt_` id), `400 invalid_request` (no `audience`), `400++invalid_target` (a sandbox agent at an audience not in `DEV_SANDBOX_AUDIENCES`), `400 invalid_scope` (a `scope` naming a++capability the agent does not hold now — no grant, revoked or expired, outside the ceiling, budget `0` — or nothing held++at the audience), `400 unsupported_grant_type` (`agent` with anything but `client_credentials`); a node client id never++hosts an agent (`invalid_grant`). Every issuance and refusal is a `dev_audit` row (`agent.token_issued` with audience,++`cap`, `cap_confirm`, `jti`, expiry; `agent.token_refused` with the error and its internal reason), never a secret or the++token. Network's own guard (`principals.guard`) answers `403 capability.denied` to an agent token on every route.## 5. Auth rules@@@ -420,7 +432,7 @@ the digest. An agent cannot describe its own request to its owner.2. **Standing rule shortcut.** If a live rule covers (agent, capability) — `always`; `until` with `until_at` in thefuture; `session` with the same `session_id` and `until_at` in the future — the confirmation is created already`approved` (`decided_by = 'rule:<id>'`, `rule_id` set, `200`). Every sensitive use still leaves a row.--3. **Decide.** The owner sees it in the inbox (and, once slice 9 lands, a notification). `approve` / `deny` move++3. **Decide.** The owner sees it in the inbox (and, once openvibe-shared has its type, a notification). `approve` / `deny` move`pending` → `approved` / `denied` and set `decided_at`. Anything else is `409 confirmation.not_pending`; a pending rowpast `expires_at` is `409 confirmation.expired` (and is marked expired). Approving with `session`, `until` (`until`required, at most 30 days) or `always` also inserts a `dev_standing_rules` row; `session` lasts at most 24 hours and@@@ -455,7 +467,15 @@ the digest. An agent cannot describe its own request to its owner.or delegated-grant `expires_at` passing before its sweep, a capability leaving the catalog on upgrade).Every transition writes a `dev_audit` row (`confirmation.created|approved|denied|expired|cancelled|used`, project id,--agent id, capability; never `summary` or `details`, which may hold message text).++agent id, capability; never `summary` or `details`, which may hold message text) carrying a++`network.confirmation.changed@1` event (built, slice 9) in the same transaction, so the outbox relays it exactly when the++change commits. Each transition is a conditional `UPDATE` that one writer wins, so each (confirmation, change) is emitted++once: a second sweep, a repeated cancel or a lost race writes nothing. The payload is the contract's (ids, capability,++audience, state, change, `standing_rule`/`rule_id`, `cancel_reason`, `expires_at` on created and approved,++`changed_at`), never the summary, details, request digest or a label; the envelope's actor is the deciding owner for++approve/deny and the system otherwise, `on_behalf_of` the owner. A cascade skips rows already past `expires_at`, and++`cancel` refuses them (`409 confirmation.not_pending`): an approved but expired confirmation is reported `expired` and++stays as it is.### Budgets: who checks what@@@ -587,15 +607,18 @@ and agree).`server/developer/confirmations.js`, mounted under the guard in `server/internal/routes.js`), no `DEFAULT_GRANTS`row yet (none until a receiver ships), internal cases in `test/confirmations.test.js`. Checks: `npm test`,`npm run test:pg`, `node scripts/contracts-drift.js`.--8. **Agent tokens** — *blocked on Contracts publishing the agent claims (gap 1).* The `agent` branch in++8. **Agent tokens** (built). `server/developer/agent-tokens.js` (`mint`), the `agent` branch in`server/developer/tokens.js` and `principals.issueToken`, `cap`/`cap_confirm` split, `test/agent-tokens.test.js`.-- Network's own guard keeps refusing agent tokens on its routes until a route needs them. Checks: `npm test`,++ Network's own guard refuses agent tokens on its routes until a route needs them. No migration. Checks: `npm test`,++ `npm run test:pg`.++9. **Decision events** (built) **and the owner notification** (*still blocked on an openvibe-shared notification++ type*). Audit rows carry `network.confirmation.changed@1` (`store.audit` takes the envelope's `actor` and++ `on_behalf_of`), `test/confirmation-events.test.js`. Still to do: `notification-service.js` notifies the owner on++ create (payload rules of `docs/notification-digest.md`: never the summary). No migration. Checks: `npm test`,`npm run test:pg`.--9. **Decision events and the owner notification** — *blocked on the `network.confirmation.changed@1` payload and an-- openvibe-shared notification type.* Audit rows gain `event`; `notification-service.js` notifies the owner on create-- (payload rules of `docs/notification-digest.md`: never the summary). Checks: `npm test`, `npm run test:pg`.--Slices 2–7 are built, in this order (2–5 each added the next migration number; 6–7 need none); 8–9 wait only on releases outside Network and are flagged by `scripts/contracts-drift.js`.++Slices 2–9 are built, in this order (2–5 each added the next migration number; 6–9 need none); only the slice-9 owner++notification waits on a release outside Network.## 9. Unresolved@@@ -12,6 +12,7 @@ const subjects = require('../identity/subjects');const { staffClaims } = require('./staff-claims');const principals = require('../identity/principals');const devTokens = require('../developer/tokens');++const devPolicy = require('../developer/policy');const oidc = require('./oidc');const refreshTokens = require('./refresh-tokens');const router = express.Router();@@@ -220,7 +221,7 @@ router.post('/token', async (req, res) => {// Developer apps (app_<ULID>): client_credentials or authorization_code + PKCE, own credential store.if (devTokens.isAppClient(client_id)) {-- const out = await devTokens.handleTokenRequest(db, req.body, { privateKey: req.app.locals.privateKey, issuer: config.jwt.issuer, config });++ const out = await devTokens.handleTokenRequest(db, req.body, { privateKey: req.app.locals.privateKey, issuer: config.jwt.issuer, config, ctx: req.ov });res.set('Cache-Control', 'no-store');return res.status(out.status).json(out.body);}@@@ -230,6 +231,7 @@ router.post('/token', async (req, res) => {if (grant_type === 'client_credentials') {// A paired machine (node principal, docs/t2-cells-and-node-principal.md section 4.3): its own credential store.if (/^nod_/.test(client_id)) {++ if (req.body.agent !== undefined) return res.status(400).json({ error: 'invalid_grant', error_description: 'nodes host no agents' });const out = await require('../registry/node-principals').issueNodeToken(db, {clientId: client_id, clientSecret: client_secret, audience: req.body.audience,privateKey: req.app.locals.privateKey, issuer: config.jwt.issuer,@@@ -239,6 +241,7 @@ router.post('/token', async (req, res) => {const out = await principals.issueToken(db, {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,});res.set('Cache-Control', 'no-store');return res.status(out.status).json(out.body);nnew file mode 100644@@@ -0,0 +1,122 @@++'use strict';++/**++ * Agent tokens (plan T2 WS-Z2 slice 8, docs/t2-projects-and-grants.md section 4 "Agent tokens"): the agent's host++ * authenticates at /oauth/token as itself — an app with its client_secret (./tokens.js), a service through++ * principals.issueToken — and adds `agent=agt_…`, `audience` and an optional `scope`.++ *++ * Claims (identity.service-token-claims@1, v0.90.0): sub agent:agt_<ULID>, actor_type agent, aud [audience], cap (the++ * agent's active, unexpired auto grants), cap_confirm (its confirm ones; omitted when none), ns, project_id, env++ * (the agent's environment), on_behalf_of (the owner), act { sub: app:app_… | svc:<host> }, iat, exp (+300 s), jti.++ * Both lists are the delegated grants ∩ the host's ceiling now (agents.ceiling) minus a budget of 0, so a token never++ * holds more than its host may do for the project. A confirm-mode capability is never in `cap`: each use needs an++ * approved confirmation that the owning service consumes (/internal/confirmations), so a receiver that does not know++ * agents refuses it. Signed by Network's RS256 key (the JWKS one), never a shared secret; no refresh.++ *++ * Refusals (OAuth errors; the host's own credentials are checked by the caller first):++ * 400 invalid_grant one message for every host-binding failure (unknown agent, another host's agent, app of++ * another project or environment, revoked app, archived project, agent paused or revoked,++ * owner no longer a member, banned or deleted), so a host cannot probe others' agents++ * 400 invalid_request no audience++ * 400 invalid_target a sandbox agent asking for an audience that does not accept sandbox tokens++ * 400 invalid_scope a scope the agent does not hold now (no grant, revoked or expired, outside the host's++ * ceiling, budget 0), or nothing at all for the audience++ * Every issuance and refusal writes a dev_audit row (agent.token_issued / agent.token_refused: audience, cap,++ * cap_confirm, jti, expiry, or the error and the internal reason); never a secret or the token.++ */++const crypto = require('crypto');++const { serviceAuth, assertValid, capabilities } = require('openvibe-contracts');++const store = require('./store');++const agents = require('./agents');++const { projectNamespaces, TOKEN_TTL_S } = require('./tokens');++++const BINDING = 'unknown agent, or not one this client may act for';++++/**++ * Mint a token for `agentId` on behalf of `host` — { kind: 'app', app } or { kind: 'service', clientId } — already++ * authenticated by the caller. → { status, body } (an OAuth token response or error).++ */++async function mint(db, { agentId, host, audience, scope, privateKey, issuer, settings, ctx }) {++ const hostSub = host.kind === 'app' ? `app:${host.app.id}` : `svc:${host.clientId}`;++ const agentOk = agents.AGENT_ID_RE.test(String(agentId || ''));++ const agent = agentOk ? await db.prepare('SELECT * FROM dev_agents WHERE id = ?').get(String(agentId)) : null;++ const refuse = async (error, description, reason, detail = {}) => {++ await store.audit(db, { projectId: agent ? agent.project_id : null, actor: hostSub, action: 'agent.token_refused',++ target: agentOk ? `agent:${agentId}` : null, detail: { error, reason, audience: audience ? String(audience).slice(0, 100) : undefined, ...detail }, ctx });++ return { status: 400, body: { error, error_description: description } };++ };++ const binding = await bindingFailure(db, agent, host);++ if (binding) return await refuse('invalid_grant', BINDING, binding);++ const aud = String(audience || '').trim();++ if (!aud || !/^[a-z0-9.-]+$/.test(aud)) return await refuse('invalid_request', 'audience is required', 'no audience');++ if (agent.environment === 'sandbox' && !settings.sandboxAudiences.has(aud)) {++ return await refuse('invalid_target', `${aud} does not accept sandbox tokens`, 'sandbox audience');++ }++++ const held = await heldNow(db, agent, aud, settings);++ const wanted = scope ? String(scope).split(/\s+/).filter(Boolean) : null;++ const missing = wanted ? wanted.filter((w) => !held.has(w)) : [];++ if (missing.length) return await refuse('invalid_scope', `not delegated: ${missing.join(' ')}`, 'scope not held', { missing });++ const chosen = wanted ? [...new Set(wanted)].sort() : [...held.keys()].sort();++ if (!chosen.length) return await refuse('invalid_scope', `no delegated grants for audience ${aud}`, 'nothing held');++ const cap = chosen.filter((c) => held.get(c) === 'auto');++ const capConfirm = chosen.filter((c) => held.get(c) === 'confirm');++++ const now = Math.floor(Date.now() / 1000);++ const claims = {++ iss: issuer, sub: `agent:${agent.id}`, actor_type: 'agent', aud: [aud], cap,++ ...(capConfirm.length ? { cap_confirm: capConfirm } : {}),++ ns: await namespaces(db, agent, aud, chosen),++ project_id: agent.project_id, env: agent.environment, on_behalf_of: agent.owner_subject, act: { sub: hostSub },++ iat: now, exp: now + TOKEN_TTL_S, jti: `tok_${crypto.randomBytes(12).toString('hex')}`,++ };++ assertValid('identity.service-token-claims@1', claims);++ const token = serviceAuth.signServiceToken(claims, privateKey);++ await store.audit(db, { projectId: agent.project_id, actor: hostSub, action: 'agent.token_issued', target: `agent:${agent.id}`,++ detail: { audience: aud, cap, cap_confirm: capConfirm, jti: claims.jti, expires_at: new Date(claims.exp * 1000).toISOString() }, ctx });++ return { status: 200, body: { access_token: token, token_type: 'Bearer', expires_in: TOKEN_TTL_S, scope: chosen.join(' ') } };++}++++/** Why `host` may not mint for `agent` now (an internal reason for the audit row), or null. */++async function bindingFailure(db, agent, host) {++ if (!agent) return 'unknown agent';++ if (host.kind === 'app') {++ if (agent.host_kind !== 'app' || agent.host_app_id !== host.app.id) return 'not the agent\'s host';++ // The 0016 foreign key already ties these; re-read so a future change to it cannot widen tokens.++ if (host.app.project_id !== agent.project_id || host.app.environment !== agent.environment) return 'host app of another project or environment';++ if (host.app.revoked_at) return 'host app revoked';++ } else if (agent.host_kind !== 'service' || agent.host_service !== host.clientId) return 'not the agent\'s host';++ if (agent.status !== 'active') return `agent ${agent.status}`;++ const project = await db.prepare('SELECT archived_at FROM dev_projects WHERE id = ?').get(agent.project_id);++ if (!project || project.archived_at) return 'project archived';++ if (!await store.memberRole(db, agent.project_id, agent.owner_subject)) return 'owner no longer a member';++ const owner = await db.prepare('SELECT is_banned, deleted_at FROM users WHERE subject_id = ?').get(agent.owner_subject);++ if (!owner || Number(owner.is_banned) || owner.deleted_at) return 'owner account not active';++ return null;++}++++/**++ * The capabilities the agent holds at `audience` now → Map(capability → 'auto' | 'confirm'): its active, unexpired++ * delegated grants, inside the host's ceiling, without a budget of 0 (the per-capability off switch).++ */++async function heldNow(db, agent, audience, settings) {++ const now = new Date().toISOString();++ const grants = (await db.prepare("SELECT * FROM dev_agent_grants WHERE agent_id = ? AND audience = ? AND status = 'active' ORDER BY capability").all(agent.id, audience))++ .filter((g) => !g.expires_at || g.expires_at > now);++ if (!grants.length) return new Map();++ const ceiling = await agents.ceiling(db, agent, audience, settings);++ const off = new Set((await db.prepare('SELECT capability FROM dev_agent_budgets WHERE agent_id = ? AND limit_value = 0').all(agent.id)).map((b) => b.capability));++ return new Map(grants.filter((g) => ceiling.has(g.capability) && !off.has(g.capability)).map((g) => [g.capability, agents.effectiveMode(g)]));++}++++/**++ * ns: an app host's agent names what the app's own token names. A service host's agent names the project's namespaces++ * that the host's own principal_grants rows for these capabilities cover (contracts' namespace matcher); none → [].++ */++async function namespaces(db, agent, audience, chosen) {++ const project = projectNamespaces(agent.project_id);++ if (agent.host_kind === 'app') return project;++ const rows = (await require('../identity/principals').grantsFor(db, agent.host_service, audience)).filter((g) => chosen.includes(g.capability));++ return project.filter((n) => rows.some((g) => capabilities.namespaceAllowed(g.namespaces, n)));++}++++module.exports = { mint };@@@ -23,7 +23,7 @@* - Budgets (slice 5, migrations/0019_agent_budgets.sql): the owner or an admin+ caps a capability the agent holds,* never above the project's quota for it; revoking the grant deletes its budget. The owning service meters.* - The owning service reads what it enforces at GET /internal/agents/:agent (slice 6, internalAgentView): its own-- * audience's grants and their budgets, never another service's. Agent tokens come in a later slice (section 8).++ * audience's grants and their budgets, never another service's. Its host mints its tokens (slice 8, ./agent-tokens.js).*/const { ids, capabilities } = require('openvibe-contracts');const policy = require('./policy');@@@ -453,6 +453,6 @@ async function internalAgentView(db, agentId, audience) {module.exports = {ensureSchema, AGENT_ID_RE, agentView, loadAgent, atLeast, listAgents, getAgent, createAgent, renameAgent, changeStatus, revokeWhere,-- effectiveMode, withinHost, listGrants, putGrant, deleteGrant, revokeBeyondHost,++ effectiveMode, ceiling, withinHost, listGrants, putGrant, deleteGrant, revokeBeyondHost,budgetView, listBudgets, setBudget, deleteBudget, internalAgentView,};@@@ -16,10 +16,14 @@* - Every change that takes authority away calls `cancelFor` in its own transaction: the pending and approved-unused* rows it affects become `cancelled` with the cause, and the matching standing rules are revoked. Resuming an agent* revives nothing.-- * - Every transition is a dev_audit row without an event (Contracts has no payload yet, slice 9). Audit rows never-- * carry `summary` or `details`, which may hold message text.++ * - Every transition is a dev_audit row carrying a network.confirmation.changed@1 event (slice 9), written with the++ * state change in one transaction, so the Events outbox (store.audit) relays it exactly when the change commits.++ * Each transition is a conditional UPDATE that only one writer wins, so a confirmation emits each change once:++ * a second sweep, a repeated cancel or a lost race writes nothing. A standing-rule approval is one `created`++ * event in state `approved` with its `rule_id`. Neither the audit row nor the event carries `summary`, `details`,++ * the request digest or a label; the event names the owner only as the envelope's `on_behalf_of`.*/--const { ids, validate, capabilities } = require('openvibe-contracts');++const { ids, validate, assertValid, capabilities } = require('openvibe-contracts');const policy = require('./policy');const store = require('./store');const agents = require('./agents');@@@ -64,9 +68,28 @@ function ruleView(r) {const agentContext = (a) => (a ? { name: a.name, project_id: a.project_id, host: agents.agentView(a).host } : null);--async function audit(db, c, action, { actor, detail, ctx } = {}) {++async function audit(db, c, action, { actor, detail, ctx, event } = {}) {await store.audit(db, { projectId: c.project_id, actor: actor || 'system:network', action, target: `confirmation:${c.id}`,-- detail: { agent: c.agent_id, capability: c.capability, ...detail }, ctx });++ detail: { agent: c.agent_id, capability: c.capability, ...detail }, ctx, event });++}++++const SYSTEM = { type: 'system', id: 'network' };++++/**++ * The network.confirmation.changed@1 event of one transition of row `c`: `change` is what happened, `state` the state++ * after it. The envelope's actor is the deciding owner for approve/deny (the audit row's user) and the system for++ * everything else; on_behalf_of is the owner. expires_at rides on `created` and `approved`, as the contract's fixtures.++ */++function changed(c, change, state, { standingRule, ruleId, cancelReason, at = nowIso(), byOwner = false } = {}) {++ const payload = {++ confirmation_id: c.id, agent_id: c.agent_id, project_id: c.project_id, capability: c.capability, audience: c.audience, state, change,++ ...(standingRule ? { standing_rule: standingRule } : {}), ...(ruleId != null ? { rule_id: Number(ruleId) } : {}),++ ...(cancelReason ? { cancel_reason: cancelReason } : {}), ...(change === 'created' || change === 'approved' ? { expires_at: c.expires_at } : {}),++ changed_at: at,++ };++ assertValid('network.confirmation.changed@1', payload);++ return { type: 'network.confirmation.changed', subject: { type: 'confirmation', id: c.id }, payload,++ ...(byOwner ? {} : { actor: SYSTEM }), on_behalf_of: { type: 'user', id: c.owner_subject } };}// ── Authority, as at creation and again at consume ─────────────@@@ -127,7 +150,7 @@ async function create(db, { agentId, capability, audience, summary, details, resAND (rule = 'always' OR (rule = 'until' AND until_at > ?) OR (rule = 'session' AND session_id = ? AND until_at > ?)) ORDER BY id LIMIT 1`).get(agent.id, capability, now, sessionId == null ? null : String(sessionId), now);const row = {-- id, project_id: agent.project_id, agent_id: agent.id, owner_subject: agent.owner_subject, capability, summary, details: detailsJson,++ id, project_id: agent.project_id, agent_id: agent.id, owner_subject: agent.owner_subject, capability, audience, summary, details: detailsJson,resources: JSON.stringify(resources || []), state: rule ? 'approved' : 'pending', standing_rule: rule ? rule.rule : null, expires_at: new Date(Date.now() + ttl * 1000).toISOString(),created_at: now, decided_at: rule ? now : null,};@@@ -138,7 +161,8 @@ async function create(db, { agentId, capability, audience, summary, details, resVALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, row.project_id, row.agent_id, row.owner_subject, capability, audience, summary, row.details, row.resources, row.state, row.standing_rule,sessionId == null ? null : String(sessionId), requestDigest, rule ? rule.id : null, row.expires_at, now, row.decided_at, rule ? `rule:${rule.id}` : null);-- await audit(db, row, 'confirmation.created', { actor: `service:${audience}`, detail: { state: row.state }, ctx });++ await audit(db, row, 'confirmation.created', { actor: `service:${audience}`, detail: { state: row.state }, ctx,++ event: changed(row, 'created', row.state, { ruleId: rule ? rule.id : null, at: now }) });if (rule) await audit(db, row, 'confirmation.approved', { actor: `rule:${rule.id}`, detail: { rule_id: Number(rule.id), standing_rule: rule.rule }, ctx });});return confirmationView(await db.prepare('SELECT * FROM dev_confirmations WHERE id = ?').get(id));@@@ -167,7 +191,7 @@ async function consume(db, { id, audience, requestDigest }, { ctx, settings } =const r = await db.prepare("UPDATE dev_confirmations SET used_at = ? WHERE id = ? AND audience = ? AND state = 'approved' AND used_at IS NULL AND expires_at > ? AND request_digest = ?").run(now, c.id, audience, now, String(requestDigest));if (r.changes !== 1) fail(409, 'confirmation.used', 'the confirmation was already used');-- await audit(db, c, 'confirmation.used', { actor: `service:${audience}`, ctx });++ await audit(db, c, 'confirmation.used', { actor: `service:${audience}`, ctx, event: changed(c, 'used', 'approved', { at: now }) });return { confirmation: confirmationView({ ...c, used_at: now }), used_at: now };});}@@@ -196,26 +220,31 @@ async function cancel(db, { id, audience }, { ctx } = {}) {const state = c.used_at ? 'used' : stateOf(c);if (state === 'cancelled') return;if (state !== 'pending' && state !== 'approved') fail(409, 'confirmation.not_pending', `confirmation is ${state}`);-- await db.prepare(`UPDATE dev_confirmations SET state = 'cancelled', cancel_reason = 'service' WHERE id = ? AND ${LIVE}`).run(c.id);-- await audit(db, c, 'confirmation.cancelled', { actor: `service:${audience}`, detail: { from: c.state, reason: 'service' }, ctx });++ const r = await db.prepare(`UPDATE dev_confirmations SET state = 'cancelled', cancel_reason = 'service' WHERE id = ? AND ${LIVE}`).run(c.id);++ if (!r.changes) return;++ await audit(db, c, 'confirmation.cancelled', { actor: `service:${audience}`, detail: { from: c.state, reason: 'service' }, ctx,++ event: changed(c, 'cancelled', 'cancelled', { cancelReason: 'service' }) });});return { confirmation: confirmationView(await serviceRow(db, id, audience)) };}/**-- * The cascade, in the caller's transaction: cancel the pending and approved-unused confirmations of `agentId` (only-- * those for `capability` when given) with `reason`, one audit row each, and revoke the matching live standing rules.++ * The cascade, in the caller's transaction: cancel the pending and approved-unused confirmations of `agentId` still++ * inside their ttl (only those for `capability` when given) with `reason`, one audit row and event each, and revoke the++ * matching live standing rules.* → how many confirmations were cancelled.*/async function cancelFor(db, { agentId, capability = null, reason, actor = 'system:network' }, { ctx } = {}) {const capSql = capability ? ' AND capability = ?' : '';const params = capability ? [agentId, capability] : [agentId];let n = 0;-- for (const c of await db.prepare(`SELECT * FROM dev_confirmations WHERE agent_id = ?${capSql} AND ${LIVE} ORDER BY id`).all(...params)) {-- const r = await db.prepare(`UPDATE dev_confirmations SET state = 'cancelled', cancel_reason = ? WHERE id = ? AND ${LIVE}`).run(reason, c.id);++ // A row past expires_at is already expired (stateOf): the sweep records a pending one, an approved one stays spent-proof.++ const at = nowIso();++ for (const c of await db.prepare(`SELECT * FROM dev_confirmations WHERE agent_id = ?${capSql} AND ${LIVE} AND expires_at > ? ORDER BY id`).all(...params, at)) {++ const r = await db.prepare(`UPDATE dev_confirmations SET state = 'cancelled', cancel_reason = ? WHERE id = ? AND ${LIVE} AND expires_at > ?`).run(reason, c.id, at);if (!r.changes) continue;n++;-- await audit(db, c, 'confirmation.cancelled', { actor, detail: { from: c.state, reason }, ctx });++ await audit(db, c, 'confirmation.cancelled', { actor, detail: { from: c.state, reason }, ctx, event: changed(c, 'cancelled', 'cancelled', { cancelReason: reason }) });}const t = nowIso();for (const rule of await db.prepare(`SELECT * FROM dev_standing_rules WHERE agent_id = ?${capSql} AND revoked_at IS NULL ORDER BY id`).all(...params)) {@@@ -242,7 +271,7 @@ async function expireDue(db) {const r = await db.prepare("UPDATE dev_confirmations SET state = 'expired' WHERE id = ? AND state = 'pending' AND expires_at <= ?").run(c.id, nowIso());if (!r.changes) return;n++;-- await audit(db, c, 'confirmation.expired');++ await audit(db, c, 'confirmation.expired', { event: changed(c, 'expired', 'expired') });});}return n;@@@ -302,8 +331,8 @@ async function decide(db, actor, id, action, body = {}, { ctx } = {}) {const c = await ownRow(db, actor, id, { lock: true });const now = nowIso();if (c.state === 'pending' && c.expires_at <= now) {-- await db.prepare("UPDATE dev_confirmations SET state = 'expired' WHERE id = ? AND state = 'pending'").run(c.id);-- await audit(db, c, 'confirmation.expired', { ctx });++ const r = await db.prepare("UPDATE dev_confirmations SET state = 'expired' WHERE id = ? AND state = 'pending'").run(c.id);++ if (r.changes) await audit(db, c, 'confirmation.expired', { ctx, event: changed(c, 'expired', 'expired', { at: now }) });return true;}if (c.state !== 'pending') fail(409, 'confirmation.not_pending', `confirmation is ${stateOf(c, now)}`);@@@ -319,7 +348,8 @@ async function decide(db, actor, id, action, body = {}, { ctx } = {}) {const to = action === 'approve' ? 'approved' : 'denied';await db.prepare("UPDATE dev_confirmations SET state = ?, standing_rule = ?, decided_at = ?, decided_by = ? WHERE id = ? AND state = 'pending'").run(to, standing, now, actor.label, c.id);-- await audit(db, c, `confirmation.${to}`, { actor: actor.label, detail: standing ? { standing_rule: standing, rule_id: ruleId == null ? undefined : Number(ruleId) } : {}, ctx });++ await audit(db, c, `confirmation.${to}`, { actor: actor.label, detail: standing ? { standing_rule: standing, rule_id: ruleId == null ? undefined : Number(ruleId) } : {}, ctx,++ event: changed(c, to, to, { standingRule: standing, ruleId, at: now, byOwner: true }) });return false;});if (expired) fail(409, 'confirmation.expired', 'the confirmation has expired');@@@ -47,7 +47,8 @@ function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); no/*** Append one audit row. `actor` is a subject string ('user:usr_…', 'system:network').-- * With `event` = { type, subject: { type, id }, payload }, the row also carries an event envelope.++ * With `event` = { type, subject: { type, id }, payload, actor?, on_behalf_of? }, the row also carries an event++ * envelope; its actor is the row's user, else the system, unless `event.actor` (a SubjectRef) says otherwise.* `detail` must never contain secret material (callers pass ids, hints and names only).*/async function audit(db, { projectId, actor, action, target, detail, ctx, event }) {@@@ -57,7 +58,8 @@ async function audit(db, { projectId, actor, action, target, detail, ctx, eventenvelope = {event_id: ids.newId('event'),event_type: event.type, version: 1, source: 'network',-- actor: atype === 'user' ? { type: 'user', id: aid } : { type: 'system', id: 'network' },++ actor: event.actor || (atype === 'user' ? { type: 'user', id: aid } : { type: 'system', id: 'network' }),++ ...(event.on_behalf_of ? { on_behalf_of: event.on_behalf_of } : {}),timestamp: nowIso(), visibility: 'internal', subject: event.subject, payload: event.payload || {},};if (ctx && /^[0-9a-f]{32}$/.test(ctx.traceId || '')) envelope.trace_id = ctx.traceId;@@@ -19,6 +19,8 @@* (DEV_SANDBOX_AUDIENCES); receivers also refuse env=sandbox unless they opted in.* No refresh tokens: a revoked credential or app stops new tokens at once, and issued tokens end* within their 5-minute lifetime.++ * With `agent=agt_…` (client_credentials only) the app asks for a token of an agent it hosts instead++ * (./agent-tokens.js, plan T2 WS-Z2 slice 8); without it, app tokens are unchanged.*/const crypto = require('crypto');const { serviceAuth, assertValid } = require('openvibe-contracts');@@@ -155,15 +157,20 @@ async function authenticate(db, app, clientSecret) {}/** /oauth/token for an app client id. Returns { status, body }. */--async function handleTokenRequest(db, body, { privateKey, issuer, config }) {++async function handleTokenRequest(db, body, { privateKey, issuer, config, ctx }) {const settings = policy.settings(config);const found = await usableApp(db, body.client_id);if (found.error) return found.error;const { app, project } = found;++ if (body.agent !== undefined && body.grant_type !== 'client_credentials') return oauthError(400, 'unsupported_grant_type', 'agent tokens use client_credentials');if (body.grant_type === 'client_credentials') {if (app.client_type !== 'confidential') return oauthError(400, 'unauthorized_client', 'public apps use authorization_code with PKCE');const bad = await authenticate(db, app, body.client_secret);if (bad) return bad;++ // The app hosts an agent (slice 8): its token, not the app's.++ if (body.agent !== undefined) {++ return await require('./agent-tokens').mint(db, { agentId: body.agent, host: { kind: 'app', app }, audience: body.audience, scope: body.scope, privateKey, issuer, settings, ctx });++ }return await mint({ app, project, audience: body.audience, scope: body.scope, privateKey, issuer, settings, db });}if (body.grant_type === 'authorization_code') {@@@ -308,10 +308,15 @@ 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.*/--async function issueToken(db, { clientId, clientSecret, audience, scope, privateKey, issuer }) {++async function issueToken(db, { clientId, clientSecret, audience, scope, privateKey, issuer, agent, settings, ctx }) {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' } };++ // The service hosts an agent (plan T2 WS-Z2 slice 8): the agent's token, bounded by this service's own grants.++ if (agent !== undefined) {++ return await require('../developer/agent-tokens').mint(db, { agentId: agent, host: { kind: 'service', clientId: client.client_id }, audience, scope, privateKey, issuer,++ settings: settings || require('../developer/policy').settings(), ctx });++ }const aud = String(audience || '').trim();if (!aud) return { status: 400, body: { error: 'invalid_request', error_description: 'audience is required' } };const grants = await grantsFor(db, client.client_id, aud);@@@ -384,6 +389,12 @@ function guard(capability, { ownApp, namespace } = {}) {}check(req, res, () => {const principal = req.principal;++ // Agent tokens (plan T2 WS-Z2 slice 8) act at the owning services; no Network route takes one yet.++ if (principal && /^agent:/.test(String(principal.sub))) {++ audits.push(record({ req, capability, principal, allowed: false, code: 'capability.denied' }));++ require('../observability').principalDenied({ req, code: 'capability.denied' });++ return http.sendProblem(res, 403, 'capability.denied', { detail: 'agent tokens are not accepted here', ctx: req.ov });++ }// Developer sandbox tokens (env: sandbox) are refused unless Network opted in as an audience// (DEV_SANDBOX_AUDIENCES); the signature was verified by check() above.if (principal) {nnew file mode 100644@@@ -0,0 +1,233 @@++'use strict';++// Agent tokens (plan T2 WS-Z2 slice 8, docs/t2-projects-and-grants.md section 4 "Agent tokens";++// server/developer/agent-tokens.js): the host mints with agent=agt_… at /oauth/token. The claims validate as++// identity.service-token-claims@1 (v0.90.0) and carry exactly sub/actor_type agent, aud, cap (auto grants),++// cap_confirm (confirm grants), ns, project_id, env, on_behalf_of, act, iat/exp/jti; RS256 with Network's key.++// Refusals: another host, another project's app, an unknown agent, a paused/revoked agent, a banned owner, an archived++// project (400 invalid_grant, one message); a revoked or expired grant, a budget of 0, a capability outside the host's++// ceiling (left out; asked by scope: 400 invalid_scope); a sandbox agent at an audience without sandbox tokens++// (invalid_target). A confirm-mode capability is never in cap: its use needs an approval the owning service consumes++// once, even under concurrency. Service-host namespaces; app tokens without `agent` are unchanged; Network's own++// guard refuses agent tokens; every issue and refusal is audited without secrets.++// node test/agent-tokens.test.js++const assert = require('assert');++const crypto = require('crypto');++const http = require('http');++const express = require('express');++const jwt = require('jsonwebtoken');++const { validate, serviceAuth } = require('openvibe-contracts');++const { getDb } = require('../server/db/database');++const subjects = require('../server/identity/subjects');++const confirmations = require('../server/developer/confirmations');++++(async () => {++const log = console.log; console.log = () => {};++const db = getDb();++console.log = log;++await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('actor', 'actor-secret', 'OpenVibe.Actor', '[]', 1)").run();++await db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();++await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES (10, 'owner', 'x', 'user'), (14, 'staff', 'x', 'admin')`).run();++const owner = await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = 10').get());++++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 = { baseUrl: ISSUER, loginUrl: ISSUER, jwt: { issuer: ISSUER, accessTokenExpiry: '1h' }, developer: { sandboxAllowance: 'media.object.read', agentHostServices: 'actor' } };++app.locals.privateKey = keys.privateKey;++app.locals.publicKey = keys.publicKey;++app.use('/oauth', require('../server/auth/oauth-routes'));++app.use('/internal', require('../server/internal/routes'));++app.use('/api/v1/projects', require('../server/developer/routes').router());++const server = http.createServer(app);++const U = { owner: jwt.sign({ sub: 10, id: 10 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' }), staff: jwt.sign({ sub: 14, id: 14 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' }) };++++(async () => {++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const base = `http://127.0.0.1:${server.address().port}`;++ const api = async (who, method, p, body) => {++ const r = await fetch(`${base}/api/v1/projects${p}`, { method, headers: { authorization: `Bearer ${U[who]}`, ...(body ? { 'content-type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined });++ const text = await r.text();++ return { status: r.status, body: text ? JSON.parse(text) : null };++ };++ const ok = (r, status = 200) => { assert.strictEqual(r.status, status, JSON.stringify(r.body)); return r.body; };++ const SECRETS = {};++ const tokenReq = async (client, params) => {++ const r = await fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },++ body: new URLSearchParams({ grant_type: 'client_credentials', client_id: client, client_secret: SECRETS[client], audience: 'openvibe.media', ...params }) });++ return { status: r.status, body: await r.json() };++ };++ const claimsOf = (r) => { assert.strictEqual(r.status, 200, JSON.stringify(r.body)); return jwt.decode(r.body.access_token); };++ const mint = async (client, agent, params = {}) => claimsOf(await tokenReq(client, { agent, ...params }));++ const refusedGrant = async (client, agent, error, params = {}) => {++ const r = await tokenReq(client, { agent, ...params });++ assert.deepStrictEqual([r.status, r.body.error], [400, error], JSON.stringify(r.body));++ return r.body;++ };++ const capsOf = async (client, agent, params) => { const c = await mint(client, agent, params); return [c.cap, c.cap_confirm || []]; };++ try {++ // Project P: production app PR hosts agent A (read + upload auto, delete confirm); PR2 is another app of P;++ // project Q's app QR; a sandbox app SB hosts agent SA.++ const P = ok(await api('owner', 'POST', '', { name: 'Agent tokens' }), 201).id;++ ok(await api('staff', 'PUT', `/${P}/environment-policy`, { environment_policy: 'sandbox+production' }));++ ok(await api('staff', 'PUT', `/${P}/allowance`, { capabilities: ['media.object.read', 'media.object.upload', 'media.object.delete'] }));++ const newApp = async (project, name, environment) => {++ const a = ok(await api('owner', 'POST', `/${project}/apps`, { name, environment }), 201);++ SECRETS[a.id] = a.credential.client_secret;++ return a.id;++ };++ const PR = await newApp(P, 'Bot', 'production');++ const PR2 = await newApp(P, 'Other', 'production');++ const SB = await newApp(P, 'Sandbox bot', 'sandbox');++ for (const c of ['media.object.read', 'media.object.upload', 'media.object.delete']) ok(await api('owner', 'POST', `/${P}/apps/${PR}/grants`, { capability: c }), 201);++ ok(await api('owner', 'POST', `/${P}/apps/${SB}/grants`, { capability: 'media.object.read' }), 201);++ const Q = ok(await api('owner', 'POST', '', { name: 'Other project' }), 201).id;++ const QR = await newApp(Q, 'Q bot', 'sandbox');++ const A = ok(await api('owner', 'POST', `/${P}/agents`, { name: 'Helper', host: { type: 'app', id: PR } }), 201).id;++ ok(await api('owner', 'PUT', `/${P}/agents/${A}/grants/media.object.delete`, {}));++ ok(await api('owner', 'PUT', `/${P}/agents/${A}/grants/media.object.upload`, { mode: 'auto' }));++ ok(await api('owner', 'PUT', `/${P}/agents/${A}/grants/media.object.read`, { mode: 'auto' }));++ const SA = ok(await api('owner', 'POST', `/${P}/agents`, { name: 'Sandbox helper', host: { type: 'app', id: SB } }), 201).id;++ ok(await api('owner', 'PUT', `/${P}/agents/${SA}/grants/media.object.read`, { mode: 'auto' }));++++ // ── The claims: exactly the v0.90.0 agent shape, signed by Network's RS256 key ──++ const res = await tokenReq(PR, { agent: A });++ const claims = claimsOf(res);++ const v = validate('identity.service-token-claims@1', claims);++ assert.ok(v.valid, JSON.stringify(v.errors));++ const ownApp = claimsOf(await tokenReq(PR, {}));++ assert.deepStrictEqual(claims, {++ iss: ISSUER, sub: `agent:${A}`, actor_type: 'agent', aud: ['openvibe.media'], cap: ['media.object.read', 'media.object.upload'],++ cap_confirm: ['media.object.delete'], ns: ownApp.ns, project_id: P, env: 'production', on_behalf_of: owner, act: { sub: `app:${PR}` },++ iat: claims.iat, exp: claims.iat + 300, jti: claims.jti,++ });++ assert.match(claims.jti, /^tok_[0-9a-f]{24}$/);++ assert.deepStrictEqual([res.body.token_type, res.body.expires_in, res.body.scope], ['Bearer', 300, 'media.object.delete media.object.read media.object.upload']);++ const verified = serviceAuth.verifyServiceToken(res.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.media' });++ assert.ok(verified.ok, verified.reason);++ assert.strictEqual(jwt.decode(res.body.access_token, { complete: true }).header.alg, 'RS256');++ // The host's own app token is what it was: no agent claims.++ assert.deepStrictEqual(Object.keys(ownApp).sort(), ['actor_type', 'aud', 'cap', 'env', 'exp', 'iat', 'iss', 'jti', 'ns', 'project_id', 'sub']);++ assert.deepStrictEqual([ownApp.sub, ownApp.actor_type, ownApp.cap, ownApp.ns], [`app:${PR}`, 'app', ['media.object.delete', 'media.object.read', 'media.object.upload'], [P, `app.${P}.*`]]);++ // A scope narrows; a confirm-mode capability alone gives cap [] and cap_confirm, never cap.++ assert.deepStrictEqual(await capsOf(PR, A, { scope: 'media.object.delete' }), [[], ['media.object.delete']]);++ assert.ok(!('cap_confirm' in await mint(PR, A, { scope: 'media.object.read' })), 'no cap_confirm when there is none');++ await refusedGrant(PR, A, 'invalid_scope', { scope: 'media.object.list' });++++ // ── Host binding: one answer for all of them ──++ const unknown = await refusedGrant(PR, `agt_${'9'.repeat(26)}`, 'invalid_grant');++ assert.deepStrictEqual(await refusedGrant(PR2, A, 'invalid_grant'), unknown, 'another app of the project');++ assert.deepStrictEqual(await refusedGrant(QR, A, 'invalid_grant', { audience: 'openvibe.media' }), unknown, 'an app of another project');++ assert.deepStrictEqual(await refusedGrant(PR, 'agt_nope', 'invalid_grant'), unknown);++ assert.deepStrictEqual(await refusedGrant(PR, SA, 'invalid_grant'), unknown, 'an agent of another host');++ SECRETS.live = 'live-secret';++ assert.deepStrictEqual(await refusedGrant('live', A, 'invalid_grant'), unknown, 'a service that is not the host');++ const badSecret = await fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },++ body: new URLSearchParams({ grant_type: 'client_credentials', client_id: PR, client_secret: 'ovsec_wrong', audience: 'openvibe.media', agent: A }) });++ assert.strictEqual(badSecret.status, 401);++ const code = await fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },++ body: new URLSearchParams({ grant_type: 'authorization_code', client_id: PR, client_secret: SECRETS[PR], code: 'x', audience: 'openvibe.media', agent: A }) });++ assert.deepStrictEqual([code.status, (await code.json()).error], [400, 'unsupported_grant_type']);++ // A paused agent, a banned owner.++ await db.prepare('UPDATE dev_agents SET status = ? WHERE id = ?').run('paused', A);++ await refusedGrant(PR, A, 'invalid_grant');++ await db.prepare("UPDATE dev_agents SET status = 'active' WHERE id = ?").run(A);++ await db.prepare('UPDATE users SET is_banned = 1 WHERE id = 10').run();++ await refusedGrant(PR, A, 'invalid_grant');++ await db.prepare('UPDATE users SET is_banned = 0 WHERE id = 10').run();++ await mint(PR, A);++ // A sandbox agent only for audiences that take sandbox tokens.++ await refusedGrant(SB, SA, 'invalid_target', { audience: 'openvibe.chat' });++ assert.strictEqual((await mint(SB, SA)).env, 'sandbox');++++ // ── Grants: revoked, expired, a budget of 0 and the ceiling each leave the capability out ──++ ok(await api('owner', 'PUT', `/${P}/agents/${A}/budgets/media.object.upload`, { limit: 0, window: 'day' }));++ assert.deepStrictEqual(await capsOf(PR, A), [['media.object.read'], ['media.object.delete']], 'budget 0 is the off switch');++ await refusedGrant(PR, A, 'invalid_scope', { scope: 'media.object.upload' });++ ok(await api('owner', 'PUT', `/${P}/agents/${A}/budgets/media.object.upload`, { limit: 5, window: 'day' }));++ assert.deepStrictEqual((await capsOf(PR, A))[0], ['media.object.read', 'media.object.upload']);++ ok(await api('owner', 'PUT', `/${P}/agents/${A}/budgets/media.object.delete`, { limit: 0, window: 'day' }));++ assert.deepStrictEqual(await capsOf(PR, A), [['media.object.read', 'media.object.upload'], []], 'budget 0 drops a confirm one too');++ await refusedGrant(PR, A, 'invalid_scope', { scope: 'media.object.delete' });++ ok(await api('owner', 'DELETE', `/${P}/agents/${A}/budgets/media.object.delete`));++ await db.prepare("UPDATE dev_agent_grants SET expires_at = '2001-01-01T00:00:00.000Z' WHERE agent_id = ? AND capability = 'media.object.upload'").run(A);++ assert.deepStrictEqual((await capsOf(PR, A))[0], ['media.object.read'], 'an expired grant, before any sweep');++ await db.prepare("UPDATE dev_agent_grants SET expires_at = NULL WHERE agent_id = ? AND capability = 'media.object.upload'").run(A);++ // Outside the ceiling without a cascade (the host's app grant flipped underneath): left out at issuance.++ await db.prepare("UPDATE dev_grants SET status = 'revoked' WHERE app_id = ? AND capability = 'media.object.upload'").run(PR);++ assert.deepStrictEqual((await capsOf(PR, A))[0], ['media.object.read']);++ await db.prepare("UPDATE dev_grants SET status = 'approved' WHERE app_id = ? AND capability = 'media.object.upload'").run(PR);++ ok(await api('owner', 'DELETE', `/${P}/agents/${A}/grants/media.object.read`));++ assert.deepStrictEqual(await capsOf(PR, A), [['media.object.upload'], ['media.object.delete']], 'a revoked grant');++ await refusedGrant(PR, A, 'invalid_scope', { scope: 'media.object.read' });++ await refusedGrant(PR, A, 'invalid_scope', { audience: 'openvibe.events' });++++ // ── Confirm mode: the token carries the capability in cap_confirm; each use is one approval, spent once ──++ const digest = (s) => crypto.createHash('sha256').update(s).digest('hex');++ const D = digest('DELETE /v1/objects/obj_1');++ const ask = () => confirmations.create(db, { agentId: A, capability: 'media.object.delete', audience: 'openvibe.media', summary: 'Delete obj_1', requestDigest: D });++ const actor = { subject: owner, label: `user:${owner}` };++ const c1 = await ask();++ const spend = (c, d = D) => confirmations.consume(db, { id: c.id, audience: 'openvibe.media', requestDigest: d });++ const rejects = (p, code) => assert.rejects(p, (e) => { assert.strictEqual(e.code, code, e.message); return true; });++ await rejects(spend(c1), 'confirmation.not_pending');++ await confirmations.decide(db, actor, c1.id, 'approve', {});++ await rejects(spend(c1, digest('DELETE /v1/objects/obj_2')), 'confirmation.mismatch');++ const results = await Promise.allSettled([spend(c1), spend(c1), spend(c1)]);++ assert.strictEqual(results.filter((r) => r.status === 'fulfilled').length, 1, 'single use under concurrency');++ for (const r of results.filter((x) => x.status === 'rejected')) assert.strictEqual(r.reason.code, 'confirmation.used');++ const c2 = await ask();++ await confirmations.decide(db, actor, c2.id, 'approve', {});++ await db.prepare("UPDATE dev_confirmations SET expires_at = '2001-01-01T00:00:00.000Z' WHERE id = ?").run(c2.id);++ await rejects(spend(c2), 'confirmation.expired');++ assert.deepStrictEqual(await capsOf(PR, A), [['media.object.upload'], ['media.object.delete']], 'issuance neither needs nor spends an approval');++++ // ── Service host: ns bounded by the host's own principal_grants rows ──++ SECRETS.actor = 'actor-secret';++ const grantActor = (cap, ns) => db.prepare("INSERT INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES ('actor', ?, 'openvibe.media', ?, 'test')").run(cap, JSON.stringify(ns));++ await grantActor('media.object.read', [P]);++ await grantActor('media.object.delete', ['app.*']);++ const S = ok(await api('owner', 'POST', `/${P}/agents`, { name: 'Actor agent', host: { type: 'service', id: 'actor' } }), 201).id;++ ok(await api('owner', 'PUT', `/${P}/agents/${S}/grants/media.object.read`, { mode: 'auto' }));++ ok(await api('owner', 'PUT', `/${P}/agents/${S}/grants/media.object.delete`, {}));++ const sc = await mint('actor', S);++ assert.ok(validate('identity.service-token-claims@1', sc).valid);++ assert.deepStrictEqual([sc.sub, sc.cap, sc.cap_confirm, sc.ns, sc.env, sc.act], [`agent:${S}`, ['media.object.read'], ['media.object.delete'], [P, `app.${P}.*`], 'production', { sub: 'svc:actor' }]);++ assert.deepStrictEqual((await mint('actor', S, { scope: 'media.object.read' })).ns, [P], 'only what the read row names');++ await db.prepare("UPDATE principal_grants SET namespaces = '[]' WHERE client_id = 'actor'").run();++ assert.deepStrictEqual((await mint('actor', S)).ns, [], 'rows that name no namespace give none');++ await refusedGrant('actor', A, 'invalid_grant');++ await refusedGrant(PR, S, 'invalid_grant');++++ // ── Network's own routes refuse an agent token ──++ const now = Math.floor(Date.now() / 1000);++ const forged = serviceAuth.signServiceToken({ iss: ISSUER, sub: `agent:${A}`, actor_type: 'agent', aud: ['openvibe.network'], cap: ['network.project.read'],++ project_id: P, env: 'production', on_behalf_of: owner, act: { sub: `app:${PR}` }, iat: now, exp: now + 300, jti: 'tok_agentnet01' }, keys.privateKey);++ const g = await fetch(`${base}/internal/agents/${A}`, { headers: { authorization: `Bearer ${forged}` } });++ assert.deepStrictEqual([g.status, (await g.json()).code], [403, 'capability.denied']);++++ // ── Revoked agent, archived project ──++ ok(await api('owner', 'DELETE', `/${P}/agents/${A}`));++ await refusedGrant(PR, A, 'invalid_grant');++ await db.prepare("UPDATE dev_projects SET archived_at = '2026-01-01T00:00:00.000Z' WHERE id = ?").run(P);++ await refusedGrant('actor', S, 'invalid_grant');++++ // ── Audit: every issue and refusal, never a secret or a token ──++ const rows = await db.prepare("SELECT * FROM dev_audit WHERE action IN ('agent.token_issued', 'agent.token_refused') ORDER BY id").all();++ const issued = rows.filter((r) => r.action === 'agent.token_issued');++ const refusals = rows.filter((r) => r.action === 'agent.token_refused');++ assert.ok(issued.some((r) => JSON.parse(r.detail).jti === claims.jti && r.actor === `app:${PR}` && r.target === `agent:${A}` && r.project_id === P));++ assert.ok(refusals.length >= 15, `refusals audited (${refusals.length})`);++ assert.ok(refusals.some((r) => JSON.parse(r.detail).reason === 'not the agent\'s host' && r.actor === `app:${PR2}`));++ assert.ok(refusals.some((r) => JSON.parse(r.detail).reason === 'agent revoked'));++ assert.ok(refusals.some((r) => r.project_id === null && JSON.parse(r.detail).reason === 'unknown agent'));++ const text = JSON.stringify(rows);++ for (const s of [...Object.values(SECRETS), res.body.access_token, 'ovsec_']) assert.ok(!text.includes(s), 'no secret or token in the audit');++ console.log('agent-tokens: all tests passed');++ } finally {++ server.close();++ }++})().catch((err) => { console.error(err); process.exit(1); });++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,170 @@++'use strict';++// Confirmation decision events (plan T2 WS-Z2 slice 9, docs/t2-projects-and-grants.md section 5;++// server/developer/confirmations.js): every transition — created, approved, denied, used, cancelled (by the service++// or a cascade) and expired (lazily at a decision, or by the sweep) — writes one network.confirmation.changed@1++// envelope with its audit row, in the transaction of the change, into the Events outbox. Each change is emitted once:++// a second sweep, a repeated cancel and a refused transition write nothing. Payloads validate against the v0.90.0++// schema and never carry the summary, details, request digest or a label. An approved but expired confirmation++// cannot be cancelled (409 confirmation.not_pending) and stays as it was.++// node test/confirmation-events.test.js++const assert = require('assert');++const crypto = require('crypto');++const http = require('http');++const express = require('express');++const jwt = require('jsonwebtoken');++const { validate } = require('openvibe-contracts');++const { getDb } = require('../server/db/database');++const subjects = require('../server/identity/subjects');++const relay = require('../server/developer/event-relay');++const confirmations = require('../server/developer/confirmations');++++(async () => {++const log = console.log; console.log = () => {};++const db = getDb();++console.log = log;++await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES (10, 'owner', 'x', 'user'), (14, 'staff', 'x', 'admin')`).run();++const owner = await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = 10').get());++++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.locals.db = db;++app.locals.config = { baseUrl: ISSUER, loginUrl: ISSUER, jwt: { issuer: ISSUER, accessTokenExpiry: '1h' }, developer: { sandboxAllowance: '' } };++app.locals.privateKey = keys.privateKey;++app.locals.publicKey = keys.publicKey;++app.use('/api/v1/projects', require('../server/developer/routes').router());++const server = http.createServer(app);++const U = { owner: jwt.sign({ sub: 10, id: 10 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' }), staff: jwt.sign({ sub: 14, id: 14 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' }) };++++(async () => {++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const base = `http://127.0.0.1:${server.address().port}`;++ const api = async (who, method, p, body) => {++ const r = await fetch(`${base}/api/v1/projects${p}`, { method, headers: { authorization: `Bearer ${U[who]}`, ...(body ? { 'content-type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined });++ const text = await r.text();++ return { status: r.status, body: text ? JSON.parse(text) : null };++ };++ const ok = (r, status = 200) => { assert.strictEqual(r.status, status, JSON.stringify(r.body)); return r.body; };++ const quiet = { log() {}, warn() {} };++ const outbox = await relay.startRelay(db, { eventsUrl: 'http://127.0.0.1:1', privateKey: keys.privateKey, issuer: ISSUER, autoStart: false, log: quiet });++ try {++ const P = ok(await api('owner', 'POST', '', { name: 'Confirmation events' }), 201).id;++ ok(await api('staff', 'PUT', `/${P}/environment-policy`, { environment_policy: 'sandbox+production' }));++ ok(await api('staff', 'PUT', `/${P}/allowance`, { capabilities: ['media.object.delete'] }));++ const PR = ok(await api('owner', 'POST', `/${P}/apps`, { name: 'Bot', environment: 'production' }), 201).id;++ ok(await api('owner', 'POST', `/${P}/apps/${PR}/grants`, { capability: 'media.object.delete' }), 201);++ const A = ok(await api('owner', 'POST', `/${P}/agents`, { name: 'Helper', host: { type: 'app', id: PR } }), 201).id;++ ok(await api('owner', 'PUT', `/${P}/agents/${A}/grants/media.object.delete`, {}));++++ const D = crypto.createHash('sha256').update('DELETE /v1/objects/obj_1').digest('hex');++ const SUMMARY = 'Delete the photo "beach-secret.jpg"';++ const ask = (extra = {}) => confirmations.create(db, { agentId: A, capability: 'media.object.delete', audience: 'openvibe.media', summary: SUMMARY,++ details: { note: 'private-detail-text' }, requestDigest: D, sessionId: 'session-0001', ...extra });++ const actor = { subject: owner, label: `user:${owner}` };++ const decide = (c, action, body = {}) => confirmations.decide(db, actor, c.id, action, body);++ const spend = (c) => confirmations.consume(db, { id: c.id, audience: 'openvibe.media', requestDigest: D });++ const cancel = (c) => confirmations.cancel(db, { id: c.id, audience: 'openvibe.media' });++ const rejects = (p, status, code) => assert.rejects(p, (e) => { assert.deepStrictEqual([e.status, e.code], [status, code], e.message); return true; });++ const past = (c) => db.prepare("UPDATE dev_confirmations SET expires_at = '2001-01-01T00:00:00.000Z' WHERE id = ?").run(c.id);++ const envelopes = async (c) => (await db.prepare("SELECT event FROM dev_audit WHERE event_type = 'network.confirmation.changed' AND target = ? ORDER BY id").all(`confirmation:${c.id}`)).map((r) => JSON.parse(r.event));++ const changes = async (c) => (await envelopes(c)).map((e) => [e.payload.change, e.payload.state]);++++ // created → approved → used, each once, each a valid payload in a valid envelope.++ const c1 = await ask();++ assert.deepStrictEqual(await changes(c1), [['created', 'pending']]);++ await decide(c1, 'approve');++ await rejects(decide(c1, 'approve'), 409, 'confirmation.not_pending');++ await spend(c1);++ await rejects(spend(c1), 409, 'confirmation.used');++ assert.deepStrictEqual(await changes(c1), [['created', 'pending'], ['approved', 'approved'], ['used', 'approved']]);++ const [created, approved, used] = await envelopes(c1);++ assert.deepStrictEqual(Object.keys(created.payload).sort(), ['agent_id', 'audience', 'capability', 'change', 'changed_at', 'confirmation_id', 'expires_at', 'project_id', 'state']);++ assert.deepStrictEqual(created.payload, { ...created.payload, confirmation_id: c1.id, agent_id: A, project_id: P, capability: 'media.object.delete', audience: 'openvibe.media' });++ assert.strictEqual(approved.payload.standing_rule, 'once');++ assert.ok(!('expires_at' in used.payload));++ assert.deepStrictEqual([created.actor, approved.actor, used.actor], [{ type: 'system', id: 'network' }, { type: 'user', id: owner }, { type: 'system', id: 'network' }]);++ for (const e of [created, approved, used]) {++ assert.deepStrictEqual([e.event_type, e.version, e.source, e.visibility, e.subject, e.on_behalf_of], ['network.confirmation.changed', 1, 'network', 'internal', { type: 'confirmation', id: c1.id }, { type: 'user', id: owner }]);++ assert.ok(validate('events.event-envelope@1', e).valid);++ const v = validate('network.confirmation.changed@1', e.payload);++ assert.ok(v.valid, JSON.stringify(v.errors));++ const text = JSON.stringify(e);++ for (const s of [SUMMARY, 'private-detail-text', D, 'session-0001', `user:${owner}`]) assert.ok(!text.includes(s), `never ${s}`);++ }++++ // denied++ const c2 = await ask();++ await decide(c2, 'deny');++ await rejects(decide(c2, 'deny'), 409, 'confirmation.not_pending');++ assert.deepStrictEqual(await changes(c2), [['created', 'pending'], ['denied', 'denied']]);++ assert.deepStrictEqual((await envelopes(c2))[1].actor, { type: 'user', id: owner });++++ // cancelled by the service: again is a no-op, and emits nothing++ const c3 = await ask();++ await cancel(c3);++ await cancel(c3);++ assert.deepStrictEqual(await changes(c3), [['created', 'pending'], ['cancelled', 'cancelled']]);++ assert.strictEqual((await envelopes(c3))[1].payload.cancel_reason, 'service');++++ // expired by the sweep, once however often it runs; a decision afterwards changes nothing++ const c4 = await ask();++ await past(c4);++ assert.ok(await confirmations.expireDue(db) >= 1);++ assert.strictEqual(await confirmations.expireDue(db), 0);++ await rejects(decide(c4, 'approve'), 409, 'confirmation.not_pending');++ assert.deepStrictEqual(await changes(c4), [['created', 'pending'], ['expired', 'expired']]);++++ // expired lazily at a decision, then the sweep finds nothing more++ const c5 = await ask();++ await past(c5);++ await rejects(decide(c5, 'approve'), 409, 'confirmation.expired');++ assert.strictEqual(await confirmations.expireDue(db), 0);++ assert.deepStrictEqual(await changes(c5), [['created', 'pending'], ['expired', 'expired']]);++++ // An approved but unused confirmation past expires_at: cancel is 409 confirmation.not_pending, the row stays++ // approved and nothing is emitted; the sweep leaves it alone.++ const c6 = await ask();++ await decide(c6, 'approve');++ await past(c6);++ await rejects(cancel(c6), 409, 'confirmation.not_pending');++ assert.strictEqual(await confirmations.expireDue(db), 0);++ const r6 = await db.prepare('SELECT state, cancel_reason, used_at FROM dev_confirmations WHERE id = ?').get(c6.id);++ assert.deepStrictEqual([r6.state, r6.cancel_reason, r6.used_at], ['approved', null, null]);++ assert.strictEqual((await confirmations.read(db, { id: c6.id, audience: 'openvibe.media' })).confirmation.state, 'expired');++ assert.deepStrictEqual(await changes(c6), [['created', 'pending'], ['approved', 'approved']]);++++ // A standing rule: the approval names it; the next request is one `created` event, approved, with its rule_id.++ const c7 = await ask();++ const { rule } = await decide(c7, 'approve', { standing_rule: 'session' });++ assert.deepStrictEqual([(await envelopes(c7))[1].payload.standing_rule, (await envelopes(c7))[1].payload.rule_id], ['session', rule.id]);++ const c8 = await ask();++ assert.strictEqual(c8.state, 'approved');++ const e8 = await envelopes(c8);++ assert.deepStrictEqual(e8.map((e) => [e.payload.change, e.payload.state, e.payload.rule_id]), [['created', 'approved', rule.id]]);++ assert.deepStrictEqual(e8[0].actor, { type: 'system', id: 'network' });++++ // A cascade (pausing the agent) cancels the pending and approved-unused ones, one event each, with the cause.++ const c9 = await confirmations.create(db, { agentId: A, capability: 'media.object.delete', audience: 'openvibe.media', summary: SUMMARY, requestDigest: D });++ ok(await api('owner', 'POST', `/${P}/agents/${A}/pause`));++ await api('owner', 'POST', `/${P}/agents/${A}/pause`); // again: nothing more to cancel++ for (const c of [c8, c9]) {++ const e = await envelopes(c);++ assert.deepStrictEqual(e.slice(-1).map((x) => [x.payload.change, x.payload.state, x.payload.cancel_reason, x.actor.type]), [['cancelled', 'cancelled', 'agent_paused', 'system']]);++ assert.strictEqual(e.filter((x) => x.payload.change === 'cancelled').length, 1);++ }++ for (const c of [c1, c2, c4, c5, c6]) assert.ok(!(await changes(c)).some(([ch]) => ch === 'cancelled'), 'spent, decided and expired rows are left alone');++++ // Every event is in the outbox, written in the transaction of its change.++ const all = (await db.prepare("SELECT event FROM dev_audit WHERE event_type = 'network.confirmation.changed' ORDER BY id").all()).map((r) => JSON.parse(r.event).event_id);++ const queued = (await db.prepare(`SELECT event_id FROM ${relay.TABLE}`).all()).map((r) => r.event_id);++ assert.ok(all.length >= 20, `${all.length} events`);++ for (const id of all) assert.ok(queued.includes(id), `outbox has ${id}`);++ assert.ok(await outbox.pending() >= all.length);++ console.log('confirmation-events: all tests passed');++ } finally {++ await relay.stopRelay(db);++ server.close();++ }++})().catch((err) => { console.error(err); process.exit(1); });++})().catch((err) => { console.error(err); process.exit(1); });@@@ -190,7 +190,8 @@ for (const [id, name] of PEOPLE) T[name] = jwt.sign({ sub: id, id }, keys.privat// ── The service cancels a pending one ──const c6 = await ask(bot);-- assert.strictEqual(await confirmations.cancelFor(db, { agentId: bot, capability: 'media.object.delete', reason: 'service', actor: 'service:openvibe.media' }), 2, 'c6 and the approved-unused c4');++ assert.strictEqual(await confirmations.cancelFor(db, { agentId: bot, capability: 'media.object.delete', reason: 'service', actor: 'service:openvibe.media' }), 1, 'c6 only: the approved-unused c4 is past expiry, so already expired');++ assert.deepStrictEqual([(await row(c4.id)).state, (await row(c4.id)).cancel_reason], ['approved', null]);assert.strictEqual(valid(ok(await inbox('owner', 'GET', `/${c6.id}`)).confirmation).state, 'cancelled');await rejects(spend(c6), 409, 'confirmation.cancelled');refused(await inbox('owner', 'POST', `/${c6.id}/approve`, {}), 409, 'confirmation.not_pending');@@@ -418,13 +419,16 @@ for (const [id, name] of PEOPLE) T[name] = jwt.sign({ sub: id, id }, keys.privatassert.deepStrictEqual([i6.status, i6.body.confirmation.state, i6.body.confirmation.standing_rule], [200, 'approved', 'always']);ok(await internal('media', 'POST', `/${i6.body.confirmation.id}/consume`, { request_digest: D }));-- // ── Audits: every transition, never the summary or the details ──++ // ── Audits: every transition, never the summary or the details; each carries its network.confirmation.changed@1++ // event (slice 9) except a standing rule's approval, which the `created` event already reports ──const audits = await db.prepare("SELECT action, actor, detail, event FROM dev_audit WHERE action LIKE 'confirmation.%' ORDER BY id").all();assert.deepStrictEqual([...new Set(audits.map((a) => a.action))].sort(),['confirmation.approved', 'confirmation.cancelled', 'confirmation.created', 'confirmation.denied', 'confirmation.expired', 'confirmation.used']);for (const a of audits) {-- assert.strictEqual(a.event, null);++ if (/^rule:/.test(a.actor)) assert.strictEqual(a.event, null);++ else assert.ok(validate('network.confirmation.changed@1', JSON.parse(a.event).payload).valid, a.event);assert.ok(!a.detail.includes('beach-secret') && !a.detail.includes('private-detail-text'), a.detail);++ assert.ok(!String(a.event).includes('beach-secret') && !String(a.event).includes('private-detail-text'), a.event);const d = JSON.parse(a.detail);assert.ok(d.agent && d.capability && !('summary' in d) && !('details' in d));}
@@ -5,8 +5,9 @@ merge `4ec36e1`) and **lane B step 3 built** (`GET /internal/projects/:project_i
@@ -5,8 +5,9 @@ merge `4ec36e1`) and **lane B step 3 built** (`GET /internal/projects/:project_i
only: `server/internal/routes.js:490`; PR #28, merge `e422342`) and **slice 3 built** (delegated grants and modes:
only: `server/internal/routes.js:490`; PR #28, merge `e422342`) and **slice 3 built** (delegated grants and modes:
`migrations/0017_agent_grants.sql`, the ceiling in `server/developer/agents.js`), **slices 4–5 built** (confirmations,
`migrations/0017_agent_grants.sql`, the ceiling in `server/developer/agents.js`), **slices 4–5 built** (confirmations,
owner side, and budgets: `0018`, `0019`) and **slices 6–7 built** (`GET /internal/agents/:agent` and the four
owner side, and budgets: `0018`, `0019`) and **slices 6–7 built** (`GET /internal/agents/:agent` and the four
-`/internal/confirmations` routes, `server/internal/routes.js`, with the pin at `openvibe-contracts` v0.90.0); slices 8–9
-are still unbuilt (design, §8).
+`/internal/confirmations` routes, `server/internal/routes.js`, with the pin at `openvibe-contracts` v0.90.0) and **slices
+8–9 built** (agent tokens, `server/developer/agent-tokens.js`; `network.confirmation.changed@1` decision events from
+`server/developer/confirmations.js`); the owner notification of slice 9 still waits on an openvibe-shared type (§8).
Plan T2, "Projects and grants: projects as the ownership boundary for every resource;
Plan T2, "Projects and grants: projects as the ownership boundary for every resource;
`agt_` principals, delegated grants with modes, sensitive capabilities, confirmation requests, budgets (WS-Z2)".
`agt_` principals, delegated grants with modes, sensitive capabilities, confirmation requests, budgets (WS-Z2)".
Pinned version: `openvibe-contracts` **v0.90.0** (`package.json:33`, since slice 7); every contract claim below was
Pinned version: `openvibe-contracts` **v0.90.0** (`package.json:33`, since slice 7); every contract claim below was
@@ -348,7 +349,18 @@ An agent token never carries a namespace its host's own token for that audience
@@ -348,7 +349,18 @@ An agent token never carries a namespace its host's own token for that audience
namespaces as well as capabilities.
namespaces as well as capabilities.
**Fail closed by construction:** a confirm-mode capability is never in `cap`. A receiver that does not know agents sees
**Fail closed by construction:** a confirm-mode capability is never in `cap`. A receiver that does not know agents sees
-only `cap`, so it can never perform a sensitive action without a confirmation; it simply refuses it.
+only `cap`, so it can never perform a sensitive action without a confirmation; it simply refuses it. Issuance consumes no
+confirmation: `cap_confirm` says the agent may *ask*, and each use is one approval the owning service spends once
+(`consume`, §5 step 4, under the agent row's `FOR UPDATE`).
+
+**Refusals** (built, slice 8; OAuth errors, after the host's own credentials passed): `400 invalid_grant` (the host
+binding above, one message, also an unknown or malformed `agt_` id), `400 invalid_request` (no `audience`), `400
+invalid_target` (a sandbox agent at an audience not in `DEV_SANDBOX_AUDIENCES`), `400 invalid_scope` (a `scope` naming a
+capability the agent does not hold now — no grant, revoked or expired, outside the ceiling, budget `0` — or nothing held
+at the audience), `400 unsupported_grant_type` (`agent` with anything but `client_credentials`); a node client id never
+hosts an agent (`invalid_grant`). Every issuance and refusal is a `dev_audit` row (`agent.token_issued` with audience,
+`cap`, `cap_confirm`, `jti`, expiry; `agent.token_refused` with the error and its internal reason), never a secret or the
+token. Network's own guard (`principals.guard`) answers `403 capability.denied` to an agent token on every route.
## 5. Auth rules
## 5. Auth rules
@@ -420,7 +432,7 @@ the digest. An agent cannot describe its own request to its owner.
@@ -420,7 +432,7 @@ the digest. An agent cannot describe its own request to its owner.
2. **Standing rule shortcut.** If a live rule covers (agent, capability) — `always`; `until` with `until_at` in the
2. **Standing rule shortcut.** If a live rule covers (agent, capability) — `always`; `until` with `until_at` in the
future; `session` with the same `session_id` and `until_at` in the future — the confirmation is created already
future; `session` with the same `session_id` and `until_at` in the future — the confirmation is created already
`approved` (`decided_by = 'rule:<id>'`, `rule_id` set, `200`). Every sensitive use still leaves a row.
`approved` (`decided_by = 'rule:<id>'`, `rule_id` set, `200`). Every sensitive use still leaves a row.
-3. **Decide.** The owner sees it in the inbox (and, once slice 9 lands, a notification). `approve` / `deny` move
+3. **Decide.** The owner sees it in the inbox (and, once openvibe-shared has its type, a notification). `approve` / `deny` move
`pending` → `approved` / `denied` and set `decided_at`. Anything else is `409 confirmation.not_pending`; a pending row
`pending` → `approved` / `denied` and set `decided_at`. Anything else is `409 confirmation.not_pending`; a pending row
past `expires_at` is `409 confirmation.expired` (and is marked expired). Approving with `session`, `until` (`until`
past `expires_at` is `409 confirmation.expired` (and is marked expired). Approving with `session`, `until` (`until`
required, at most 30 days) or `always` also inserts a `dev_standing_rules` row; `session` lasts at most 24 hours and
required, at most 30 days) or `always` also inserts a `dev_standing_rules` row; `session` lasts at most 24 hours and
@@ -455,7 +467,15 @@ the digest. An agent cannot describe its own request to its owner.
@@ -455,7 +467,15 @@ the digest. An agent cannot describe its own request to its owner.
or delegated-grant `expires_at` passing before its sweep, a capability leaving the catalog on upgrade).
or delegated-grant `expires_at` passing before its sweep, a capability leaving the catalog on upgrade).
Every transition writes a `dev_audit` row (`confirmation.created|approved|denied|expired|cancelled|used`, project id,
Every transition writes a `dev_audit` row (`confirmation.created|approved|denied|expired|cancelled|used`, project id,
-agent id, capability; never `summary` or `details`, which may hold message text).
+agent id, capability; never `summary` or `details`, which may hold message text) carrying a
+`network.confirmation.changed@1` event (built, slice 9) in the same transaction, so the outbox relays it exactly when the
+change commits. Each transition is a conditional `UPDATE` that one writer wins, so each (confirmation, change) is emitted
+once: a second sweep, a repeated cancel or a lost race writes nothing. The payload is the contract's (ids, capability,
+audience, state, change, `standing_rule`/`rule_id`, `cancel_reason`, `expires_at` on created and approved,
+`changed_at`), never the summary, details, request digest or a label; the envelope's actor is the deciding owner for
+approve/deny and the system otherwise, `on_behalf_of` the owner. A cascade skips rows already past `expires_at`, and
+`cancel` refuses them (`409 confirmation.not_pending`): an approved but expired confirmation is reported `expired` and
+stays as it is.
### Budgets: who checks what
### Budgets: who checks what
@@ -587,15 +607,18 @@ and agree).
@@ -587,15 +607,18 @@ and agree).
`server/developer/confirmations.js`, mounted under the guard in `server/internal/routes.js`), no `DEFAULT_GRANTS`
`server/developer/confirmations.js`, mounted under the guard in `server/internal/routes.js`), no `DEFAULT_GRANTS`
row yet (none until a receiver ships), internal cases in `test/confirmations.test.js`. Checks: `npm test`,
row yet (none until a receiver ships), internal cases in `test/confirmations.test.js`. Checks: `npm test`,
`npm run test:pg`, `node scripts/contracts-drift.js`.
`npm run test:pg`, `node scripts/contracts-drift.js`.
-8. **Agent tokens** — *blocked on Contracts publishing the agent claims (gap 1).* The `agent` branch in
+8. **Agent tokens** (built). `server/developer/agent-tokens.js` (`mint`), the `agent` branch in
`server/developer/tokens.js` and `principals.issueToken`, `cap`/`cap_confirm` split, `test/agent-tokens.test.js`.
`server/developer/tokens.js` and `principals.issueToken`, `cap`/`cap_confirm` split, `test/agent-tokens.test.js`.
- Network's own guard keeps refusing agent tokens on its routes until a route needs them. Checks: `npm test`,
+ Network's own guard refuses agent tokens on its routes until a route needs them. No migration. Checks: `npm test`,
+ `npm run test:pg`.
+9. **Decision events** (built) **and the owner notification** (*still blocked on an openvibe-shared notification
+ type*). Audit rows carry `network.confirmation.changed@1` (`store.audit` takes the envelope's `actor` and
+ `on_behalf_of`), `test/confirmation-events.test.js`. Still to do: `notification-service.js` notifies the owner on
+ create (payload rules of `docs/notification-digest.md`: never the summary). No migration. Checks: `npm test`,
`npm run test:pg`.
`npm run test:pg`.
-9. **Decision events and the owner notification** — *blocked on the `network.confirmation.changed@1` payload and an
- openvibe-shared notification type.* Audit rows gain `event`; `notification-service.js` notifies the owner on create
- (payload rules of `docs/notification-digest.md`: never the summary). Checks: `npm test`, `npm run test:pg`.
-Slices 2–7 are built, in this order (2–5 each added the next migration number; 6–7 need none); 8–9 wait only on releases outside Network and are flagged by `scripts/contracts-drift.js`.
+Slices 2–9 are built, in this order (2–5 each added the next migration number; 6–9 need none); only the slice-9 owner
+notification waits on a release outside Network.
## 9. Unresolved
## 9. Unresolved
@@ -12,6 +12,7 @@ const subjects = require('../identity/subjects');
@@ -12,6 +12,7 @@ const subjects = require('../identity/subjects');
const { staffClaims } = require('./staff-claims');
const { staffClaims } = require('./staff-claims');
const principals = require('../identity/principals');
const principals = require('../identity/principals');
const devTokens = require('../developer/tokens');
const devTokens = require('../developer/tokens');
+const devPolicy = require('../developer/policy');
const oidc = require('./oidc');
const oidc = require('./oidc');
const refreshTokens = require('./refresh-tokens');
const refreshTokens = require('./refresh-tokens');
const router = express.Router();
const router = express.Router();
@@ -220,7 +221,7 @@ router.post('/token', async (req, res) => {
@@ -220,7 +221,7 @@ router.post('/token', async (req, res) => {
// Developer apps (app_<ULID>): client_credentials or authorization_code + PKCE, own credential store.
// Developer apps (app_<ULID>): client_credentials or authorization_code + PKCE, own credential store.
if (devTokens.isAppClient(client_id)) {
if (devTokens.isAppClient(client_id)) {
- const out = await devTokens.handleTokenRequest(db, req.body, { privateKey: req.app.locals.privateKey, issuer: config.jwt.issuer, config });
+ const out = await devTokens.handleTokenRequest(db, req.body, { privateKey: req.app.locals.privateKey, issuer: config.jwt.issuer, config, ctx: req.ov });
res.set('Cache-Control', 'no-store');
res.set('Cache-Control', 'no-store');
return res.status(out.status).json(out.body);
return res.status(out.status).json(out.body);
}
}
@@ -230,6 +231,7 @@ router.post('/token', async (req, res) => {
@@ -230,6 +231,7 @@ router.post('/token', async (req, res) => {
if (grant_type === 'client_credentials') {
if (grant_type === 'client_credentials') {
// A paired machine (node principal, docs/t2-cells-and-node-principal.md section 4.3): its own credential store.
// A paired machine (node principal, docs/t2-cells-and-node-principal.md section 4.3): its own credential store.
if (/^nod_/.test(client_id)) {
if (/^nod_/.test(client_id)) {
+ if (req.body.agent !== undefined) return res.status(400).json({ error: 'invalid_grant', error_description: 'nodes host no agents' });
const out = await require('../registry/node-principals').issueNodeToken(db, {
const out = await require('../registry/node-principals').issueNodeToken(db, {
clientId: client_id, clientSecret: client_secret, audience: req.body.audience,
clientId: client_id, clientSecret: client_secret, audience: req.body.audience,
privateKey: req.app.locals.privateKey, issuer: config.jwt.issuer,
privateKey: req.app.locals.privateKey, issuer: config.jwt.issuer,
@@ -239,6 +241,7 @@ router.post('/token', async (req, res) => {
@@ -239,6 +241,7 @@ router.post('/token', async (req, res) => {
const out = await principals.issueToken(db, {
const out = await principals.issueToken(db, {
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,
});
});
res.set('Cache-Control', 'no-store');
res.set('Cache-Control', 'no-store');
return res.status(out.status).json(out.body);
return res.status(out.status).json(out.body);
new file mode 100644
new file mode 100644
@@ -0,0 +1,122 @@
@@ -0,0 +1,122 @@
+'use strict';
+/**
+ * Agent tokens (plan T2 WS-Z2 slice 8, docs/t2-projects-and-grants.md section 4 "Agent tokens"): the agent's host
+ * authenticates at /oauth/token as itself — an app with its client_secret (./tokens.js), a service through
+ * principals.issueToken — and adds `agent=agt_…`, `audience` and an optional `scope`.
+ *
+ * Claims (identity.service-token-claims@1, v0.90.0): sub agent:agt_<ULID>, actor_type agent, aud [audience], cap (the
+ * agent's active, unexpired auto grants), cap_confirm (its confirm ones; omitted when none), ns, project_id, env
+ * (the agent's environment), on_behalf_of (the owner), act { sub: app:app_… | svc:<host> }, iat, exp (+300 s), jti.
+ * Both lists are the delegated grants ∩ the host's ceiling now (agents.ceiling) minus a budget of 0, so a token never
+ * holds more than its host may do for the project. A confirm-mode capability is never in `cap`: each use needs an
+ * approved confirmation that the owning service consumes (/internal/confirmations), so a receiver that does not know
+ * agents refuses it. Signed by Network's RS256 key (the JWKS one), never a shared secret; no refresh.
+ *
+ * Refusals (OAuth errors; the host's own credentials are checked by the caller first):
+ * 400 invalid_grant one message for every host-binding failure (unknown agent, another host's agent, app of
+ * another project or environment, revoked app, archived project, agent paused or revoked,
+ * owner no longer a member, banned or deleted), so a host cannot probe others' agents
+ * 400 invalid_request no audience
+ * 400 invalid_target a sandbox agent asking for an audience that does not accept sandbox tokens
+ * 400 invalid_scope a scope the agent does not hold now (no grant, revoked or expired, outside the host's
+ * ceiling, budget 0), or nothing at all for the audience
+ * Every issuance and refusal writes a dev_audit row (agent.token_issued / agent.token_refused: audience, cap,
+ * cap_confirm, jti, expiry, or the error and the internal reason); never a secret or the token.
+ */
+const crypto = require('crypto');
+const { serviceAuth, assertValid, capabilities } = require('openvibe-contracts');
+const store = require('./store');
+const agents = require('./agents');
+const { projectNamespaces, TOKEN_TTL_S } = require('./tokens');
+
+const BINDING = 'unknown agent, or not one this client may act for';
+
+/**
+ * Mint a token for `agentId` on behalf of `host` — { kind: 'app', app } or { kind: 'service', clientId } — already
+ * authenticated by the caller. → { status, body } (an OAuth token response or error).
+ */
+async function mint(db, { agentId, host, audience, scope, privateKey, issuer, settings, ctx }) {
+ const hostSub = host.kind === 'app' ? `app:${host.app.id}` : `svc:${host.clientId}`;
+ const agentOk = agents.AGENT_ID_RE.test(String(agentId || ''));
+ const agent = agentOk ? await db.prepare('SELECT * FROM dev_agents WHERE id = ?').get(String(agentId)) : null;
+ const refuse = async (error, description, reason, detail = {}) => {
+ await store.audit(db, { projectId: agent ? agent.project_id : null, actor: hostSub, action: 'agent.token_refused',
+ target: agentOk ? `agent:${agentId}` : null, detail: { error, reason, audience: audience ? String(audience).slice(0, 100) : undefined, ...detail }, ctx });
+ return { status: 400, body: { error, error_description: description } };
+ };
+ const binding = await bindingFailure(db, agent, host);
+ if (binding) return await refuse('invalid_grant', BINDING, binding);
+ const aud = String(audience || '').trim();
+ if (!aud || !/^[a-z0-9.-]+$/.test(aud)) return await refuse('invalid_request', 'audience is required', 'no audience');
+ if (agent.environment === 'sandbox' && !settings.sandboxAudiences.has(aud)) {
+ return await refuse('invalid_target', `${aud} does not accept sandbox tokens`, 'sandbox audience');
+ }
+
+ const held = await heldNow(db, agent, aud, settings);
+ const wanted = scope ? String(scope).split(/\s+/).filter(Boolean) : null;
+ const missing = wanted ? wanted.filter((w) => !held.has(w)) : [];
+ if (missing.length) return await refuse('invalid_scope', `not delegated: ${missing.join(' ')}`, 'scope not held', { missing });
+ const chosen = wanted ? [...new Set(wanted)].sort() : [...held.keys()].sort();
+ if (!chosen.length) return await refuse('invalid_scope', `no delegated grants for audience ${aud}`, 'nothing held');
+ const cap = chosen.filter((c) => held.get(c) === 'auto');
+ const capConfirm = chosen.filter((c) => held.get(c) === 'confirm');
+
+ const now = Math.floor(Date.now() / 1000);
+ const claims = {
+ iss: issuer, sub: `agent:${agent.id}`, actor_type: 'agent', aud: [aud], cap,
+ ...(capConfirm.length ? { cap_confirm: capConfirm } : {}),
+ ns: await namespaces(db, agent, aud, chosen),
+ project_id: agent.project_id, env: agent.environment, on_behalf_of: agent.owner_subject, act: { sub: hostSub },
+ iat: now, exp: now + TOKEN_TTL_S, jti: `tok_${crypto.randomBytes(12).toString('hex')}`,
+ };
+ assertValid('identity.service-token-claims@1', claims);
+ const token = serviceAuth.signServiceToken(claims, privateKey);
+ await store.audit(db, { projectId: agent.project_id, actor: hostSub, action: 'agent.token_issued', target: `agent:${agent.id}`,
+ detail: { audience: aud, cap, cap_confirm: capConfirm, jti: claims.jti, expires_at: new Date(claims.exp * 1000).toISOString() }, ctx });
+ return { status: 200, body: { access_token: token, token_type: 'Bearer', expires_in: TOKEN_TTL_S, scope: chosen.join(' ') } };
+}
+
+/** Why `host` may not mint for `agent` now (an internal reason for the audit row), or null. */
+async function bindingFailure(db, agent, host) {
+ if (!agent) return 'unknown agent';
+ if (host.kind === 'app') {
+ if (agent.host_kind !== 'app' || agent.host_app_id !== host.app.id) return 'not the agent\'s host';
+ // The 0016 foreign key already ties these; re-read so a future change to it cannot widen tokens.
+ if (host.app.project_id !== agent.project_id || host.app.environment !== agent.environment) return 'host app of another project or environment';
+ if (host.app.revoked_at) return 'host app revoked';
+ } else if (agent.host_kind !== 'service' || agent.host_service !== host.clientId) return 'not the agent\'s host';
+ if (agent.status !== 'active') return `agent ${agent.status}`;
+ const project = await db.prepare('SELECT archived_at FROM dev_projects WHERE id = ?').get(agent.project_id);
+ if (!project || project.archived_at) return 'project archived';
+ if (!await store.memberRole(db, agent.project_id, agent.owner_subject)) return 'owner no longer a member';
+ const owner = await db.prepare('SELECT is_banned, deleted_at FROM users WHERE subject_id = ?').get(agent.owner_subject);
+ if (!owner || Number(owner.is_banned) || owner.deleted_at) return 'owner account not active';
+ return null;
+}
+
+/**
+ * The capabilities the agent holds at `audience` now → Map(capability → 'auto' | 'confirm'): its active, unexpired
+ * delegated grants, inside the host's ceiling, without a budget of 0 (the per-capability off switch).
+ */
+async function heldNow(db, agent, audience, settings) {
+ const now = new Date().toISOString();
+ const grants = (await db.prepare("SELECT * FROM dev_agent_grants WHERE agent_id = ? AND audience = ? AND status = 'active' ORDER BY capability").all(agent.id, audience))
+ .filter((g) => !g.expires_at || g.expires_at > now);
+ if (!grants.length) return new Map();
+ const ceiling = await agents.ceiling(db, agent, audience, settings);
+ const off = new Set((await db.prepare('SELECT capability FROM dev_agent_budgets WHERE agent_id = ? AND limit_value = 0').all(agent.id)).map((b) => b.capability));
+ return new Map(grants.filter((g) => ceiling.has(g.capability) && !off.has(g.capability)).map((g) => [g.capability, agents.effectiveMode(g)]));
+}
+
+/**
+ * ns: an app host's agent names what the app's own token names. A service host's agent names the project's namespaces
+ * that the host's own principal_grants rows for these capabilities cover (contracts' namespace matcher); none → [].
+ */
+async function namespaces(db, agent, audience, chosen) {
+ const project = projectNamespaces(agent.project_id);
+ if (agent.host_kind === 'app') return project;
+ const rows = (await require('../identity/principals').grantsFor(db, agent.host_service, audience)).filter((g) => chosen.includes(g.capability));
+ return project.filter((n) => rows.some((g) => capabilities.namespaceAllowed(g.namespaces, n)));
+}
+
+module.exports = { mint };
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
* - Budgets (slice 5, migrations/0019_agent_budgets.sql): the owner or an admin+ caps a capability the agent holds,
* - Budgets (slice 5, migrations/0019_agent_budgets.sql): the owner or an admin+ caps a capability the agent holds,
* never above the project's quota for it; revoking the grant deletes its budget. The owning service meters.
* never above the project's quota for it; revoking the grant deletes its budget. The owning service meters.
* - The owning service reads what it enforces at GET /internal/agents/:agent (slice 6, internalAgentView): its own
* - The owning service reads what it enforces at GET /internal/agents/:agent (slice 6, internalAgentView): its own
- * audience's grants and their budgets, never another service's. Agent tokens come in a later slice (section 8).
+ * audience's grants and their budgets, never another service's. Its host mints its tokens (slice 8, ./agent-tokens.js).
*/
*/
const { ids, capabilities } = require('openvibe-contracts');
const { ids, capabilities } = require('openvibe-contracts');
const policy = require('./policy');
const policy = require('./policy');
@@ -453,6 +453,6 @@ async function internalAgentView(db, agentId, audience) {
@@ -453,6 +453,6 @@ async function internalAgentView(db, agentId, audience) {
module.exports = {
module.exports = {
ensureSchema, AGENT_ID_RE, agentView, loadAgent, atLeast, listAgents, getAgent, createAgent, renameAgent, changeStatus, revokeWhere,
ensureSchema, AGENT_ID_RE, agentView, loadAgent, atLeast, listAgents, getAgent, createAgent, renameAgent, changeStatus, revokeWhere,
- effectiveMode, withinHost, listGrants, putGrant, deleteGrant, revokeBeyondHost,
+ effectiveMode, ceiling, withinHost, listGrants, putGrant, deleteGrant, revokeBeyondHost,
budgetView, listBudgets, setBudget, deleteBudget, internalAgentView,
budgetView, listBudgets, setBudget, deleteBudget, internalAgentView,
};
};
@@ -16,10 +16,14 @@
@@ -16,10 +16,14 @@
* - Every change that takes authority away calls `cancelFor` in its own transaction: the pending and approved-unused
* - Every change that takes authority away calls `cancelFor` in its own transaction: the pending and approved-unused
* rows it affects become `cancelled` with the cause, and the matching standing rules are revoked. Resuming an agent
* rows it affects become `cancelled` with the cause, and the matching standing rules are revoked. Resuming an agent
* revives nothing.
* revives nothing.
- * - Every transition is a dev_audit row without an event (Contracts has no payload yet, slice 9). Audit rows never
- * carry `summary` or `details`, which may hold message text.
+ * - Every transition is a dev_audit row carrying a network.confirmation.changed@1 event (slice 9), written with the
+ * state change in one transaction, so the Events outbox (store.audit) relays it exactly when the change commits.
+ * Each transition is a conditional UPDATE that only one writer wins, so a confirmation emits each change once:
+ * a second sweep, a repeated cancel or a lost race writes nothing. A standing-rule approval is one `created`
+ * event in state `approved` with its `rule_id`. Neither the audit row nor the event carries `summary`, `details`,
+ * the request digest or a label; the event names the owner only as the envelope's `on_behalf_of`.
*/
*/
-const { ids, validate, capabilities } = require('openvibe-contracts');
+const { ids, validate, assertValid, capabilities } = require('openvibe-contracts');
const policy = require('./policy');
const policy = require('./policy');
const store = require('./store');
const store = require('./store');
const agents = require('./agents');
const agents = require('./agents');
@@ -64,9 +68,28 @@ function ruleView(r) {
@@ -64,9 +68,28 @@ function ruleView(r) {
const agentContext = (a) => (a ? { name: a.name, project_id: a.project_id, host: agents.agentView(a).host } : null);
const agentContext = (a) => (a ? { name: a.name, project_id: a.project_id, host: agents.agentView(a).host } : null);
-async function audit(db, c, action, { actor, detail, ctx } = {}) {
+async function audit(db, c, action, { actor, detail, ctx, event } = {}) {
await store.audit(db, { projectId: c.project_id, actor: actor || 'system:network', action, target: `confirmation:${c.id}`,
await store.audit(db, { projectId: c.project_id, actor: actor || 'system:network', action, target: `confirmation:${c.id}`,
- detail: { agent: c.agent_id, capability: c.capability, ...detail }, ctx });
+ detail: { agent: c.agent_id, capability: c.capability, ...detail }, ctx, event });
+}
+
+const SYSTEM = { type: 'system', id: 'network' };
+
+/**
+ * The network.confirmation.changed@1 event of one transition of row `c`: `change` is what happened, `state` the state
+ * after it. The envelope's actor is the deciding owner for approve/deny (the audit row's user) and the system for
+ * everything else; on_behalf_of is the owner. expires_at rides on `created` and `approved`, as the contract's fixtures.
+ */
+function changed(c, change, state, { standingRule, ruleId, cancelReason, at = nowIso(), byOwner = false } = {}) {
+ const payload = {
+ confirmation_id: c.id, agent_id: c.agent_id, project_id: c.project_id, capability: c.capability, audience: c.audience, state, change,
+ ...(standingRule ? { standing_rule: standingRule } : {}), ...(ruleId != null ? { rule_id: Number(ruleId) } : {}),
+ ...(cancelReason ? { cancel_reason: cancelReason } : {}), ...(change === 'created' || change === 'approved' ? { expires_at: c.expires_at } : {}),
+ changed_at: at,
+ };
+ assertValid('network.confirmation.changed@1', payload);
+ return { type: 'network.confirmation.changed', subject: { type: 'confirmation', id: c.id }, payload,
+ ...(byOwner ? {} : { actor: SYSTEM }), on_behalf_of: { type: 'user', id: c.owner_subject } };
}
}
// ── Authority, as at creation and again at consume ─────────────
// ── Authority, as at creation and again at consume ─────────────
@@ -127,7 +150,7 @@ async function create(db, { agentId, capability, audience, summary, details, res
@@ -127,7 +150,7 @@ async function create(db, { agentId, capability, audience, summary, details, res
AND (rule = 'always' OR (rule = 'until' AND until_at > ?) OR (rule = 'session' AND session_id = ? AND until_at > ?)) ORDER BY id LIMIT 1`)
AND (rule = 'always' OR (rule = 'until' AND until_at > ?) OR (rule = 'session' AND session_id = ? AND until_at > ?)) ORDER BY id LIMIT 1`)
.get(agent.id, capability, now, sessionId == null ? null : String(sessionId), now);
.get(agent.id, capability, now, sessionId == null ? null : String(sessionId), now);
const row = {
const row = {
- id, project_id: agent.project_id, agent_id: agent.id, owner_subject: agent.owner_subject, capability, summary, details: detailsJson,
+ id, project_id: agent.project_id, agent_id: agent.id, owner_subject: agent.owner_subject, capability, audience, summary, details: detailsJson,
resources: JSON.stringify(resources || []), state: rule ? 'approved' : 'pending', standing_rule: rule ? rule.rule : null, expires_at: new Date(Date.now() + ttl * 1000).toISOString(),
resources: JSON.stringify(resources || []), state: rule ? 'approved' : 'pending', standing_rule: rule ? rule.rule : null, expires_at: new Date(Date.now() + ttl * 1000).toISOString(),
created_at: now, decided_at: rule ? now : null,
created_at: now, decided_at: rule ? now : null,
};
};
@@ -138,7 +161,8 @@ async function create(db, { agentId, capability, audience, summary, details, res
@@ -138,7 +161,8 @@ async function create(db, { agentId, capability, audience, summary, details, res
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(id, row.project_id, row.agent_id, row.owner_subject, capability, audience, summary, row.details, row.resources, row.state, row.standing_rule,
.run(id, row.project_id, row.agent_id, row.owner_subject, capability, audience, summary, row.details, row.resources, row.state, row.standing_rule,
sessionId == null ? null : String(sessionId), requestDigest, rule ? rule.id : null, row.expires_at, now, row.decided_at, rule ? `rule:${rule.id}` : null);
sessionId == null ? null : String(sessionId), requestDigest, rule ? rule.id : null, row.expires_at, now, row.decided_at, rule ? `rule:${rule.id}` : null);
- await audit(db, row, 'confirmation.created', { actor: `service:${audience}`, detail: { state: row.state }, ctx });
+ await audit(db, row, 'confirmation.created', { actor: `service:${audience}`, detail: { state: row.state }, ctx,
+ event: changed(row, 'created', row.state, { ruleId: rule ? rule.id : null, at: now }) });
if (rule) await audit(db, row, 'confirmation.approved', { actor: `rule:${rule.id}`, detail: { rule_id: Number(rule.id), standing_rule: rule.rule }, ctx });
if (rule) await audit(db, row, 'confirmation.approved', { actor: `rule:${rule.id}`, detail: { rule_id: Number(rule.id), standing_rule: rule.rule }, ctx });
});
});
return confirmationView(await db.prepare('SELECT * FROM dev_confirmations WHERE id = ?').get(id));
return confirmationView(await db.prepare('SELECT * FROM dev_confirmations WHERE id = ?').get(id));
@@ -167,7 +191,7 @@ async function consume(db, { id, audience, requestDigest }, { ctx, settings } =
@@ -167,7 +191,7 @@ async function consume(db, { id, audience, requestDigest }, { ctx, settings } =
const r = await db.prepare("UPDATE dev_confirmations SET used_at = ? WHERE id = ? AND audience = ? AND state = 'approved' AND used_at IS NULL AND expires_at > ? AND request_digest = ?")
const r = await db.prepare("UPDATE dev_confirmations SET used_at = ? WHERE id = ? AND audience = ? AND state = 'approved' AND used_at IS NULL AND expires_at > ? AND request_digest = ?")
.run(now, c.id, audience, now, String(requestDigest));
.run(now, c.id, audience, now, String(requestDigest));
if (r.changes !== 1) fail(409, 'confirmation.used', 'the confirmation was already used');
if (r.changes !== 1) fail(409, 'confirmation.used', 'the confirmation was already used');
- await audit(db, c, 'confirmation.used', { actor: `service:${audience}`, ctx });
+ await audit(db, c, 'confirmation.used', { actor: `service:${audience}`, ctx, event: changed(c, 'used', 'approved', { at: now }) });
return { confirmation: confirmationView({ ...c, used_at: now }), used_at: now };
return { confirmation: confirmationView({ ...c, used_at: now }), used_at: now };
});
});
}
}
@@ -196,26 +220,31 @@ async function cancel(db, { id, audience }, { ctx } = {}) {
@@ -196,26 +220,31 @@ async function cancel(db, { id, audience }, { ctx } = {}) {
const state = c.used_at ? 'used' : stateOf(c);
const state = c.used_at ? 'used' : stateOf(c);
if (state === 'cancelled') return;
if (state === 'cancelled') return;
if (state !== 'pending' && state !== 'approved') fail(409, 'confirmation.not_pending', `confirmation is ${state}`);
if (state !== 'pending' && state !== 'approved') fail(409, 'confirmation.not_pending', `confirmation is ${state}`);
- await db.prepare(`UPDATE dev_confirmations SET state = 'cancelled', cancel_reason = 'service' WHERE id = ? AND ${LIVE}`).run(c.id);
- await audit(db, c, 'confirmation.cancelled', { actor: `service:${audience}`, detail: { from: c.state, reason: 'service' }, ctx });
+ const r = await db.prepare(`UPDATE dev_confirmations SET state = 'cancelled', cancel_reason = 'service' WHERE id = ? AND ${LIVE}`).run(c.id);
+ if (!r.changes) return;
+ await audit(db, c, 'confirmation.cancelled', { actor: `service:${audience}`, detail: { from: c.state, reason: 'service' }, ctx,
+ event: changed(c, 'cancelled', 'cancelled', { cancelReason: 'service' }) });
});
});
return { confirmation: confirmationView(await serviceRow(db, id, audience)) };
return { confirmation: confirmationView(await serviceRow(db, id, audience)) };
}
}
/**
/**
- * The cascade, in the caller's transaction: cancel the pending and approved-unused confirmations of `agentId` (only
- * those for `capability` when given) with `reason`, one audit row each, and revoke the matching live standing rules.
+ * The cascade, in the caller's transaction: cancel the pending and approved-unused confirmations of `agentId` still
+ * inside their ttl (only those for `capability` when given) with `reason`, one audit row and event each, and revoke the
+ * matching live standing rules.
* → how many confirmations were cancelled.
* → how many confirmations were cancelled.
*/
*/
async function cancelFor(db, { agentId, capability = null, reason, actor = 'system:network' }, { ctx } = {}) {
async function cancelFor(db, { agentId, capability = null, reason, actor = 'system:network' }, { ctx } = {}) {
const capSql = capability ? ' AND capability = ?' : '';
const capSql = capability ? ' AND capability = ?' : '';
const params = capability ? [agentId, capability] : [agentId];
const params = capability ? [agentId, capability] : [agentId];
let n = 0;
let n = 0;
- for (const c of await db.prepare(`SELECT * FROM dev_confirmations WHERE agent_id = ?${capSql} AND ${LIVE} ORDER BY id`).all(...params)) {
- const r = await db.prepare(`UPDATE dev_confirmations SET state = 'cancelled', cancel_reason = ? WHERE id = ? AND ${LIVE}`).run(reason, c.id);
+ // A row past expires_at is already expired (stateOf): the sweep records a pending one, an approved one stays spent-proof.
+ const at = nowIso();
+ for (const c of await db.prepare(`SELECT * FROM dev_confirmations WHERE agent_id = ?${capSql} AND ${LIVE} AND expires_at > ? ORDER BY id`).all(...params, at)) {
+ const r = await db.prepare(`UPDATE dev_confirmations SET state = 'cancelled', cancel_reason = ? WHERE id = ? AND ${LIVE} AND expires_at > ?`).run(reason, c.id, at);
if (!r.changes) continue;
if (!r.changes) continue;
n++;
n++;
- await audit(db, c, 'confirmation.cancelled', { actor, detail: { from: c.state, reason }, ctx });
+ await audit(db, c, 'confirmation.cancelled', { actor, detail: { from: c.state, reason }, ctx, event: changed(c, 'cancelled', 'cancelled', { cancelReason: reason }) });
}
}
const t = nowIso();
const t = nowIso();
for (const rule of await db.prepare(`SELECT * FROM dev_standing_rules WHERE agent_id = ?${capSql} AND revoked_at IS NULL ORDER BY id`).all(...params)) {
for (const rule of await db.prepare(`SELECT * FROM dev_standing_rules WHERE agent_id = ?${capSql} AND revoked_at IS NULL ORDER BY id`).all(...params)) {
@@ -242,7 +271,7 @@ async function expireDue(db) {
@@ -242,7 +271,7 @@ async function expireDue(db) {
const r = await db.prepare("UPDATE dev_confirmations SET state = 'expired' WHERE id = ? AND state = 'pending' AND expires_at <= ?").run(c.id, nowIso());
const r = await db.prepare("UPDATE dev_confirmations SET state = 'expired' WHERE id = ? AND state = 'pending' AND expires_at <= ?").run(c.id, nowIso());
if (!r.changes) return;
if (!r.changes) return;
n++;
n++;
- await audit(db, c, 'confirmation.expired');
+ await audit(db, c, 'confirmation.expired', { event: changed(c, 'expired', 'expired') });
});
});
}
}
return n;
return n;
@@ -302,8 +331,8 @@ async function decide(db, actor, id, action, body = {}, { ctx } = {}) {
@@ -302,8 +331,8 @@ async function decide(db, actor, id, action, body = {}, { ctx } = {}) {
const c = await ownRow(db, actor, id, { lock: true });
const c = await ownRow(db, actor, id, { lock: true });
const now = nowIso();
const now = nowIso();
if (c.state === 'pending' && c.expires_at <= now) {
if (c.state === 'pending' && c.expires_at <= now) {
- await db.prepare("UPDATE dev_confirmations SET state = 'expired' WHERE id = ? AND state = 'pending'").run(c.id);
- await audit(db, c, 'confirmation.expired', { ctx });
+ const r = await db.prepare("UPDATE dev_confirmations SET state = 'expired' WHERE id = ? AND state = 'pending'").run(c.id);
+ if (r.changes) await audit(db, c, 'confirmation.expired', { ctx, event: changed(c, 'expired', 'expired', { at: now }) });
return true;
return true;
}
}
if (c.state !== 'pending') fail(409, 'confirmation.not_pending', `confirmation is ${stateOf(c, now)}`);
if (c.state !== 'pending') fail(409, 'confirmation.not_pending', `confirmation is ${stateOf(c, now)}`);
@@ -319,7 +348,8 @@ async function decide(db, actor, id, action, body = {}, { ctx } = {}) {
@@ -319,7 +348,8 @@ async function decide(db, actor, id, action, body = {}, { ctx } = {}) {
const to = action === 'approve' ? 'approved' : 'denied';
const to = action === 'approve' ? 'approved' : 'denied';
await db.prepare("UPDATE dev_confirmations SET state = ?, standing_rule = ?, decided_at = ?, decided_by = ? WHERE id = ? AND state = 'pending'")
await db.prepare("UPDATE dev_confirmations SET state = ?, standing_rule = ?, decided_at = ?, decided_by = ? WHERE id = ? AND state = 'pending'")
.run(to, standing, now, actor.label, c.id);
.run(to, standing, now, actor.label, c.id);
- await audit(db, c, `confirmation.${to}`, { actor: actor.label, detail: standing ? { standing_rule: standing, rule_id: ruleId == null ? undefined : Number(ruleId) } : {}, ctx });
+ await audit(db, c, `confirmation.${to}`, { actor: actor.label, detail: standing ? { standing_rule: standing, rule_id: ruleId == null ? undefined : Number(ruleId) } : {}, ctx,
+ event: changed(c, to, to, { standingRule: standing, ruleId, at: now, byOwner: true }) });
return false;
return false;
});
});
if (expired) fail(409, 'confirmation.expired', 'the confirmation has expired');
if (expired) fail(409, 'confirmation.expired', 'the confirmation has expired');
@@ -47,7 +47,8 @@ function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); no
@@ -47,7 +47,8 @@ function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); no
/**
/**
* Append one audit row. `actor` is a subject string ('user:usr_…', 'system:network').
* Append one audit row. `actor` is a subject string ('user:usr_…', 'system:network').
- * With `event` = { type, subject: { type, id }, payload }, the row also carries an event envelope.
+ * With `event` = { type, subject: { type, id }, payload, actor?, on_behalf_of? }, the row also carries an event
+ * envelope; its actor is the row's user, else the system, unless `event.actor` (a SubjectRef) says otherwise.
* `detail` must never contain secret material (callers pass ids, hints and names only).
* `detail` must never contain secret material (callers pass ids, hints and names only).
*/
*/
async function audit(db, { projectId, actor, action, target, detail, ctx, event }) {
async function audit(db, { projectId, actor, action, target, detail, ctx, event }) {
@@ -57,7 +58,8 @@ async function audit(db, { projectId, actor, action, target, detail, ctx, event
@@ -57,7 +58,8 @@ async function audit(db, { projectId, actor, action, target, detail, ctx, event
envelope = {
envelope = {
event_id: ids.newId('event'),
event_id: ids.newId('event'),
event_type: event.type, version: 1, source: 'network',
event_type: event.type, version: 1, source: 'network',
- actor: atype === 'user' ? { type: 'user', id: aid } : { type: 'system', id: 'network' },
+ actor: event.actor || (atype === 'user' ? { type: 'user', id: aid } : { type: 'system', id: 'network' }),
+ ...(event.on_behalf_of ? { on_behalf_of: event.on_behalf_of } : {}),
timestamp: nowIso(), visibility: 'internal', subject: event.subject, payload: event.payload || {},
timestamp: nowIso(), visibility: 'internal', subject: event.subject, payload: event.payload || {},
};
};
if (ctx && /^[0-9a-f]{32}$/.test(ctx.traceId || '')) envelope.trace_id = ctx.traceId;
if (ctx && /^[0-9a-f]{32}$/.test(ctx.traceId || '')) envelope.trace_id = ctx.traceId;
@@ -19,6 +19,8 @@
@@ -19,6 +19,8 @@
* (DEV_SANDBOX_AUDIENCES); receivers also refuse env=sandbox unless they opted in.
* (DEV_SANDBOX_AUDIENCES); receivers also refuse env=sandbox unless they opted in.
* No refresh tokens: a revoked credential or app stops new tokens at once, and issued tokens end
* No refresh tokens: a revoked credential or app stops new tokens at once, and issued tokens end
* within their 5-minute lifetime.
* within their 5-minute lifetime.
+ * With `agent=agt_…` (client_credentials only) the app asks for a token of an agent it hosts instead
+ * (./agent-tokens.js, plan T2 WS-Z2 slice 8); without it, app tokens are unchanged.
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
const { serviceAuth, assertValid } = require('openvibe-contracts');
const { serviceAuth, assertValid } = require('openvibe-contracts');
@@ -155,15 +157,20 @@ async function authenticate(db, app, clientSecret) {
@@ -155,15 +157,20 @@ async function authenticate(db, app, clientSecret) {
}
}
/** /oauth/token for an app client id. Returns { status, body }. */
/** /oauth/token for an app client id. Returns { status, body }. */
-async function handleTokenRequest(db, body, { privateKey, issuer, config }) {
+async function handleTokenRequest(db, body, { privateKey, issuer, config, ctx }) {
const settings = policy.settings(config);
const settings = policy.settings(config);
const found = await usableApp(db, body.client_id);
const found = await usableApp(db, body.client_id);
if (found.error) return found.error;
if (found.error) return found.error;
const { app, project } = found;
const { app, project } = found;
+ if (body.agent !== undefined && body.grant_type !== 'client_credentials') return oauthError(400, 'unsupported_grant_type', 'agent tokens use client_credentials');
if (body.grant_type === 'client_credentials') {
if (body.grant_type === 'client_credentials') {
if (app.client_type !== 'confidential') return oauthError(400, 'unauthorized_client', 'public apps use authorization_code with PKCE');
if (app.client_type !== 'confidential') return oauthError(400, 'unauthorized_client', 'public apps use authorization_code with PKCE');
const bad = await authenticate(db, app, body.client_secret);
const bad = await authenticate(db, app, body.client_secret);
if (bad) return bad;
if (bad) return bad;
+ // The app hosts an agent (slice 8): its token, not the app's.
+ if (body.agent !== undefined) {
+ return await require('./agent-tokens').mint(db, { agentId: body.agent, host: { kind: 'app', app }, audience: body.audience, scope: body.scope, privateKey, issuer, settings, ctx });
+ }
return await mint({ app, project, audience: body.audience, scope: body.scope, privateKey, issuer, settings, db });
return await mint({ app, project, audience: body.audience, scope: body.scope, privateKey, issuer, settings, db });
}
}
if (body.grant_type === 'authorization_code') {
if (body.grant_type === 'authorization_code') {
@@ -308,10 +308,15 @@ function sameSecret(a, b) {
@@ -308,10 +308,15 @@ 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.
*/
*/
-async function issueToken(db, { clientId, clientSecret, audience, scope, privateKey, issuer }) {
+async function issueToken(db, { clientId, clientSecret, audience, scope, privateKey, issuer, agent, settings, ctx }) {
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' } };
+ // The service hosts an agent (plan T2 WS-Z2 slice 8): the agent's token, bounded by this service's own grants.
+ if (agent !== undefined) {
+ return await require('../developer/agent-tokens').mint(db, { agentId: agent, host: { kind: 'service', clientId: client.client_id }, audience, scope, privateKey, issuer,
+ settings: settings || require('../developer/policy').settings(), ctx });
+ }
const aud = String(audience || '').trim();
const aud = String(audience || '').trim();
if (!aud) return { status: 400, body: { error: 'invalid_request', error_description: 'audience is required' } };
if (!aud) return { status: 400, body: { error: 'invalid_request', error_description: 'audience is required' } };
const grants = await grantsFor(db, client.client_id, aud);
const grants = await grantsFor(db, client.client_id, aud);
@@ -384,6 +389,12 @@ function guard(capability, { ownApp, namespace } = {}) {
@@ -384,6 +389,12 @@ function guard(capability, { ownApp, namespace } = {}) {
}
}
check(req, res, () => {
check(req, res, () => {
const principal = req.principal;
const principal = req.principal;
+ // Agent tokens (plan T2 WS-Z2 slice 8) act at the owning services; no Network route takes one yet.
+ if (principal && /^agent:/.test(String(principal.sub))) {
+ audits.push(record({ req, capability, principal, allowed: false, code: 'capability.denied' }));
+ require('../observability').principalDenied({ req, code: 'capability.denied' });
+ return http.sendProblem(res, 403, 'capability.denied', { detail: 'agent tokens are not accepted here', ctx: req.ov });
+ }
// Developer sandbox tokens (env: sandbox) are refused unless Network opted in as an audience
// Developer sandbox tokens (env: sandbox) are refused unless Network opted in as an audience
// (DEV_SANDBOX_AUDIENCES); the signature was verified by check() above.
// (DEV_SANDBOX_AUDIENCES); the signature was verified by check() above.
if (principal) {
if (principal) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,233 @@
@@ -0,0 +1,233 @@
+'use strict';
+// Agent tokens (plan T2 WS-Z2 slice 8, docs/t2-projects-and-grants.md section 4 "Agent tokens";
+// server/developer/agent-tokens.js): the host mints with agent=agt_… at /oauth/token. The claims validate as
+// identity.service-token-claims@1 (v0.90.0) and carry exactly sub/actor_type agent, aud, cap (auto grants),
+// cap_confirm (confirm grants), ns, project_id, env, on_behalf_of, act, iat/exp/jti; RS256 with Network's key.
+// Refusals: another host, another project's app, an unknown agent, a paused/revoked agent, a banned owner, an archived
+// project (400 invalid_grant, one message); a revoked or expired grant, a budget of 0, a capability outside the host's
+// ceiling (left out; asked by scope: 400 invalid_scope); a sandbox agent at an audience without sandbox tokens
+// (invalid_target). A confirm-mode capability is never in cap: its use needs an approval the owning service consumes
+// once, even under concurrency. Service-host namespaces; app tokens without `agent` are unchanged; Network's own
+// guard refuses agent tokens; every issue and refusal is audited without secrets.
+// node test/agent-tokens.test.js
+const assert = require('assert');
+const crypto = require('crypto');
+const http = require('http');
+const express = require('express');
+const jwt = require('jsonwebtoken');
+const { validate, serviceAuth } = require('openvibe-contracts');
+const { getDb } = require('../server/db/database');
+const subjects = require('../server/identity/subjects');
+const confirmations = require('../server/developer/confirmations');
+
+(async () => {
+const log = console.log; console.log = () => {};
+const db = getDb();
+console.log = log;
+await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('actor', 'actor-secret', 'OpenVibe.Actor', '[]', 1)").run();
+await db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
+await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES (10, 'owner', 'x', 'user'), (14, 'staff', 'x', 'admin')`).run();
+const owner = await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = 10').get());
+
+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 = { baseUrl: ISSUER, loginUrl: ISSUER, jwt: { issuer: ISSUER, accessTokenExpiry: '1h' }, developer: { sandboxAllowance: 'media.object.read', agentHostServices: 'actor' } };
+app.locals.privateKey = keys.privateKey;
+app.locals.publicKey = keys.publicKey;
+app.use('/oauth', require('../server/auth/oauth-routes'));
+app.use('/internal', require('../server/internal/routes'));
+app.use('/api/v1/projects', require('../server/developer/routes').router());
+const server = http.createServer(app);
+const U = { owner: jwt.sign({ sub: 10, id: 10 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' }), staff: jwt.sign({ sub: 14, id: 14 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' }) };
+
+(async () => {
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const api = async (who, method, p, body) => {
+ const r = await fetch(`${base}/api/v1/projects${p}`, { method, headers: { authorization: `Bearer ${U[who]}`, ...(body ? { 'content-type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined });
+ const text = await r.text();
+ return { status: r.status, body: text ? JSON.parse(text) : null };
+ };
+ const ok = (r, status = 200) => { assert.strictEqual(r.status, status, JSON.stringify(r.body)); return r.body; };
+ const SECRETS = {};
+ const tokenReq = async (client, params) => {
+ const r = await fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ grant_type: 'client_credentials', client_id: client, client_secret: SECRETS[client], audience: 'openvibe.media', ...params }) });
+ return { status: r.status, body: await r.json() };
+ };
+ const claimsOf = (r) => { assert.strictEqual(r.status, 200, JSON.stringify(r.body)); return jwt.decode(r.body.access_token); };
+ const mint = async (client, agent, params = {}) => claimsOf(await tokenReq(client, { agent, ...params }));
+ const refusedGrant = async (client, agent, error, params = {}) => {
+ const r = await tokenReq(client, { agent, ...params });
+ assert.deepStrictEqual([r.status, r.body.error], [400, error], JSON.stringify(r.body));
+ return r.body;
+ };
+ const capsOf = async (client, agent, params) => { const c = await mint(client, agent, params); return [c.cap, c.cap_confirm || []]; };
+ try {
+ // Project P: production app PR hosts agent A (read + upload auto, delete confirm); PR2 is another app of P;
+ // project Q's app QR; a sandbox app SB hosts agent SA.
+ const P = ok(await api('owner', 'POST', '', { name: 'Agent tokens' }), 201).id;
+ ok(await api('staff', 'PUT', `/${P}/environment-policy`, { environment_policy: 'sandbox+production' }));
+ ok(await api('staff', 'PUT', `/${P}/allowance`, { capabilities: ['media.object.read', 'media.object.upload', 'media.object.delete'] }));
+ const newApp = async (project, name, environment) => {
+ const a = ok(await api('owner', 'POST', `/${project}/apps`, { name, environment }), 201);
+ SECRETS[a.id] = a.credential.client_secret;
+ return a.id;
+ };
+ const PR = await newApp(P, 'Bot', 'production');
+ const PR2 = await newApp(P, 'Other', 'production');
+ const SB = await newApp(P, 'Sandbox bot', 'sandbox');
+ for (const c of ['media.object.read', 'media.object.upload', 'media.object.delete']) ok(await api('owner', 'POST', `/${P}/apps/${PR}/grants`, { capability: c }), 201);
+ ok(await api('owner', 'POST', `/${P}/apps/${SB}/grants`, { capability: 'media.object.read' }), 201);
+ const Q = ok(await api('owner', 'POST', '', { name: 'Other project' }), 201).id;
+ const QR = await newApp(Q, 'Q bot', 'sandbox');
+ const A = ok(await api('owner', 'POST', `/${P}/agents`, { name: 'Helper', host: { type: 'app', id: PR } }), 201).id;
+ ok(await api('owner', 'PUT', `/${P}/agents/${A}/grants/media.object.delete`, {}));
+ ok(await api('owner', 'PUT', `/${P}/agents/${A}/grants/media.object.upload`, { mode: 'auto' }));
+ ok(await api('owner', 'PUT', `/${P}/agents/${A}/grants/media.object.read`, { mode: 'auto' }));
+ const SA = ok(await api('owner', 'POST', `/${P}/agents`, { name: 'Sandbox helper', host: { type: 'app', id: SB } }), 201).id;
+ ok(await api('owner', 'PUT', `/${P}/agents/${SA}/grants/media.object.read`, { mode: 'auto' }));
+
+ // ── The claims: exactly the v0.90.0 agent shape, signed by Network's RS256 key ──
+ const res = await tokenReq(PR, { agent: A });
+ const claims = claimsOf(res);
+ const v = validate('identity.service-token-claims@1', claims);
+ assert.ok(v.valid, JSON.stringify(v.errors));
+ const ownApp = claimsOf(await tokenReq(PR, {}));
+ assert.deepStrictEqual(claims, {
+ iss: ISSUER, sub: `agent:${A}`, actor_type: 'agent', aud: ['openvibe.media'], cap: ['media.object.read', 'media.object.upload'],
+ cap_confirm: ['media.object.delete'], ns: ownApp.ns, project_id: P, env: 'production', on_behalf_of: owner, act: { sub: `app:${PR}` },
+ iat: claims.iat, exp: claims.iat + 300, jti: claims.jti,
+ });
+ assert.match(claims.jti, /^tok_[0-9a-f]{24}$/);
+ assert.deepStrictEqual([res.body.token_type, res.body.expires_in, res.body.scope], ['Bearer', 300, 'media.object.delete media.object.read media.object.upload']);
+ const verified = serviceAuth.verifyServiceToken(res.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.media' });
+ assert.ok(verified.ok, verified.reason);
+ assert.strictEqual(jwt.decode(res.body.access_token, { complete: true }).header.alg, 'RS256');
+ // The host's own app token is what it was: no agent claims.
+ assert.deepStrictEqual(Object.keys(ownApp).sort(), ['actor_type', 'aud', 'cap', 'env', 'exp', 'iat', 'iss', 'jti', 'ns', 'project_id', 'sub']);
+ assert.deepStrictEqual([ownApp.sub, ownApp.actor_type, ownApp.cap, ownApp.ns], [`app:${PR}`, 'app', ['media.object.delete', 'media.object.read', 'media.object.upload'], [P, `app.${P}.*`]]);
+ // A scope narrows; a confirm-mode capability alone gives cap [] and cap_confirm, never cap.
+ assert.deepStrictEqual(await capsOf(PR, A, { scope: 'media.object.delete' }), [[], ['media.object.delete']]);
+ assert.ok(!('cap_confirm' in await mint(PR, A, { scope: 'media.object.read' })), 'no cap_confirm when there is none');
+ await refusedGrant(PR, A, 'invalid_scope', { scope: 'media.object.list' });
+
+ // ── Host binding: one answer for all of them ──
+ const unknown = await refusedGrant(PR, `agt_${'9'.repeat(26)}`, 'invalid_grant');
+ assert.deepStrictEqual(await refusedGrant(PR2, A, 'invalid_grant'), unknown, 'another app of the project');
+ assert.deepStrictEqual(await refusedGrant(QR, A, 'invalid_grant', { audience: 'openvibe.media' }), unknown, 'an app of another project');
+ assert.deepStrictEqual(await refusedGrant(PR, 'agt_nope', 'invalid_grant'), unknown);
+ assert.deepStrictEqual(await refusedGrant(PR, SA, 'invalid_grant'), unknown, 'an agent of another host');
+ SECRETS.live = 'live-secret';
+ assert.deepStrictEqual(await refusedGrant('live', A, 'invalid_grant'), unknown, 'a service that is not the host');
+ const badSecret = await fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ grant_type: 'client_credentials', client_id: PR, client_secret: 'ovsec_wrong', audience: 'openvibe.media', agent: A }) });
+ assert.strictEqual(badSecret.status, 401);
+ const code = await fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ grant_type: 'authorization_code', client_id: PR, client_secret: SECRETS[PR], code: 'x', audience: 'openvibe.media', agent: A }) });
+ assert.deepStrictEqual([code.status, (await code.json()).error], [400, 'unsupported_grant_type']);
+ // A paused agent, a banned owner.
+ await db.prepare('UPDATE dev_agents SET status = ? WHERE id = ?').run('paused', A);
+ await refusedGrant(PR, A, 'invalid_grant');
+ await db.prepare("UPDATE dev_agents SET status = 'active' WHERE id = ?").run(A);
+ await db.prepare('UPDATE users SET is_banned = 1 WHERE id = 10').run();
+ await refusedGrant(PR, A, 'invalid_grant');
+ await db.prepare('UPDATE users SET is_banned = 0 WHERE id = 10').run();
+ await mint(PR, A);
+ // A sandbox agent only for audiences that take sandbox tokens.
+ await refusedGrant(SB, SA, 'invalid_target', { audience: 'openvibe.chat' });
+ assert.strictEqual((await mint(SB, SA)).env, 'sandbox');
+
+ // ── Grants: revoked, expired, a budget of 0 and the ceiling each leave the capability out ──
+ ok(await api('owner', 'PUT', `/${P}/agents/${A}/budgets/media.object.upload`, { limit: 0, window: 'day' }));
+ assert.deepStrictEqual(await capsOf(PR, A), [['media.object.read'], ['media.object.delete']], 'budget 0 is the off switch');
+ await refusedGrant(PR, A, 'invalid_scope', { scope: 'media.object.upload' });
+ ok(await api('owner', 'PUT', `/${P}/agents/${A}/budgets/media.object.upload`, { limit: 5, window: 'day' }));
+ assert.deepStrictEqual((await capsOf(PR, A))[0], ['media.object.read', 'media.object.upload']);
+ ok(await api('owner', 'PUT', `/${P}/agents/${A}/budgets/media.object.delete`, { limit: 0, window: 'day' }));
+ assert.deepStrictEqual(await capsOf(PR, A), [['media.object.read', 'media.object.upload'], []], 'budget 0 drops a confirm one too');
+ await refusedGrant(PR, A, 'invalid_scope', { scope: 'media.object.delete' });
+ ok(await api('owner', 'DELETE', `/${P}/agents/${A}/budgets/media.object.delete`));
+ await db.prepare("UPDATE dev_agent_grants SET expires_at = '2001-01-01T00:00:00.000Z' WHERE agent_id = ? AND capability = 'media.object.upload'").run(A);
+ assert.deepStrictEqual((await capsOf(PR, A))[0], ['media.object.read'], 'an expired grant, before any sweep');
+ await db.prepare("UPDATE dev_agent_grants SET expires_at = NULL WHERE agent_id = ? AND capability = 'media.object.upload'").run(A);
+ // Outside the ceiling without a cascade (the host's app grant flipped underneath): left out at issuance.
+ await db.prepare("UPDATE dev_grants SET status = 'revoked' WHERE app_id = ? AND capability = 'media.object.upload'").run(PR);
+ assert.deepStrictEqual((await capsOf(PR, A))[0], ['media.object.read']);
+ await db.prepare("UPDATE dev_grants SET status = 'approved' WHERE app_id = ? AND capability = 'media.object.upload'").run(PR);
+ ok(await api('owner', 'DELETE', `/${P}/agents/${A}/grants/media.object.read`));
+ assert.deepStrictEqual(await capsOf(PR, A), [['media.object.upload'], ['media.object.delete']], 'a revoked grant');
+ await refusedGrant(PR, A, 'invalid_scope', { scope: 'media.object.read' });
+ await refusedGrant(PR, A, 'invalid_scope', { audience: 'openvibe.events' });
+
+ // ── Confirm mode: the token carries the capability in cap_confirm; each use is one approval, spent once ──
+ const digest = (s) => crypto.createHash('sha256').update(s).digest('hex');
+ const D = digest('DELETE /v1/objects/obj_1');
+ const ask = () => confirmations.create(db, { agentId: A, capability: 'media.object.delete', audience: 'openvibe.media', summary: 'Delete obj_1', requestDigest: D });
+ const actor = { subject: owner, label: `user:${owner}` };
+ const c1 = await ask();
+ const spend = (c, d = D) => confirmations.consume(db, { id: c.id, audience: 'openvibe.media', requestDigest: d });
+ const rejects = (p, code) => assert.rejects(p, (e) => { assert.strictEqual(e.code, code, e.message); return true; });
+ await rejects(spend(c1), 'confirmation.not_pending');
+ await confirmations.decide(db, actor, c1.id, 'approve', {});
+ await rejects(spend(c1, digest('DELETE /v1/objects/obj_2')), 'confirmation.mismatch');
+ const results = await Promise.allSettled([spend(c1), spend(c1), spend(c1)]);
+ assert.strictEqual(results.filter((r) => r.status === 'fulfilled').length, 1, 'single use under concurrency');
+ for (const r of results.filter((x) => x.status === 'rejected')) assert.strictEqual(r.reason.code, 'confirmation.used');
+ const c2 = await ask();
+ await confirmations.decide(db, actor, c2.id, 'approve', {});
+ await db.prepare("UPDATE dev_confirmations SET expires_at = '2001-01-01T00:00:00.000Z' WHERE id = ?").run(c2.id);
+ await rejects(spend(c2), 'confirmation.expired');
+ assert.deepStrictEqual(await capsOf(PR, A), [['media.object.upload'], ['media.object.delete']], 'issuance neither needs nor spends an approval');
+
+ // ── Service host: ns bounded by the host's own principal_grants rows ──
+ SECRETS.actor = 'actor-secret';
+ const grantActor = (cap, ns) => db.prepare("INSERT INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES ('actor', ?, 'openvibe.media', ?, 'test')").run(cap, JSON.stringify(ns));
+ await grantActor('media.object.read', [P]);
+ await grantActor('media.object.delete', ['app.*']);
+ const S = ok(await api('owner', 'POST', `/${P}/agents`, { name: 'Actor agent', host: { type: 'service', id: 'actor' } }), 201).id;
+ ok(await api('owner', 'PUT', `/${P}/agents/${S}/grants/media.object.read`, { mode: 'auto' }));
+ ok(await api('owner', 'PUT', `/${P}/agents/${S}/grants/media.object.delete`, {}));
+ const sc = await mint('actor', S);
+ assert.ok(validate('identity.service-token-claims@1', sc).valid);
+ assert.deepStrictEqual([sc.sub, sc.cap, sc.cap_confirm, sc.ns, sc.env, sc.act], [`agent:${S}`, ['media.object.read'], ['media.object.delete'], [P, `app.${P}.*`], 'production', { sub: 'svc:actor' }]);
+ assert.deepStrictEqual((await mint('actor', S, { scope: 'media.object.read' })).ns, [P], 'only what the read row names');
+ await db.prepare("UPDATE principal_grants SET namespaces = '[]' WHERE client_id = 'actor'").run();
+ assert.deepStrictEqual((await mint('actor', S)).ns, [], 'rows that name no namespace give none');
+ await refusedGrant('actor', A, 'invalid_grant');
+ await refusedGrant(PR, S, 'invalid_grant');
+
+ // ── Network's own routes refuse an agent token ──
+ const now = Math.floor(Date.now() / 1000);
+ const forged = serviceAuth.signServiceToken({ iss: ISSUER, sub: `agent:${A}`, actor_type: 'agent', aud: ['openvibe.network'], cap: ['network.project.read'],
+ project_id: P, env: 'production', on_behalf_of: owner, act: { sub: `app:${PR}` }, iat: now, exp: now + 300, jti: 'tok_agentnet01' }, keys.privateKey);
+ const g = await fetch(`${base}/internal/agents/${A}`, { headers: { authorization: `Bearer ${forged}` } });
+ assert.deepStrictEqual([g.status, (await g.json()).code], [403, 'capability.denied']);
+
+ // ── Revoked agent, archived project ──
+ ok(await api('owner', 'DELETE', `/${P}/agents/${A}`));
+ await refusedGrant(PR, A, 'invalid_grant');
+ await db.prepare("UPDATE dev_projects SET archived_at = '2026-01-01T00:00:00.000Z' WHERE id = ?").run(P);
+ await refusedGrant('actor', S, 'invalid_grant');
+
+ // ── Audit: every issue and refusal, never a secret or a token ──
+ const rows = await db.prepare("SELECT * FROM dev_audit WHERE action IN ('agent.token_issued', 'agent.token_refused') ORDER BY id").all();
+ const issued = rows.filter((r) => r.action === 'agent.token_issued');
+ const refusals = rows.filter((r) => r.action === 'agent.token_refused');
+ assert.ok(issued.some((r) => JSON.parse(r.detail).jti === claims.jti && r.actor === `app:${PR}` && r.target === `agent:${A}` && r.project_id === P));
+ assert.ok(refusals.length >= 15, `refusals audited (${refusals.length})`);
+ assert.ok(refusals.some((r) => JSON.parse(r.detail).reason === 'not the agent\'s host' && r.actor === `app:${PR2}`));
+ assert.ok(refusals.some((r) => JSON.parse(r.detail).reason === 'agent revoked'));
+ assert.ok(refusals.some((r) => r.project_id === null && JSON.parse(r.detail).reason === 'unknown agent'));
+ const text = JSON.stringify(rows);
+ for (const s of [...Object.values(SECRETS), res.body.access_token, 'ovsec_']) assert.ok(!text.includes(s), 'no secret or token in the audit');
+ console.log('agent-tokens: all tests passed');
+ } finally {
+ server.close();
+ }
+})().catch((err) => { console.error(err); process.exit(1); });
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,170 @@
@@ -0,0 +1,170 @@
+'use strict';
+// Confirmation decision events (plan T2 WS-Z2 slice 9, docs/t2-projects-and-grants.md section 5;
+// server/developer/confirmations.js): every transition — created, approved, denied, used, cancelled (by the service
+// or a cascade) and expired (lazily at a decision, or by the sweep) — writes one network.confirmation.changed@1
+// envelope with its audit row, in the transaction of the change, into the Events outbox. Each change is emitted once:
+// a second sweep, a repeated cancel and a refused transition write nothing. Payloads validate against the v0.90.0
+// schema and never carry the summary, details, request digest or a label. An approved but expired confirmation
+// cannot be cancelled (409 confirmation.not_pending) and stays as it was.
+// node test/confirmation-events.test.js
+const assert = require('assert');
+const crypto = require('crypto');
+const http = require('http');
+const express = require('express');
+const jwt = require('jsonwebtoken');
+const { validate } = require('openvibe-contracts');
+const { getDb } = require('../server/db/database');
+const subjects = require('../server/identity/subjects');
+const relay = require('../server/developer/event-relay');
+const confirmations = require('../server/developer/confirmations');
+
+(async () => {
+const log = console.log; console.log = () => {};
+const db = getDb();
+console.log = log;
+await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES (10, 'owner', 'x', 'user'), (14, 'staff', 'x', 'admin')`).run();
+const owner = await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = 10').get());
+
+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.locals.db = db;
+app.locals.config = { baseUrl: ISSUER, loginUrl: ISSUER, jwt: { issuer: ISSUER, accessTokenExpiry: '1h' }, developer: { sandboxAllowance: '' } };
+app.locals.privateKey = keys.privateKey;
+app.locals.publicKey = keys.publicKey;
+app.use('/api/v1/projects', require('../server/developer/routes').router());
+const server = http.createServer(app);
+const U = { owner: jwt.sign({ sub: 10, id: 10 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' }), staff: jwt.sign({ sub: 14, id: 14 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' }) };
+
+(async () => {
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const api = async (who, method, p, body) => {
+ const r = await fetch(`${base}/api/v1/projects${p}`, { method, headers: { authorization: `Bearer ${U[who]}`, ...(body ? { 'content-type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined });
+ const text = await r.text();
+ return { status: r.status, body: text ? JSON.parse(text) : null };
+ };
+ const ok = (r, status = 200) => { assert.strictEqual(r.status, status, JSON.stringify(r.body)); return r.body; };
+ const quiet = { log() {}, warn() {} };
+ const outbox = await relay.startRelay(db, { eventsUrl: 'http://127.0.0.1:1', privateKey: keys.privateKey, issuer: ISSUER, autoStart: false, log: quiet });
+ try {
+ const P = ok(await api('owner', 'POST', '', { name: 'Confirmation events' }), 201).id;
+ ok(await api('staff', 'PUT', `/${P}/environment-policy`, { environment_policy: 'sandbox+production' }));
+ ok(await api('staff', 'PUT', `/${P}/allowance`, { capabilities: ['media.object.delete'] }));
+ const PR = ok(await api('owner', 'POST', `/${P}/apps`, { name: 'Bot', environment: 'production' }), 201).id;
+ ok(await api('owner', 'POST', `/${P}/apps/${PR}/grants`, { capability: 'media.object.delete' }), 201);
+ const A = ok(await api('owner', 'POST', `/${P}/agents`, { name: 'Helper', host: { type: 'app', id: PR } }), 201).id;
+ ok(await api('owner', 'PUT', `/${P}/agents/${A}/grants/media.object.delete`, {}));
+
+ const D = crypto.createHash('sha256').update('DELETE /v1/objects/obj_1').digest('hex');
+ const SUMMARY = 'Delete the photo "beach-secret.jpg"';
+ const ask = (extra = {}) => confirmations.create(db, { agentId: A, capability: 'media.object.delete', audience: 'openvibe.media', summary: SUMMARY,
+ details: { note: 'private-detail-text' }, requestDigest: D, sessionId: 'session-0001', ...extra });
+ const actor = { subject: owner, label: `user:${owner}` };
+ const decide = (c, action, body = {}) => confirmations.decide(db, actor, c.id, action, body);
+ const spend = (c) => confirmations.consume(db, { id: c.id, audience: 'openvibe.media', requestDigest: D });
+ const cancel = (c) => confirmations.cancel(db, { id: c.id, audience: 'openvibe.media' });
+ const rejects = (p, status, code) => assert.rejects(p, (e) => { assert.deepStrictEqual([e.status, e.code], [status, code], e.message); return true; });
+ const past = (c) => db.prepare("UPDATE dev_confirmations SET expires_at = '2001-01-01T00:00:00.000Z' WHERE id = ?").run(c.id);
+ const envelopes = async (c) => (await db.prepare("SELECT event FROM dev_audit WHERE event_type = 'network.confirmation.changed' AND target = ? ORDER BY id").all(`confirmation:${c.id}`)).map((r) => JSON.parse(r.event));
+ const changes = async (c) => (await envelopes(c)).map((e) => [e.payload.change, e.payload.state]);
+
+ // created → approved → used, each once, each a valid payload in a valid envelope.
+ const c1 = await ask();
+ assert.deepStrictEqual(await changes(c1), [['created', 'pending']]);
+ await decide(c1, 'approve');
+ await rejects(decide(c1, 'approve'), 409, 'confirmation.not_pending');
+ await spend(c1);
+ await rejects(spend(c1), 409, 'confirmation.used');
+ assert.deepStrictEqual(await changes(c1), [['created', 'pending'], ['approved', 'approved'], ['used', 'approved']]);
+ const [created, approved, used] = await envelopes(c1);
+ assert.deepStrictEqual(Object.keys(created.payload).sort(), ['agent_id', 'audience', 'capability', 'change', 'changed_at', 'confirmation_id', 'expires_at', 'project_id', 'state']);
+ assert.deepStrictEqual(created.payload, { ...created.payload, confirmation_id: c1.id, agent_id: A, project_id: P, capability: 'media.object.delete', audience: 'openvibe.media' });
+ assert.strictEqual(approved.payload.standing_rule, 'once');
+ assert.ok(!('expires_at' in used.payload));
+ assert.deepStrictEqual([created.actor, approved.actor, used.actor], [{ type: 'system', id: 'network' }, { type: 'user', id: owner }, { type: 'system', id: 'network' }]);
+ for (const e of [created, approved, used]) {
+ assert.deepStrictEqual([e.event_type, e.version, e.source, e.visibility, e.subject, e.on_behalf_of], ['network.confirmation.changed', 1, 'network', 'internal', { type: 'confirmation', id: c1.id }, { type: 'user', id: owner }]);
+ assert.ok(validate('events.event-envelope@1', e).valid);
+ const v = validate('network.confirmation.changed@1', e.payload);
+ assert.ok(v.valid, JSON.stringify(v.errors));
+ const text = JSON.stringify(e);
+ for (const s of [SUMMARY, 'private-detail-text', D, 'session-0001', `user:${owner}`]) assert.ok(!text.includes(s), `never ${s}`);
+ }
+
+ // denied
+ const c2 = await ask();
+ await decide(c2, 'deny');
+ await rejects(decide(c2, 'deny'), 409, 'confirmation.not_pending');
+ assert.deepStrictEqual(await changes(c2), [['created', 'pending'], ['denied', 'denied']]);
+ assert.deepStrictEqual((await envelopes(c2))[1].actor, { type: 'user', id: owner });
+
+ // cancelled by the service: again is a no-op, and emits nothing
+ const c3 = await ask();
+ await cancel(c3);
+ await cancel(c3);
+ assert.deepStrictEqual(await changes(c3), [['created', 'pending'], ['cancelled', 'cancelled']]);
+ assert.strictEqual((await envelopes(c3))[1].payload.cancel_reason, 'service');
+
+ // expired by the sweep, once however often it runs; a decision afterwards changes nothing
+ const c4 = await ask();
+ await past(c4);
+ assert.ok(await confirmations.expireDue(db) >= 1);
+ assert.strictEqual(await confirmations.expireDue(db), 0);
+ await rejects(decide(c4, 'approve'), 409, 'confirmation.not_pending');
+ assert.deepStrictEqual(await changes(c4), [['created', 'pending'], ['expired', 'expired']]);
+
+ // expired lazily at a decision, then the sweep finds nothing more
+ const c5 = await ask();
+ await past(c5);
+ await rejects(decide(c5, 'approve'), 409, 'confirmation.expired');
+ assert.strictEqual(await confirmations.expireDue(db), 0);
+ assert.deepStrictEqual(await changes(c5), [['created', 'pending'], ['expired', 'expired']]);
+
+ // An approved but unused confirmation past expires_at: cancel is 409 confirmation.not_pending, the row stays
+ // approved and nothing is emitted; the sweep leaves it alone.
+ const c6 = await ask();
+ await decide(c6, 'approve');
+ await past(c6);
+ await rejects(cancel(c6), 409, 'confirmation.not_pending');
+ assert.strictEqual(await confirmations.expireDue(db), 0);
+ const r6 = await db.prepare('SELECT state, cancel_reason, used_at FROM dev_confirmations WHERE id = ?').get(c6.id);
+ assert.deepStrictEqual([r6.state, r6.cancel_reason, r6.used_at], ['approved', null, null]);
+ assert.strictEqual((await confirmations.read(db, { id: c6.id, audience: 'openvibe.media' })).confirmation.state, 'expired');
+ assert.deepStrictEqual(await changes(c6), [['created', 'pending'], ['approved', 'approved']]);
+
+ // A standing rule: the approval names it; the next request is one `created` event, approved, with its rule_id.
+ const c7 = await ask();
+ const { rule } = await decide(c7, 'approve', { standing_rule: 'session' });
+ assert.deepStrictEqual([(await envelopes(c7))[1].payload.standing_rule, (await envelopes(c7))[1].payload.rule_id], ['session', rule.id]);
+ const c8 = await ask();
+ assert.strictEqual(c8.state, 'approved');
+ const e8 = await envelopes(c8);
+ assert.deepStrictEqual(e8.map((e) => [e.payload.change, e.payload.state, e.payload.rule_id]), [['created', 'approved', rule.id]]);
+ assert.deepStrictEqual(e8[0].actor, { type: 'system', id: 'network' });
+
+ // A cascade (pausing the agent) cancels the pending and approved-unused ones, one event each, with the cause.
+ const c9 = await confirmations.create(db, { agentId: A, capability: 'media.object.delete', audience: 'openvibe.media', summary: SUMMARY, requestDigest: D });
+ ok(await api('owner', 'POST', `/${P}/agents/${A}/pause`));
+ await api('owner', 'POST', `/${P}/agents/${A}/pause`); // again: nothing more to cancel
+ for (const c of [c8, c9]) {
+ const e = await envelopes(c);
+ assert.deepStrictEqual(e.slice(-1).map((x) => [x.payload.change, x.payload.state, x.payload.cancel_reason, x.actor.type]), [['cancelled', 'cancelled', 'agent_paused', 'system']]);
+ assert.strictEqual(e.filter((x) => x.payload.change === 'cancelled').length, 1);
+ }
+ for (const c of [c1, c2, c4, c5, c6]) assert.ok(!(await changes(c)).some(([ch]) => ch === 'cancelled'), 'spent, decided and expired rows are left alone');
+
+ // Every event is in the outbox, written in the transaction of its change.
+ const all = (await db.prepare("SELECT event FROM dev_audit WHERE event_type = 'network.confirmation.changed' ORDER BY id").all()).map((r) => JSON.parse(r.event).event_id);
+ const queued = (await db.prepare(`SELECT event_id FROM ${relay.TABLE}`).all()).map((r) => r.event_id);
+ assert.ok(all.length >= 20, `${all.length} events`);
+ for (const id of all) assert.ok(queued.includes(id), `outbox has ${id}`);
+ assert.ok(await outbox.pending() >= all.length);
+ console.log('confirmation-events: all tests passed');
+ } finally {
+ await relay.stopRelay(db);
+ server.close();
+ }
+})().catch((err) => { console.error(err); process.exit(1); });
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -190,7 +190,8 @@ for (const [id, name] of PEOPLE) T[name] = jwt.sign({ sub: id, id }, keys.privat
@@ -190,7 +190,8 @@ for (const [id, name] of PEOPLE) T[name] = jwt.sign({ sub: id, id }, keys.privat
// ── The service cancels a pending one ──
// ── The service cancels a pending one ──
const c6 = await ask(bot);
const c6 = await ask(bot);
- assert.strictEqual(await confirmations.cancelFor(db, { agentId: bot, capability: 'media.object.delete', reason: 'service', actor: 'service:openvibe.media' }), 2, 'c6 and the approved-unused c4');
+ assert.strictEqual(await confirmations.cancelFor(db, { agentId: bot, capability: 'media.object.delete', reason: 'service', actor: 'service:openvibe.media' }), 1, 'c6 only: the approved-unused c4 is past expiry, so already expired');
+ assert.deepStrictEqual([(await row(c4.id)).state, (await row(c4.id)).cancel_reason], ['approved', null]);
assert.strictEqual(valid(ok(await inbox('owner', 'GET', `/${c6.id}`)).confirmation).state, 'cancelled');
assert.strictEqual(valid(ok(await inbox('owner', 'GET', `/${c6.id}`)).confirmation).state, 'cancelled');
await rejects(spend(c6), 409, 'confirmation.cancelled');
await rejects(spend(c6), 409, 'confirmation.cancelled');
refused(await inbox('owner', 'POST', `/${c6.id}/approve`, {}), 409, 'confirmation.not_pending');
refused(await inbox('owner', 'POST', `/${c6.id}/approve`, {}), 409, 'confirmation.not_pending');
@@ -418,13 +419,16 @@ for (const [id, name] of PEOPLE) T[name] = jwt.sign({ sub: id, id }, keys.privat
@@ -418,13 +419,16 @@ for (const [id, name] of PEOPLE) T[name] = jwt.sign({ sub: id, id }, keys.privat
assert.deepStrictEqual([i6.status, i6.body.confirmation.state, i6.body.confirmation.standing_rule], [200, 'approved', 'always']);
assert.deepStrictEqual([i6.status, i6.body.confirmation.state, i6.body.confirmation.standing_rule], [200, 'approved', 'always']);
ok(await internal('media', 'POST', `/${i6.body.confirmation.id}/consume`, { request_digest: D }));
ok(await internal('media', 'POST', `/${i6.body.confirmation.id}/consume`, { request_digest: D }));
- // ── Audits: every transition, never the summary or the details ──
+ // ── Audits: every transition, never the summary or the details; each carries its network.confirmation.changed@1
+ // event (slice 9) except a standing rule's approval, which the `created` event already reports ──
const audits = await db.prepare("SELECT action, actor, detail, event FROM dev_audit WHERE action LIKE 'confirmation.%' ORDER BY id").all();
const audits = await db.prepare("SELECT action, actor, detail, event FROM dev_audit WHERE action LIKE 'confirmation.%' ORDER BY id").all();
assert.deepStrictEqual([...new Set(audits.map((a) => a.action))].sort(),
assert.deepStrictEqual([...new Set(audits.map((a) => a.action))].sort(),
['confirmation.approved', 'confirmation.cancelled', 'confirmation.created', 'confirmation.denied', 'confirmation.expired', 'confirmation.used']);
['confirmation.approved', 'confirmation.cancelled', 'confirmation.created', 'confirmation.denied', 'confirmation.expired', 'confirmation.used']);
for (const a of audits) {
for (const a of audits) {
- assert.strictEqual(a.event, null);
+ if (/^rule:/.test(a.actor)) assert.strictEqual(a.event, null);
+ else assert.ok(validate('network.confirmation.changed@1', JSON.parse(a.event).payload).valid, a.event);
assert.ok(!a.detail.includes('beach-secret') && !a.detail.includes('private-detail-text'), a.detail);
assert.ok(!a.detail.includes('beach-secret') && !a.detail.includes('private-detail-text'), a.detail);
+ assert.ok(!String(a.event).includes('beach-secret') && !String(a.event).includes('private-detail-text'), a.event);
const d = JSON.parse(a.detail);
const d = JSON.parse(a.detail);
assert.ok(d.agent && d.capability && !('summary' in d) && !('details' in d));
assert.ok(d.agent && d.capability && !('summary' in d) && !('details' in d));
}
}