openvibers/openvibe.network Commits

Add delegated agent grants with modes and the delegation ceiling (#34)

OP OpenVibers committed 4 Oct 2026 · 31aac9fc18 · 9 files, +621 −14

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

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

Changes

ddiff --git a/docs/cutover-pr-openvibe-network-34.md b/docs/cutover-pr-openvibe-network-34.md
nnew file mode 100644
iindex 00000000..bb10abc1
---- /dev/null
++++ b/docs/cutover-pr-openvibe-network-34.md
@@@ -0,0 +1,158 @@
++# Cutover runbook: PR #34 — delegated agent grants (migration 0017)
++
++PR [#34](https://github.com/OpenVibers/OpenVibe.Network/pull/34) adds plan T2 WS-Z2 slice 3: delegated agent
++grants. The data change is one new table, `dev_agent_grants`, and two indexes on it
++(`migrations/0017_agent_grants.sql`). No existing table is altered, no column is renamed or dropped, no row is
++touched. The migration is `phase: expand` and every statement is `IF NOT EXISTS`, so it is safe to run on a
++live production database and safe to run twice.
++
++This runbook covers the deploy of that migration. It is much smaller than the SQLite → PostgreSQL cutover
++([cutover-t2-postgres.md](cutover-t2-postgres.md)): there is no data move, no freeze window, no snapshot
++import. The migration runs as part of the normal boot of the new release.
++
++---
++
++## What changes
++
++| Object | Kind | Notes |
++| --- | --- | --- |
++| `dev_agent_grants` | table | one row per (agent, capability); FK → `dev_agents(id)`; CHECK on `mode`, `status`, `audience` format, and `revoked` ↔ `revoked_at` |
++| `dev_agent_grants_active_idx` | partial index | `(agent_id) WHERE status = 'active'` |
++| `dev_agent_grants_cap_idx` | index | `(capability, status)` |
++
++No existing table, index, view or function is modified. The application code that reads and writes the table
++(`server/developer/agents.js`, `server/developer/routes.js`, `server/developer/store.js`,
++`server/identity/grants-admin.js`) only activates when an owner creates a grant through the developer API;
++before that, the table is empty and the indexes cost nothing.
++
++---
++
++## Prerequisites
++
++1. **Production is on PostgreSQL.** The migration is SQL that targets PostgreSQL (and PGlite in tests). The
++ current production release (`bad7b106fda0` at the time of writing) already serves from PostgreSQL; confirm
++ with `ov access run openvibe-ovh health network` (must say `ready`) and `validate network` (must say
++ `checkout clean`).
++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 PR #34 into `origin/main`. Steps 1 and 2 must use
++ that one SHA.
++
++---
++
++## Step 1: rehearse on a copy
++
++Run the migration against a throwaway copy of the production schema. The goal is to prove that 0017 applies
++cleanly on top of 0015 (the last migration production has applied) and that the resulting schema matches what
++the tests assert.
++
++```bash
++TARGET=<merge sha of PR #34>
++STAGE=/var/tmp/network-pr34-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 and 0017, and asserts the
++indexes, constraints and FK:
++
++```bash
++cd "$STAGE" && sudo -u ubuntu NETWORK_TEST_STORE=pglite node test/agent-schema.test.js
++```
++
++**Go:** exit 0 and `agent schema: all tests passed` on stdout.
++**No-go:** read the failure. A migration syntax error or a constraint mismatch means the PR is not ready; do
++not deploy.
++
++For an extra check against the real production schema (optional, recommended), point `NETWORK_TEST_STORE=env`
++at a scratch PostgreSQL database that has been restored from a production dump, and run the same test. The
++scratch database must never be the production URLs.
++
++The owner writes `~/openvibe/agents/ds/deploy/rehearsals/pr-34-agent-grants.json` with `{"ok": true}` only
++after the rehearsal passes. ds-finish refuses to deploy without that file and without the merged PR's
++`cutover` block.
++
++---
++
++## Step 2: deploy
++
++The migration runs as part of the normal boot of the new release. `server/db/database.js` runs every pending
++migration in `migrations/` at startup, using `DATABASE_DIRECT_URL` for the schema changes. No manual SQL, no
++script, no freeze.
++
++```bash
++ov deploy OpenVibe.Network "PR #34: delegated agent grants (migration 0017)"
++```
++
++The pipeline's own gates (verify, review, merge-by-policy) run before this. 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 `0017_agent_grants.sql` (the only pending migration), and starts serving.
++
++**Go:** `ov access run openvibe-ovh 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.
++**No-go:** the service fails to start. Check the journal. A migration error at this point means the rehearsal
++did not match production; roll back (below).
++
++---
++
++## Verification after deploy
++
++```bash
++# The migration is recorded.
++ov access run openvibe-ovh journal openvibe-network.service 200 | grep -i '0017\|migration\|agent_grants'
++
++# The table exists and is empty (no grants have been created yet).
++# This requires a DATABASE_DIRECT_URL connection; run on the host:
++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();
++ const t = await c.query("SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = $1)", ["dev_agent_grants"]);
++ const r = await c.query("SELECT count(*) FROM dev_agent_grants");
++ const m = await c.query("SELECT id FROM ov_migrations WHERE id = $1", ["0017"]);
++ console.log("table:", t.rows[0].exists, "rows:", r.rows[0].count, "migration:", m.rows.length === 1 ? "applied" : "MISSING");
++ await c.end();
++ })().catch((e) => { console.error("FAIL", e.message.replace(/\/\/[^@]*@/, "//***@")); process.exit(1); });'
++```
++
++Expected: `table: true rows: 0 migration: applied`.
++
++---
++
++## Rollback
++
++The migration is additive and the table is unused until an owner creates a grant. Two rollback paths:
++
++- **Code rollback, table stays.** Revert the deploy to the previous release. The new release's code no longer
++ references `dev_agent_grants`, so the empty table is inert. No data is lost because no row was ever written.
++ The migration remains recorded in `ov_migrations`; a future re-deploy of PR #34 will see it as already
++ applied (`IF NOT EXISTS` throughout) and move on.
++- **Full rollback, table dropped.** If the table must be removed (for example, to re-run the migration from
++ scratch after a schema fix), connect as the owner role:
++ ```sql
++ DROP TABLE IF EXISTS dev_agent_grants;
++ DELETE FROM ov_migrations WHERE id = '0017';
++ ```
++ Do this only when no grant row exists (`SELECT count(*) FROM dev_agent_grants` = 0). If any row exists, the
++ grants are in use and the table cannot be dropped without losing them.
++
++In either case, restart the service after the rollback and confirm `health network` says `ready`.
++
++---
++
++## What is not covered here
++
++- **Data move.** There is none. The table starts empty.
++- **Freeze window.** There is none. The migration is a fast `CREATE TABLE` + two `CREATE INDEX` on an empty
++ table; it takes milliseconds.
++- **SQLite path.** Production is PostgreSQL-only; the SQLite runner is not exercised here.
ddiff --git a/docs/t2-projects-and-grants.md b/docs/t2-projects-and-grants.md
iindex a4af451d..87df8504 100644
---- a/docs/t2-projects-and-grants.md
++++ b/docs/t2-projects-and-grants.md
@@@ -2,7 +2,8 @@
Status: **slices 1–2 built** (proposals, and agents: `migrations/0016_agents.sql`, `server/developer/agents.js`; PR #25,
merge `4ec36e1`) and **lane B step 3 built** (`GET /internal/projects/:project_id` under `network.project.read`, Host
--only: `server/internal/routes.js:490`; PR #28, merge `e422342`); slices 3–9 are still unbuilt (design, §8).
++only: `server/internal/routes.js:490`; PR #28, merge `e422342`) and **slice 3 built** (delegated grants and modes:
++`migrations/0017_agent_grants.sql`, the ceiling in `server/developer/agents.js`); slices 4–9 are still unbuilt (design, §8).
Plan T2, "Projects and grants: projects as the ownership boundary for every resource;
`agt_` principals, delegated grants with modes, sensitive capabilities, confirmation requests, budgets (WS-Z2)".
Pinned version: `openvibe-contracts` **v0.85.0** (`package.json:33`); every contract claim below was re-checked against
@@@ -63,8 +64,8 @@ allowances changes.
## 3. Data model
--Agents are `0016` (built); delegated grants, confirmations and budgets take the next free numbers when their slices
--land (`0017`–`0019` if nothing else lands first). The design first reserved `0010`–`0013`, but the `openvibe-sdk/db`
++Agents are `0016` and delegated grants `0017` (built); confirmations and budgets take the next free numbers when their
++slices land (`0018`–`0019` if nothing else lands first). The design first reserved `0010`–`0013`, but the `openvibe-sdk/db`
runner tracks applied migrations by id and **refuses** a pending file numbered below one already applied (`migrate:
0010_agents.sql is older than applied migration 0014`), and main already ships `0014` and `0015`; `test/agent-schema.test.js` pins
this. `0010`–`0013` stay unused. Header convention of the existing migrations: `-- phase: expand`, `-- plan T2 WS-Z2.
@@@ -121,7 +122,7 @@ CREATE INDEX IF NOT EXISTS dev_agents_service_idx ON dev_agents (host_service) W
service-hosted agent is `production`: the code that runs it is first-party, not the project's.
- `revoked` is final, like an app. `paused` is the owner's kill switch and is reversible.
--### Next — `00NN_agent_grants.sql` (slice 3)
++### 0017 — `0017_agent_grants.sql` (built, slice 3)
```sql
CREATE TABLE IF NOT EXISTS dev_agent_grants (
@@@ -148,6 +149,13 @@ CREATE INDEX IF NOT EXISTS dev_agent_grants_cap_idx ON dev_agent_grants (capa
capability Contracts marks sensitive later is treated as sensitive on the next token with no data migration. The stored
`mode` is what the owner chose; `effective_mode` (section 5) is computed.
++As built: `PUT` takes the project row's lock (as the cascades do) and, for a service host, holds the host's
++`principal_grants` row `FOR SHARE`, so a ceiling change and a `PUT` never miss each other. A ceiling cascade sets
++`revoked_by` to the subject label of whoever shrank the ceiling (`system:network` for the expiry sweep) and
++`revoke_reason = 'beyond_host'`; a person's revoke leaves `revoke_reason` empty. Each change is one `dev_audit` row,
++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)
```sql
@@@ -541,10 +549,11 @@ and agree).
`account-data.js` (`erase`, `networkPart`), `AGENT_HOST_SERVICES` in `.env.example` and the config table of
`docs/developer-projects.md`, `test/agents.test.js`,
`test/agent-schema.test.js`. Checks: `npm test`, `npm run test:pg`.
--3. **Delegated grants and modes.** `migrations/00NN_agent_grants.sql`, grant routes, the ceiling (reusing
++3. **Delegated grants and modes** (built). `migrations/0017_agent_grants.sql`, grant routes, the ceiling (reusing
`tokens.effectiveGrants` and `principals.grantsFor`), cascades in `setAllowance`, `decideGrant`/grant revoke and
`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`.
++ 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,
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
ddiff --git a/migrations/0017_agent_grants.sql b/migrations/0017_agent_grants.sql
nnew file mode 100644
iindex 00000000..1fae622d
---- /dev/null
++++ b/migrations/0017_agent_grants.sql
@@@ -0,0 +1,25 @@
++-- phase: expand
++-- plan T2 WS-Z2. Delegated grants (docs/t2-projects-and-grants.md section 3, slice 3): what an agent may do for its
++-- owner, one row per capability, never more than its host may do for the project (the delegation ceiling, checked by
++-- server/developer/agents.js). `mode` is what the owner chose; whether a capability is sensitive is read from the
++-- installed catalog on every read, so effective_mode is never stored. revoked is final for that row until the owner
++-- sets the grant again.
++
++CREATE TABLE IF NOT EXISTS dev_agent_grants (
++ agent_id text COLLATE "C" NOT NULL REFERENCES dev_agents(id),
++ capability text COLLATE "C" NOT NULL,
++ audience text COLLATE "C" NOT NULL CHECK (audience ~ '^openvibe\.[a-z][a-z0-9-]{0,63}$'),
++ mode text COLLATE "C" NOT NULL CHECK (mode IN ('auto', 'confirm')),
++ status text COLLATE "C" NOT NULL CHECK (status IN ('active', 'revoked')),
++ granted_at text COLLATE "C" NOT NULL,
++ granted_by text COLLATE "C" NOT NULL,
++ updated_at text COLLATE "C" NOT NULL,
++ expires_at text COLLATE "C",
++ revoked_at text COLLATE "C",
++ revoked_by text COLLATE "C",
++ revoke_reason text COLLATE "C",
++ PRIMARY KEY (agent_id, capability),
++ CHECK ((status = 'revoked') = (revoked_at IS NOT NULL))
++);
++CREATE INDEX IF NOT EXISTS dev_agent_grants_active_idx ON dev_agent_grants (agent_id) WHERE status = 'active';
++CREATE INDEX IF NOT EXISTS dev_agent_grants_cap_idx ON dev_agent_grants (capability, status);
ddiff --git a/server/developer/agents.js b/server/developer/agents.js
iindex f925cf43..a3e1f5e7 100644
---- a/server/developer/agents.js
++++ b/server/developer/agents.js
@@@ -11,9 +11,15 @@
* - Losing the host or the project takes the agents with it, in the transaction that causes it: revoking the app,
* archiving the project, the owner leaving it, and the owner's account erasure (revokeWhere).
* - Every change is a dev_audit row without an event: Contracts has no payload for it yet.
-- * Delegated grants, budgets, standing rules, confirmations and agent tokens come in later slices (section 8).
++ * - Delegated grants (slice 3, migrations/0017_agent_grants.sql): the owner alone sets one, for a capability the
++ * host may use for this project (the delegation ceiling, `ceiling` below); the owner, an admin+ or staff revoke it.
++ * 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).
*/
--const { ids } = require('openvibe-contracts');
++const { ids, capabilities } = require('openvibe-contracts');
++const policy = require('./policy');
const store = require('./store');
const AGENT_ID_RE = /^agt_[0-9A-HJKMNP-TV-Z]{26}$/;
@@@ -161,4 +167,172 @@ async function changeStatus(db, actor, projectId, agentId, action, { ctx }) {
return agentView(await db.prepare('SELECT * FROM dev_agents WHERE id = ?').get(a.id));
}
--module.exports = { ensureSchema, AGENT_ID_RE, agentView, listAgents, getAgent, createAgent, renameAgent, changeStatus, revokeWhere };
++// ── Delegated grants ───────────────────────────────────────────
++
++const GRANT_MODES = ['auto', 'confirm'];
++
++/** Sensitive per the installed catalog, read on every call (never stored), so a capability marked later counts at once. */
++const isSensitive = (capability) => { const c = capabilities.get(String(capability)); return !!(c && c.sensitive); };
++/** What a grant means now: a sensitive capability is always confirmed, whatever mode the owner stored. */
++const effectiveMode = (grant) => (isSensitive(grant.capability) ? 'confirm' : grant.mode);
++
++/**
++ * The delegation ceiling: the capabilities the agent's host may use for this project at `audience`, now.
++ * App host: the app's effectiveGrants (approved ∩ allowance, ∪ the sandbox allowance for a sandbox app, ∩ grantable).
++ * Service host: its unexpired principal_grants, never an `internal` capability. Either way only `active` ones, and
++ * nothing for a revoked agent, a revoked host app or an archived project.
++ */
++async function ceiling(db, agent, audience, settings) {
++ if (agent.status === 'revoked') return new Set();
++ let held = [];
++ if (agent.host_kind === 'app') {
++ const app = await db.prepare('SELECT * FROM dev_apps WHERE id = ? AND revoked_at IS NULL').get(agent.host_app_id);
++ const project = app && await db.prepare('SELECT * FROM dev_projects WHERE id = ? AND archived_at IS NULL').get(app.project_id);
++ // Required here: tokens.js requires store.js, which requires this module lazily.
++ if (project) held = await require('./tokens').effectiveGrants(db, app, project, audience, settings || policy.settings());
++ } else {
++ held = (await require('../identity/principals').grantsFor(db, agent.host_service, audience)).map((g) => g.capability)
++ .filter((c) => { const m = capabilities.get(c); return m && m.visibility !== 'internal'; });
++ }
++ return new Set(held.filter((c) => { const m = capabilities.get(c); return m && m.status === 'active'; }));
++}
++
++async function withinHost(db, agent, capability, settings) {
++ const audience = policy.audienceOf(capability);
++ return !!audience && (await ceiling(db, agent, audience, settings)).has(capability);
++}
++
++function grantView(g, within) {
++ return {
++ capability: g.capability, audience: g.audience, mode: g.mode, effective_mode: effectiveMode(g), sensitive: isSensitive(g.capability),
++ status: g.status, within_host: within, expires_at: g.expires_at || null, granted_at: g.granted_at, granted_by: g.granted_by,
++ };
++}
++
++/** Views of an agent's grant rows, each ceiling computed once per audience. */
++async function grantViews(db, agent, rows, settings) {
++ const ceilings = new Map();
++ const out = [];
++ for (const g of rows) {
++ if (!ceilings.has(g.audience)) ceilings.set(g.audience, await ceiling(db, agent, g.audience, settings));
++ out.push(grantView(g, ceilings.get(g.audience).has(g.capability)));
++ }
++ return out;
++}
++
++/** The audit row of a delegated-grant change: no event, Contracts has no payload for it yet (section 2). */
++async function grantAudit(db, agent, g, { actor, from, to, mode, reason, ctx }) {
++ await store.audit(db, { projectId: agent.project_id, actor, action: 'grant.changed', target: `agent:${agent.id}`,
++ detail: { capability: g.capability, audience: g.audience, from, to, mode, reason: reason || undefined }, ctx });
++}
++
++async function revokeGrantRow(db, agent, g, { actor, reason, 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;
++}
++
++function expiryOf(v) {
++ if (v == null || v === '') return null;
++ const d = new Date(String(v));
++ if (Number.isNaN(d.getTime())) fail(422, 'grant.invalid', 'expires_at is not a date');
++ if (d.getTime() <= Date.now()) fail(422, 'grant.invalid', 'expires_at is in the past');
++ return d.toISOString();
++}
++
++async function listGrants(db, actor, projectId, agentId, { settings } = {}) {
++ const { project } = await store.access(db, actor, projectId, { allowArchived: true });
++ const a = await loadAgent(db, project.id, agentId);
++ return await grantViews(db, a, await db.prepare('SELECT * FROM dev_agent_grants WHERE agent_id = ? ORDER BY capability').all(a.id), settings);
++}
++
++async function readGrant(db, agent, capability, settings) {
++ return (await grantViews(db, agent, [await db.prepare('SELECT * FROM dev_agent_grants WHERE agent_id = ? AND capability = ?').get(agent.id, capability)], settings))[0];
++}
++
++/**
++ * Set a delegated grant: { mode: 'auto'|'confirm' (default confirm), expires_at? }. The agent's owner only: a person
++ * delegates their own authority. Refused: an unknown capability (404 grant.unknown_capability), one that is not
++ * active (404 grant.not_grantable), `auto` on a sensitive one (422 grant.sensitive_requires_confirm), one outside the
++ * host's ceiling (403 grant.beyond_host). Setting a revoked grant again makes it active with a new granted_at.
++ */
++async function putGrant(db, actor, projectId, agentId, capability, body, { ctx, settings }) {
++ 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)) fail(403, 'agent.forbidden', "only the agent's owner delegates to it");
++ const cap = capabilities.get(String(capability));
++ if (!cap) fail(404, 'grant.unknown_capability', `no capability ${String(capability).slice(0, 80)} in the catalog`);
++ if (cap.status !== 'active') fail(404, 'grant.not_grantable', `${cap.id} is ${cap.status}`);
++ const mode = body.mode === undefined || body.mode === null ? 'confirm' : body.mode;
++ if (!GRANT_MODES.includes(mode)) fail(422, 'grant.invalid', "mode is 'auto' or 'confirm'");
++ const expiresAt = expiryOf(body.expires_at);
++ if (mode === 'auto' && isSensitive(cap.id)) fail(422, 'grant.sensitive_requires_confirm', `${cap.id} is sensitive: every use is confirmed`);
++ const audience = policy.audienceOf(cap.id);
++ await db.tx(async () => {
++ // Under the project's lock, which an allowance change (its UPDATE), an app grant revoke, an app revocation, an
++ // archive and a member's removal hold while they cascade: the ceiling read here is the one they leave behind.
++ await store.lockProject(db, project.id);
++ await store.access(db, actor, projectId, { staffOk: false });
++ const cur = await db.prepare('SELECT * FROM dev_agents WHERE id = ? FOR UPDATE').get(a.id);
++ if (cur.status === 'revoked') fail(409, 'agent.revoked', 'agent is revoked');
++ // A service's grant row is held until commit, so a concurrent /api/admin/grants/revoke cascades after this.
++ if (cur.host_kind === 'service') await db.prepare('SELECT 1 AS held FROM principal_grants WHERE client_id = ? AND capability = ? AND audience = ? FOR SHARE').get(cur.host_service, cap.id, audience);
++ if (!await withinHost(db, cur, cap.id, settings)) fail(403, 'grant.beyond_host', `${cap.id} is not something this agent's host may do for the project`);
++ const prev = await db.prepare('SELECT * FROM dev_agent_grants WHERE agent_id = ? AND capability = ? FOR UPDATE').get(a.id, cap.id);
++ const active = prev && prev.status === 'active';
++ if (active && prev.mode === mode && (prev.expires_at || null) === expiresAt) return;
++ const t = nowIso();
++ if (active) {
++ await db.prepare('UPDATE dev_agent_grants SET mode = ?, expires_at = ?, updated_at = ? WHERE agent_id = ? AND capability = ?').run(mode, expiresAt, t, a.id, cap.id);
++ } else {
++ await db.prepare(`INSERT INTO dev_agent_grants (agent_id, capability, audience, mode, status, granted_at, granted_by, updated_at, expires_at)
++ VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?)
++ ON CONFLICT (agent_id, capability) DO UPDATE SET audience = excluded.audience, mode = excluded.mode, status = 'active',
++ granted_at = excluded.granted_at, granted_by = excluded.granted_by, updated_at = excluded.updated_at, expires_at = excluded.expires_at,
++ revoked_at = NULL, revoked_by = NULL, revoke_reason = NULL`)
++ .run(a.id, cap.id, audience, mode, t, actor.label, t, expiresAt);
++ }
++ await grantAudit(db, cur, { capability: cap.id, audience }, { actor: actor.label, from: prev ? prev.status : 'none', to: 'active', mode, ctx });
++ });
++ return await readGrant(db, await loadAgent(db, project.id, a.id), cap.id, settings);
++}
++
++/** Revoke a delegated grant: the agent's owner, an admin+ or staff. */
++async function deleteGrant(db, actor, projectId, agentId, capability, { ctx, settings }) {
++ const { project, role } = await store.access(db, actor, projectId, { allowArchived: true });
++ const a = await loadAgent(db, project.id, agentId);
++ if (!(role && a.owner_subject === actor.subject) && !atLeast(role, 'admin') && !actor.staff) {
++ fail(403, 'agent.forbidden', "only the agent's owner, an admin or staff revoke its grants");
++ }
++ await db.tx(async () => {
++ const g = await db.prepare('SELECT * FROM dev_agent_grants WHERE agent_id = ? AND capability = ? FOR UPDATE').get(a.id, String(capability));
++ if (!g) fail(404, 'grant.not_found', 'no such grant');
++ if (g.status !== 'active') fail(409, 'grant.not_active', `grant is ${g.status}`);
++ await revokeGrantRow(db, a, g, { actor: actor.label, reason: null, ctx });
++ });
++ return await readGrant(db, a, String(capability), settings);
++}
++
++/**
++ * The ceiling cascade, in the caller's transaction: revoke every active delegated grant of the (not revoked) agents
++ * 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 }) {
++ 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++;
++ }
++ }
++ return n;
++}
++
++module.exports = {
++ ensureSchema, AGENT_ID_RE, agentView, listAgents, getAgent, createAgent, renameAgent, changeStatus, revokeWhere,
++ effectiveMode, withinHost, listGrants, putGrant, deleteGrant, revokeBeyondHost,
++};
ddiff --git a/server/developer/routes.js b/server/developer/routes.js
iindex 5ca2ce2c..69f0d471 100644
---- a/server/developer/routes.js
++++ b/server/developer/routes.js
@@@ -42,6 +42,9 @@ const cache = require('openvibe-shared/cache-policy');
* POST /:project/agents/:agent/pause its owner, admin+ or staff
* POST /:project/agents/:agent/resume its owner or admin+ (not staff)
* DELETE /:project/agents/:agent revoke, final: its owner, admin+ or staff
++ * 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/quotas viewer+
* PUT /:project/quotas/:capability { limit, window, unit } staff
* DELETE /:project/quotas/:capability staff
@@@ -136,6 +139,9 @@ function router() {
r.post('/:project/agents/:agent/pause', handle(async (db, a, req, o) => await agents.changeStatus(db, a, req.params.project, req.params.agent, 'pause', o)));
r.post('/:project/agents/:agent/resume', handle(async (db, a, req, o) => await agents.changeStatus(db, a, req.params.project, req.params.agent, 'resume', o)));
r.delete('/:project/agents/:agent', handle(async (db, a, req, o) => await agents.changeStatus(db, a, req.params.project, req.params.agent, 'revoke', o)));
++ r.get('/:project/agents/:agent/grants', handle(async (db, a, req, o) => ({ grants: await agents.listGrants(db, a, req.params.project, req.params.agent, o) })));
++ r.put('/:project/agents/:agent/grants/:capability', handle(async (db, a, req, o) => await agents.putGrant(db, a, req.params.project, req.params.agent, req.params.capability, req.body || {}, o)));
++ r.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/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)));
ddiff --git a/server/developer/store.js b/server/developer/store.js
iindex f67ff8e2..0430a6c7 100644
---- a/server/developer/store.js
++++ b/server/developer/store.js
@@@ -258,6 +258,8 @@ async function setAllowance(db, actor, projectId, { capabilities: list }, { ctx,
await grantEvent(db, actor, project.id, g.app_id, g.capability, g.audience, g.status, next, ctx, 'outside allowance');
trimmed.push({ app_id: g.app_id, capability: g.capability, status: next });
}
++ // Then the delegated grants of this project's app-hosted agents that the smaller ceiling no longer covers (WS-Z2).
++ await require('./agents').revokeBeyondHost(db, "project_id = ? AND host_kind = 'app'", [project.id], { actor: actor.label, ctx, settings });
});
return { allowance: wanted, trimmed };
}
@@@ -651,8 +653,12 @@ async function decideGrant(db, actor, projectId, appId, capability, decision, {
if (g.status !== 'approved') fail(409, 'grant.not_active', `grant is ${g.status}`);
}
await db.tx(async () => {
++ // A revoke takes the project's lock first, as a delegated grant's PUT does, so neither misses the other.
++ if (decision === 'revoked') await lockProject(db, project.id);
await db.prepare('UPDATE dev_grants SET status = ?, decided_by = ?, decided_at = ? WHERE app_id = ? AND capability = ?').run(decision, actor.label, nowIso(), app.id, g.capability);
await grantEvent(db, actor, project.id, app.id, g.capability, g.audience, g.status, decision, ctx);
++ // The app's agents lose the delegated grants it no longer covers (WS-Z2).
++ if (decision === 'revoked') await require('./agents').revokeBeyondHost(db, 'host_app_id = ?', [app.id], { actor: actor.label, ctx, settings });
});
return grantView(await db.prepare('SELECT * FROM dev_grants WHERE app_id = ? AND capability = ?').get(app.id, g.capability));
}
ddiff --git a/server/identity/grants-admin.js b/server/identity/grants-admin.js
iindex d4ee4bb6..b77cbd09 100644
---- a/server/identity/grants-admin.js
++++ b/server/identity/grants-admin.js
@@@ -14,6 +14,9 @@ const cache = require('openvibe-shared/cache-policy');
* POST /api/admin/grants { client_id, capability, audience?, namespaces?, expires_at?, reason }
* POST /api/admin/grants/revoke { client_id, capability, audience?, reason }
*
++ * A revoke and a recorded expiry also revoke, in the same transaction, the delegated grants of agents this service
++ * hosts that its remaining grants no longer cover (plan T2 WS-Z2, server/developer/agents.js revokeBeyondHost).
++ *
* The audience defaults to, and must be, the capability owner's (openvibe.<owner>). Expired grants stop
* counting at once (grantsFor filters them); expireDue(), every 10 minutes, records each expiry once.
*/
@@@ -140,10 +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);
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.
++ */
++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' });
++}
++
/** Record every grant whose expiry has passed, once. → how many */
async function expireDue(db) {
const due = await db.prepare('SELECT * FROM principal_grants WHERE revoked_at IS NULL AND expires_at IS NOT NULL AND expires_at <= ov_now()').all();
@@@ -152,6 +164,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 (due.length) kick(db);
ddiff --git a/test/agent-grants.test.js b/test/agent-grants.test.js
nnew file mode 100644
iindex 00000000..cbc1075a
---- /dev/null
++++ b/test/agent-grants.test.js
@@@ -0,0 +1,197 @@
++'use strict';
++// Delegated grants (plan T2 WS-Z2 slice 3, docs/t2-projects-and-grants.md sections 4-7; server/developer/agents.js):
++// the delegation ceiling for production-app, sandbox-app and service hosts; modes (auto on a sensitive capability is
++// refused, and effective_mode follows the installed catalog on every read); only the agent's owner sets a grant; and
++// every cascade with its audit row: an allowance shrink, an app grant revoke, a service grant revoke and its expiry,
++// then an app revocation, a member's removal and an archive taking the agents.
++// node test/agent-grants.test.js
++const assert = require('assert');
++const crypto = require('crypto');
++const http = require('http');
++const express = require('express');
++const jwt = require('jsonwebtoken');
++const { capabilities } = require('openvibe-contracts');
++const { getDb } = require('../server/db/database');
++const subjects = require('../server/identity/subjects');
++const grantsAdmin = require('../server/identity/grants-admin');
++
++(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();
++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());
++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 grantRow = async (agent, cap) => await db.prepare('SELECT * FROM dev_agent_grants WHERE agent_id = ? AND capability = ?').get(agent, cap);
++ const audits = async (agent) => (await db.prepare("SELECT actor, detail, event FROM dev_audit WHERE target = ? AND action = 'grant.changed' ORDER BY id").all(`agent:${agent}`))
++ .map((r) => { assert.strictEqual(r.event, null, 'no event until Contracts has a payload'); const d = JSON.parse(r.detail); return `${d.capability} ${d.from}>${d.to} ${d.mode} ${d.reason || '-'} ${r.actor}`; });
++ try {
++ // ── A project: a production app with two approved grants, a sandbox app with one, and Actor as a service host ──
++ const P = ok(await api('owner', 'POST', '', { name: 'Delegation' }), 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', 'events.app.publish'] }));
++ const PR = ok(await api('owner', 'POST', `/${P}/apps`, { name: 'Bot', environment: 'production' }), 201).id;
++ const SB = ok(await api('dev', 'POST', `/${P}/apps`, { name: 'Bot sandbox', environment: 'sandbox' }), 201).id;
++ for (const c of ['media.object.upload', 'media.object.delete']) assert.strictEqual(ok(await api('owner', 'POST', `/${P}/apps/${PR}/grants`, { capability: c }), 201).status, 'approved');
++ assert.strictEqual(ok(await api('dev', 'POST', `/${P}/apps/${SB}/grants`, { capability: 'media.object.read' }), 201).status, 'requested');
++ assert.strictEqual(ok(await api('owner', 'POST', `/${P}/apps/${SB}/grants/media.object.read/approve`)).status, 'approved');
++ for (const c of ['chat.message.send', 'chat.moderation.read']) await grantsAdmin.grant(db, { client_id: 'actor', capability: c, reason: 'agent host test' }, null);
++ const ownBot = ok(await api('owner', 'POST', `/${P}/agents`, { name: 'Prod bot', host: { type: 'app', id: PR } }), 201).id;
++ const devBot = ok(await api('dev', 'POST', `/${P}/agents`, { name: 'Sandbox bot', host: { type: 'app', id: SB } }), 201).id;
++ const svcBot = ok(await api('dev', 'POST', `/${P}/agents`, { name: 'Actor helper', host: { type: 'service', id: 'actor' } }), 201).id;
++ const put = (who, agent, cap, body = {}) => api(who, 'PUT', `/${P}/agents/${agent}/grants/${cap}`, body);
++
++ // ── Production-app host: only what the app holds now, inside the allowance; never first-party ──
++ const up = ok(await put('owner', ownBot, 'media.object.upload', { mode: 'auto' }));
++ assert.deepStrictEqual({ ...up, granted_at: undefined }, {
++ capability: 'media.object.upload', audience: 'openvibe.media', mode: 'auto', effective_mode: 'auto', sensitive: false,
++ status: 'active', within_host: true, expires_at: null, granted_at: undefined, granted_by: `user:${S.owner}`,
++ });
++ assert.match(up.granted_at, /^\d{4}-\d{2}-\d{2}T/);
++ refused(await put('owner', ownBot, 'events.app.publish'), 403, 'grant.beyond_host'); // in the allowance, but the app lacks it
++ refused(await put('owner', ownBot, 'media.object.read'), 403, 'grant.beyond_host'); // the sandbox allowance is not a production app's
++ refused(await put('owner', ownBot, 'chat.message.send'), 403, 'grant.beyond_host'); // first-party
++ refused(await put('owner', ownBot, 'nope.thing.do'), 404, 'grant.unknown_capability');
++ refused(await put('owner', ownBot, 'pics.image.upload'), 404, 'grant.not_grantable'); // planned
++ refused(await put('owner', ownBot, 'media.object.upload', { mode: 'always' }), 422, 'grant.invalid');
++ refused(await put('owner', ownBot, 'media.object.upload', { expires_at: '2001-01-01T00:00:00Z' }), 422, 'grant.invalid');
++
++ // ── Modes: auto on a sensitive capability is refused; confirm is the default ──
++ refused(await put('owner', ownBot, 'media.object.delete', { mode: 'auto' }), 422, 'grant.sensitive_requires_confirm');
++ const del = ok(await put('owner', ownBot, 'media.object.delete'));
++ assert.deepStrictEqual([del.mode, del.effective_mode, del.sensitive, del.within_host], ['confirm', 'confirm', true, true]);
++ const later = new Date(Date.now() + 86400e3).toISOString();
++ assert.strictEqual(ok(await put('owner', ownBot, 'media.object.upload', { mode: 'auto', expires_at: later })).expires_at, later);
++
++ // ── effective_mode follows the installed catalog: a grant set to auto before its capability became sensitive reads as confirm ──
++ const manifest = capabilities.get('media.object.upload');
++ const wasSensitive = manifest.sensitive;
++ manifest.sensitive = true;
++ try {
++ const g = ok(await api('owner', 'GET', `/${P}/agents/${ownBot}/grants`)).grants.find((x) => x.capability === 'media.object.upload');
++ assert.deepStrictEqual([g.mode, g.effective_mode, g.sensitive], ['auto', 'confirm', true]);
++ assert.strictEqual((await grantRow(ownBot, 'media.object.upload')).mode, 'auto', 'the stored row is not rewritten');
++ } finally {
++ if (wasSensitive === undefined) delete manifest.sensitive; else manifest.sensitive = wasSensitive;
++ }
++ assert.strictEqual(ok(await api('owner', 'GET', `/${P}/agents/${ownBot}/grants`)).grants.find((x) => x.capability === 'media.object.upload').effective_mode, 'auto');
++
++ // ── Only the agent's owner sets a grant ──
++ refused(await put('admin', ownBot, 'media.object.read'), 403, 'agent.forbidden');
++ refused(await put('viewer', ownBot, 'media.object.read'), 403, 'agent.forbidden');
++ refused(await put('dev', ownBot, 'media.object.upload'), 403, 'agent.forbidden');
++ refused(await put('staff', ownBot, 'media.object.upload'), 403, 'project.forbidden');
++ refused(await put('stranger', ownBot, 'media.object.upload'), 404, 'project.not_found');
++ refused(await put(null, ownBot, 'media.object.upload'), 401, 'auth.required');
++
++ // ── Sandbox-app host: the sandbox allowance counts, and still only what the app holds ──
++ assert.strictEqual(ok(await put('dev', devBot, 'media.object.read', { mode: 'auto' })).within_host, true);
++ refused(await put('dev', devBot, 'media.object.upload'), 403, 'grant.beyond_host');
++
++ // ── Service host: its principal_grants, never an internal capability ──
++ refused(await put('dev', svcBot, 'chat.message.send', { mode: 'auto' }), 422, 'grant.sensitive_requires_confirm');
++ const chat = ok(await put('dev', svcBot, 'chat.message.send', { mode: 'confirm' }));
++ assert.deepStrictEqual([chat.audience, chat.effective_mode, chat.within_host], ['openvibe.chat', 'confirm', true]);
++ refused(await put('dev', svcBot, 'chat.moderation.read'), 403, 'grant.beyond_host'); // internal, though Actor holds it
++ refused(await put('dev', svcBot, 'media.object.read'), 403, 'grant.beyond_host'); // Actor does not hold it
++
++ // ── Read: members and staff ──
++ assert.deepStrictEqual(ok(await api('viewer', 'GET', `/${P}/agents/${ownBot}/grants`)).grants.map((g) => g.capability), ['media.object.delete', 'media.object.upload']);
++ assert.strictEqual(ok(await api('staff', 'GET', `/${P}/agents/${svcBot}/grants`)).grants.length, 1);
++ refused(await api('stranger', 'GET', `/${P}/agents/${ownBot}/grants`), 404, 'project.not_found');
++
++ // ── Revoke: the owner, an admin+ or staff; not twice ──
++ refused(await api('viewer', 'DELETE', `/${P}/agents/${ownBot}/grants/media.object.delete`), 403, 'agent.forbidden');
++ refused(await api('dev', 'DELETE', `/${P}/agents/${ownBot}/grants/media.object.delete`), 403, 'agent.forbidden');
++ assert.strictEqual(ok(await api('staff', 'DELETE', `/${P}/agents/${ownBot}/grants/media.object.delete`)).status, 'revoked');
++ refused(await api('owner', 'DELETE', `/${P}/agents/${ownBot}/grants/media.object.delete`), 409, 'grant.not_active');
++ refused(await api('owner', 'DELETE', `/${P}/agents/${ownBot}/grants/media.object.list`), 404, 'grant.not_found');
++ assert.strictEqual((await grantRow(ownBot, 'media.object.delete')).revoked_by, `user:${S.staff}`);
++ assert.strictEqual(ok(await put('owner', ownBot, 'media.object.delete')).status, 'active', 'the owner may set it again');
++ const again = await grantRow(ownBot, 'media.object.delete');
++ assert.deepStrictEqual([again.revoked_at, again.revoked_by, again.revoke_reason], [null, null, null]);
++
++ // ── Cascade: an allowance shrink revokes the delegated grants outside it; the sandbox allowance keeps devBot's ──
++ ok(await api('staff', 'PUT', `/${P}/allowance`, { capabilities: ['media.object.upload', 'events.app.publish'] }));
++ const cut = await grantRow(ownBot, 'media.object.delete');
++ assert.deepStrictEqual([cut.status, cut.revoked_by, cut.revoke_reason], ['revoked', `user:${S.staff}`, 'beyond_host']);
++ assert.strictEqual((await grantRow(ownBot, 'media.object.upload')).status, 'active');
++ assert.strictEqual((await grantRow(devBot, 'media.object.read')).status, 'active');
++
++ // ── Cascade: revoking the app's grant revokes its agents' delegated grant ──
++ ok(await api('owner', 'DELETE', `/${P}/apps/${PR}/grants/media.object.upload`));
++ assert.deepStrictEqual([(await grantRow(ownBot, 'media.object.upload')).status, (await grantRow(ownBot, 'media.object.upload')).revoke_reason], ['revoked', 'beyond_host']);
++ assert.deepStrictEqual(await audits(ownBot), [
++ `media.object.upload none>active auto - user:${S.owner}`,
++ `media.object.delete none>active confirm - user:${S.owner}`,
++ `media.object.upload active>active auto - user:${S.owner}`,
++ `media.object.delete active>revoked confirm - user:${S.staff}`,
++ `media.object.delete revoked>active confirm - user:${S.owner}`,
++ `media.object.delete active>revoked confirm beyond_host user:${S.staff}`,
++ `media.object.upload active>revoked auto beyond_host user:${S.owner}`,
++ ]);
++
++ // ── Cascade: /api/admin/grants/revoke on the service host ──
++ await grantsAdmin.revoke(db, { client_id: 'actor', capability: 'chat.message.send', reason: 'no longer needed' }, S.owner);
++ assert.deepStrictEqual([(await grantRow(svcBot, 'chat.message.send')).status, (await grantRow(svcBot, 'chat.message.send')).revoked_by], ['revoked', `user:${S.owner}`]);
++
++ // ── Cascade: the service grant's expiry. It stops counting at once (within_host false); the sweep revokes ──
++ await grantsAdmin.grant(db, { client_id: 'actor', capability: 'blog.post.publish', reason: 'agent host test', expires_at: later }, null);
++ assert.strictEqual(ok(await put('dev', svcBot, 'blog.post.publish')).within_host, true);
++ await db.prepare("UPDATE principal_grants SET expires_at = '2001-01-01 00:00:00' WHERE client_id = 'actor' AND capability = 'blog.post.publish'").run();
++ const lapsed = ok(await api('dev', 'GET', `/${P}/agents/${svcBot}/grants`)).grants.find((g) => g.capability === 'blog.post.publish');
++ assert.deepStrictEqual([lapsed.status, lapsed.within_host], ['active', false]);
++ assert.ok(await grantsAdmin.expireDue(db) >= 1);
++ assert.strictEqual((await grantRow(svcBot, 'blog.post.publish')).status, 'revoked');
++ assert.deepStrictEqual(await audits(svcBot), [
++ `chat.message.send none>active confirm - user:${S.dev}`,
++ `chat.message.send active>revoked confirm beyond_host user:${S.owner}`,
++ `blog.post.publish none>active confirm - user:${S.dev}`,
++ 'blog.post.publish active>revoked confirm beyond_host system:network',
++ ]);
++
++ // ── Losing the host or the project takes the agents (slice 2's cascades), each with its audit row; a revoked
++ // agent's grants are outside every ceiling, and it takes no new ones ──
++ const reasons = async (agent) => (await db.prepare("SELECT detail FROM dev_audit WHERE target = ? AND action = 'agent.revoked'").all(`agent:${agent}`)).map((r) => JSON.parse(r.detail).reason);
++ ok(await api('dev', 'DELETE', `/${P}/apps/${SB}`));
++ assert.deepStrictEqual(await reasons(devBot), ['app_revoked']);
++ assert.strictEqual(ok(await api('dev', 'GET', `/${P}/agents/${devBot}/grants`)).grants[0].within_host, false);
++ refused(await put('dev', devBot, 'media.object.read'), 409, 'agent.revoked');
++ ok(await api('admin', 'DELETE', `/${P}/members/${S.dev}`), 204);
++ assert.deepStrictEqual(await reasons(svcBot), ['member_removed']);
++ ok(await api('owner', 'POST', `/${P}/archive`));
++ assert.deepStrictEqual(await reasons(ownBot), ['project_archived']);
++ refused(await put('owner', ownBot, 'media.object.delete'), 409, 'project.archived');
++ console.log('agent grants: 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 ebe1cd07..8a163fcf 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -1,7 +1,7 @@
'use strict';
--// The agents migration (plan T2 WS-Z2 slice 2, migrations/0016_agents.sql) reaches a database that production
--// already migrated: a database at 0015 without it gains it from the normal runner, once, and the 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
++// The agents migrations (plan T2 WS-Z2 slices 2-3, migrations/0016_agents.sql and 0017_agent_grants.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.
// node test/agent-schema.test.js
const assert = require('assert');
@@@ -43,11 +43,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']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', '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.
-- await asOwner((db) => db.exec(fs.readFileSync(path.join(MIGRATIONS, '0016_agents.sql'), 'utf8')));
++ for (const f of ['0016_agents.sql', '0017_agent_grants.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.
@@@ -64,6 +64,25 @@ const quiet = { log() {}, warn() {}, error() {} };
assert.deepStrictEqual([r.updated_at.length, r.status], [24, 'active']);
await assert.rejects(agent(`agt_${'6'.repeat(26)}`, other, 'sandbox'), /foreign key/i, 'an app of another project');
await assert.rejects(agent(`agt_${'7'.repeat(26)}`, prj, 'production'), /foreign key/i, 'an app of the other environment');
++
++ // Delegated grants (0017): one row per (agent, capability), of an existing agent, with a known mode and status,
++ // and a revoked row always says when.
++ const now = new Date().toISOString();
++ const grant = (g) => t.db.prepare(`INSERT INTO dev_agent_grants (agent_id, capability, audience, mode, status, granted_at, granted_by, updated_at, revoked_at)
++ VALUES (?, ?, ?, ?, ?, ?, 'test', ?, ?)`).run(g.agent || `agt_${'5'.repeat(26)}`, g.capability || 'media.object.read', g.audience || 'openvibe.media',
++ g.mode || 'confirm', g.status || 'active', now, now, g.revoked_at === undefined ? null : g.revoked_at);
++ await grant({});
++ await assert.rejects(grant({}), /duplicate key|unique/i, 'one row per agent and capability');
++ await grant({ capability: 'media.object.upload', mode: 'auto', status: 'revoked', revoked_at: now });
++ await assert.rejects(grant({ agent: `agt_${'8'.repeat(26)}`, capability: 'media.object.list' }), /foreign key/i, 'an unknown agent');
++ await assert.rejects(grant({ capability: 'media.object.list', mode: 'always' }), /check constraint/i, 'an unknown mode');
++ await assert.rejects(grant({ capability: 'media.object.list', status: 'paused' }), /check constraint/i, 'an unknown status');
++ await assert.rejects(grant({ capability: 'media.object.list', status: 'revoked' }), /check constraint/i, 'revoked without revoked_at');
++ await assert.rejects(grant({ capability: 'media.object.list', revoked_at: now }), /check constraint/i, 'active with revoked_at');
++ await assert.rejects(grant({ capability: 'media.object.list', audience: 'media' }), /check constraint/i, 'an audience that is not openvibe.<service>');
++ 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'/);
console.log('agent schema: all tests passed');
} finally {
await t.close();