openvibers/openvibe.network Commits

Add owner-side agent confirmations and per-capability agent budgets (#37)

OP OpenVibers committed 4 Oct 2026 · 8ca298c404 · 13 files, +1361 −25

local checks ✓ 3/3 · CI ✓ 3/3 · review SHIP by DeepSeek harness · 1 fix round

https://github.com/OpenVibers/OpenVibe.Network/pull/37

Changes

ddiff --git a/docs/cutover-t2-confirmations-budgets.md b/docs/cutover-t2-confirmations-budgets.md
nnew file mode 100644
iindex 00000000..52d22d14
---- /dev/null
++++ b/docs/cutover-t2-confirmations-budgets.md
@@@ -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.
ddiff --git a/docs/developer-projects.md b/docs/developer-projects.md
iindex b0f4db61..982adb92 100644
---- a/docs/developer-projects.md
++++ b/docs/developer-projects.md
@@@ -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).
ddiff --git a/docs/t2-projects-and-grants.md b/docs/t2-projects-and-grants.md
iindex 87df8504..fc626924 100644
---- a/docs/t2-projects-and-grants.md
++++ b/docs/t2-projects-and-grants.md
@@@ -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
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
CREATE 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 the
cascades.
--### Next — `00NN_agent_budgets.sql` (slice 5)
++### 0019 — `0019_agent_budgets.sql` (built, slice 5)
```sql
CREATE 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 returns
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`,
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),
`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`,
`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), in
ddiff --git a/migrations/0018_confirmations.sql b/migrations/0018_confirmations.sql
nnew file mode 100644
iindex 00000000..627e89f4
---- /dev/null
++++ b/migrations/0018_confirmations.sql
@@@ -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;
ddiff --git a/migrations/0019_agent_budgets.sql b/migrations/0019_agent_budgets.sql
nnew file mode 100644
iindex 00000000..f7021806
---- /dev/null
++++ b/migrations/0019_agent_budgets.sql
@@@ -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)
++);
ddiff --git a/server/developer/agents.js b/server/developer/agents.js
iindex a3e1f5e7..66a9c738 100644
---- a/server/developer/agents.js
++++ b/server/developer/agents.js
@@@ -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.ROL
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) {
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,
};
ddiff --git a/server/developer/confirmations.js b/server/developer/confirmations.js
nnew file mode 100644
iindex 00000000..18a84e19
---- /dev/null
++++ b/server/developer/confirmations.js
@@@ -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,
++};
ddiff --git a/server/developer/routes.js b/server/developer/routes.js
iindex 69f0d471..91c1dc1e 100644
---- a/server/developer/routes.js
++++ b/server/developer/routes.js
@@@ -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 };
ddiff --git a/server/identity/grants-admin.js b/server/identity/grants-admin.js
iindex b77cbd09..3cc8e27e 100644
---- a/server/identity/grants-admin.js
++++ b/server/identity/grants-admin.js
@@@ -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);
ddiff --git a/server/index.js b/server/index.js
iindex c2d5c165..fc082ab0 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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)),
ddiff --git a/test/agent-budgets.test.js b/test/agent-budgets.test.js
nnew file mode 100644
iindex 00000000..7c6a7ab6
---- /dev/null
++++ b/test/agent-budgets.test.js
@@@ -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); });
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex 8a163fcf..c3623e94 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -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();
ddiff --git a/test/confirmations.test.js b/test/confirmations.test.js
nnew file mode 100644
iindex 00000000..ad2bd8d5
---- /dev/null
++++ b/test/confirmations.test.js
@@@ -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); });