openvibers/openvibe.network Commits
Add owner-side agent confirmations and per-capability agent budgets (#37)
Changes
nnew file mode 100644@@@ -0,0 +1,192 @@++# Cutover runbook: confirmations and agent budgets (migrations 0018 and 0019)++++This PR adds plan T2 WS-Z2 slices 4 and 5: the owner side of confirmations and agent budgets++(`docs/t2-projects-and-grants.md` sections 3-5). The data change is three new tables and their indexes++(`migrations/0018_confirmations.sql`, `migrations/0019_agent_budgets.sql`). No existing table is altered, no column is++renamed or dropped, no row is touched. Both migrations are `phase: expand` and every statement is `IF NOT EXISTS`, so++they are safe to run on a live production database and safe to run twice.++++Like [cutover-pr-openvibe-network-34.md](cutover-pr-openvibe-network-34.md) (migration 0017), this is much smaller than++the SQLite → PostgreSQL cutover ([cutover-t2-postgres.md](cutover-t2-postgres.md)): no data move, no freeze window, no++snapshot import. The migrations run as part of the normal boot of the new release.++++---++++## What changes++++| Object | Kind | Notes |++| --- | --- | --- |++| `dev_standing_rules` | table | identity `id`; FK → `dev_agents(id)`; CHECK on `rule` (`session`/`until`/`always`), `session_id` format, `source` (`cnf_…`), `session` ↔ `session_id`, `always` ↔ no `until_at` |++| `dev_standing_rules_live_idx` | partial index | `(agent_id, capability) WHERE revoked_at IS NULL` |++| `dev_confirmations` | table | `cnf_…` primary key; FK → `dev_projects(id)`, `dev_agents(id)`, `dev_standing_rules(id)`; CHECK on owner (`usr_…`), summary length, state, standing rule, digest (64 lowercase hex), and the decision/use columns against the state |++| `dev_confirmations_inbox_idx` | index | `(owner_subject, state, created_at)` |++| `dev_confirmations_due_idx` | partial index | `(expires_at) WHERE state = 'pending'` |++| `dev_confirmations_agent_idx` | index | `(agent_id, state)` |++| `dev_confirmations_spendable_idx` | partial index | `(agent_id, capability) WHERE state = 'approved' AND used_at IS NULL` |++| `dev_agent_budgets` | table | primary key `(agent_id, capability)`; FK → `dev_agents(id)`; CHECK `limit_value >= 0`, `budget_window` in `minute`/`hour`/`day`/`month`/`total`; `unit` defaults to `requests` |++++No existing table, index, view or function is modified. The code that reads and writes the tables++(`server/developer/confirmations.js`, `server/developer/agents.js`, `server/developer/routes.js`,++`server/identity/grants-admin.js`, `server/index.js`) adds `/api/v1/confirmations`, the budget and rule routes under++`/api/v1/projects/:project/agents/:agent`, and a one-minute expiry sweep. Until an owning service creates a++confirmation (the `/internal/confirmations` routes come in slice 7) and an owner sets a budget, the tables stay empty;++the cascades that now also cancel confirmations find nothing to cancel.++++---++++## Prerequisites++++1. **Production is on PostgreSQL and has applied 0017.** Confirm with `ov access run openvibe-ovh health network`++ (must say `ready`) and `validate network` (must say `checkout clean`). The runner refuses a pending file numbered++ below an applied one, so 0018 and 0019 must be the next numbers after what production has applied.++2. **`DATABASE_DIRECT_URL` is set and connects.** The migration runner uses the direct (owner-role) connection for++ schema changes. Check by name only:++ ```bash++ sudo grep -cE '^DATABASE_DIRECT_URL=.+' /etc/openvibe/network.env++ ```++ Must print `1`. Never print the value.++3. **A green rehearsal on a copy of the production database.** See [Step 1](#step-1-rehearse-on-a-copy). Only after++ it passes does the owner write the rehearsal file the deploy gates on.++4. **The target commit is fixed.** It is the merge commit of this PR into `origin/main`. Steps 1 and 2 must use that++ one SHA.++++---++++## Step 1: rehearse on a copy++++```bash++TARGET=<merge sha of this PR>++STAGE=/var/tmp/network-confirmations-stage++sudo -u ubuntu git clone --no-checkout /opt/openvibe.network "$STAGE"++sudo -u ubuntu git -C "$STAGE" checkout --detach "$TARGET"++cd "$STAGE" && sudo -u ubuntu npm ci++```++++Then run the agent-schema test, which builds a fresh database at 0015, applies 0016 to 0019, and asserts the++constraints, foreign keys and indexes of every table they add:++++```bash++cd "$STAGE" && sudo -u ubuntu NETWORK_TEST_STORE=pglite node test/run.js agent-schema++```++++**Go:** exit 0 and `agent schema: all tests passed`.++**No-go:** read the failure. A migration syntax error or a constraint mismatch means the PR is not ready; do not++deploy.++++The harness rehearses the PR's head by itself (`ov rehearse OpenVibe.Network 37`) on a scratch PostgreSQL: it applies++`origin/main`'s migrations, the repository's fixtures and then this PR's migrations, and finally the commands++declared in this fenced block. They assert that the rehearsal database carries both migrations and all three tables:++++```rehearse++migrations: migrations++node -e 'const{Client}=require("pg");(async()=>{const c=new Client({connectionString:process.env.REHEARSAL_DATABASE_URL});await c.connect();const t=await c.query("SELECT table_name FROM information_schema.tables WHERE table_schema=current_schema() AND table_name=ANY($1) ORDER BY table_name",[["dev_standing_rules","dev_confirmations","dev_agent_budgets"]]);const m=await c.query("SELECT id FROM ov_migrations WHERE id=ANY($1) ORDER BY id",[["0018","0019"]]);await c.end();const tables=t.rows.map(r=>r.table_name),ids=m.rows.map(r=>r.id);if(tables.length!==3||ids.length!==2){console.error("FAIL tables="+tables+" migrations="+ids);process.exit(1);}console.log("rehearse: tables "+tables.join(",")+", migrations "+ids.join(","));})().catch(e=>{console.error("FAIL",e.code||e.message);process.exit(1)})'++```++++**Go:** the rehearsal job's last line is `rehearse: tables dev_agent_budgets,dev_confirmations,dev_standing_rules,++migrations 0018,0019` (exit 0).++**No-go:** a held migration or a missing table means the migration does not apply on top of production's schema; fix++it and push (the new head is rehearsed again).++++For an extra check against the real production schema (optional, recommended), point the test at a scratch++PostgreSQL database restored from a production dump (`NETWORK_TEST_STORE=pg` with `OV_TEST_PG_URL` /++`OV_TEST_PG_DIRECT_URL`) and run the same test. The scratch database must never be the production URLs.++++The owner writes `~/openvibe/agents/ds/deploy/rehearsals/pr-<number>-confirmations-budgets.json` with++`{"ok": true}` only after the rehearsal passes.++++---++++## The backup++++Before the deploy, take the pre-deploy restore point into the broker's backup area:++++```bash++ov access run openvibe-ovh db-backup++```++++It writes outside the release and outside the repository; keep it. The migrations are additive and the three tables++are empty until a service creates a confirmation or an owner sets a budget, so a code rollback ([below](#rollback))++is usually enough and does not need this backup. But it is the only restore point that knows the schema state++before `0018` and `0019`, and it costs one command in a pipeline that already holds the host lock. A restore of this++backup overwrites every write made after it (accounts, sessions, notifications, coin transactions, follows, blocks,++preferences, and any confirmation, standing rule or budget created on this PR's routes); restores are an owner++decision, coordinate with the database owner separately.++++---++++## Step 2: deploy++++`server/db/database.js` runs every pending migration at startup, using `DATABASE_DIRECT_URL` for schema changes. No++manual SQL, no script, no freeze.++++```bash++ov deploy OpenVibe.Network "PR #<number>: confirmations and agent budgets (migrations 0018, 0019)"++```++++After merge, the deploy:++++1. Checks out the merge commit on `/opt/openvibe.network`.++2. Restarts `openvibe-network.service`.++3. The new process boots, runs `0018_confirmations.sql` then `0019_agent_budgets.sql` (the only pending migrations),++ starts the confirmation expiry sweep and starts serving.++++**Go:** `health network` says `ready` on the merge SHA; `validate network` says `checkout clean` at that SHA;++`journal openvibe-network.service` shows the boot banner with no migration error and no `[Confirmations] expiry:`++warning.++**No-go:** the service fails to start. Check the journal; a migration error here means the rehearsal did not match++production: roll back (below).++++---++++## Verification after deploy++++```bash++ov access run openvibe-ovh journal openvibe-network.service 200 | grep -i '0018\|0019\|migration\|confirmation'++++# On the host, with the owner connection:++sudo node --env-file=/etc/openvibe/network.env -e '++ const { Client } = require("pg");++ (async () => {++ const c = new Client({ connectionString: process.env.DATABASE_DIRECT_URL });++ await c.connect();++ for (const t of ["dev_standing_rules", "dev_confirmations", "dev_agent_budgets"]) {++ const r = await c.query(`SELECT count(*) FROM ${t}`);++ console.log(t, "rows:", r.rows[0].count);++ }++ const m = await c.query("SELECT id FROM ov_migrations WHERE id IN ($1, $2) ORDER BY id", ["0018", "0019"]);++ console.log("migrations:", m.rows.map((r) => r.id).join(",") || "MISSING");++ await c.end();++ })().catch((e) => { console.error("FAIL", e.message.replace(/\/\/[^@]*@/, "//***@")); process.exit(1); });'++```++++Expected: each table `rows: 0`, `migrations: 0018,0019`. An unauthenticated++`curl -s -o /dev/null -w '%{http_code}' https://openvibe.network/api/v1/confirmations` answers `401`.++++---++++## Rollback++++The migrations are additive and the tables are empty until a service creates a confirmation or an owner sets a budget.++++- **Code rollback, tables stay.** Revert to the previous release. Its code never references the new tables, so they++ are inert; the migrations stay recorded in `ov_migrations`, and a later re-deploy sees them as applied.++- **Full rollback, tables dropped.** Only when all three tables are empty, as the owner role:++ ```sql++ DROP TABLE IF EXISTS dev_agent_budgets;++ DROP TABLE IF EXISTS dev_confirmations;++ DROP TABLE IF EXISTS dev_standing_rules;++ DELETE FROM ov_migrations WHERE id IN ('0018', '0019');++ ```++ `dev_confirmations` references `dev_standing_rules`, so drop it first. If any row exists, owners' budgets, rules or++ pending decisions are in use and the tables cannot be dropped without losing them.++++Restart the service after either rollback and confirm `health network` says `ready`.++++---++++## What is not covered here++++- **Data move.** None; the tables start empty.++- **Freeze window.** None; three `CREATE TABLE` and six `CREATE INDEX` on empty tables take milliseconds.++- **Contracts.** No pin bump: `network.confirmation-request@1` is in the pinned v0.85.0. The internal routes, agent++ tokens and decision events (slices 7-9) wait on Contracts releases.@@@ -189,12 +189,44 @@ Base: `/api/v1/projects`. Every call needs `Authorization: Bearer <Network user| `POST /:project/apps/:app/grants` | developer+ | `{ capability }` || `POST /:project/apps/:app/grants/:capability/approve` / `deny` | admin+ | || `DELETE /:project/apps/:app/grants/:capability` | admin+, staff | revoke |++| `GET /:project/agents[?owner=me]`, `GET /:project/agents/:agent` | viewer+, staff | agents acting for a member through an app or service host (`docs/t2-projects-and-grants.md`) |++| `POST /:project/agents` | developer+ for yourself (admin+ for a production app host) | `{ name, host: { type: 'app' \| 'service', id } }` |++| `PATCH /:project/agents/:agent` | its owner, admin+ | `{ name }` |++| `POST /:project/agents/:agent/pause` / `resume`, `DELETE /:project/agents/:agent` | its owner, admin+ (staff may pause and revoke) | pausing or revoking cancels its pending and approved-unused confirmations |++| `GET /:project/agents/:agent/grants` | viewer+, staff | delegated grants with `effective_mode` and `within_host` |++| `PUT /:project/agents/:agent/grants/:capability` | its owner only | `{ mode: 'auto' \| 'confirm', expires_at? }`, inside the host's ceiling |++| `DELETE /:project/agents/:agent/grants/:capability` | its owner, admin+, staff | revoke; deletes its budget, cancels its confirmations and standing rules |++| `GET /:project/agents/:agent/budgets` | viewer+, staff | `{ budgets: [{ capability, limit, window, unit, enforced_by }] }` |++| `PUT /:project/agents/:agent/budgets/:capability` / `DELETE` | its owner, admin+ (not staff) | `{ limit, window, unit? }`; needs an active grant (`404 grant.not_found`), never above the project's quota for it, same window and unit (`422 budget.beyond_quota`); `0` is allowed |++| `GET /:project/agents/:agent/rules` | viewer+, staff | the live standing rules left by approvals |++| `DELETE /:project/agents/:agent/rules/:rule` | its owner, admin+, staff | revoke a standing rule || `GET /:project/quotas` | viewer+ | || `PUT /:project/quotas/:capability` / `DELETE` | staff | `{ limit, window, unit }` || `GET /:project/usage[?days=&env=]` | admin+, staff | usage per day, totals, quotas with their use, errors ([Usage](#usage)) || `GET /:project/audit[?before=&limit=]` | admin+, staff | newest first, paged by `next_before` || `POST /:project/export-tokens` | owner, admin (not staff as such) | `{ audience, env }` → a 5-minute read-only export token ([Export tokens](#export-tokens)) |++### The confirmation inbox++++Base: `/api/v1/confirmations`, with the same rules as above (Bearer user tokens only, problems, request ids, private++no-store, 60 requests per minute). A confirmation is a sensitive use of a delegated grant waiting for the agent's owner++(`network.confirmation-request@1`, `server/developer/confirmations.js`); only that owner sees it, so anyone else,++admins and staff included, gets `404 confirmation.not_found`.++++| Method and path | Body / result |++|---|---|++| `GET /[?state=pending&before=&limit=]` | `{ confirmations: [<network.confirmation-request@1>], agents: { "agt_…": { name, project_id, host } }, next_before }`; `state` is `pending` (default), `approved`, `denied`, `expired` or `cancelled`; newest first |++| `GET /:id` | `{ confirmation, agent }` |++| `POST /:id/approve` | `{ standing_rule?: 'once' \| 'session' \| 'until' \| 'always', until? }` → `{ confirmation, rule? }`; `session` needs the request's `session_id` (`422 confirmation.no_session`) and lasts at most 24 hours, `until` is required for `until` and at most 30 days away |++| `POST /:id/deny` | `{}` → `{ confirmation }` |++++A decision on anything but a pending row is `409 confirmation.not_pending`; a pending row past `expires_at` is++`409 confirmation.expired`. Pending rows past `expires_at` read as `expired` at once and are recorded so by a sweep every++minute. Pausing or revoking the agent, revoking or expiring its grant, shrinking its host's ceiling, the owner leaving++the project or being erased, revoking the host app and archiving the project cancel the affected pending and++approved-unused confirmations (`cancel_reason` names the cause) and revoke the matching standing rules. The owning++services' side (`/internal/confirmations`) and agent tokens come in later slices.++### Service-side project reads`GET /internal/projects/:project_id` (loopback only, like all of `/internal`) needs a service token holding `network.project.read` for `openvibe.network`. Owning services use it to key tenancy by `project_id` and to enforce the quotas Network records (ADR-014).@@@ -156,7 +156,7 @@ As built: `PUT` takes the project row's lock (as the cascades do) and, for a seraction `grant.changed`, target `agent:agt_…`, detail `{ capability, audience, from, to, mode, reason? }`, without anevent (section 2, gap 3). A revoked agent's grants stay as they are and read back `within_host: false`.--### Next — `00NN_confirmations.sql` (slice 4)++### 0018 — `0018_confirmations.sql` (built, slice 4)```sqlCREATE TABLE IF NOT EXISTS dev_standing_rules (@@@ -219,7 +219,7 @@ CREATE INDEX IF NOT EXISTS dev_confirmations_spendable_idx ON dev_confirmations`approved`, unused row may be cancelled too (its `decided_at` stays); the spendable index finds those rows for thecascades.--### Next — `00NN_agent_budgets.sql` (slice 5)++### 0019 — `0019_agent_budgets.sql` (built, slice 5)```sqlCREATE TABLE IF NOT EXISTS dev_agent_budgets (@@@ -554,14 +554,14 @@ and agree).`grants-admin.js` revoke + `expireDue`, `test/agent-grants.test.js`,cases added to `test/agent-schema.test.js`. Checks: `npm test`, `npm run test:pg`. `GET /agents/:agent` still returnsthe `agent` alone; its grants are `GET /agents/:agent/grants`.--4. **Confirmations, owner side.** `migrations/00NN_confirmations.sql`, `server/developer/confirmations.js` (store,++4. **Confirmations, owner side** (built). `migrations/0018_confirmations.sql`, `server/developer/confirmations.js` (store,lifecycle, expiry sweep started next to `grants-admin` `start`), `/api/v1/confirmations` mounted in `server/index.js`,rule routes, the store's `consume` with the step-4 re-checks, the confirmation cancel step added to every cascade ofslices 2–3 (pause, revoke, grant revoke and expiry, ceiling cascades, member removal, erase, app revoke, archive),`test/confirmations.test.js` creating and consuming rows through thestore functions (the internal routes wait for slice 7), cases added to `test/agent-schema.test.js`. Checks: `npm test`,`npm run test:pg`.--5. **Budgets.** `migrations/00NN_agent_budgets.sql`, budget routes, `budget.beyond_quota`,++5. **Budgets** (built). `migrations/0019_agent_budgets.sql`, budget routes, `budget.beyond_quota`,`test/agent-budgets.test.js` (without the internal read), cases added to`test/agent-schema.test.js`. Checks: `npm test`, `npm run test:pg`.6. **Internal agent read.** `GET /internal/agents/:agent` under `network.project.read` (already in the catalog), innnew file mode 100644@@@ -0,0 +1,54 @@++-- phase: expand++-- plan T2 WS-Z2. Confirmations, owner side (docs/t2-projects-and-grants.md section 3, slice 4): a sensitive use of a++-- delegated grant waits for its owner's approval (network.confirmation-request@1), and an approval may leave a standing++-- rule behind. `request_digest` binds an approval to one action; an approved row is spent once (`used_at`). Every change++-- that takes authority away cancels the pending and approved-unused rows it affects and revokes the matching rules, in++-- its own transaction (server/developer/confirmations.js cancelFor). summary and details may hold message text: they++-- never reach an audit row.++++CREATE TABLE IF NOT EXISTS dev_standing_rules (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ agent_id text COLLATE "C" NOT NULL REFERENCES dev_agents(id),++ capability text COLLATE "C" NOT NULL,++ rule text COLLATE "C" NOT NULL CHECK (rule IN ('session', 'until', 'always')),++ session_id text COLLATE "C" CHECK (session_id ~ '^[A-Za-z0-9._:-]{8,128}$'),++ until_at text COLLATE "C",++ source text COLLATE "C" NOT NULL CHECK (source ~ '^cnf_[0-9A-HJKMNP-TV-Z]{26}$'),++ created_at text COLLATE "C" NOT NULL,++ created_by text COLLATE "C" NOT NULL,++ revoked_at text COLLATE "C",++ revoked_by text COLLATE "C",++ CHECK ((rule = 'session') = (session_id IS NOT NULL)),++ CHECK ((rule = 'always') = (until_at IS NULL))++);++CREATE INDEX IF NOT EXISTS dev_standing_rules_live_idx ON dev_standing_rules (agent_id, capability) WHERE revoked_at IS NULL;++++CREATE TABLE IF NOT EXISTS dev_confirmations (++ id text COLLATE "C" PRIMARY KEY CHECK (id ~ '^cnf_[0-9A-HJKMNP-TV-Z]{26}$'),++ project_id text COLLATE "C" NOT NULL REFERENCES dev_projects(id),++ agent_id text COLLATE "C" NOT NULL REFERENCES dev_agents(id),++ owner_subject text COLLATE "C" NOT NULL CHECK (owner_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),++ capability text COLLATE "C" NOT NULL,++ audience text COLLATE "C" NOT NULL,++ summary text COLLATE "C" NOT NULL CHECK (length(summary) BETWEEN 1 AND 500),++ details text COLLATE "C" NOT NULL DEFAULT '{}',++ resources text COLLATE "C" NOT NULL DEFAULT '[]',++ state text COLLATE "C" NOT NULL CHECK (state IN ('pending', 'approved', 'denied', 'expired', 'cancelled')),++ standing_rule text COLLATE "C" CHECK (standing_rule IN ('once', 'session', 'until', 'always')),++ session_id text COLLATE "C",++ request_digest text COLLATE "C" NOT NULL CHECK (request_digest ~ '^[0-9a-f]{64}$'),++ rule_id bigint REFERENCES dev_standing_rules(id),++ expires_at text COLLATE "C" NOT NULL,++ created_at text COLLATE "C" NOT NULL,++ decided_at text COLLATE "C",++ decided_by text COLLATE "C",++ used_at text COLLATE "C",++ cancel_reason text COLLATE "C",++ CHECK (state <> 'pending' OR decided_at IS NULL),++ CHECK (state NOT IN ('approved', 'denied') OR decided_at IS NOT NULL),++ CHECK (used_at IS NULL OR state = 'approved')++);++CREATE INDEX IF NOT EXISTS dev_confirmations_inbox_idx ON dev_confirmations (owner_subject, state, created_at);++CREATE INDEX IF NOT EXISTS dev_confirmations_due_idx ON dev_confirmations (expires_at) WHERE state = 'pending';++CREATE INDEX IF NOT EXISTS dev_confirmations_agent_idx ON dev_confirmations (agent_id, state);++CREATE INDEX IF NOT EXISTS dev_confirmations_spendable_idx ON dev_confirmations (agent_id, capability) WHERE state = 'approved' AND used_at IS NULL;nnew file mode 100644@@@ -0,0 +1,16 @@++-- phase: expand++-- plan T2 WS-Z2. Agent budgets (docs/t2-projects-and-grants.md section 3, slice 5): an owner (or an admin+) caps what++-- an agent may use of a capability it holds, never above the project's quota for it. The shape of dev_quotas, so the++-- two compare field by field. Network records budgets; the owning service meters usage against them. Revoking the++-- delegated grant deletes its budget.++++CREATE TABLE IF NOT EXISTS dev_agent_budgets (++ agent_id text COLLATE "C" NOT NULL REFERENCES dev_agents(id),++ capability text COLLATE "C" NOT NULL,++ limit_value bigint NOT NULL CHECK (limit_value >= 0),++ budget_window text COLLATE "C" NOT NULL CHECK (budget_window IN ('minute', 'hour', 'day', 'month', 'total')),++ unit text COLLATE "C" NOT NULL DEFAULT 'requests',++ updated_at text COLLATE "C" NOT NULL,++ updated_by text COLLATE "C" NOT NULL,++ PRIMARY KEY (agent_id, capability)++);@@@ -16,7 +16,13 @@* A capability the installed catalog marks sensitive is always `confirm`, whatever mode is stored. A change that* shrinks the ceiling (an allowance, an app grant, a service's principal_grants row) revokes the delegated grants* outside it in its own transaction (revokeBeyondHost).-- * Budgets, standing rules, confirmations and agent tokens come in later slices (section 8).++ * - Confirmations (slice 4, ./confirmations.js): every change here that takes authority away — a pause, a revoke, a++ * revokeWhere cascade, a delegated grant's revocation — cancels the affected pending and approved-unused++ * confirmations and revokes the matching standing rules in the same transaction (cancel below). So do the++ * store.js and grants-admin.js cascades, which all go through revokeWhere and revokeBeyondHost.++ * - 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.++ * Agent tokens come in a later slice (section 8).*/const { ids, capabilities } = require('openvibe-contracts');const policy = require('./policy');@@@ -29,6 +35,13 @@ const atLeast = (role, need) => !!role && store.ROLES.indexOf(role) >= store.ROLfunction ensureSchema(db) { /* the schema is migrations/0016_agents.sql (plan T2); nothing is created at runtime */ }++// revokeWhere's revoked_by for an erased account; a confirmation names the cause as section 5 step 6 does.++const CANCEL_REASON = { account_deleted: 'account_erased' };++/** Cancel the agent's live confirmations (of one capability when given) and revoke its matching rules, in the caller's tx. */++const cancel = (db, agentId, capability, reason, actor, ctx) =>++ // Required here: confirmations.js requires this module.++ require('./confirmations').cancelFor(db, { agentId, capability, reason: CANCEL_REASON[reason] || reason, actor }, { ctx });++function agentView(a) {return {id: a.id, subject: { type: 'agent', id: a.id }, project_id: a.project_id,@@@ -136,6 +149,7 @@ async function revokeWhere(db, where, params, { actor, reason, ctx }) {if (!r.changes) continue;n++;await store.audit(db, { projectId: a.project_id, actor, action: 'agent.revoked', target: `agent:${a.id}`, detail: { reason }, ctx });++ await cancel(db, a.id, null, reason, actor, ctx);}return n;}@@@ -163,6 +177,7 @@ async function changeStatus(db, actor, projectId, agentId, action, { ctx }) {if (!r.changes) fail(409, 'agent.revoked', 'agent is revoked');await store.audit(db, { projectId: project.id, actor: actor.label, action: `agent.${to === 'active' ? 'resumed' : to}`, target: `agent:${a.id}`,detail: { from: cur.status, staff: actor.staff && !role ? true : undefined }, ctx });++ if (to !== 'active') await cancel(db, a.id, null, `agent_${to}`, actor.label, ctx);});return agentView(await db.prepare('SELECT * FROM dev_agents WHERE id = ?').get(a.id));}@@@ -226,12 +241,21 @@ async function grantAudit(db, agent, g, { actor, from, to, mode, reason, ctx })detail: { capability: g.capability, audience: g.audience, from, to, mode, reason: reason || undefined }, ctx });}--async function revokeGrantRow(db, agent, g, { actor, reason, ctx }) {++/**++ * Revoke one active delegated grant, in the caller's transaction: its budget goes with it, and its live confirmations++ * and standing rules are cancelled with `cancelReason` (default: beyond_host for a ceiling cascade, grant_revoked else).++ */++async function revokeGrantRow(db, agent, g, { actor, reason, cancelReason, ctx }) {const t = nowIso();const r = await db.prepare("UPDATE dev_agent_grants SET status = 'revoked', revoked_at = ?, revoked_by = ?, revoke_reason = ?, updated_at = ? WHERE agent_id = ? AND capability = ? AND status = 'active'").run(t, actor, reason || null, t, agent.id, g.capability);-- if (r.changes) await grantAudit(db, agent, g, { actor, from: 'active', to: 'revoked', mode: g.mode, reason, ctx });-- return r.changes > 0;++ if (!r.changes) return false;++ await grantAudit(db, agent, g, { actor, from: 'active', to: 'revoked', mode: g.mode, reason, ctx });++ if ((await db.prepare('DELETE FROM dev_agent_budgets WHERE agent_id = ? AND capability = ?').run(agent.id, g.capability)).changes) {++ await store.audit(db, { projectId: agent.project_id, actor, action: 'agent.budget_removed', target: `agent:${agent.id}`, detail: { capability: g.capability, reason: 'grant_revoked' }, ctx });++ }++ await cancel(db, agent.id, g.capability, cancelReason || reason || 'grant_revoked', actor, ctx);++ return true;}function expiryOf(v) {@@@ -320,19 +344,88 @@ async function deleteGrant(db, actor, projectId, agentId, capability, { ctx, set* matching `where` that their host's ceiling no longer covers, one audit row each (reason beyond_host). `actor` is the* subject label of whoever shrank it. Returns how many were revoked.*/--async function revokeBeyondHost(db, where, params, { actor, ctx, settings }) {++async function revokeBeyondHost(db, where, params, { actor, ctx, settings, cancelReason }) {let n = 0;for (const a of await db.prepare(`SELECT * FROM dev_agents WHERE (${where}) AND status <> 'revoked' ORDER BY id`).all(...params)) {const ceilings = new Map();for (const g of await db.prepare("SELECT * FROM dev_agent_grants WHERE agent_id = ? AND status = 'active' ORDER BY capability").all(a.id)) {if (!ceilings.has(g.audience)) ceilings.set(g.audience, await ceiling(db, a, g.audience, settings));-- if (!ceilings.get(g.audience).has(g.capability) && await revokeGrantRow(db, a, g, { actor, reason: 'beyond_host', ctx })) n++;++ if (!ceilings.get(g.audience).has(g.capability) && await revokeGrantRow(db, a, g, { actor, reason: 'beyond_host', cancelReason, ctx })) n++;}}return n;}++// ── Budgets ────────────────────────────────────────────────────++++const BUDGET_WINDOWS = ['minute', 'hour', 'day', 'month', 'total'];++++/** enforced_by is the capability's audience: the owning service meters, Network never does. */++function budgetView(b) {++ return { capability: b.capability, limit: Number(b.limit_value), window: b.budget_window, unit: b.unit, enforced_by: policy.audienceOf(b.capability) };++}++++async function listBudgets(db, actor, projectId, agentId) {++ const { project } = await store.access(db, actor, projectId, { allowArchived: true });++ const a = await loadAgent(db, project.id, agentId);++ return (await db.prepare('SELECT * FROM dev_agent_budgets WHERE agent_id = ? ORDER BY capability').all(a.id)).map(budgetView);++}++++/** The agent's owner or an admin+ (staff as such never: a budget is the project's own decision). */++async function budgetAccess(db, actor, projectId, agentId) {++ const { project, role } = await store.access(db, actor, projectId, { staffOk: false });++ const a = await loadAgent(db, project.id, agentId);++ if (!(role && a.owner_subject === actor.subject) && !atLeast(role, 'admin')) fail(403, 'agent.forbidden', "only the agent's owner or an admin sets its budgets");++ return { project, agent: a };++}++++/**++ * Set a budget: { limit, window, unit? } for a capability the agent holds (an active delegated grant, else 404++ * grant.not_found). When the project has a quota for the capability, the budget has its window and unit and a limit++ * no higher (else 422 budget.beyond_quota: a different window or unit does not compare). A limit of 0 is legal.++ */++async function setBudget(db, actor, projectId, agentId, capability, body, { ctx }) {++ const { project, agent } = await budgetAccess(db, actor, projectId, agentId);++ const limit = body.limit;++ if (typeof limit !== 'number' || !Number.isSafeInteger(limit) || limit < 0) fail(422, 'budget.invalid', 'limit is a non-negative integer');++ const window = String(body.window || '');++ if (!BUDGET_WINDOWS.includes(window)) fail(422, 'budget.invalid', `window is one of ${BUDGET_WINDOWS.join(', ')}`);++ const unit = body.unit == null ? 'requests' : String(body.unit);++ if (!/^[a-z][a-z0-9_]{0,31}$/.test(unit)) fail(422, 'budget.invalid', 'unit is a short lowercase word (requests, bytes, tokens, ...)');++ const cap = String(capability);++ await db.tx(async () => {++ const cur = await db.prepare('SELECT * FROM dev_agents WHERE id = ? FOR UPDATE').get(agent.id);++ if (cur.status === 'revoked') fail(409, 'agent.revoked', 'agent is revoked');++ if (!await db.prepare("SELECT 1 AS ok FROM dev_agent_grants WHERE agent_id = ? AND capability = ? AND status = 'active' FOR SHARE").get(agent.id, cap)) {++ fail(404, 'grant.not_found', 'the agent holds no active grant for that capability');++ }++ const q = await db.prepare('SELECT * FROM dev_quotas WHERE project_id = ? AND capability = ?').get(project.id, cap);++ if (q && (q.quota_window !== window || q.unit !== unit || limit > Number(q.limit_value))) {++ fail(422, 'budget.beyond_quota', `the project's quota for ${cap} is ${q.limit_value} ${q.unit} per ${q.quota_window}`);++ }++ await db.prepare(`INSERT INTO dev_agent_budgets (agent_id, capability, limit_value, budget_window, unit, updated_at, updated_by) VALUES (?, ?, ?, ?, ?, ?, ?)++ ON CONFLICT (agent_id, capability) DO UPDATE SET limit_value = excluded.limit_value, budget_window = excluded.budget_window, unit = excluded.unit,++ updated_at = excluded.updated_at, updated_by = excluded.updated_by`)++ .run(agent.id, cap, limit, window, unit, nowIso(), actor.label);++ await store.audit(db, { projectId: project.id, actor: actor.label, action: 'agent.budget_set', target: `agent:${agent.id}`, detail: { capability: cap, limit, window, unit }, ctx });++ });++ return budgetView(await db.prepare('SELECT * FROM dev_agent_budgets WHERE agent_id = ? AND capability = ?').get(agent.id, cap));++}++++async function deleteBudget(db, actor, projectId, agentId, capability, { ctx }) {++ const { project, agent } = await budgetAccess(db, actor, projectId, agentId);++ const cap = String(capability);++ return await db.tx(async () => {++ const b = await db.prepare('SELECT * FROM dev_agent_budgets WHERE agent_id = ? AND capability = ? FOR UPDATE').get(agent.id, cap);++ if (!b) fail(404, 'budget.not_found', 'no budget for that capability');++ await db.prepare('DELETE FROM dev_agent_budgets WHERE agent_id = ? AND capability = ?').run(agent.id, cap);++ await store.audit(db, { projectId: project.id, actor: actor.label, action: 'agent.budget_removed', target: `agent:${agent.id}`, detail: { capability: cap }, ctx });++ return budgetView(b);++ });++}++module.exports = {-- ensureSchema, AGENT_ID_RE, agentView, 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,++ budgetView, listBudgets, setBudget, deleteBudget,};nnew file mode 100644@@@ -0,0 +1,356 @@++'use strict';++/**++ * Confirmations, owner side (plan T2 WS-Z2 slice 4, docs/t2-projects-and-grants.md sections 3-5): a sensitive use of a++ * delegated grant waits for its owner's approval. The schema is migrations/0018_confirmations.sql.++ *++ * - The owning service of the capability creates, consumes and cancels them (`create`, `consume`; its++ * /internal/confirmations routes wait for slice 7). The owner reads, approves and denies them at++ * /api/v1/confirmations (`router`); nobody else sees one (not admins, not staff: 404 confirmation.not_found).++ * - Views are network.confirmation-request@1 documents; audience, session_id, request_digest, rule_id, used_at,++ * decided_by, cancel_reason and project_id stay Network-local, never inside the document.++ * - Approving with `session`, `until` or `always` leaves a standing rule that approves the next matching request at++ * creation (`decided_by = 'rule:<id>'`). Denying never does.++ * - `consume` spends an approval once, after re-checking in one transaction (the agent row locked first) everything++ * that let it be created: authority that lapsed without a Network write still stops it.++ * - 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.++ */++const { ids, validate, capabilities } = require('openvibe-contracts');++const policy = require('./policy');++const store = require('./store');++const agents = require('./agents');++++const CNF_ID_RE = /^cnf_[0-9A-HJKMNP-TV-Z]{26}$/;++const SESSION_RE = /^[A-Za-z0-9._:-]{8,128}$/;++const DIGEST_RE = /^[0-9a-f]{64}$/;++const STATES = ['pending', 'approved', 'denied', 'expired', 'cancelled'];++const RULES = ['once', 'session', 'until', 'always'];++const TTL = { min: 60, max: 86400, default: 900 };++const MAX_PENDING = 20;++const SESSION_MAX_MS = 24 * 3600 * 1000;++const UNTIL_MAX_MS = 30 * 24 * 3600 * 1000;++const MAX_DETAILS = 8192;++// A row that may still be spent or decided: what a cascade cancels.++const LIVE = "(state = 'pending' OR (state = 'approved' AND used_at IS NULL))";++++const fail = (status, code, detail) => { throw new store.DevError(status, code, detail); };++const nowIso = () => new Date().toISOString();++++/** The state a read reports: a pending row past expires_at is expired before the sweep records it. */++const stateOf = (c, now = nowIso()) => (c.state === 'pending' && c.expires_at <= now ? 'expired' : c.state);++++/** network.confirmation-request@1: owner and requested_by rebuilt as SubjectRefs, details/resources parsed. */++function confirmationView(c) {++ const doc = {++ id: c.id, owner: { type: 'user', id: c.owner_subject }, requested_by: { type: 'agent', id: c.agent_id },++ capability: c.capability, summary: c.summary, details: JSON.parse(c.details || '{}'), resources: JSON.parse(c.resources || '[]'),++ state: stateOf(c), expires_at: c.expires_at, created_at: c.created_at,++ };++ if (c.standing_rule) doc.standing_rule = c.standing_rule;++ if (c.decided_at) doc.decided_at = c.decided_at;++ return doc;++}++++function ruleView(r) {++ return {++ id: Number(r.id), capability: r.capability, rule: r.rule, until_at: r.until_at || null, session_id: r.session_id || null,++ source: r.source, created_at: r.created_at, revoked_at: r.revoked_at || 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 } = {}) {++ 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 });++}++++// ── Authority, as at creation and again at consume ─────────────++++/**++ * Throws unless `agent` may still ask for `capability` now: the agent is active, its project is not archived, its owner++ * is still a member and not banned or deleted, an app host is live and still the project's and environment's; the++ * delegated grant is active, unexpired and confirmed; the host's ceiling still covers it; its budget is not 0.++ */++async function checkAuthority(db, agent, capability, settings) {++ const inactive = (why) => fail(409, 'confirmation.agent_inactive', why);++ if (agent.status !== 'active') inactive(`agent is ${agent.status}`);++ const project = await db.prepare('SELECT archived_at FROM dev_projects WHERE id = ?').get(agent.project_id);++ if (!project || project.archived_at) inactive('project is archived');++ if (!await store.memberRole(db, agent.project_id, agent.owner_subject)) inactive('owner is 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) inactive('owner account is not active');++ if (agent.host_kind === 'app' && !await db.prepare('SELECT 1 AS ok FROM dev_apps WHERE id = ? AND project_id = ? AND environment = ? AND revoked_at IS NULL')++ .get(agent.host_app_id, agent.project_id, agent.environment)) inactive('host app is revoked');++ const notDelegated = (why) => fail(403, 'grant.not_delegated', why);++ const g = await db.prepare("SELECT * FROM dev_agent_grants WHERE agent_id = ? AND capability = ? AND status = 'active'").get(agent.id, capability);++ if (!g || (g.expires_at && g.expires_at <= nowIso())) notDelegated('no active delegated grant for that capability');++ if (agents.effectiveMode(g) !== 'confirm') notDelegated('the grant is auto: nothing to confirm');++ if (!await agents.withinHost(db, agent, capability, settings)) notDelegated("the capability is outside the agent's host");++ const b = await db.prepare('SELECT limit_value FROM dev_agent_budgets WHERE agent_id = ? AND capability = ?').get(agent.id, capability);++ if (b && Number(b.limit_value) === 0) notDelegated('the budget for that capability is 0');++}++++// ── The owning service's side (routes in slice 7) ──────────────++++/**++ * Create a confirmation for an agent's sensitive use of `capability`, asked by `audience` (the owning service). The++ * owner is the agent's, never the caller's say. A live standing rule approves it at once. → the contract document.++ */++async function create(db, { agentId, capability, audience, summary, details, resources, requestDigest, sessionId, ttlS }, { ctx, settings } = {}) {++ const invalid = (d) => fail(422, 'confirmation.invalid', d);++ if (!agents.AGENT_ID_RE.test(String(agentId))) fail(404, 'agent.not_found', 'no such agent');++ if (!capabilities.get(String(capability))) fail(404, 'grant.unknown_capability', 'no such capability in the catalog');++ if (policy.audienceOf(capability) !== audience) fail(403, 'confirmation.wrong_audience', 'the capability is not the caller\'s');++ if (typeof summary !== 'string' || !summary.trim() || summary.length > 500) invalid('summary is 1-500 characters');++ if (details !== undefined && (!details || typeof details !== 'object' || Array.isArray(details))) invalid('details is an object');++ if (resources !== undefined && !Array.isArray(resources)) invalid('resources is an array of entity refs');++ const detailsJson = JSON.stringify(details || {});++ if (detailsJson.length > MAX_DETAILS) invalid('details is too large');++ if (!DIGEST_RE.test(String(requestDigest))) invalid('request_digest is a lowercase hex SHA-256');++ if (sessionId != null && !SESSION_RE.test(String(sessionId))) invalid('session_id is 8-128 of [A-Za-z0-9._:-]');++ const ttl = ttlS == null ? TTL.default : Number(ttlS);++ if (!Number.isInteger(ttl) || ttl < TTL.min || ttl > TTL.max) invalid(`ttl_s is ${TTL.min}-${TTL.max}`);++ const id = ids.newId('confirmation');++ await db.tx(async () => {++ const agent = await db.prepare('SELECT * FROM dev_agents WHERE id = ? FOR UPDATE').get(agentId);++ if (!agent) fail(404, 'agent.not_found', 'no such agent');++ await checkAuthority(db, agent, capability, settings);++ const now = nowIso();++ const pending = await db.prepare("SELECT count(*) AS n FROM dev_confirmations WHERE agent_id = ? AND state = 'pending' AND expires_at > ?").get(agent.id, now);++ if (Number(pending.n) >= MAX_PENDING) fail(429, 'confirmation.too_many_pending', `at most ${MAX_PENDING} pending confirmations per agent`);++ const rule = await db.prepare(`SELECT * FROM dev_standing_rules WHERE agent_id = ? AND capability = ? AND revoked_at IS NULL++ 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,++ 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,++ };++ const v = validate('network.confirmation-request@1', confirmationView(row));++ if (!v.valid) invalid(v.errors.map((e) => `${e.path} ${e.message}`).join('; ').slice(0, 300));++ await db.prepare(`INSERT INTO dev_confirmations (id, project_id, agent_id, owner_subject, capability, audience, summary, details, resources, state, standing_rule,++ session_id, request_digest, rule_id, expires_at, created_at, decided_at, decided_by)++ 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 });++ 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));++}++++/**++ * Spend an approval once, for the action whose digest is `requestDigest`, asked by the audience that created it. One++ * transaction: the agent row is locked first so no revocation interleaves, authority is re-checked, then one++ * conditional UPDATE spends it. → { confirmation, used_at }++ */++async function consume(db, { id, audience, requestDigest }, { ctx, settings } = {}) {++ const notFound = () => fail(404, 'confirmation.not_found', 'no such confirmation');++ if (!CNF_ID_RE.test(String(id))) notFound();++ const first = await db.prepare('SELECT agent_id FROM dev_confirmations WHERE id = ? AND audience = ?').get(id, audience);++ if (!first) notFound();++ return await db.tx(async () => {++ const agent = await db.prepare('SELECT * FROM dev_agents WHERE id = ? FOR UPDATE').get(first.agent_id);++ const c = await db.prepare('SELECT * FROM dev_confirmations WHERE id = ? FOR UPDATE').get(id);++ const now = nowIso();++ if (c.state === 'cancelled') fail(409, 'confirmation.cancelled', `cancelled (${c.cancel_reason || 'service'})`);++ if (c.used_at) fail(409, 'confirmation.used', 'the confirmation was already used');++ if (c.state === 'expired' || c.expires_at <= now) fail(409, 'confirmation.expired', 'the confirmation has expired');++ if (c.state !== 'approved') fail(409, 'confirmation.not_pending', `confirmation is ${c.state}`);++ if (c.request_digest !== String(requestDigest)) fail(409, 'confirmation.mismatch', 'the approval is for another action');++ await checkAuthority(db, agent, c.capability, 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 });++ return { confirmation: confirmationView({ ...c, used_at: now }), used_at: now };++ });++}++++/**++ * 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.++ * → 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);++ if (!r.changes) continue;++ n++;++ await audit(db, c, 'confirmation.cancelled', { actor, detail: { from: c.state, reason }, ctx });++ }++ 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)) {++ await revokeRuleRow(db, rule, { actor, reason, t, ctx });++ }++ return n;++}++++async function revokeRuleRow(db, rule, { actor, reason, t = nowIso(), ctx }) {++ const r = await db.prepare('UPDATE dev_standing_rules SET revoked_at = ?, revoked_by = ? WHERE id = ? AND revoked_at IS NULL').run(t, actor, rule.id);++ if (!r.changes) return false;++ const a = await db.prepare('SELECT project_id FROM dev_agents WHERE id = ?').get(rule.agent_id);++ await store.audit(db, { projectId: a.project_id, actor, action: 'agent.rule_revoked', target: `agent:${rule.agent_id}`,++ detail: { rule_id: Number(rule.id), capability: rule.capability, rule: rule.rule, reason: reason || undefined }, ctx });++ return true;++}++++/** Record every pending confirmation past expires_at as expired, once. → how many */++async function expireDue(db) {++ const due = await db.prepare("SELECT * FROM dev_confirmations WHERE state = 'pending' AND expires_at <= ? ORDER BY id").all(nowIso());++ let n = 0;++ for (const c of due) {++ await db.tx(async () => {++ 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');++ });++ }++ return n;++}++++// ── The owner's inbox ──────────────────────────────────────────++++async function ownRow(db, actor, id, { lock = false } = {}) {++ const c = CNF_ID_RE.test(String(id)) ? await db.prepare(`SELECT * FROM dev_confirmations WHERE id = ? AND owner_subject = ?${lock ? ' FOR UPDATE' : ''}`).get(id, actor.subject) : null;++ if (!c) fail(404, 'confirmation.not_found', 'no such confirmation');++ return c;++}++++/** The caller's own confirmations, newest first. state defaults to pending; before is the last id of the previous page. */++async function list(db, actor, { state = 'pending', before, limit } = {}) {++ const st = state == null || state === '' ? 'pending' : String(state);++ if (!STATES.includes(st)) fail(422, 'confirmation.invalid', `state is one of ${STATES.join(', ')}`);++ const n = Math.min(100, Math.max(1, Number(limit) || 50));++ const now = nowIso();++ const where = { pending: "state = 'pending' AND expires_at > ?", expired: "(state = 'expired' OR (state = 'pending' AND expires_at <= ?))" }[st] || 'state = ?';++ const params = [actor.subject, st === 'pending' || st === 'expired' ? now : st];++ let sql = `SELECT * FROM dev_confirmations WHERE owner_subject = ? AND ${where}`;++ if (before !== undefined && before !== '') {++ if (!CNF_ID_RE.test(String(before))) fail(422, 'confirmation.invalid', 'before is a confirmation id');++ sql += ' AND id < ?';++ params.push(String(before));++ }++ const rows = await db.prepare(`${sql} ORDER BY id DESC LIMIT ?`).all(...params, n);++ const ctxs = {};++ for (const agentId of new Set(rows.map((r) => r.agent_id))) ctxs[agentId] = agentContext(await db.prepare('SELECT * FROM dev_agents WHERE id = ?').get(agentId));++ return { confirmations: rows.map(confirmationView), agents: ctxs, next_before: rows.length === n ? rows[rows.length - 1].id : null };++}++++async function get(db, actor, id) {++ const c = await ownRow(db, actor, id);++ return { confirmation: confirmationView(c), agent: agentContext(await db.prepare('SELECT * FROM dev_agents WHERE id = ?').get(c.agent_id)) };++}++++function untilOf(v, maxMs, required) {++ if (v == null || v === '') { if (required) fail(422, 'confirmation.invalid', 'until is required'); return null; }++ const d = new Date(String(v));++ if (Number.isNaN(d.getTime()) || d.getTime() <= Date.now()) fail(422, 'confirmation.invalid', 'until is a future date-time');++ if (d.getTime() > Date.now() + maxMs) fail(422, 'confirmation.invalid', `until is at most ${maxMs === SESSION_MAX_MS ? '24 hours' : '30 days'} away`);++ return d.toISOString();++}++++/**++ * The owner approves or denies a pending confirmation. A pending row past expires_at is recorded expired and refused++ * (409 confirmation.expired); any other state is 409 confirmation.not_pending. Approving with session/until/always++ * also leaves a standing rule. → { confirmation, rule? }++ */++async function decide(db, actor, id, action, body = {}, { ctx } = {}) {++ if (action !== 'approve' && action !== 'deny') fail(404, 'confirmation.not_found', 'no such action');++ const standing = action === 'approve' ? (body.standing_rule == null ? 'once' : body.standing_rule) : null;++ if (action === 'approve' && !RULES.includes(standing)) fail(422, 'confirmation.invalid', `standing_rule is one of ${RULES.join(', ')}`);++ const expired = await db.tx(async () => {++ 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 });++ return true;++ }++ if (c.state !== 'pending') fail(409, 'confirmation.not_pending', `confirmation is ${stateOf(c, now)}`);++ let ruleId = null;++ if (standing && standing !== 'once') {++ if (standing === 'session' && !c.session_id) fail(422, 'confirmation.no_session', 'the request carries no session_id');++ const untilAt = standing === 'always' ? null++ : standing === 'session' ? (untilOf(body.until, SESSION_MAX_MS, false) || new Date(Date.now() + SESSION_MAX_MS).toISOString())++ : untilOf(body.until, UNTIL_MAX_MS, true);++ ruleId = (await db.prepare(`INSERT INTO dev_standing_rules (agent_id, capability, rule, session_id, until_at, source, created_at, created_by)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`).get(c.agent_id, c.capability, standing, standing === 'session' ? c.session_id : null, untilAt, c.id, now, actor.label)).id;++ }++ 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 });++ return false;++ });++ if (expired) fail(409, 'confirmation.expired', 'the confirmation has expired');++ const c = await ownRow(db, actor, id);++ const out = { confirmation: confirmationView(c) };++ const rule = await db.prepare('SELECT * FROM dev_standing_rules WHERE source = ? ORDER BY id DESC LIMIT 1').get(c.id);++ if (rule) out.rule = ruleView(rule);++ return out;++}++++// ── Standing rules, under /api/v1/projects/:project/agents/:agent/rules ──++++async function listRules(db, actor, projectId, agentId) {++ const { project } = await store.access(db, actor, projectId, { allowArchived: true });++ const a = await agents.loadAgent(db, project.id, agentId);++ return (await db.prepare('SELECT * FROM dev_standing_rules WHERE agent_id = ? AND revoked_at IS NULL ORDER BY id').all(a.id)).map(ruleView);++}++++/** Revoke a standing rule: the agent's owner, an admin+ or staff. */++async function revokeRule(db, actor, projectId, agentId, ruleId, { ctx }) {++ const { project, role } = await store.access(db, actor, projectId, { allowArchived: true });++ const a = await agents.loadAgent(db, project.id, agentId);++ if (!(role && a.owner_subject === actor.subject) && !agents.atLeast(role, 'admin') && !actor.staff) {++ fail(403, 'agent.forbidden', "only the agent's owner, an admin or staff revoke its rules");++ }++ const id = /^[1-9][0-9]{0,17}$/.test(String(ruleId)) ? String(ruleId) : null;++ const rule = id && await db.prepare('SELECT * FROM dev_standing_rules WHERE id = ? AND agent_id = ?').get(id, a.id);++ if (!rule) fail(404, 'rule.not_found', 'no such rule');++ await db.tx(async () => { await revokeRuleRow(db, rule, { actor: actor.label, ctx }); });++ return ruleView(await db.prepare('SELECT * FROM dev_standing_rules WHERE id = ?').get(rule.id));++}++++// ── Routes and the sweep ───────────────────────────────────────++++/**++ * /api/v1/confirmations — the owner's inbox (Bearer user tokens only, as /api/v1/projects).++ * GET /[?state=pending&before=&limit=] { confirmations, agents, next_before }++ * GET /:id { confirmation, agent }++ * POST /:id/approve { standing_rule?, until? } { confirmation, rule? }++ * POST /:id/deny { confirmation }++ */++function router() {++ const { userApi, handler, finish } = require('./routes');++ const r = userApi();++ const handle = handler();++ r.get('/', handle(async (db, a, req) => await list(db, a, { state: req.query.state, before: req.query.before, limit: req.query.limit })));++ r.get('/:id', handle(async (db, a, req) => await get(db, a, req.params.id)));++ r.post('/:id/approve', handle(async (db, a, req, o) => await decide(db, a, req.params.id, 'approve', req.body || {}, o)));++ r.post('/:id/deny', handle(async (db, a, req, o) => await decide(db, a, req.params.id, 'deny', req.body || {}, o)));++ return finish(r);++}++++let timer = null;++function start(db, { intervalMs = 60_000 } = {}) {++ if (timer) return;++ const run = async () => { try { await expireDue(db); } catch (err) { console.warn('[Confirmations] expiry:', err.message); } };++ timer = setInterval(run, intervalMs);++ if (timer.unref) timer.unref();++}++function stop() { if (timer) clearInterval(timer); timer = null; }++++module.exports = {++ create, list, get, decide, consume, cancelFor, expireDue, confirmationView, ruleView, listRules, revokeRule, router, start, stop,++ MAX_PENDING,++};@@@ -45,6 +45,12 @@ const cache = require('openvibe-shared/cache-policy');* GET /:project/agents/:agent/grants viewer+: delegated grants with effective_mode and within_host* PUT /:project/agents/:agent/grants/:capability { mode, expires_at? } its owner only, inside the host's ceiling* DELETE /:project/agents/:agent/grants/:capability revoke: its owner, admin+ or staff++ * GET /:project/agents/:agent/budgets viewer+: { capability, limit, window, unit, enforced_by }++ * PUT /:project/agents/:agent/budgets/:capability { limit, window, unit? } its owner or admin+ (not staff); needs++ * an active grant, never beyond the project's quota++ * DELETE /:project/agents/:agent/budgets/:capability its owner or admin+ (not staff)++ * GET /:project/agents/:agent/rules viewer+: the live standing rules (confirmations.js)++ * DELETE /:project/agents/:agent/rules/:rule revoke: its owner, admin+ or staff* GET /:project/quotas viewer+* PUT /:project/quotas/:capability { limit, window, unit } staff* DELETE /:project/quotas/:capability staff@@@ -59,11 +65,16 @@ const { http } = require('openvibe-contracts');const { verifySession } = require('../auth/session');const store = require('./store');const agents = require('./agents');++const confirmations = require('./confirmations');const policy = require('./policy');const tokens = require('./tokens');const usage = require('./usage');--function router() {++/**++ * The base of a user API: problems with request ids, JSON bodies, private no-store, and Bearer Network user tokens++ * only (req.actor). Shared with /api/v1/confirmations (confirmations.js).++ */++function userApi() {const r = express.Router();r.use(http.middleware());r.use(express.json({ limit: '64kb' }));@@@ -83,15 +94,30 @@ function router() {try { req.actor = await store.actorOf(db, out.user); } catch (err) { return send(req, res, err); }next();});++ return r;++}++/** handle(fn, status): fn(db, actor, req, { ctx, settings }) → JSON body, or 204 for undefined; errors as problems. */++function handler() {const ctxOf = (req) => ({ ctx: req.ov, settings: policy.settings(req.app.locals.config) });-- const handle = (fn, status = 200) => async (req, res) => {++ return (fn, status = 200) => async (req, res) => {try {const out = await fn(req.app.locals.db, req.actor, req, ctxOf(req));if (out === undefined) return res.status(204).end();res.status(status).json(out);} catch (err) { send(req, res, err); }};++}++++/** Malformed JSON and other body errors as problems too. */++function finish(r) {++ r.use((err, req, res, _next) => send(req, res, err));++ return r;++}++++function router() {++ const r = userApi();++ const handle = handler();r.get('/catalog', (req, res) => res.json({capabilities: policy.grantableCatalog(),@@@ -142,6 +168,11 @@ function router() {r.get('/:project/agents/:agent/grants', handle(async (db, a, req, o) => ({ grants: await agents.listGrants(db, a, req.params.project, req.params.agent, o) })));r.put('/:project/agents/:agent/grants/:capability', handle(async (db, a, req, o) => await agents.putGrant(db, a, req.params.project, req.params.agent, req.params.capability, req.body || {}, o)));r.delete('/:project/agents/:agent/grants/:capability', handle(async (db, a, req, o) => await agents.deleteGrant(db, a, req.params.project, req.params.agent, req.params.capability, o)));++ r.get('/:project/agents/:agent/budgets', handle(async (db, a, req) => ({ budgets: await agents.listBudgets(db, a, req.params.project, req.params.agent) })));++ r.put('/:project/agents/:agent/budgets/:capability', handle(async (db, a, req, o) => await agents.setBudget(db, a, req.params.project, req.params.agent, req.params.capability, req.body || {}, o)));++ r.delete('/:project/agents/:agent/budgets/:capability', handle(async (db, a, req, o) => await agents.deleteBudget(db, a, req.params.project, req.params.agent, req.params.capability, o)));++ r.get('/:project/agents/:agent/rules', handle(async (db, a, req) => ({ rules: await confirmations.listRules(db, a, req.params.project, req.params.agent) })));++ r.delete('/:project/agents/:agent/rules/:rule', handle(async (db, a, req, o) => await confirmations.revokeRule(db, a, req.params.project, req.params.agent, req.params.rule, o)));r.get('/:project/quotas', handle(async (db, a, req) => ({ quotas: await store.listQuotas(db, a, req.params.project), note: 'quotas are enforced by the service that owns each capability; Network records and exposes them' })));r.put('/:project/quotas/:capability', handle(async (db, a, req, o) => await store.setQuota(db, a, req.params.project, req.params.capability, req.body || {}, o)));@@@ -163,9 +194,7 @@ function router() {privateKey: req.app.locals.privateKey, issuer: req.app.locals.config.jwt.issuer, ctx: o.ctx,}), 201));-- // Malformed JSON and other body errors as problems too.-- r.use((err, req, res, _next) => send(req, res, err));-- return r;++ return finish(r);}function send(req, res, err) {@@@ -177,4 +206,4 @@ function send(req, res, err) {return http.sendProblem(res, 500, 'internal.error', { detail: 'unexpected error', ctx: req.ov });}--module.exports = { router };++module.exports = { router, userApi, handler, finish };@@@ -143,17 +143,19 @@ async function revoke(db, body, actor) {.run(actor || 'owner', reason, t.client_id, t.capability, t.audience);const namespaces = JSON.parse(prev.namespaces || '[]');await record(db, { ...t, change: 'revoked', namespaces, expires_at: fromSql(prev.expires_at), reason, actor });-- await revokeDelegated(db, t.client_id, actor);++ await revokeDelegated(db, t.client_id, actor, 'beyond_host');return { ...t, change: 'revoked' };});}/*** The agents this service hosts lose the delegated grants its remaining grants no longer cover (plan T2 WS-Z2), in the-- * caller's transaction. Required lazily: agents.js reaches principals.js, which requires this module.++ * caller's transaction; their confirmations and standing rules for those capabilities are cancelled with `cancelReason`++ * (beyond_host for a revoke, grant_expired for an expiry). Required lazily: agents.js reaches principals.js, which++ * requires this module.*/--async function revokeDelegated(db, clientId, actor) {-- await require('../developer/agents').revokeBeyondHost(db, "host_kind = 'service' AND host_service = ?", [clientId], { actor: actor ? `user:${actor}` : 'system:network' });++async function revokeDelegated(db, clientId, actor, cancelReason) {++ await require('../developer/agents').revokeBeyondHost(db, "host_kind = 'service' AND host_service = ?", [clientId], { actor: actor ? `user:${actor}` : 'system:network', cancelReason });}/** Record every grant whose expiry has passed, once. → how many */@@@ -164,7 +166,7 @@ async function expireDue(db) {const n = (await db.prepare("UPDATE principal_grants SET revoked_at = expires_at, revoked_by = 'expiry' WHERE client_id = ? AND capability = ? AND audience = ? AND revoked_at IS NULL").run(g.client_id, g.capability, g.audience)).changes;if (n) await record(db, { client_id: g.client_id, capability: g.capability, audience: g.audience, change: 'expired', namespaces: JSON.parse(g.namespaces || '[]'), expires_at: fromSql(g.expires_at), reason: g.reason || null, actor: null });-- if (n) await revokeDelegated(db, g.client_id, null);++ if (n) await revokeDelegated(db, g.client_id, null, 'grant_expired');});}if (due.length) kick(db);@@@ -608,6 +608,8 @@ app.use('/api/modules', require('./identity/modules').userRouter(requireAuth));// Developer projects, apps, credentials, grants and quotas (server/developer, ADR-014). Bearer user// tokens only; never X-Internal-Key.app.use('/api/v1/projects', rateLimit({ windowMs: 60_000, max: 60 }), require('./developer/routes').router());++// The owner's confirmation inbox (plan T2 WS-Z2 slice 4, server/developer/confirmations.js): the same Bearer-only rules.++app.use('/api/v1/confirmations', rateLimit({ windowMs: 60_000, max: 60 }), require('./developer/confirmations').router());// Their network.app.* / credential / grant events go to OpenVibe.Events through an outbox when// OV_EVENTS_INTERNAL_URL is set (server/developer/event-relay.js); off otherwise.await require('./developer/event-relay').startRelay(db, { eventsUrl: config.eventsInternalUrl, privateKey, issuer: config.jwt.issuer });@@@ -615,6 +617,8 @@ await require('./developer/event-relay').startRelay(db, { eventsUrl: config.even// through the same outbox (server/identity/profile-events.js).await require('./identity/profile-events').start(db);require('./identity/grants-admin').start(db);++// Pending confirmations past expires_at are recorded expired every minute (reads report them expired before that).++require('./developer/confirmations').start(db);// Notification API (authenticated users)app.use('/api/notifications', createNotificationRoutes(db, notificationService, requireAuth));@@@ -974,6 +978,7 @@ gracefulStop({() => frameService.stop(),() => require('./identity/profile-events').stop(),() => require('./identity/grants-admin').stop(),++ () => require('./developer/confirmations').stop(),],close: [() => within(1500, require('./developer/event-relay').stopRelay(db)),nnew file mode 100644@@@ -0,0 +1,137 @@++'use strict';++// Agent budgets (plan T2 WS-Z2 slice 5, docs/t2-projects-and-grants.md sections 3-5 and 7; server/developer/agents.js):++// the owner or an admin+ sets one (never staff as such, never another member), with enforced_by the owning service;++// it needs an active delegated grant and stays within the project's quota (same window and unit); a bad limit or++// window is refused; revoking the grant deletes it; a budget of 0 stops confirmations from being spent.++// node test/agent-budgets.test.js++const assert = require('assert');++const crypto = require('crypto');++const http = require('http');++const express = require('express');++const jwt = require('jsonwebtoken');++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;++const PEOPLE = [[10, 'owner'], [11, 'dev'], [12, 'viewer'], [13, 'stranger'], [14, 'staff'], [15, 'admin']];++await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES++ (10, 'owner', 'x', 'user'), (11, 'dev', 'x', 'user'), (12, 'viewer', 'x', 'user'), (13, 'stranger', 'x', 'user'), (14, 'staff', 'x', 'admin'), (15, 'admin', 'x', 'user')`).run();++const S = {};++for (const [id, name] of PEOPLE) S[name] = await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));++++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++const ISSUER = 'https://openvibe.network';++const app = express();++app.locals.db = db;++app.locals.config = { baseUrl: 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('/api/v1/projects', require('../server/developer/routes').router());++const server = http.createServer(app);++const T = {};++for (const [id, name] of PEOPLE) T[name] = jwt.sign({ sub: id, id }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });++++(async () => {++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ 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: { ...(who ? { authorization: `Bearer ${T[who]}` } : {}), ...(body ? { 'content-type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined });++ const text = await r.text();++ return { status: r.status, type: r.headers.get('content-type') || '', body: text ? JSON.parse(text) : null };++ };++ const ok = (r, status = 200) => { assert.strictEqual(r.status, status, JSON.stringify(r.body)); return r.body; };++ const refused = (r, status, code) => { assert.strictEqual(r.status, status, JSON.stringify(r.body)); assert.strictEqual(r.body.code, code); assert.match(r.type, /problem\+json/); };++ const audits = async (agent) => (await db.prepare("SELECT action, actor, detail FROM dev_audit WHERE target = ? AND action LIKE 'agent.budget_%' ORDER BY id").all(`agent:${agent}`))++ .map((r) => { const d = JSON.parse(r.detail); return `${r.action} ${d.capability} ${d.limit === undefined ? '-' : d.limit} ${d.reason || '-'} ${r.actor}`; });++ try {++ const P = ok(await api('owner', 'POST', '', { name: 'Budgets' }), 201).id;++ for (const [name, role] of [['dev', 'developer'], ['viewer', 'viewer'], ['admin', 'admin']]) ok(await api('owner', 'POST', `/${P}/members`, { username: name, role }), 201);++ ok(await api('staff', 'PUT', `/${P}/environment-policy`, { environment_policy: 'sandbox+production' }));++ ok(await api('staff', 'PUT', `/${P}/allowance`, { capabilities: ['media.object.upload', 'media.object.delete'] }));++ const PR = ok(await api('owner', 'POST', `/${P}/apps`, { name: 'Bot', environment: 'production' }), 201).id;++ for (const c of ['media.object.upload', 'media.object.delete']) ok(await api('owner', 'POST', `/${P}/apps/${PR}/grants`, { capability: c }), 201);++ const bot = ok(await api('admin', 'POST', `/${P}/agents`, { name: 'Uploader', host: { type: 'app', id: PR } }), 201).id;++ ok(await api('admin', 'PUT', `/${P}/agents/${bot}/grants/media.object.upload`, { mode: 'auto' }));++ ok(await api('admin', 'PUT', `/${P}/agents/${bot}/grants/media.object.delete`, {}));++ const put = (who, cap, body, agent = bot) => api(who, 'PUT', `/${P}/agents/${agent}/budgets/${cap}`, body);++++ // ── The agent's owner sets one; the owning service enforces it ──++ assert.deepStrictEqual(ok(await put('admin', 'media.object.upload', { limit: 1073741824, window: 'day', unit: 'bytes' })),++ { capability: 'media.object.upload', limit: 1073741824, window: 'day', unit: 'bytes', enforced_by: 'openvibe.media' });++ // Overwrite; unit defaults to requests++ assert.deepStrictEqual(ok(await put('admin', 'media.object.upload', { limit: 50, window: 'hour' })),++ { capability: 'media.object.upload', limit: 50, window: 'hour', unit: 'requests', enforced_by: 'openvibe.media' });++ // An admin+ caps a member's agent (here the project owner, who does not own the agent)++ ok(await put('owner', 'media.object.delete', { limit: 10, window: 'day' }));++ // A budget of 0 is legal (the issuance off switch)++ ok(await put('admin', 'media.object.delete', { limit: 0, window: 'day' }));++ assert.deepStrictEqual(ok(await api('viewer', 'GET', `/${P}/agents/${bot}/budgets`)).budgets.map((b) => [b.capability, b.limit, b.window]),++ [['media.object.delete', 0, 'day'], ['media.object.upload', 50, 'hour']]);++ assert.strictEqual(ok(await api('staff', 'GET', `/${P}/agents/${bot}/budgets`)).budgets.length, 2, 'staff read');++ refused(await api('stranger', 'GET', `/${P}/agents/${bot}/budgets`), 404, 'project.not_found');++++ // ── Who: not a developer who does not own it, not a viewer, not staff as such, not a stranger ──++ refused(await put('dev', 'media.object.upload', { limit: 1, window: 'day' }), 403, 'agent.forbidden');++ refused(await put('viewer', 'media.object.upload', { limit: 1, window: 'day' }), 403, 'agent.forbidden');++ refused(await put('staff', 'media.object.upload', { limit: 1, window: 'day' }), 403, 'project.forbidden');++ refused(await put('stranger', 'media.object.upload', { limit: 1, window: 'day' }), 404, 'project.not_found');++ refused(await put(null, 'media.object.upload', { limit: 1, window: 'day' }), 401, 'auth.required');++ refused(await api('dev', 'DELETE', `/${P}/agents/${bot}/budgets/media.object.upload`), 403, 'agent.forbidden');++ refused(await api('staff', 'DELETE', `/${P}/agents/${bot}/budgets/media.object.upload`), 403, 'project.forbidden');++++ // ── Validation ──++ for (const body of [{ limit: -1, window: 'day' }, { limit: 1.5, window: 'day' }, { limit: '5', window: 'day' }, { window: 'day' },++ { limit: 5, window: 'week' }, { limit: 5 }, { limit: 5, window: 'day', unit: 'Bytes!' }]) {++ refused(await put('admin', 'media.object.upload', body), 422, 'budget.invalid');++ }++ refused(await put('admin', 'media.object.upload', { limit: 5, window: 'day' }, `agt_${'9'.repeat(26)}`), 404, 'agent.not_found');++ refused(await put('admin', 'media.object.list', { limit: 5, window: 'day' }), 404, 'grant.not_found');++++ // ── Never beyond the project's quota: a higher limit, another window or another unit ──++ ok(await api('staff', 'PUT', `/${P}/quotas/media.object.upload`, { limit: 100, window: 'hour', unit: 'requests' }));++ refused(await put('admin', 'media.object.upload', { limit: 101, window: 'hour' }), 422, 'budget.beyond_quota');++ refused(await put('admin', 'media.object.upload', { limit: 10, window: 'day' }), 422, 'budget.beyond_quota');++ refused(await put('admin', 'media.object.upload', { limit: 10, window: 'hour', unit: 'bytes' }), 422, 'budget.beyond_quota');++ assert.strictEqual(ok(await put('admin', 'media.object.upload', { limit: 100, window: 'hour' })).limit, 100, 'equal to the quota');++ assert.strictEqual(ok(await api('admin', 'GET', `/${P}/agents/${bot}/budgets`)).budgets.find((b) => b.capability === 'media.object.upload').limit, 100);++++ // ── A budget of 0 stops an approved confirmation from being spent ──++ const D = 'a'.repeat(64);++ await db.prepare('DELETE FROM dev_agent_budgets WHERE agent_id = ? AND capability = ?').run(bot, 'media.object.delete');++ const c = await confirmations.create(db, { agentId: bot, capability: 'media.object.delete', audience: 'openvibe.media', summary: 'Delete one photo', requestDigest: D });++ await db.prepare("UPDATE dev_confirmations SET state = 'approved', decided_at = ?, decided_by = 'test' WHERE id = ?").run(new Date().toISOString(), c.id);++ ok(await put('admin', 'media.object.delete', { limit: 0, window: 'day' }));++ await assert.rejects(confirmations.consume(db, { id: c.id, audience: 'openvibe.media', requestDigest: D }), (e) => e.code === 'grant.not_delegated');++ assert.strictEqual((await db.prepare('SELECT used_at FROM dev_confirmations WHERE id = ?').get(c.id)).used_at, null);++++ // ── DELETE removes it; revoking the grant deletes its budget ──++ assert.strictEqual(ok(await api('admin', 'DELETE', `/${P}/agents/${bot}/budgets/media.object.delete`)).limit, 0);++ refused(await api('admin', 'DELETE', `/${P}/agents/${bot}/budgets/media.object.delete`), 404, 'budget.not_found');++ ok(await api('staff', 'DELETE', `/${P}/agents/${bot}/grants/media.object.upload`));++ assert.deepStrictEqual(ok(await api('admin', 'GET', `/${P}/agents/${bot}/budgets`)).budgets, []);++ refused(await put('admin', 'media.object.upload', { limit: 5, window: 'hour' }), 404, 'grant.not_found');++ // Each change is an audit row (no event), and the cascade names its cause.++ assert.deepStrictEqual(await audits(bot), [++ `agent.budget_set media.object.upload 1073741824 - user:${S.admin}`,++ `agent.budget_set media.object.upload 50 - user:${S.admin}`,++ `agent.budget_set media.object.delete 10 - user:${S.owner}`,++ `agent.budget_set media.object.delete 0 - user:${S.admin}`,++ `agent.budget_set media.object.upload 100 - user:${S.admin}`,++ `agent.budget_set media.object.delete 0 - user:${S.admin}`,++ `agent.budget_removed media.object.delete - - user:${S.admin}`,++ `agent.budget_removed media.object.upload - grant_revoked user:${S.staff}`,++ ]);++++ // ── A revoked agent takes no budget ──++ ok(await api('admin', 'DELETE', `/${P}/agents/${bot}`));++ refused(await put('admin', 'media.object.delete', { limit: 5, window: 'day' }), 409, 'agent.revoked');++ console.log('agent budgets: all tests passed');++ } finally {++ server.close();++ }++})().catch((err) => { console.error(err); process.exit(1); });++})().catch((err) => { console.error(err); process.exit(1); });@@@ -1,5 +1,6 @@'use strict';--// The agents migrations (plan T2 WS-Z2 slices 2-3, migrations/0016_agents.sql and 0017_agent_grants.sql) reach a++// The agents migrations (plan T2 WS-Z2 slices 2-5, migrations/0016_agents.sql, 0017_agent_grants.sql,++// 0018_confirmations.sql and 0019_agent_budgets.sql) reach a// database that production already migrated: a database at 0015 without them gains them from the normal runner,// once, and each file is safe to run twice. It also pins why it is 0016 and not the design's 0010: the openvibe-sdk/db runner tracks applied// migrations by id but refuses a pending file numbered below one already applied.@@@ -43,11 +44,11 @@ const quiet = { log() {}, warn() {}, error() {} };// The real directory: 0016 applies on top of 0015, once.const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand']]);++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand']]);assert.ok(await hasAgents());assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.-- for (const f of ['0016_agents.sql', '0017_agent_grants.sql']) await asOwner((db) => db.exec(fs.readFileSync(path.join(MIGRATIONS, f), 'utf8')));++ for (const f of ['0016_agents.sql', '0017_agent_grants.sql', '0018_confirmations.sql', '0019_agent_budgets.sql']) await asOwner((db) => db.exec(fs.readFileSync(path.join(MIGRATIONS, f), 'utf8')));// The table works on the migrated database: ISO defaults like every other text timestamp, and the app host// must be an app of the agent's own project and environment.@@@ -83,6 +84,64 @@ const quiet = { log() {}, warn() {}, error() {} };const idx = (await t.db.prepare("SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = current_schema() AND tablename = 'dev_agent_grants' ORDER BY indexname").all());assert.deepStrictEqual(idx.map((i) => i.indexname), ['dev_agent_grants_active_idx', 'dev_agent_grants_cap_idx', 'dev_agent_grants_pkey']);assert.match(idx[0].indexdef, /WHERE \(?status = 'active'/);++++ // Confirmations (0018): an id, owner and digest of the right shape, a known state, the decision columns++ // consistent with it, used only once approved; standing rules whose session/until columns match their kind.++ const cnf = (c) => `cnf_${c.repeat(26)}`;++ const ins = (c) => t.db.prepare(`INSERT INTO dev_confirmations (id, project_id, agent_id, owner_subject, capability, audience, summary, state, request_digest,++ expires_at, created_at, decided_at, used_at, rule_id) VALUES (?, ?, ?, ?, 'media.object.delete', 'openvibe.media', ?, ?, ?, ?, ?, ?, ?, ?)`)++ .run(c.id || cnf('A'), prj, c.agent || `agt_${'5'.repeat(26)}`, c.owner || usr, c.summary === undefined ? 'Delete a photo' : c.summary, c.state || 'pending',++ c.digest || 'f'.repeat(64), now, now, c.decided_at === undefined ? null : c.decided_at, c.used_at === undefined ? null : c.used_at, c.rule_id === undefined ? null : c.rule_id);++ await ins({});++ const d = await t.db.prepare('SELECT details, resources, standing_rule FROM dev_confirmations').get();++ assert.deepStrictEqual([d.details, d.resources, d.standing_rule], ['{}', '[]', null]);++ await assert.rejects(ins({}), /duplicate key|unique/i, 'one row per id');++ await assert.rejects(ins({ id: 'cnf_short' }), /check constraint/i, 'a malformed id');++ await assert.rejects(ins({ id: cnf('B'), owner: 'agt_x' }), /check constraint/i, 'an owner that is not a user');++ await assert.rejects(ins({ id: cnf('B'), summary: '' }), /check constraint/i, 'an empty summary');++ await assert.rejects(ins({ id: cnf('B'), summary: 'x'.repeat(501) }), /check constraint/i, 'a summary over 500');++ await assert.rejects(ins({ id: cnf('B'), digest: 'F'.repeat(64) }), /check constraint/i, 'a digest that is not lowercase hex');++ await assert.rejects(ins({ id: cnf('B'), state: 'used' }), /check constraint/i, 'an unknown state');++ await assert.rejects(ins({ id: cnf('B'), decided_at: now }), /check constraint/i, 'pending with decided_at');++ await assert.rejects(ins({ id: cnf('B'), state: 'approved' }), /check constraint/i, 'approved without decided_at');++ await assert.rejects(ins({ id: cnf('B'), state: 'denied' }), /check constraint/i, 'denied without decided_at');++ await assert.rejects(ins({ id: cnf('B'), state: 'cancelled', used_at: now }), /check constraint/i, 'used but not approved');++ await assert.rejects(ins({ id: cnf('B'), agent: `agt_${'8'.repeat(26)}` }), /foreign key/i, 'an unknown agent');++ await assert.rejects(ins({ id: cnf('B'), rule_id: 999 }), /foreign key/i, 'an unknown rule');++ await ins({ id: cnf('C'), state: 'approved', decided_at: now, used_at: now });++ await ins({ id: cnf('D'), state: 'cancelled' });++ const rule = (r) => t.db.prepare('INSERT INTO dev_standing_rules (agent_id, capability, rule, session_id, until_at, source, created_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id')++ .get(`agt_${'5'.repeat(26)}`, 'media.object.delete', r.rule, r.session_id === undefined ? null : r.session_id, r.until_at === undefined ? null : r.until_at, r.source || cnf('A'), now, 'test');++ const ids = [];++ ids.push((await rule({ rule: 'always' })).id, (await rule({ rule: 'until', until_at: now })).id, (await rule({ rule: 'session', session_id: 'session-1', until_at: now })).id);++ assert.deepStrictEqual(ids.map(Number), [1, 2, 3], 'identity ids');++ await assert.rejects(rule({ rule: 'once' }), /check constraint/i, 'once is never a standing rule');++ await assert.rejects(rule({ rule: 'session', until_at: now }), /check constraint/i, 'session without session_id');++ await assert.rejects(rule({ rule: 'session', session_id: 'short', until_at: now }), /check constraint/i, 'a malformed session_id');++ await assert.rejects(rule({ rule: 'until', session_id: 'session-1', until_at: now }), /check constraint/i, 'session_id on another kind');++ await assert.rejects(rule({ rule: 'until' }), /check constraint/i, 'until without until_at');++ await assert.rejects(rule({ rule: 'always', until_at: now }), /check constraint/i, 'always with until_at');++ await assert.rejects(rule({ rule: 'always', source: 'cnf_x' }), /check constraint/i, 'a source that is not a confirmation id');++ await ins({ id: cnf('E'), state: 'approved', decided_at: now, rule_id: ids[0] });++ const cIdx = await t.db.prepare("SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = current_schema() AND tablename = 'dev_confirmations' ORDER BY indexname").all();++ assert.deepStrictEqual(cIdx.map((i) => i.indexname), ['dev_confirmations_agent_idx', 'dev_confirmations_due_idx', 'dev_confirmations_inbox_idx', 'dev_confirmations_pkey', 'dev_confirmations_spendable_idx']);++ assert.match(cIdx[1].indexdef, /WHERE \(?state = 'pending'/);++ assert.match(cIdx[4].indexdef, /WHERE \(?\(?state = 'approved'.*used_at IS NULL/);++ const rIdx = await t.db.prepare("SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = current_schema() AND tablename = 'dev_standing_rules' ORDER BY indexname").all();++ assert.deepStrictEqual(rIdx.map((i) => i.indexname), ['dev_standing_rules_live_idx', 'dev_standing_rules_pkey']);++ assert.match(rIdx[0].indexdef, /WHERE \(?revoked_at IS NULL/);++++ // Budgets (0019): one per (agent, capability), of an existing agent, a non-negative limit, a known window,++ // unit requests by default.++ const budget = (b) => t.db.prepare('INSERT INTO dev_agent_budgets (agent_id, capability, limit_value, budget_window, updated_at, updated_by) VALUES (?, ?, ?, ?, ?, ?)')++ .run(b.agent || `agt_${'5'.repeat(26)}`, b.capability || 'media.object.upload', b.limit === undefined ? 10 : b.limit, b.window || 'day', now, 'test');++ await budget({});++ assert.strictEqual((await t.db.prepare('SELECT unit FROM dev_agent_budgets').get()).unit, 'requests');++ await assert.rejects(budget({}), /duplicate key|unique/i, 'one budget per agent and capability');++ await budget({ capability: 'media.object.delete', limit: 0 });++ await assert.rejects(budget({ capability: 'media.object.list', limit: -1 }), /check constraint/i, 'a negative limit');++ await assert.rejects(budget({ capability: 'media.object.list', window: 'week' }), /check constraint/i, 'an unknown window');++ await assert.rejects(budget({ agent: `agt_${'8'.repeat(26)}`, capability: 'media.object.list' }), /foreign key/i, 'an unknown agent');console.log('agent schema: all tests passed');} finally {await t.close();nnew file mode 100644@@@ -0,0 +1,361 @@++'use strict';++// Confirmations, owner side (plan T2 WS-Z2 slice 4, docs/t2-projects-and-grants.md sections 3-5 and 7;++// server/developer/confirmations.js): views are network.confirmation-request@1; create → approve → consume once;++// digest mismatch, deny, expiry (lazy and swept), the service's cancel, standing rules once/session/until/always and++// their revocation; only the owner sees one; 20 pending per agent; the wrong audience; audits without summary/details;++// one revoke-after-approval case per cause; authority that lapses without a write; two concurrent consumes.++// Rows are created and consumed through the store (the /internal routes wait for slice 7).++// node test/confirmations.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 grantsAdmin = require('../server/identity/grants-admin');++const accountData = require('../server/identity/account-data');++const confirmations = require('../server/developer/confirmations');++++(async () => {++const log = console.log; console.log = () => {};++const db = getDb();++console.log = log;++const PEOPLE = [[10, 'owner'], [11, 'admin'], [12, 'dev'], [13, 'stranger'], [14, 'staff'], [17, 'erased']];++await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES++ (10, 'owner', 'x', 'user'), (11, 'admin', 'x', 'user'), (12, 'dev', 'x', 'user'), (13, 'stranger', 'x', 'user'), (14, 'staff', 'x', 'admin'), (17, 'erased', 'x', 'user')`).run();++await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('actor', 'x', 'OpenVibe.Actor', '[]', 1)").run();++const S = {};++for (const [id, name] of PEOPLE) S[name] = await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));++++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++const ISSUER = 'https://openvibe.network';++const app = express();++app.locals.db = db;++app.locals.config = { baseUrl: 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('/api/v1/projects', require('../server/developer/routes').router());++app.use('/api/v1/confirmations', confirmations.router());++const server = http.createServer(app);++const T = {};++for (const [id, name] of PEOPLE) T[name] = jwt.sign({ sub: id, id }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });++++(async () => {++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ const call = async (who, method, p, body) => {++ const r = await fetch(`${base}${p}`, { method, headers: { ...(who ? { authorization: `Bearer ${T[who]}` } : {}), ...(body ? { 'content-type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined });++ const text = await r.text();++ return { status: r.status, type: r.headers.get('content-type') || '', cache: r.headers.get('cache-control') || '', body: text ? JSON.parse(text) : null };++ };++ const api = (who, method, p, body) => call(who, method, `/api/v1/projects${p}`, body);++ const inbox = (who, method, p, body) => call(who, method, `/api/v1/confirmations${p}`, body);++ const ok = (r, status = 200) => { assert.strictEqual(r.status, status, JSON.stringify(r.body)); return r.body; };++ const refused = (r, status, code) => { assert.strictEqual(r.status, status, JSON.stringify(r.body)); assert.strictEqual(r.body.code, code); assert.match(r.type, /problem\+json/); };++ const rejects = (p, status, code) => assert.rejects(p, (e) => { assert.deepStrictEqual([e.status, e.code], [status, code], e.message); return true; });++ const valid = (doc) => { const v = validate('network.confirmation-request@1', doc); assert.ok(v.valid, JSON.stringify(v.errors)); return doc; };++ const row = async (id) => await db.prepare('SELECT * FROM dev_confirmations WHERE id = ?').get(id);++ const digest = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');++ const D = digest('DELETE /v1/objects/obj_1');++ const SUMMARY = 'Delete the photo "beach-secret.jpg" from your library';++ const DETAILS = { note: 'private-detail-text', count: 1 };++ const audOf = (cap) => `openvibe.${cap.split('.')[0]}`;++ const ask = (agentId, capability = 'media.object.delete', extra = {}) => confirmations.create(db, {++ agentId, capability, audience: audOf(capability), summary: SUMMARY, details: DETAILS,++ resources: [{ service: 'media', type: 'object', id: 'obj_1' }], requestDigest: D, ...extra,++ });++ const spend = (c, extra = {}) => confirmations.consume(db, { id: c.id, audience: audOf(c.capability), requestDigest: D, ...extra });++ const approve = async (c, who = 'owner', body = {}) => ok(await inbox(who, 'POST', `/${c.id}/approve`, body));++ for (const c of ['chat.message.send']) await grantsAdmin.grant(db, { client_id: 'actor', capability: c, reason: 'agent host test' }, null);++++ // A project with a production app holding media.object.delete and .upload, an admin and a developer member.++ let n = 0;++ const project = async () => {++ const P = ok(await api('owner', 'POST', '', { name: `Confirm ${++n}` }), 201).id;++ for (const [name, role] of [['admin', 'admin'], ['dev', 'developer']]) ok(await api('owner', 'POST', `/${P}/members`, { username: name, role }), 201);++ ok(await api('staff', 'PUT', `/${P}/environment-policy`, { environment_policy: 'sandbox+production' }));++ ok(await api('staff', 'PUT', `/${P}/allowance`, { capabilities: ['media.object.upload', 'media.object.delete'] }));++ const PR = ok(await api('owner', 'POST', `/${P}/apps`, { name: 'Bot', environment: 'production' }), 201).id;++ for (const c of ['media.object.upload', 'media.object.delete']) ok(await api('owner', 'POST', `/${P}/apps/${PR}/grants`, { capability: c }), 201);++ return { P, PR };++ };++ // An agent with media.object.delete (confirm) and media.object.upload (auto), or a service-hosted one with chat.message.send.++ const appAgent = async ({ P, PR }, who = 'owner') => {++ const id = ok(await api(who, 'POST', `/${P}/agents`, { name: 'Bot', host: { type: 'app', id: PR } }), 201).id;++ ok(await api(who, 'PUT', `/${P}/agents/${id}/grants/media.object.delete`, {}));++ ok(await api(who, 'PUT', `/${P}/agents/${id}/grants/media.object.upload`, { mode: 'auto' }));++ return id;++ };++ const svcAgent = async ({ P }, who = 'owner') => {++ const id = ok(await api(who, 'POST', `/${P}/agents`, { name: 'Helper', host: { type: 'service', id: 'actor' } }), 201).id;++ ok(await api(who, 'PUT', `/${P}/agents/${id}/grants/chat.message.send`, {}));++ return id;++ };++ const approved = async (agentId, capability) => { const c = await ask(agentId, capability); await approve(c); return c; };++ const cancelledBy = async (c, reason) => {++ const r = await row(c.id);++ assert.deepStrictEqual([r.state, r.cancel_reason, r.used_at], ['cancelled', reason, null], `${c.id} cancelled by ${reason}`);++ await rejects(spend(c), 409, 'confirmation.cancelled');++ };++++ try {++ const W = await project();++ const bot = await appAgent(W);++++ // ── create → approve → consume once ──++ const c1 = valid(await ask(bot));++ assert.deepStrictEqual([c1.state, c1.owner, c1.requested_by, c1.capability, c1.summary, c1.details, c1.standing_rule, c1.decided_at],++ ['pending', { type: 'user', id: S.owner }, { type: 'agent', id: bot }, 'media.object.delete', SUMMARY, DETAILS, undefined, undefined]);++ for (const k of ['audience', 'session_id', 'request_digest', 'rule_id', 'used_at', 'decided_by', 'cancel_reason', 'project_id']) assert.ok(!(k in c1), `${k} stays Network-local`);++ assert.ok(Date.parse(c1.expires_at) - Date.parse(c1.created_at) >= 899_000, 'ttl defaults to 900 s');++ await rejects(spend(c1), 409, 'confirmation.not_pending');++ const listed = ok(await inbox('owner', 'GET', '/'));++ assert.match((await inbox('owner', 'GET', '/')).cache, /private/);++ assert.deepStrictEqual(listed.confirmations.map((c) => c.id), [c1.id]);++ listed.confirmations.forEach(valid);++ assert.deepStrictEqual(listed.agents[bot], { name: 'Bot', project_id: W.P, host: { type: 'app', id: W.PR } });++ assert.strictEqual(listed.next_before, null);++ const one = ok(await inbox('owner', 'GET', `/${c1.id}`));++ valid(one.confirmation);++ assert.strictEqual(one.agent.name, 'Bot');++ const a1 = await approve(c1);++ valid(a1.confirmation);++ assert.deepStrictEqual([a1.confirmation.state, a1.confirmation.standing_rule, a1.rule], ['approved', 'once', undefined]);++ assert.ok(a1.confirmation.decided_at);++ refused(await inbox('owner', 'POST', `/${c1.id}/approve`, {}), 409, 'confirmation.not_pending');++ refused(await inbox('owner', 'POST', `/${c1.id}/deny`, {}), 409, 'confirmation.not_pending');++ await rejects(spend(c1, { requestDigest: digest('DELETE /v1/objects/obj_2') }), 409, 'confirmation.mismatch');++ await rejects(spend(c1, { audience: 'openvibe.chat' }), 404, 'confirmation.not_found');++ const used = await spend(c1);++ valid(used.confirmation);++ assert.match(used.used_at, /^\d{4}-/);++ await rejects(spend(c1), 409, 'confirmation.used');++ assert.strictEqual((await row(c1.id)).used_at, used.used_at);++++ // ── deny ──++ const c2 = await ask(bot);++ const d2 = ok(await inbox('owner', 'POST', `/${c2.id}/deny`, {}));++ valid(d2.confirmation);++ assert.strictEqual(d2.confirmation.state, 'denied');++ await rejects(spend(c2), 409, 'confirmation.not_pending');++ assert.strictEqual(ok(await inbox('owner', 'GET', '/?state=denied')).confirmations[0].id, c2.id);++ refused(await inbox('owner', 'GET', '/?state=open'), 422, 'confirmation.invalid');++++ // ── Creation refusals ──++ await rejects(ask(bot, 'media.object.upload'), 403, 'grant.not_delegated'); // auto: nothing to confirm++ await rejects(ask(bot, 'media.object.delete', { audience: 'openvibe.chat' }), 403, 'confirmation.wrong_audience');++ await rejects(ask(bot, 'media.object.delete', { requestDigest: 'abc' }), 422, 'confirmation.invalid');++ await rejects(ask(bot, 'media.object.delete', { ttlS: 59 }), 422, 'confirmation.invalid');++ await rejects(ask(bot, 'media.object.delete', { ttlS: 86401 }), 422, 'confirmation.invalid');++ await rejects(ask(bot, 'media.object.delete', { summary: '' }), 422, 'confirmation.invalid');++ await rejects(ask(bot, 'media.object.delete', { resources: [{ id: 'x' }] }), 422, 'confirmation.invalid');++ await rejects(ask(`agt_${'9'.repeat(26)}`), 404, 'agent.not_found');++++ // ── Expiry: reads report it, a decision records it, consume refuses an approved one past expiry, the sweep records the rest ──++ const c3 = await ask(bot, 'media.object.delete', { ttlS: 60 });++ await db.prepare("UPDATE dev_confirmations SET expires_at = '2001-01-01T00:00:00.000Z' WHERE id = ?").run(c3.id);++ assert.strictEqual(valid(ok(await inbox('owner', 'GET', `/${c3.id}`)).confirmation).state, 'expired');++ assert.ok(!ok(await inbox('owner', 'GET', '/')).confirmations.some((c) => c.id === c3.id));++ assert.ok(ok(await inbox('owner', 'GET', '/?state=expired')).confirmations.some((c) => c.id === c3.id));++ refused(await inbox('owner', 'POST', `/${c3.id}/approve`, {}), 409, 'confirmation.expired');++ assert.strictEqual((await row(c3.id)).state, 'expired', 'the refused decision recorded the expiry');++ const c4 = await approved(bot);++ await db.prepare("UPDATE dev_confirmations SET expires_at = '2001-01-01T00:00:00.000Z' WHERE id = ?").run(c4.id);++ await rejects(spend(c4), 409, 'confirmation.expired');++ const c5 = await ask(bot);++ await db.prepare("UPDATE dev_confirmations SET expires_at = '2001-01-01T00:00:00.000Z' WHERE id = ?").run(c5.id);++ assert.strictEqual(await confirmations.expireDue(db), 1);++ assert.strictEqual(await confirmations.expireDue(db), 0, 'recorded once');++ assert.strictEqual((await row(c5.id)).state, 'expired');++ assert.strictEqual((await row(c4.id)).state, 'approved', 'an approved row is not swept');++++ // ── 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(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');++++ // ── Only the owner: another member, an admin and staff get 404, never the row ──++ const c7 = await ask(bot);++ for (const who of ['admin', 'stranger', 'staff']) {++ refused(await inbox(who, 'GET', `/${c7.id}`), 404, 'confirmation.not_found');++ refused(await inbox(who, 'POST', `/${c7.id}/approve`, {}), 404, 'confirmation.not_found');++ refused(await inbox(who, 'POST', `/${c7.id}/deny`, {}), 404, 'confirmation.not_found');++ assert.deepStrictEqual(ok(await inbox(who, 'GET', '/')).confirmations, []);++ }++ refused(await inbox(null, 'GET', '/'), 401, 'auth.required');++ refused(await inbox('owner', 'GET', '/cnf_nope'), 404, 'confirmation.not_found');++ assert.strictEqual((await row(c7.id)).state, 'pending');++++ // ── Standing rules ──++ refused(await inbox('owner', 'POST', `/${c7.id}/approve`, { standing_rule: 'session' }), 422, 'confirmation.no_session');++ refused(await inbox('owner', 'POST', `/${c7.id}/approve`, { standing_rule: 'until' }), 422, 'confirmation.invalid');++ refused(await inbox('owner', 'POST', `/${c7.id}/approve`, { standing_rule: 'until', until: new Date(Date.now() + 31 * 86400e3).toISOString() }), 422, 'confirmation.invalid');++ refused(await inbox('owner', 'POST', `/${c7.id}/approve`, { standing_rule: 'forever' }), 422, 'confirmation.invalid');++ assert.strictEqual((await row(c7.id)).state, 'pending', 'a refused approval changes nothing');++ assert.strictEqual((await db.prepare('SELECT count(*) AS n FROM dev_standing_rules').get()).n, 0);++ // session: needs the request's session_id, lasts at most 24 hours, approves the same session only.++ const sess = 'run-2026-10-04:abc';++ const c8 = await ask(bot, 'media.object.delete', { sessionId: sess });++ const a8 = await approve(c8, 'owner', { standing_rule: 'session' });++ assert.deepStrictEqual([a8.confirmation.standing_rule, a8.rule.rule, a8.rule.session_id, a8.rule.source], ['session', 'session', sess, c8.id]);++ assert.ok(Date.parse(a8.rule.until_at) <= Date.now() + 24 * 3600e3);++ const c9 = valid(await ask(bot, 'media.object.delete', { sessionId: sess }));++ assert.deepStrictEqual([c9.state, c9.standing_rule], ['approved', 'session']);++ assert.deepStrictEqual([(await row(c9.id)).decided_by, Number((await row(c9.id)).rule_id)], [`rule:${a8.rule.id}`, a8.rule.id]);++ assert.strictEqual((await spend(c9)).confirmation.state, 'approved');++ assert.strictEqual((await ask(bot, 'media.object.delete', { sessionId: 'another-session' })).state, 'pending');++ assert.strictEqual((await ask(bot)).state, 'pending');++ // until and always++ const c10 = await ask(bot);++ const until = new Date(Date.now() + 7 * 86400e3).toISOString();++ assert.strictEqual((await approve(c10, 'owner', { standing_rule: 'until', until })).rule.until_at, until);++ assert.strictEqual((await ask(bot)).state, 'approved');++ const rules = ok(await api('dev', 'GET', `/${W.P}/agents/${bot}/rules`)).rules;++ assert.deepStrictEqual(rules.map((r) => r.rule), ['session', 'until']);++ // Revoking rules: a developer who is not the owner may not; the owner (or admin, or staff) may.++ refused(await api('dev', 'DELETE', `/${W.P}/agents/${bot}/rules/${rules[0].id}`), 403, 'agent.forbidden');++ refused(await api('owner', 'DELETE', `/${W.P}/agents/${bot}/rules/999999`), 404, 'rule.not_found');++ refused(await api('owner', 'DELETE', `/${W.P}/agents/${bot}/rules/abc`), 404, 'rule.not_found');++ assert.ok(ok(await api('owner', 'DELETE', `/${W.P}/agents/${bot}/rules/${rules[0].id}`)).revoked_at);++ assert.ok(ok(await api('staff', 'DELETE', `/${W.P}/agents/${bot}/rules/${rules[1].id}`)).revoked_at);++ assert.deepStrictEqual(ok(await api('owner', 'GET', `/${W.P}/agents/${bot}/rules`)).rules, []);++ assert.strictEqual((await ask(bot, 'media.object.delete', { sessionId: sess })).state, 'pending', 'a revoked rule approves nothing');++ const c11 = await ask(bot);++ assert.strictEqual((await approve(c11, 'owner', { standing_rule: 'always' })).rule.until_at, null);++ assert.strictEqual((await ask(bot)).state, 'approved');++ const ruleAudit = await db.prepare("SELECT actor, detail FROM dev_audit WHERE action = 'agent.rule_revoked' ORDER BY id").all();++ assert.deepStrictEqual(ruleAudit.map((r) => r.actor), [`user:${S.owner}`, `user:${S.staff}`]);++++ // ── 20 pending per agent ──++ const busy = await appAgent(W);++ for (let i = 0; i < 20; i++) await ask(busy);++ await rejects(ask(busy), 429, 'confirmation.too_many_pending');++ assert.strictEqual(ok(await inbox('owner', 'GET', '/?limit=5')).next_before !== null, true);++ const page1 = ok(await inbox('owner', 'GET', '/?limit=5'));++ const page2 = ok(await inbox('owner', 'GET', `/?limit=5&before=${page1.next_before}`));++ assert.ok(page2.confirmations.every((c) => c.id < page1.next_before));++++ // ── The owner banned: nothing more is created or spent ──++ await db.prepare("UPDATE dev_standing_rules SET revoked_at = ?, revoked_by = 'test' WHERE revoked_at IS NULL").run(new Date().toISOString());++ const c12 = await approved(bot);++ await db.prepare('UPDATE users SET is_banned = 1 WHERE id = 10').run();++ await rejects(spend(c12), 409, 'confirmation.agent_inactive');++ await rejects(ask(bot), 409, 'confirmation.agent_inactive');++ await db.prepare('UPDATE users SET is_banned = 0 WHERE id = 10').run();++ assert.strictEqual((await row(c12.id)).used_at, null);++ assert.strictEqual((await spend(c12)).confirmation.id, c12.id);++++ // ── Two concurrent consumes of one approval: exactly one 200 ──++ const c13 = await approved(bot, 'media.object.delete');++ const both = await Promise.allSettled([spend(c13), spend(c13)]);++ assert.deepStrictEqual(both.map((r) => r.status).sort(), ['fulfilled', 'rejected']);++ assert.strictEqual(both.find((r) => r.status === 'rejected').reason.code, 'confirmation.used');++++ // ── Revoke after approval, one case per cause: cancelled with the cause, consume refused, resume revives nothing ──++ // Pausing the agent++ const paused = await appAgent(W);++ const p1 = await approved(paused);++ const p2 = await ask(paused);++ ok(await api('owner', 'POST', `/${W.P}/agents/${paused}/pause`));++ await cancelledBy(p1, 'agent_paused');++ assert.deepStrictEqual([(await row(p2.id)).state, (await row(p2.id)).cancel_reason], ['cancelled', 'agent_paused'], 'pending ones too');++ await rejects(ask(paused), 409, 'confirmation.agent_inactive');++ ok(await api('owner', 'POST', `/${W.P}/agents/${paused}/resume`));++ await rejects(spend(p1), 409, 'confirmation.cancelled');++ assert.strictEqual((await ask(paused)).state, 'pending', 'its next action asks again');++ // Revoking the agent++ const revoked = await appAgent(W);++ const r1 = await approved(revoked);++ ok(await api('admin', 'DELETE', `/${W.P}/agents/${revoked}`));++ await cancelledBy(r1, 'agent_revoked');++ // Revoking the delegated grant (and only that capability's rows and rules)++ const ungranted = await appAgent(W);++ const g1 = await approved(ungranted);++ await approve(await ask(ungranted), 'owner', { standing_rule: 'always' });++ ok(await api('owner', 'DELETE', `/${W.P}/agents/${ungranted}/grants/media.object.delete`));++ await cancelledBy(g1, 'grant_revoked');++ assert.strictEqual((await db.prepare('SELECT count(*) AS n FROM dev_standing_rules WHERE agent_id = ? AND revoked_at IS NULL').get(ungranted)).n, 0, 'its rules are revoked');++ ok(await api('owner', 'PUT', `/${W.P}/agents/${ungranted}/grants/media.object.delete`, {}));++ assert.strictEqual((await ask(ungranted)).state, 'pending', 'granting again revives no rule');++ // Shrinking the allowance (app host)++ const A = await project();++ const shrunk = await appAgent(A);++ const s1 = await approved(shrunk);++ ok(await api('staff', 'PUT', `/${A.P}/allowance`, { capabilities: ['media.object.upload'] }));++ await cancelledBy(s1, 'beyond_host');++ // Revoking the app's grant (app host)++ const G = await project();++ const appGrantBot = await appAgent(G);++ const ag1 = await approved(appGrantBot);++ ok(await api('owner', 'DELETE', `/${G.P}/apps/${G.PR}/grants/media.object.delete`));++ await cancelledBy(ag1, 'beyond_host');++ // Revoking the host's principal_grants row (service host), and its expiry++ const svc = await svcAgent(W);++ const v1 = await approved(svc, 'chat.message.send');++ await grantsAdmin.revoke(db, { client_id: 'actor', capability: 'chat.message.send', reason: 'test revoke' }, null);++ await cancelledBy(v1, 'beyond_host');++ await grantsAdmin.grant(db, { client_id: 'actor', capability: 'chat.message.send', reason: 'agent host test', expires_at: new Date(Date.now() + 3600e3).toISOString() }, null);++ ok(await api('owner', 'PUT', `/${W.P}/agents/${svc}/grants/chat.message.send`, {}));++ const v2 = await approved(svc, 'chat.message.send');++ await db.prepare("UPDATE principal_grants SET expires_at = '2001-01-01 00:00:00' WHERE client_id = 'actor' AND capability = 'chat.message.send'").run();++ // Lapse without a write: the sweep has not run, consume refuses all the same and spends nothing.++ await rejects(spend(v2), 403, 'grant.not_delegated');++ assert.strictEqual((await row(v2.id)).used_at, null);++ await grantsAdmin.expireDue(db);++ await cancelledBy(v2, 'grant_expired');++ await grantsAdmin.grant(db, { client_id: 'actor', capability: 'chat.message.send', reason: 'agent host test' }, null);++ // A delegated grant's own expires_at passing, without a sweep++ const lapsed = await appAgent(W);++ const l1 = await approved(lapsed);++ await db.prepare("UPDATE dev_agent_grants SET expires_at = '2001-01-01T00:00:00.000Z' WHERE agent_id = ? AND capability = 'media.object.delete'").run(lapsed);++ await rejects(spend(l1), 403, 'grant.not_delegated');++ assert.deepStrictEqual([(await row(l1.id)).state, (await row(l1.id)).used_at], ['approved', null]);++ // The owner removed from the project++ const leaver = await svcAgent(W, 'dev');++ const m1 = await ask(leaver, 'chat.message.send');++ refused(await inbox('owner', 'POST', `/${m1.id}/approve`, {}), 404, 'confirmation.not_found'); // the agent's owner decides, not the project's++ await approve(m1, 'dev');++ ok(await api('owner', 'DELETE', `/${W.P}/members/${S.dev}`), 204);++ await cancelledBy(m1, 'member_removed');++ // Revoking the host app++ const R = await project();++ const appBot = await appAgent(R);++ const ap1 = await approved(appBot);++ ok(await api('owner', 'DELETE', `/${R.P}/apps/${R.PR}`));++ await cancelledBy(ap1, 'app_revoked');++ // Archiving the project++ const X = await project();++ const archBot = await appAgent(X);++ const x1 = await approved(archBot);++ ok(await api('owner', 'POST', `/${X.P}/archive`));++ await cancelledBy(x1, 'project_archived');++ // The owner's account erased++ const E = await project();++ ok(await api('owner', 'POST', `/${E.P}/members`, { username: 'erased', role: 'admin' }), 201);++ const erasedBot = await appAgent(E, 'erased');++ const e1 = await ask(erasedBot); await approve(e1, 'erased');++ const delId = `del_${'1'.repeat(26)}`;++ await db.prepare('INSERT INTO account_deletions (id, user_id, subject, requested_at, delete_after) VALUES (?, 17, ?, ?, ?)').run(delId, S.erased, new Date().toISOString(), new Date().toISOString());++ await accountData.erase(db, await db.prepare('SELECT * FROM account_deletions WHERE id = ?').get(delId));++ await cancelledBy(e1, 'account_erased');++++ // ── Audits: every transition, never the summary or the details ──++ 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);++ assert.ok(!a.detail.includes('beach-secret') && !a.detail.includes('private-detail-text'), a.detail);++ const d = JSON.parse(a.detail);++ assert.ok(d.agent && d.capability && !('summary' in d) && !('details' in d));++ }++ console.log('confirmations: 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,192 @@
@@ -0,0 +1,192 @@
+# Cutover runbook: confirmations and agent budgets (migrations 0018 and 0019)
+
+This PR adds plan T2 WS-Z2 slices 4 and 5: the owner side of confirmations and agent budgets
+(`docs/t2-projects-and-grants.md` sections 3-5). The data change is three new tables and their indexes
+(`migrations/0018_confirmations.sql`, `migrations/0019_agent_budgets.sql`). No existing table is altered, no column is
+renamed or dropped, no row is touched. Both migrations are `phase: expand` and every statement is `IF NOT EXISTS`, so
+they are safe to run on a live production database and safe to run twice.
+
+Like [cutover-pr-openvibe-network-34.md](cutover-pr-openvibe-network-34.md) (migration 0017), this is much smaller than
+the SQLite → PostgreSQL cutover ([cutover-t2-postgres.md](cutover-t2-postgres.md)): no data move, no freeze window, no
+snapshot import. The migrations run as part of the normal boot of the new release.
+
+---
+
+## What changes
+
+| Object | Kind | Notes |
+| --- | --- | --- |
+| `dev_standing_rules` | table | identity `id`; FK → `dev_agents(id)`; CHECK on `rule` (`session`/`until`/`always`), `session_id` format, `source` (`cnf_…`), `session` ↔ `session_id`, `always` ↔ no `until_at` |
+| `dev_standing_rules_live_idx` | partial index | `(agent_id, capability) WHERE revoked_at IS NULL` |
+| `dev_confirmations` | table | `cnf_…` primary key; FK → `dev_projects(id)`, `dev_agents(id)`, `dev_standing_rules(id)`; CHECK on owner (`usr_…`), summary length, state, standing rule, digest (64 lowercase hex), and the decision/use columns against the state |
+| `dev_confirmations_inbox_idx` | index | `(owner_subject, state, created_at)` |
+| `dev_confirmations_due_idx` | partial index | `(expires_at) WHERE state = 'pending'` |
+| `dev_confirmations_agent_idx` | index | `(agent_id, state)` |
+| `dev_confirmations_spendable_idx` | partial index | `(agent_id, capability) WHERE state = 'approved' AND used_at IS NULL` |
+| `dev_agent_budgets` | table | primary key `(agent_id, capability)`; FK → `dev_agents(id)`; CHECK `limit_value >= 0`, `budget_window` in `minute`/`hour`/`day`/`month`/`total`; `unit` defaults to `requests` |
+
+No existing table, index, view or function is modified. The code that reads and writes the tables
+(`server/developer/confirmations.js`, `server/developer/agents.js`, `server/developer/routes.js`,
+`server/identity/grants-admin.js`, `server/index.js`) adds `/api/v1/confirmations`, the budget and rule routes under
+`/api/v1/projects/:project/agents/:agent`, and a one-minute expiry sweep. Until an owning service creates a
+confirmation (the `/internal/confirmations` routes come in slice 7) and an owner sets a budget, the tables stay empty;
+the cascades that now also cancel confirmations find nothing to cancel.
+
+---
+
+## Prerequisites
+
+1. **Production is on PostgreSQL and has applied 0017.** Confirm with `ov access run openvibe-ovh health network`
+ (must say `ready`) and `validate network` (must say `checkout clean`). The runner refuses a pending file numbered
+ below an applied one, so 0018 and 0019 must be the next numbers after what production has applied.
+2. **`DATABASE_DIRECT_URL` is set and connects.** The migration runner uses the direct (owner-role) connection for
+ schema changes. Check by name only:
+ ```bash
+ sudo grep -cE '^DATABASE_DIRECT_URL=.+' /etc/openvibe/network.env
+ ```
+ Must print `1`. Never print the value.
+3. **A green rehearsal on a copy of the production database.** See [Step 1](#step-1-rehearse-on-a-copy). Only after
+ it passes does the owner write the rehearsal file the deploy gates on.
+4. **The target commit is fixed.** It is the merge commit of this PR into `origin/main`. Steps 1 and 2 must use that
+ one SHA.
+
+---
+
+## Step 1: rehearse on a copy
+
+```bash
+TARGET=<merge sha of this PR>
+STAGE=/var/tmp/network-confirmations-stage
+sudo -u ubuntu git clone --no-checkout /opt/openvibe.network "$STAGE"
+sudo -u ubuntu git -C "$STAGE" checkout --detach "$TARGET"
+cd "$STAGE" && sudo -u ubuntu npm ci
+```
+
+Then run the agent-schema test, which builds a fresh database at 0015, applies 0016 to 0019, and asserts the
+constraints, foreign keys and indexes of every table they add:
+
+```bash
+cd "$STAGE" && sudo -u ubuntu NETWORK_TEST_STORE=pglite node test/run.js agent-schema
+```
+
+**Go:** exit 0 and `agent schema: all tests passed`.
+**No-go:** read the failure. A migration syntax error or a constraint mismatch means the PR is not ready; do not
+deploy.
+
+The harness rehearses the PR's head by itself (`ov rehearse OpenVibe.Network 37`) on a scratch PostgreSQL: it applies
+`origin/main`'s migrations, the repository's fixtures and then this PR's migrations, and finally the commands
+declared in this fenced block. They assert that the rehearsal database carries both migrations and all three tables:
+
+```rehearse
+migrations: migrations
+node -e 'const{Client}=require("pg");(async()=>{const c=new Client({connectionString:process.env.REHEARSAL_DATABASE_URL});await c.connect();const t=await c.query("SELECT table_name FROM information_schema.tables WHERE table_schema=current_schema() AND table_name=ANY($1) ORDER BY table_name",[["dev_standing_rules","dev_confirmations","dev_agent_budgets"]]);const m=await c.query("SELECT id FROM ov_migrations WHERE id=ANY($1) ORDER BY id",[["0018","0019"]]);await c.end();const tables=t.rows.map(r=>r.table_name),ids=m.rows.map(r=>r.id);if(tables.length!==3||ids.length!==2){console.error("FAIL tables="+tables+" migrations="+ids);process.exit(1);}console.log("rehearse: tables "+tables.join(",")+", migrations "+ids.join(","));})().catch(e=>{console.error("FAIL",e.code||e.message);process.exit(1)})'
+```
+
+**Go:** the rehearsal job's last line is `rehearse: tables dev_agent_budgets,dev_confirmations,dev_standing_rules,
+migrations 0018,0019` (exit 0).
+**No-go:** a held migration or a missing table means the migration does not apply on top of production's schema; fix
+it and push (the new head is rehearsed again).
+
+For an extra check against the real production schema (optional, recommended), point the test at a scratch
+PostgreSQL database restored from a production dump (`NETWORK_TEST_STORE=pg` with `OV_TEST_PG_URL` /
+`OV_TEST_PG_DIRECT_URL`) and run the same test. The scratch database must never be the production URLs.
+
+The owner writes `~/openvibe/agents/ds/deploy/rehearsals/pr-<number>-confirmations-budgets.json` with
+`{"ok": true}` only after the rehearsal passes.
+
+---
+
+## The backup
+
+Before the deploy, take the pre-deploy restore point into the broker's backup area:
+
+```bash
+ov access run openvibe-ovh db-backup
+```
+
+It writes outside the release and outside the repository; keep it. The migrations are additive and the three tables
+are empty until a service creates a confirmation or an owner sets a budget, so a code rollback ([below](#rollback))
+is usually enough and does not need this backup. But it is the only restore point that knows the schema state
+before `0018` and `0019`, and it costs one command in a pipeline that already holds the host lock. A restore of this
+backup overwrites every write made after it (accounts, sessions, notifications, coin transactions, follows, blocks,
+preferences, and any confirmation, standing rule or budget created on this PR's routes); restores are an owner
+decision, coordinate with the database owner separately.
+
+---
+
+## Step 2: deploy
+
+`server/db/database.js` runs every pending migration at startup, using `DATABASE_DIRECT_URL` for schema changes. No
+manual SQL, no script, no freeze.
+
+```bash
+ov deploy OpenVibe.Network "PR #<number>: confirmations and agent budgets (migrations 0018, 0019)"
+```
+
+After merge, the deploy:
+
+1. Checks out the merge commit on `/opt/openvibe.network`.
+2. Restarts `openvibe-network.service`.
+3. The new process boots, runs `0018_confirmations.sql` then `0019_agent_budgets.sql` (the only pending migrations),
+ starts the confirmation expiry sweep and starts serving.
+
+**Go:** `health network` says `ready` on the merge SHA; `validate network` says `checkout clean` at that SHA;
+`journal openvibe-network.service` shows the boot banner with no migration error and no `[Confirmations] expiry:`
+warning.
+**No-go:** the service fails to start. Check the journal; a migration error here means the rehearsal did not match
+production: roll back (below).
+
+---
+
+## Verification after deploy
+
+```bash
+ov access run openvibe-ovh journal openvibe-network.service 200 | grep -i '0018\|0019\|migration\|confirmation'
+
+# On the host, with the owner connection:
+sudo node --env-file=/etc/openvibe/network.env -e '
+ const { Client } = require("pg");
+ (async () => {
+ const c = new Client({ connectionString: process.env.DATABASE_DIRECT_URL });
+ await c.connect();
+ for (const t of ["dev_standing_rules", "dev_confirmations", "dev_agent_budgets"]) {
+ const r = await c.query(`SELECT count(*) FROM ${t}`);
+ console.log(t, "rows:", r.rows[0].count);
+ }
+ const m = await c.query("SELECT id FROM ov_migrations WHERE id IN ($1, $2) ORDER BY id", ["0018", "0019"]);
+ console.log("migrations:", m.rows.map((r) => r.id).join(",") || "MISSING");
+ await c.end();
+ })().catch((e) => { console.error("FAIL", e.message.replace(/\/\/[^@]*@/, "//***@")); process.exit(1); });'
+```
+
+Expected: each table `rows: 0`, `migrations: 0018,0019`. An unauthenticated
+`curl -s -o /dev/null -w '%{http_code}' https://openvibe.network/api/v1/confirmations` answers `401`.
+
+---
+
+## Rollback
+
+The migrations are additive and the tables are empty until a service creates a confirmation or an owner sets a budget.
+
+- **Code rollback, tables stay.** Revert to the previous release. Its code never references the new tables, so they
+ are inert; the migrations stay recorded in `ov_migrations`, and a later re-deploy sees them as applied.
+- **Full rollback, tables dropped.** Only when all three tables are empty, as the owner role:
+ ```sql
+ DROP TABLE IF EXISTS dev_agent_budgets;
+ DROP TABLE IF EXISTS dev_confirmations;
+ DROP TABLE IF EXISTS dev_standing_rules;
+ DELETE FROM ov_migrations WHERE id IN ('0018', '0019');
+ ```
+ `dev_confirmations` references `dev_standing_rules`, so drop it first. If any row exists, owners' budgets, rules or
+ pending decisions are in use and the tables cannot be dropped without losing them.
+
+Restart the service after either rollback and confirm `health network` says `ready`.
+
+---
+
+## What is not covered here
+
+- **Data move.** None; the tables start empty.
+- **Freeze window.** None; three `CREATE TABLE` and six `CREATE INDEX` on empty tables take milliseconds.
+- **Contracts.** No pin bump: `network.confirmation-request@1` is in the pinned v0.85.0. The internal routes, agent
+ tokens and decision events (slices 7-9) wait on Contracts releases.
@@ -189,12 +189,44 @@ Base: `/api/v1/projects`. Every call needs `Authorization: Bearer <Network user
@@ -189,12 +189,44 @@ Base: `/api/v1/projects`. Every call needs `Authorization: Bearer <Network user
| `POST /:project/apps/:app/grants` | developer+ | `{ capability }` |
| `POST /:project/apps/:app/grants` | developer+ | `{ capability }` |
| `POST /:project/apps/:app/grants/:capability/approve` / `deny` | admin+ | |
| `POST /:project/apps/:app/grants/:capability/approve` / `deny` | admin+ | |
| `DELETE /:project/apps/:app/grants/:capability` | admin+, staff | revoke |
| `DELETE /:project/apps/:app/grants/:capability` | admin+, staff | revoke |
+| `GET /:project/agents[?owner=me]`, `GET /:project/agents/:agent` | viewer+, staff | agents acting for a member through an app or service host (`docs/t2-projects-and-grants.md`) |
+| `POST /:project/agents` | developer+ for yourself (admin+ for a production app host) | `{ name, host: { type: 'app' \| 'service', id } }` |
+| `PATCH /:project/agents/:agent` | its owner, admin+ | `{ name }` |
+| `POST /:project/agents/:agent/pause` / `resume`, `DELETE /:project/agents/:agent` | its owner, admin+ (staff may pause and revoke) | pausing or revoking cancels its pending and approved-unused confirmations |
+| `GET /:project/agents/:agent/grants` | viewer+, staff | delegated grants with `effective_mode` and `within_host` |
+| `PUT /:project/agents/:agent/grants/:capability` | its owner only | `{ mode: 'auto' \| 'confirm', expires_at? }`, inside the host's ceiling |
+| `DELETE /:project/agents/:agent/grants/:capability` | its owner, admin+, staff | revoke; deletes its budget, cancels its confirmations and standing rules |
+| `GET /:project/agents/:agent/budgets` | viewer+, staff | `{ budgets: [{ capability, limit, window, unit, enforced_by }] }` |
+| `PUT /:project/agents/:agent/budgets/:capability` / `DELETE` | its owner, admin+ (not staff) | `{ limit, window, unit? }`; needs an active grant (`404 grant.not_found`), never above the project's quota for it, same window and unit (`422 budget.beyond_quota`); `0` is allowed |
+| `GET /:project/agents/:agent/rules` | viewer+, staff | the live standing rules left by approvals |
+| `DELETE /:project/agents/:agent/rules/:rule` | its owner, admin+, staff | revoke a standing rule |
| `GET /:project/quotas` | viewer+ | |
| `GET /:project/quotas` | viewer+ | |
| `PUT /:project/quotas/:capability` / `DELETE` | staff | `{ limit, window, unit }` |
| `PUT /:project/quotas/:capability` / `DELETE` | staff | `{ limit, window, unit }` |
| `GET /:project/usage[?days=&env=]` | admin+, staff | usage per day, totals, quotas with their use, errors ([Usage](#usage)) |
| `GET /:project/usage[?days=&env=]` | admin+, staff | usage per day, totals, quotas with their use, errors ([Usage](#usage)) |
| `GET /:project/audit[?before=&limit=]` | admin+, staff | newest first, paged by `next_before` |
| `GET /:project/audit[?before=&limit=]` | admin+, staff | newest first, paged by `next_before` |
| `POST /:project/export-tokens` | owner, admin (not staff as such) | `{ audience, env }` → a 5-minute read-only export token ([Export tokens](#export-tokens)) |
| `POST /:project/export-tokens` | owner, admin (not staff as such) | `{ audience, env }` → a 5-minute read-only export token ([Export tokens](#export-tokens)) |
+### The confirmation inbox
+
+Base: `/api/v1/confirmations`, with the same rules as above (Bearer user tokens only, problems, request ids, private
+no-store, 60 requests per minute). A confirmation is a sensitive use of a delegated grant waiting for the agent's owner
+(`network.confirmation-request@1`, `server/developer/confirmations.js`); only that owner sees it, so anyone else,
+admins and staff included, gets `404 confirmation.not_found`.
+
+| Method and path | Body / result |
+|---|---|
+| `GET /[?state=pending&before=&limit=]` | `{ confirmations: [<network.confirmation-request@1>], agents: { "agt_…": { name, project_id, host } }, next_before }`; `state` is `pending` (default), `approved`, `denied`, `expired` or `cancelled`; newest first |
+| `GET /:id` | `{ confirmation, agent }` |
+| `POST /:id/approve` | `{ standing_rule?: 'once' \| 'session' \| 'until' \| 'always', until? }` → `{ confirmation, rule? }`; `session` needs the request's `session_id` (`422 confirmation.no_session`) and lasts at most 24 hours, `until` is required for `until` and at most 30 days away |
+| `POST /:id/deny` | `{}` → `{ confirmation }` |
+
+A decision on anything but a pending row is `409 confirmation.not_pending`; a pending row past `expires_at` is
+`409 confirmation.expired`. Pending rows past `expires_at` read as `expired` at once and are recorded so by a sweep every
+minute. Pausing or revoking the agent, revoking or expiring its grant, shrinking its host's ceiling, the owner leaving
+the project or being erased, revoking the host app and archiving the project cancel the affected pending and
+approved-unused confirmations (`cancel_reason` names the cause) and revoke the matching standing rules. The owning
+services' side (`/internal/confirmations`) and agent tokens come in later slices.
+
### Service-side project reads
### Service-side project reads
`GET /internal/projects/:project_id` (loopback only, like all of `/internal`) needs a service token holding `network.project.read` for `openvibe.network`. Owning services use it to key tenancy by `project_id` and to enforce the quotas Network records (ADR-014).
`GET /internal/projects/:project_id` (loopback only, like all of `/internal`) needs a service token holding `network.project.read` for `openvibe.network`. Owning services use it to key tenancy by `project_id` and to enforce the quotas Network records (ADR-014).
@@ -156,7 +156,7 @@ As built: `PUT` takes the project row's lock (as the cascades do) and, for a ser
@@ -156,7 +156,7 @@ As built: `PUT` takes the project row's lock (as the cascades do) and, for a ser
action `grant.changed`, target `agent:agt_…`, detail `{ capability, audience, from, to, mode, reason? }`, without an
action `grant.changed`, target `agent:agt_…`, detail `{ capability, audience, from, to, mode, reason? }`, without an
event (section 2, gap 3). A revoked agent's grants stay as they are and read back `within_host: false`.
event (section 2, gap 3). A revoked agent's grants stay as they are and read back `within_host: false`.
-### Next — `00NN_confirmations.sql` (slice 4)
+### 0018 — `0018_confirmations.sql` (built, slice 4)
```sql
```sql
CREATE TABLE IF NOT EXISTS dev_standing_rules (
CREATE TABLE IF NOT EXISTS dev_standing_rules (
@@ -219,7 +219,7 @@ CREATE INDEX IF NOT EXISTS dev_confirmations_spendable_idx ON dev_confirmations
@@ -219,7 +219,7 @@ CREATE INDEX IF NOT EXISTS dev_confirmations_spendable_idx ON dev_confirmations
`approved`, unused row may be cancelled too (its `decided_at` stays); the spendable index finds those rows for the
`approved`, unused row may be cancelled too (its `decided_at` stays); the spendable index finds those rows for the
cascades.
cascades.
-### Next — `00NN_agent_budgets.sql` (slice 5)
+### 0019 — `0019_agent_budgets.sql` (built, slice 5)
```sql
```sql
CREATE TABLE IF NOT EXISTS dev_agent_budgets (
CREATE TABLE IF NOT EXISTS dev_agent_budgets (
@@ -554,14 +554,14 @@ and agree).
@@ -554,14 +554,14 @@ and agree).
`grants-admin.js` revoke + `expireDue`, `test/agent-grants.test.js`,
`grants-admin.js` revoke + `expireDue`, `test/agent-grants.test.js`,
cases added to `test/agent-schema.test.js`. Checks: `npm test`, `npm run test:pg`. `GET /agents/:agent` still returns
cases added to `test/agent-schema.test.js`. Checks: `npm test`, `npm run test:pg`. `GET /agents/:agent` still returns
the `agent` alone; its grants are `GET /agents/:agent/grants`.
the `agent` alone; its grants are `GET /agents/:agent/grants`.
-4. **Confirmations, owner side.** `migrations/00NN_confirmations.sql`, `server/developer/confirmations.js` (store,
+4. **Confirmations, owner side** (built). `migrations/0018_confirmations.sql`, `server/developer/confirmations.js` (store,
lifecycle, expiry sweep started next to `grants-admin` `start`), `/api/v1/confirmations` mounted in `server/index.js`,
lifecycle, expiry sweep started next to `grants-admin` `start`), `/api/v1/confirmations` mounted in `server/index.js`,
rule routes, the store's `consume` with the step-4 re-checks, the confirmation cancel step added to every cascade of
rule routes, the store's `consume` with the step-4 re-checks, the confirmation cancel step added to every cascade of
slices 2–3 (pause, revoke, grant revoke and expiry, ceiling cascades, member removal, erase, app revoke, archive),
slices 2–3 (pause, revoke, grant revoke and expiry, ceiling cascades, member removal, erase, app revoke, archive),
`test/confirmations.test.js` creating and consuming rows through the
`test/confirmations.test.js` creating and consuming rows through the
store functions (the internal routes wait for slice 7), cases added to `test/agent-schema.test.js`. Checks: `npm test`,
store functions (the internal routes wait for slice 7), cases added to `test/agent-schema.test.js`. Checks: `npm test`,
`npm run test:pg`.
`npm run test:pg`.
-5. **Budgets.** `migrations/00NN_agent_budgets.sql`, budget routes, `budget.beyond_quota`,
+5. **Budgets** (built). `migrations/0019_agent_budgets.sql`, budget routes, `budget.beyond_quota`,
`test/agent-budgets.test.js` (without the internal read), cases added to
`test/agent-budgets.test.js` (without the internal read), cases added to
`test/agent-schema.test.js`. Checks: `npm test`, `npm run test:pg`.
`test/agent-schema.test.js`. Checks: `npm test`, `npm run test:pg`.
6. **Internal agent read.** `GET /internal/agents/:agent` under `network.project.read` (already in the catalog), in
6. **Internal agent read.** `GET /internal/agents/:agent` under `network.project.read` (already in the catalog), in
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
+-- phase: expand
+-- plan T2 WS-Z2. Confirmations, owner side (docs/t2-projects-and-grants.md section 3, slice 4): a sensitive use of a
+-- delegated grant waits for its owner's approval (network.confirmation-request@1), and an approval may leave a standing
+-- rule behind. `request_digest` binds an approval to one action; an approved row is spent once (`used_at`). Every change
+-- that takes authority away cancels the pending and approved-unused rows it affects and revokes the matching rules, in
+-- its own transaction (server/developer/confirmations.js cancelFor). summary and details may hold message text: they
+-- never reach an audit row.
+
+CREATE TABLE IF NOT EXISTS dev_standing_rules (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ agent_id text COLLATE "C" NOT NULL REFERENCES dev_agents(id),
+ capability text COLLATE "C" NOT NULL,
+ rule text COLLATE "C" NOT NULL CHECK (rule IN ('session', 'until', 'always')),
+ session_id text COLLATE "C" CHECK (session_id ~ '^[A-Za-z0-9._:-]{8,128}$'),
+ until_at text COLLATE "C",
+ source text COLLATE "C" NOT NULL CHECK (source ~ '^cnf_[0-9A-HJKMNP-TV-Z]{26}$'),
+ created_at text COLLATE "C" NOT NULL,
+ created_by text COLLATE "C" NOT NULL,
+ revoked_at text COLLATE "C",
+ revoked_by text COLLATE "C",
+ CHECK ((rule = 'session') = (session_id IS NOT NULL)),
+ CHECK ((rule = 'always') = (until_at IS NULL))
+);
+CREATE INDEX IF NOT EXISTS dev_standing_rules_live_idx ON dev_standing_rules (agent_id, capability) WHERE revoked_at IS NULL;
+
+CREATE TABLE IF NOT EXISTS dev_confirmations (
+ id text COLLATE "C" PRIMARY KEY CHECK (id ~ '^cnf_[0-9A-HJKMNP-TV-Z]{26}$'),
+ project_id text COLLATE "C" NOT NULL REFERENCES dev_projects(id),
+ agent_id text COLLATE "C" NOT NULL REFERENCES dev_agents(id),
+ owner_subject text COLLATE "C" NOT NULL CHECK (owner_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
+ capability text COLLATE "C" NOT NULL,
+ audience text COLLATE "C" NOT NULL,
+ summary text COLLATE "C" NOT NULL CHECK (length(summary) BETWEEN 1 AND 500),
+ details text COLLATE "C" NOT NULL DEFAULT '{}',
+ resources text COLLATE "C" NOT NULL DEFAULT '[]',
+ state text COLLATE "C" NOT NULL CHECK (state IN ('pending', 'approved', 'denied', 'expired', 'cancelled')),
+ standing_rule text COLLATE "C" CHECK (standing_rule IN ('once', 'session', 'until', 'always')),
+ session_id text COLLATE "C",
+ request_digest text COLLATE "C" NOT NULL CHECK (request_digest ~ '^[0-9a-f]{64}$'),
+ rule_id bigint REFERENCES dev_standing_rules(id),
+ expires_at text COLLATE "C" NOT NULL,
+ created_at text COLLATE "C" NOT NULL,
+ decided_at text COLLATE "C",
+ decided_by text COLLATE "C",
+ used_at text COLLATE "C",
+ cancel_reason text COLLATE "C",
+ CHECK (state <> 'pending' OR decided_at IS NULL),
+ CHECK (state NOT IN ('approved', 'denied') OR decided_at IS NOT NULL),
+ CHECK (used_at IS NULL OR state = 'approved')
+);
+CREATE INDEX IF NOT EXISTS dev_confirmations_inbox_idx ON dev_confirmations (owner_subject, state, created_at);
+CREATE INDEX IF NOT EXISTS dev_confirmations_due_idx ON dev_confirmations (expires_at) WHERE state = 'pending';
+CREATE INDEX IF NOT EXISTS dev_confirmations_agent_idx ON dev_confirmations (agent_id, state);
+CREATE INDEX IF NOT EXISTS dev_confirmations_spendable_idx ON dev_confirmations (agent_id, capability) WHERE state = 'approved' AND used_at IS NULL;
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
+-- phase: expand
+-- plan T2 WS-Z2. Agent budgets (docs/t2-projects-and-grants.md section 3, slice 5): an owner (or an admin+) caps what
+-- an agent may use of a capability it holds, never above the project's quota for it. The shape of dev_quotas, so the
+-- two compare field by field. Network records budgets; the owning service meters usage against them. Revoking the
+-- delegated grant deletes its budget.
+
+CREATE TABLE IF NOT EXISTS dev_agent_budgets (
+ agent_id text COLLATE "C" NOT NULL REFERENCES dev_agents(id),
+ capability text COLLATE "C" NOT NULL,
+ limit_value bigint NOT NULL CHECK (limit_value >= 0),
+ budget_window text COLLATE "C" NOT NULL CHECK (budget_window IN ('minute', 'hour', 'day', 'month', 'total')),
+ unit text COLLATE "C" NOT NULL DEFAULT 'requests',
+ updated_at text COLLATE "C" NOT NULL,
+ updated_by text COLLATE "C" NOT NULL,
+ PRIMARY KEY (agent_id, capability)
+);
@@ -16,7 +16,13 @@
@@ -16,7 +16,13 @@
* A capability the installed catalog marks sensitive is always `confirm`, whatever mode is stored. A change that
* A capability the installed catalog marks sensitive is always `confirm`, whatever mode is stored. A change that
* shrinks the ceiling (an allowance, an app grant, a service's principal_grants row) revokes the delegated grants
* shrinks the ceiling (an allowance, an app grant, a service's principal_grants row) revokes the delegated grants
* outside it in its own transaction (revokeBeyondHost).
* outside it in its own transaction (revokeBeyondHost).
- * Budgets, standing rules, confirmations and agent tokens come in later slices (section 8).
+ * - Confirmations (slice 4, ./confirmations.js): every change here that takes authority away — a pause, a revoke, a
+ * revokeWhere cascade, a delegated grant's revocation — cancels the affected pending and approved-unused
+ * confirmations and revokes the matching standing rules in the same transaction (cancel below). So do the
+ * store.js and grants-admin.js cascades, which all go through revokeWhere and revokeBeyondHost.
+ * - 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.
+ * Agent tokens come in a later slice (section 8).
*/
*/
const { ids, capabilities } = require('openvibe-contracts');
const { ids, capabilities } = require('openvibe-contracts');
const policy = require('./policy');
const policy = require('./policy');
@@ -29,6 +35,13 @@ const atLeast = (role, need) => !!role && store.ROLES.indexOf(role) >= store.ROL
@@ -29,6 +35,13 @@ const atLeast = (role, need) => !!role && store.ROLES.indexOf(role) >= store.ROL
function ensureSchema(db) { /* the schema is migrations/0016_agents.sql (plan T2); nothing is created at runtime */ }
function ensureSchema(db) { /* the schema is migrations/0016_agents.sql (plan T2); nothing is created at runtime */ }
+// revokeWhere's revoked_by for an erased account; a confirmation names the cause as section 5 step 6 does.
+const CANCEL_REASON = { account_deleted: 'account_erased' };
+/** Cancel the agent's live confirmations (of one capability when given) and revoke its matching rules, in the caller's tx. */
+const cancel = (db, agentId, capability, reason, actor, ctx) =>
+ // Required here: confirmations.js requires this module.
+ require('./confirmations').cancelFor(db, { agentId, capability, reason: CANCEL_REASON[reason] || reason, actor }, { ctx });
+
function agentView(a) {
function agentView(a) {
return {
return {
id: a.id, subject: { type: 'agent', id: a.id }, project_id: a.project_id,
id: a.id, subject: { type: 'agent', id: a.id }, project_id: a.project_id,
@@ -136,6 +149,7 @@ async function revokeWhere(db, where, params, { actor, reason, ctx }) {
@@ -136,6 +149,7 @@ async function revokeWhere(db, where, params, { actor, reason, ctx }) {
if (!r.changes) continue;
if (!r.changes) continue;
n++;
n++;
await store.audit(db, { projectId: a.project_id, actor, action: 'agent.revoked', target: `agent:${a.id}`, detail: { reason }, ctx });
await store.audit(db, { projectId: a.project_id, actor, action: 'agent.revoked', target: `agent:${a.id}`, detail: { reason }, ctx });
+ await cancel(db, a.id, null, reason, actor, ctx);
}
}
return n;
return n;
}
}
@@ -163,6 +177,7 @@ async function changeStatus(db, actor, projectId, agentId, action, { ctx }) {
@@ -163,6 +177,7 @@ async function changeStatus(db, actor, projectId, agentId, action, { ctx }) {
if (!r.changes) fail(409, 'agent.revoked', 'agent is revoked');
if (!r.changes) fail(409, 'agent.revoked', 'agent is revoked');
await store.audit(db, { projectId: project.id, actor: actor.label, action: `agent.${to === 'active' ? 'resumed' : to}`, target: `agent:${a.id}`,
await store.audit(db, { projectId: project.id, actor: actor.label, action: `agent.${to === 'active' ? 'resumed' : to}`, target: `agent:${a.id}`,
detail: { from: cur.status, staff: actor.staff && !role ? true : undefined }, ctx });
detail: { from: cur.status, staff: actor.staff && !role ? true : undefined }, ctx });
+ if (to !== 'active') await cancel(db, a.id, null, `agent_${to}`, actor.label, ctx);
});
});
return agentView(await db.prepare('SELECT * FROM dev_agents WHERE id = ?').get(a.id));
return agentView(await db.prepare('SELECT * FROM dev_agents WHERE id = ?').get(a.id));
}
}
@@ -226,12 +241,21 @@ async function grantAudit(db, agent, g, { actor, from, to, mode, reason, ctx })
@@ -226,12 +241,21 @@ async function grantAudit(db, agent, g, { actor, from, to, mode, reason, ctx })
detail: { capability: g.capability, audience: g.audience, from, to, mode, reason: reason || undefined }, ctx });
detail: { capability: g.capability, audience: g.audience, from, to, mode, reason: reason || undefined }, ctx });
}
}
-async function revokeGrantRow(db, agent, g, { actor, reason, ctx }) {
+/**
+ * Revoke one active delegated grant, in the caller's transaction: its budget goes with it, and its live confirmations
+ * and standing rules are cancelled with `cancelReason` (default: beyond_host for a ceiling cascade, grant_revoked else).
+ */
+async function revokeGrantRow(db, agent, g, { actor, reason, cancelReason, ctx }) {
const t = nowIso();
const t = nowIso();
const r = await db.prepare("UPDATE dev_agent_grants SET status = 'revoked', revoked_at = ?, revoked_by = ?, revoke_reason = ?, updated_at = ? WHERE agent_id = ? AND capability = ? AND status = 'active'")
const r = await db.prepare("UPDATE dev_agent_grants SET status = 'revoked', revoked_at = ?, revoked_by = ?, revoke_reason = ?, updated_at = ? WHERE agent_id = ? AND capability = ? AND status = 'active'")
.run(t, actor, reason || null, t, agent.id, g.capability);
.run(t, actor, reason || null, t, agent.id, g.capability);
- if (r.changes) await grantAudit(db, agent, g, { actor, from: 'active', to: 'revoked', mode: g.mode, reason, ctx });
- return r.changes > 0;
+ if (!r.changes) return false;
+ await grantAudit(db, agent, g, { actor, from: 'active', to: 'revoked', mode: g.mode, reason, ctx });
+ if ((await db.prepare('DELETE FROM dev_agent_budgets WHERE agent_id = ? AND capability = ?').run(agent.id, g.capability)).changes) {
+ await store.audit(db, { projectId: agent.project_id, actor, action: 'agent.budget_removed', target: `agent:${agent.id}`, detail: { capability: g.capability, reason: 'grant_revoked' }, ctx });
+ }
+ await cancel(db, agent.id, g.capability, cancelReason || reason || 'grant_revoked', actor, ctx);
+ return true;
}
}
function expiryOf(v) {
function expiryOf(v) {
@@ -320,19 +344,88 @@ async function deleteGrant(db, actor, projectId, agentId, capability, { ctx, set
@@ -320,19 +344,88 @@ async function deleteGrant(db, actor, projectId, agentId, capability, { ctx, set
* matching `where` that their host's ceiling no longer covers, one audit row each (reason beyond_host). `actor` is the
* matching `where` that their host's ceiling no longer covers, one audit row each (reason beyond_host). `actor` is the
* subject label of whoever shrank it. Returns how many were revoked.
* subject label of whoever shrank it. Returns how many were revoked.
*/
*/
-async function revokeBeyondHost(db, where, params, { actor, ctx, settings }) {
+async function revokeBeyondHost(db, where, params, { actor, ctx, settings, cancelReason }) {
let n = 0;
let n = 0;
for (const a of await db.prepare(`SELECT * FROM dev_agents WHERE (${where}) AND status <> 'revoked' ORDER BY id`).all(...params)) {
for (const a of await db.prepare(`SELECT * FROM dev_agents WHERE (${where}) AND status <> 'revoked' ORDER BY id`).all(...params)) {
const ceilings = new Map();
const ceilings = new Map();
for (const g of await db.prepare("SELECT * FROM dev_agent_grants WHERE agent_id = ? AND status = 'active' ORDER BY capability").all(a.id)) {
for (const g of await db.prepare("SELECT * FROM dev_agent_grants WHERE agent_id = ? AND status = 'active' ORDER BY capability").all(a.id)) {
if (!ceilings.has(g.audience)) ceilings.set(g.audience, await ceiling(db, a, g.audience, settings));
if (!ceilings.has(g.audience)) ceilings.set(g.audience, await ceiling(db, a, g.audience, settings));
- if (!ceilings.get(g.audience).has(g.capability) && await revokeGrantRow(db, a, g, { actor, reason: 'beyond_host', ctx })) n++;
+ if (!ceilings.get(g.audience).has(g.capability) && await revokeGrantRow(db, a, g, { actor, reason: 'beyond_host', cancelReason, ctx })) n++;
}
}
}
}
return n;
return n;
}
}
+// ── Budgets ────────────────────────────────────────────────────
+
+const BUDGET_WINDOWS = ['minute', 'hour', 'day', 'month', 'total'];
+
+/** enforced_by is the capability's audience: the owning service meters, Network never does. */
+function budgetView(b) {
+ return { capability: b.capability, limit: Number(b.limit_value), window: b.budget_window, unit: b.unit, enforced_by: policy.audienceOf(b.capability) };
+}
+
+async function listBudgets(db, actor, projectId, agentId) {
+ const { project } = await store.access(db, actor, projectId, { allowArchived: true });
+ const a = await loadAgent(db, project.id, agentId);
+ return (await db.prepare('SELECT * FROM dev_agent_budgets WHERE agent_id = ? ORDER BY capability').all(a.id)).map(budgetView);
+}
+
+/** The agent's owner or an admin+ (staff as such never: a budget is the project's own decision). */
+async function budgetAccess(db, actor, projectId, agentId) {
+ const { project, role } = await store.access(db, actor, projectId, { staffOk: false });
+ const a = await loadAgent(db, project.id, agentId);
+ if (!(role && a.owner_subject === actor.subject) && !atLeast(role, 'admin')) fail(403, 'agent.forbidden', "only the agent's owner or an admin sets its budgets");
+ return { project, agent: a };
+}
+
+/**
+ * Set a budget: { limit, window, unit? } for a capability the agent holds (an active delegated grant, else 404
+ * grant.not_found). When the project has a quota for the capability, the budget has its window and unit and a limit
+ * no higher (else 422 budget.beyond_quota: a different window or unit does not compare). A limit of 0 is legal.
+ */
+async function setBudget(db, actor, projectId, agentId, capability, body, { ctx }) {
+ const { project, agent } = await budgetAccess(db, actor, projectId, agentId);
+ const limit = body.limit;
+ if (typeof limit !== 'number' || !Number.isSafeInteger(limit) || limit < 0) fail(422, 'budget.invalid', 'limit is a non-negative integer');
+ const window = String(body.window || '');
+ if (!BUDGET_WINDOWS.includes(window)) fail(422, 'budget.invalid', `window is one of ${BUDGET_WINDOWS.join(', ')}`);
+ const unit = body.unit == null ? 'requests' : String(body.unit);
+ if (!/^[a-z][a-z0-9_]{0,31}$/.test(unit)) fail(422, 'budget.invalid', 'unit is a short lowercase word (requests, bytes, tokens, ...)');
+ const cap = String(capability);
+ await db.tx(async () => {
+ const cur = await db.prepare('SELECT * FROM dev_agents WHERE id = ? FOR UPDATE').get(agent.id);
+ if (cur.status === 'revoked') fail(409, 'agent.revoked', 'agent is revoked');
+ if (!await db.prepare("SELECT 1 AS ok FROM dev_agent_grants WHERE agent_id = ? AND capability = ? AND status = 'active' FOR SHARE").get(agent.id, cap)) {
+ fail(404, 'grant.not_found', 'the agent holds no active grant for that capability');
+ }
+ const q = await db.prepare('SELECT * FROM dev_quotas WHERE project_id = ? AND capability = ?').get(project.id, cap);
+ if (q && (q.quota_window !== window || q.unit !== unit || limit > Number(q.limit_value))) {
+ fail(422, 'budget.beyond_quota', `the project's quota for ${cap} is ${q.limit_value} ${q.unit} per ${q.quota_window}`);
+ }
+ await db.prepare(`INSERT INTO dev_agent_budgets (agent_id, capability, limit_value, budget_window, unit, updated_at, updated_by) VALUES (?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT (agent_id, capability) DO UPDATE SET limit_value = excluded.limit_value, budget_window = excluded.budget_window, unit = excluded.unit,
+ updated_at = excluded.updated_at, updated_by = excluded.updated_by`)
+ .run(agent.id, cap, limit, window, unit, nowIso(), actor.label);
+ await store.audit(db, { projectId: project.id, actor: actor.label, action: 'agent.budget_set', target: `agent:${agent.id}`, detail: { capability: cap, limit, window, unit }, ctx });
+ });
+ return budgetView(await db.prepare('SELECT * FROM dev_agent_budgets WHERE agent_id = ? AND capability = ?').get(agent.id, cap));
+}
+
+async function deleteBudget(db, actor, projectId, agentId, capability, { ctx }) {
+ const { project, agent } = await budgetAccess(db, actor, projectId, agentId);
+ const cap = String(capability);
+ return await db.tx(async () => {
+ const b = await db.prepare('SELECT * FROM dev_agent_budgets WHERE agent_id = ? AND capability = ? FOR UPDATE').get(agent.id, cap);
+ if (!b) fail(404, 'budget.not_found', 'no budget for that capability');
+ await db.prepare('DELETE FROM dev_agent_budgets WHERE agent_id = ? AND capability = ?').run(agent.id, cap);
+ await store.audit(db, { projectId: project.id, actor: actor.label, action: 'agent.budget_removed', target: `agent:${agent.id}`, detail: { capability: cap }, ctx });
+ return budgetView(b);
+ });
+}
+
module.exports = {
module.exports = {
- ensureSchema, AGENT_ID_RE, agentView, 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, withinHost, listGrants, putGrant, deleteGrant, revokeBeyondHost,
+ budgetView, listBudgets, setBudget, deleteBudget,
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,356 @@
@@ -0,0 +1,356 @@
+'use strict';
+/**
+ * Confirmations, owner side (plan T2 WS-Z2 slice 4, docs/t2-projects-and-grants.md sections 3-5): a sensitive use of a
+ * delegated grant waits for its owner's approval. The schema is migrations/0018_confirmations.sql.
+ *
+ * - The owning service of the capability creates, consumes and cancels them (`create`, `consume`; its
+ * /internal/confirmations routes wait for slice 7). The owner reads, approves and denies them at
+ * /api/v1/confirmations (`router`); nobody else sees one (not admins, not staff: 404 confirmation.not_found).
+ * - Views are network.confirmation-request@1 documents; audience, session_id, request_digest, rule_id, used_at,
+ * decided_by, cancel_reason and project_id stay Network-local, never inside the document.
+ * - Approving with `session`, `until` or `always` leaves a standing rule that approves the next matching request at
+ * creation (`decided_by = 'rule:<id>'`). Denying never does.
+ * - `consume` spends an approval once, after re-checking in one transaction (the agent row locked first) everything
+ * that let it be created: authority that lapsed without a Network write still stops it.
+ * - 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.
+ */
+const { ids, validate, capabilities } = require('openvibe-contracts');
+const policy = require('./policy');
+const store = require('./store');
+const agents = require('./agents');
+
+const CNF_ID_RE = /^cnf_[0-9A-HJKMNP-TV-Z]{26}$/;
+const SESSION_RE = /^[A-Za-z0-9._:-]{8,128}$/;
+const DIGEST_RE = /^[0-9a-f]{64}$/;
+const STATES = ['pending', 'approved', 'denied', 'expired', 'cancelled'];
+const RULES = ['once', 'session', 'until', 'always'];
+const TTL = { min: 60, max: 86400, default: 900 };
+const MAX_PENDING = 20;
+const SESSION_MAX_MS = 24 * 3600 * 1000;
+const UNTIL_MAX_MS = 30 * 24 * 3600 * 1000;
+const MAX_DETAILS = 8192;
+// A row that may still be spent or decided: what a cascade cancels.
+const LIVE = "(state = 'pending' OR (state = 'approved' AND used_at IS NULL))";
+
+const fail = (status, code, detail) => { throw new store.DevError(status, code, detail); };
+const nowIso = () => new Date().toISOString();
+
+/** The state a read reports: a pending row past expires_at is expired before the sweep records it. */
+const stateOf = (c, now = nowIso()) => (c.state === 'pending' && c.expires_at <= now ? 'expired' : c.state);
+
+/** network.confirmation-request@1: owner and requested_by rebuilt as SubjectRefs, details/resources parsed. */
+function confirmationView(c) {
+ const doc = {
+ id: c.id, owner: { type: 'user', id: c.owner_subject }, requested_by: { type: 'agent', id: c.agent_id },
+ capability: c.capability, summary: c.summary, details: JSON.parse(c.details || '{}'), resources: JSON.parse(c.resources || '[]'),
+ state: stateOf(c), expires_at: c.expires_at, created_at: c.created_at,
+ };
+ if (c.standing_rule) doc.standing_rule = c.standing_rule;
+ if (c.decided_at) doc.decided_at = c.decided_at;
+ return doc;
+}
+
+function ruleView(r) {
+ return {
+ id: Number(r.id), capability: r.capability, rule: r.rule, until_at: r.until_at || null, session_id: r.session_id || null,
+ source: r.source, created_at: r.created_at, revoked_at: r.revoked_at || 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 } = {}) {
+ 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 });
+}
+
+// ── Authority, as at creation and again at consume ─────────────
+
+/**
+ * Throws unless `agent` may still ask for `capability` now: the agent is active, its project is not archived, its owner
+ * is still a member and not banned or deleted, an app host is live and still the project's and environment's; the
+ * delegated grant is active, unexpired and confirmed; the host's ceiling still covers it; its budget is not 0.
+ */
+async function checkAuthority(db, agent, capability, settings) {
+ const inactive = (why) => fail(409, 'confirmation.agent_inactive', why);
+ if (agent.status !== 'active') inactive(`agent is ${agent.status}`);
+ const project = await db.prepare('SELECT archived_at FROM dev_projects WHERE id = ?').get(agent.project_id);
+ if (!project || project.archived_at) inactive('project is archived');
+ if (!await store.memberRole(db, agent.project_id, agent.owner_subject)) inactive('owner is 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) inactive('owner account is not active');
+ if (agent.host_kind === 'app' && !await db.prepare('SELECT 1 AS ok FROM dev_apps WHERE id = ? AND project_id = ? AND environment = ? AND revoked_at IS NULL')
+ .get(agent.host_app_id, agent.project_id, agent.environment)) inactive('host app is revoked');
+ const notDelegated = (why) => fail(403, 'grant.not_delegated', why);
+ const g = await db.prepare("SELECT * FROM dev_agent_grants WHERE agent_id = ? AND capability = ? AND status = 'active'").get(agent.id, capability);
+ if (!g || (g.expires_at && g.expires_at <= nowIso())) notDelegated('no active delegated grant for that capability');
+ if (agents.effectiveMode(g) !== 'confirm') notDelegated('the grant is auto: nothing to confirm');
+ if (!await agents.withinHost(db, agent, capability, settings)) notDelegated("the capability is outside the agent's host");
+ const b = await db.prepare('SELECT limit_value FROM dev_agent_budgets WHERE agent_id = ? AND capability = ?').get(agent.id, capability);
+ if (b && Number(b.limit_value) === 0) notDelegated('the budget for that capability is 0');
+}
+
+// ── The owning service's side (routes in slice 7) ──────────────
+
+/**
+ * Create a confirmation for an agent's sensitive use of `capability`, asked by `audience` (the owning service). The
+ * owner is the agent's, never the caller's say. A live standing rule approves it at once. → the contract document.
+ */
+async function create(db, { agentId, capability, audience, summary, details, resources, requestDigest, sessionId, ttlS }, { ctx, settings } = {}) {
+ const invalid = (d) => fail(422, 'confirmation.invalid', d);
+ if (!agents.AGENT_ID_RE.test(String(agentId))) fail(404, 'agent.not_found', 'no such agent');
+ if (!capabilities.get(String(capability))) fail(404, 'grant.unknown_capability', 'no such capability in the catalog');
+ if (policy.audienceOf(capability) !== audience) fail(403, 'confirmation.wrong_audience', 'the capability is not the caller\'s');
+ if (typeof summary !== 'string' || !summary.trim() || summary.length > 500) invalid('summary is 1-500 characters');
+ if (details !== undefined && (!details || typeof details !== 'object' || Array.isArray(details))) invalid('details is an object');
+ if (resources !== undefined && !Array.isArray(resources)) invalid('resources is an array of entity refs');
+ const detailsJson = JSON.stringify(details || {});
+ if (detailsJson.length > MAX_DETAILS) invalid('details is too large');
+ if (!DIGEST_RE.test(String(requestDigest))) invalid('request_digest is a lowercase hex SHA-256');
+ if (sessionId != null && !SESSION_RE.test(String(sessionId))) invalid('session_id is 8-128 of [A-Za-z0-9._:-]');
+ const ttl = ttlS == null ? TTL.default : Number(ttlS);
+ if (!Number.isInteger(ttl) || ttl < TTL.min || ttl > TTL.max) invalid(`ttl_s is ${TTL.min}-${TTL.max}`);
+ const id = ids.newId('confirmation');
+ await db.tx(async () => {
+ const agent = await db.prepare('SELECT * FROM dev_agents WHERE id = ? FOR UPDATE').get(agentId);
+ if (!agent) fail(404, 'agent.not_found', 'no such agent');
+ await checkAuthority(db, agent, capability, settings);
+ const now = nowIso();
+ const pending = await db.prepare("SELECT count(*) AS n FROM dev_confirmations WHERE agent_id = ? AND state = 'pending' AND expires_at > ?").get(agent.id, now);
+ if (Number(pending.n) >= MAX_PENDING) fail(429, 'confirmation.too_many_pending', `at most ${MAX_PENDING} pending confirmations per agent`);
+ const rule = await db.prepare(`SELECT * FROM dev_standing_rules WHERE agent_id = ? AND capability = ? AND revoked_at IS NULL
+ 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,
+ 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,
+ };
+ const v = validate('network.confirmation-request@1', confirmationView(row));
+ if (!v.valid) invalid(v.errors.map((e) => `${e.path} ${e.message}`).join('; ').slice(0, 300));
+ await db.prepare(`INSERT INTO dev_confirmations (id, project_id, agent_id, owner_subject, capability, audience, summary, details, resources, state, standing_rule,
+ session_id, request_digest, rule_id, expires_at, created_at, decided_at, decided_by)
+ 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 });
+ 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));
+}
+
+/**
+ * Spend an approval once, for the action whose digest is `requestDigest`, asked by the audience that created it. One
+ * transaction: the agent row is locked first so no revocation interleaves, authority is re-checked, then one
+ * conditional UPDATE spends it. → { confirmation, used_at }
+ */
+async function consume(db, { id, audience, requestDigest }, { ctx, settings } = {}) {
+ const notFound = () => fail(404, 'confirmation.not_found', 'no such confirmation');
+ if (!CNF_ID_RE.test(String(id))) notFound();
+ const first = await db.prepare('SELECT agent_id FROM dev_confirmations WHERE id = ? AND audience = ?').get(id, audience);
+ if (!first) notFound();
+ return await db.tx(async () => {
+ const agent = await db.prepare('SELECT * FROM dev_agents WHERE id = ? FOR UPDATE').get(first.agent_id);
+ const c = await db.prepare('SELECT * FROM dev_confirmations WHERE id = ? FOR UPDATE').get(id);
+ const now = nowIso();
+ if (c.state === 'cancelled') fail(409, 'confirmation.cancelled', `cancelled (${c.cancel_reason || 'service'})`);
+ if (c.used_at) fail(409, 'confirmation.used', 'the confirmation was already used');
+ if (c.state === 'expired' || c.expires_at <= now) fail(409, 'confirmation.expired', 'the confirmation has expired');
+ if (c.state !== 'approved') fail(409, 'confirmation.not_pending', `confirmation is ${c.state}`);
+ if (c.request_digest !== String(requestDigest)) fail(409, 'confirmation.mismatch', 'the approval is for another action');
+ await checkAuthority(db, agent, c.capability, 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 });
+ return { confirmation: confirmationView({ ...c, used_at: now }), used_at: now };
+ });
+}
+
+/**
+ * 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.
+ * → 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);
+ if (!r.changes) continue;
+ n++;
+ await audit(db, c, 'confirmation.cancelled', { actor, detail: { from: c.state, reason }, ctx });
+ }
+ 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)) {
+ await revokeRuleRow(db, rule, { actor, reason, t, ctx });
+ }
+ return n;
+}
+
+async function revokeRuleRow(db, rule, { actor, reason, t = nowIso(), ctx }) {
+ const r = await db.prepare('UPDATE dev_standing_rules SET revoked_at = ?, revoked_by = ? WHERE id = ? AND revoked_at IS NULL').run(t, actor, rule.id);
+ if (!r.changes) return false;
+ const a = await db.prepare('SELECT project_id FROM dev_agents WHERE id = ?').get(rule.agent_id);
+ await store.audit(db, { projectId: a.project_id, actor, action: 'agent.rule_revoked', target: `agent:${rule.agent_id}`,
+ detail: { rule_id: Number(rule.id), capability: rule.capability, rule: rule.rule, reason: reason || undefined }, ctx });
+ return true;
+}
+
+/** Record every pending confirmation past expires_at as expired, once. → how many */
+async function expireDue(db) {
+ const due = await db.prepare("SELECT * FROM dev_confirmations WHERE state = 'pending' AND expires_at <= ? ORDER BY id").all(nowIso());
+ let n = 0;
+ for (const c of due) {
+ await db.tx(async () => {
+ 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');
+ });
+ }
+ return n;
+}
+
+// ── The owner's inbox ──────────────────────────────────────────
+
+async function ownRow(db, actor, id, { lock = false } = {}) {
+ const c = CNF_ID_RE.test(String(id)) ? await db.prepare(`SELECT * FROM dev_confirmations WHERE id = ? AND owner_subject = ?${lock ? ' FOR UPDATE' : ''}`).get(id, actor.subject) : null;
+ if (!c) fail(404, 'confirmation.not_found', 'no such confirmation');
+ return c;
+}
+
+/** The caller's own confirmations, newest first. state defaults to pending; before is the last id of the previous page. */
+async function list(db, actor, { state = 'pending', before, limit } = {}) {
+ const st = state == null || state === '' ? 'pending' : String(state);
+ if (!STATES.includes(st)) fail(422, 'confirmation.invalid', `state is one of ${STATES.join(', ')}`);
+ const n = Math.min(100, Math.max(1, Number(limit) || 50));
+ const now = nowIso();
+ const where = { pending: "state = 'pending' AND expires_at > ?", expired: "(state = 'expired' OR (state = 'pending' AND expires_at <= ?))" }[st] || 'state = ?';
+ const params = [actor.subject, st === 'pending' || st === 'expired' ? now : st];
+ let sql = `SELECT * FROM dev_confirmations WHERE owner_subject = ? AND ${where}`;
+ if (before !== undefined && before !== '') {
+ if (!CNF_ID_RE.test(String(before))) fail(422, 'confirmation.invalid', 'before is a confirmation id');
+ sql += ' AND id < ?';
+ params.push(String(before));
+ }
+ const rows = await db.prepare(`${sql} ORDER BY id DESC LIMIT ?`).all(...params, n);
+ const ctxs = {};
+ for (const agentId of new Set(rows.map((r) => r.agent_id))) ctxs[agentId] = agentContext(await db.prepare('SELECT * FROM dev_agents WHERE id = ?').get(agentId));
+ return { confirmations: rows.map(confirmationView), agents: ctxs, next_before: rows.length === n ? rows[rows.length - 1].id : null };
+}
+
+async function get(db, actor, id) {
+ const c = await ownRow(db, actor, id);
+ return { confirmation: confirmationView(c), agent: agentContext(await db.prepare('SELECT * FROM dev_agents WHERE id = ?').get(c.agent_id)) };
+}
+
+function untilOf(v, maxMs, required) {
+ if (v == null || v === '') { if (required) fail(422, 'confirmation.invalid', 'until is required'); return null; }
+ const d = new Date(String(v));
+ if (Number.isNaN(d.getTime()) || d.getTime() <= Date.now()) fail(422, 'confirmation.invalid', 'until is a future date-time');
+ if (d.getTime() > Date.now() + maxMs) fail(422, 'confirmation.invalid', `until is at most ${maxMs === SESSION_MAX_MS ? '24 hours' : '30 days'} away`);
+ return d.toISOString();
+}
+
+/**
+ * The owner approves or denies a pending confirmation. A pending row past expires_at is recorded expired and refused
+ * (409 confirmation.expired); any other state is 409 confirmation.not_pending. Approving with session/until/always
+ * also leaves a standing rule. → { confirmation, rule? }
+ */
+async function decide(db, actor, id, action, body = {}, { ctx } = {}) {
+ if (action !== 'approve' && action !== 'deny') fail(404, 'confirmation.not_found', 'no such action');
+ const standing = action === 'approve' ? (body.standing_rule == null ? 'once' : body.standing_rule) : null;
+ if (action === 'approve' && !RULES.includes(standing)) fail(422, 'confirmation.invalid', `standing_rule is one of ${RULES.join(', ')}`);
+ const expired = await db.tx(async () => {
+ 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 });
+ return true;
+ }
+ if (c.state !== 'pending') fail(409, 'confirmation.not_pending', `confirmation is ${stateOf(c, now)}`);
+ let ruleId = null;
+ if (standing && standing !== 'once') {
+ if (standing === 'session' && !c.session_id) fail(422, 'confirmation.no_session', 'the request carries no session_id');
+ const untilAt = standing === 'always' ? null
+ : standing === 'session' ? (untilOf(body.until, SESSION_MAX_MS, false) || new Date(Date.now() + SESSION_MAX_MS).toISOString())
+ : untilOf(body.until, UNTIL_MAX_MS, true);
+ ruleId = (await db.prepare(`INSERT INTO dev_standing_rules (agent_id, capability, rule, session_id, until_at, source, created_at, created_by)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`).get(c.agent_id, c.capability, standing, standing === 'session' ? c.session_id : null, untilAt, c.id, now, actor.label)).id;
+ }
+ 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 });
+ return false;
+ });
+ if (expired) fail(409, 'confirmation.expired', 'the confirmation has expired');
+ const c = await ownRow(db, actor, id);
+ const out = { confirmation: confirmationView(c) };
+ const rule = await db.prepare('SELECT * FROM dev_standing_rules WHERE source = ? ORDER BY id DESC LIMIT 1').get(c.id);
+ if (rule) out.rule = ruleView(rule);
+ return out;
+}
+
+// ── Standing rules, under /api/v1/projects/:project/agents/:agent/rules ──
+
+async function listRules(db, actor, projectId, agentId) {
+ const { project } = await store.access(db, actor, projectId, { allowArchived: true });
+ const a = await agents.loadAgent(db, project.id, agentId);
+ return (await db.prepare('SELECT * FROM dev_standing_rules WHERE agent_id = ? AND revoked_at IS NULL ORDER BY id').all(a.id)).map(ruleView);
+}
+
+/** Revoke a standing rule: the agent's owner, an admin+ or staff. */
+async function revokeRule(db, actor, projectId, agentId, ruleId, { ctx }) {
+ const { project, role } = await store.access(db, actor, projectId, { allowArchived: true });
+ const a = await agents.loadAgent(db, project.id, agentId);
+ if (!(role && a.owner_subject === actor.subject) && !agents.atLeast(role, 'admin') && !actor.staff) {
+ fail(403, 'agent.forbidden', "only the agent's owner, an admin or staff revoke its rules");
+ }
+ const id = /^[1-9][0-9]{0,17}$/.test(String(ruleId)) ? String(ruleId) : null;
+ const rule = id && await db.prepare('SELECT * FROM dev_standing_rules WHERE id = ? AND agent_id = ?').get(id, a.id);
+ if (!rule) fail(404, 'rule.not_found', 'no such rule');
+ await db.tx(async () => { await revokeRuleRow(db, rule, { actor: actor.label, ctx }); });
+ return ruleView(await db.prepare('SELECT * FROM dev_standing_rules WHERE id = ?').get(rule.id));
+}
+
+// ── Routes and the sweep ───────────────────────────────────────
+
+/**
+ * /api/v1/confirmations — the owner's inbox (Bearer user tokens only, as /api/v1/projects).
+ * GET /[?state=pending&before=&limit=] { confirmations, agents, next_before }
+ * GET /:id { confirmation, agent }
+ * POST /:id/approve { standing_rule?, until? } { confirmation, rule? }
+ * POST /:id/deny { confirmation }
+ */
+function router() {
+ const { userApi, handler, finish } = require('./routes');
+ const r = userApi();
+ const handle = handler();
+ r.get('/', handle(async (db, a, req) => await list(db, a, { state: req.query.state, before: req.query.before, limit: req.query.limit })));
+ r.get('/:id', handle(async (db, a, req) => await get(db, a, req.params.id)));
+ r.post('/:id/approve', handle(async (db, a, req, o) => await decide(db, a, req.params.id, 'approve', req.body || {}, o)));
+ r.post('/:id/deny', handle(async (db, a, req, o) => await decide(db, a, req.params.id, 'deny', req.body || {}, o)));
+ return finish(r);
+}
+
+let timer = null;
+function start(db, { intervalMs = 60_000 } = {}) {
+ if (timer) return;
+ const run = async () => { try { await expireDue(db); } catch (err) { console.warn('[Confirmations] expiry:', err.message); } };
+ timer = setInterval(run, intervalMs);
+ if (timer.unref) timer.unref();
+}
+function stop() { if (timer) clearInterval(timer); timer = null; }
+
+module.exports = {
+ create, list, get, decide, consume, cancelFor, expireDue, confirmationView, ruleView, listRules, revokeRule, router, start, stop,
+ MAX_PENDING,
+};
@@ -45,6 +45,12 @@ const cache = require('openvibe-shared/cache-policy');
@@ -45,6 +45,12 @@ const cache = require('openvibe-shared/cache-policy');
* GET /:project/agents/:agent/grants viewer+: delegated grants with effective_mode and within_host
* GET /:project/agents/:agent/grants viewer+: delegated grants with effective_mode and within_host
* PUT /:project/agents/:agent/grants/:capability { mode, expires_at? } its owner only, inside the host's ceiling
* PUT /:project/agents/:agent/grants/:capability { mode, expires_at? } its owner only, inside the host's ceiling
* DELETE /:project/agents/:agent/grants/:capability revoke: its owner, admin+ or staff
* DELETE /:project/agents/:agent/grants/:capability revoke: its owner, admin+ or staff
+ * GET /:project/agents/:agent/budgets viewer+: { capability, limit, window, unit, enforced_by }
+ * PUT /:project/agents/:agent/budgets/:capability { limit, window, unit? } its owner or admin+ (not staff); needs
+ * an active grant, never beyond the project's quota
+ * DELETE /:project/agents/:agent/budgets/:capability its owner or admin+ (not staff)
+ * GET /:project/agents/:agent/rules viewer+: the live standing rules (confirmations.js)
+ * DELETE /:project/agents/:agent/rules/:rule revoke: its owner, admin+ or staff
* GET /:project/quotas viewer+
* GET /:project/quotas viewer+
* PUT /:project/quotas/:capability { limit, window, unit } staff
* PUT /:project/quotas/:capability { limit, window, unit } staff
* DELETE /:project/quotas/:capability staff
* DELETE /:project/quotas/:capability staff
@@ -59,11 +65,16 @@ const { http } = require('openvibe-contracts');
@@ -59,11 +65,16 @@ const { http } = require('openvibe-contracts');
const { verifySession } = require('../auth/session');
const { verifySession } = require('../auth/session');
const store = require('./store');
const store = require('./store');
const agents = require('./agents');
const agents = require('./agents');
+const confirmations = require('./confirmations');
const policy = require('./policy');
const policy = require('./policy');
const tokens = require('./tokens');
const tokens = require('./tokens');
const usage = require('./usage');
const usage = require('./usage');
-function router() {
+/**
+ * The base of a user API: problems with request ids, JSON bodies, private no-store, and Bearer Network user tokens
+ * only (req.actor). Shared with /api/v1/confirmations (confirmations.js).
+ */
+function userApi() {
const r = express.Router();
const r = express.Router();
r.use(http.middleware());
r.use(http.middleware());
r.use(express.json({ limit: '64kb' }));
r.use(express.json({ limit: '64kb' }));
@@ -83,15 +94,30 @@ function router() {
@@ -83,15 +94,30 @@ function router() {
try { req.actor = await store.actorOf(db, out.user); } catch (err) { return send(req, res, err); }
try { req.actor = await store.actorOf(db, out.user); } catch (err) { return send(req, res, err); }
next();
next();
});
});
+ return r;
+}
+/** handle(fn, status): fn(db, actor, req, { ctx, settings }) → JSON body, or 204 for undefined; errors as problems. */
+function handler() {
const ctxOf = (req) => ({ ctx: req.ov, settings: policy.settings(req.app.locals.config) });
const ctxOf = (req) => ({ ctx: req.ov, settings: policy.settings(req.app.locals.config) });
- const handle = (fn, status = 200) => async (req, res) => {
+ return (fn, status = 200) => async (req, res) => {
try {
try {
const out = await fn(req.app.locals.db, req.actor, req, ctxOf(req));
const out = await fn(req.app.locals.db, req.actor, req, ctxOf(req));
if (out === undefined) return res.status(204).end();
if (out === undefined) return res.status(204).end();
res.status(status).json(out);
res.status(status).json(out);
} catch (err) { send(req, res, err); }
} catch (err) { send(req, res, err); }
};
};
+}
+
+/** Malformed JSON and other body errors as problems too. */
+function finish(r) {
+ r.use((err, req, res, _next) => send(req, res, err));
+ return r;
+}
+
+function router() {
+ const r = userApi();
+ const handle = handler();
r.get('/catalog', (req, res) => res.json({
r.get('/catalog', (req, res) => res.json({
capabilities: policy.grantableCatalog(),
capabilities: policy.grantableCatalog(),
@@ -142,6 +168,11 @@ function router() {
@@ -142,6 +168,11 @@ function router() {
r.get('/:project/agents/:agent/grants', handle(async (db, a, req, o) => ({ grants: await agents.listGrants(db, a, req.params.project, req.params.agent, o) })));
r.get('/:project/agents/:agent/grants', handle(async (db, a, req, o) => ({ grants: await agents.listGrants(db, a, req.params.project, req.params.agent, o) })));
r.put('/:project/agents/:agent/grants/:capability', handle(async (db, a, req, o) => await agents.putGrant(db, a, req.params.project, req.params.agent, req.params.capability, req.body || {}, o)));
r.put('/:project/agents/:agent/grants/:capability', handle(async (db, a, req, o) => await agents.putGrant(db, a, req.params.project, req.params.agent, req.params.capability, req.body || {}, o)));
r.delete('/:project/agents/:agent/grants/:capability', handle(async (db, a, req, o) => await agents.deleteGrant(db, a, req.params.project, req.params.agent, req.params.capability, o)));
r.delete('/:project/agents/:agent/grants/:capability', handle(async (db, a, req, o) => await agents.deleteGrant(db, a, req.params.project, req.params.agent, req.params.capability, o)));
+ r.get('/:project/agents/:agent/budgets', handle(async (db, a, req) => ({ budgets: await agents.listBudgets(db, a, req.params.project, req.params.agent) })));
+ r.put('/:project/agents/:agent/budgets/:capability', handle(async (db, a, req, o) => await agents.setBudget(db, a, req.params.project, req.params.agent, req.params.capability, req.body || {}, o)));
+ r.delete('/:project/agents/:agent/budgets/:capability', handle(async (db, a, req, o) => await agents.deleteBudget(db, a, req.params.project, req.params.agent, req.params.capability, o)));
+ r.get('/:project/agents/:agent/rules', handle(async (db, a, req) => ({ rules: await confirmations.listRules(db, a, req.params.project, req.params.agent) })));
+ r.delete('/:project/agents/:agent/rules/:rule', handle(async (db, a, req, o) => await confirmations.revokeRule(db, a, req.params.project, req.params.agent, req.params.rule, o)));
r.get('/:project/quotas', handle(async (db, a, req) => ({ quotas: await store.listQuotas(db, a, req.params.project), note: 'quotas are enforced by the service that owns each capability; Network records and exposes them' })));
r.get('/:project/quotas', handle(async (db, a, req) => ({ quotas: await store.listQuotas(db, a, req.params.project), note: 'quotas are enforced by the service that owns each capability; Network records and exposes them' })));
r.put('/:project/quotas/:capability', handle(async (db, a, req, o) => await store.setQuota(db, a, req.params.project, req.params.capability, req.body || {}, o)));
r.put('/:project/quotas/:capability', handle(async (db, a, req, o) => await store.setQuota(db, a, req.params.project, req.params.capability, req.body || {}, o)));
@@ -163,9 +194,7 @@ function router() {
@@ -163,9 +194,7 @@ function router() {
privateKey: req.app.locals.privateKey, issuer: req.app.locals.config.jwt.issuer, ctx: o.ctx,
privateKey: req.app.locals.privateKey, issuer: req.app.locals.config.jwt.issuer, ctx: o.ctx,
}), 201));
}), 201));
- // Malformed JSON and other body errors as problems too.
- r.use((err, req, res, _next) => send(req, res, err));
- return r;
+ return finish(r);
}
}
function send(req, res, err) {
function send(req, res, err) {
@@ -177,4 +206,4 @@ function send(req, res, err) {
@@ -177,4 +206,4 @@ function send(req, res, err) {
return http.sendProblem(res, 500, 'internal.error', { detail: 'unexpected error', ctx: req.ov });
return http.sendProblem(res, 500, 'internal.error', { detail: 'unexpected error', ctx: req.ov });
}
}
-module.exports = { router };
+module.exports = { router, userApi, handler, finish };
@@ -143,17 +143,19 @@ async function revoke(db, body, actor) {
@@ -143,17 +143,19 @@ async function revoke(db, body, actor) {
.run(actor || 'owner', reason, t.client_id, t.capability, t.audience);
.run(actor || 'owner', reason, t.client_id, t.capability, t.audience);
const namespaces = JSON.parse(prev.namespaces || '[]');
const namespaces = JSON.parse(prev.namespaces || '[]');
await record(db, { ...t, change: 'revoked', namespaces, expires_at: fromSql(prev.expires_at), reason, actor });
await record(db, { ...t, change: 'revoked', namespaces, expires_at: fromSql(prev.expires_at), reason, actor });
- await revokeDelegated(db, t.client_id, actor);
+ await revokeDelegated(db, t.client_id, actor, 'beyond_host');
return { ...t, change: 'revoked' };
return { ...t, change: 'revoked' };
});
});
}
}
/**
/**
* The agents this service hosts lose the delegated grants its remaining grants no longer cover (plan T2 WS-Z2), in the
* The agents this service hosts lose the delegated grants its remaining grants no longer cover (plan T2 WS-Z2), in the
- * caller's transaction. Required lazily: agents.js reaches principals.js, which requires this module.
+ * caller's transaction; their confirmations and standing rules for those capabilities are cancelled with `cancelReason`
+ * (beyond_host for a revoke, grant_expired for an expiry). Required lazily: agents.js reaches principals.js, which
+ * requires this module.
*/
*/
-async function revokeDelegated(db, clientId, actor) {
- await require('../developer/agents').revokeBeyondHost(db, "host_kind = 'service' AND host_service = ?", [clientId], { actor: actor ? `user:${actor}` : 'system:network' });
+async function revokeDelegated(db, clientId, actor, cancelReason) {
+ await require('../developer/agents').revokeBeyondHost(db, "host_kind = 'service' AND host_service = ?", [clientId], { actor: actor ? `user:${actor}` : 'system:network', cancelReason });
}
}
/** Record every grant whose expiry has passed, once. → how many */
/** Record every grant whose expiry has passed, once. → how many */
@@ -164,7 +166,7 @@ async function expireDue(db) {
@@ -164,7 +166,7 @@ async function expireDue(db) {
const n = (await db.prepare("UPDATE principal_grants SET revoked_at = expires_at, revoked_by = 'expiry' WHERE client_id = ? AND capability = ? AND audience = ? AND revoked_at IS NULL")
const n = (await db.prepare("UPDATE principal_grants SET revoked_at = expires_at, revoked_by = 'expiry' WHERE client_id = ? AND capability = ? AND audience = ? AND revoked_at IS NULL")
.run(g.client_id, g.capability, g.audience)).changes;
.run(g.client_id, g.capability, g.audience)).changes;
if (n) await record(db, { client_id: g.client_id, capability: g.capability, audience: g.audience, change: 'expired', namespaces: JSON.parse(g.namespaces || '[]'), expires_at: fromSql(g.expires_at), reason: g.reason || null, actor: null });
if (n) await record(db, { client_id: g.client_id, capability: g.capability, audience: g.audience, change: 'expired', namespaces: JSON.parse(g.namespaces || '[]'), expires_at: fromSql(g.expires_at), reason: g.reason || null, actor: null });
- if (n) await revokeDelegated(db, g.client_id, null);
+ if (n) await revokeDelegated(db, g.client_id, null, 'grant_expired');
});
});
}
}
if (due.length) kick(db);
if (due.length) kick(db);
@@ -608,6 +608,8 @@ app.use('/api/modules', require('./identity/modules').userRouter(requireAuth));
@@ -608,6 +608,8 @@ app.use('/api/modules', require('./identity/modules').userRouter(requireAuth));
// Developer projects, apps, credentials, grants and quotas (server/developer, ADR-014). Bearer user
// Developer projects, apps, credentials, grants and quotas (server/developer, ADR-014). Bearer user
// tokens only; never X-Internal-Key.
// tokens only; never X-Internal-Key.
app.use('/api/v1/projects', rateLimit({ windowMs: 60_000, max: 60 }), require('./developer/routes').router());
app.use('/api/v1/projects', rateLimit({ windowMs: 60_000, max: 60 }), require('./developer/routes').router());
+// The owner's confirmation inbox (plan T2 WS-Z2 slice 4, server/developer/confirmations.js): the same Bearer-only rules.
+app.use('/api/v1/confirmations', rateLimit({ windowMs: 60_000, max: 60 }), require('./developer/confirmations').router());
// Their network.app.* / credential / grant events go to OpenVibe.Events through an outbox when
// Their network.app.* / credential / grant events go to OpenVibe.Events through an outbox when
// OV_EVENTS_INTERNAL_URL is set (server/developer/event-relay.js); off otherwise.
// OV_EVENTS_INTERNAL_URL is set (server/developer/event-relay.js); off otherwise.
await require('./developer/event-relay').startRelay(db, { eventsUrl: config.eventsInternalUrl, privateKey, issuer: config.jwt.issuer });
await require('./developer/event-relay').startRelay(db, { eventsUrl: config.eventsInternalUrl, privateKey, issuer: config.jwt.issuer });
@@ -615,6 +617,8 @@ await require('./developer/event-relay').startRelay(db, { eventsUrl: config.even
@@ -615,6 +617,8 @@ await require('./developer/event-relay').startRelay(db, { eventsUrl: config.even
// through the same outbox (server/identity/profile-events.js).
// through the same outbox (server/identity/profile-events.js).
await require('./identity/profile-events').start(db);
await require('./identity/profile-events').start(db);
require('./identity/grants-admin').start(db);
require('./identity/grants-admin').start(db);
+// Pending confirmations past expires_at are recorded expired every minute (reads report them expired before that).
+require('./developer/confirmations').start(db);
// Notification API (authenticated users)
// Notification API (authenticated users)
app.use('/api/notifications', createNotificationRoutes(db, notificationService, requireAuth));
app.use('/api/notifications', createNotificationRoutes(db, notificationService, requireAuth));
@@ -974,6 +978,7 @@ gracefulStop({
@@ -974,6 +978,7 @@ gracefulStop({
() => frameService.stop(),
() => frameService.stop(),
() => require('./identity/profile-events').stop(),
() => require('./identity/profile-events').stop(),
() => require('./identity/grants-admin').stop(),
() => require('./identity/grants-admin').stop(),
+ () => require('./developer/confirmations').stop(),
],
],
close: [
close: [
() => within(1500, require('./developer/event-relay').stopRelay(db)),
() => within(1500, require('./developer/event-relay').stopRelay(db)),
new file mode 100644
new file mode 100644
@@ -0,0 +1,137 @@
@@ -0,0 +1,137 @@
+'use strict';
+// Agent budgets (plan T2 WS-Z2 slice 5, docs/t2-projects-and-grants.md sections 3-5 and 7; server/developer/agents.js):
+// the owner or an admin+ sets one (never staff as such, never another member), with enforced_by the owning service;
+// it needs an active delegated grant and stays within the project's quota (same window and unit); a bad limit or
+// window is refused; revoking the grant deletes it; a budget of 0 stops confirmations from being spent.
+// node test/agent-budgets.test.js
+const assert = require('assert');
+const crypto = require('crypto');
+const http = require('http');
+const express = require('express');
+const jwt = require('jsonwebtoken');
+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;
+const PEOPLE = [[10, 'owner'], [11, 'dev'], [12, 'viewer'], [13, 'stranger'], [14, 'staff'], [15, 'admin']];
+await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES
+ (10, 'owner', 'x', 'user'), (11, 'dev', 'x', 'user'), (12, 'viewer', 'x', 'user'), (13, 'stranger', 'x', 'user'), (14, 'staff', 'x', 'admin'), (15, 'admin', 'x', 'user')`).run();
+const S = {};
+for (const [id, name] of PEOPLE) S[name] = await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));
+
+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+const ISSUER = 'https://openvibe.network';
+const app = express();
+app.locals.db = db;
+app.locals.config = { baseUrl: 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('/api/v1/projects', require('../server/developer/routes').router());
+const server = http.createServer(app);
+const T = {};
+for (const [id, name] of PEOPLE) T[name] = jwt.sign({ sub: id, id }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
+
+(async () => {
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ 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: { ...(who ? { authorization: `Bearer ${T[who]}` } : {}), ...(body ? { 'content-type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined });
+ const text = await r.text();
+ return { status: r.status, type: r.headers.get('content-type') || '', body: text ? JSON.parse(text) : null };
+ };
+ const ok = (r, status = 200) => { assert.strictEqual(r.status, status, JSON.stringify(r.body)); return r.body; };
+ const refused = (r, status, code) => { assert.strictEqual(r.status, status, JSON.stringify(r.body)); assert.strictEqual(r.body.code, code); assert.match(r.type, /problem\+json/); };
+ const audits = async (agent) => (await db.prepare("SELECT action, actor, detail FROM dev_audit WHERE target = ? AND action LIKE 'agent.budget_%' ORDER BY id").all(`agent:${agent}`))
+ .map((r) => { const d = JSON.parse(r.detail); return `${r.action} ${d.capability} ${d.limit === undefined ? '-' : d.limit} ${d.reason || '-'} ${r.actor}`; });
+ try {
+ const P = ok(await api('owner', 'POST', '', { name: 'Budgets' }), 201).id;
+ for (const [name, role] of [['dev', 'developer'], ['viewer', 'viewer'], ['admin', 'admin']]) ok(await api('owner', 'POST', `/${P}/members`, { username: name, role }), 201);
+ ok(await api('staff', 'PUT', `/${P}/environment-policy`, { environment_policy: 'sandbox+production' }));
+ ok(await api('staff', 'PUT', `/${P}/allowance`, { capabilities: ['media.object.upload', 'media.object.delete'] }));
+ const PR = ok(await api('owner', 'POST', `/${P}/apps`, { name: 'Bot', environment: 'production' }), 201).id;
+ for (const c of ['media.object.upload', 'media.object.delete']) ok(await api('owner', 'POST', `/${P}/apps/${PR}/grants`, { capability: c }), 201);
+ const bot = ok(await api('admin', 'POST', `/${P}/agents`, { name: 'Uploader', host: { type: 'app', id: PR } }), 201).id;
+ ok(await api('admin', 'PUT', `/${P}/agents/${bot}/grants/media.object.upload`, { mode: 'auto' }));
+ ok(await api('admin', 'PUT', `/${P}/agents/${bot}/grants/media.object.delete`, {}));
+ const put = (who, cap, body, agent = bot) => api(who, 'PUT', `/${P}/agents/${agent}/budgets/${cap}`, body);
+
+ // ── The agent's owner sets one; the owning service enforces it ──
+ assert.deepStrictEqual(ok(await put('admin', 'media.object.upload', { limit: 1073741824, window: 'day', unit: 'bytes' })),
+ { capability: 'media.object.upload', limit: 1073741824, window: 'day', unit: 'bytes', enforced_by: 'openvibe.media' });
+ // Overwrite; unit defaults to requests
+ assert.deepStrictEqual(ok(await put('admin', 'media.object.upload', { limit: 50, window: 'hour' })),
+ { capability: 'media.object.upload', limit: 50, window: 'hour', unit: 'requests', enforced_by: 'openvibe.media' });
+ // An admin+ caps a member's agent (here the project owner, who does not own the agent)
+ ok(await put('owner', 'media.object.delete', { limit: 10, window: 'day' }));
+ // A budget of 0 is legal (the issuance off switch)
+ ok(await put('admin', 'media.object.delete', { limit: 0, window: 'day' }));
+ assert.deepStrictEqual(ok(await api('viewer', 'GET', `/${P}/agents/${bot}/budgets`)).budgets.map((b) => [b.capability, b.limit, b.window]),
+ [['media.object.delete', 0, 'day'], ['media.object.upload', 50, 'hour']]);
+ assert.strictEqual(ok(await api('staff', 'GET', `/${P}/agents/${bot}/budgets`)).budgets.length, 2, 'staff read');
+ refused(await api('stranger', 'GET', `/${P}/agents/${bot}/budgets`), 404, 'project.not_found');
+
+ // ── Who: not a developer who does not own it, not a viewer, not staff as such, not a stranger ──
+ refused(await put('dev', 'media.object.upload', { limit: 1, window: 'day' }), 403, 'agent.forbidden');
+ refused(await put('viewer', 'media.object.upload', { limit: 1, window: 'day' }), 403, 'agent.forbidden');
+ refused(await put('staff', 'media.object.upload', { limit: 1, window: 'day' }), 403, 'project.forbidden');
+ refused(await put('stranger', 'media.object.upload', { limit: 1, window: 'day' }), 404, 'project.not_found');
+ refused(await put(null, 'media.object.upload', { limit: 1, window: 'day' }), 401, 'auth.required');
+ refused(await api('dev', 'DELETE', `/${P}/agents/${bot}/budgets/media.object.upload`), 403, 'agent.forbidden');
+ refused(await api('staff', 'DELETE', `/${P}/agents/${bot}/budgets/media.object.upload`), 403, 'project.forbidden');
+
+ // ── Validation ──
+ for (const body of [{ limit: -1, window: 'day' }, { limit: 1.5, window: 'day' }, { limit: '5', window: 'day' }, { window: 'day' },
+ { limit: 5, window: 'week' }, { limit: 5 }, { limit: 5, window: 'day', unit: 'Bytes!' }]) {
+ refused(await put('admin', 'media.object.upload', body), 422, 'budget.invalid');
+ }
+ refused(await put('admin', 'media.object.upload', { limit: 5, window: 'day' }, `agt_${'9'.repeat(26)}`), 404, 'agent.not_found');
+ refused(await put('admin', 'media.object.list', { limit: 5, window: 'day' }), 404, 'grant.not_found');
+
+ // ── Never beyond the project's quota: a higher limit, another window or another unit ──
+ ok(await api('staff', 'PUT', `/${P}/quotas/media.object.upload`, { limit: 100, window: 'hour', unit: 'requests' }));
+ refused(await put('admin', 'media.object.upload', { limit: 101, window: 'hour' }), 422, 'budget.beyond_quota');
+ refused(await put('admin', 'media.object.upload', { limit: 10, window: 'day' }), 422, 'budget.beyond_quota');
+ refused(await put('admin', 'media.object.upload', { limit: 10, window: 'hour', unit: 'bytes' }), 422, 'budget.beyond_quota');
+ assert.strictEqual(ok(await put('admin', 'media.object.upload', { limit: 100, window: 'hour' })).limit, 100, 'equal to the quota');
+ assert.strictEqual(ok(await api('admin', 'GET', `/${P}/agents/${bot}/budgets`)).budgets.find((b) => b.capability === 'media.object.upload').limit, 100);
+
+ // ── A budget of 0 stops an approved confirmation from being spent ──
+ const D = 'a'.repeat(64);
+ await db.prepare('DELETE FROM dev_agent_budgets WHERE agent_id = ? AND capability = ?').run(bot, 'media.object.delete');
+ const c = await confirmations.create(db, { agentId: bot, capability: 'media.object.delete', audience: 'openvibe.media', summary: 'Delete one photo', requestDigest: D });
+ await db.prepare("UPDATE dev_confirmations SET state = 'approved', decided_at = ?, decided_by = 'test' WHERE id = ?").run(new Date().toISOString(), c.id);
+ ok(await put('admin', 'media.object.delete', { limit: 0, window: 'day' }));
+ await assert.rejects(confirmations.consume(db, { id: c.id, audience: 'openvibe.media', requestDigest: D }), (e) => e.code === 'grant.not_delegated');
+ assert.strictEqual((await db.prepare('SELECT used_at FROM dev_confirmations WHERE id = ?').get(c.id)).used_at, null);
+
+ // ── DELETE removes it; revoking the grant deletes its budget ──
+ assert.strictEqual(ok(await api('admin', 'DELETE', `/${P}/agents/${bot}/budgets/media.object.delete`)).limit, 0);
+ refused(await api('admin', 'DELETE', `/${P}/agents/${bot}/budgets/media.object.delete`), 404, 'budget.not_found');
+ ok(await api('staff', 'DELETE', `/${P}/agents/${bot}/grants/media.object.upload`));
+ assert.deepStrictEqual(ok(await api('admin', 'GET', `/${P}/agents/${bot}/budgets`)).budgets, []);
+ refused(await put('admin', 'media.object.upload', { limit: 5, window: 'hour' }), 404, 'grant.not_found');
+ // Each change is an audit row (no event), and the cascade names its cause.
+ assert.deepStrictEqual(await audits(bot), [
+ `agent.budget_set media.object.upload 1073741824 - user:${S.admin}`,
+ `agent.budget_set media.object.upload 50 - user:${S.admin}`,
+ `agent.budget_set media.object.delete 10 - user:${S.owner}`,
+ `agent.budget_set media.object.delete 0 - user:${S.admin}`,
+ `agent.budget_set media.object.upload 100 - user:${S.admin}`,
+ `agent.budget_set media.object.delete 0 - user:${S.admin}`,
+ `agent.budget_removed media.object.delete - - user:${S.admin}`,
+ `agent.budget_removed media.object.upload - grant_revoked user:${S.staff}`,
+ ]);
+
+ // ── A revoked agent takes no budget ──
+ ok(await api('admin', 'DELETE', `/${P}/agents/${bot}`));
+ refused(await put('admin', 'media.object.delete', { limit: 5, window: 'day' }), 409, 'agent.revoked');
+ console.log('agent budgets: all tests passed');
+ } finally {
+ server.close();
+ }
+})().catch((err) => { console.error(err); process.exit(1); });
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -1,5 +1,6 @@
@@ -1,5 +1,6 @@
'use strict';
'use strict';
-// The agents migrations (plan T2 WS-Z2 slices 2-3, migrations/0016_agents.sql and 0017_agent_grants.sql) reach a
+// The agents migrations (plan T2 WS-Z2 slices 2-5, migrations/0016_agents.sql, 0017_agent_grants.sql,
+// 0018_confirmations.sql and 0019_agent_budgets.sql) reach a
// database that production already migrated: a database at 0015 without them gains them from the normal runner,
// database that production already migrated: a database at 0015 without them gains them from the normal runner,
// once, and each file is safe to run twice. It also pins why it is 0016 and not the design's 0010: the openvibe-sdk/db runner tracks applied
// once, and each file is safe to run twice. It also pins why it is 0016 and not the design's 0010: the openvibe-sdk/db runner tracks applied
// migrations by id but refuses a pending file numbered below one already applied.
// migrations by id but refuses a pending file numbered below one already applied.
@@ -43,11 +44,11 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -43,11 +44,11 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand']]);
+ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand']]);
assert.ok(await hasAgents());
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
- for (const f of ['0016_agents.sql', '0017_agent_grants.sql']) await asOwner((db) => db.exec(fs.readFileSync(path.join(MIGRATIONS, f), 'utf8')));
+ for (const f of ['0016_agents.sql', '0017_agent_grants.sql', '0018_confirmations.sql', '0019_agent_budgets.sql']) await asOwner((db) => db.exec(fs.readFileSync(path.join(MIGRATIONS, f), 'utf8')));
// The table works on the migrated database: ISO defaults like every other text timestamp, and the app host
// The table works on the migrated database: ISO defaults like every other text timestamp, and the app host
// must be an app of the agent's own project and environment.
// must be an app of the agent's own project and environment.
@@ -83,6 +84,64 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -83,6 +84,64 @@ const quiet = { log() {}, warn() {}, error() {} };
const idx = (await t.db.prepare("SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = current_schema() AND tablename = 'dev_agent_grants' ORDER BY indexname").all());
const idx = (await t.db.prepare("SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = current_schema() AND tablename = 'dev_agent_grants' ORDER BY indexname").all());
assert.deepStrictEqual(idx.map((i) => i.indexname), ['dev_agent_grants_active_idx', 'dev_agent_grants_cap_idx', 'dev_agent_grants_pkey']);
assert.deepStrictEqual(idx.map((i) => i.indexname), ['dev_agent_grants_active_idx', 'dev_agent_grants_cap_idx', 'dev_agent_grants_pkey']);
assert.match(idx[0].indexdef, /WHERE \(?status = 'active'/);
assert.match(idx[0].indexdef, /WHERE \(?status = 'active'/);
+
+ // Confirmations (0018): an id, owner and digest of the right shape, a known state, the decision columns
+ // consistent with it, used only once approved; standing rules whose session/until columns match their kind.
+ const cnf = (c) => `cnf_${c.repeat(26)}`;
+ const ins = (c) => t.db.prepare(`INSERT INTO dev_confirmations (id, project_id, agent_id, owner_subject, capability, audience, summary, state, request_digest,
+ expires_at, created_at, decided_at, used_at, rule_id) VALUES (?, ?, ?, ?, 'media.object.delete', 'openvibe.media', ?, ?, ?, ?, ?, ?, ?, ?)`)
+ .run(c.id || cnf('A'), prj, c.agent || `agt_${'5'.repeat(26)}`, c.owner || usr, c.summary === undefined ? 'Delete a photo' : c.summary, c.state || 'pending',
+ c.digest || 'f'.repeat(64), now, now, c.decided_at === undefined ? null : c.decided_at, c.used_at === undefined ? null : c.used_at, c.rule_id === undefined ? null : c.rule_id);
+ await ins({});
+ const d = await t.db.prepare('SELECT details, resources, standing_rule FROM dev_confirmations').get();
+ assert.deepStrictEqual([d.details, d.resources, d.standing_rule], ['{}', '[]', null]);
+ await assert.rejects(ins({}), /duplicate key|unique/i, 'one row per id');
+ await assert.rejects(ins({ id: 'cnf_short' }), /check constraint/i, 'a malformed id');
+ await assert.rejects(ins({ id: cnf('B'), owner: 'agt_x' }), /check constraint/i, 'an owner that is not a user');
+ await assert.rejects(ins({ id: cnf('B'), summary: '' }), /check constraint/i, 'an empty summary');
+ await assert.rejects(ins({ id: cnf('B'), summary: 'x'.repeat(501) }), /check constraint/i, 'a summary over 500');
+ await assert.rejects(ins({ id: cnf('B'), digest: 'F'.repeat(64) }), /check constraint/i, 'a digest that is not lowercase hex');
+ await assert.rejects(ins({ id: cnf('B'), state: 'used' }), /check constraint/i, 'an unknown state');
+ await assert.rejects(ins({ id: cnf('B'), decided_at: now }), /check constraint/i, 'pending with decided_at');
+ await assert.rejects(ins({ id: cnf('B'), state: 'approved' }), /check constraint/i, 'approved without decided_at');
+ await assert.rejects(ins({ id: cnf('B'), state: 'denied' }), /check constraint/i, 'denied without decided_at');
+ await assert.rejects(ins({ id: cnf('B'), state: 'cancelled', used_at: now }), /check constraint/i, 'used but not approved');
+ await assert.rejects(ins({ id: cnf('B'), agent: `agt_${'8'.repeat(26)}` }), /foreign key/i, 'an unknown agent');
+ await assert.rejects(ins({ id: cnf('B'), rule_id: 999 }), /foreign key/i, 'an unknown rule');
+ await ins({ id: cnf('C'), state: 'approved', decided_at: now, used_at: now });
+ await ins({ id: cnf('D'), state: 'cancelled' });
+ const rule = (r) => t.db.prepare('INSERT INTO dev_standing_rules (agent_id, capability, rule, session_id, until_at, source, created_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id')
+ .get(`agt_${'5'.repeat(26)}`, 'media.object.delete', r.rule, r.session_id === undefined ? null : r.session_id, r.until_at === undefined ? null : r.until_at, r.source || cnf('A'), now, 'test');
+ const ids = [];
+ ids.push((await rule({ rule: 'always' })).id, (await rule({ rule: 'until', until_at: now })).id, (await rule({ rule: 'session', session_id: 'session-1', until_at: now })).id);
+ assert.deepStrictEqual(ids.map(Number), [1, 2, 3], 'identity ids');
+ await assert.rejects(rule({ rule: 'once' }), /check constraint/i, 'once is never a standing rule');
+ await assert.rejects(rule({ rule: 'session', until_at: now }), /check constraint/i, 'session without session_id');
+ await assert.rejects(rule({ rule: 'session', session_id: 'short', until_at: now }), /check constraint/i, 'a malformed session_id');
+ await assert.rejects(rule({ rule: 'until', session_id: 'session-1', until_at: now }), /check constraint/i, 'session_id on another kind');
+ await assert.rejects(rule({ rule: 'until' }), /check constraint/i, 'until without until_at');
+ await assert.rejects(rule({ rule: 'always', until_at: now }), /check constraint/i, 'always with until_at');
+ await assert.rejects(rule({ rule: 'always', source: 'cnf_x' }), /check constraint/i, 'a source that is not a confirmation id');
+ await ins({ id: cnf('E'), state: 'approved', decided_at: now, rule_id: ids[0] });
+ const cIdx = await t.db.prepare("SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = current_schema() AND tablename = 'dev_confirmations' ORDER BY indexname").all();
+ assert.deepStrictEqual(cIdx.map((i) => i.indexname), ['dev_confirmations_agent_idx', 'dev_confirmations_due_idx', 'dev_confirmations_inbox_idx', 'dev_confirmations_pkey', 'dev_confirmations_spendable_idx']);
+ assert.match(cIdx[1].indexdef, /WHERE \(?state = 'pending'/);
+ assert.match(cIdx[4].indexdef, /WHERE \(?\(?state = 'approved'.*used_at IS NULL/);
+ const rIdx = await t.db.prepare("SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = current_schema() AND tablename = 'dev_standing_rules' ORDER BY indexname").all();
+ assert.deepStrictEqual(rIdx.map((i) => i.indexname), ['dev_standing_rules_live_idx', 'dev_standing_rules_pkey']);
+ assert.match(rIdx[0].indexdef, /WHERE \(?revoked_at IS NULL/);
+
+ // Budgets (0019): one per (agent, capability), of an existing agent, a non-negative limit, a known window,
+ // unit requests by default.
+ const budget = (b) => t.db.prepare('INSERT INTO dev_agent_budgets (agent_id, capability, limit_value, budget_window, updated_at, updated_by) VALUES (?, ?, ?, ?, ?, ?)')
+ .run(b.agent || `agt_${'5'.repeat(26)}`, b.capability || 'media.object.upload', b.limit === undefined ? 10 : b.limit, b.window || 'day', now, 'test');
+ await budget({});
+ assert.strictEqual((await t.db.prepare('SELECT unit FROM dev_agent_budgets').get()).unit, 'requests');
+ await assert.rejects(budget({}), /duplicate key|unique/i, 'one budget per agent and capability');
+ await budget({ capability: 'media.object.delete', limit: 0 });
+ await assert.rejects(budget({ capability: 'media.object.list', limit: -1 }), /check constraint/i, 'a negative limit');
+ await assert.rejects(budget({ capability: 'media.object.list', window: 'week' }), /check constraint/i, 'an unknown window');
+ await assert.rejects(budget({ agent: `agt_${'8'.repeat(26)}`, capability: 'media.object.list' }), /foreign key/i, 'an unknown agent');
console.log('agent schema: all tests passed');
console.log('agent schema: all tests passed');
} finally {
} finally {
await t.close();
await t.close();
new file mode 100644
new file mode 100644
@@ -0,0 +1,361 @@
@@ -0,0 +1,361 @@
+'use strict';
+// Confirmations, owner side (plan T2 WS-Z2 slice 4, docs/t2-projects-and-grants.md sections 3-5 and 7;
+// server/developer/confirmations.js): views are network.confirmation-request@1; create → approve → consume once;
+// digest mismatch, deny, expiry (lazy and swept), the service's cancel, standing rules once/session/until/always and
+// their revocation; only the owner sees one; 20 pending per agent; the wrong audience; audits without summary/details;
+// one revoke-after-approval case per cause; authority that lapses without a write; two concurrent consumes.
+// Rows are created and consumed through the store (the /internal routes wait for slice 7).
+// node test/confirmations.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 grantsAdmin = require('../server/identity/grants-admin');
+const accountData = require('../server/identity/account-data');
+const confirmations = require('../server/developer/confirmations');
+
+(async () => {
+const log = console.log; console.log = () => {};
+const db = getDb();
+console.log = log;
+const PEOPLE = [[10, 'owner'], [11, 'admin'], [12, 'dev'], [13, 'stranger'], [14, 'staff'], [17, 'erased']];
+await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES
+ (10, 'owner', 'x', 'user'), (11, 'admin', 'x', 'user'), (12, 'dev', 'x', 'user'), (13, 'stranger', 'x', 'user'), (14, 'staff', 'x', 'admin'), (17, 'erased', 'x', 'user')`).run();
+await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('actor', 'x', 'OpenVibe.Actor', '[]', 1)").run();
+const S = {};
+for (const [id, name] of PEOPLE) S[name] = await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));
+
+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+const ISSUER = 'https://openvibe.network';
+const app = express();
+app.locals.db = db;
+app.locals.config = { baseUrl: 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('/api/v1/projects', require('../server/developer/routes').router());
+app.use('/api/v1/confirmations', confirmations.router());
+const server = http.createServer(app);
+const T = {};
+for (const [id, name] of PEOPLE) T[name] = jwt.sign({ sub: id, id }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
+
+(async () => {
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const call = async (who, method, p, body) => {
+ const r = await fetch(`${base}${p}`, { method, headers: { ...(who ? { authorization: `Bearer ${T[who]}` } : {}), ...(body ? { 'content-type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined });
+ const text = await r.text();
+ return { status: r.status, type: r.headers.get('content-type') || '', cache: r.headers.get('cache-control') || '', body: text ? JSON.parse(text) : null };
+ };
+ const api = (who, method, p, body) => call(who, method, `/api/v1/projects${p}`, body);
+ const inbox = (who, method, p, body) => call(who, method, `/api/v1/confirmations${p}`, body);
+ const ok = (r, status = 200) => { assert.strictEqual(r.status, status, JSON.stringify(r.body)); return r.body; };
+ const refused = (r, status, code) => { assert.strictEqual(r.status, status, JSON.stringify(r.body)); assert.strictEqual(r.body.code, code); assert.match(r.type, /problem\+json/); };
+ const rejects = (p, status, code) => assert.rejects(p, (e) => { assert.deepStrictEqual([e.status, e.code], [status, code], e.message); return true; });
+ const valid = (doc) => { const v = validate('network.confirmation-request@1', doc); assert.ok(v.valid, JSON.stringify(v.errors)); return doc; };
+ const row = async (id) => await db.prepare('SELECT * FROM dev_confirmations WHERE id = ?').get(id);
+ const digest = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
+ const D = digest('DELETE /v1/objects/obj_1');
+ const SUMMARY = 'Delete the photo "beach-secret.jpg" from your library';
+ const DETAILS = { note: 'private-detail-text', count: 1 };
+ const audOf = (cap) => `openvibe.${cap.split('.')[0]}`;
+ const ask = (agentId, capability = 'media.object.delete', extra = {}) => confirmations.create(db, {
+ agentId, capability, audience: audOf(capability), summary: SUMMARY, details: DETAILS,
+ resources: [{ service: 'media', type: 'object', id: 'obj_1' }], requestDigest: D, ...extra,
+ });
+ const spend = (c, extra = {}) => confirmations.consume(db, { id: c.id, audience: audOf(c.capability), requestDigest: D, ...extra });
+ const approve = async (c, who = 'owner', body = {}) => ok(await inbox(who, 'POST', `/${c.id}/approve`, body));
+ for (const c of ['chat.message.send']) await grantsAdmin.grant(db, { client_id: 'actor', capability: c, reason: 'agent host test' }, null);
+
+ // A project with a production app holding media.object.delete and .upload, an admin and a developer member.
+ let n = 0;
+ const project = async () => {
+ const P = ok(await api('owner', 'POST', '', { name: `Confirm ${++n}` }), 201).id;
+ for (const [name, role] of [['admin', 'admin'], ['dev', 'developer']]) ok(await api('owner', 'POST', `/${P}/members`, { username: name, role }), 201);
+ ok(await api('staff', 'PUT', `/${P}/environment-policy`, { environment_policy: 'sandbox+production' }));
+ ok(await api('staff', 'PUT', `/${P}/allowance`, { capabilities: ['media.object.upload', 'media.object.delete'] }));
+ const PR = ok(await api('owner', 'POST', `/${P}/apps`, { name: 'Bot', environment: 'production' }), 201).id;
+ for (const c of ['media.object.upload', 'media.object.delete']) ok(await api('owner', 'POST', `/${P}/apps/${PR}/grants`, { capability: c }), 201);
+ return { P, PR };
+ };
+ // An agent with media.object.delete (confirm) and media.object.upload (auto), or a service-hosted one with chat.message.send.
+ const appAgent = async ({ P, PR }, who = 'owner') => {
+ const id = ok(await api(who, 'POST', `/${P}/agents`, { name: 'Bot', host: { type: 'app', id: PR } }), 201).id;
+ ok(await api(who, 'PUT', `/${P}/agents/${id}/grants/media.object.delete`, {}));
+ ok(await api(who, 'PUT', `/${P}/agents/${id}/grants/media.object.upload`, { mode: 'auto' }));
+ return id;
+ };
+ const svcAgent = async ({ P }, who = 'owner') => {
+ const id = ok(await api(who, 'POST', `/${P}/agents`, { name: 'Helper', host: { type: 'service', id: 'actor' } }), 201).id;
+ ok(await api(who, 'PUT', `/${P}/agents/${id}/grants/chat.message.send`, {}));
+ return id;
+ };
+ const approved = async (agentId, capability) => { const c = await ask(agentId, capability); await approve(c); return c; };
+ const cancelledBy = async (c, reason) => {
+ const r = await row(c.id);
+ assert.deepStrictEqual([r.state, r.cancel_reason, r.used_at], ['cancelled', reason, null], `${c.id} cancelled by ${reason}`);
+ await rejects(spend(c), 409, 'confirmation.cancelled');
+ };
+
+ try {
+ const W = await project();
+ const bot = await appAgent(W);
+
+ // ── create → approve → consume once ──
+ const c1 = valid(await ask(bot));
+ assert.deepStrictEqual([c1.state, c1.owner, c1.requested_by, c1.capability, c1.summary, c1.details, c1.standing_rule, c1.decided_at],
+ ['pending', { type: 'user', id: S.owner }, { type: 'agent', id: bot }, 'media.object.delete', SUMMARY, DETAILS, undefined, undefined]);
+ for (const k of ['audience', 'session_id', 'request_digest', 'rule_id', 'used_at', 'decided_by', 'cancel_reason', 'project_id']) assert.ok(!(k in c1), `${k} stays Network-local`);
+ assert.ok(Date.parse(c1.expires_at) - Date.parse(c1.created_at) >= 899_000, 'ttl defaults to 900 s');
+ await rejects(spend(c1), 409, 'confirmation.not_pending');
+ const listed = ok(await inbox('owner', 'GET', '/'));
+ assert.match((await inbox('owner', 'GET', '/')).cache, /private/);
+ assert.deepStrictEqual(listed.confirmations.map((c) => c.id), [c1.id]);
+ listed.confirmations.forEach(valid);
+ assert.deepStrictEqual(listed.agents[bot], { name: 'Bot', project_id: W.P, host: { type: 'app', id: W.PR } });
+ assert.strictEqual(listed.next_before, null);
+ const one = ok(await inbox('owner', 'GET', `/${c1.id}`));
+ valid(one.confirmation);
+ assert.strictEqual(one.agent.name, 'Bot');
+ const a1 = await approve(c1);
+ valid(a1.confirmation);
+ assert.deepStrictEqual([a1.confirmation.state, a1.confirmation.standing_rule, a1.rule], ['approved', 'once', undefined]);
+ assert.ok(a1.confirmation.decided_at);
+ refused(await inbox('owner', 'POST', `/${c1.id}/approve`, {}), 409, 'confirmation.not_pending');
+ refused(await inbox('owner', 'POST', `/${c1.id}/deny`, {}), 409, 'confirmation.not_pending');
+ await rejects(spend(c1, { requestDigest: digest('DELETE /v1/objects/obj_2') }), 409, 'confirmation.mismatch');
+ await rejects(spend(c1, { audience: 'openvibe.chat' }), 404, 'confirmation.not_found');
+ const used = await spend(c1);
+ valid(used.confirmation);
+ assert.match(used.used_at, /^\d{4}-/);
+ await rejects(spend(c1), 409, 'confirmation.used');
+ assert.strictEqual((await row(c1.id)).used_at, used.used_at);
+
+ // ── deny ──
+ const c2 = await ask(bot);
+ const d2 = ok(await inbox('owner', 'POST', `/${c2.id}/deny`, {}));
+ valid(d2.confirmation);
+ assert.strictEqual(d2.confirmation.state, 'denied');
+ await rejects(spend(c2), 409, 'confirmation.not_pending');
+ assert.strictEqual(ok(await inbox('owner', 'GET', '/?state=denied')).confirmations[0].id, c2.id);
+ refused(await inbox('owner', 'GET', '/?state=open'), 422, 'confirmation.invalid');
+
+ // ── Creation refusals ──
+ await rejects(ask(bot, 'media.object.upload'), 403, 'grant.not_delegated'); // auto: nothing to confirm
+ await rejects(ask(bot, 'media.object.delete', { audience: 'openvibe.chat' }), 403, 'confirmation.wrong_audience');
+ await rejects(ask(bot, 'media.object.delete', { requestDigest: 'abc' }), 422, 'confirmation.invalid');
+ await rejects(ask(bot, 'media.object.delete', { ttlS: 59 }), 422, 'confirmation.invalid');
+ await rejects(ask(bot, 'media.object.delete', { ttlS: 86401 }), 422, 'confirmation.invalid');
+ await rejects(ask(bot, 'media.object.delete', { summary: '' }), 422, 'confirmation.invalid');
+ await rejects(ask(bot, 'media.object.delete', { resources: [{ id: 'x' }] }), 422, 'confirmation.invalid');
+ await rejects(ask(`agt_${'9'.repeat(26)}`), 404, 'agent.not_found');
+
+ // ── Expiry: reads report it, a decision records it, consume refuses an approved one past expiry, the sweep records the rest ──
+ const c3 = await ask(bot, 'media.object.delete', { ttlS: 60 });
+ await db.prepare("UPDATE dev_confirmations SET expires_at = '2001-01-01T00:00:00.000Z' WHERE id = ?").run(c3.id);
+ assert.strictEqual(valid(ok(await inbox('owner', 'GET', `/${c3.id}`)).confirmation).state, 'expired');
+ assert.ok(!ok(await inbox('owner', 'GET', '/')).confirmations.some((c) => c.id === c3.id));
+ assert.ok(ok(await inbox('owner', 'GET', '/?state=expired')).confirmations.some((c) => c.id === c3.id));
+ refused(await inbox('owner', 'POST', `/${c3.id}/approve`, {}), 409, 'confirmation.expired');
+ assert.strictEqual((await row(c3.id)).state, 'expired', 'the refused decision recorded the expiry');
+ const c4 = await approved(bot);
+ await db.prepare("UPDATE dev_confirmations SET expires_at = '2001-01-01T00:00:00.000Z' WHERE id = ?").run(c4.id);
+ await rejects(spend(c4), 409, 'confirmation.expired');
+ const c5 = await ask(bot);
+ await db.prepare("UPDATE dev_confirmations SET expires_at = '2001-01-01T00:00:00.000Z' WHERE id = ?").run(c5.id);
+ assert.strictEqual(await confirmations.expireDue(db), 1);
+ assert.strictEqual(await confirmations.expireDue(db), 0, 'recorded once');
+ assert.strictEqual((await row(c5.id)).state, 'expired');
+ assert.strictEqual((await row(c4.id)).state, 'approved', 'an approved row is not swept');
+
+ // ── 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(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');
+
+ // ── Only the owner: another member, an admin and staff get 404, never the row ──
+ const c7 = await ask(bot);
+ for (const who of ['admin', 'stranger', 'staff']) {
+ refused(await inbox(who, 'GET', `/${c7.id}`), 404, 'confirmation.not_found');
+ refused(await inbox(who, 'POST', `/${c7.id}/approve`, {}), 404, 'confirmation.not_found');
+ refused(await inbox(who, 'POST', `/${c7.id}/deny`, {}), 404, 'confirmation.not_found');
+ assert.deepStrictEqual(ok(await inbox(who, 'GET', '/')).confirmations, []);
+ }
+ refused(await inbox(null, 'GET', '/'), 401, 'auth.required');
+ refused(await inbox('owner', 'GET', '/cnf_nope'), 404, 'confirmation.not_found');
+ assert.strictEqual((await row(c7.id)).state, 'pending');
+
+ // ── Standing rules ──
+ refused(await inbox('owner', 'POST', `/${c7.id}/approve`, { standing_rule: 'session' }), 422, 'confirmation.no_session');
+ refused(await inbox('owner', 'POST', `/${c7.id}/approve`, { standing_rule: 'until' }), 422, 'confirmation.invalid');
+ refused(await inbox('owner', 'POST', `/${c7.id}/approve`, { standing_rule: 'until', until: new Date(Date.now() + 31 * 86400e3).toISOString() }), 422, 'confirmation.invalid');
+ refused(await inbox('owner', 'POST', `/${c7.id}/approve`, { standing_rule: 'forever' }), 422, 'confirmation.invalid');
+ assert.strictEqual((await row(c7.id)).state, 'pending', 'a refused approval changes nothing');
+ assert.strictEqual((await db.prepare('SELECT count(*) AS n FROM dev_standing_rules').get()).n, 0);
+ // session: needs the request's session_id, lasts at most 24 hours, approves the same session only.
+ const sess = 'run-2026-10-04:abc';
+ const c8 = await ask(bot, 'media.object.delete', { sessionId: sess });
+ const a8 = await approve(c8, 'owner', { standing_rule: 'session' });
+ assert.deepStrictEqual([a8.confirmation.standing_rule, a8.rule.rule, a8.rule.session_id, a8.rule.source], ['session', 'session', sess, c8.id]);
+ assert.ok(Date.parse(a8.rule.until_at) <= Date.now() + 24 * 3600e3);
+ const c9 = valid(await ask(bot, 'media.object.delete', { sessionId: sess }));
+ assert.deepStrictEqual([c9.state, c9.standing_rule], ['approved', 'session']);
+ assert.deepStrictEqual([(await row(c9.id)).decided_by, Number((await row(c9.id)).rule_id)], [`rule:${a8.rule.id}`, a8.rule.id]);
+ assert.strictEqual((await spend(c9)).confirmation.state, 'approved');
+ assert.strictEqual((await ask(bot, 'media.object.delete', { sessionId: 'another-session' })).state, 'pending');
+ assert.strictEqual((await ask(bot)).state, 'pending');
+ // until and always
+ const c10 = await ask(bot);
+ const until = new Date(Date.now() + 7 * 86400e3).toISOString();
+ assert.strictEqual((await approve(c10, 'owner', { standing_rule: 'until', until })).rule.until_at, until);
+ assert.strictEqual((await ask(bot)).state, 'approved');
+ const rules = ok(await api('dev', 'GET', `/${W.P}/agents/${bot}/rules`)).rules;
+ assert.deepStrictEqual(rules.map((r) => r.rule), ['session', 'until']);
+ // Revoking rules: a developer who is not the owner may not; the owner (or admin, or staff) may.
+ refused(await api('dev', 'DELETE', `/${W.P}/agents/${bot}/rules/${rules[0].id}`), 403, 'agent.forbidden');
+ refused(await api('owner', 'DELETE', `/${W.P}/agents/${bot}/rules/999999`), 404, 'rule.not_found');
+ refused(await api('owner', 'DELETE', `/${W.P}/agents/${bot}/rules/abc`), 404, 'rule.not_found');
+ assert.ok(ok(await api('owner', 'DELETE', `/${W.P}/agents/${bot}/rules/${rules[0].id}`)).revoked_at);
+ assert.ok(ok(await api('staff', 'DELETE', `/${W.P}/agents/${bot}/rules/${rules[1].id}`)).revoked_at);
+ assert.deepStrictEqual(ok(await api('owner', 'GET', `/${W.P}/agents/${bot}/rules`)).rules, []);
+ assert.strictEqual((await ask(bot, 'media.object.delete', { sessionId: sess })).state, 'pending', 'a revoked rule approves nothing');
+ const c11 = await ask(bot);
+ assert.strictEqual((await approve(c11, 'owner', { standing_rule: 'always' })).rule.until_at, null);
+ assert.strictEqual((await ask(bot)).state, 'approved');
+ const ruleAudit = await db.prepare("SELECT actor, detail FROM dev_audit WHERE action = 'agent.rule_revoked' ORDER BY id").all();
+ assert.deepStrictEqual(ruleAudit.map((r) => r.actor), [`user:${S.owner}`, `user:${S.staff}`]);
+
+ // ── 20 pending per agent ──
+ const busy = await appAgent(W);
+ for (let i = 0; i < 20; i++) await ask(busy);
+ await rejects(ask(busy), 429, 'confirmation.too_many_pending');
+ assert.strictEqual(ok(await inbox('owner', 'GET', '/?limit=5')).next_before !== null, true);
+ const page1 = ok(await inbox('owner', 'GET', '/?limit=5'));
+ const page2 = ok(await inbox('owner', 'GET', `/?limit=5&before=${page1.next_before}`));
+ assert.ok(page2.confirmations.every((c) => c.id < page1.next_before));
+
+ // ── The owner banned: nothing more is created or spent ──
+ await db.prepare("UPDATE dev_standing_rules SET revoked_at = ?, revoked_by = 'test' WHERE revoked_at IS NULL").run(new Date().toISOString());
+ const c12 = await approved(bot);
+ await db.prepare('UPDATE users SET is_banned = 1 WHERE id = 10').run();
+ await rejects(spend(c12), 409, 'confirmation.agent_inactive');
+ await rejects(ask(bot), 409, 'confirmation.agent_inactive');
+ await db.prepare('UPDATE users SET is_banned = 0 WHERE id = 10').run();
+ assert.strictEqual((await row(c12.id)).used_at, null);
+ assert.strictEqual((await spend(c12)).confirmation.id, c12.id);
+
+ // ── Two concurrent consumes of one approval: exactly one 200 ──
+ const c13 = await approved(bot, 'media.object.delete');
+ const both = await Promise.allSettled([spend(c13), spend(c13)]);
+ assert.deepStrictEqual(both.map((r) => r.status).sort(), ['fulfilled', 'rejected']);
+ assert.strictEqual(both.find((r) => r.status === 'rejected').reason.code, 'confirmation.used');
+
+ // ── Revoke after approval, one case per cause: cancelled with the cause, consume refused, resume revives nothing ──
+ // Pausing the agent
+ const paused = await appAgent(W);
+ const p1 = await approved(paused);
+ const p2 = await ask(paused);
+ ok(await api('owner', 'POST', `/${W.P}/agents/${paused}/pause`));
+ await cancelledBy(p1, 'agent_paused');
+ assert.deepStrictEqual([(await row(p2.id)).state, (await row(p2.id)).cancel_reason], ['cancelled', 'agent_paused'], 'pending ones too');
+ await rejects(ask(paused), 409, 'confirmation.agent_inactive');
+ ok(await api('owner', 'POST', `/${W.P}/agents/${paused}/resume`));
+ await rejects(spend(p1), 409, 'confirmation.cancelled');
+ assert.strictEqual((await ask(paused)).state, 'pending', 'its next action asks again');
+ // Revoking the agent
+ const revoked = await appAgent(W);
+ const r1 = await approved(revoked);
+ ok(await api('admin', 'DELETE', `/${W.P}/agents/${revoked}`));
+ await cancelledBy(r1, 'agent_revoked');
+ // Revoking the delegated grant (and only that capability's rows and rules)
+ const ungranted = await appAgent(W);
+ const g1 = await approved(ungranted);
+ await approve(await ask(ungranted), 'owner', { standing_rule: 'always' });
+ ok(await api('owner', 'DELETE', `/${W.P}/agents/${ungranted}/grants/media.object.delete`));
+ await cancelledBy(g1, 'grant_revoked');
+ assert.strictEqual((await db.prepare('SELECT count(*) AS n FROM dev_standing_rules WHERE agent_id = ? AND revoked_at IS NULL').get(ungranted)).n, 0, 'its rules are revoked');
+ ok(await api('owner', 'PUT', `/${W.P}/agents/${ungranted}/grants/media.object.delete`, {}));
+ assert.strictEqual((await ask(ungranted)).state, 'pending', 'granting again revives no rule');
+ // Shrinking the allowance (app host)
+ const A = await project();
+ const shrunk = await appAgent(A);
+ const s1 = await approved(shrunk);
+ ok(await api('staff', 'PUT', `/${A.P}/allowance`, { capabilities: ['media.object.upload'] }));
+ await cancelledBy(s1, 'beyond_host');
+ // Revoking the app's grant (app host)
+ const G = await project();
+ const appGrantBot = await appAgent(G);
+ const ag1 = await approved(appGrantBot);
+ ok(await api('owner', 'DELETE', `/${G.P}/apps/${G.PR}/grants/media.object.delete`));
+ await cancelledBy(ag1, 'beyond_host');
+ // Revoking the host's principal_grants row (service host), and its expiry
+ const svc = await svcAgent(W);
+ const v1 = await approved(svc, 'chat.message.send');
+ await grantsAdmin.revoke(db, { client_id: 'actor', capability: 'chat.message.send', reason: 'test revoke' }, null);
+ await cancelledBy(v1, 'beyond_host');
+ await grantsAdmin.grant(db, { client_id: 'actor', capability: 'chat.message.send', reason: 'agent host test', expires_at: new Date(Date.now() + 3600e3).toISOString() }, null);
+ ok(await api('owner', 'PUT', `/${W.P}/agents/${svc}/grants/chat.message.send`, {}));
+ const v2 = await approved(svc, 'chat.message.send');
+ await db.prepare("UPDATE principal_grants SET expires_at = '2001-01-01 00:00:00' WHERE client_id = 'actor' AND capability = 'chat.message.send'").run();
+ // Lapse without a write: the sweep has not run, consume refuses all the same and spends nothing.
+ await rejects(spend(v2), 403, 'grant.not_delegated');
+ assert.strictEqual((await row(v2.id)).used_at, null);
+ await grantsAdmin.expireDue(db);
+ await cancelledBy(v2, 'grant_expired');
+ await grantsAdmin.grant(db, { client_id: 'actor', capability: 'chat.message.send', reason: 'agent host test' }, null);
+ // A delegated grant's own expires_at passing, without a sweep
+ const lapsed = await appAgent(W);
+ const l1 = await approved(lapsed);
+ await db.prepare("UPDATE dev_agent_grants SET expires_at = '2001-01-01T00:00:00.000Z' WHERE agent_id = ? AND capability = 'media.object.delete'").run(lapsed);
+ await rejects(spend(l1), 403, 'grant.not_delegated');
+ assert.deepStrictEqual([(await row(l1.id)).state, (await row(l1.id)).used_at], ['approved', null]);
+ // The owner removed from the project
+ const leaver = await svcAgent(W, 'dev');
+ const m1 = await ask(leaver, 'chat.message.send');
+ refused(await inbox('owner', 'POST', `/${m1.id}/approve`, {}), 404, 'confirmation.not_found'); // the agent's owner decides, not the project's
+ await approve(m1, 'dev');
+ ok(await api('owner', 'DELETE', `/${W.P}/members/${S.dev}`), 204);
+ await cancelledBy(m1, 'member_removed');
+ // Revoking the host app
+ const R = await project();
+ const appBot = await appAgent(R);
+ const ap1 = await approved(appBot);
+ ok(await api('owner', 'DELETE', `/${R.P}/apps/${R.PR}`));
+ await cancelledBy(ap1, 'app_revoked');
+ // Archiving the project
+ const X = await project();
+ const archBot = await appAgent(X);
+ const x1 = await approved(archBot);
+ ok(await api('owner', 'POST', `/${X.P}/archive`));
+ await cancelledBy(x1, 'project_archived');
+ // The owner's account erased
+ const E = await project();
+ ok(await api('owner', 'POST', `/${E.P}/members`, { username: 'erased', role: 'admin' }), 201);
+ const erasedBot = await appAgent(E, 'erased');
+ const e1 = await ask(erasedBot); await approve(e1, 'erased');
+ const delId = `del_${'1'.repeat(26)}`;
+ await db.prepare('INSERT INTO account_deletions (id, user_id, subject, requested_at, delete_after) VALUES (?, 17, ?, ?, ?)').run(delId, S.erased, new Date().toISOString(), new Date().toISOString());
+ await accountData.erase(db, await db.prepare('SELECT * FROM account_deletions WHERE id = ?').get(delId));
+ await cancelledBy(e1, 'account_erased');
+
+ // ── Audits: every transition, never the summary or the details ──
+ 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);
+ assert.ok(!a.detail.includes('beach-secret') && !a.detail.includes('private-detail-text'), a.detail);
+ const d = JSON.parse(a.detail);
+ assert.ok(d.agent && d.capability && !('summary' in d) && !('details' in d));
+ }
+ console.log('confirmations: all tests passed');
+ } finally {
+ server.close();
+ }
+})().catch((err) => { console.error(err); process.exit(1); });
+})().catch((err) => { console.error(err); process.exit(1); });