openvibers/openvibe.network Commits
Serialize agent creation and resume with revoke and cascade transactions (#25)
Changes
@@@ -38,6 +38,9 @@ DEV_CREDENTIAL_OVERLAP_S=86400DEV_DEFAULT_ALLOWANCE=DEV_MAX_PROJECTS_PER_OWNER=10DEV_MAX_APPS_PER_PROJECT=20++# First-party services (OAuth client ids) that may host a project member's agents, comma-separated++# (docs/t2-projects-and-grants.md). Leave it UNSET for the code default (actor); empty = none.++# AGENT_HOST_SERVICES=actor# OpenVibe.Events base URL for relaying developer-project events (network.app.*, network.credential.*,# network.grant.changed). Unset = no relay (events stay in dev_audit and are backfilled once it is set).# OV_EVENTS_INTERNAL_URL=http://127.0.0.1:4300nnew file mode 100644@@@ -0,0 +1,23 @@++{++ "id": "network.confirmation.manage",++ "version": "1.0",++ "owner": "network",++ "status": "planned",++ "visibility": "first-party",++ "description": "Create, read, consume and cancel the confirmation requests (network.confirmation-request@1) of the agents that use this service's sensitive capabilities (roadmap WS-Z2, docs/t2-projects-and-grants.md section 5). The owning service writes the summary and the request digest; Network derives the owner from the agent and re-checks the agent, its grant and its host's authority on consume. A service only ever sees confirmations whose audience is its own. Never grantable to apps or agents.",++ "permissions": [++ "confirmations:write",++ "confirmations:read"++ ],++ "resourceConstraints": [++ "confirmation"++ ],++ "quotaClass": "confirmations",++ "events": [],++ "implementedBy": [++ "POST /internal/confirmations (planned)",++ "GET /internal/confirmations/:id (planned)",++ "POST /internal/confirmations/:id/consume (planned)",++ "POST /internal/confirmations/:id/cancel (planned)"++ ]++}@@@ -54,3 +54,27 @@ File: `network.service-manifest.json`. It adds the two capabilities, and it adds- `network.grant.changed`Payloads are in `../developer-projects.md#events`. They are recorded in Network's audit as envelopes but not relayed to Events yet.++++# Agents and confirmations (plan T2 WS-Z2)++++Changes the agents of `../t2-projects-and-grants.md` need beyond the pinned openvibe-contracts **v0.85.0**. Sections 1–4 above are published (v0.85.0 has the app claims, `prj`, `partner` and the two project capabilities). Everything below is additive. Until it is released, Network builds agents, delegated grants and the owner side of confirmations, but mints no agent token and serves no `/internal/confirmations` route.++++## 7. `identity.service-token-claims@1`: agent claims++++File: `identity.service-token-claims.v1.json`, the full proposed schema (the v0.85.0 schema plus these).++++- `sub` gains `agent:agt_<ULID>` (as `ids.principalSub({ type: 'agent', id })` already returns), `actor_type` gains `agent`.++- New optional `cap_confirm`: exact capability ids the agent may use only with an approved confirmation (or a standing rule). `cap` holds only the delegated grants whose effective mode is `auto`; the two lists never overlap.++- New optional `act` (`{ sub }`, RFC 8693): the host that minted the token, `app:app_…` or `svc:<name>`.++- `on_behalf_of` also describes the agent's owner.++- New `allOf` rule: when `actor_type` is `agent`, `project_id`, `env` and `on_behalf_of` are required.++++Add fixtures: a valid agent token with `cap_confirm`, and an invalid one without `on_behalf_of`. `serviceAuth` should treat a `cap_confirm` capability as not granted by `requireCapability` (the receiver checks the confirmation instead).++++## 8. Event payload `network.confirmation.changed@1`++++File: `network.confirmation.changed.v1.json`. One payload for every transition of a confirmation request (`created`, `approved`, `denied`, `expired`, `cancelled`, `used`), with the agent, project, capability, audience and new state. It never carries `summary` or `details`. Add `network.confirmation.changed` to the Network manifest's `eventsProduced` with it.++++## 9. Capability `network.confirmation.manage`++++In `../capabilities-proposal/network.confirmation.manage.json`: first-party, planned. What an owning service holds to create, read, consume and cancel the confirmations of its own sensitive capabilities (`POST /internal/confirmations`, `GET /internal/confirmations/:id`, `POST …/:id/consume`, `POST …/:id/cancel`).@@@ -2,7 +2,7 @@"$schema": "https://json-schema.org/draft/2020-12/schema","$id": "https://openvibe.network/contracts/identity/service-token-claims.v1.json","title": "ServiceTokenClaims",-- "description": "Claims of a short-lived RS256 client-credentials token issued by OpenVibe.Network to a service or app principal. Replaces X-Internal-Key. App tokens (actor_type app) also carry project_id and env; receivers refuse env=sandbox unless they opted in.",++ "description": "Claims of a short-lived RS256 client-credentials token issued by OpenVibe.Network to a service, app, mod, node or agent principal. Replaces X-Internal-Key. App tokens (actor_type app) also carry project_id and env; receivers refuse env=sandbox unless they opted in. Agent tokens (actor_type agent; roadmap WS-Z2, docs/t2-projects-and-grants.md) are minted for an agent by its host and also carry project_id, env and on_behalf_of (the owner), and act (the host).","type": "object","required": ["iss",@@@ -23,13 +23,15 @@},"sub": {"type": "string",-- "pattern": "^(svc:[a-z][a-z0-9-]{1,39}|app:app_[0-9A-HJKMNP-TV-Z]{26}|mod:mod_[0-9A-HJKMNP-TV-Z]{26})$"++ "pattern": "^(svc:[a-z][a-z0-9-]{1,39}|app:app_[0-9A-HJKMNP-TV-Z]{26}|mod:mod_[0-9A-HJKMNP-TV-Z]{26}|node:nod_[0-9A-HJKMNP-TV-Z]{26}|agent:agt_[0-9A-HJKMNP-TV-Z]{26})$"},"actor_type": {"enum": ["service","app",-- "mod"++ "mod",++ "node",++ "agent"]},"aud": {@@@ -46,14 +48,14 @@"type": "string","pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_*]+)+$"},-- "description": "Granted capability ids; a trailing .* grants a family."++ "description": "Granted capability ids; a trailing .* grants a family. For an agent: only the delegated grants whose effective mode is auto."},"ns": {"type": "array","items": {"type": "string"},-- "description": "Namespace constraints, e.g. live.* or mod.example.*"++ "description": "Namespace constraints, e.g. live.* or mod.example.*. A developer app token carries its project_id (the whole project, as issued before namespaces were rows) and app.<project_id>.*: on Media, app.<project_id> and app.<project_id>.sandbox are the project's production and sandbox namespaces, with children below them."},"iat": {"type": "integer"@@@ -80,7 +82,30 @@"on_behalf_of": {"type": "string","pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$",-- "description": "The person who authorized an app through the authorization-code flow. Absent on client_credentials tokens."++ "description": "The person an app or agent acts for: on an app token, the person who authorized it through the authorization-code flow (absent on client_credentials tokens); on an agent token, the agent's owner (always present)."++ },++ "cap_confirm": {++ "type": "array",++ "items": {++ "type": "string",++ "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)+$"++ },++ "description": "Agent tokens only: delegated capabilities whose every use needs an approved confirmation request (network.confirmation-request@1) or a standing rule. Exact ids, never a family. Disjoint from cap. A receiver answers 403 confirmation.required without a usable OpenVibe-Confirmation header."++ },++ "act": {++ "type": "object",++ "additionalProperties": false,++ "required": [++ "sub"++ ],++ "properties": {++ "sub": {++ "type": "string",++ "pattern": "^(svc:[a-z][a-z0-9-]{1,39}|app:app_[0-9A-HJKMNP-TV-Z]{26})$",++ "description": "The host that minted the token for the agent: a developer app of the agent's project and environment, or a first-party service."++ }++ },++ "description": "Agent tokens only (RFC 8693 actor claim): the host acting for the agent."}},"allOf": [@@@ -101,6 +126,25 @@"env"]}++ },++ {++ "if": {++ "properties": {++ "actor_type": {++ "const": "agent"++ }++ },++ "required": [++ "actor_type"++ ]++ },++ "then": {++ "required": [++ "project_id",++ "env",++ "on_behalf_of"++ ]++ }}]}nnew file mode 100644@@@ -0,0 +1,74 @@++{++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/events/payloads/network.confirmation.changed.v1.json",++ "title": "NetworkConfirmationChangedPayload",++ "description": "network.confirmation.changed v1 (OpenVibe.Network; roadmap WS-Z2, docs/t2-projects-and-grants.md section 5). A confirmation request (network.confirmation-request@1) an agent raised for a sensitive capability was created, approved, denied, expired, cancelled or used. Written to Network's outbox in the transaction that changes it, beside an audit row. Never carries the summary or details, which may hold message text: a reader that needs them asks Network with its own authority. Envelope: subject { type: confirmation, id: cnf_… }, visibility internal, on_behalf_of the owner, actor the owner who decided (or the system for an expiry, a cascade or a standing rule).",++ "type": "object",++ "required": [++ "confirmation_id",++ "agent_id",++ "project_id",++ "capability",++ "audience",++ "state",++ "change"++ ],++ "additionalProperties": false,++ "properties": {++ "confirmation_id": {++ "type": "string",++ "pattern": "^cnf_[0-9A-HJKMNP-TV-Z]{26}$"++ },++ "agent_id": {++ "type": "string",++ "pattern": "^agt_[0-9A-HJKMNP-TV-Z]{26}$",++ "description": "The agent that asked."++ },++ "project_id": {++ "type": "string",++ "pattern": "^prj_[0-9A-HJKMNP-TV-Z]{26}$",++ "description": "The agent's developer project."++ },++ "capability": {++ "type": "string",++ "pattern": "^[a-z][a-z0-9-]*\\.[a-z0-9-]+\\.[a-z0-9-]+$"++ },++ "audience": {++ "type": "string",++ "pattern": "^openvibe\\.[a-z][a-z0-9-]{0,63}$",++ "description": "The owning service of the capability: the only one that may consume or cancel the confirmation."++ },++ "state": {++ "type": "string",++ "enum": ["pending", "approved", "denied", "expired", "cancelled"],++ "description": "The state after the change. A use leaves it approved and sets used_at."++ },++ "change": {++ "type": "string",++ "enum": ["created", "approved", "denied", "expired", "cancelled", "used"]++ },++ "standing_rule": {++ "type": "string",++ "enum": ["once", "session", "until", "always"],++ "description": "On approved: the rule the owner chose (once creates none)."++ },++ "rule_id": {++ "type": "integer",++ "minimum": 1,++ "description": "On created through a standing rule, or on approved with a session, until or always rule: that rule."++ },++ "cancel_reason": {++ "type": "string",++ "enum": ["service", "agent_paused", "agent_revoked", "grant_revoked", "grant_expired", "beyond_host", "member_removed", "account_erased", "app_revoked", "project_archived"],++ "description": "On cancelled: what took the authority away."++ },++ "expires_at": {++ "type": "string",++ "format": "date-time"++ },++ "changed_at": {++ "type": "string",++ "format": "date-time"++ }++ }++}@@@ -43,7 +43,7 @@ A new project is `sandbox` only. Staff switch it to `sandbox+production`, which## Grants, the allowance and the capability catalog- The only grants that exist are capabilities in the openvibe-contracts catalog, at the version Network has installed.--- **The grantability rule.** The contracts `visibility` enum is `public | first-party | internal`. Only `active` capabilities with visibility `public` may be granted to apps. `first-party` and `internal` capabilities are never grantable: staff cannot add them to an allowance, members cannot request them, and token issuance filters them out again. The code also accepts a proposed `partner` visibility. Staff may put a `partner` capability in one project's allowance by hand, but it never comes from the default allowance. `partner` is not in the enum yet; see `docs/contracts-proposal/`.++- **The grantability rule.** The contracts `visibility` enum is `public | partner | first-party | internal` (`partner` since the v0.83.0 release; Network pins v0.85.0). Only `active` capabilities with visibility `public` or `partner` may be granted to apps. Staff may put a `partner` capability in one project's allowance by hand, but it never comes from the default or the sandbox allowance. `first-party` and `internal` capabilities are never grantable: staff cannot add them to an allowance, members cannot request them, and token issuance filters them out again.- **The allowance** is the set of grantable capabilities one project's apps may hold. Only staff set it. `DEV_DEFAULT_ALLOWANCE` seeds new projects, with public capabilities only. It is empty by default, so staff decide each project's **production** apps.- **The sandbox allowance** is added to the allowance for **sandbox apps only**, in every project, without a staff decision. It comes from `DEV_SANDBOX_ALLOWANCE`. When that is unset, the code default is `media.object.upload`, `media.object.read`, `media.object.list`, `media.object.delete`, `events.app.publish`, `events.app.read`, `events.app.subscribe`, `tools.job.create`, `tools.job.read` and `tools.job.cancel`. Only `public` + `active` capabilities of the installed openvibe-contracts catalog count. `partner`, `first-party`, `internal` and unknown ids are dropped. The three `events.app.*` ids are defined from openvibe-contracts v0.28.0 (Network pins v0.30.1); with an older release they would be left out. `media.object.list` and `media.object.delete` (Media's list and delete verbs, WS-G task 2) are left out the same way until the pinned release defines them; until then a sandbox app's upload and read grants cover them at Media. The project view and `GET /catalog` show it as `sandbox_allowance`. Production apps never use it.- **A grant never exceeds the allowance** (for a sandbox app: allowance ∪ sandbox allowance). It is checked in three places. Approval refuses with `403 grant.beyond_allowance`. Shrinking the allowance revokes approved grants, and denies requested ones, that fall outside it. A sandbox app's grant that is still inside the sandbox allowance is kept. Token issuance intersects approved grants with the current allowance and with the current grantability.@@@ -237,6 +237,7 @@ Until the relay is turned on in an environment, nobody else receives these event| `DEV_DEFAULT_ALLOWANCE` | empty | Public capabilities a new project starts with || `DEV_MAX_PROJECTS_PER_OWNER` | 10 | Active projects per owner || `DEV_MAX_APPS_PER_PROJECT` | 20 | Active apps per project |++| `AGENT_HOST_SERVICES` | unset: `actor` | First-party services (OAuth client ids) that may host a member's agents (comma list; `docs/t2-projects-and-grants.md`). Each must also be an OAuth client. Unset means the code default; an empty value means none. |## Known gaps (next steps)@@@ -1,9 +1,10 @@# T2 WS-Z2 — projects, agents and delegated grants (design)--Status: **design, nothing built.** Plan T2, "Projects and grants: projects as the ownership boundary for every resource;++Status: **slices 1–2 built** (proposals, and agents: `migrations/0016_agents.sql`, `server/developer/agents.js`);++slices 3–9 are design. 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 versions at time of writing: `openvibe-contracts` **v0.83.0** (`package.json:30`); every contract claim below was--checked against the `v0.83.0` tag of OpenVibe.Contracts, and the gaps were re-checked against `v0.84.0`.++Pinned version: `openvibe-contracts` **v0.85.0** (`package.json:33`); every contract claim below was re-checked against++the `v0.85.0` tag of OpenVibe.Contracts when slices 1–2 were built (it was written against v0.83.0).Scope: an **agent** is a principal (`agt_<ULID>`) that acts for one person, inside one developer project, run by onehost (a developer app or a first-party service such as OpenVibe.Actor). Its owner **delegates** part of the host's@@@ -13,21 +14,21 @@ authority to it, capability by capability, each in a **mode**. A **sensitive** cfor quotas (`docs/developer-projects.md`, "Quotas"). Nothing about apps, service principals, the grantability rule or theallowances changes.--## 1. What exists (origin/main `1ea05ff`)++## 1. What exists (origin/main `4e7b56b`, before slices 1–2)| Piece | Where | State ||---|---|---|--| Projects, members, roles, apps, app grants, quotas, audit | `migrations/0001_initial.sql:436-530`, `server/developer/store.js`, `/api/v1/projects` (`server/index.js:592`) | built; the ownership boundary already used by node principals (`0008`) |--| App grant ceiling | `store.js:518` `withinAllowance`, `:550` `decideGrant` (`403 grant.beyond_allowance`), `:187` `setAllowance` (shrink revokes), `server/developer/tokens.js:48` `effectiveGrants` (issuance intersects again) | built |--| Grantability | `server/developer/policy.js:16-30` (`public` and `partner`, `active` only), `:97` `allowanceFor` (sandbox allowance for sandbox apps) | built |--| Service-principal grants | `principal_grants` + `principal_grant_changes`, `server/identity/grants-admin.js` (owner-only, reason, expiry, `network.principal_grant.changed`), `server/identity/principals.js:287` `grantsFor` | built |--| Subjects | `server/identity/subjects.js:95,103` resolve `usr_` and `gst_` only | no `agt_` |--| Capability guard | `principals.js:344` `guard(capability)` throws at boot on an id the catalog does not have | built |--| Delegated client capability | `docs/capabilities-proposal/network.project.manage.json` (in the catalog at v0.83.0 as `public`, `planned`) | proposed, unused |++| Projects, members, roles, apps, app grants, quotas, audit | `migrations/0001_initial.sql:436-530`, `server/developer/store.js`, `/api/v1/projects` (`server/index.js:608`) | built; the ownership boundary already used by node principals (`0008`) |++| App grant ceiling | `store.js:559` `withinAllowance`, `:591` `decideGrant` (`403 grant.beyond_allowance`), `:196` `setAllowance` (shrink revokes), `server/developer/tokens.js:48` `effectiveGrants` (issuance intersects again) | built |++| Grantability | `server/developer/policy.js:16-30` (`public` and `partner`, `active` only), `:100` `allowanceFor` (sandbox allowance for sandbox apps) | built |++| Service-principal grants | `principal_grants` + `principal_grant_changes`, `server/identity/grants-admin.js` (owner-only, reason, expiry, `network.principal_grant.changed`), `server/identity/principals.js:290` `grantsFor` | built |++| Subjects | `server/identity/subjects.js` resolves `usr_` and `gst_` only | no `agt_` |++| Capability guard | `principals.js:347` `guard(capability)` throws at boot on an id the catalog does not have | built |++| Delegated client capability | `docs/capabilities-proposal/network.project.manage.json` (in the catalog at v0.85.0 as `public`, `planned`) | proposed, unused || Service-side project read | `network.project.read` (catalog: `first-party`, `planned`; route "GET /internal/projects/:project_id (planned)") | unused |--| Sensitive capabilities, confirmations, budgets, agents | — | missing |++| Sensitive capabilities, confirmations, budgets, agents | — | missing (agents: built by slice 2) |--## 2. Contract vocabulary (v0.83.0, used verbatim)++## 2. Contract vocabulary (v0.85.0, used verbatim)- **`identity.subject-ref@1`** has the agent form `{ "type": "agent", "id": "agt_<ULID>" }` ("an agent (OpenVibe.Actor, ora developer app's agent) acting under grants its owner delegated"). `ids.PREFIX` has `agent: 'agt'` and@@@ -35,7 +36,7 @@ allowances changes.- **`events.event-envelope@1`** has an optional `on_behalf_of` (a SubjectRef): the person an agent acted for.- **`capabilities.capability@1`** has an optional boolean **`sensitive`**: "the action has an external side effect (money,sending as the person, publishing, applying, deleting, physical control): an agent needs its owner's confirmation unless-- a standing rule covers it". 23 capabilities carry `sensitive: true` at v0.83.0. Two of them are grantable to apps++ a standing rule covers it". 23 capabilities carry `sensitive: true` at v0.85.0. Two of them are grantable to appstoday (`public` + `active`): `media.object.delete` (in the default sandbox allowance) and `space.post.write`. The othersensitive ones are `first-party` (e.g. `chat.message.send`, `blog.post.publish`, `tips.superchat.create`), `internal`(`billing.*`, `network.coins.transfer`) or `public` but `planned`/`deprecated`.@@@ -43,15 +44,16 @@ allowances changes.`requested_by` (SubjectRefs), `capability`, `summary` (≤ 500), `state` (`pending | approved | denied | expired |cancelled`), `expires_at`, `created_at`; optional `details` (object), `resources` (`common.entity-ref@1[]`),`standing_rule` (`once | session | until | always`), `decided_at`.--- The capability `visibility` enum at v0.83.0 is **`public | partner | first-party | internal`**. `docs/developer-projects.md`++- The capability `visibility` enum (since v0.83.0) is **`public | partner | first-party | internal`**. `docs/developer-projects.md`("`partner` is not in the enum yet") and the comment at `policy.js:8` predate that; slice 1 corrects both. The ruleitself does not change.--**Gaps — not in v0.83.0 nor v0.84.0, so the slices that need them are blocked (section 8):**++**Gaps — not in v0.85.0, so the slices that need them are blocked (section 8):**--1. `identity.service-token-claims@1`: `sub` allows only `svc:` / `app:` / `mod:` and `actor_type` only `service | app |-- mod`. An agent token cannot validate. (Same shape of gap as the node actor in `docs/t2-resource-registry.md` §9.)--2. No capability for a service to create or consume a confirmation (`network.*` at v0.83.0 has none; inventing one makes++1. `identity.service-token-claims@1`: `sub` allows only `svc:` / `app:` / `mod:` / `node:` and `actor_type` only++ `service | app | mod | node`. An agent token cannot validate. (v0.85.0 closed the same gap for the node actor of++ `docs/t2-resource-registry.md` §9.)++2. No capability for a service to create or consume a confirmation (`network.*` at v0.85.0 has none; inventing one makes`principals.guard` throw at boot).3. No event payload for a confirmation decision or an agent/delegated-grant change, so these changes are audit rowswithout `event` (the export-token precedent: "The row is not a platform event").@@@ -59,38 +61,22 @@ allowances changes.## 3. Data model--Migrations `0010`–`0013` (`0008` is the highest on main; `0009` is reserved for registry slice 5). Header convention of--`0007`/`0008`: `-- phase: expand`, `-- plan T2 WS-Z2. <why>`, then DDL with `IF NOT EXISTS`. The runtime creates nothing--(`ensureSchema()` stays a no-op with a comment).++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`++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.++<why>`, then DDL with `IF NOT EXISTS`. The runtime creates nothing (`ensureSchema()` stays a no-op with a comment).--**Stores.** Network runs on PostgreSQL in production and on embedded PGlite in development and in `npm test`;--`npm run test:pg` runs the same suite on real PostgreSQL. The migration runner applies the files below, in the dialect of--`0007`/`0008`: `text COLLATE "C"` (compares like the old SQLite text), timestamps as ISO `text` (`ov_now_iso()`), `~`--regex CHECKs, JSON as `text` parsed in JS (never `jsonb`, as `platform_nodes.doc`), `bigint GENERATED ALWAYS AS--IDENTITY`, partial indexes, no triggers.++**Stores.** Network runs on PostgreSQL only (the 2026-10-02 cutover): production on PostgreSQL, development and++`npm test` on embedded PGlite, `npm run test:pg` on real PostgreSQL. The migration runner applies the files below, in the++dialect of `0007`/`0008`: `text COLLATE "C"`, timestamps as ISO `text` (`ov_now_iso()`), `~` regex CHECKs, JSON as `text`++parsed in JS (never `jsonb`, as `platform_nodes.doc`), `bigint GENERATED ALWAYS AS IDENTITY`, partial indexes, no++triggers. There are no SQLite forms: nothing reads a SQLite copy of these tables, and they did not exist before the++cutover. The accept/reject cases live in `test/agents.test.js` (and the later slices' tests) against the migrated++database.--**SQLite.** The serving SQLite store is retired (ADR-035; `server/db/database.js`), but SQLite is still read by--`scripts/migrate-to-postgres.js` and `scripts/pg-preflight-collisions.js`, and the schema must stay expressible there.--Every table below therefore has a **SQLite form**, written mechanically from the PostgreSQL file and kept next to the--test that checks it (`test/fixtures/sqlite/00NN_*.sql`, section 7). Nothing at runtime applies it. The rules:----| PostgreSQL (`migrations/00NN_*.sql`) | SQLite form |--|---|---|--| `text COLLATE "C"` | `text` (SQLite's default `BINARY` collation compares like `"C"`) |--| `DEFAULT ov_now_iso()` | `DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))` (the same `…T07:53:27.720Z` shape) |--| `bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY` / `bigint` | `INTEGER PRIMARY KEY AUTOINCREMENT` / `INTEGER` |--| `c ~ '^agt_[0-9A-HJKMNP-TV-Z]{26}$'` (also `usr_`, `cnf_`) | `substr(c, 1, 4) = 'agt_' AND length(c) = 30 AND substr(c, 5) NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*'` |--| `audience ~ '^openvibe\.[a-z][a-z0-9-]{0,63}$'` | `length(audience) BETWEEN 10 AND 73 AND substr(audience, 1, 9) = 'openvibe.' AND substr(audience, 10, 1) GLOB '[a-z]' AND substr(audience, 11) NOT GLOB '*[^a-z0-9-]*'` |--| `session_id ~ '^[A-Za-z0-9._:-]{8,128}$'` | `length(session_id) BETWEEN 8 AND 128 AND session_id NOT GLOB '*[^A-Za-z0-9._:-]*'` |--| `request_digest ~ '^[0-9a-f]{64}$'` | `length(request_digest) = 64 AND request_digest NOT GLOB '*[^0-9a-f]*'` |--| composite and partial indexes, `IF NOT EXISTS`, multi-column `FOREIGN KEY` | unchanged (SQLite enforces the foreign keys with `PRAGMA foreign_keys = ON`) |----Both forms were applied while writing this doc (PGlite, and `node:sqlite` in memory, each twice to prove `IF NOT--EXISTS`) and agreed on 17 accept/reject cases: wrong prefix or a non-Crockford character, a cross-project or--cross-environment app host, a service host in sandbox, `revoked` without `revoked_at`, a bad audience, session id or--digest, `always` with `until_at`, `used_at` on a pending confirmation, a negative budget, and the ISO default.----### 0010 — `0010_agents.sql`++### 0016 — `0016_agents.sql` (built)```sqlCREATE UNIQUE INDEX IF NOT EXISTS dev_apps_project_env_key ON dev_apps (id, project_id, environment);@@@ -133,7 +119,7 @@ CREATE INDEX IF NOT EXISTS dev_agents_service_idx ON dev_agents (host_service) Wservice-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.--### 0011 — `0011_agent_grants.sql`++### Next — `00NN_agent_grants.sql` (slice 3)```sqlCREATE TABLE IF NOT EXISTS dev_agent_grants (@@@ -160,7 +146,7 @@ CREATE INDEX IF NOT EXISTS dev_agent_grants_cap_idx ON dev_agent_grants (capacapability 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.--### 0012 — `0012_confirmations.sql`++### Next — `00NN_confirmations.sql` (slice 4)```sqlCREATE TABLE IF NOT EXISTS dev_standing_rules (@@@ -223,7 +209,7 @@ CREATE INDEX IF NOT EXISTS dev_confirmations_spendable_idx ON dev_confirmations`approved`, unused row may be cancelled too (its `decided_at` stays); the spendable index finds those rows for thecascades.--### 0013 — `0013_agent_budgets.sql`++### Next — `00NN_agent_budgets.sql` (slice 5)```sqlCREATE TABLE IF NOT EXISTS dev_agent_budgets (@@@ -254,7 +240,7 @@ Public routes follow the projects API exactly (`docs/developer-projects.md`, "AP|---|---|| `GET /agents[?owner=me]` | `{ agents: [agent] }` || `POST /agents` | `{ name, host: {type:'app', id:'app_…'} \| {type:'service', id:'actor'} }` → `201 agent`; an app host must be a non-revoked app **of `:project`** (otherwise `404 app.not_found`, the same answer as an unknown id, so other projects' app ids are not probed) |--| `GET /agents/:agent` | `agent` with `grants`, `budgets`, `rules` |++| `GET /agents/:agent` | `agent` with `grants`, `budgets`, `rules` (each added by its slice; slice 2 returns the `agent` alone) || `PATCH /agents/:agent` | `{ name }` → `agent` || `POST /agents/:agent/pause`, `/resume` | → `agent` || `DELETE /agents/:agent` | revoke (final) → `agent` |@@@ -318,7 +304,7 @@ service through `principals.issueToken`), and adds `agent=agt_…` plus `audienc**Host binding, checked on every issuance** (`400 invalid_grant` for all of them, one message, so a host cannot learnwhether someone else's agent exists): the authenticated client **is** the agent's host (`app.id = agent.host_app_id`,or `client_id = agent.host_service`); for an app host, `app.project_id = agent.project_id`, `app.environment =--agent.environment` and the app is not revoked (the foreign key of `0010` already makes the first two true for every++agent.environment` and the app is not revoked (the foreign key of `0016` already makes the first two true for everystored row; issuance re-reads them so a future change to that key cannot widen tokens); the project is not archived;the agent is `active`; the owner is still a member and not banned or deleted.@@@ -417,7 +403,7 @@ the digest. An agent cannot describe its own request to its owner.4. **Use once.** The agent retries with the header. The service calls `consume` with its digest of *this* request. Anapproval is only as good as the authority behind it **now**, so consume re-checks everything that let it be created,in one transaction (`db.tx`) that first locks the agent row (`SELECT … FROM dev_agents WHERE id = ? FOR UPDATE` on-- PostgreSQL; SQLite serialises writers anyway) so a revocation cannot interleave:++ PostgreSQL) so a revocation cannot interleave:- the agent is `active`, its project is not archived, and its owner is still a member, not banned and not deleted;- for an app host, the app is not revoked and still belongs to the agent's project and environment;- the delegated grant (agent, capability) is `active`, unexpired, and its `effective_mode` is still `confirm`;@@@ -465,10 +451,15 @@ the capability's audience. Owning services read budgets from `GET /internal/agen- **App grants (`dev_grants`, `store.js`).** Untouched: same table, statuses, roles, approval, `grant.beyond_allowance`,`network.grant.changed`. Three existing transactions gain one cascade each, **after** their current work and in the-- same transaction: `setAllowance` (`:187`) and `decideGrant`/the grant revoke (`:550`) revoke the delegated grants of-- the app's agents that fall outside `effectiveGrants`; `revokeAppTx` (`:397`) and `archiveProject` (`:176`) revoke the-- agents themselves (`revoked_by = 'app_revoked'` / `'project_archived'`). `removeMember` (`:281`) revokes the leaving-- person's agents in that project. Each of these cascades also cancels the affected pending and approved-unused++ same transaction: `setAllowance` (`:196`) and `decideGrant`/the grant revoke (`:591`) revoke the delegated grants of++ the app's agents that fall outside `effectiveGrants`; `revokeAppTx` (`:436`) and `archiveProject` (`:183`) revoke the++ agents themselves (`revoked_by = 'app_revoked'` / `'project_archived'`; built, slice 2 — the archive revokes its agents++ before its apps, so each records the archive as the cause). `removeMember` (`:319`) revokes the leaving person's++ agents in that project (`'member_removed'`). Each of these transactions, and an account erasure, first takes the++ project row's lock (`store.lockProject`, `FOR NO KEY UPDATE`); agent creation takes it too and re-checks the project,++ the membership and the host under it, so no agent is created against a state a cascade is ending. A pause, resume or++ revoke re-reads the agent `FOR UPDATE` and updates only from that status, so a resume never undoes a concurrent revoke++ (`test/agents-concurrency.test.js`). Each of these cascades also cancels the affected pending and approved-unusedconfirmations and revokes the standing rules (section 5, step 6). A sandbox agent's grant still inside the sandbox allowance survives a staff change,for the same reason the app's grant does.- **App tokens (`tokens.js`).** Unchanged byte for byte when no `agent` parameter is sent; the new branch is taken only@@@ -481,11 +472,12 @@ the capability's audience. Owning services read budgets from `GET /internal/agen- **Sensitive app grants.** `media.object.delete` and `space.post.write` keep working for apps exactly as today:`capability@1` defines confirmation for **agents**. Apps acting with `on_behalf_of` are listed as unresolved.- **Mod principals, node principals, export tokens.** Untouched.--- **Account deletion (`account-data.js:315` `erase`)** revokes the subject's agents, cancels their pending and approved-unused-- confirmations and revokes their rules; the account export (`:160` `networkPart`) lists the person's agents, grants, rules and++- **Account deletion (`account-data.js:319` `erase`)** revokes the subject's agents (`revoked_by = 'account_deleted'`,++ then `'project_archived'` for the rest of a project it archives; built, slice 2), cancels their pending and approved-unused++ confirmations and revokes their rules; the account export (`:162` `networkPart`) lists the person's agents (built: `developer_projects.json` `agents`, without `revoked_by`), grants, rules andconfirmations (without other people's data).- **Migration of existing rows.** None needed: the four tables start empty, no existing column changes, no backfill,-- `0010`–`0013` are `expand` only. Rolling back a slice means its routes disappear; its empty or orphaned rows are inert++ `0016` and its successors are `expand` only. Rolling back a slice means its routes disappear; its empty or orphaned rows are inertbecause issuance and the routes are the only readers.## 7. Test plan@@@ -499,10 +491,10 @@ keypair, a bare express app, user tokens, service tokens through `POST /oauth/to`host_app_id`, service host in sandbox, revoked without `revoked_at`); **an app of another project as host**: the routeanswers `404 app.not_found`, and a direct insert of such a row (or of an app of the other environment) fails on thecomposite foreign key; cascades: revoke app, archive project, remove member, account erase.--- `test/agent-schema.test.js` — applies `0010`–`0013` and their SQLite forms (`test/fixtures/sqlite/00NN_*.sql`, to a-- `node:sqlite` in-memory database with `PRAGMA foreign_keys = ON`; `node:sqlite` because the native `better-sqlite3`-- build need not match the running Node), each twice, and runs one table of accept/reject rows against both, asserting-- the same outcome (the 17 cases of section 3, grown with each slice). A drift between the two forms fails here.++- `test/agent-schema.test.js` — through the normal runner only: a database migrated to `0015` without the agents++ migration gains `0016` (once; re-running the file changes nothing), a file numbered below an applied one is refused++ (why the design's `0010` became `0016`), and the composite foreign key holds on the migrated database. Each later++ slice adds its migration to it.- `test/agent-grants.test.js` — ceiling: an app-hosted agent cannot get a capability the app lacks, outside theallowance, or `first-party`; a sandbox agent gets a sandbox-allowance capability; a service-hosted agent never gets an`internal` one; `auto` on `media.object.delete` → `422`; `effective_mode` follows the catalog; only the owner may PUT;@@@ -533,33 +525,33 @@ Regression runs each slice: `test/developer-projects.test.js`, `test/developer-d`test/developer-export-tokens.test.js`, `test/grants-admin.test.js`, `test/principals.test.js`,`test/service-principal.test.js`, `test/account-data.test.js`, `test/security-idor.test.js`. Every slice: the new filealone while iterating, then `npm test` and `npm run test:pg` (PGlite and real PostgreSQL must both apply the migration--and agree; `test/agent-schema.test.js` adds the SQLite form to that agreement).++and agree).## 8. Slices — each one PR--1. **Docs and proposals.** `docs/developer-projects.md` and the `policy.js:8` comment: `partner` is in the v0.83.0 enum.++1. **Docs and proposals** (built). `docs/developer-projects.md` and the `policy.js:8` comment: `partner` is in the enum.`docs/capabilities-proposal/network.confirmation.manage.json` (first-party, `implementedBy` the four`/internal/confirmations` routes). `docs/contracts-proposal/`: `identity.service-token-claims@1` gains `actor_typeagent`, `sub agent:agt_…`, `cap_confirm`, `act`, and requires `project_id`, `env`, `on_behalf_of` for agents; a`network.confirmation.changed@1` payload. No migration. Checks: `npm test` (docs-only, sanity).--2. **Agents.** `migrations/0010_agents.sql`, `server/developer/agents.js` (store + `ensureSchema` no-op), routes in++2. **Agents** (built). `migrations/0016_agents.sql`, `server/developer/agents.js` (store + `ensureSchema` no-op), routes in`server/developer/routes.js`, cascades in `store.js` (`revokeAppTx`, `archiveProject`, `removeMember`) and`account-data.js` (`erase`, `networkPart`), `AGENT_HOST_SERVICES` in `.env.example` and the config table of-- `docs/developer-projects.md`, `test/fixtures/sqlite/0010_agents.sql`, `test/agents.test.js`,++ `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/0011_agent_grants.sql`, grant routes, the ceiling (reusing++3. **Delegated grants and modes.** `migrations/00NN_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/fixtures/sqlite/0011_agent_grants.sql`, `test/agent-grants.test.js`,++ `grants-admin.js` revoke + `expireDue`, `test/agent-grants.test.js`,cases added to `test/agent-schema.test.js`. Checks: `npm test`, `npm run test:pg`.--4. **Confirmations, owner side.** `migrations/0012_confirmations.sql`, `server/developer/confirmations.js` (store,++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 ofslices 2–3 (pause, revoke, grant revoke and expiry, ceiling cascades, member removal, erase, app revoke, archive),-- `test/fixtures/sqlite/0012_confirmations.sql`, `test/confirmations.test.js` creating and consuming rows through the++ `test/confirmations.test.js` creating and consuming rows through thestore functions (the internal routes wait for slice 7), cases added to `test/agent-schema.test.js`. Checks: `npm test`,`npm run test:pg`.--5. **Budgets.** `migrations/0013_agent_budgets.sql`, budget routes, `budget.beyond_quota`,-- `test/fixtures/sqlite/0013_agent_budgets.sql`, `test/agent-budgets.test.js` (without the internal read), cases added to++5. **Budgets.** `migrations/00NN_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`server/developer/agents.js`, wired in `server/index.js`; tests added to `test/agent-budgets.test.js`. Checks:nnew file mode 100644@@@ -0,0 +1,35 @@++-- phase: expand++-- plan T2 WS-Z2. Agents (docs/t2-projects-and-grants.md section 3, slice 2): an agent (agt_<ULID>) acts for one++-- person, inside one developer project, run by one host: an app of that project in the same environment, or a++-- first-party service (always production). The composite foreign key makes a host app of another project or++-- environment impossible; dev_apps.id is already the primary key, so the unique index only gives it a target.++-- revoked is final, paused is the owner's reversible kill switch. Numbered after 0015: the runner refuses a file++-- older than one already applied, so the design's 0010 could never reach a database at 0014 or later.++++CREATE UNIQUE INDEX IF NOT EXISTS dev_apps_project_env_key ON dev_apps (id, project_id, environment);++++CREATE TABLE IF NOT EXISTS dev_agents (++ id text COLLATE "C" PRIMARY KEY CHECK (id ~ '^agt_[0-9A-HJKMNP-TV-Z]{26}$'),++ project_id text COLLATE "C" NOT NULL REFERENCES dev_projects(id),++ owner_subject text COLLATE "C" NOT NULL CHECK (owner_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),++ host_kind text COLLATE "C" NOT NULL CHECK (host_kind IN ('app', 'service')),++ host_app_id text COLLATE "C",++ host_service text COLLATE "C" REFERENCES oauth_clients(client_id),++ environment text COLLATE "C" NOT NULL CHECK (environment IN ('sandbox', 'production')),++ name text COLLATE "C" NOT NULL CHECK (length(name) BETWEEN 1 AND 80),++ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'paused', 'revoked')),++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),++ created_by text COLLATE "C" NOT NULL,++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),++ revoked_at text COLLATE "C",++ revoked_by text COLLATE "C",++ FOREIGN KEY (host_app_id, project_id, environment) REFERENCES dev_apps (id, project_id, environment),++ CHECK ((host_kind = 'app') = (host_app_id IS NOT NULL)),++ CHECK ((host_kind = 'service') = (host_service IS NOT NULL)),++ CHECK (host_kind = 'app' OR environment = 'production'),++ CHECK ((status = 'revoked') = (revoked_at IS NOT NULL))++);++CREATE INDEX IF NOT EXISTS dev_agents_project_idx ON dev_agents (project_id, status);++CREATE INDEX IF NOT EXISTS dev_agents_owner_idx ON dev_agents (owner_subject, status);++CREATE INDEX IF NOT EXISTS dev_agents_app_idx ON dev_agents (host_app_id) WHERE host_app_id IS NOT NULL;++CREATE INDEX IF NOT EXISTS dev_agents_service_idx ON dev_agents (host_service) WHERE host_service IS NOT NULL;@@@ -42,6 +42,9 @@ module.exports = {defaultAllowance: process.env.DEV_DEFAULT_ALLOWANCE || '',maxProjectsPerOwner: process.env.DEV_MAX_PROJECTS_PER_OWNER || 10,maxAppsPerProject: process.env.DEV_MAX_APPS_PER_PROJECT || 20,++ // First-party services (OAuth client ids) that may host a project member's agents (comma list).++ // Unset = the code default (actor: server/developer/policy.js); set to an empty value = none.++ agentHostServices: process.env.AGENT_HOST_SERVICES,},// OpenVibe.Events base URL for Network's own events (developer projects). Unset = no relay:nnew file mode 100644@@@ -0,0 +1,164 @@++'use strict';++/**++ * Agents (plan T2 WS-Z2, docs/t2-projects-and-grants.md sections 3-5): an agent (agt_<ULID>) acts for one person,++ * inside one developer project, run by one host — an app of that project (in the app's environment) or a++ * first-party service listed in AGENT_HOST_SERVICES (always production). The schema is migrations/0016_agents.sql.++ *++ * - A person creates agents for themselves only: developer+ for a sandbox app or a service host, admin+ for a++ * production app. Nobody creates an agent for another member; staff never create one.++ * - The owner or an admin+ renames and resumes; the owner, an admin+ or staff pause and revoke. A staff pause is++ * lifted by the owner (or an admin), never by staff as such. `revoked` is final; `paused` is reversible.++ * - 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).++ */++const { ids } = require('openvibe-contracts');++const store = require('./store');++++const AGENT_ID_RE = /^agt_[0-9A-HJKMNP-TV-Z]{26}$/;++const fail = (status, code, detail) => { throw new store.DevError(status, code, detail); };++const nowIso = () => new Date().toISOString();++const atLeast = (role, need) => !!role && store.ROLES.indexOf(role) >= store.ROLES.indexOf(need);++++function ensureSchema(db) { /* the schema is migrations/0016_agents.sql (plan T2); nothing is created at runtime */ }++++function agentView(a) {++ return {++ id: a.id, subject: { type: 'agent', id: a.id }, project_id: a.project_id,++ owner: { type: 'user', id: a.owner_subject },++ host: a.host_kind === 'app' ? { type: 'app', id: a.host_app_id } : { type: 'service', id: a.host_service },++ environment: a.environment, name: a.name, status: a.status,++ created_at: a.created_at, updated_at: a.updated_at, revoked_at: a.revoked_at || null,++ };++}++++const cleanName = (v) => {++ const s = String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]/g, '').trim();++ if (!s || s.length > 80) fail(422, 'agent.invalid', 'name must be 1-80 characters');++ return s;++};++++async function loadAgent(db, projectId, agentId) {++ const a = AGENT_ID_RE.test(String(agentId)) ? await db.prepare('SELECT * FROM dev_agents WHERE id = ? AND project_id = ?').get(agentId, projectId) : null;++ if (!a) fail(404, 'agent.not_found', 'no such agent');++ return a;++}++++async function listAgents(db, actor, projectId, { owner } = {}) {++ const { project } = await store.access(db, actor, projectId, { allowArchived: true });++ const rows = owner === 'me'++ ? await db.prepare('SELECT * FROM dev_agents WHERE project_id = ? AND owner_subject = ? ORDER BY id').all(project.id, actor.subject)++ : await db.prepare('SELECT * FROM dev_agents WHERE project_id = ? ORDER BY id').all(project.id);++ return rows.map(agentView);++}++++async function getAgent(db, actor, projectId, agentId) {++ const { project } = await store.access(db, actor, projectId, { allowArchived: true });++ return agentView(await loadAgent(db, project.id, agentId));++}++++/** The host a creation names, checked against the project and the caller's role: an app host must be a live app of the project. */++async function resolveHost(db, project, role, host, settings) {++ if (host.type === 'app') {++ const app = store.APP_ID_RE.test(String(host.id))++ ? await db.prepare('SELECT * FROM dev_apps WHERE id = ? AND project_id = ? AND revoked_at IS NULL').get(host.id, project.id) : null;++ if (!app) fail(404, 'app.not_found', 'no such app');++ if (app.environment === 'production' && !atLeast(role, 'admin')) fail(403, 'project.forbidden', 'requires admin role');++ return { kind: 'app', app: app.id, service: null, environment: app.environment };++ }++ if (host.type === 'service') {++ const svc = String(host.id || '');++ if (!settings.agentHostServices.has(svc) || !await db.prepare('SELECT client_id FROM oauth_clients WHERE client_id = ?').get(svc)) {++ fail(422, 'agent.host_not_allowed', `${svc.slice(0, 40) || 'that service'} does not host agents`);++ }++ return { kind: 'service', app: null, service: svc, environment: 'production' };++ }++ fail(422, 'agent.invalid', "host is { type: 'app', id: 'app_…' } or { type: 'service', id }");++}++++/** Create an agent for the caller. An app host must be a live app of this project: anything else is app.not_found. */++async function createAgent(db, actor, projectId, body, { ctx, settings }) {++ await store.access(db, actor, projectId, { need: 'developer', staffOk: false });++ if (body.owner !== undefined) {++ const o = body.owner && typeof body.owner === 'object' ? body.owner.id : body.owner;++ if (o !== actor.subject) fail(403, 'agent.forbidden', 'an agent is created for yourself only');++ }++ const name = cleanName(body.name);++ const host = body.host && typeof body.host === 'object' ? body.host : {};++ const id = ids.newId('agent');++ const t = nowIso();++ await db.tx(async () => {++ // Under the project's lock, which an app revocation, a member's removal, an archive and an account erasure++ // take before revoking agents: the project, the membership and the host are checked again here, so a++ // cascade that commits first is seen, and one that comes after sees this agent and revokes it.++ await store.lockProject(db, projectId);++ const { project, role } = await store.access(db, actor, projectId, { need: 'developer', staffOk: false });++ const h = await resolveHost(db, project, role, host, settings);++ await db.prepare(`INSERT INTO dev_agents (id, project_id, owner_subject, host_kind, host_app_id, host_service, environment, name, created_at, created_by, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, project.id, actor.subject, h.kind, h.app, h.service, h.environment, name, t, actor.label, t);++ await store.audit(db, { projectId: project.id, actor: actor.label, action: 'agent.created', target: `agent:${id}`,++ detail: { name, host: { type: h.kind, id: h.app || h.service }, environment: h.environment }, ctx });++ });++ return agentView(await db.prepare('SELECT * FROM dev_agents WHERE id = ?').get(id));++}++++async function renameAgent(db, actor, projectId, agentId, body, { ctx }) {++ const { project, role } = await store.access(db, actor, projectId, { staffOk: false });++ const a = await loadAgent(db, project.id, agentId);++ if (a.owner_subject !== actor.subject && !atLeast(role, 'admin')) fail(403, 'agent.forbidden', "only the agent's owner or an admin renames it");++ if (a.status === 'revoked') fail(409, 'agent.revoked', 'agent is revoked');++ const name = cleanName(body.name);++ await db.tx(async () => {++ await db.prepare('UPDATE dev_agents SET name = ?, updated_at = ? WHERE id = ?').run(name, nowIso(), a.id);++ await store.audit(db, { projectId: project.id, actor: actor.label, action: 'agent.renamed', target: `agent:${a.id}`, detail: { from: a.name, to: name }, ctx });++ });++ return agentView(await db.prepare('SELECT * FROM dev_agents WHERE id = ?').get(a.id));++}++++/**++ * Revoke every agent matching `where` that is not revoked yet, in the caller's transaction: one audit row each.++ * `reason` is stored as revoked_by (app_revoked, project_archived, member_removed, account_deleted); `actor` is++ * the subject label of whoever caused it. Returns how many were revoked.++ */++async function revokeWhere(db, where, params, { actor, reason, ctx }) {++ const t = nowIso();++ let n = 0;++ const rows = await db.prepare(`SELECT id, project_id FROM dev_agents WHERE (${where}) AND status <> 'revoked' ORDER BY id`).all(...params);++ for (const a of rows) {++ const r = await db.prepare("UPDATE dev_agents SET status = 'revoked', revoked_at = ?, revoked_by = ?, updated_at = ? WHERE id = ? AND status <> 'revoked'").run(t, reason, t, a.id);++ if (!r.changes) continue;++ n++;++ await store.audit(db, { projectId: a.project_id, actor, action: 'agent.revoked', target: `agent:${a.id}`, detail: { reason }, ctx });++ }++ return n;++}++++const TO = { pause: 'paused', resume: 'active', revoke: 'revoked' };++++/** pause | resume | revoke. Staff may pause and revoke any project's agent, but not resume one. */++async function changeStatus(db, actor, projectId, agentId, action, { ctx }) {++ const staffOk = action !== 'resume';++ const { project, role } = await store.access(db, actor, projectId, { staffOk, allowArchived: action === 'revoke' });++ const a = await loadAgent(db, project.id, agentId);++ if (!(role && a.owner_subject === actor.subject) && !atLeast(role, 'admin') && !(staffOk && actor.staff)) {++ fail(403, 'agent.forbidden', `only the agent's owner, an admin${staffOk ? ' or staff' : ''} may ${action} it`);++ }++ const to = TO[action];++ await db.tx(async () => {++ // The status is read again under the row's lock and the update is conditioned on it: a revoke that commits++ // while this runs (a person's, or a cascade's) is seen here, never undone by a resume that read `paused`.++ const cur = await db.prepare('SELECT * FROM dev_agents WHERE id = ? FOR UPDATE').get(a.id);++ if (cur.status === to) return;++ if (cur.status === 'revoked') fail(409, 'agent.revoked', 'agent is revoked');++ const t = nowIso();++ const r = await db.prepare('UPDATE dev_agents SET status = ?, revoked_at = ?, revoked_by = ?, updated_at = ? WHERE id = ? AND status = ?')++ .run(to, to === 'revoked' ? t : null, to === 'revoked' ? actor.label : null, t, a.id, cur.status);++ 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 });++ });++ 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 };@@@ -5,8 +5,8 @@** Grantability comes from the capability's `visibility` in openvibe-contracts:* public grantable to apps (may appear in a project's allowance, including a default one)-- * partner (proposed; not in the contracts enum yet) grantable only when staff put it in one-- * project's allowance by hand, never through the default allowance++ * partner grantable only when staff put it in one project's allowance by hand, never through++ * the default or the sandbox allowance* first-party never grantable to apps: only first-party service principals hold these* internal never grantable to apps* A capability must also be `active` (not planned, deprecated or retired) to be granted.@@@ -16,6 +16,7 @@ const { capabilities } = require('openvibe-contracts');const GRANTABLE_VISIBILITIES = new Set(['public', 'partner']);const DEFAULT_ALLOWANCE_VISIBILITIES = new Set(['public']);const ENVIRONMENTS = ['sandbox', 'production'];++const DEFAULT_AGENT_HOST_SERVICES = ['actor'];const ENVIRONMENT_POLICIES = { sandbox: ['sandbox'], 'sandbox+production': ['sandbox', 'production'] };/** { grantable, code, reason } for one capability id. */@@@ -87,6 +88,8 @@ function settings(config) {defaultAllowance: publicOnly(list(d.defaultAllowance)),maxProjectsPerOwner: num(d.maxProjectsPerOwner, 10, 1, 1000),maxAppsPerProject: num(d.maxAppsPerProject, 20, 1, 1000),++ // First-party services that may host agents (OAuth client ids, AGENT_HOST_SERVICES). Unset = actor; empty = none.++ agentHostServices: new Set(list(orDefault(d.agentHostServices, DEFAULT_AGENT_HOST_SERVICES))),};}@@@ -34,6 +34,14 @@ const cache = require('openvibe-shared/cache-policy');* POST /:project/apps/:app/grants/:capability/approve owner/admin, inside the allowance only* POST /:project/apps/:app/grants/:capability/deny owner/admin* DELETE /:project/apps/:app/grants/:capability owner/admin or staff++ * GET /:project/agents[?owner=me] viewer+ (agents.js, plan T2 WS-Z2)++ * POST /:project/agents { name, host } for yourself: developer+ (admin+ production app host);++ * host { type: app, id } of this project or { type: service, id }++ * GET /:project/agents/:agent viewer+++ * PATCH /:project/agents/:agent { name } its owner or admin+++ * 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/quotas viewer+* PUT /:project/quotas/:capability { limit, window, unit } staff* DELETE /:project/quotas/:capability staff@@@ -47,6 +55,7 @@ const express = require('express');const { http } = require('openvibe-contracts');const { verifySession } = require('../auth/session');const store = require('./store');++const agents = require('./agents');const policy = require('./policy');const tokens = require('./tokens');const usage = require('./usage');@@@ -120,6 +129,14 @@ function router() {r.post('/:project/apps/:app/grants/:capability/deny', handle(async (db, a, req, o) => await store.decideGrant(db, a, req.params.project, req.params.app, req.params.capability, 'denied', o)));r.delete('/:project/apps/:app/grants/:capability', handle(async (db, a, req, o) => await store.decideGrant(db, a, req.params.project, req.params.app, req.params.capability, 'revoked', o)));++ r.get('/:project/agents', handle(async (db, a, req) => ({ agents: await agents.listAgents(db, a, req.params.project, { owner: req.query.owner }) })));++ r.post('/:project/agents', handle(async (db, a, req, o) => await agents.createAgent(db, a, req.params.project, req.body || {}, o), 201));++ r.get('/:project/agents/:agent', handle(async (db, a, req) => await agents.getAgent(db, a, req.params.project, req.params.agent)));++ r.patch('/:project/agents/:agent', handle(async (db, a, req, o) => await agents.renameAgent(db, a, req.params.project, req.params.agent, req.body || {}, o)));++ 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/quotas', handle(async (db, a, req) => ({ quotas: await store.listQuotas(db, a, req.params.project), note: 'quotas are enforced by the service that owns each capability; Network records and exposes them' })));r.put('/:project/quotas/:capability', handle(async (db, a, req, o) => await store.setQuota(db, a, req.params.project, req.params.capability, req.body || {}, o)));r.delete('/:project/quotas/:capability', handle(async (db, a, req, o) => await store.deleteQuota(db, a, req.params.project, req.params.capability, o)));@@@ -103,6 +103,16 @@ async function memberRole(db, projectId, subject) {return r ? r.role : null;}++/**++ * Take the project row's lock for the rest of the caller's transaction. Every transaction that revokes agents as a++ * cascade (an app's revocation, a member's removal, the project's archive, an account erasure) takes it first, and++ * agent creation takes it before re-checking the project, the membership and the host: so an agent is never++ * created against a state one of them is about to end. NO KEY UPDATE, so inserts referencing the row still pass.++ */++async function lockProject(db, projectId) {++ return await db.prepare('SELECT * FROM dev_projects WHERE id = ? FOR NO KEY UPDATE').get(projectId);++}++/*** Load a project the actor may see. Non-members get 404 (existence is not disclosed); staff see all.* `need` is the minimum member role; `staffOk` lets staff through regardless of membership.@@@ -179,10 +189,13 @@ async function renameProject(db, actor, projectId, { name }, { ctx, settings })return await projectView(db, await db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(project.id), role, settings);}--/** Archive: irreversible. Every app of the project is revoked (credentials included). */++/** Archive: irreversible. Every agent and app of the project is revoked (credentials included). */async function archiveProject(db, actor, projectId, { ctx, settings }) {const { project, role } = await access(db, actor, projectId, { need: 'owner', staffOk: true });await db.tx(async () => {++ await lockProject(db, project.id);++ // Agents first, so each records the archive as its cause rather than its app's revocation.++ await require('./agents').revokeWhere(db, 'project_id = ?', [project.id], { actor: actor.label, reason: 'project_archived', ctx });for (const app of await db.prepare('SELECT * FROM dev_apps WHERE project_id = ? AND revoked_at IS NULL').all(project.id)) await revokeAppTx(db, actor, project, app, ctx, 'project archived');await db.prepare('UPDATE dev_projects SET archived_at = ?, archived_by = ? WHERE id = ?').run(nowIso(), actor.label, project.id);await audit(db, { projectId: project.id, actor: actor.label, action: 'project.archived', target: project.id, ctx });@@@ -322,8 +335,10 @@ async function removeMember(db, actor, projectId, subject, { ctx }) {if (current === 'owner') fail(403, 'member.forbidden', 'the owner cannot be removed');if (!self && !canAssign(role, current)) fail(403, 'member.forbidden', `a ${role} cannot remove a ${current}`);await db.tx(async () => {++ await lockProject(db, project.id);await db.prepare('DELETE FROM dev_project_members WHERE project_id = ? AND subject_id = ?').run(project.id, subject);await audit(db, { projectId: project.id, actor: actor.label, action: self ? 'member.left' : 'member.removed', target: `user:${subject}`, detail: { role: current }, ctx });++ await require('./agents').revokeWhere(db, 'project_id = ? AND owner_subject = ?', [project.id, subject], { actor: actor.label, reason: 'member_removed', ctx });});}@@@ -431,12 +446,15 @@ async function updateApp(db, actor, projectId, appId, body, { ctx }) {}async function revokeAppTx(db, actor, project, app, ctx, reason) {++ await lockProject(db, project.id);const t = nowIso();await db.prepare('UPDATE dev_apps SET revoked_at = ?, revoked_by = ? WHERE id = ?').run(t, actor.label, app.id);await db.prepare('UPDATE dev_credentials SET revoked_at = ?, revoked_by = ? WHERE app_id = ? AND revoked_at IS NULL').run(t, actor.label, app.id);await db.prepare('UPDATE dev_auth_codes SET used = 1 WHERE app_id = ?').run(app.id);await audit(db, { projectId: project.id, actor: actor.label, action: 'app.revoked', target: `app:${app.id}`, detail: { reason: reason || null }, ctx,event: { type: 'network.app.revoked', subject: { type: 'app', id: app.id }, payload: { project_id: project.id, environment: app.environment, reason: reason || null } } });++ // Its agents go with it (WS-Z2). Required here, not at the top: agents.js requires this module.++ await require('./agents').revokeWhere(db, 'host_app_id = ?', [app.id], { actor: actor.label, reason: 'app_revoked', ctx });}/** Revoke an app: immediate for new tokens; issued tokens expire within their 5-minute lifetime. */@@@ -650,7 +668,7 @@ async function deleteQuota(db, actor, projectId, capability, { ctx }) {module.exports = {ensureSchema, DevError, ROLES, APP_ID_RE, PROJECT_ID_RE, SECRET_PREFIX,-- audit, listAudit, actorOf, access, memberRole,++ audit, listAudit, actorOf, access, memberRole, lockProject,createProject, listProjects, renameProject, archiveProject, setAllowance, setEnvironmentPolicy, setPlacement, projectView,listMembers, addMember, updateMember, removeMember,listApps, getApp, createApp, updateApp, revokeApp,@@@ -28,6 +28,7 @@ const fs = require('fs');const path = require('path');const { ids, validate, staff } = require('openvibe-contracts');const eventRelay = require('../developer/event-relay');++const agents = require('../developer/agents');const wallet = require('../coins/wallet');const revocation = require('../auth/revocation');const zip = require('../utils/zip');@@@ -197,6 +198,8 @@ async function networkPart(db, userId) {add('developer_projects.json', {owned: (await rowsOf(db, 'dev_projects', 'owner_subject = ?', [u.subject_id])).rows,memberships: (await rowsOf(db, 'dev_project_members', 'subject_id = ?', [u.subject_id], { omit: ['added_by'] })).rows,++ // The agents that act for this person (WS-Z2); who revoked one may be someone else, so revoked_by stays out.++ agents: (await rowsOf(db, 'dev_agents', 'owner_subject = ?', [u.subject_id], { omit: ['revoked_by'] })).rows,});add('account_merges.json', await tableExists(db, 'account_merges')? await db.prepare('SELECT id, from_subject, into_subject, initiated_by, reason, moved, merged_at FROM account_merges WHERE into_user_id = ? ORDER BY merged_at').all(u.id) : []);@@@ -339,6 +342,12 @@ async function erase(db, deletion, { now = Date.now() } = {}) {await del('user_modules', `subject_id IN ${inS}`, subjects, 'modules');await del('user_follows', `follower_subject IN ${inS} OR target_id IN ${inS}`, [...subjects, ...subjects], 'follows');await del('user_blocks', `blocker_subject IN ${inS} OR blocked_subject IN ${inS}`, [...subjects, ...subjects], 'blocks');++ // The person's agents first (WS-Z2), then the agents of the projects archived below.++ // Their projects' locks first, as agent creation takes them (developer/store.lockProject): no agent of theirs is created after this.++ if (await tableExists(db, 'dev_projects')) {++ await db.prepare(`SELECT p.id FROM dev_projects p JOIN dev_project_members m ON m.project_id = p.id WHERE m.subject_id IN ${inS} ORDER BY p.id FOR NO KEY UPDATE OF p`).all(...subjects);++ }++ if (await tableExists(db, 'dev_agents')) count('agents_revoked', await agents.revokeWhere(db, `owner_subject IN ${inS}`, subjects, { actor: 'system:network', reason: 'account_deleted' }));if (await tableExists(db, 'dev_projects')) {for (const p of await db.prepare(`SELECT id FROM dev_projects WHERE owner_subject IN ${inS} AND archived_at IS NULL`).all(...subjects)) {const heir = await db.prepare(`SELECT subject_id FROM dev_project_members WHERE project_id = ? AND subject_id NOT IN ${inS} AND role IN ('owner', 'admin')@@@ -348,6 +357,7 @@ async function erase(db, deletion, { now = Date.now() } = {}) {await db.prepare("UPDATE dev_apps SET revoked_at = ?, revoked_by = 'account_deleted' WHERE id = ?").run(at, a.id);await db.prepare("UPDATE dev_credentials SET revoked_at = ?, revoked_by = 'account_deleted' WHERE app_id = ? AND revoked_at IS NULL").run(at, a.id);}++ if (await tableExists(db, 'dev_agents')) count('agents_revoked', await agents.revokeWhere(db, 'project_id = ?', [p.id], { actor: 'system:network', reason: 'project_archived' }));await db.prepare("UPDATE dev_projects SET archived_at = ?, archived_by = 'account_deleted' WHERE id = ?").run(at, p.id);count('projects_archived', 1);}nnew file mode 100644@@@ -0,0 +1,71 @@++'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++// migrations by id but refuses a pending file numbered below one already applied.++// node test/agent-schema.test.js++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const { createTestDb } = require('openvibe-sdk/testing');++const { createDb } = require('openvibe-sdk/db');++++const MIGRATIONS = path.join(__dirname, '..', 'migrations');++const quiet = { log() {}, warn() {}, error() {} };++++(async () => {++ // A copy of the migrations up to 0015: what production ran before this release.++ const before = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-agent-schema-'));++ for (const f of fs.readdirSync(MIGRATIONS)) if (/^\d{4}_.*\.sql$/.test(f) && f.slice(0, 4) <= '0015') fs.copyFileSync(path.join(MIGRATIONS, f), path.join(before, f));++ assert.ok(fs.existsSync(path.join(MIGRATIONS, '0016_agents.sql')));++ assert.ok(!fs.readdirSync(MIGRATIONS).some((f) => /^001[0-3]_/.test(f)), '0010-0013 stay unused');++++ const t = await createTestDb({ migrations: before, store: process.env.NETWORK_TEST_STORE || 'pglite', service: 'network', log: quiet });++ // Schema changes run as the owner (DATABASE_DIRECT_URL), as server/db/database.js does; PGlite has one role.++ const asOwner = async (fn) => {++ if (!t.directUrl) return fn(t.db);++ const owner = createDb({ url: t.directUrl, service: 'network-test-owner', max: 1, log: quiet });++ try { return await fn(owner); } finally { await owner.close(); }++ };++ const applied = async () => (await t.db.prepare('SELECT id FROM ov_migrations ORDER BY id').all()).map((r) => r.id);++ const hasAgents = async () => !!await t.db.prepare("SELECT 1 AS ok FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = 'dev_agents'").get();++ try {++ assert.strictEqual((await applied()).pop(), '0015');++ assert.ok(!await hasAgents());++++ // Step 0: a file numbered below an applied one is refused, never silently skipped.++ const probe = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-agent-probe-'));++ for (const f of fs.readdirSync(before)) fs.copyFileSync(path.join(before, f), path.join(probe, f));++ fs.copyFileSync(path.join(MIGRATIONS, '0016_agents.sql'), path.join(probe, '0010_agents.sql'));++ await assert.rejects(asOwner((db) => db.migrate({ dir: probe, log: quiet })), /0010_agents\.sql is older than applied migration 001[45]/);++ assert.ok(!await hasAgents(), 'the refused file left nothing behind');++++ // 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.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')));++++ // 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.++ const prj = `prj_${'1'.repeat(26)}`, other = `prj_${'2'.repeat(26)}`, app = `app_${'3'.repeat(26)}`, usr = `usr_${'4'.repeat(26)}`;++ for (const p of [prj, other]) {++ await t.db.prepare("INSERT INTO dev_projects (id, owner_subject, name, environment_policy, allowance, created_at, created_by) VALUES (?, ?, 'p', 'sandbox', '[]', ?, 'test')").run(p, usr, new Date().toISOString());++ }++ await t.db.prepare("INSERT INTO dev_apps (id, project_id, name, environment, oauth_client_id, client_type, created_at, created_by) VALUES (?, ?, 'a', 'sandbox', ?, 'public', ?, 'test')").run(app, prj, app, new Date().toISOString());++ const agent = (id, projectId, env) => t.db.prepare("INSERT INTO dev_agents (id, project_id, owner_subject, host_kind, host_app_id, environment, name, created_by) VALUES (?, ?, ?, 'app', ?, ?, 'n', 'test')")++ .run(id, projectId, usr, app, env);++ await agent(`agt_${'5'.repeat(26)}`, prj, 'sandbox');++ const r = await t.db.prepare('SELECT created_at, updated_at, status FROM dev_agents').get();++ assert.match(r.created_at, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);++ 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');++ console.log('agent schema: all tests passed');++ } finally {++ await t.close();++ }++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,184 @@++'use strict';++// Agents under concurrent writers (plan T2 WS-Z2 slice 2, server/developer/agents.js): one transaction is held open++// at a chosen point while a competing request runs against it, then let go. On PostgreSQL (npm run test:pg) the++// competing request really runs and must wait on a lock; on PGlite, one connection, it waits its turn, so the same++// assertions hold there too. Covers creation against an app revocation, a member's removal and a project archive++// (each order), and resume against a revoke (a person's, and an app revocation's cascade).++// node test/agents-concurrency.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 agents = require('../server/developer/agents');++const store = require('../server/developer/store');++++(async () => {++const log = console.log; console.log = () => {};++const db = getDb();++console.log = log;++const PG = process.env.NETWORK_TEST_STORE === 'pg';++const USERS = [[10, 'owner'], [11, 'dev'], [12, 'leaver'], [13, 'stayer']];++await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES ${USERS.map(([id, n]) => `(${id}, '${n}', 'x', 'user')`).join(', ')}`).run();++await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris) VALUES ('actor', 'x', 'OpenVibe.Actor', '[]')").run();++const S = {};++for (const [id, name] of USERS) 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: '', 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 USERS) T[name] = jwt.sign({ sub: id, id }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });++++// Hold the first transaction that reaches `point` (agents.revokeWhere or store.audit, matched by `when`) until the++// competing request is waiting on it: on PostgreSQL, until another session is seen waiting on a lock, which must++// happen within 10 s and before the competing request finishes (else the interleaving was not tested and this++// fails); on PGlite, briefly. The hold is always let go, so a failure never hangs the test.++const realRevokeWhere = agents.revokeWhere;++const realAudit = store.audit;++const sleep = (ms) => new Promise((r) => setTimeout(r, ms));++async function interleave({ point, when, first, second }) {++ let entered, release;++ const reached = new Promise((r) => { entered = r; });++ const gate = new Promise((r) => { release = r; });++ let armed = true;++ const hold = async (args) => { if (armed && when(...args)) { armed = false; entered(); await gate; } };++ if (point === 'revokeWhere') agents.revokeWhere = async (...a) => { const n = await realRevokeWhere(...a); await hold([a[3]]); return n; };++ else store.audit = async (...a) => { await realAudit(...a); await hold([a[1]]); };++ let one, two;++ try {++ one = first();++ await Promise.race([reached, one.then(() => { throw new Error('the first request finished without reaching the hold point'); })]);++ let settled = false;++ two = second();++ two.then(() => { settled = true; }, () => { settled = true; });++ if (PG) {++ const until = Date.now() + 10000;++ for (;;) {++ const waiting = (await db.prepare("SELECT COUNT(*) AS n FROM pg_stat_activity WHERE datname = current_database() AND pid <> pg_backend_pid() AND wait_event_type = 'Lock'").get()).n;++ if (Number(waiting) > 0) break;++ assert.ok(!settled, 'the competing request finished without waiting on the held transaction');++ assert.ok(Date.now() < until, 'the competing request never waited on a lock held by the first transaction');++ await sleep(20);++ }++ } else await sleep(100);++ release();++ return await Promise.all([one, two]);++ } catch (err) {++ release();++ await Promise.allSettled([one, two].filter(Boolean));++ throw err;++ } finally {++ agents.revokeWhere = realRevokeWhere;++ store.audit = realAudit;++ }++}++++(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: { authorization: `Bearer ${T[who]}`, ...(body ? { 'content-type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined });++ const text = await r.text();++ return { status: r.status, body: text ? JSON.parse(text) : null };++ };++ const ok = (r, status = 200) => { assert.strictEqual(r.status, status, JSON.stringify(r.body)); return r.body; };++ const refused = (r, status, code) => { assert.strictEqual(r.status, status, JSON.stringify(r.body)); assert.strictEqual(r.body.code, code); };++ const row = async (id) => await db.prepare('SELECT * FROM dev_agents WHERE id = ?').get(id);++ const live = async (where, ...params) => (await db.prepare(`SELECT COUNT(*) AS n FROM dev_agents WHERE status <> 'revoked' AND ${where}`).get(...params)).n;++ const project = async (name) => {++ const id = ok(await api('owner', 'POST', '', { name }), 201).id;++ for (const u of ['dev', 'leaver', 'stayer']) ok(await api('owner', 'POST', `/${id}/members`, { username: u, role: 'developer' }), 201);++ return id;++ };++ const sandboxApp = async (p, name) => ok(await api('dev', 'POST', `/${p}/apps`, { name, environment: 'sandbox' }), 201).id;++ const actorHost = { type: 'service', id: 'actor' };++ try {++ const P = await project('Concurrent');++++ // ── Creation against an app's revocation ──++ // The revocation first: the creation that read the app as live re-checks it and finds it revoked.++ const A1 = await sandboxApp(P, 'A1');++ let [rev, made] = await interleave({ point: 'revokeWhere', when: (o) => o.reason === 'app_revoked',++ first: () => api('dev', 'DELETE', `/${P}/apps/${A1}`), second: () => api('dev', 'POST', `/${P}/agents`, { name: 'Late', host: { type: 'app', id: A1 } }) });++ ok(rev);++ refused(made, 404, 'app.not_found');++ assert.strictEqual(await live('host_app_id = ?', A1), 0, 'no live agent on a revoked app');++ // The creation first: the revocation waits for it and takes the new agent with the app.++ const A2 = await sandboxApp(P, 'A2');++ [made, rev] = await interleave({ point: 'audit', when: (e) => e.action === 'agent.created',++ first: () => api('dev', 'POST', `/${P}/agents`, { name: 'Early', host: { type: 'app', id: A2 } }), second: () => api('dev', 'DELETE', `/${P}/apps/${A2}`) });++ ok(rev);++ assert.deepStrictEqual([(await row(ok(made, 201).id)).status, (await row(made.body.id)).revoked_by], ['revoked', 'app_revoked']);++++ // ── Creation against a member's removal ──++ let removed;++ [removed, made] = await interleave({ point: 'revokeWhere', when: (o) => o.reason === 'member_removed',++ first: () => api('owner', 'DELETE', `/${P}/members/${S.leaver}`), second: () => api('leaver', 'POST', `/${P}/agents`, { name: 'Late', host: actorHost }) });++ ok(removed, 204);++ refused(made, 404, 'project.not_found');++ assert.strictEqual(await live('project_id = ? AND owner_subject = ?', P, S.leaver), 0, 'no live agent of a removed member');++ [made, removed] = await interleave({ point: 'audit', when: (e) => e.action === 'agent.created',++ first: () => api('stayer', 'POST', `/${P}/agents`, { name: 'Early', host: actorHost }), second: () => api('owner', 'DELETE', `/${P}/members/${S.stayer}`) });++ ok(removed, 204);++ assert.deepStrictEqual([(await row(ok(made, 201).id)).status, (await row(made.body.id)).revoked_by], ['revoked', 'member_removed']);++++ // ── Creation against the project's archive ──++ const P2 = await project('Archived first');++ let archived;++ [archived, made] = await interleave({ point: 'revokeWhere', when: (o) => o.reason === 'project_archived',++ first: () => api('owner', 'POST', `/${P2}/archive`), second: () => api('dev', 'POST', `/${P2}/agents`, { name: 'Late', host: actorHost }) });++ ok(archived);++ refused(made, 409, 'project.archived');++ assert.strictEqual(await live('project_id = ?', P2), 0, 'no live agent in an archived project');++ const P3 = await project('Created first');++ [made, archived] = await interleave({ point: 'audit', when: (e) => e.action === 'agent.created',++ first: () => api('dev', 'POST', `/${P3}/agents`, { name: 'Early', host: actorHost }), second: () => api('owner', 'POST', `/${P3}/archive`) });++ ok(archived);++ assert.deepStrictEqual([(await row(ok(made, 201).id)).status, (await row(made.body.id)).revoked_by], ['revoked', 'project_archived']);++++ // ── Resume against revoke ──++ // A revoke in flight is never undone by a resume that read the agent as paused.++ const paused = async (host) => {++ const a = ok(await api('dev', 'POST', `/${P}/agents`, { name: 'Paused', host }), 201);++ ok(await api('dev', 'POST', `/${P}/agents/${a.id}/pause`));++ return a.id;++ };++ let g = await paused(actorHost), resumed;++ [rev, resumed] = await interleave({ point: 'audit', when: (e) => e.action === 'agent.revoked',++ first: () => api('owner', 'DELETE', `/${P}/agents/${g}`), second: () => api('dev', 'POST', `/${P}/agents/${g}/resume`) });++ ok(rev);++ refused(resumed, 409, 'agent.revoked');++ assert.deepStrictEqual([(await row(g)).status, !!(await row(g)).revoked_at], ['revoked', true]);++ // The same against an app revocation's cascade.++ const A3 = await sandboxApp(P, 'A3');++ g = await paused({ type: 'app', id: A3 });++ [rev, resumed] = await interleave({ point: 'revokeWhere', when: (o) => o.reason === 'app_revoked',++ first: () => api('dev', 'DELETE', `/${P}/apps/${A3}`), second: () => api('dev', 'POST', `/${P}/agents/${g}/resume`) });++ ok(rev);++ refused(resumed, 409, 'agent.revoked');++ assert.deepStrictEqual([(await row(g)).status, (await row(g)).revoked_by], ['revoked', 'app_revoked']);++ // The resume first: the revoke waits for it and still wins.++ g = await paused(actorHost);++ [resumed, rev] = await interleave({ point: 'audit', when: (e) => e.action === 'agent.resumed',++ first: () => api('dev', 'POST', `/${P}/agents/${g}/resume`), second: () => api('owner', 'DELETE', `/${P}/agents/${g}`) });++ assert.strictEqual(ok(resumed).status, 'active');++ assert.strictEqual(ok(rev).status, 'revoked');++ assert.strictEqual((await row(g)).status, 'revoked');++ const trail = (await db.prepare('SELECT action FROM dev_audit WHERE target = ? ORDER BY id').all(`agent:${g}`)).map((a) => a.action);++ assert.deepStrictEqual(trail, ['agent.created', 'agent.paused', 'agent.resumed', 'agent.revoked']);++++ console.log(`agents-concurrency (${PG ? 'PostgreSQL' : 'PGlite'}): all tests passed`);++ } finally {++ server.close();++ }++})().catch((err) => { console.error(err); process.exit(1); });++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,197 @@++'use strict';++// Agents (plan T2 WS-Z2 slice 2, docs/t2-projects-and-grants.md; server/developer/agents.js): who may create,++// rename, pause, resume and revoke; an agent is only ever its creator's; AGENT_HOST_SERVICES; an app of another++// project is not a host (404, and the composite foreign key); the CHECK constraints; and the cascades from an app++// revocation, a member's removal, an account erasure and a project archive.++// node test/agents.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 accountData = require('../server/identity/account-data');++++(async () => {++const log = console.log; console.log = () => {};++const db = getDb();++console.log = log;++await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES++ (10, 'owner', 'x', 'user'), (11, 'dev', 'x', 'user'), (12, 'viewer', 'x', 'user'), (13, 'stranger', 'x', 'user'),++ (14, 'staff', 'x', 'admin'), (15, 'admin', 'x', 'user'), (16, 'leaver', 'x', 'user'), (17, 'erased', 'x', 'user')`).run();++await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris) VALUES ('actor', 'x', 'OpenVibe.Actor', '[]')").run();++const S = {};++for (const [id, name] of [[10, 'owner'], [11, 'dev'], [12, 'viewer'], [13, 'stranger'], [14, 'staff'], [15, 'admin'], [16, 'leaver'], [17, 'erased']]) {++ 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;++// 'ghost' is listed but is no OAuth client; 'live' is an OAuth client but not listed. Neither hosts agents.++app.locals.config = { baseUrl: ISSUER, jwt: { issuer: ISSUER, accessTokenExpiry: '1h' }, developer: { sandboxAllowance: '', agentHostServices: 'actor,ghost' } };++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 [[10, 'owner'], [11, 'dev'], [12, 'viewer'], [13, 'stranger'], [14, 'staff'], [15, 'admin'], [16, 'leaver'], [17, 'erased']]) {++ 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 row = async (id) => await db.prepare('SELECT * FROM dev_agents WHERE id = ?').get(id);++ try {++ // ── A project with every role, a sandbox and a production app, and another project ──++ const P = ok(await api('owner', 'POST', '', { name: 'Agents' }), 201).id;++ for (const [name, role] of [['dev', 'developer'], ['viewer', 'viewer'], ['admin', 'admin'], ['leaver', 'developer'], ['erased', 'developer']]) {++ ok(await api('owner', 'POST', `/${P}/members`, { username: name, role }), 201);++ }++ ok(await api('staff', 'PUT', `/${P}/environment-policy`, { environment_policy: 'sandbox+production' }));++ const SB = ok(await api('dev', 'POST', `/${P}/apps`, { name: 'Bot sandbox', environment: 'sandbox' }), 201).id;++ const PR = ok(await api('owner', 'POST', `/${P}/apps`, { name: 'Bot', environment: 'production' }), 201).id;++ const Q = ok(await api('stranger', 'POST', '', { name: 'Elsewhere' }), 201).id;++ const QA = ok(await api('stranger', 'POST', `/${Q}/apps`, { name: 'Theirs', environment: 'sandbox' }), 201).id;++ const sandboxHost = { type: 'app', id: SB };++++ // ── Authentication: user tokens only ──++ refused(await api(null, 'GET', `/${P}/agents`), 401, 'auth.required');++++ // ── Create: for yourself, by role and host ──++ refused(await api('viewer', 'POST', `/${P}/agents`, { name: 'x', host: sandboxHost }), 403, 'project.forbidden');++ refused(await api('staff', 'POST', `/${P}/agents`, { name: 'x', host: sandboxHost }), 403, 'project.forbidden');++ refused(await api('stranger', 'POST', `/${P}/agents`, { name: 'x', host: sandboxHost }), 404, 'project.not_found');++ refused(await api('dev', 'POST', `/${P}/agents`, { name: 'x', host: { type: 'app', id: PR } }), 403, 'project.forbidden');++ refused(await api('dev', 'POST', `/${P}/agents`, { name: 'x', host: sandboxHost, owner: { type: 'user', id: S.owner } }), 403, 'agent.forbidden');++ refused(await api('admin', 'POST', `/${P}/agents`, { name: 'x', host: sandboxHost, owner: S.dev }), 403, 'agent.forbidden');++ // Another project's app is answered exactly like an unknown id, so app ids are not probed.++ refused(await api('dev', 'POST', `/${P}/agents`, { name: 'x', host: { type: 'app', id: QA } }), 404, 'app.not_found');++ refused(await api('dev', 'POST', `/${P}/agents`, { name: 'x', host: { type: 'app', id: `app_${'0'.repeat(26)}` } }), 404, 'app.not_found');++ refused(await api('dev', 'POST', `/${P}/agents`, { name: 'x', host: { type: 'service', id: 'live' } }), 422, 'agent.host_not_allowed');++ refused(await api('dev', 'POST', `/${P}/agents`, { name: 'x', host: { type: 'service', id: 'ghost' } }), 422, 'agent.host_not_allowed');++ refused(await api('dev', 'POST', `/${P}/agents`, { name: 'x', host: { type: 'mod', id: 'mod_x' } }), 422, 'agent.invalid');++ refused(await api('dev', 'POST', `/${P}/agents`, { name: ' ', host: sandboxHost }), 422, 'agent.invalid');++++ const devBot = ok(await api('dev', 'POST', `/${P}/agents`, { name: 'Release bot', host: sandboxHost, owner: { type: 'user', id: S.dev } }), 201);++ assert.match(devBot.id, /^agt_[0-9A-HJKMNP-TV-Z]{26}$/);++ for (const ref of [devBot.subject, devBot.owner, devBot.host]) assert.ok(validate('identity.subject-ref@1', ref).valid, JSON.stringify(ref));++ assert.deepStrictEqual([devBot.project_id, devBot.owner.id, devBot.environment, devBot.status, devBot.revoked_at], [P, S.dev, 'sandbox', 'active', null]);++ const adminBot = ok(await api('admin', 'POST', `/${P}/agents`, { name: 'Prod bot', host: { type: 'app', id: PR } }), 201);++ assert.strictEqual(adminBot.environment, 'production');++ const actorBot = ok(await api('dev', 'POST', `/${P}/agents`, { name: 'Actor helper', host: { type: 'service', id: 'actor' } }), 201);++ assert.deepStrictEqual([actorBot.host, actorBot.environment], [{ type: 'service', id: 'actor' }, 'production']);++ assert.ok(validate('identity.subject-ref@1', actorBot.host).valid);++ assert.strictEqual((await row(devBot.id)).created_by, `user:${S.dev}`);++++ // ── Read: members and staff; nobody else, and never through another project ──++ assert.strictEqual(ok(await api('viewer', 'GET', `/${P}/agents`)).agents.length, 3);++ assert.deepStrictEqual(ok(await api('dev', 'GET', `/${P}/agents?owner=me`)).agents.map((a) => a.id).sort(), [devBot.id, actorBot.id].sort());++ assert.strictEqual(ok(await api('staff', 'GET', `/${P}/agents`)).agents.length, 3);++ refused(await api('stranger', 'GET', `/${P}/agents`), 404, 'project.not_found');++ refused(await api('stranger', 'GET', `/${Q}/agents/${devBot.id}`), 404, 'agent.not_found');++ assert.deepStrictEqual(ok(await api('viewer', 'GET', `/${P}/agents/${devBot.id}`)), devBot);++++ // ── Rename: the owner or an admin+ ──++ refused(await api('viewer', 'PATCH', `/${P}/agents/${devBot.id}`, { name: 'Mine now' }), 403, 'agent.forbidden');++ refused(await api('staff', 'PATCH', `/${P}/agents/${devBot.id}`, { name: 'Staff' }), 403, 'project.forbidden');++ refused(await api('leaver', 'PATCH', `/${P}/agents/${devBot.id}`, { name: 'Other dev' }), 403, 'agent.forbidden');++ assert.strictEqual(ok(await api('admin', 'PATCH', `/${P}/agents/${devBot.id}`, { name: 'Renamed by admin' })).name, 'Renamed by admin');++ assert.strictEqual(ok(await api('dev', 'PATCH', `/${P}/agents/${devBot.id}`, { name: 'Release bot' })).name, 'Release bot');++++ // ── Pause and resume: staff may pause but not resume; the owner lifts a staff pause ──++ refused(await api('viewer', 'POST', `/${P}/agents/${devBot.id}/pause`), 403, 'agent.forbidden');++ assert.strictEqual(ok(await api('staff', 'POST', `/${P}/agents/${devBot.id}/pause`)).status, 'paused');++ assert.strictEqual(ok(await api('staff', 'POST', `/${P}/agents/${devBot.id}/pause`)).status, 'paused', 'pausing twice is a no-op');++ refused(await api('staff', 'POST', `/${P}/agents/${devBot.id}/resume`), 403, 'project.forbidden');++ refused(await api('leaver', 'POST', `/${P}/agents/${devBot.id}/resume`), 403, 'agent.forbidden');++ assert.strictEqual(ok(await api('dev', 'POST', `/${P}/agents/${devBot.id}/resume`)).status, 'active');++ assert.strictEqual(ok(await api('admin', 'POST', `/${P}/agents/${devBot.id}/pause`)).status, 'paused');++ assert.strictEqual(ok(await api('admin', 'POST', `/${P}/agents/${devBot.id}/resume`)).status, 'active');++++ // ── Revoke: final ──++ const doomed = ok(await api('dev', 'POST', `/${P}/agents`, { name: 'Doomed', host: sandboxHost }), 201);++ refused(await api('viewer', 'DELETE', `/${P}/agents/${doomed.id}`), 403, 'agent.forbidden');++ const gone = ok(await api('staff', 'DELETE', `/${P}/agents/${doomed.id}`));++ assert.strictEqual(gone.status, 'revoked'); assert.ok(gone.revoked_at);++ assert.strictEqual((await row(doomed.id)).revoked_by, `user:${S.staff}`);++ assert.deepStrictEqual(ok(await api('dev', 'DELETE', `/${P}/agents/${doomed.id}`)), gone, 'revoking twice answers the same agent');++ refused(await api('dev', 'POST', `/${P}/agents/${doomed.id}/resume`), 409, 'agent.revoked');++ refused(await api('dev', 'POST', `/${P}/agents/${doomed.id}/pause`), 409, 'agent.revoked');++ refused(await api('dev', 'PATCH', `/${P}/agents/${doomed.id}`, { name: 'Back' }), 409, 'agent.revoked');++++ const actions = (await db.prepare("SELECT action, actor FROM dev_audit WHERE target = ? ORDER BY id").all(`agent:${doomed.id}`)).map((a) => `${a.action} ${a.actor}`);++ assert.deepStrictEqual(actions, [`agent.created user:${S.dev}`, `agent.revoked user:${S.staff}`]);++ assert.ok(!(await db.prepare("SELECT event FROM dev_audit WHERE action LIKE 'agent.%' AND event IS NOT NULL").get()), 'no agent event until Contracts has a payload');++++ // ── The database refuses what the routes never write ──++ const id = () => `agt_${crypto.randomBytes(20).toString('hex').toUpperCase().replace(/[^0-9A-HJKMNP-TV-Z]/g, '0').slice(0, 26)}`;++ const insert = (r) => db.prepare(`INSERT INTO dev_agents (id, project_id, owner_subject, host_kind, host_app_id, host_service, environment, name, status, created_by, revoked_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(r.id || id(), r.project_id || P, S.dev, r.host_kind || 'app', r.host_app_id === undefined ? SB : r.host_app_id,++ r.host_service || null, r.environment || 'sandbox', 'x', r.status || 'active', 'test', r.revoked_at || null);++ const rejects = async (r, why) => { await assert.rejects(insert(r), undefined, why); };++ await insert({});++ await rejects({ host_app_id: null }, 'an app host needs host_app_id');++ await rejects({ host_kind: 'service', host_app_id: null, host_service: 'actor', environment: 'sandbox' }, 'a service host is production');++ await rejects({ host_kind: 'service', host_app_id: SB, host_service: 'actor', environment: 'sandbox' }, 'a service host has no app');++ await rejects({ status: 'revoked' }, 'revoked needs revoked_at');++ await rejects({ revoked_at: new Date().toISOString() }, 'revoked_at needs revoked');++ await rejects({ host_app_id: QA }, 'an app of another project (composite foreign key)');++ await rejects({ host_app_id: SB, environment: 'production' }, 'an app of the other environment (composite foreign key)');++ await rejects({ id: 'agt_lowercase0000000000000000000' }, 'not a Crockford ULID');++ await rejects({ id: `agx_${'0'.repeat(26)}` }, 'wrong prefix');++ await rejects({ host_kind: 'service', host_app_id: null, host_service: 'nobody', environment: 'production' }, 'a service host is an OAuth client');++++ // ── Cascades ──++ // Revoking the app revokes the agents it hosts, and only those.++ const sbBot = ok(await api('dev', 'POST', `/${P}/agents`, { name: 'On SB', host: sandboxHost }), 201);++ ok(await api('dev', 'DELETE', `/${P}/apps/${SB}`));++ for (const a of [devBot, sbBot]) assert.deepStrictEqual([(await row(a.id)).status, (await row(a.id)).revoked_by], ['revoked', 'app_revoked'], a.name);++ assert.strictEqual((await row(actorBot.id)).status, 'active');++ refused(await api('dev', 'POST', `/${P}/agents`, { name: 'x', host: sandboxHost }), 404, 'app.not_found');++ assert.ok(await db.prepare("SELECT 1 FROM dev_audit WHERE action = 'agent.revoked' AND target = ? AND detail LIKE '%app_revoked%'").get(`agent:${sbBot.id}`));++++ // A member who leaves (or is removed) takes their agents in that project with them.++ const leaverBot = ok(await api('leaver', 'POST', `/${P}/agents`, { name: 'Leaver', host: { type: 'service', id: 'actor' } }), 201);++ ok(await api('admin', 'DELETE', `/${P}/members/${S.leaver}`), 204);++ assert.deepStrictEqual([(await row(leaverBot.id)).status, (await row(leaverBot.id)).revoked_by], ['revoked', 'member_removed']);++ assert.strictEqual((await row(actorBot.id)).status, 'active');++++ // An account erasure revokes the person's agents; their export lists them first, without who revoked one.++ const erasedBot = ok(await api('erased', 'POST', `/${P}/agents`, { name: 'Erased', host: { type: 'service', id: 'actor' } }), 201);++ const part = await accountData.networkPart(db, 17);++ const dev = part.files.find((f) => f.name === 'developer_projects.json').content;++ assert.deepStrictEqual(dev.agents.map((a) => [a.id, a.owner_subject, a.status]), [[erasedBot.id, S.erased, 'active']]);++ assert.ok(dev.agents.every((a) => !('revoked_by' in a)));++ 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());++ const erased = await accountData.erase(db, await db.prepare('SELECT * FROM account_deletions WHERE id = ?').get(delId));++ assert.strictEqual(erased.agents_revoked, 1);++ assert.deepStrictEqual([(await row(erasedBot.id)).status, (await row(erasedBot.id)).revoked_by], ['revoked', 'account_deleted']);++ assert.strictEqual((await row(actorBot.id)).status, 'active');++++ // Archiving the project revokes every agent left, each with the archive as its cause.++ ok(await api('dev', 'POST', `/${P}/agents/${actorBot.id}/pause`));++ ok(await api('owner', 'POST', `/${P}/archive`));++ for (const a of [actorBot, adminBot]) assert.deepStrictEqual([(await row(a.id)).status, (await row(a.id)).revoked_by], ['revoked', 'project_archived'], a.name);++ refused(await api('owner', 'POST', `/${P}/agents`, { name: 'x', host: { type: 'service', id: 'actor' } }), 409, 'project.archived');++ assert.strictEqual(ok(await api('viewer', 'GET', `/${P}/agents`)).agents.filter((a) => a.status !== 'revoked').length, 0);++ assert.strictEqual(ok(await api('owner', 'DELETE', `/${P}/agents/${adminBot.id}`)).status, 'revoked', 'revoking in an archived project answers the agent');++++ console.log('agents: all tests passed');++ } finally {++ server.close();++ }++})().catch((err) => { console.error(err); process.exit(1); });++})().catch((err) => { console.error(err); process.exit(1); });
@@ -38,6 +38,9 @@ DEV_CREDENTIAL_OVERLAP_S=86400
@@ -38,6 +38,9 @@ DEV_CREDENTIAL_OVERLAP_S=86400
DEV_DEFAULT_ALLOWANCE=
DEV_DEFAULT_ALLOWANCE=
DEV_MAX_PROJECTS_PER_OWNER=10
DEV_MAX_PROJECTS_PER_OWNER=10
DEV_MAX_APPS_PER_PROJECT=20
DEV_MAX_APPS_PER_PROJECT=20
+# First-party services (OAuth client ids) that may host a project member's agents, comma-separated
+# (docs/t2-projects-and-grants.md). Leave it UNSET for the code default (actor); empty = none.
+# AGENT_HOST_SERVICES=actor
# OpenVibe.Events base URL for relaying developer-project events (network.app.*, network.credential.*,
# OpenVibe.Events base URL for relaying developer-project events (network.app.*, network.credential.*,
# network.grant.changed). Unset = no relay (events stay in dev_audit and are backfilled once it is set).
# network.grant.changed). Unset = no relay (events stay in dev_audit and are backfilled once it is set).
# OV_EVENTS_INTERNAL_URL=http://127.0.0.1:4300
# OV_EVENTS_INTERNAL_URL=http://127.0.0.1:4300
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+{
+ "id": "network.confirmation.manage",
+ "version": "1.0",
+ "owner": "network",
+ "status": "planned",
+ "visibility": "first-party",
+ "description": "Create, read, consume and cancel the confirmation requests (network.confirmation-request@1) of the agents that use this service's sensitive capabilities (roadmap WS-Z2, docs/t2-projects-and-grants.md section 5). The owning service writes the summary and the request digest; Network derives the owner from the agent and re-checks the agent, its grant and its host's authority on consume. A service only ever sees confirmations whose audience is its own. Never grantable to apps or agents.",
+ "permissions": [
+ "confirmations:write",
+ "confirmations:read"
+ ],
+ "resourceConstraints": [
+ "confirmation"
+ ],
+ "quotaClass": "confirmations",
+ "events": [],
+ "implementedBy": [
+ "POST /internal/confirmations (planned)",
+ "GET /internal/confirmations/:id (planned)",
+ "POST /internal/confirmations/:id/consume (planned)",
+ "POST /internal/confirmations/:id/cancel (planned)"
+ ]
+}
@@ -54,3 +54,27 @@ File: `network.service-manifest.json`. It adds the two capabilities, and it adds
@@ -54,3 +54,27 @@ File: `network.service-manifest.json`. It adds the two capabilities, and it adds
- `network.grant.changed`
- `network.grant.changed`
Payloads are in `../developer-projects.md#events`. They are recorded in Network's audit as envelopes but not relayed to Events yet.
Payloads are in `../developer-projects.md#events`. They are recorded in Network's audit as envelopes but not relayed to Events yet.
+
+# Agents and confirmations (plan T2 WS-Z2)
+
+Changes the agents of `../t2-projects-and-grants.md` need beyond the pinned openvibe-contracts **v0.85.0**. Sections 1–4 above are published (v0.85.0 has the app claims, `prj`, `partner` and the two project capabilities). Everything below is additive. Until it is released, Network builds agents, delegated grants and the owner side of confirmations, but mints no agent token and serves no `/internal/confirmations` route.
+
+## 7. `identity.service-token-claims@1`: agent claims
+
+File: `identity.service-token-claims.v1.json`, the full proposed schema (the v0.85.0 schema plus these).
+
+- `sub` gains `agent:agt_<ULID>` (as `ids.principalSub({ type: 'agent', id })` already returns), `actor_type` gains `agent`.
+- New optional `cap_confirm`: exact capability ids the agent may use only with an approved confirmation (or a standing rule). `cap` holds only the delegated grants whose effective mode is `auto`; the two lists never overlap.
+- New optional `act` (`{ sub }`, RFC 8693): the host that minted the token, `app:app_…` or `svc:<name>`.
+- `on_behalf_of` also describes the agent's owner.
+- New `allOf` rule: when `actor_type` is `agent`, `project_id`, `env` and `on_behalf_of` are required.
+
+Add fixtures: a valid agent token with `cap_confirm`, and an invalid one without `on_behalf_of`. `serviceAuth` should treat a `cap_confirm` capability as not granted by `requireCapability` (the receiver checks the confirmation instead).
+
+## 8. Event payload `network.confirmation.changed@1`
+
+File: `network.confirmation.changed.v1.json`. One payload for every transition of a confirmation request (`created`, `approved`, `denied`, `expired`, `cancelled`, `used`), with the agent, project, capability, audience and new state. It never carries `summary` or `details`. Add `network.confirmation.changed` to the Network manifest's `eventsProduced` with it.
+
+## 9. Capability `network.confirmation.manage`
+
+In `../capabilities-proposal/network.confirmation.manage.json`: first-party, planned. What an owning service holds to create, read, consume and cancel the confirmations of its own sensitive capabilities (`POST /internal/confirmations`, `GET /internal/confirmations/:id`, `POST …/:id/consume`, `POST …/:id/cancel`).
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/identity/service-token-claims.v1.json",
"$id": "https://openvibe.network/contracts/identity/service-token-claims.v1.json",
"title": "ServiceTokenClaims",
"title": "ServiceTokenClaims",
- "description": "Claims of a short-lived RS256 client-credentials token issued by OpenVibe.Network to a service or app principal. Replaces X-Internal-Key. App tokens (actor_type app) also carry project_id and env; receivers refuse env=sandbox unless they opted in.",
+ "description": "Claims of a short-lived RS256 client-credentials token issued by OpenVibe.Network to a service, app, mod, node or agent principal. Replaces X-Internal-Key. App tokens (actor_type app) also carry project_id and env; receivers refuse env=sandbox unless they opted in. Agent tokens (actor_type agent; roadmap WS-Z2, docs/t2-projects-and-grants.md) are minted for an agent by its host and also carry project_id, env and on_behalf_of (the owner), and act (the host).",
"type": "object",
"type": "object",
"required": [
"required": [
"iss",
"iss",
@@ -23,13 +23,15 @@
@@ -23,13 +23,15 @@
},
},
"sub": {
"sub": {
"type": "string",
"type": "string",
- "pattern": "^(svc:[a-z][a-z0-9-]{1,39}|app:app_[0-9A-HJKMNP-TV-Z]{26}|mod:mod_[0-9A-HJKMNP-TV-Z]{26})$"
+ "pattern": "^(svc:[a-z][a-z0-9-]{1,39}|app:app_[0-9A-HJKMNP-TV-Z]{26}|mod:mod_[0-9A-HJKMNP-TV-Z]{26}|node:nod_[0-9A-HJKMNP-TV-Z]{26}|agent:agt_[0-9A-HJKMNP-TV-Z]{26})$"
},
},
"actor_type": {
"actor_type": {
"enum": [
"enum": [
"service",
"service",
"app",
"app",
- "mod"
+ "mod",
+ "node",
+ "agent"
]
]
},
},
"aud": {
"aud": {
@@ -46,14 +48,14 @@
@@ -46,14 +48,14 @@
"type": "string",
"type": "string",
"pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_*]+)+$"
"pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_*]+)+$"
},
},
- "description": "Granted capability ids; a trailing .* grants a family."
+ "description": "Granted capability ids; a trailing .* grants a family. For an agent: only the delegated grants whose effective mode is auto."
},
},
"ns": {
"ns": {
"type": "array",
"type": "array",
"items": {
"items": {
"type": "string"
"type": "string"
},
},
- "description": "Namespace constraints, e.g. live.* or mod.example.*"
+ "description": "Namespace constraints, e.g. live.* or mod.example.*. A developer app token carries its project_id (the whole project, as issued before namespaces were rows) and app.<project_id>.*: on Media, app.<project_id> and app.<project_id>.sandbox are the project's production and sandbox namespaces, with children below them."
},
},
"iat": {
"iat": {
"type": "integer"
"type": "integer"
@@ -80,7 +82,30 @@
@@ -80,7 +82,30 @@
"on_behalf_of": {
"on_behalf_of": {
"type": "string",
"type": "string",
"pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$",
"pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$",
- "description": "The person who authorized an app through the authorization-code flow. Absent on client_credentials tokens."
+ "description": "The person an app or agent acts for: on an app token, the person who authorized it through the authorization-code flow (absent on client_credentials tokens); on an agent token, the agent's owner (always present)."
+ },
+ "cap_confirm": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)+$"
+ },
+ "description": "Agent tokens only: delegated capabilities whose every use needs an approved confirmation request (network.confirmation-request@1) or a standing rule. Exact ids, never a family. Disjoint from cap. A receiver answers 403 confirmation.required without a usable OpenVibe-Confirmation header."
+ },
+ "act": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "sub"
+ ],
+ "properties": {
+ "sub": {
+ "type": "string",
+ "pattern": "^(svc:[a-z][a-z0-9-]{1,39}|app:app_[0-9A-HJKMNP-TV-Z]{26})$",
+ "description": "The host that minted the token for the agent: a developer app of the agent's project and environment, or a first-party service."
+ }
+ },
+ "description": "Agent tokens only (RFC 8693 actor claim): the host acting for the agent."
}
}
},
},
"allOf": [
"allOf": [
@@ -101,6 +126,25 @@
@@ -101,6 +126,25 @@
"env"
"env"
]
]
}
}
+ },
+ {
+ "if": {
+ "properties": {
+ "actor_type": {
+ "const": "agent"
+ }
+ },
+ "required": [
+ "actor_type"
+ ]
+ },
+ "then": {
+ "required": [
+ "project_id",
+ "env",
+ "on_behalf_of"
+ ]
+ }
}
}
]
]
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,74 @@
@@ -0,0 +1,74 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/events/payloads/network.confirmation.changed.v1.json",
+ "title": "NetworkConfirmationChangedPayload",
+ "description": "network.confirmation.changed v1 (OpenVibe.Network; roadmap WS-Z2, docs/t2-projects-and-grants.md section 5). A confirmation request (network.confirmation-request@1) an agent raised for a sensitive capability was created, approved, denied, expired, cancelled or used. Written to Network's outbox in the transaction that changes it, beside an audit row. Never carries the summary or details, which may hold message text: a reader that needs them asks Network with its own authority. Envelope: subject { type: confirmation, id: cnf_… }, visibility internal, on_behalf_of the owner, actor the owner who decided (or the system for an expiry, a cascade or a standing rule).",
+ "type": "object",
+ "required": [
+ "confirmation_id",
+ "agent_id",
+ "project_id",
+ "capability",
+ "audience",
+ "state",
+ "change"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "confirmation_id": {
+ "type": "string",
+ "pattern": "^cnf_[0-9A-HJKMNP-TV-Z]{26}$"
+ },
+ "agent_id": {
+ "type": "string",
+ "pattern": "^agt_[0-9A-HJKMNP-TV-Z]{26}$",
+ "description": "The agent that asked."
+ },
+ "project_id": {
+ "type": "string",
+ "pattern": "^prj_[0-9A-HJKMNP-TV-Z]{26}$",
+ "description": "The agent's developer project."
+ },
+ "capability": {
+ "type": "string",
+ "pattern": "^[a-z][a-z0-9-]*\\.[a-z0-9-]+\\.[a-z0-9-]+$"
+ },
+ "audience": {
+ "type": "string",
+ "pattern": "^openvibe\\.[a-z][a-z0-9-]{0,63}$",
+ "description": "The owning service of the capability: the only one that may consume or cancel the confirmation."
+ },
+ "state": {
+ "type": "string",
+ "enum": ["pending", "approved", "denied", "expired", "cancelled"],
+ "description": "The state after the change. A use leaves it approved and sets used_at."
+ },
+ "change": {
+ "type": "string",
+ "enum": ["created", "approved", "denied", "expired", "cancelled", "used"]
+ },
+ "standing_rule": {
+ "type": "string",
+ "enum": ["once", "session", "until", "always"],
+ "description": "On approved: the rule the owner chose (once creates none)."
+ },
+ "rule_id": {
+ "type": "integer",
+ "minimum": 1,
+ "description": "On created through a standing rule, or on approved with a session, until or always rule: that rule."
+ },
+ "cancel_reason": {
+ "type": "string",
+ "enum": ["service", "agent_paused", "agent_revoked", "grant_revoked", "grant_expired", "beyond_host", "member_removed", "account_erased", "app_revoked", "project_archived"],
+ "description": "On cancelled: what took the authority away."
+ },
+ "expires_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "changed_at": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+}
@@ -43,7 +43,7 @@ A new project is `sandbox` only. Staff switch it to `sandbox+production`, which
@@ -43,7 +43,7 @@ A new project is `sandbox` only. Staff switch it to `sandbox+production`, which
## Grants, the allowance and the capability catalog
## Grants, the allowance and the capability catalog
- The only grants that exist are capabilities in the openvibe-contracts catalog, at the version Network has installed.
- The only grants that exist are capabilities in the openvibe-contracts catalog, at the version Network has installed.
-- **The grantability rule.** The contracts `visibility` enum is `public | first-party | internal`. Only `active` capabilities with visibility `public` may be granted to apps. `first-party` and `internal` capabilities are never grantable: staff cannot add them to an allowance, members cannot request them, and token issuance filters them out again. The code also accepts a proposed `partner` visibility. Staff may put a `partner` capability in one project's allowance by hand, but it never comes from the default allowance. `partner` is not in the enum yet; see `docs/contracts-proposal/`.
+- **The grantability rule.** The contracts `visibility` enum is `public | partner | first-party | internal` (`partner` since the v0.83.0 release; Network pins v0.85.0). Only `active` capabilities with visibility `public` or `partner` may be granted to apps. Staff may put a `partner` capability in one project's allowance by hand, but it never comes from the default or the sandbox allowance. `first-party` and `internal` capabilities are never grantable: staff cannot add them to an allowance, members cannot request them, and token issuance filters them out again.
- **The allowance** is the set of grantable capabilities one project's apps may hold. Only staff set it. `DEV_DEFAULT_ALLOWANCE` seeds new projects, with public capabilities only. It is empty by default, so staff decide each project's **production** apps.
- **The allowance** is the set of grantable capabilities one project's apps may hold. Only staff set it. `DEV_DEFAULT_ALLOWANCE` seeds new projects, with public capabilities only. It is empty by default, so staff decide each project's **production** apps.
- **The sandbox allowance** is added to the allowance for **sandbox apps only**, in every project, without a staff decision. It comes from `DEV_SANDBOX_ALLOWANCE`. When that is unset, the code default is `media.object.upload`, `media.object.read`, `media.object.list`, `media.object.delete`, `events.app.publish`, `events.app.read`, `events.app.subscribe`, `tools.job.create`, `tools.job.read` and `tools.job.cancel`. Only `public` + `active` capabilities of the installed openvibe-contracts catalog count. `partner`, `first-party`, `internal` and unknown ids are dropped. The three `events.app.*` ids are defined from openvibe-contracts v0.28.0 (Network pins v0.30.1); with an older release they would be left out. `media.object.list` and `media.object.delete` (Media's list and delete verbs, WS-G task 2) are left out the same way until the pinned release defines them; until then a sandbox app's upload and read grants cover them at Media. The project view and `GET /catalog` show it as `sandbox_allowance`. Production apps never use it.
- **The sandbox allowance** is added to the allowance for **sandbox apps only**, in every project, without a staff decision. It comes from `DEV_SANDBOX_ALLOWANCE`. When that is unset, the code default is `media.object.upload`, `media.object.read`, `media.object.list`, `media.object.delete`, `events.app.publish`, `events.app.read`, `events.app.subscribe`, `tools.job.create`, `tools.job.read` and `tools.job.cancel`. Only `public` + `active` capabilities of the installed openvibe-contracts catalog count. `partner`, `first-party`, `internal` and unknown ids are dropped. The three `events.app.*` ids are defined from openvibe-contracts v0.28.0 (Network pins v0.30.1); with an older release they would be left out. `media.object.list` and `media.object.delete` (Media's list and delete verbs, WS-G task 2) are left out the same way until the pinned release defines them; until then a sandbox app's upload and read grants cover them at Media. The project view and `GET /catalog` show it as `sandbox_allowance`. Production apps never use it.
- **A grant never exceeds the allowance** (for a sandbox app: allowance ∪ sandbox allowance). It is checked in three places. Approval refuses with `403 grant.beyond_allowance`. Shrinking the allowance revokes approved grants, and denies requested ones, that fall outside it. A sandbox app's grant that is still inside the sandbox allowance is kept. Token issuance intersects approved grants with the current allowance and with the current grantability.
- **A grant never exceeds the allowance** (for a sandbox app: allowance ∪ sandbox allowance). It is checked in three places. Approval refuses with `403 grant.beyond_allowance`. Shrinking the allowance revokes approved grants, and denies requested ones, that fall outside it. A sandbox app's grant that is still inside the sandbox allowance is kept. Token issuance intersects approved grants with the current allowance and with the current grantability.
@@ -237,6 +237,7 @@ Until the relay is turned on in an environment, nobody else receives these event
@@ -237,6 +237,7 @@ Until the relay is turned on in an environment, nobody else receives these event
| `DEV_DEFAULT_ALLOWANCE` | empty | Public capabilities a new project starts with |
| `DEV_DEFAULT_ALLOWANCE` | empty | Public capabilities a new project starts with |
| `DEV_MAX_PROJECTS_PER_OWNER` | 10 | Active projects per owner |
| `DEV_MAX_PROJECTS_PER_OWNER` | 10 | Active projects per owner |
| `DEV_MAX_APPS_PER_PROJECT` | 20 | Active apps per project |
| `DEV_MAX_APPS_PER_PROJECT` | 20 | Active apps per project |
+| `AGENT_HOST_SERVICES` | unset: `actor` | First-party services (OAuth client ids) that may host a member's agents (comma list; `docs/t2-projects-and-grants.md`). Each must also be an OAuth client. Unset means the code default; an empty value means none. |
## Known gaps (next steps)
## Known gaps (next steps)
@@ -1,9 +1,10 @@
@@ -1,9 +1,10 @@
# T2 WS-Z2 — projects, agents and delegated grants (design)
# T2 WS-Z2 — projects, agents and delegated grants (design)
-Status: **design, nothing built.** Plan T2, "Projects and grants: projects as the ownership boundary for every resource;
+Status: **slices 1–2 built** (proposals, and agents: `migrations/0016_agents.sql`, `server/developer/agents.js`);
+slices 3–9 are design. Plan T2, "Projects and grants: projects as the ownership boundary for every resource;
`agt_` principals, delegated grants with modes, sensitive capabilities, confirmation requests, budgets (WS-Z2)".
`agt_` principals, delegated grants with modes, sensitive capabilities, confirmation requests, budgets (WS-Z2)".
-Pinned versions at time of writing: `openvibe-contracts` **v0.83.0** (`package.json:30`); every contract claim below was
-checked against the `v0.83.0` tag of OpenVibe.Contracts, and the gaps were re-checked against `v0.84.0`.
+Pinned version: `openvibe-contracts` **v0.85.0** (`package.json:33`); every contract claim below was re-checked against
+the `v0.85.0` tag of OpenVibe.Contracts when slices 1–2 were built (it was written against v0.83.0).
Scope: an **agent** is a principal (`agt_<ULID>`) that acts for one person, inside one developer project, run by one
Scope: an **agent** is a principal (`agt_<ULID>`) that acts for one person, inside one developer project, run by one
host (a developer app or a first-party service such as OpenVibe.Actor). Its owner **delegates** part of the host's
host (a developer app or a first-party service such as OpenVibe.Actor). Its owner **delegates** part of the host's
@@ -13,21 +14,21 @@ authority to it, capability by capability, each in a **mode**. A **sensitive** c
@@ -13,21 +14,21 @@ authority to it, capability by capability, each in a **mode**. A **sensitive** c
for quotas (`docs/developer-projects.md`, "Quotas"). Nothing about apps, service principals, the grantability rule or the
for quotas (`docs/developer-projects.md`, "Quotas"). Nothing about apps, service principals, the grantability rule or the
allowances changes.
allowances changes.
-## 1. What exists (origin/main `1ea05ff`)
+## 1. What exists (origin/main `4e7b56b`, before slices 1–2)
| Piece | Where | State |
| Piece | Where | State |
|---|---|---|
|---|---|---|
-| Projects, members, roles, apps, app grants, quotas, audit | `migrations/0001_initial.sql:436-530`, `server/developer/store.js`, `/api/v1/projects` (`server/index.js:592`) | built; the ownership boundary already used by node principals (`0008`) |
-| App grant ceiling | `store.js:518` `withinAllowance`, `:550` `decideGrant` (`403 grant.beyond_allowance`), `:187` `setAllowance` (shrink revokes), `server/developer/tokens.js:48` `effectiveGrants` (issuance intersects again) | built |
-| Grantability | `server/developer/policy.js:16-30` (`public` and `partner`, `active` only), `:97` `allowanceFor` (sandbox allowance for sandbox apps) | built |
-| Service-principal grants | `principal_grants` + `principal_grant_changes`, `server/identity/grants-admin.js` (owner-only, reason, expiry, `network.principal_grant.changed`), `server/identity/principals.js:287` `grantsFor` | built |
-| Subjects | `server/identity/subjects.js:95,103` resolve `usr_` and `gst_` only | no `agt_` |
-| Capability guard | `principals.js:344` `guard(capability)` throws at boot on an id the catalog does not have | built |
-| Delegated client capability | `docs/capabilities-proposal/network.project.manage.json` (in the catalog at v0.83.0 as `public`, `planned`) | proposed, unused |
+| Projects, members, roles, apps, app grants, quotas, audit | `migrations/0001_initial.sql:436-530`, `server/developer/store.js`, `/api/v1/projects` (`server/index.js:608`) | built; the ownership boundary already used by node principals (`0008`) |
+| App grant ceiling | `store.js:559` `withinAllowance`, `:591` `decideGrant` (`403 grant.beyond_allowance`), `:196` `setAllowance` (shrink revokes), `server/developer/tokens.js:48` `effectiveGrants` (issuance intersects again) | built |
+| Grantability | `server/developer/policy.js:16-30` (`public` and `partner`, `active` only), `:100` `allowanceFor` (sandbox allowance for sandbox apps) | built |
+| Service-principal grants | `principal_grants` + `principal_grant_changes`, `server/identity/grants-admin.js` (owner-only, reason, expiry, `network.principal_grant.changed`), `server/identity/principals.js:290` `grantsFor` | built |
+| Subjects | `server/identity/subjects.js` resolves `usr_` and `gst_` only | no `agt_` |
+| Capability guard | `principals.js:347` `guard(capability)` throws at boot on an id the catalog does not have | built |
+| Delegated client capability | `docs/capabilities-proposal/network.project.manage.json` (in the catalog at v0.85.0 as `public`, `planned`) | proposed, unused |
| Service-side project read | `network.project.read` (catalog: `first-party`, `planned`; route "GET /internal/projects/:project_id (planned)") | unused |
| Service-side project read | `network.project.read` (catalog: `first-party`, `planned`; route "GET /internal/projects/:project_id (planned)") | unused |
-| Sensitive capabilities, confirmations, budgets, agents | — | missing |
+| Sensitive capabilities, confirmations, budgets, agents | — | missing (agents: built by slice 2) |
-## 2. Contract vocabulary (v0.83.0, used verbatim)
+## 2. Contract vocabulary (v0.85.0, used verbatim)
- **`identity.subject-ref@1`** has the agent form `{ "type": "agent", "id": "agt_<ULID>" }` ("an agent (OpenVibe.Actor, or
- **`identity.subject-ref@1`** has the agent form `{ "type": "agent", "id": "agt_<ULID>" }` ("an agent (OpenVibe.Actor, or
a developer app's agent) acting under grants its owner delegated"). `ids.PREFIX` has `agent: 'agt'` and
a developer app's agent) acting under grants its owner delegated"). `ids.PREFIX` has `agent: 'agt'` and
@@ -35,7 +36,7 @@ allowances changes.
@@ -35,7 +36,7 @@ allowances changes.
- **`events.event-envelope@1`** has an optional `on_behalf_of` (a SubjectRef): the person an agent acted for.
- **`events.event-envelope@1`** has an optional `on_behalf_of` (a SubjectRef): the person an agent acted for.
- **`capabilities.capability@1`** has an optional boolean **`sensitive`**: "the action has an external side effect (money,
- **`capabilities.capability@1`** has an optional boolean **`sensitive`**: "the action has an external side effect (money,
sending as the person, publishing, applying, deleting, physical control): an agent needs its owner's confirmation unless
sending as the person, publishing, applying, deleting, physical control): an agent needs its owner's confirmation unless
- a standing rule covers it". 23 capabilities carry `sensitive: true` at v0.83.0. Two of them are grantable to apps
+ a standing rule covers it". 23 capabilities carry `sensitive: true` at v0.85.0. Two of them are grantable to apps
today (`public` + `active`): `media.object.delete` (in the default sandbox allowance) and `space.post.write`. The other
today (`public` + `active`): `media.object.delete` (in the default sandbox allowance) and `space.post.write`. The other
sensitive ones are `first-party` (e.g. `chat.message.send`, `blog.post.publish`, `tips.superchat.create`), `internal`
sensitive ones are `first-party` (e.g. `chat.message.send`, `blog.post.publish`, `tips.superchat.create`), `internal`
(`billing.*`, `network.coins.transfer`) or `public` but `planned`/`deprecated`.
(`billing.*`, `network.coins.transfer`) or `public` but `planned`/`deprecated`.
@@ -43,15 +44,16 @@ allowances changes.
@@ -43,15 +44,16 @@ allowances changes.
`requested_by` (SubjectRefs), `capability`, `summary` (≤ 500), `state` (`pending | approved | denied | expired |
`requested_by` (SubjectRefs), `capability`, `summary` (≤ 500), `state` (`pending | approved | denied | expired |
cancelled`), `expires_at`, `created_at`; optional `details` (object), `resources` (`common.entity-ref@1[]`),
cancelled`), `expires_at`, `created_at`; optional `details` (object), `resources` (`common.entity-ref@1[]`),
`standing_rule` (`once | session | until | always`), `decided_at`.
`standing_rule` (`once | session | until | always`), `decided_at`.
-- The capability `visibility` enum at v0.83.0 is **`public | partner | first-party | internal`**. `docs/developer-projects.md`
+- The capability `visibility` enum (since v0.83.0) is **`public | partner | first-party | internal`**. `docs/developer-projects.md`
("`partner` is not in the enum yet") and the comment at `policy.js:8` predate that; slice 1 corrects both. The rule
("`partner` is not in the enum yet") and the comment at `policy.js:8` predate that; slice 1 corrects both. The rule
itself does not change.
itself does not change.
-**Gaps — not in v0.83.0 nor v0.84.0, so the slices that need them are blocked (section 8):**
+**Gaps — not in v0.85.0, so the slices that need them are blocked (section 8):**
-1. `identity.service-token-claims@1`: `sub` allows only `svc:` / `app:` / `mod:` and `actor_type` only `service | app |
- mod`. An agent token cannot validate. (Same shape of gap as the node actor in `docs/t2-resource-registry.md` §9.)
-2. No capability for a service to create or consume a confirmation (`network.*` at v0.83.0 has none; inventing one makes
+1. `identity.service-token-claims@1`: `sub` allows only `svc:` / `app:` / `mod:` / `node:` and `actor_type` only
+ `service | app | mod | node`. An agent token cannot validate. (v0.85.0 closed the same gap for the node actor of
+ `docs/t2-resource-registry.md` §9.)
+2. No capability for a service to create or consume a confirmation (`network.*` at v0.85.0 has none; inventing one makes
`principals.guard` throw at boot).
`principals.guard` throw at boot).
3. No event payload for a confirmation decision or an agent/delegated-grant change, so these changes are audit rows
3. No event payload for a confirmation decision or an agent/delegated-grant change, so these changes are audit rows
without `event` (the export-token precedent: "The row is not a platform event").
without `event` (the export-token precedent: "The row is not a platform event").
@@ -59,38 +61,22 @@ allowances changes.
@@ -59,38 +61,22 @@ allowances changes.
## 3. Data model
## 3. Data model
-Migrations `0010`–`0013` (`0008` is the highest on main; `0009` is reserved for registry slice 5). Header convention of
-`0007`/`0008`: `-- phase: expand`, `-- plan T2 WS-Z2. <why>`, then DDL with `IF NOT EXISTS`. The runtime creates nothing
-(`ensureSchema()` stays a no-op with a comment).
+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`
+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.
+<why>`, then DDL with `IF NOT EXISTS`. The runtime creates nothing (`ensureSchema()` stays a no-op with a comment).
-**Stores.** Network runs on PostgreSQL in production and on embedded PGlite in development and in `npm test`;
-`npm run test:pg` runs the same suite on real PostgreSQL. The migration runner applies the files below, in the dialect of
-`0007`/`0008`: `text COLLATE "C"` (compares like the old SQLite text), timestamps as ISO `text` (`ov_now_iso()`), `~`
-regex CHECKs, JSON as `text` parsed in JS (never `jsonb`, as `platform_nodes.doc`), `bigint GENERATED ALWAYS AS
-IDENTITY`, partial indexes, no triggers.
+**Stores.** Network runs on PostgreSQL only (the 2026-10-02 cutover): production on PostgreSQL, development and
+`npm test` on embedded PGlite, `npm run test:pg` on real PostgreSQL. The migration runner applies the files below, in the
+dialect of `0007`/`0008`: `text COLLATE "C"`, timestamps as ISO `text` (`ov_now_iso()`), `~` regex CHECKs, JSON as `text`
+parsed in JS (never `jsonb`, as `platform_nodes.doc`), `bigint GENERATED ALWAYS AS IDENTITY`, partial indexes, no
+triggers. There are no SQLite forms: nothing reads a SQLite copy of these tables, and they did not exist before the
+cutover. The accept/reject cases live in `test/agents.test.js` (and the later slices' tests) against the migrated
+database.
-**SQLite.** The serving SQLite store is retired (ADR-035; `server/db/database.js`), but SQLite is still read by
-`scripts/migrate-to-postgres.js` and `scripts/pg-preflight-collisions.js`, and the schema must stay expressible there.
-Every table below therefore has a **SQLite form**, written mechanically from the PostgreSQL file and kept next to the
-test that checks it (`test/fixtures/sqlite/00NN_*.sql`, section 7). Nothing at runtime applies it. The rules:
-
-| PostgreSQL (`migrations/00NN_*.sql`) | SQLite form |
-|---|---|
-| `text COLLATE "C"` | `text` (SQLite's default `BINARY` collation compares like `"C"`) |
-| `DEFAULT ov_now_iso()` | `DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))` (the same `…T07:53:27.720Z` shape) |
-| `bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY` / `bigint` | `INTEGER PRIMARY KEY AUTOINCREMENT` / `INTEGER` |
-| `c ~ '^agt_[0-9A-HJKMNP-TV-Z]{26}$'` (also `usr_`, `cnf_`) | `substr(c, 1, 4) = 'agt_' AND length(c) = 30 AND substr(c, 5) NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*'` |
-| `audience ~ '^openvibe\.[a-z][a-z0-9-]{0,63}$'` | `length(audience) BETWEEN 10 AND 73 AND substr(audience, 1, 9) = 'openvibe.' AND substr(audience, 10, 1) GLOB '[a-z]' AND substr(audience, 11) NOT GLOB '*[^a-z0-9-]*'` |
-| `session_id ~ '^[A-Za-z0-9._:-]{8,128}$'` | `length(session_id) BETWEEN 8 AND 128 AND session_id NOT GLOB '*[^A-Za-z0-9._:-]*'` |
-| `request_digest ~ '^[0-9a-f]{64}$'` | `length(request_digest) = 64 AND request_digest NOT GLOB '*[^0-9a-f]*'` |
-| composite and partial indexes, `IF NOT EXISTS`, multi-column `FOREIGN KEY` | unchanged (SQLite enforces the foreign keys with `PRAGMA foreign_keys = ON`) |
-
-Both forms were applied while writing this doc (PGlite, and `node:sqlite` in memory, each twice to prove `IF NOT
-EXISTS`) and agreed on 17 accept/reject cases: wrong prefix or a non-Crockford character, a cross-project or
-cross-environment app host, a service host in sandbox, `revoked` without `revoked_at`, a bad audience, session id or
-digest, `always` with `until_at`, `used_at` on a pending confirmation, a negative budget, and the ISO default.
-
-### 0010 — `0010_agents.sql`
+### 0016 — `0016_agents.sql` (built)
```sql
```sql
CREATE UNIQUE INDEX IF NOT EXISTS dev_apps_project_env_key ON dev_apps (id, project_id, environment);
CREATE UNIQUE INDEX IF NOT EXISTS dev_apps_project_env_key ON dev_apps (id, project_id, environment);
@@ -133,7 +119,7 @@ CREATE INDEX IF NOT EXISTS dev_agents_service_idx ON dev_agents (host_service) W
@@ -133,7 +119,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.
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.
- `revoked` is final, like an app. `paused` is the owner's kill switch and is reversible.
-### 0011 — `0011_agent_grants.sql`
+### Next — `00NN_agent_grants.sql` (slice 3)
```sql
```sql
CREATE TABLE IF NOT EXISTS dev_agent_grants (
CREATE TABLE IF NOT EXISTS dev_agent_grants (
@@ -160,7 +146,7 @@ CREATE INDEX IF NOT EXISTS dev_agent_grants_cap_idx ON dev_agent_grants (capa
@@ -160,7 +146,7 @@ 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
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.
`mode` is what the owner chose; `effective_mode` (section 5) is computed.
-### 0012 — `0012_confirmations.sql`
+### Next — `00NN_confirmations.sql` (slice 4)
```sql
```sql
CREATE TABLE IF NOT EXISTS dev_standing_rules (
CREATE TABLE IF NOT EXISTS dev_standing_rules (
@@ -223,7 +209,7 @@ CREATE INDEX IF NOT EXISTS dev_confirmations_spendable_idx ON dev_confirmations
@@ -223,7 +209,7 @@ CREATE INDEX IF NOT EXISTS dev_confirmations_spendable_idx ON dev_confirmations
`approved`, unused row may be cancelled too (its `decided_at` stays); the spendable index finds those rows for the
`approved`, unused row may be cancelled too (its `decided_at` stays); the spendable index finds those rows for the
cascades.
cascades.
-### 0013 — `0013_agent_budgets.sql`
+### Next — `00NN_agent_budgets.sql` (slice 5)
```sql
```sql
CREATE TABLE IF NOT EXISTS dev_agent_budgets (
CREATE TABLE IF NOT EXISTS dev_agent_budgets (
@@ -254,7 +240,7 @@ Public routes follow the projects API exactly (`docs/developer-projects.md`, "AP
@@ -254,7 +240,7 @@ Public routes follow the projects API exactly (`docs/developer-projects.md`, "AP
|---|---|
|---|---|
| `GET /agents[?owner=me]` | `{ agents: [agent] }` |
| `GET /agents[?owner=me]` | `{ agents: [agent] }` |
| `POST /agents` | `{ name, host: {type:'app', id:'app_…'} \| {type:'service', id:'actor'} }` → `201 agent`; an app host must be a non-revoked app **of `:project`** (otherwise `404 app.not_found`, the same answer as an unknown id, so other projects' app ids are not probed) |
| `POST /agents` | `{ name, host: {type:'app', id:'app_…'} \| {type:'service', id:'actor'} }` → `201 agent`; an app host must be a non-revoked app **of `:project`** (otherwise `404 app.not_found`, the same answer as an unknown id, so other projects' app ids are not probed) |
-| `GET /agents/:agent` | `agent` with `grants`, `budgets`, `rules` |
+| `GET /agents/:agent` | `agent` with `grants`, `budgets`, `rules` (each added by its slice; slice 2 returns the `agent` alone) |
| `PATCH /agents/:agent` | `{ name }` → `agent` |
| `PATCH /agents/:agent` | `{ name }` → `agent` |
| `POST /agents/:agent/pause`, `/resume` | → `agent` |
| `POST /agents/:agent/pause`, `/resume` | → `agent` |
| `DELETE /agents/:agent` | revoke (final) → `agent` |
| `DELETE /agents/:agent` | revoke (final) → `agent` |
@@ -318,7 +304,7 @@ service through `principals.issueToken`), and adds `agent=agt_…` plus `audienc
@@ -318,7 +304,7 @@ service through `principals.issueToken`), and adds `agent=agt_…` plus `audienc
**Host binding, checked on every issuance** (`400 invalid_grant` for all of them, one message, so a host cannot learn
**Host binding, checked on every issuance** (`400 invalid_grant` for all of them, one message, so a host cannot learn
whether someone else's agent exists): the authenticated client **is** the agent's host (`app.id = agent.host_app_id`,
whether someone else's agent exists): the authenticated client **is** the agent's host (`app.id = agent.host_app_id`,
or `client_id = agent.host_service`); for an app host, `app.project_id = agent.project_id`, `app.environment =
or `client_id = agent.host_service`); for an app host, `app.project_id = agent.project_id`, `app.environment =
-agent.environment` and the app is not revoked (the foreign key of `0010` already makes the first two true for every
+agent.environment` and the app is not revoked (the foreign key of `0016` already makes the first two true for every
stored row; issuance re-reads them so a future change to that key cannot widen tokens); the project is not archived;
stored row; issuance re-reads them so a future change to that key cannot widen tokens); the project is not archived;
the agent is `active`; the owner is still a member and not banned or deleted.
the agent is `active`; the owner is still a member and not banned or deleted.
@@ -417,7 +403,7 @@ the digest. An agent cannot describe its own request to its owner.
@@ -417,7 +403,7 @@ the digest. An agent cannot describe its own request to its owner.
4. **Use once.** The agent retries with the header. The service calls `consume` with its digest of *this* request. An
4. **Use once.** The agent retries with the header. The service calls `consume` with its digest of *this* request. An
approval is only as good as the authority behind it **now**, so consume re-checks everything that let it be created,
approval is only as good as the authority behind it **now**, so consume re-checks everything that let it be created,
in one transaction (`db.tx`) that first locks the agent row (`SELECT … FROM dev_agents WHERE id = ? FOR UPDATE` on
in one transaction (`db.tx`) that first locks the agent row (`SELECT … FROM dev_agents WHERE id = ? FOR UPDATE` on
- PostgreSQL; SQLite serialises writers anyway) so a revocation cannot interleave:
+ PostgreSQL) so a revocation cannot interleave:
- the agent is `active`, its project is not archived, and its owner is still a member, not banned and not deleted;
- the agent is `active`, its project is not archived, and its owner is still a member, not banned and not deleted;
- for an app host, the app is not revoked and still belongs to the agent's project and environment;
- for an app host, the app is not revoked and still belongs to the agent's project and environment;
- the delegated grant (agent, capability) is `active`, unexpired, and its `effective_mode` is still `confirm`;
- the delegated grant (agent, capability) is `active`, unexpired, and its `effective_mode` is still `confirm`;
@@ -465,10 +451,15 @@ the capability's audience. Owning services read budgets from `GET /internal/agen
@@ -465,10 +451,15 @@ the capability's audience. Owning services read budgets from `GET /internal/agen
- **App grants (`dev_grants`, `store.js`).** Untouched: same table, statuses, roles, approval, `grant.beyond_allowance`,
- **App grants (`dev_grants`, `store.js`).** Untouched: same table, statuses, roles, approval, `grant.beyond_allowance`,
`network.grant.changed`. Three existing transactions gain one cascade each, **after** their current work and in the
`network.grant.changed`. Three existing transactions gain one cascade each, **after** their current work and in the
- same transaction: `setAllowance` (`:187`) and `decideGrant`/the grant revoke (`:550`) revoke the delegated grants of
- the app's agents that fall outside `effectiveGrants`; `revokeAppTx` (`:397`) and `archiveProject` (`:176`) revoke the
- agents themselves (`revoked_by = 'app_revoked'` / `'project_archived'`). `removeMember` (`:281`) revokes the leaving
- person's agents in that project. Each of these cascades also cancels the affected pending and approved-unused
+ same transaction: `setAllowance` (`:196`) and `decideGrant`/the grant revoke (`:591`) revoke the delegated grants of
+ the app's agents that fall outside `effectiveGrants`; `revokeAppTx` (`:436`) and `archiveProject` (`:183`) revoke the
+ agents themselves (`revoked_by = 'app_revoked'` / `'project_archived'`; built, slice 2 — the archive revokes its agents
+ before its apps, so each records the archive as the cause). `removeMember` (`:319`) revokes the leaving person's
+ agents in that project (`'member_removed'`). Each of these transactions, and an account erasure, first takes the
+ project row's lock (`store.lockProject`, `FOR NO KEY UPDATE`); agent creation takes it too and re-checks the project,
+ the membership and the host under it, so no agent is created against a state a cascade is ending. A pause, resume or
+ revoke re-reads the agent `FOR UPDATE` and updates only from that status, so a resume never undoes a concurrent revoke
+ (`test/agents-concurrency.test.js`). Each of these cascades also cancels the affected pending and approved-unused
confirmations and revokes the standing rules (section 5, step 6). A sandbox agent's grant still inside the sandbox allowance survives a staff change,
confirmations and revokes the standing rules (section 5, step 6). A sandbox agent's grant still inside the sandbox allowance survives a staff change,
for the same reason the app's grant does.
for the same reason the app's grant does.
- **App tokens (`tokens.js`).** Unchanged byte for byte when no `agent` parameter is sent; the new branch is taken only
- **App tokens (`tokens.js`).** Unchanged byte for byte when no `agent` parameter is sent; the new branch is taken only
@@ -481,11 +472,12 @@ the capability's audience. Owning services read budgets from `GET /internal/agen
@@ -481,11 +472,12 @@ the capability's audience. Owning services read budgets from `GET /internal/agen
- **Sensitive app grants.** `media.object.delete` and `space.post.write` keep working for apps exactly as today:
- **Sensitive app grants.** `media.object.delete` and `space.post.write` keep working for apps exactly as today:
`capability@1` defines confirmation for **agents**. Apps acting with `on_behalf_of` are listed as unresolved.
`capability@1` defines confirmation for **agents**. Apps acting with `on_behalf_of` are listed as unresolved.
- **Mod principals, node principals, export tokens.** Untouched.
- **Mod principals, node principals, export tokens.** Untouched.
-- **Account deletion (`account-data.js:315` `erase`)** revokes the subject's agents, cancels their pending and approved-unused
- confirmations and revokes their rules; the account export (`:160` `networkPart`) lists the person's agents, grants, rules and
+- **Account deletion (`account-data.js:319` `erase`)** revokes the subject's agents (`revoked_by = 'account_deleted'`,
+ then `'project_archived'` for the rest of a project it archives; built, slice 2), cancels their pending and approved-unused
+ confirmations and revokes their rules; the account export (`:162` `networkPart`) lists the person's agents (built: `developer_projects.json` `agents`, without `revoked_by`), grants, rules and
confirmations (without other people's data).
confirmations (without other people's data).
- **Migration of existing rows.** None needed: the four tables start empty, no existing column changes, no backfill,
- **Migration of existing rows.** None needed: the four tables start empty, no existing column changes, no backfill,
- `0010`–`0013` are `expand` only. Rolling back a slice means its routes disappear; its empty or orphaned rows are inert
+ `0016` and its successors are `expand` only. Rolling back a slice means its routes disappear; its empty or orphaned rows are inert
because issuance and the routes are the only readers.
because issuance and the routes are the only readers.
## 7. Test plan
## 7. Test plan
@@ -499,10 +491,10 @@ keypair, a bare express app, user tokens, service tokens through `POST /oauth/to
@@ -499,10 +491,10 @@ keypair, a bare express app, user tokens, service tokens through `POST /oauth/to
`host_app_id`, service host in sandbox, revoked without `revoked_at`); **an app of another project as host**: the route
`host_app_id`, service host in sandbox, revoked without `revoked_at`); **an app of another project as host**: the route
answers `404 app.not_found`, and a direct insert of such a row (or of an app of the other environment) fails on the
answers `404 app.not_found`, and a direct insert of such a row (or of an app of the other environment) fails on the
composite foreign key; cascades: revoke app, archive project, remove member, account erase.
composite foreign key; cascades: revoke app, archive project, remove member, account erase.
-- `test/agent-schema.test.js` — applies `0010`–`0013` and their SQLite forms (`test/fixtures/sqlite/00NN_*.sql`, to a
- `node:sqlite` in-memory database with `PRAGMA foreign_keys = ON`; `node:sqlite` because the native `better-sqlite3`
- build need not match the running Node), each twice, and runs one table of accept/reject rows against both, asserting
- the same outcome (the 17 cases of section 3, grown with each slice). A drift between the two forms fails here.
+- `test/agent-schema.test.js` — through the normal runner only: a database migrated to `0015` without the agents
+ migration gains `0016` (once; re-running the file changes nothing), a file numbered below an applied one is refused
+ (why the design's `0010` became `0016`), and the composite foreign key holds on the migrated database. Each later
+ slice adds its migration to it.
- `test/agent-grants.test.js` — ceiling: an app-hosted agent cannot get a capability the app lacks, outside the
- `test/agent-grants.test.js` — ceiling: an app-hosted agent cannot get a capability the app lacks, outside the
allowance, or `first-party`; a sandbox agent gets a sandbox-allowance capability; a service-hosted agent never gets an
allowance, or `first-party`; a sandbox agent gets a sandbox-allowance capability; a service-hosted agent never gets an
`internal` one; `auto` on `media.object.delete` → `422`; `effective_mode` follows the catalog; only the owner may PUT;
`internal` one; `auto` on `media.object.delete` → `422`; `effective_mode` follows the catalog; only the owner may PUT;
@@ -533,33 +525,33 @@ Regression runs each slice: `test/developer-projects.test.js`, `test/developer-d
@@ -533,33 +525,33 @@ Regression runs each slice: `test/developer-projects.test.js`, `test/developer-d
`test/developer-export-tokens.test.js`, `test/grants-admin.test.js`, `test/principals.test.js`,
`test/developer-export-tokens.test.js`, `test/grants-admin.test.js`, `test/principals.test.js`,
`test/service-principal.test.js`, `test/account-data.test.js`, `test/security-idor.test.js`. Every slice: the new file
`test/service-principal.test.js`, `test/account-data.test.js`, `test/security-idor.test.js`. Every slice: the new file
alone while iterating, then `npm test` and `npm run test:pg` (PGlite and real PostgreSQL must both apply the migration
alone while iterating, then `npm test` and `npm run test:pg` (PGlite and real PostgreSQL must both apply the migration
-and agree; `test/agent-schema.test.js` adds the SQLite form to that agreement).
+and agree).
## 8. Slices — each one PR
## 8. Slices — each one PR
-1. **Docs and proposals.** `docs/developer-projects.md` and the `policy.js:8` comment: `partner` is in the v0.83.0 enum.
+1. **Docs and proposals** (built). `docs/developer-projects.md` and the `policy.js:8` comment: `partner` is in the enum.
`docs/capabilities-proposal/network.confirmation.manage.json` (first-party, `implementedBy` the four
`docs/capabilities-proposal/network.confirmation.manage.json` (first-party, `implementedBy` the four
`/internal/confirmations` routes). `docs/contracts-proposal/`: `identity.service-token-claims@1` gains `actor_type
`/internal/confirmations` routes). `docs/contracts-proposal/`: `identity.service-token-claims@1` gains `actor_type
agent`, `sub agent:agt_…`, `cap_confirm`, `act`, and requires `project_id`, `env`, `on_behalf_of` for agents; a
agent`, `sub agent:agt_…`, `cap_confirm`, `act`, and requires `project_id`, `env`, `on_behalf_of` for agents; a
`network.confirmation.changed@1` payload. No migration. Checks: `npm test` (docs-only, sanity).
`network.confirmation.changed@1` payload. No migration. Checks: `npm test` (docs-only, sanity).
-2. **Agents.** `migrations/0010_agents.sql`, `server/developer/agents.js` (store + `ensureSchema` no-op), routes in
+2. **Agents** (built). `migrations/0016_agents.sql`, `server/developer/agents.js` (store + `ensureSchema` no-op), routes in
`server/developer/routes.js`, cascades in `store.js` (`revokeAppTx`, `archiveProject`, `removeMember`) and
`server/developer/routes.js`, cascades in `store.js` (`revokeAppTx`, `archiveProject`, `removeMember`) and
`account-data.js` (`erase`, `networkPart`), `AGENT_HOST_SERVICES` in `.env.example` and the config table of
`account-data.js` (`erase`, `networkPart`), `AGENT_HOST_SERVICES` in `.env.example` and the config table of
- `docs/developer-projects.md`, `test/fixtures/sqlite/0010_agents.sql`, `test/agents.test.js`,
+ `docs/developer-projects.md`, `test/agents.test.js`,
`test/agent-schema.test.js`. Checks: `npm test`, `npm run test:pg`.
`test/agent-schema.test.js`. Checks: `npm test`, `npm run test:pg`.
-3. **Delegated grants and modes.** `migrations/0011_agent_grants.sql`, grant routes, the ceiling (reusing
+3. **Delegated grants and modes.** `migrations/00NN_agent_grants.sql`, grant routes, the ceiling (reusing
`tokens.effectiveGrants` and `principals.grantsFor`), cascades in `setAllowance`, `decideGrant`/grant revoke and
`tokens.effectiveGrants` and `principals.grantsFor`), cascades in `setAllowance`, `decideGrant`/grant revoke and
- `grants-admin.js` revoke + `expireDue`, `test/fixtures/sqlite/0011_agent_grants.sql`, `test/agent-grants.test.js`,
+ `grants-admin.js` revoke + `expireDue`, `test/agent-grants.test.js`,
cases added to `test/agent-schema.test.js`. Checks: `npm test`, `npm run test:pg`.
cases added to `test/agent-schema.test.js`. Checks: `npm test`, `npm run test:pg`.
-4. **Confirmations, owner side.** `migrations/0012_confirmations.sql`, `server/developer/confirmations.js` (store,
+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`,
lifecycle, expiry sweep started next to `grants-admin` `start`), `/api/v1/confirmations` mounted in `server/index.js`,
rule routes, the store's `consume` with the step-4 re-checks, the confirmation cancel step added to every cascade of
rule routes, the store's `consume` with the step-4 re-checks, the confirmation cancel step added to every cascade of
slices 2–3 (pause, revoke, grant revoke and expiry, ceiling cascades, member removal, erase, app revoke, archive),
slices 2–3 (pause, revoke, grant revoke and expiry, ceiling cascades, member removal, erase, app revoke, archive),
- `test/fixtures/sqlite/0012_confirmations.sql`, `test/confirmations.test.js` creating and consuming rows through the
+ `test/confirmations.test.js` creating and consuming rows through the
store functions (the internal routes wait for slice 7), cases added to `test/agent-schema.test.js`. Checks: `npm test`,
store functions (the internal routes wait for slice 7), cases added to `test/agent-schema.test.js`. Checks: `npm test`,
`npm run test:pg`.
`npm run test:pg`.
-5. **Budgets.** `migrations/0013_agent_budgets.sql`, budget routes, `budget.beyond_quota`,
- `test/fixtures/sqlite/0013_agent_budgets.sql`, `test/agent-budgets.test.js` (without the internal read), cases added to
+5. **Budgets.** `migrations/00NN_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`.
`test/agent-schema.test.js`. Checks: `npm test`, `npm run test:pg`.
6. **Internal agent read.** `GET /internal/agents/:agent` under `network.project.read` (already in the catalog), in
6. **Internal agent read.** `GET /internal/agents/:agent` under `network.project.read` (already in the catalog), in
`server/developer/agents.js`, wired in `server/index.js`; tests added to `test/agent-budgets.test.js`. Checks:
`server/developer/agents.js`, wired in `server/index.js`; tests added to `test/agent-budgets.test.js`. Checks:
new file mode 100644
new file mode 100644
@@ -0,0 +1,35 @@
@@ -0,0 +1,35 @@
+-- phase: expand
+-- plan T2 WS-Z2. Agents (docs/t2-projects-and-grants.md section 3, slice 2): an agent (agt_<ULID>) acts for one
+-- person, inside one developer project, run by one host: an app of that project in the same environment, or a
+-- first-party service (always production). The composite foreign key makes a host app of another project or
+-- environment impossible; dev_apps.id is already the primary key, so the unique index only gives it a target.
+-- revoked is final, paused is the owner's reversible kill switch. Numbered after 0015: the runner refuses a file
+-- older than one already applied, so the design's 0010 could never reach a database at 0014 or later.
+
+CREATE UNIQUE INDEX IF NOT EXISTS dev_apps_project_env_key ON dev_apps (id, project_id, environment);
+
+CREATE TABLE IF NOT EXISTS dev_agents (
+ id text COLLATE "C" PRIMARY KEY CHECK (id ~ '^agt_[0-9A-HJKMNP-TV-Z]{26}$'),
+ project_id text COLLATE "C" NOT NULL REFERENCES dev_projects(id),
+ owner_subject text COLLATE "C" NOT NULL CHECK (owner_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
+ host_kind text COLLATE "C" NOT NULL CHECK (host_kind IN ('app', 'service')),
+ host_app_id text COLLATE "C",
+ host_service text COLLATE "C" REFERENCES oauth_clients(client_id),
+ environment text COLLATE "C" NOT NULL CHECK (environment IN ('sandbox', 'production')),
+ name text COLLATE "C" NOT NULL CHECK (length(name) BETWEEN 1 AND 80),
+ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'paused', 'revoked')),
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),
+ created_by text COLLATE "C" NOT NULL,
+ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),
+ revoked_at text COLLATE "C",
+ revoked_by text COLLATE "C",
+ FOREIGN KEY (host_app_id, project_id, environment) REFERENCES dev_apps (id, project_id, environment),
+ CHECK ((host_kind = 'app') = (host_app_id IS NOT NULL)),
+ CHECK ((host_kind = 'service') = (host_service IS NOT NULL)),
+ CHECK (host_kind = 'app' OR environment = 'production'),
+ CHECK ((status = 'revoked') = (revoked_at IS NOT NULL))
+);
+CREATE INDEX IF NOT EXISTS dev_agents_project_idx ON dev_agents (project_id, status);
+CREATE INDEX IF NOT EXISTS dev_agents_owner_idx ON dev_agents (owner_subject, status);
+CREATE INDEX IF NOT EXISTS dev_agents_app_idx ON dev_agents (host_app_id) WHERE host_app_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS dev_agents_service_idx ON dev_agents (host_service) WHERE host_service IS NOT NULL;
@@ -42,6 +42,9 @@ module.exports = {
@@ -42,6 +42,9 @@ module.exports = {
defaultAllowance: process.env.DEV_DEFAULT_ALLOWANCE || '',
defaultAllowance: process.env.DEV_DEFAULT_ALLOWANCE || '',
maxProjectsPerOwner: process.env.DEV_MAX_PROJECTS_PER_OWNER || 10,
maxProjectsPerOwner: process.env.DEV_MAX_PROJECTS_PER_OWNER || 10,
maxAppsPerProject: process.env.DEV_MAX_APPS_PER_PROJECT || 20,
maxAppsPerProject: process.env.DEV_MAX_APPS_PER_PROJECT || 20,
+ // First-party services (OAuth client ids) that may host a project member's agents (comma list).
+ // Unset = the code default (actor: server/developer/policy.js); set to an empty value = none.
+ agentHostServices: process.env.AGENT_HOST_SERVICES,
},
},
// OpenVibe.Events base URL for Network's own events (developer projects). Unset = no relay:
// OpenVibe.Events base URL for Network's own events (developer projects). Unset = no relay:
new file mode 100644
new file mode 100644
@@ -0,0 +1,164 @@
@@ -0,0 +1,164 @@
+'use strict';
+/**
+ * Agents (plan T2 WS-Z2, docs/t2-projects-and-grants.md sections 3-5): an agent (agt_<ULID>) acts for one person,
+ * inside one developer project, run by one host — an app of that project (in the app's environment) or a
+ * first-party service listed in AGENT_HOST_SERVICES (always production). The schema is migrations/0016_agents.sql.
+ *
+ * - A person creates agents for themselves only: developer+ for a sandbox app or a service host, admin+ for a
+ * production app. Nobody creates an agent for another member; staff never create one.
+ * - The owner or an admin+ renames and resumes; the owner, an admin+ or staff pause and revoke. A staff pause is
+ * lifted by the owner (or an admin), never by staff as such. `revoked` is final; `paused` is reversible.
+ * - 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).
+ */
+const { ids } = require('openvibe-contracts');
+const store = require('./store');
+
+const AGENT_ID_RE = /^agt_[0-9A-HJKMNP-TV-Z]{26}$/;
+const fail = (status, code, detail) => { throw new store.DevError(status, code, detail); };
+const nowIso = () => new Date().toISOString();
+const atLeast = (role, need) => !!role && store.ROLES.indexOf(role) >= store.ROLES.indexOf(need);
+
+function ensureSchema(db) { /* the schema is migrations/0016_agents.sql (plan T2); nothing is created at runtime */ }
+
+function agentView(a) {
+ return {
+ id: a.id, subject: { type: 'agent', id: a.id }, project_id: a.project_id,
+ owner: { type: 'user', id: a.owner_subject },
+ host: a.host_kind === 'app' ? { type: 'app', id: a.host_app_id } : { type: 'service', id: a.host_service },
+ environment: a.environment, name: a.name, status: a.status,
+ created_at: a.created_at, updated_at: a.updated_at, revoked_at: a.revoked_at || null,
+ };
+}
+
+const cleanName = (v) => {
+ const s = String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]/g, '').trim();
+ if (!s || s.length > 80) fail(422, 'agent.invalid', 'name must be 1-80 characters');
+ return s;
+};
+
+async function loadAgent(db, projectId, agentId) {
+ const a = AGENT_ID_RE.test(String(agentId)) ? await db.prepare('SELECT * FROM dev_agents WHERE id = ? AND project_id = ?').get(agentId, projectId) : null;
+ if (!a) fail(404, 'agent.not_found', 'no such agent');
+ return a;
+}
+
+async function listAgents(db, actor, projectId, { owner } = {}) {
+ const { project } = await store.access(db, actor, projectId, { allowArchived: true });
+ const rows = owner === 'me'
+ ? await db.prepare('SELECT * FROM dev_agents WHERE project_id = ? AND owner_subject = ? ORDER BY id').all(project.id, actor.subject)
+ : await db.prepare('SELECT * FROM dev_agents WHERE project_id = ? ORDER BY id').all(project.id);
+ return rows.map(agentView);
+}
+
+async function getAgent(db, actor, projectId, agentId) {
+ const { project } = await store.access(db, actor, projectId, { allowArchived: true });
+ return agentView(await loadAgent(db, project.id, agentId));
+}
+
+/** The host a creation names, checked against the project and the caller's role: an app host must be a live app of the project. */
+async function resolveHost(db, project, role, host, settings) {
+ if (host.type === 'app') {
+ const app = store.APP_ID_RE.test(String(host.id))
+ ? await db.prepare('SELECT * FROM dev_apps WHERE id = ? AND project_id = ? AND revoked_at IS NULL').get(host.id, project.id) : null;
+ if (!app) fail(404, 'app.not_found', 'no such app');
+ if (app.environment === 'production' && !atLeast(role, 'admin')) fail(403, 'project.forbidden', 'requires admin role');
+ return { kind: 'app', app: app.id, service: null, environment: app.environment };
+ }
+ if (host.type === 'service') {
+ const svc = String(host.id || '');
+ if (!settings.agentHostServices.has(svc) || !await db.prepare('SELECT client_id FROM oauth_clients WHERE client_id = ?').get(svc)) {
+ fail(422, 'agent.host_not_allowed', `${svc.slice(0, 40) || 'that service'} does not host agents`);
+ }
+ return { kind: 'service', app: null, service: svc, environment: 'production' };
+ }
+ fail(422, 'agent.invalid', "host is { type: 'app', id: 'app_…' } or { type: 'service', id }");
+}
+
+/** Create an agent for the caller. An app host must be a live app of this project: anything else is app.not_found. */
+async function createAgent(db, actor, projectId, body, { ctx, settings }) {
+ await store.access(db, actor, projectId, { need: 'developer', staffOk: false });
+ if (body.owner !== undefined) {
+ const o = body.owner && typeof body.owner === 'object' ? body.owner.id : body.owner;
+ if (o !== actor.subject) fail(403, 'agent.forbidden', 'an agent is created for yourself only');
+ }
+ const name = cleanName(body.name);
+ const host = body.host && typeof body.host === 'object' ? body.host : {};
+ const id = ids.newId('agent');
+ const t = nowIso();
+ await db.tx(async () => {
+ // Under the project's lock, which an app revocation, a member's removal, an archive and an account erasure
+ // take before revoking agents: the project, the membership and the host are checked again here, so a
+ // cascade that commits first is seen, and one that comes after sees this agent and revokes it.
+ await store.lockProject(db, projectId);
+ const { project, role } = await store.access(db, actor, projectId, { need: 'developer', staffOk: false });
+ const h = await resolveHost(db, project, role, host, settings);
+ await db.prepare(`INSERT INTO dev_agents (id, project_id, owner_subject, host_kind, host_app_id, host_service, environment, name, created_at, created_by, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, project.id, actor.subject, h.kind, h.app, h.service, h.environment, name, t, actor.label, t);
+ await store.audit(db, { projectId: project.id, actor: actor.label, action: 'agent.created', target: `agent:${id}`,
+ detail: { name, host: { type: h.kind, id: h.app || h.service }, environment: h.environment }, ctx });
+ });
+ return agentView(await db.prepare('SELECT * FROM dev_agents WHERE id = ?').get(id));
+}
+
+async function renameAgent(db, actor, projectId, agentId, body, { ctx }) {
+ const { project, role } = await store.access(db, actor, projectId, { staffOk: false });
+ const a = await loadAgent(db, project.id, agentId);
+ if (a.owner_subject !== actor.subject && !atLeast(role, 'admin')) fail(403, 'agent.forbidden', "only the agent's owner or an admin renames it");
+ if (a.status === 'revoked') fail(409, 'agent.revoked', 'agent is revoked');
+ const name = cleanName(body.name);
+ await db.tx(async () => {
+ await db.prepare('UPDATE dev_agents SET name = ?, updated_at = ? WHERE id = ?').run(name, nowIso(), a.id);
+ await store.audit(db, { projectId: project.id, actor: actor.label, action: 'agent.renamed', target: `agent:${a.id}`, detail: { from: a.name, to: name }, ctx });
+ });
+ return agentView(await db.prepare('SELECT * FROM dev_agents WHERE id = ?').get(a.id));
+}
+
+/**
+ * Revoke every agent matching `where` that is not revoked yet, in the caller's transaction: one audit row each.
+ * `reason` is stored as revoked_by (app_revoked, project_archived, member_removed, account_deleted); `actor` is
+ * the subject label of whoever caused it. Returns how many were revoked.
+ */
+async function revokeWhere(db, where, params, { actor, reason, ctx }) {
+ const t = nowIso();
+ let n = 0;
+ const rows = await db.prepare(`SELECT id, project_id FROM dev_agents WHERE (${where}) AND status <> 'revoked' ORDER BY id`).all(...params);
+ for (const a of rows) {
+ const r = await db.prepare("UPDATE dev_agents SET status = 'revoked', revoked_at = ?, revoked_by = ?, updated_at = ? WHERE id = ? AND status <> 'revoked'").run(t, reason, t, a.id);
+ if (!r.changes) continue;
+ n++;
+ await store.audit(db, { projectId: a.project_id, actor, action: 'agent.revoked', target: `agent:${a.id}`, detail: { reason }, ctx });
+ }
+ return n;
+}
+
+const TO = { pause: 'paused', resume: 'active', revoke: 'revoked' };
+
+/** pause | resume | revoke. Staff may pause and revoke any project's agent, but not resume one. */
+async function changeStatus(db, actor, projectId, agentId, action, { ctx }) {
+ const staffOk = action !== 'resume';
+ const { project, role } = await store.access(db, actor, projectId, { staffOk, allowArchived: action === 'revoke' });
+ const a = await loadAgent(db, project.id, agentId);
+ if (!(role && a.owner_subject === actor.subject) && !atLeast(role, 'admin') && !(staffOk && actor.staff)) {
+ fail(403, 'agent.forbidden', `only the agent's owner, an admin${staffOk ? ' or staff' : ''} may ${action} it`);
+ }
+ const to = TO[action];
+ await db.tx(async () => {
+ // The status is read again under the row's lock and the update is conditioned on it: a revoke that commits
+ // while this runs (a person's, or a cascade's) is seen here, never undone by a resume that read `paused`.
+ const cur = await db.prepare('SELECT * FROM dev_agents WHERE id = ? FOR UPDATE').get(a.id);
+ if (cur.status === to) return;
+ if (cur.status === 'revoked') fail(409, 'agent.revoked', 'agent is revoked');
+ const t = nowIso();
+ const r = await db.prepare('UPDATE dev_agents SET status = ?, revoked_at = ?, revoked_by = ?, updated_at = ? WHERE id = ? AND status = ?')
+ .run(to, to === 'revoked' ? t : null, to === 'revoked' ? actor.label : null, t, a.id, cur.status);
+ 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 });
+ });
+ 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 };
@@ -5,8 +5,8 @@
@@ -5,8 +5,8 @@
*
*
* Grantability comes from the capability's `visibility` in openvibe-contracts:
* Grantability comes from the capability's `visibility` in openvibe-contracts:
* public grantable to apps (may appear in a project's allowance, including a default one)
* public grantable to apps (may appear in a project's allowance, including a default one)
- * partner (proposed; not in the contracts enum yet) grantable only when staff put it in one
- * project's allowance by hand, never through the default allowance
+ * partner grantable only when staff put it in one project's allowance by hand, never through
+ * the default or the sandbox allowance
* first-party never grantable to apps: only first-party service principals hold these
* first-party never grantable to apps: only first-party service principals hold these
* internal never grantable to apps
* internal never grantable to apps
* A capability must also be `active` (not planned, deprecated or retired) to be granted.
* A capability must also be `active` (not planned, deprecated or retired) to be granted.
@@ -16,6 +16,7 @@ const { capabilities } = require('openvibe-contracts');
@@ -16,6 +16,7 @@ const { capabilities } = require('openvibe-contracts');
const GRANTABLE_VISIBILITIES = new Set(['public', 'partner']);
const GRANTABLE_VISIBILITIES = new Set(['public', 'partner']);
const DEFAULT_ALLOWANCE_VISIBILITIES = new Set(['public']);
const DEFAULT_ALLOWANCE_VISIBILITIES = new Set(['public']);
const ENVIRONMENTS = ['sandbox', 'production'];
const ENVIRONMENTS = ['sandbox', 'production'];
+const DEFAULT_AGENT_HOST_SERVICES = ['actor'];
const ENVIRONMENT_POLICIES = { sandbox: ['sandbox'], 'sandbox+production': ['sandbox', 'production'] };
const ENVIRONMENT_POLICIES = { sandbox: ['sandbox'], 'sandbox+production': ['sandbox', 'production'] };
/** { grantable, code, reason } for one capability id. */
/** { grantable, code, reason } for one capability id. */
@@ -87,6 +88,8 @@ function settings(config) {
@@ -87,6 +88,8 @@ function settings(config) {
defaultAllowance: publicOnly(list(d.defaultAllowance)),
defaultAllowance: publicOnly(list(d.defaultAllowance)),
maxProjectsPerOwner: num(d.maxProjectsPerOwner, 10, 1, 1000),
maxProjectsPerOwner: num(d.maxProjectsPerOwner, 10, 1, 1000),
maxAppsPerProject: num(d.maxAppsPerProject, 20, 1, 1000),
maxAppsPerProject: num(d.maxAppsPerProject, 20, 1, 1000),
+ // First-party services that may host agents (OAuth client ids, AGENT_HOST_SERVICES). Unset = actor; empty = none.
+ agentHostServices: new Set(list(orDefault(d.agentHostServices, DEFAULT_AGENT_HOST_SERVICES))),
};
};
}
}
@@ -34,6 +34,14 @@ const cache = require('openvibe-shared/cache-policy');
@@ -34,6 +34,14 @@ const cache = require('openvibe-shared/cache-policy');
* POST /:project/apps/:app/grants/:capability/approve owner/admin, inside the allowance only
* POST /:project/apps/:app/grants/:capability/approve owner/admin, inside the allowance only
* POST /:project/apps/:app/grants/:capability/deny owner/admin
* POST /:project/apps/:app/grants/:capability/deny owner/admin
* DELETE /:project/apps/:app/grants/:capability owner/admin or staff
* DELETE /:project/apps/:app/grants/:capability owner/admin or staff
+ * GET /:project/agents[?owner=me] viewer+ (agents.js, plan T2 WS-Z2)
+ * POST /:project/agents { name, host } for yourself: developer+ (admin+ production app host);
+ * host { type: app, id } of this project or { type: service, id }
+ * GET /:project/agents/:agent viewer+
+ * PATCH /:project/agents/:agent { name } its owner or admin+
+ * 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/quotas viewer+
* GET /:project/quotas viewer+
* PUT /:project/quotas/:capability { limit, window, unit } staff
* PUT /:project/quotas/:capability { limit, window, unit } staff
* DELETE /:project/quotas/:capability staff
* DELETE /:project/quotas/:capability staff
@@ -47,6 +55,7 @@ const express = require('express');
@@ -47,6 +55,7 @@ const express = require('express');
const { http } = require('openvibe-contracts');
const { http } = require('openvibe-contracts');
const { verifySession } = require('../auth/session');
const { verifySession } = require('../auth/session');
const store = require('./store');
const store = require('./store');
+const agents = require('./agents');
const policy = require('./policy');
const policy = require('./policy');
const tokens = require('./tokens');
const tokens = require('./tokens');
const usage = require('./usage');
const usage = require('./usage');
@@ -120,6 +129,14 @@ function router() {
@@ -120,6 +129,14 @@ function router() {
r.post('/:project/apps/:app/grants/:capability/deny', handle(async (db, a, req, o) => await store.decideGrant(db, a, req.params.project, req.params.app, req.params.capability, 'denied', o)));
r.post('/:project/apps/:app/grants/:capability/deny', handle(async (db, a, req, o) => await store.decideGrant(db, a, req.params.project, req.params.app, req.params.capability, 'denied', o)));
r.delete('/:project/apps/:app/grants/:capability', handle(async (db, a, req, o) => await store.decideGrant(db, a, req.params.project, req.params.app, req.params.capability, 'revoked', o)));
r.delete('/:project/apps/:app/grants/:capability', handle(async (db, a, req, o) => await store.decideGrant(db, a, req.params.project, req.params.app, req.params.capability, 'revoked', o)));
+ r.get('/:project/agents', handle(async (db, a, req) => ({ agents: await agents.listAgents(db, a, req.params.project, { owner: req.query.owner }) })));
+ r.post('/:project/agents', handle(async (db, a, req, o) => await agents.createAgent(db, a, req.params.project, req.body || {}, o), 201));
+ r.get('/:project/agents/:agent', handle(async (db, a, req) => await agents.getAgent(db, a, req.params.project, req.params.agent)));
+ r.patch('/:project/agents/:agent', handle(async (db, a, req, o) => await agents.renameAgent(db, a, req.params.project, req.params.agent, req.body || {}, o)));
+ 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/quotas', handle(async (db, a, req) => ({ quotas: await store.listQuotas(db, a, req.params.project), note: 'quotas are enforced by the service that owns each capability; Network records and exposes them' })));
r.get('/:project/quotas', handle(async (db, a, req) => ({ quotas: await store.listQuotas(db, a, req.params.project), note: 'quotas are enforced by the service that owns each capability; Network records and exposes them' })));
r.put('/:project/quotas/:capability', handle(async (db, a, req, o) => await store.setQuota(db, a, req.params.project, req.params.capability, req.body || {}, o)));
r.put('/:project/quotas/:capability', handle(async (db, a, req, o) => await store.setQuota(db, a, req.params.project, req.params.capability, req.body || {}, o)));
r.delete('/:project/quotas/:capability', handle(async (db, a, req, o) => await store.deleteQuota(db, a, req.params.project, req.params.capability, o)));
r.delete('/:project/quotas/:capability', handle(async (db, a, req, o) => await store.deleteQuota(db, a, req.params.project, req.params.capability, o)));
@@ -103,6 +103,16 @@ async function memberRole(db, projectId, subject) {
@@ -103,6 +103,16 @@ async function memberRole(db, projectId, subject) {
return r ? r.role : null;
return r ? r.role : null;
}
}
+/**
+ * Take the project row's lock for the rest of the caller's transaction. Every transaction that revokes agents as a
+ * cascade (an app's revocation, a member's removal, the project's archive, an account erasure) takes it first, and
+ * agent creation takes it before re-checking the project, the membership and the host: so an agent is never
+ * created against a state one of them is about to end. NO KEY UPDATE, so inserts referencing the row still pass.
+ */
+async function lockProject(db, projectId) {
+ return await db.prepare('SELECT * FROM dev_projects WHERE id = ? FOR NO KEY UPDATE').get(projectId);
+}
+
/**
/**
* Load a project the actor may see. Non-members get 404 (existence is not disclosed); staff see all.
* Load a project the actor may see. Non-members get 404 (existence is not disclosed); staff see all.
* `need` is the minimum member role; `staffOk` lets staff through regardless of membership.
* `need` is the minimum member role; `staffOk` lets staff through regardless of membership.
@@ -179,10 +189,13 @@ async function renameProject(db, actor, projectId, { name }, { ctx, settings })
@@ -179,10 +189,13 @@ async function renameProject(db, actor, projectId, { name }, { ctx, settings })
return await projectView(db, await db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(project.id), role, settings);
return await projectView(db, await db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(project.id), role, settings);
}
}
-/** Archive: irreversible. Every app of the project is revoked (credentials included). */
+/** Archive: irreversible. Every agent and app of the project is revoked (credentials included). */
async function archiveProject(db, actor, projectId, { ctx, settings }) {
async function archiveProject(db, actor, projectId, { ctx, settings }) {
const { project, role } = await access(db, actor, projectId, { need: 'owner', staffOk: true });
const { project, role } = await access(db, actor, projectId, { need: 'owner', staffOk: true });
await db.tx(async () => {
await db.tx(async () => {
+ await lockProject(db, project.id);
+ // Agents first, so each records the archive as its cause rather than its app's revocation.
+ await require('./agents').revokeWhere(db, 'project_id = ?', [project.id], { actor: actor.label, reason: 'project_archived', ctx });
for (const app of await db.prepare('SELECT * FROM dev_apps WHERE project_id = ? AND revoked_at IS NULL').all(project.id)) await revokeAppTx(db, actor, project, app, ctx, 'project archived');
for (const app of await db.prepare('SELECT * FROM dev_apps WHERE project_id = ? AND revoked_at IS NULL').all(project.id)) await revokeAppTx(db, actor, project, app, ctx, 'project archived');
await db.prepare('UPDATE dev_projects SET archived_at = ?, archived_by = ? WHERE id = ?').run(nowIso(), actor.label, project.id);
await db.prepare('UPDATE dev_projects SET archived_at = ?, archived_by = ? WHERE id = ?').run(nowIso(), actor.label, project.id);
await audit(db, { projectId: project.id, actor: actor.label, action: 'project.archived', target: project.id, ctx });
await audit(db, { projectId: project.id, actor: actor.label, action: 'project.archived', target: project.id, ctx });
@@ -322,8 +335,10 @@ async function removeMember(db, actor, projectId, subject, { ctx }) {
@@ -322,8 +335,10 @@ async function removeMember(db, actor, projectId, subject, { ctx }) {
if (current === 'owner') fail(403, 'member.forbidden', 'the owner cannot be removed');
if (current === 'owner') fail(403, 'member.forbidden', 'the owner cannot be removed');
if (!self && !canAssign(role, current)) fail(403, 'member.forbidden', `a ${role} cannot remove a ${current}`);
if (!self && !canAssign(role, current)) fail(403, 'member.forbidden', `a ${role} cannot remove a ${current}`);
await db.tx(async () => {
await db.tx(async () => {
+ await lockProject(db, project.id);
await db.prepare('DELETE FROM dev_project_members WHERE project_id = ? AND subject_id = ?').run(project.id, subject);
await db.prepare('DELETE FROM dev_project_members WHERE project_id = ? AND subject_id = ?').run(project.id, subject);
await audit(db, { projectId: project.id, actor: actor.label, action: self ? 'member.left' : 'member.removed', target: `user:${subject}`, detail: { role: current }, ctx });
await audit(db, { projectId: project.id, actor: actor.label, action: self ? 'member.left' : 'member.removed', target: `user:${subject}`, detail: { role: current }, ctx });
+ await require('./agents').revokeWhere(db, 'project_id = ? AND owner_subject = ?', [project.id, subject], { actor: actor.label, reason: 'member_removed', ctx });
});
});
}
}
@@ -431,12 +446,15 @@ async function updateApp(db, actor, projectId, appId, body, { ctx }) {
@@ -431,12 +446,15 @@ async function updateApp(db, actor, projectId, appId, body, { ctx }) {
}
}
async function revokeAppTx(db, actor, project, app, ctx, reason) {
async function revokeAppTx(db, actor, project, app, ctx, reason) {
+ await lockProject(db, project.id);
const t = nowIso();
const t = nowIso();
await db.prepare('UPDATE dev_apps SET revoked_at = ?, revoked_by = ? WHERE id = ?').run(t, actor.label, app.id);
await db.prepare('UPDATE dev_apps SET revoked_at = ?, revoked_by = ? WHERE id = ?').run(t, actor.label, app.id);
await db.prepare('UPDATE dev_credentials SET revoked_at = ?, revoked_by = ? WHERE app_id = ? AND revoked_at IS NULL').run(t, actor.label, app.id);
await db.prepare('UPDATE dev_credentials SET revoked_at = ?, revoked_by = ? WHERE app_id = ? AND revoked_at IS NULL').run(t, actor.label, app.id);
await db.prepare('UPDATE dev_auth_codes SET used = 1 WHERE app_id = ?').run(app.id);
await db.prepare('UPDATE dev_auth_codes SET used = 1 WHERE app_id = ?').run(app.id);
await audit(db, { projectId: project.id, actor: actor.label, action: 'app.revoked', target: `app:${app.id}`, detail: { reason: reason || null }, ctx,
await audit(db, { projectId: project.id, actor: actor.label, action: 'app.revoked', target: `app:${app.id}`, detail: { reason: reason || null }, ctx,
event: { type: 'network.app.revoked', subject: { type: 'app', id: app.id }, payload: { project_id: project.id, environment: app.environment, reason: reason || null } } });
event: { type: 'network.app.revoked', subject: { type: 'app', id: app.id }, payload: { project_id: project.id, environment: app.environment, reason: reason || null } } });
+ // Its agents go with it (WS-Z2). Required here, not at the top: agents.js requires this module.
+ await require('./agents').revokeWhere(db, 'host_app_id = ?', [app.id], { actor: actor.label, reason: 'app_revoked', ctx });
}
}
/** Revoke an app: immediate for new tokens; issued tokens expire within their 5-minute lifetime. */
/** Revoke an app: immediate for new tokens; issued tokens expire within their 5-minute lifetime. */
@@ -650,7 +668,7 @@ async function deleteQuota(db, actor, projectId, capability, { ctx }) {
@@ -650,7 +668,7 @@ async function deleteQuota(db, actor, projectId, capability, { ctx }) {
module.exports = {
module.exports = {
ensureSchema, DevError, ROLES, APP_ID_RE, PROJECT_ID_RE, SECRET_PREFIX,
ensureSchema, DevError, ROLES, APP_ID_RE, PROJECT_ID_RE, SECRET_PREFIX,
- audit, listAudit, actorOf, access, memberRole,
+ audit, listAudit, actorOf, access, memberRole, lockProject,
createProject, listProjects, renameProject, archiveProject, setAllowance, setEnvironmentPolicy, setPlacement, projectView,
createProject, listProjects, renameProject, archiveProject, setAllowance, setEnvironmentPolicy, setPlacement, projectView,
listMembers, addMember, updateMember, removeMember,
listMembers, addMember, updateMember, removeMember,
listApps, getApp, createApp, updateApp, revokeApp,
listApps, getApp, createApp, updateApp, revokeApp,
@@ -28,6 +28,7 @@ const fs = require('fs');
@@ -28,6 +28,7 @@ const fs = require('fs');
const path = require('path');
const path = require('path');
const { ids, validate, staff } = require('openvibe-contracts');
const { ids, validate, staff } = require('openvibe-contracts');
const eventRelay = require('../developer/event-relay');
const eventRelay = require('../developer/event-relay');
+const agents = require('../developer/agents');
const wallet = require('../coins/wallet');
const wallet = require('../coins/wallet');
const revocation = require('../auth/revocation');
const revocation = require('../auth/revocation');
const zip = require('../utils/zip');
const zip = require('../utils/zip');
@@ -197,6 +198,8 @@ async function networkPart(db, userId) {
@@ -197,6 +198,8 @@ async function networkPart(db, userId) {
add('developer_projects.json', {
add('developer_projects.json', {
owned: (await rowsOf(db, 'dev_projects', 'owner_subject = ?', [u.subject_id])).rows,
owned: (await rowsOf(db, 'dev_projects', 'owner_subject = ?', [u.subject_id])).rows,
memberships: (await rowsOf(db, 'dev_project_members', 'subject_id = ?', [u.subject_id], { omit: ['added_by'] })).rows,
memberships: (await rowsOf(db, 'dev_project_members', 'subject_id = ?', [u.subject_id], { omit: ['added_by'] })).rows,
+ // The agents that act for this person (WS-Z2); who revoked one may be someone else, so revoked_by stays out.
+ agents: (await rowsOf(db, 'dev_agents', 'owner_subject = ?', [u.subject_id], { omit: ['revoked_by'] })).rows,
});
});
add('account_merges.json', await tableExists(db, 'account_merges')
add('account_merges.json', await tableExists(db, 'account_merges')
? await db.prepare('SELECT id, from_subject, into_subject, initiated_by, reason, moved, merged_at FROM account_merges WHERE into_user_id = ? ORDER BY merged_at').all(u.id) : []);
? await db.prepare('SELECT id, from_subject, into_subject, initiated_by, reason, moved, merged_at FROM account_merges WHERE into_user_id = ? ORDER BY merged_at').all(u.id) : []);
@@ -339,6 +342,12 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
@@ -339,6 +342,12 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
await del('user_modules', `subject_id IN ${inS}`, subjects, 'modules');
await del('user_modules', `subject_id IN ${inS}`, subjects, 'modules');
await del('user_follows', `follower_subject IN ${inS} OR target_id IN ${inS}`, [...subjects, ...subjects], 'follows');
await del('user_follows', `follower_subject IN ${inS} OR target_id IN ${inS}`, [...subjects, ...subjects], 'follows');
await del('user_blocks', `blocker_subject IN ${inS} OR blocked_subject IN ${inS}`, [...subjects, ...subjects], 'blocks');
await del('user_blocks', `blocker_subject IN ${inS} OR blocked_subject IN ${inS}`, [...subjects, ...subjects], 'blocks');
+ // The person's agents first (WS-Z2), then the agents of the projects archived below.
+ // Their projects' locks first, as agent creation takes them (developer/store.lockProject): no agent of theirs is created after this.
+ if (await tableExists(db, 'dev_projects')) {
+ await db.prepare(`SELECT p.id FROM dev_projects p JOIN dev_project_members m ON m.project_id = p.id WHERE m.subject_id IN ${inS} ORDER BY p.id FOR NO KEY UPDATE OF p`).all(...subjects);
+ }
+ if (await tableExists(db, 'dev_agents')) count('agents_revoked', await agents.revokeWhere(db, `owner_subject IN ${inS}`, subjects, { actor: 'system:network', reason: 'account_deleted' }));
if (await tableExists(db, 'dev_projects')) {
if (await tableExists(db, 'dev_projects')) {
for (const p of await db.prepare(`SELECT id FROM dev_projects WHERE owner_subject IN ${inS} AND archived_at IS NULL`).all(...subjects)) {
for (const p of await db.prepare(`SELECT id FROM dev_projects WHERE owner_subject IN ${inS} AND archived_at IS NULL`).all(...subjects)) {
const heir = await db.prepare(`SELECT subject_id FROM dev_project_members WHERE project_id = ? AND subject_id NOT IN ${inS} AND role IN ('owner', 'admin')
const heir = await db.prepare(`SELECT subject_id FROM dev_project_members WHERE project_id = ? AND subject_id NOT IN ${inS} AND role IN ('owner', 'admin')
@@ -348,6 +357,7 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
@@ -348,6 +357,7 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
await db.prepare("UPDATE dev_apps SET revoked_at = ?, revoked_by = 'account_deleted' WHERE id = ?").run(at, a.id);
await db.prepare("UPDATE dev_apps SET revoked_at = ?, revoked_by = 'account_deleted' WHERE id = ?").run(at, a.id);
await db.prepare("UPDATE dev_credentials SET revoked_at = ?, revoked_by = 'account_deleted' WHERE app_id = ? AND revoked_at IS NULL").run(at, a.id);
await db.prepare("UPDATE dev_credentials SET revoked_at = ?, revoked_by = 'account_deleted' WHERE app_id = ? AND revoked_at IS NULL").run(at, a.id);
}
}
+ if (await tableExists(db, 'dev_agents')) count('agents_revoked', await agents.revokeWhere(db, 'project_id = ?', [p.id], { actor: 'system:network', reason: 'project_archived' }));
await db.prepare("UPDATE dev_projects SET archived_at = ?, archived_by = 'account_deleted' WHERE id = ?").run(at, p.id);
await db.prepare("UPDATE dev_projects SET archived_at = ?, archived_by = 'account_deleted' WHERE id = ?").run(at, p.id);
count('projects_archived', 1);
count('projects_archived', 1);
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
+'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
+// migrations by id but refuses a pending file numbered below one already applied.
+// node test/agent-schema.test.js
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { createTestDb } = require('openvibe-sdk/testing');
+const { createDb } = require('openvibe-sdk/db');
+
+const MIGRATIONS = path.join(__dirname, '..', 'migrations');
+const quiet = { log() {}, warn() {}, error() {} };
+
+(async () => {
+ // A copy of the migrations up to 0015: what production ran before this release.
+ const before = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-agent-schema-'));
+ for (const f of fs.readdirSync(MIGRATIONS)) if (/^\d{4}_.*\.sql$/.test(f) && f.slice(0, 4) <= '0015') fs.copyFileSync(path.join(MIGRATIONS, f), path.join(before, f));
+ assert.ok(fs.existsSync(path.join(MIGRATIONS, '0016_agents.sql')));
+ assert.ok(!fs.readdirSync(MIGRATIONS).some((f) => /^001[0-3]_/.test(f)), '0010-0013 stay unused');
+
+ const t = await createTestDb({ migrations: before, store: process.env.NETWORK_TEST_STORE || 'pglite', service: 'network', log: quiet });
+ // Schema changes run as the owner (DATABASE_DIRECT_URL), as server/db/database.js does; PGlite has one role.
+ const asOwner = async (fn) => {
+ if (!t.directUrl) return fn(t.db);
+ const owner = createDb({ url: t.directUrl, service: 'network-test-owner', max: 1, log: quiet });
+ try { return await fn(owner); } finally { await owner.close(); }
+ };
+ const applied = async () => (await t.db.prepare('SELECT id FROM ov_migrations ORDER BY id').all()).map((r) => r.id);
+ const hasAgents = async () => !!await t.db.prepare("SELECT 1 AS ok FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = 'dev_agents'").get();
+ try {
+ assert.strictEqual((await applied()).pop(), '0015');
+ assert.ok(!await hasAgents());
+
+ // Step 0: a file numbered below an applied one is refused, never silently skipped.
+ const probe = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-agent-probe-'));
+ for (const f of fs.readdirSync(before)) fs.copyFileSync(path.join(before, f), path.join(probe, f));
+ fs.copyFileSync(path.join(MIGRATIONS, '0016_agents.sql'), path.join(probe, '0010_agents.sql'));
+ await assert.rejects(asOwner((db) => db.migrate({ dir: probe, log: quiet })), /0010_agents\.sql is older than applied migration 001[45]/);
+ assert.ok(!await hasAgents(), 'the refused file left nothing behind');
+
+ // 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.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')));
+
+ // 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.
+ const prj = `prj_${'1'.repeat(26)}`, other = `prj_${'2'.repeat(26)}`, app = `app_${'3'.repeat(26)}`, usr = `usr_${'4'.repeat(26)}`;
+ for (const p of [prj, other]) {
+ await t.db.prepare("INSERT INTO dev_projects (id, owner_subject, name, environment_policy, allowance, created_at, created_by) VALUES (?, ?, 'p', 'sandbox', '[]', ?, 'test')").run(p, usr, new Date().toISOString());
+ }
+ await t.db.prepare("INSERT INTO dev_apps (id, project_id, name, environment, oauth_client_id, client_type, created_at, created_by) VALUES (?, ?, 'a', 'sandbox', ?, 'public', ?, 'test')").run(app, prj, app, new Date().toISOString());
+ const agent = (id, projectId, env) => t.db.prepare("INSERT INTO dev_agents (id, project_id, owner_subject, host_kind, host_app_id, environment, name, created_by) VALUES (?, ?, ?, 'app', ?, ?, 'n', 'test')")
+ .run(id, projectId, usr, app, env);
+ await agent(`agt_${'5'.repeat(26)}`, prj, 'sandbox');
+ const r = await t.db.prepare('SELECT created_at, updated_at, status FROM dev_agents').get();
+ assert.match(r.created_at, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
+ 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');
+ console.log('agent schema: all tests passed');
+ } finally {
+ await t.close();
+ }
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,184 @@
@@ -0,0 +1,184 @@
+'use strict';
+// Agents under concurrent writers (plan T2 WS-Z2 slice 2, server/developer/agents.js): one transaction is held open
+// at a chosen point while a competing request runs against it, then let go. On PostgreSQL (npm run test:pg) the
+// competing request really runs and must wait on a lock; on PGlite, one connection, it waits its turn, so the same
+// assertions hold there too. Covers creation against an app revocation, a member's removal and a project archive
+// (each order), and resume against a revoke (a person's, and an app revocation's cascade).
+// node test/agents-concurrency.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 agents = require('../server/developer/agents');
+const store = require('../server/developer/store');
+
+(async () => {
+const log = console.log; console.log = () => {};
+const db = getDb();
+console.log = log;
+const PG = process.env.NETWORK_TEST_STORE === 'pg';
+const USERS = [[10, 'owner'], [11, 'dev'], [12, 'leaver'], [13, 'stayer']];
+await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES ${USERS.map(([id, n]) => `(${id}, '${n}', 'x', 'user')`).join(', ')}`).run();
+await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris) VALUES ('actor', 'x', 'OpenVibe.Actor', '[]')").run();
+const S = {};
+for (const [id, name] of USERS) 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: '', 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 USERS) T[name] = jwt.sign({ sub: id, id }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
+
+// Hold the first transaction that reaches `point` (agents.revokeWhere or store.audit, matched by `when`) until the
+// competing request is waiting on it: on PostgreSQL, until another session is seen waiting on a lock, which must
+// happen within 10 s and before the competing request finishes (else the interleaving was not tested and this
+// fails); on PGlite, briefly. The hold is always let go, so a failure never hangs the test.
+const realRevokeWhere = agents.revokeWhere;
+const realAudit = store.audit;
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+async function interleave({ point, when, first, second }) {
+ let entered, release;
+ const reached = new Promise((r) => { entered = r; });
+ const gate = new Promise((r) => { release = r; });
+ let armed = true;
+ const hold = async (args) => { if (armed && when(...args)) { armed = false; entered(); await gate; } };
+ if (point === 'revokeWhere') agents.revokeWhere = async (...a) => { const n = await realRevokeWhere(...a); await hold([a[3]]); return n; };
+ else store.audit = async (...a) => { await realAudit(...a); await hold([a[1]]); };
+ let one, two;
+ try {
+ one = first();
+ await Promise.race([reached, one.then(() => { throw new Error('the first request finished without reaching the hold point'); })]);
+ let settled = false;
+ two = second();
+ two.then(() => { settled = true; }, () => { settled = true; });
+ if (PG) {
+ const until = Date.now() + 10000;
+ for (;;) {
+ const waiting = (await db.prepare("SELECT COUNT(*) AS n FROM pg_stat_activity WHERE datname = current_database() AND pid <> pg_backend_pid() AND wait_event_type = 'Lock'").get()).n;
+ if (Number(waiting) > 0) break;
+ assert.ok(!settled, 'the competing request finished without waiting on the held transaction');
+ assert.ok(Date.now() < until, 'the competing request never waited on a lock held by the first transaction');
+ await sleep(20);
+ }
+ } else await sleep(100);
+ release();
+ return await Promise.all([one, two]);
+ } catch (err) {
+ release();
+ await Promise.allSettled([one, two].filter(Boolean));
+ throw err;
+ } finally {
+ agents.revokeWhere = realRevokeWhere;
+ store.audit = realAudit;
+ }
+}
+
+(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: { authorization: `Bearer ${T[who]}`, ...(body ? { 'content-type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined });
+ const text = await r.text();
+ return { status: r.status, body: text ? JSON.parse(text) : null };
+ };
+ const ok = (r, status = 200) => { assert.strictEqual(r.status, status, JSON.stringify(r.body)); return r.body; };
+ const refused = (r, status, code) => { assert.strictEqual(r.status, status, JSON.stringify(r.body)); assert.strictEqual(r.body.code, code); };
+ const row = async (id) => await db.prepare('SELECT * FROM dev_agents WHERE id = ?').get(id);
+ const live = async (where, ...params) => (await db.prepare(`SELECT COUNT(*) AS n FROM dev_agents WHERE status <> 'revoked' AND ${where}`).get(...params)).n;
+ const project = async (name) => {
+ const id = ok(await api('owner', 'POST', '', { name }), 201).id;
+ for (const u of ['dev', 'leaver', 'stayer']) ok(await api('owner', 'POST', `/${id}/members`, { username: u, role: 'developer' }), 201);
+ return id;
+ };
+ const sandboxApp = async (p, name) => ok(await api('dev', 'POST', `/${p}/apps`, { name, environment: 'sandbox' }), 201).id;
+ const actorHost = { type: 'service', id: 'actor' };
+ try {
+ const P = await project('Concurrent');
+
+ // ── Creation against an app's revocation ──
+ // The revocation first: the creation that read the app as live re-checks it and finds it revoked.
+ const A1 = await sandboxApp(P, 'A1');
+ let [rev, made] = await interleave({ point: 'revokeWhere', when: (o) => o.reason === 'app_revoked',
+ first: () => api('dev', 'DELETE', `/${P}/apps/${A1}`), second: () => api('dev', 'POST', `/${P}/agents`, { name: 'Late', host: { type: 'app', id: A1 } }) });
+ ok(rev);
+ refused(made, 404, 'app.not_found');
+ assert.strictEqual(await live('host_app_id = ?', A1), 0, 'no live agent on a revoked app');
+ // The creation first: the revocation waits for it and takes the new agent with the app.
+ const A2 = await sandboxApp(P, 'A2');
+ [made, rev] = await interleave({ point: 'audit', when: (e) => e.action === 'agent.created',
+ first: () => api('dev', 'POST', `/${P}/agents`, { name: 'Early', host: { type: 'app', id: A2 } }), second: () => api('dev', 'DELETE', `/${P}/apps/${A2}`) });
+ ok(rev);
+ assert.deepStrictEqual([(await row(ok(made, 201).id)).status, (await row(made.body.id)).revoked_by], ['revoked', 'app_revoked']);
+
+ // ── Creation against a member's removal ──
+ let removed;
+ [removed, made] = await interleave({ point: 'revokeWhere', when: (o) => o.reason === 'member_removed',
+ first: () => api('owner', 'DELETE', `/${P}/members/${S.leaver}`), second: () => api('leaver', 'POST', `/${P}/agents`, { name: 'Late', host: actorHost }) });
+ ok(removed, 204);
+ refused(made, 404, 'project.not_found');
+ assert.strictEqual(await live('project_id = ? AND owner_subject = ?', P, S.leaver), 0, 'no live agent of a removed member');
+ [made, removed] = await interleave({ point: 'audit', when: (e) => e.action === 'agent.created',
+ first: () => api('stayer', 'POST', `/${P}/agents`, { name: 'Early', host: actorHost }), second: () => api('owner', 'DELETE', `/${P}/members/${S.stayer}`) });
+ ok(removed, 204);
+ assert.deepStrictEqual([(await row(ok(made, 201).id)).status, (await row(made.body.id)).revoked_by], ['revoked', 'member_removed']);
+
+ // ── Creation against the project's archive ──
+ const P2 = await project('Archived first');
+ let archived;
+ [archived, made] = await interleave({ point: 'revokeWhere', when: (o) => o.reason === 'project_archived',
+ first: () => api('owner', 'POST', `/${P2}/archive`), second: () => api('dev', 'POST', `/${P2}/agents`, { name: 'Late', host: actorHost }) });
+ ok(archived);
+ refused(made, 409, 'project.archived');
+ assert.strictEqual(await live('project_id = ?', P2), 0, 'no live agent in an archived project');
+ const P3 = await project('Created first');
+ [made, archived] = await interleave({ point: 'audit', when: (e) => e.action === 'agent.created',
+ first: () => api('dev', 'POST', `/${P3}/agents`, { name: 'Early', host: actorHost }), second: () => api('owner', 'POST', `/${P3}/archive`) });
+ ok(archived);
+ assert.deepStrictEqual([(await row(ok(made, 201).id)).status, (await row(made.body.id)).revoked_by], ['revoked', 'project_archived']);
+
+ // ── Resume against revoke ──
+ // A revoke in flight is never undone by a resume that read the agent as paused.
+ const paused = async (host) => {
+ const a = ok(await api('dev', 'POST', `/${P}/agents`, { name: 'Paused', host }), 201);
+ ok(await api('dev', 'POST', `/${P}/agents/${a.id}/pause`));
+ return a.id;
+ };
+ let g = await paused(actorHost), resumed;
+ [rev, resumed] = await interleave({ point: 'audit', when: (e) => e.action === 'agent.revoked',
+ first: () => api('owner', 'DELETE', `/${P}/agents/${g}`), second: () => api('dev', 'POST', `/${P}/agents/${g}/resume`) });
+ ok(rev);
+ refused(resumed, 409, 'agent.revoked');
+ assert.deepStrictEqual([(await row(g)).status, !!(await row(g)).revoked_at], ['revoked', true]);
+ // The same against an app revocation's cascade.
+ const A3 = await sandboxApp(P, 'A3');
+ g = await paused({ type: 'app', id: A3 });
+ [rev, resumed] = await interleave({ point: 'revokeWhere', when: (o) => o.reason === 'app_revoked',
+ first: () => api('dev', 'DELETE', `/${P}/apps/${A3}`), second: () => api('dev', 'POST', `/${P}/agents/${g}/resume`) });
+ ok(rev);
+ refused(resumed, 409, 'agent.revoked');
+ assert.deepStrictEqual([(await row(g)).status, (await row(g)).revoked_by], ['revoked', 'app_revoked']);
+ // The resume first: the revoke waits for it and still wins.
+ g = await paused(actorHost);
+ [resumed, rev] = await interleave({ point: 'audit', when: (e) => e.action === 'agent.resumed',
+ first: () => api('dev', 'POST', `/${P}/agents/${g}/resume`), second: () => api('owner', 'DELETE', `/${P}/agents/${g}`) });
+ assert.strictEqual(ok(resumed).status, 'active');
+ assert.strictEqual(ok(rev).status, 'revoked');
+ assert.strictEqual((await row(g)).status, 'revoked');
+ const trail = (await db.prepare('SELECT action FROM dev_audit WHERE target = ? ORDER BY id').all(`agent:${g}`)).map((a) => a.action);
+ assert.deepStrictEqual(trail, ['agent.created', 'agent.paused', 'agent.resumed', 'agent.revoked']);
+
+ console.log(`agents-concurrency (${PG ? 'PostgreSQL' : 'PGlite'}): all tests passed`);
+ } finally {
+ server.close();
+ }
+})().catch((err) => { console.error(err); process.exit(1); });
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,197 @@
@@ -0,0 +1,197 @@
+'use strict';
+// Agents (plan T2 WS-Z2 slice 2, docs/t2-projects-and-grants.md; server/developer/agents.js): who may create,
+// rename, pause, resume and revoke; an agent is only ever its creator's; AGENT_HOST_SERVICES; an app of another
+// project is not a host (404, and the composite foreign key); the CHECK constraints; and the cascades from an app
+// revocation, a member's removal, an account erasure and a project archive.
+// node test/agents.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 accountData = require('../server/identity/account-data');
+
+(async () => {
+const log = console.log; console.log = () => {};
+const db = getDb();
+console.log = log;
+await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES
+ (10, 'owner', 'x', 'user'), (11, 'dev', 'x', 'user'), (12, 'viewer', 'x', 'user'), (13, 'stranger', 'x', 'user'),
+ (14, 'staff', 'x', 'admin'), (15, 'admin', 'x', 'user'), (16, 'leaver', 'x', 'user'), (17, 'erased', 'x', 'user')`).run();
+await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris) VALUES ('actor', 'x', 'OpenVibe.Actor', '[]')").run();
+const S = {};
+for (const [id, name] of [[10, 'owner'], [11, 'dev'], [12, 'viewer'], [13, 'stranger'], [14, 'staff'], [15, 'admin'], [16, 'leaver'], [17, 'erased']]) {
+ 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;
+// 'ghost' is listed but is no OAuth client; 'live' is an OAuth client but not listed. Neither hosts agents.
+app.locals.config = { baseUrl: ISSUER, jwt: { issuer: ISSUER, accessTokenExpiry: '1h' }, developer: { sandboxAllowance: '', agentHostServices: 'actor,ghost' } };
+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 [[10, 'owner'], [11, 'dev'], [12, 'viewer'], [13, 'stranger'], [14, 'staff'], [15, 'admin'], [16, 'leaver'], [17, 'erased']]) {
+ 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 row = async (id) => await db.prepare('SELECT * FROM dev_agents WHERE id = ?').get(id);
+ try {
+ // ── A project with every role, a sandbox and a production app, and another project ──
+ const P = ok(await api('owner', 'POST', '', { name: 'Agents' }), 201).id;
+ for (const [name, role] of [['dev', 'developer'], ['viewer', 'viewer'], ['admin', 'admin'], ['leaver', 'developer'], ['erased', 'developer']]) {
+ ok(await api('owner', 'POST', `/${P}/members`, { username: name, role }), 201);
+ }
+ ok(await api('staff', 'PUT', `/${P}/environment-policy`, { environment_policy: 'sandbox+production' }));
+ const SB = ok(await api('dev', 'POST', `/${P}/apps`, { name: 'Bot sandbox', environment: 'sandbox' }), 201).id;
+ const PR = ok(await api('owner', 'POST', `/${P}/apps`, { name: 'Bot', environment: 'production' }), 201).id;
+ const Q = ok(await api('stranger', 'POST', '', { name: 'Elsewhere' }), 201).id;
+ const QA = ok(await api('stranger', 'POST', `/${Q}/apps`, { name: 'Theirs', environment: 'sandbox' }), 201).id;
+ const sandboxHost = { type: 'app', id: SB };
+
+ // ── Authentication: user tokens only ──
+ refused(await api(null, 'GET', `/${P}/agents`), 401, 'auth.required');
+
+ // ── Create: for yourself, by role and host ──
+ refused(await api('viewer', 'POST', `/${P}/agents`, { name: 'x', host: sandboxHost }), 403, 'project.forbidden');
+ refused(await api('staff', 'POST', `/${P}/agents`, { name: 'x', host: sandboxHost }), 403, 'project.forbidden');
+ refused(await api('stranger', 'POST', `/${P}/agents`, { name: 'x', host: sandboxHost }), 404, 'project.not_found');
+ refused(await api('dev', 'POST', `/${P}/agents`, { name: 'x', host: { type: 'app', id: PR } }), 403, 'project.forbidden');
+ refused(await api('dev', 'POST', `/${P}/agents`, { name: 'x', host: sandboxHost, owner: { type: 'user', id: S.owner } }), 403, 'agent.forbidden');
+ refused(await api('admin', 'POST', `/${P}/agents`, { name: 'x', host: sandboxHost, owner: S.dev }), 403, 'agent.forbidden');
+ // Another project's app is answered exactly like an unknown id, so app ids are not probed.
+ refused(await api('dev', 'POST', `/${P}/agents`, { name: 'x', host: { type: 'app', id: QA } }), 404, 'app.not_found');
+ refused(await api('dev', 'POST', `/${P}/agents`, { name: 'x', host: { type: 'app', id: `app_${'0'.repeat(26)}` } }), 404, 'app.not_found');
+ refused(await api('dev', 'POST', `/${P}/agents`, { name: 'x', host: { type: 'service', id: 'live' } }), 422, 'agent.host_not_allowed');
+ refused(await api('dev', 'POST', `/${P}/agents`, { name: 'x', host: { type: 'service', id: 'ghost' } }), 422, 'agent.host_not_allowed');
+ refused(await api('dev', 'POST', `/${P}/agents`, { name: 'x', host: { type: 'mod', id: 'mod_x' } }), 422, 'agent.invalid');
+ refused(await api('dev', 'POST', `/${P}/agents`, { name: ' ', host: sandboxHost }), 422, 'agent.invalid');
+
+ const devBot = ok(await api('dev', 'POST', `/${P}/agents`, { name: 'Release bot', host: sandboxHost, owner: { type: 'user', id: S.dev } }), 201);
+ assert.match(devBot.id, /^agt_[0-9A-HJKMNP-TV-Z]{26}$/);
+ for (const ref of [devBot.subject, devBot.owner, devBot.host]) assert.ok(validate('identity.subject-ref@1', ref).valid, JSON.stringify(ref));
+ assert.deepStrictEqual([devBot.project_id, devBot.owner.id, devBot.environment, devBot.status, devBot.revoked_at], [P, S.dev, 'sandbox', 'active', null]);
+ const adminBot = ok(await api('admin', 'POST', `/${P}/agents`, { name: 'Prod bot', host: { type: 'app', id: PR } }), 201);
+ assert.strictEqual(adminBot.environment, 'production');
+ const actorBot = ok(await api('dev', 'POST', `/${P}/agents`, { name: 'Actor helper', host: { type: 'service', id: 'actor' } }), 201);
+ assert.deepStrictEqual([actorBot.host, actorBot.environment], [{ type: 'service', id: 'actor' }, 'production']);
+ assert.ok(validate('identity.subject-ref@1', actorBot.host).valid);
+ assert.strictEqual((await row(devBot.id)).created_by, `user:${S.dev}`);
+
+ // ── Read: members and staff; nobody else, and never through another project ──
+ assert.strictEqual(ok(await api('viewer', 'GET', `/${P}/agents`)).agents.length, 3);
+ assert.deepStrictEqual(ok(await api('dev', 'GET', `/${P}/agents?owner=me`)).agents.map((a) => a.id).sort(), [devBot.id, actorBot.id].sort());
+ assert.strictEqual(ok(await api('staff', 'GET', `/${P}/agents`)).agents.length, 3);
+ refused(await api('stranger', 'GET', `/${P}/agents`), 404, 'project.not_found');
+ refused(await api('stranger', 'GET', `/${Q}/agents/${devBot.id}`), 404, 'agent.not_found');
+ assert.deepStrictEqual(ok(await api('viewer', 'GET', `/${P}/agents/${devBot.id}`)), devBot);
+
+ // ── Rename: the owner or an admin+ ──
+ refused(await api('viewer', 'PATCH', `/${P}/agents/${devBot.id}`, { name: 'Mine now' }), 403, 'agent.forbidden');
+ refused(await api('staff', 'PATCH', `/${P}/agents/${devBot.id}`, { name: 'Staff' }), 403, 'project.forbidden');
+ refused(await api('leaver', 'PATCH', `/${P}/agents/${devBot.id}`, { name: 'Other dev' }), 403, 'agent.forbidden');
+ assert.strictEqual(ok(await api('admin', 'PATCH', `/${P}/agents/${devBot.id}`, { name: 'Renamed by admin' })).name, 'Renamed by admin');
+ assert.strictEqual(ok(await api('dev', 'PATCH', `/${P}/agents/${devBot.id}`, { name: 'Release bot' })).name, 'Release bot');
+
+ // ── Pause and resume: staff may pause but not resume; the owner lifts a staff pause ──
+ refused(await api('viewer', 'POST', `/${P}/agents/${devBot.id}/pause`), 403, 'agent.forbidden');
+ assert.strictEqual(ok(await api('staff', 'POST', `/${P}/agents/${devBot.id}/pause`)).status, 'paused');
+ assert.strictEqual(ok(await api('staff', 'POST', `/${P}/agents/${devBot.id}/pause`)).status, 'paused', 'pausing twice is a no-op');
+ refused(await api('staff', 'POST', `/${P}/agents/${devBot.id}/resume`), 403, 'project.forbidden');
+ refused(await api('leaver', 'POST', `/${P}/agents/${devBot.id}/resume`), 403, 'agent.forbidden');
+ assert.strictEqual(ok(await api('dev', 'POST', `/${P}/agents/${devBot.id}/resume`)).status, 'active');
+ assert.strictEqual(ok(await api('admin', 'POST', `/${P}/agents/${devBot.id}/pause`)).status, 'paused');
+ assert.strictEqual(ok(await api('admin', 'POST', `/${P}/agents/${devBot.id}/resume`)).status, 'active');
+
+ // ── Revoke: final ──
+ const doomed = ok(await api('dev', 'POST', `/${P}/agents`, { name: 'Doomed', host: sandboxHost }), 201);
+ refused(await api('viewer', 'DELETE', `/${P}/agents/${doomed.id}`), 403, 'agent.forbidden');
+ const gone = ok(await api('staff', 'DELETE', `/${P}/agents/${doomed.id}`));
+ assert.strictEqual(gone.status, 'revoked'); assert.ok(gone.revoked_at);
+ assert.strictEqual((await row(doomed.id)).revoked_by, `user:${S.staff}`);
+ assert.deepStrictEqual(ok(await api('dev', 'DELETE', `/${P}/agents/${doomed.id}`)), gone, 'revoking twice answers the same agent');
+ refused(await api('dev', 'POST', `/${P}/agents/${doomed.id}/resume`), 409, 'agent.revoked');
+ refused(await api('dev', 'POST', `/${P}/agents/${doomed.id}/pause`), 409, 'agent.revoked');
+ refused(await api('dev', 'PATCH', `/${P}/agents/${doomed.id}`, { name: 'Back' }), 409, 'agent.revoked');
+
+ const actions = (await db.prepare("SELECT action, actor FROM dev_audit WHERE target = ? ORDER BY id").all(`agent:${doomed.id}`)).map((a) => `${a.action} ${a.actor}`);
+ assert.deepStrictEqual(actions, [`agent.created user:${S.dev}`, `agent.revoked user:${S.staff}`]);
+ assert.ok(!(await db.prepare("SELECT event FROM dev_audit WHERE action LIKE 'agent.%' AND event IS NOT NULL").get()), 'no agent event until Contracts has a payload');
+
+ // ── The database refuses what the routes never write ──
+ const id = () => `agt_${crypto.randomBytes(20).toString('hex').toUpperCase().replace(/[^0-9A-HJKMNP-TV-Z]/g, '0').slice(0, 26)}`;
+ const insert = (r) => db.prepare(`INSERT INTO dev_agents (id, project_id, owner_subject, host_kind, host_app_id, host_service, environment, name, status, created_by, revoked_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(r.id || id(), r.project_id || P, S.dev, r.host_kind || 'app', r.host_app_id === undefined ? SB : r.host_app_id,
+ r.host_service || null, r.environment || 'sandbox', 'x', r.status || 'active', 'test', r.revoked_at || null);
+ const rejects = async (r, why) => { await assert.rejects(insert(r), undefined, why); };
+ await insert({});
+ await rejects({ host_app_id: null }, 'an app host needs host_app_id');
+ await rejects({ host_kind: 'service', host_app_id: null, host_service: 'actor', environment: 'sandbox' }, 'a service host is production');
+ await rejects({ host_kind: 'service', host_app_id: SB, host_service: 'actor', environment: 'sandbox' }, 'a service host has no app');
+ await rejects({ status: 'revoked' }, 'revoked needs revoked_at');
+ await rejects({ revoked_at: new Date().toISOString() }, 'revoked_at needs revoked');
+ await rejects({ host_app_id: QA }, 'an app of another project (composite foreign key)');
+ await rejects({ host_app_id: SB, environment: 'production' }, 'an app of the other environment (composite foreign key)');
+ await rejects({ id: 'agt_lowercase0000000000000000000' }, 'not a Crockford ULID');
+ await rejects({ id: `agx_${'0'.repeat(26)}` }, 'wrong prefix');
+ await rejects({ host_kind: 'service', host_app_id: null, host_service: 'nobody', environment: 'production' }, 'a service host is an OAuth client');
+
+ // ── Cascades ──
+ // Revoking the app revokes the agents it hosts, and only those.
+ const sbBot = ok(await api('dev', 'POST', `/${P}/agents`, { name: 'On SB', host: sandboxHost }), 201);
+ ok(await api('dev', 'DELETE', `/${P}/apps/${SB}`));
+ for (const a of [devBot, sbBot]) assert.deepStrictEqual([(await row(a.id)).status, (await row(a.id)).revoked_by], ['revoked', 'app_revoked'], a.name);
+ assert.strictEqual((await row(actorBot.id)).status, 'active');
+ refused(await api('dev', 'POST', `/${P}/agents`, { name: 'x', host: sandboxHost }), 404, 'app.not_found');
+ assert.ok(await db.prepare("SELECT 1 FROM dev_audit WHERE action = 'agent.revoked' AND target = ? AND detail LIKE '%app_revoked%'").get(`agent:${sbBot.id}`));
+
+ // A member who leaves (or is removed) takes their agents in that project with them.
+ const leaverBot = ok(await api('leaver', 'POST', `/${P}/agents`, { name: 'Leaver', host: { type: 'service', id: 'actor' } }), 201);
+ ok(await api('admin', 'DELETE', `/${P}/members/${S.leaver}`), 204);
+ assert.deepStrictEqual([(await row(leaverBot.id)).status, (await row(leaverBot.id)).revoked_by], ['revoked', 'member_removed']);
+ assert.strictEqual((await row(actorBot.id)).status, 'active');
+
+ // An account erasure revokes the person's agents; their export lists them first, without who revoked one.
+ const erasedBot = ok(await api('erased', 'POST', `/${P}/agents`, { name: 'Erased', host: { type: 'service', id: 'actor' } }), 201);
+ const part = await accountData.networkPart(db, 17);
+ const dev = part.files.find((f) => f.name === 'developer_projects.json').content;
+ assert.deepStrictEqual(dev.agents.map((a) => [a.id, a.owner_subject, a.status]), [[erasedBot.id, S.erased, 'active']]);
+ assert.ok(dev.agents.every((a) => !('revoked_by' in a)));
+ 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());
+ const erased = await accountData.erase(db, await db.prepare('SELECT * FROM account_deletions WHERE id = ?').get(delId));
+ assert.strictEqual(erased.agents_revoked, 1);
+ assert.deepStrictEqual([(await row(erasedBot.id)).status, (await row(erasedBot.id)).revoked_by], ['revoked', 'account_deleted']);
+ assert.strictEqual((await row(actorBot.id)).status, 'active');
+
+ // Archiving the project revokes every agent left, each with the archive as its cause.
+ ok(await api('dev', 'POST', `/${P}/agents/${actorBot.id}/pause`));
+ ok(await api('owner', 'POST', `/${P}/archive`));
+ for (const a of [actorBot, adminBot]) assert.deepStrictEqual([(await row(a.id)).status, (await row(a.id)).revoked_by], ['revoked', 'project_archived'], a.name);
+ refused(await api('owner', 'POST', `/${P}/agents`, { name: 'x', host: { type: 'service', id: 'actor' } }), 409, 'project.archived');
+ assert.strictEqual(ok(await api('viewer', 'GET', `/${P}/agents`)).agents.filter((a) => a.status !== 'revoked').length, 0);
+ assert.strictEqual(ok(await api('owner', 'DELETE', `/${P}/agents/${adminBot.id}`)).status, 'revoked', 'revoking in an archived project answers the agent');
+
+ console.log('agents: all tests passed');
+ } finally {
+ server.close();
+ }
+})().catch((err) => { console.error(err); process.exit(1); });
+})().catch((err) => { console.error(err); process.exit(1); });