openvibers/openvibe.network Commits

Issue agent tokens and publish confirmation decision events

OP OpenVibers committed 4 Oct 2026 · eb3bde5159 · 11 files, +645 −40

Asked: T2 steps 8+9 — OpenVibe.Network WS-Z2 slices 8 and 9: agent tokens and confirmation decision events (auth/security). ONE pull request from current origin/main (git fetch first; the local checkout is stale — read with `git show origin/main:<path>`; main holds slices 2–7: 0016 agents, 0017 grants, 00…
Harness job network-t2-steps-8-9-openvibe-network-132716 (plan T2).

Changes

ddiff --git a/docs/t2-projects-and-grants.md b/docs/t2-projects-and-grants.md
iindex e9632e8b..ce80413a 100644
---- a/docs/t2-projects-and-grants.md
++++ b/docs/t2-projects-and-grants.md
@@@ -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:
`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 audience
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
--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 the
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.
--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
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
@@@ -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
ddiff --git a/server/auth/oauth-routes.js b/server/auth/oauth-routes.js
iindex 1d93b8a6..16bf234b 100644
---- a/server/auth/oauth-routes.js
++++ b/server/auth/oauth-routes.js
@@@ -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);
ddiff --git a/server/developer/agent-tokens.js b/server/developer/agent-tokens.js
nnew file mode 100644
iindex 00000000..386c7f13
---- /dev/null
++++ b/server/developer/agent-tokens.js
@@@ -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 };
ddiff --git a/server/developer/agents.js b/server/developer/agents.js
iindex e1af833b..4a3dc313 100644
---- a/server/developer/agents.js
++++ b/server/developer/agents.js
@@@ -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,
};
ddiff --git a/server/developer/confirmations.js b/server/developer/confirmations.js
iindex 3f039f9d..00957ca7 100644
---- a/server/developer/confirmations.js
++++ b/server/developer/confirmations.js
@@@ -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, res
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);
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, res
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.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');
ddiff --git a/server/developer/store.js b/server/developer/store.js
iindex 0430a6c7..0fd66a39 100644
---- a/server/developer/store.js
++++ b/server/developer/store.js
@@@ -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, event
envelope = {
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;
ddiff --git a/server/developer/tokens.js b/server/developer/tokens.js
iindex a4a86cbf..be01e4b8 100644
---- a/server/developer/tokens.js
++++ b/server/developer/tokens.js
@@@ -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') {
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex bf0f0100..08b8a6fe 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -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) {
ddiff --git a/test/agent-tokens.test.js b/test/agent-tokens.test.js
nnew file mode 100644
iindex 00000000..37ab2685
---- /dev/null
++++ b/test/agent-tokens.test.js
@@@ -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); });
ddiff --git a/test/confirmation-events.test.js b/test/confirmation-events.test.js
nnew file mode 100644
iindex 00000000..41dd4451
---- /dev/null
++++ b/test/confirmation-events.test.js
@@@ -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); });
ddiff --git a/test/confirmations.test.js b/test/confirmations.test.js
iindex 454cc03d..ee0dec10 100644
---- a/test/confirmations.test.js
++++ b/test/confirmations.test.js
@@@ -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.privat
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 }));
-- // ── 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));
}