openvibers/openvibe.network Commits

Serve agent reads and confirmations to owning services on the internal…

OP OpenVibers committed 4 Oct 2026 · 98d540060a · 16 files, +388 −37

Asked: T2 steps 6+7 — OpenVibe.Network WS-Z2 slices 6 and 7 (auth/security). ONE pull request from current origin/main (git fetch first; main is 8ca298c: slices 2–5 merged, migrations 0016–0019 on main; the local checkout may be stale — read with `git show origin/main:<path>`). Design: docs/t2-projects-an…
Harness job network-t2-steps-6-7-openvibe-network-102651 (plan T2).

Changes

ddiff --git a/docs/cutover-t2-confirmations-budgets.md b/docs/cutover-t2-confirmations-budgets.md
iindex 52d22d14..6245762d 100644
---- a/docs/cutover-t2-confirmations-budgets.md
++++ b/docs/cutover-t2-confirmations-budgets.md
@@@ -29,7 +29,8 @@ No existing table, index, view or function is modified. The code that reads and
(`server/developer/confirmations.js`, `server/developer/agents.js`, `server/developer/routes.js`,
`server/identity/grants-admin.js`, `server/index.js`) adds `/api/v1/confirmations`, the budget and rule routes under
`/api/v1/projects/:project/agents/:agent`, and a one-minute expiry sweep. Until an owning service creates a
--confirmation (the `/internal/confirmations` routes come in slice 7) and an owner sets a budget, the tables stay empty;
++confirmation (through `/internal/confirmations`, built in slice 7; no service holds `network.confirmation.manage` by
++default yet) and an owner sets a budget, the tables stay empty;
the cascades that now also cancel confirmations find nothing to cancel.
---
@@@ -188,5 +189,6 @@ Restart the service after either rollback and confirm `health network` says `rea
- **Data move.** None; the tables start empty.
- **Freeze window.** None; three `CREATE TABLE` and six `CREATE INDEX` on empty tables take milliseconds.
--- **Contracts.** No pin bump: `network.confirmation-request@1` is in the pinned v0.85.0. The internal routes, agent
-- tokens and decision events (slices 7-9) wait on Contracts releases.
++- **Contracts.** No pin bump: `network.confirmation-request@1` is in the pinned v0.85.0. The internal routes (slice 7)
++ were built after this cutover, with the pin at v0.90.0 and no migration; agent tokens and decision events (slices
++ 8-9) still wait on Network work.
ddiff --git a/docs/developer-projects.md b/docs/developer-projects.md
iindex 982adb92..07dac3ce 100644
---- a/docs/developer-projects.md
++++ b/docs/developer-projects.md
@@@ -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 | 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 grantability rule.** The contracts `visibility` enum is `public | partner | first-party | internal` (`partner` since the v0.83.0 release; Network pins v0.90.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.
@@@ -225,7 +225,8 @@ A decision on anything but a pending row is `409 confirmation.not_pending`; a pe
minute. Pausing or revoking the agent, revoking or expiring its grant, shrinking its host's ceiling, the owner leaving
the project or being erased, revoking the host app and archiving the project cancel the affected pending and
approved-unused confirmations (`cancel_reason` names the cause) and revoke the matching standing rules. The owning
--services' side (`/internal/confirmations`) and agent tokens come in later slices.
++services create, read, consume and cancel them at `/internal/confirmations` ([Service-side agent reads and
++confirmations](#service-side-agent-reads-and-confirmations)); agent tokens come in a later slice.
### Service-side project reads
@@@ -244,6 +245,20 @@ services' side (`/internal/confirmations`) and agent tokens come in later slices
Redirect URIs must be https. `http://localhost`, `127.0.0.1` and `[::1]` are allowed for sandbox apps only. Fragments and embedded credentials are refused. At most 10 per app. A public app needs at least one. A project has at most `DEV_MAX_APPS_PER_PROJECT` active apps.
++### Service-side agent reads and confirmations
++
++Loopback only, service tokens for `openvibe.network`, `Cache-Control: no-store`. The caller's audience is `openvibe.<service>` from its token (`svc:media` → `openvibe.media`); it only ever sees its own audience's grants and its own confirmations (`docs/t2-projects-and-grants.md` §4–5).
++
++| Route | Capability | Result |
++|---|---|---|
++| `GET /internal/agents/:agent` | `network.project.read` | `{ agent, grants, budgets }`: the agent's active, unexpired grants at the caller's audience (`capability`, `audience`, `mode`, `effective_mode`, `sensitive`, `expires_at`) and those grants' budgets; `grants: []` when the caller's audience has none or the agent is not `active`. `project_id`, `owner` and `host` only for a service the agent concerns (an active grant at its audience, or its service host); any other caller gets `agent: { id, subject }`; `404 agent.not_found` |
++| `POST /internal/confirmations` | `network.confirmation.manage` | `{ requested_by: { type: 'agent', id }, capability, summary, details?, resources?, request_digest, session_id?, ttl_s? }` → `201 { confirmation }` pending, or `200` when a standing rule approved it. The owner is the agent's, never the body's; the capability must be the caller's own (`403 confirmation.wrong_audience`) |
++| `GET /internal/confirmations/:id` | `network.confirmation.manage` | `{ confirmation, used_at }` |
++| `POST /internal/confirmations/:id/consume` | `network.confirmation.manage` | `{ request_digest }` → `{ confirmation, used_at }`, once, after re-checking the agent, its owner, host, grant and budget; `409 confirmation.used`, `.mismatch`, `.expired`, `.cancelled`, `.not_pending`, `.agent_inactive`, or `403 grant.not_delegated` |
++| `POST /internal/confirmations/:id/cancel` | `network.confirmation.manage` | `{ confirmation }`; a pending or approved-unused one becomes `cancelled`, again is a no-op, a used, denied or expired one is `409 confirmation.not_pending` |
++
++Another service's confirmation is `404 confirmation.not_found`. No token is `401 token.missing`; a token without the capability is `403`. Host holds `network.project.read`; no service holds `network.confirmation.manage` by default until one ships a receiver.
++
## Events
Topic, with subject and payload:
ddiff --git a/docs/t2-projects-and-grants.md b/docs/t2-projects-and-grants.md
iindex fc626924..e9632e8b 100644
---- a/docs/t2-projects-and-grants.md
++++ b/docs/t2-projects-and-grants.md
@@@ -3,11 +3,16 @@
Status: **slices 1–2 built** (proposals, and agents: `migrations/0016_agents.sql`, `server/developer/agents.js`; PR #25,
merge `4ec36e1`) and **lane B step 3 built** (`GET /internal/projects/:project_id` under `network.project.read`, Host
only: `server/internal/routes.js:490`; PR #28, merge `e422342`) and **slice 3 built** (delegated grants and modes:
--`migrations/0017_agent_grants.sql`, the ceiling in `server/developer/agents.js`); slices 4–9 are still unbuilt (design, §8).
++`migrations/0017_agent_grants.sql`, the ceiling in `server/developer/agents.js`), **slices 4–5 built** (confirmations,
++owner side, and budgets: `0018`, `0019`) and **slices 6–7 built** (`GET /internal/agents/:agent` and the four
++`/internal/confirmations` routes, `server/internal/routes.js`, with the pin at `openvibe-contracts` v0.90.0); slices 8–9
++are still unbuilt (design, §8).
Plan T2, "Projects and grants: projects as the ownership boundary for every resource;
`agt_` principals, delegated grants with modes, sensitive capabilities, confirmation requests, budgets (WS-Z2)".
--Pinned version: `openvibe-contracts` **v0.85.0** (`package.json:33`); every contract claim below was re-checked against
--the `v0.85.0` tag of OpenVibe.Contracts when slices 1–2 were built (it was written against v0.83.0).
++Pinned version: `openvibe-contracts` **v0.90.0** (`package.json:33`, since slice 7); 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).
++v0.89.0 published `network.confirmation.manage` (`planned`, `implementedBy` the four `/internal/confirmations` routes),
++the `agent` actor in `identity.service-token-claims@1` and `network.confirmation.changed@1`; v0.86–v0.90 are additive.
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
@@@ -297,14 +302,24 @@ inside (the `docs/t2-resource-registry.md` §4 rule).
| Method and path | Capability | Body → result |
|---|---|---|
--| `GET /internal/agents/:agent` | `network.project.read` (exists, first-party) | `{ agent, grants, budgets }`, grants and budgets filtered to the caller's own audience (`openvibe.<caller>`) |
--| `POST /internal/confirmations` | **`network.confirmation.manage`** (proposed, slice 1) | `{ requested_by: {type:'agent', id}, capability, summary, details?, resources?, request_digest, session_id?, ttl_s? }` → `201 { confirmation }` (pending) or `200 { confirmation }` (approved by a standing rule) |
++| `GET /internal/agents/:agent` (built, slice 6) | `network.project.read` (exists, first-party) | `{ agent, grants, budgets }`, grants and budgets filtered to the caller's own audience (`openvibe.<caller>`) |
++| `POST /internal/confirmations` (built, slice 7) | **`network.confirmation.manage`** (published in v0.89.0, `status: planned`) | `{ requested_by: {type:'agent', id}, capability, summary, details?, resources?, request_digest, session_id?, ttl_s? }` → `201 { confirmation }` (pending) or `200 { confirmation }` (approved by a standing rule) |
| `GET /internal/confirmations/:id` | same | `{ confirmation, used_at }` |
| `POST /internal/confirmations/:id/consume` | same | `{ request_digest }` → `200 { confirmation, used_at }` |
| `POST /internal/confirmations/:id/cancel` | same | → `{ confirmation }` |
`ttl_s` is 60–86400, default 900. All `/internal/confirmations` routes answer only the audience that created the
--confirmation (others: `404`).
++confirmation (others: `404`), with `Cache-Control: no-store`. The owner is always the agent's: a body's `owner` is never
++read. Cancel takes a `pending` or `approved` but unused one (`cancel_reason = 'service'`; again is a no-op), refuses a
++used, denied or expired one (`409 confirmation.not_pending`) and leaves the owner's standing rules alone. No service holds
++`network.confirmation.manage` by default yet: a service gets its `DEFAULT_GRANTS` row when it ships a receiver.
++
++`GET /internal/agents/:agent` lists only the agent's `active`, unexpired grants at the caller's audience, each `{
++capability, audience, mode, effective_mode, sensitive, expires_at }`, and the budgets of those grants alone; another
++audience gets `grants: []` (never a `404`) and `agent: { id, subject }` alone: `project_id`, `owner` and `host` go only
++to a service the agent concerns (an active, unexpired grant at its audience, or its service host). An agent that is
++not `active` lists no grants. It never carries a label
++(`granted_by`, `updated_by`), a request digest or a secret; an unknown or malformed id is `404 agent.not_found`.
### Agent tokens — `POST /oauth/token`
@@@ -564,13 +579,14 @@ and agree).
5. **Budgets** (built). `migrations/0019_agent_budgets.sql`, budget routes, `budget.beyond_quota`,
`test/agent-budgets.test.js` (without the internal read), cases added to
`test/agent-schema.test.js`. Checks: `npm test`, `npm run test:pg`.
--6. **Internal agent read.** `GET /internal/agents/:agent` under `network.project.read` (already in the catalog), in
-- `server/developer/agents.js`, wired in `server/index.js`; tests added to `test/agent-budgets.test.js`. Checks:
-- `npm test`, `npm run test:pg`.
--7. **Internal confirmations** — *blocked on Contracts publishing `network.confirmation.manage` and the pin bump.*
-- `package.json` pin, the four `/internal/confirmations` routes, `DEFAULT_GRANTS` for the services that ask first (none
-- until a receiver ships), internal cases in `test/confirmations.test.js`. Checks: `npm test`, `npm run test:pg`,
-- `node scripts/contracts-drift.js`.
++6. **Internal agent read** (built). `GET /internal/agents/:agent` under `network.project.read` (already in the catalog),
++ `internalAgentView` in `server/developer/agents.js`, the route in `server/internal/routes.js`;
++ `test/internal-agents.test.js`. Checks: `npm test`, `npm run test:pg`.
++7. **Internal confirmations** (built). The pin moved to `openvibe-contracts` v0.90.0 (v0.89.0 published
++ `network.confirmation.manage`), the four `/internal/confirmations` routes (`internalRouter`, `read` and `cancel` in
++ `server/developer/confirmations.js`, mounted under the guard in `server/internal/routes.js`), no `DEFAULT_GRANTS`
++ row yet (none until a receiver ships), internal cases in `test/confirmations.test.js`. Checks: `npm test`,
++ `npm run test:pg`, `node scripts/contracts-drift.js`.
8. **Agent tokens** — *blocked on Contracts publishing the agent claims (gap 1).* The `agent` branch in
`server/developer/tokens.js` and `principals.issueToken`, `cap`/`cap_confirm` split, `test/agent-tokens.test.js`.
Network's own guard keeps refusing agent tokens on its routes until a route needs them. Checks: `npm test`,
@@@ -579,7 +595,7 @@ and agree).
openvibe-shared notification type.* Audit rows gain `event`; `notification-service.js` notifies the owner on create
(payload rules of `docs/notification-digest.md`: never the summary). Checks: `npm test`, `npm run test:pg`.
--Slices 2–6 ship now, in this order (each adds the next migration number); 7–9 wait only on releases outside Network and are flagged by `scripts/contracts-drift.js`.
++Slices 2–7 are built, in this order (2–5 each added the next migration number; 6–7 need none); 8–9 wait only on releases outside Network and are flagged by `scripts/contracts-drift.js`.
## 9. Unresolved
ddiff --git a/docs/t2-resource-registry.md b/docs/t2-resource-registry.md
iindex 5b50f4a1..9cac12e0 100644
---- a/docs/t2-resource-registry.md
++++ b/docs/t2-resource-registry.md
@@@ -3,7 +3,7 @@
Status: **resource registry and N6 built** (`migrations/0007_resource_registry.sql`,
`migrations/0009_resource_offer_kinds.sql`, `server/registry/offers.js`); **cells and node principals built**
(section 9, `migrations/0008`). The original slice plan in §7 is historical.
--Current pin: `openvibe-contracts` **v0.85.0** (`package.json`); the v0.83.0 observations below describe the original design.
++Current pin: `openvibe-contracts` **v0.90.0** (`package.json`; the kinds below are as of v0.85.0); the v0.83.0 observations below describe the original design.
Scope: one table and one module that store **what the network can sell or place on** — node, provider, storage, delivery,
runtime, agent and harness offers — reported by the components that own them, listed publicly so that `openvibe-sdk/placement` can plan against
ddiff --git a/package-lock.json b/package-lock.json
iindex 7c1aa929..f6599d8c 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -21,7 +21,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.85.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.90.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
"pg": "^8.23.0",
@@@ -1556,9 +1556,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.85.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.85.0",
-- "integrity": "sha512-Xo54hG4kiL1kOTcxwbnH8cmt2zZhvp/gqCs+egPBLLSBg+SjVuhXIPaX8EJPQux5x1YxwO7wjDDywcHvIaGoqA==",
++ "version": "0.90.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.90.0",
++ "integrity": "sha512-3rn3499kohy++DZm2nkWHPmO6W6r55GajAJtKlxOVgbEo0qQhni+mf6SvMZDMWpRQrdRQDBtiodIr21uwA9x4Q==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 9b057595..385e54d2 100644
---- a/package.json
++++ b/package.json
@@@ -30,7 +30,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.85.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.90.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
"pg": "^8.23.0",
ddiff --git a/server/developer/agents.js b/server/developer/agents.js
iindex 66a9c738..e1af833b 100644
---- a/server/developer/agents.js
++++ b/server/developer/agents.js
@@@ -22,7 +22,8 @@
* store.js and grants-admin.js cascades, which all go through revokeWhere and revokeBeyondHost.
* - Budgets (slice 5, migrations/0019_agent_budgets.sql): the owner or an admin+ caps a capability the agent holds,
* never above the project's quota for it; revoking the grant deletes its budget. The owning service meters.
-- * Agent tokens come in a later slice (section 8).
++ * - The owning service reads what it enforces at GET /internal/agents/:agent (slice 6, internalAgentView): its own
++ * audience's grants and their budgets, never another service's. Agent tokens come in a later slice (section 8).
*/
const { ids, capabilities } = require('openvibe-contracts');
const policy = require('./policy');
@@@ -424,8 +425,34 @@ async function deleteBudget(db, actor, projectId, agentId, capability, { ctx })
});
}
++// ── The owning service's read (slice 6) ───────────────────────
++
++/**
++ * GET /internal/agents/:agent: the agent, its active, unexpired delegated grants at `audience` (the caller's own) and
++ * the budgets of those grants. A grant of another audience is never shown; an agent that is not active lists none, as
++ * it may use none. Only a service the agent concerns — one holding such a grant at its audience (whatever the agent's
++ * status) or the agent's service host — sees its project, owner and host; any other caller gets the id alone.
++ * null for an unknown id. Never a label (granted_by, updated_by) and nothing secret.
++ */
++async function internalAgentView(db, agentId, audience) {
++ const a = AGENT_ID_RE.test(String(agentId)) ? await db.prepare('SELECT * FROM dev_agents WHERE id = ?').get(agentId) : null;
++ if (!a) return null;
++ const now = nowIso();
++ const held = (await db.prepare("SELECT * FROM dev_agent_grants WHERE agent_id = ? AND audience = ? AND status = 'active' ORDER BY capability").all(a.id, String(audience)))
++ .filter((g) => !g.expires_at || g.expires_at > now);
++ const concerned = held.length > 0 || (a.host_kind === 'service' && `openvibe.${a.host_service}` === String(audience));
++ const grants = a.status !== 'active' ? [] : held;
++ const caps = new Set(grants.map((g) => g.capability));
++ const budgets = grants.length ? (await db.prepare('SELECT * FROM dev_agent_budgets WHERE agent_id = ? ORDER BY capability').all(a.id)).filter((b) => caps.has(b.capability)) : [];
++ return {
++ agent: concerned ? agentView(a) : { id: a.id, subject: { type: 'agent', id: a.id } },
++ grants: grants.map((g) => ({ capability: g.capability, audience: g.audience, mode: g.mode, effective_mode: effectiveMode(g), sensitive: isSensitive(g.capability), expires_at: g.expires_at || null })),
++ budgets: budgets.map(budgetView),
++ };
++}
++
module.exports = {
ensureSchema, AGENT_ID_RE, agentView, loadAgent, atLeast, listAgents, getAgent, createAgent, renameAgent, changeStatus, revokeWhere,
effectiveMode, withinHost, listGrants, putGrant, deleteGrant, revokeBeyondHost,
-- budgetView, listBudgets, setBudget, deleteBudget,
++ budgetView, listBudgets, setBudget, deleteBudget, internalAgentView,
};
ddiff --git a/server/developer/confirmations.js b/server/developer/confirmations.js
iindex 18a84e19..84e6b2cf 100644
---- a/server/developer/confirmations.js
++++ b/server/developer/confirmations.js
@@@ -3,8 +3,9 @@
* Confirmations, owner side (plan T2 WS-Z2 slice 4, docs/t2-projects-and-grants.md sections 3-5): a sensitive use of a
* delegated grant waits for its owner's approval. The schema is migrations/0018_confirmations.sql.
*
-- * - The owning service of the capability creates, consumes and cancels them (`create`, `consume`; its
-- * /internal/confirmations routes wait for slice 7). The owner reads, approves and denies them at
++ * - The owning service of the capability creates, reads, consumes and cancels them (`create`, `read`, `consume`,
++ * `cancel`) at /internal/confirmations with network.confirmation.manage (slice 7, `internalRouter`); each service
++ * sees only the ones it created (others: 404). The owner reads, approves and denies them at
* /api/v1/confirmations (`router`); nobody else sees one (not admins, not staff: 404 confirmation.not_found).
* - Views are network.confirmation-request@1 documents; audience, session_id, request_digest, rule_id, used_at,
* decided_by, cancel_reason and project_id stay Network-local, never inside the document.
@@@ -94,7 +95,7 @@ async function checkAuthority(db, agent, capability, settings) {
if (b && Number(b.limit_value) === 0) notDelegated('the budget for that capability is 0');
}
--// ── The owning service's side (routes in slice 7) ──────────────
++// ── The owning service's side (/internal/confirmations) ────────
/**
* Create a confirmation for an agent's sensitive use of `capability`, asked by `audience` (the owning service). The
@@@ -171,6 +172,36 @@ async function consume(db, { id, audience, requestDigest }, { ctx, settings } =
});
}
++/** The audience's own row, else 404: another service's confirmation is not found, never refused. */
++async function serviceRow(db, id, audience, { lock = false } = {}) {
++ const c = CNF_ID_RE.test(String(id)) ? await db.prepare(`SELECT * FROM dev_confirmations WHERE id = ? AND audience = ?${lock ? ' FOR UPDATE' : ''}`).get(id, audience) : null;
++ if (!c) fail(404, 'confirmation.not_found', 'no such confirmation');
++ return c;
++}
++
++/** The owning service reads one it created. → { confirmation, used_at } */
++async function read(db, { id, audience }) {
++ const c = await serviceRow(db, id, audience);
++ return { confirmation: confirmationView(c), used_at: c.used_at || null };
++}
++
++/**
++ * The owning service withdraws one it created: a pending or approved-unused row becomes cancelled (reason `service`);
++ * one already cancelled is returned as it is; a used, denied or expired one is 409 confirmation.not_pending. The
++ * owner's standing rules are left alone. → { confirmation }
++ */
++async function cancel(db, { id, audience }, { ctx } = {}) {
++ await db.tx(async () => {
++ const c = await serviceRow(db, id, audience, { lock: true });
++ const state = c.used_at ? 'used' : stateOf(c);
++ if (state === 'cancelled') return;
++ if (state !== 'pending' && state !== 'approved') fail(409, 'confirmation.not_pending', `confirmation is ${state}`);
++ await db.prepare(`UPDATE dev_confirmations SET state = 'cancelled', cancel_reason = 'service' WHERE id = ? AND ${LIVE}`).run(c.id);
++ await audit(db, c, 'confirmation.cancelled', { actor: `service:${audience}`, detail: { from: c.state, reason: 'service' }, ctx });
++ });
++ return { confirmation: confirmationView(await serviceRow(db, id, audience)) };
++}
++
/**
* The cascade, in the caller's transaction: cancel the pending and approved-unused confirmations of `agentId` (only
* those for `capability` when given) with `reason`, one audit row each, and revoke the matching live standing rules.
@@@ -341,6 +372,39 @@ function router() {
return finish(r);
}
++/**
++ * /internal/confirmations — the owning service's side (service tokens with network.confirmation.manage; the guard is
++ * the mount's, server/internal/routes.js). The caller's audience is openvibe.<service> from its token's sub.
++ * POST / { requested_by: { type: 'agent', id }, capability, summary, details?, resources?, request_digest,
++ * session_id?, ttl_s? } 201 { confirmation } pending, 200 { confirmation } approved by a rule
++ * GET /:id { confirmation, used_at }
++ * POST /:id/consume { request_digest } { confirmation, used_at }
++ * POST /:id/cancel { confirmation }
++ * The owner is the agent's: a body's `owner` is never read.
++ */
++function internalRouter() {
++ const { handler, finish, send } = require('./routes');
++ const r = require('express').Router();
++ const handle = handler();
++ const aud = (req) => `openvibe.${String(req.principal.sub).replace(/^svc:/, '')}`;
++ const body = (req) => (req.body && typeof req.body === 'object' ? req.body : {});
++ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
++ r.post('/', async (req, res) => {
++ try {
++ const b = body(req);
++ const by = b.requested_by && typeof b.requested_by === 'object' ? b.requested_by : {};
++ if (by.type !== 'agent') fail(422, 'confirmation.invalid', "requested_by is { type: 'agent', id: 'agt_…' }");
++ const c = await create(req.app.locals.db, { agentId: by.id, capability: b.capability, audience: aud(req), summary: b.summary, details: b.details,
++ resources: b.resources, requestDigest: b.request_digest, sessionId: b.session_id, ttlS: b.ttl_s }, { ctx: req.ov, settings: policy.settings(req.app.locals.config) });
++ res.status(c.state === 'pending' ? 201 : 200).json({ confirmation: c });
++ } catch (err) { send(req, res, err); }
++ });
++ r.get('/:id', handle(async (db, _a, req) => await read(db, { id: req.params.id, audience: aud(req) })));
++ r.post('/:id/consume', handle(async (db, _a, req, o) => await consume(db, { id: req.params.id, audience: aud(req), requestDigest: body(req).request_digest }, o)));
++ r.post('/:id/cancel', handle(async (db, _a, req, o) => await cancel(db, { id: req.params.id, audience: aud(req) }, o)));
++ return finish(r);
++}
++
let timer = null;
function start(db, { intervalMs = 60_000 } = {}) {
if (timer) return;
@@@ -351,6 +415,6 @@ function start(db, { intervalMs = 60_000 } = {}) {
function stop() { if (timer) clearInterval(timer); timer = null; }
module.exports = {
-- create, list, get, decide, consume, cancelFor, expireDue, confirmationView, ruleView, listRules, revokeRule, router, start, stop,
++ create, read, consume, cancel, internalRouter, list, get, decide, cancelFor, expireDue, confirmationView, ruleView, listRules, revokeRule, router, start, stop,
MAX_PENDING,
};
ddiff --git a/server/developer/routes.js b/server/developer/routes.js
iindex 91c1dc1e..33ec5775 100644
---- a/server/developer/routes.js
++++ b/server/developer/routes.js
@@@ -206,4 +206,4 @@ function send(req, res, err) {
return http.sendProblem(res, 500, 'internal.error', { detail: 'unexpected error', ctx: req.ov });
}
--module.exports = { router, userApi, handler, finish };
++module.exports = { router, userApi, handler, finish, send };
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex bf0f0100..e46923a0 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -130,6 +130,8 @@ const DEFAULT_GRANTS = [
['trade', 'events.event.publish', 'openvibe.events', []],
['trade', 'sources.item.read', 'openvibe.sources', []],
['trade', 'sources.source.read', 'openvibe.sources', []],
++ // Trade subscribes to the Sources filings it follows (plan T9 step 1), as Coupons and Deals do.
++ ['trade', 'events.subscription.manage', 'openvibe.events', []],
// Wave 18: Deals and Coupons; Wave 21 Stage B: the Host API publishes deploy/domain events.
['deals', 'identity.subject.resolve', SELF_AUDIENCE, []],
['deals', 'events.event.publish', 'openvibe.events', []],
ddiff --git a/server/internal/routes.js b/server/internal/routes.js
iindex a31e4061..c8242c7f 100644
---- a/server/internal/routes.js
++++ b/server/internal/routes.js
@@@ -498,4 +498,26 @@ router.get('/projects/:project_id', principals.guard('network.project.read'), as
}
});
++// ── Read an agent ────────────────────────────────────────────
++// Set before the guard so its refusals are not cached either.
++const noStore = (req, res, next) => { res.set('Cache-Control', 'no-store'); next(); };
++// The owning service enforces an agent's delegated grants and budgets (plan T2 WS-Z2 slice 6,
++// docs/t2-projects-and-grants.md section 4): only the grants at the caller's own audience, never another's.
++router.get('/agents/:agent', noStore, principals.guard('network.project.read'), async (req, res) => {
++ try {
++ const audience = `openvibe.${String(req.principal.sub).replace(/^svc:/, '')}`;
++ const out = await require('../developer/agents').internalAgentView(getDb(req), req.params.agent, audience);
++ if (!out) return require('openvibe-contracts').http.sendProblem(res, 404, 'agent.not_found', { detail: 'no such agent' });
++ res.json(out);
++ } catch (err) {
++ console.error('[Internal] agent read error:', err.message);
++ require('openvibe-contracts').http.sendProblem(res, 500, 'internal.error', { detail: 'agent read failed' });
++ }
++});
++
++// ── Confirmations, the owning service's side ─────────────────
++// Ask an agent's owner to confirm one sensitive action and spend the approval once (slice 7,
++// server/developer/confirmations.js). Each service sees only the confirmations it created.
++router.use('/confirmations', noStore, principals.guard('network.confirmation.manage'), require('../developer/confirmations').internalRouter());
++
module.exports = router;
ddiff --git a/test/confirmations.test.js b/test/confirmations.test.js
iindex ad2bd8d5..641f49bb 100644
---- a/test/confirmations.test.js
++++ b/test/confirmations.test.js
@@@ -4,7 +4,8 @@
// digest mismatch, deny, expiry (lazy and swept), the service's cancel, standing rules once/session/until/always and
// their revocation; only the owner sees one; 20 pending per agent; the wrong audience; audits without summary/details;
// one revoke-after-approval case per cause; authority that lapses without a write; two concurrent consumes.
--// Rows are created and consumed through the store (the /internal routes wait for slice 7).
++// Most rows are created and consumed through the store; the /internal/confirmations routes (slice 7) are checked with
++// service tokens: the gate, one service never seeing another's, consume once with the digest, expiry and cancel.
// node test/confirmations.test.js
const assert = require('assert');
const crypto = require('crypto');
@@@ -26,6 +27,9 @@ const PEOPLE = [[10, 'owner'], [11, 'admin'], [12, 'dev'], [13, 'stranger'], [14
await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES
(10, 'owner', 'x', 'user'), (11, 'admin', 'x', 'user'), (12, 'dev', 'x', 'user'), (13, 'stranger', 'x', 'user'), (14, 'staff', 'x', 'admin'), (17, 'erased', 'x', 'user')`).run();
await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('actor', 'x', 'OpenVibe.Actor', '[]', 1)").run();
++// No service holds network.confirmation.manage by default (no receiver ships yet): Media and Tools get it here, Live does not.
++for (const c of ['media', 'tools', 'live']) await db.prepare(`UPDATE oauth_clients SET client_secret = '${c}-secret' WHERE client_id = ?`).run(c);
++for (const c of ['media', 'tools']) await db.prepare("INSERT INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES (?, 'network.confirmation.manage', 'openvibe.network', '[]', 'test')").run(c);
const S = {};
for (const [id, name] of PEOPLE) S[name] = await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));
@@@ -36,6 +40,10 @@ app.locals.db = db;
app.locals.config = { baseUrl: ISSUER, jwt: { issuer: ISSUER, accessTokenExpiry: '1h' }, developer: { sandboxAllowance: 'media.object.read', agentHostServices: 'actor' } };
app.locals.privateKey = keys.privateKey;
app.locals.publicKey = keys.publicKey;
++app.use(express.json());
++app.use(express.urlencoded({ extended: true }));
++app.use('/oauth', require('../server/auth/oauth-routes'));
++app.use('/internal', require('../server/internal/routes'));
app.use('/api/v1/projects', require('../server/developer/routes').router());
app.use('/api/v1/confirmations', confirmations.router());
const server = http.createServer(app);
@@@ -68,6 +76,15 @@ for (const [id, name] of PEOPLE) T[name] = jwt.sign({ sub: id, id }, keys.privat
});
const spend = (c, extra = {}) => confirmations.consume(db, { id: c.id, audience: audOf(c.capability), requestDigest: D, ...extra });
const approve = async (c, who = 'owner', body = {}) => ok(await inbox(who, 'POST', `/${c.id}/approve`, body));
++ const svcToken = async (id) => (await (await fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
++ body: new URLSearchParams({ grant_type: 'client_credentials', client_id: id, client_secret: `${id}-secret`, audience: 'openvibe.network' }) })).json()).access_token;
++ const SVC = {};
++ for (const c of ['media', 'tools', 'live']) SVC[c] = await svcToken(c);
++ const internal = async (who, method, p, body) => {
++ const r = await fetch(`${base}/internal/confirmations${p}`, { method, headers: { ...(who ? { authorization: `Bearer ${SVC[who]}` } : {}), ...(body ? { 'content-type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined });
++ const text = await r.text();
++ return { status: r.status, type: r.headers.get('content-type') || '', cache: r.headers.get('cache-control') || '', text, body: text ? JSON.parse(text) : null };
++ };
for (const c of ['chat.message.send']) await grantsAdmin.grant(db, { client_id: 'actor', capability: c, reason: 'agent host test' }, null);
// A project with a production app holding media.object.delete and .upload, an admin and a developer member.
@@@ -343,6 +360,63 @@ for (const [id, name] of PEOPLE) T[name] = jwt.sign({ sub: id, id }, keys.privat
await accountData.erase(db, await db.prepare('SELECT * FROM account_deletions WHERE id = ?').get(delId));
await cancelledBy(e1, 'account_erased');
++ // ── /internal/confirmations: the owning service's routes (slice 7) ──
++ const routed = await appAgent(await project());
++ const askBody = (extra = {}) => ({ requested_by: { type: 'agent', id: routed }, capability: 'media.object.delete', summary: SUMMARY, details: DETAILS,
++ resources: [{ service: 'media', type: 'object', id: 'obj_1' }], request_digest: D, ...extra });
++ refused(await internal(null, 'POST', '', askBody()), 401, 'token.missing');
++ refused(await internal('live', 'POST', '', askBody()), 403, 'capability.denied');
++ refused(await internal('live', 'GET', '/cnf_nope'), 403, 'capability.denied');
++ // The owner is the agent's: a body naming another person is ignored.
++ const made = await internal('media', 'POST', '', askBody({ owner: { type: 'user', id: S.stranger } }));
++ assert.strictEqual(made.status, 201, made.text);
++ assert.strictEqual(made.cache, 'no-store');
++ const i1 = valid(made.body.confirmation);
++ assert.deepStrictEqual([i1.state, i1.owner, i1.requested_by], ['pending', { type: 'user', id: S.owner }, { type: 'agent', id: routed }]);
++ assert.ok(!made.text.includes(D) && !made.text.includes('openvibe.media'), 'never the digest or the audience');
++ refused(await internal('media', 'POST', '', askBody({ requested_by: routed })), 422, 'confirmation.invalid');
++ refused(await internal('media', 'POST', '', askBody({ requested_by: { type: 'agent', id: `agt_${'9'.repeat(26)}` } })), 404, 'agent.not_found');
++ refused(await internal('tools', 'POST', '', askBody()), 403, 'confirmation.wrong_audience');
++ // Each service sees only its own: Tools cannot read, consume or cancel Media's.
++ assert.deepStrictEqual(ok(await internal('media', 'GET', `/${i1.id}`)), { confirmation: i1, used_at: null });
++ refused(await internal('tools', 'GET', `/${i1.id}`), 404, 'confirmation.not_found');
++ refused(await internal('tools', 'POST', `/${i1.id}/consume`, { request_digest: D }), 404, 'confirmation.not_found');
++ refused(await internal('tools', 'POST', `/${i1.id}/cancel`, {}), 404, 'confirmation.not_found');
++ refused(await internal('media', 'GET', '/cnf_nope'), 404, 'confirmation.not_found');
++ // Consume once, with the action's digest.
++ refused(await internal('media', 'POST', `/${i1.id}/consume`, { request_digest: D }), 409, 'confirmation.not_pending');
++ await approve(i1);
++ refused(await internal('media', 'POST', `/${i1.id}/consume`, { request_digest: digest('DELETE /v1/objects/obj_2') }), 409, 'confirmation.mismatch');
++ refused(await internal('media', 'POST', `/${i1.id}/consume`, {}), 409, 'confirmation.mismatch');
++ const spent = ok(await internal('media', 'POST', `/${i1.id}/consume`, { request_digest: D }));
++ valid(spent.confirmation);
++ assert.match(spent.used_at, /^\d{4}-/);
++ assert.strictEqual(ok(await internal('media', 'GET', `/${i1.id}`)).used_at, spent.used_at);
++ refused(await internal('media', 'POST', `/${i1.id}/consume`, { request_digest: D }), 409, 'confirmation.used');
++ refused(await internal('media', 'POST', `/${i1.id}/cancel`, {}), 409, 'confirmation.not_pending');
++ // Expired: an approved one past expires_at is refused, and so is cancelling a pending one past it.
++ const i2 = ok(await internal('media', 'POST', '', askBody({ ttl_s: 60 })), 201).confirmation;
++ await approve(i2);
++ await db.prepare("UPDATE dev_confirmations SET expires_at = '2001-01-01T00:00:00.000Z' WHERE id = ?").run(i2.id);
++ refused(await internal('media', 'POST', `/${i2.id}/consume`, { request_digest: D }), 409, 'confirmation.expired');
++ const i3 = ok(await internal('media', 'POST', '', askBody()), 201).confirmation;
++ await db.prepare("UPDATE dev_confirmations SET expires_at = '2001-01-01T00:00:00.000Z' WHERE id = ?").run(i3.id);
++ refused(await internal('media', 'POST', `/${i3.id}/cancel`, {}), 409, 'confirmation.not_pending');
++ // Cancelled: pending or approved-unused, again is a no-op; the owner can no longer decide, the service no longer spend.
++ const i4 = ok(await internal('media', 'POST', '', askBody()), 201).confirmation;
++ assert.strictEqual(valid(ok(await internal('media', 'POST', `/${i4.id}/cancel`, {})).confirmation).state, 'cancelled');
++ assert.strictEqual(ok(await internal('media', 'POST', `/${i4.id}/cancel`, {})).confirmation.state, 'cancelled');
++ assert.strictEqual((await row(i4.id)).cancel_reason, 'service');
++ refused(await inbox('owner', 'POST', `/${i4.id}/approve`, {}), 409, 'confirmation.not_pending');
++ const i5 = ok(await internal('media', 'POST', '', askBody()), 201).confirmation;
++ await approve(i5, 'owner', { standing_rule: 'always' });
++ ok(await internal('media', 'POST', `/${i5.id}/cancel`, {}));
++ refused(await internal('media', 'POST', `/${i5.id}/consume`, { request_digest: D }), 409, 'confirmation.cancelled');
++ // The owner's standing rule outlives the service's cancel: the next request is approved at once (200, not 201).
++ const i6 = await internal('media', 'POST', '', askBody());
++ assert.deepStrictEqual([i6.status, i6.body.confirmation.state, i6.body.confirmation.standing_rule], [200, 'approved', 'always']);
++ ok(await internal('media', 'POST', `/${i6.body.confirmation.id}/consume`, { request_digest: D }));
++
// ── Audits: every transition, never the summary or the details ──
const audits = await db.prepare("SELECT action, actor, detail, event FROM dev_audit WHERE action LIKE 'confirmation.%' ORDER BY id").all();
assert.deepStrictEqual([...new Set(audits.map((a) => a.action))].sort(),
ddiff --git a/test/internal-agents.test.js b/test/internal-agents.test.js
nnew file mode 100644
iindex 00000000..93cc936f
---- /dev/null
++++ b/test/internal-agents.test.js
@@@ -0,0 +1,121 @@
++'use strict';
++// The owning service's agent read (plan T2 WS-Z2 slice 6, docs/t2-projects-and-grants.md section 4):
++// GET /internal/agents/:agent takes network.project.read on a service token and returns the agent, its active and
++// unexpired delegated grants at the caller's own audience (effective_mode from the catalog) and only those grants'
++// budgets. Another audience's grants are never shown (grants: [], not 404), nor the agent's project, owner or host;
++// an agent that is not active lists none;
++// an unknown or malformed id is a 404; no labels, digests or secrets; Cache-Control: no-store.
++// node test/internal-agents.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');
++
++(async () => {
++const log = console.log; console.log = () => {};
++const db = getDb();
++console.log = log;
++for (const c of ['host', 'live', 'media']) await db.prepare(`UPDATE oauth_clients SET client_secret = '${c}-secret' WHERE client_id = ?`).run(c);
++// Host holds network.project.read by default; Media gets it here to read its own audience's grants.
++await db.prepare("INSERT INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES ('media', 'network.project.read', 'openvibe.network', '[]', 'test') ON CONFLICT DO NOTHING").run();
++await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES (10, 'owner', 'x', 'user'), (14, 'staff', 'x', 'admin')`).run();
++const owner = await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = 10').get());
++
++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++const ISSUER = 'https://openvibe.network';
++const app = express();
++app.use(express.json());
++app.use(express.urlencoded({ extended: true }));
++app.locals.db = db;
++app.locals.config = { baseUrl: ISSUER, loginUrl: ISSUER, jwt: { issuer: ISSUER, accessTokenExpiry: '1h' }, developer: { sandboxAllowance: '' } };
++app.locals.privateKey = keys.privateKey;
++app.locals.publicKey = keys.publicKey;
++app.use('/oauth', require('../server/auth/oauth-routes'));
++app.use('/internal', require('../server/internal/routes'));
++app.use('/api/v1/projects', require('../server/developer/routes').router());
++const server = http.createServer(app);
++const U = { owner: jwt.sign({ sub: 10, id: 10 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' }), staff: jwt.sign({ sub: 14, id: 14 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' }) };
++
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const api = async (who, method, p, body) => {
++ const r = await fetch(`${base}/api/v1/projects${p}`, { method, headers: { authorization: `Bearer ${U[who]}`, ...(body ? { 'content-type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined });
++ const text = await r.text();
++ return { status: r.status, body: text ? JSON.parse(text) : null };
++ };
++ const ok = (r, status = 200) => { assert.strictEqual(r.status, status, JSON.stringify(r.body)); return r.body; };
++ const token = async (id, scope) => (await (await fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: id, client_secret: `${id}-secret`, audience: 'openvibe.network', ...(scope ? { scope } : {}) }) })).json()).access_token;
++ const read = async (id, headers = {}) => {
++ const r = await fetch(`${base}/internal/agents/${encodeURIComponent(id)}`, { headers });
++ const text = await r.text();
++ return { status: r.status, type: r.headers.get('content-type') || '', cache: r.headers.get('cache-control'), text, body: text ? JSON.parse(text) : null };
++ };
++ const refused = (r, status, code) => { assert.strictEqual(r.status, status, r.text); assert.strictEqual(r.body.code, code); assert.match(r.type, /problem\+json/); };
++ try {
++ // A production app holding three media capabilities; the agent is delegated delete (confirm), upload (auto)
++ // and read (revoked again), with a budget on upload.
++ const P = ok(await api('owner', 'POST', '', { name: 'Agents read' }), 201).id;
++ ok(await api('staff', 'PUT', `/${P}/environment-policy`, { environment_policy: 'sandbox+production' }));
++ ok(await api('staff', 'PUT', `/${P}/allowance`, { capabilities: ['media.object.read', 'media.object.upload', 'media.object.delete'] }));
++ const PR = ok(await api('owner', 'POST', `/${P}/apps`, { name: 'Bot', environment: 'production' }), 201).id;
++ for (const c of ['media.object.read', 'media.object.upload', 'media.object.delete']) ok(await api('owner', 'POST', `/${P}/apps/${PR}/grants`, { capability: c }), 201);
++ const A = ok(await api('owner', 'POST', `/${P}/agents`, { name: 'Helper', host: { type: 'app', id: PR } }), 201).id;
++ ok(await api('owner', 'PUT', `/${P}/agents/${A}/grants/media.object.delete`, {}));
++ ok(await api('owner', 'PUT', `/${P}/agents/${A}/grants/media.object.upload`, { mode: 'auto' }));
++ ok(await api('owner', 'PUT', `/${P}/agents/${A}/grants/media.object.read`, { mode: 'auto' }));
++ ok(await api('owner', 'DELETE', `/${P}/agents/${A}/grants/media.object.read`));
++ ok(await api('owner', 'PUT', `/${P}/agents/${A}/budgets/media.object.upload`, { limit: 50, window: 'day' }));
++ const media = { authorization: `Bearer ${await token('media')}` };
++ const host = { authorization: `Bearer ${await token('host')}` };
++
++ // ── The gate ──
++ refused(await read(A), 401, 'token.missing');
++ const denied = await read(A, { authorization: `Bearer ${await token('live')}` });
++ refused(denied, 403, 'capability.denied');
++ assert.strictEqual(denied.cache, 'no-store', 'the guard\'s refusal is not cached either');
++
++ // ── The owning service sees its own audience's grants and their budgets ──
++ const r = await read(A, media);
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.cache, 'no-store');
++ assert.deepStrictEqual(r.body.agent, { ...r.body.agent, id: A, project_id: P, owner: { type: 'user', id: owner }, host: { type: 'app', id: PR }, environment: 'production', status: 'active' });
++ assert.deepStrictEqual(r.body.grants, [
++ { capability: 'media.object.delete', audience: 'openvibe.media', mode: 'confirm', effective_mode: 'confirm', sensitive: true, expires_at: null },
++ { capability: 'media.object.upload', audience: 'openvibe.media', mode: 'auto', effective_mode: 'auto', sensitive: false, expires_at: null },
++ ], 'active grants only: the revoked read is not listed');
++ assert.deepStrictEqual(r.body.budgets, [{ capability: 'media.object.upload', limit: 50, window: 'day', unit: 'requests', enforced_by: 'openvibe.media' }]);
++ for (const s of ['granted_by', 'updated_by', 'created_by', 'request_digest', 'secret', `user:${owner}`]) assert.ok(!r.text.includes(s), `never ${s}`);
++
++ // ── Another audience: 200 with the id alone, never a 404, the grants, the project, owner or host ──
++ const h = await read(A, host);
++ assert.strictEqual(h.status, 200, h.text);
++ assert.deepStrictEqual(h.body, { agent: { id: A, subject: { type: 'agent', id: A } }, grants: [], budgets: [] });
++ for (const s of [owner, PR, P]) assert.ok(!h.text.includes(s), `another audience never learns ${s}`);
++
++ // ── A grant whose expires_at passed, before any sweep, is not listed; nor its budget ──
++ await db.prepare("UPDATE dev_agent_grants SET expires_at = '2001-01-01T00:00:00.000Z' WHERE agent_id = ? AND capability = 'media.object.upload'").run(A);
++ const e = (await read(A, media)).body;
++ assert.deepStrictEqual([e.grants.map((g) => g.capability), e.budgets], [['media.object.delete'], []]);
++ await db.prepare("UPDATE dev_agent_grants SET expires_at = NULL WHERE agent_id = ? AND capability = 'media.object.upload'").run(A);
++
++ // ── An agent that is not active lists no grants ──
++ ok(await api('owner', 'POST', `/${P}/agents/${A}/pause`));
++ const p = (await read(A, media)).body;
++ assert.deepStrictEqual([p.agent.status, p.grants, p.budgets], ['paused', [], []]);
++ ok(await api('owner', 'POST', `/${P}/agents/${A}/resume`));
++ assert.strictEqual((await read(A, media)).body.grants.length, 2);
++
++ // ── Unknown and malformed ids ──
++ refused(await read(`agt_${'9'.repeat(26)}`, media), 404, 'agent.not_found');
++ refused(await read('agt_nope', media), 404, 'agent.not_found');
++ refused(await read(P, media), 404, 'agent.not_found');
++ console.log('internal-agents: all tests passed');
++ } finally {
++ server.close();
++ }
++})().catch((err) => { console.error(err); process.exit(1); });
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/principals.test.js b/test/principals.test.js
iindex 8fc7b1be..dae058f0 100644
---- a/test/principals.test.js
++++ b/test/principals.test.js
@@@ -167,6 +167,11 @@ const server = http.createServer(app);
const cps = await token({ client_id: 'coupons', client_secret: 'coupons-secret', audience: 'openvibe.events', scope: 'events.subscription.manage' });
assert.strictEqual(cps.status, 200, JSON.stringify(cps.body));
assert.strictEqual(cps.body.scope, 'events.subscription.manage', 'Coupons manages its Events subscriptions');
++ // Trade subscribes to the Sources filings it follows (plan T9 step 1).
++ await db.prepare("UPDATE oauth_clients SET client_secret = 'trade-secret' WHERE client_id = 'trade'").run();
++ const trs = await token({ client_id: 'trade', client_secret: 'trade-secret', audience: 'openvibe.events', scope: 'events.subscription.manage' });
++ assert.strictEqual(trs.status, 200, JSON.stringify(trs.body));
++ assert.strictEqual(trs.body.scope, 'events.subscription.manage', 'Trade manages its Events subscriptions');
// Go-live fan-out accepts Live's token (network.notifications.push); a narrower token is refused.
r = await post('/internal/events/stream-live', {}, { authorization: `Bearer ${full}` });
ddiff --git a/test/registry-ecosystem.test.js b/test/registry-ecosystem.test.js
iindex 23f67f98..93ad3f84 100644
---- a/test/registry-ecosystem.test.js
++++ b/test/registry-ecosystem.test.js
@@@ -77,8 +77,8 @@ const { createEcosystemRegistry } = require('../server/registry/ecosystem');
assert.strictEqual(inCat('events'), 'platform'); assert.strictEqual(inCat('ai'), 'platform'); assert.strictEqual(inCat('sources'), 'platform');
assert.strictEqual(inCat('sdk'), 'library'); assert.strictEqual(inCat('contracts'), 'library');
assert.strictEqual(inCat('examples'), 'repository');
-- // 'planned' = exposure placeholder: Contracts 0.83.0's media-hub and space (charter only, nothing runs)
-- assert.deepStrictEqual(cats.planned.services.map(x => x.id).sort(), ['media-hub', 'space']);
++ // 'planned' = exposure placeholder: Contracts 0.83.0's media-hub and space, v0.87-v0.90's zone and run (charter only, nothing runs)
++ assert.deepStrictEqual(cats.planned.services.map(x => x.id).sort(), ['media-hub', 'run', 'space', 'zone']);
const liveRow = cats.site.services.find(x => x.id === 'live');
assert.strictEqual(liveRow.runtime, 'down'); assert.ok(liveRow.checked_at, 'rows carry the last check');
r = await get('/api/v1/registry/categories/library');
ddiff --git a/test/registry-exposure.test.js b/test/registry-exposure.test.js
iindex cc24b006..7621bb9a 100644
---- a/test/registry-exposure.test.js
++++ b/test/registry-exposure.test.js
@@@ -20,6 +20,8 @@ const PINNED = {
news: 'internal', reviews: 'internal', deals: 'internal', coupons: 'internal', trade: 'internal', host: 'internal',
tips: 'internal', vip: 'internal', openre: 'internal', search: 'live', sources: 'internal', chat: 'live', 'media-hub': 'placeholder', space: 'placeholder',
ai: 'internal',
++ // openvibe-contracts v0.86-v0.90: Bot runs (its own exposure row); Zone and Run are charters only.
++ bot: 'live', zone: 'placeholder', run: 'placeholder',
sdk: 'library', shared: 'library', contracts: 'library', publishing: 'library',
examples: 'repository',
};
@@@ -102,8 +104,9 @@ const ready = [200, { ready: true, status: 'ready', failed: [], degraded: [], ch
assert.strictEqual(sBy.live.label, 'up');
assert.strictEqual(sBy.live.origin, 'https://openvibe.live');
assert.strictEqual(sBy.shared.label, `not running (library, released v${require('openvibe-shared/package.json').version})`);
-- // Contracts 0.83.0 added two placeholder services (media-hub, space); none is retired
-- assert.deepStrictEqual(st.exposure_summary, { live: 14, internal: 11, library: 4, repository: 1, placeholder: 2, retired: 0 });
++ // Contracts 0.83.0 added two placeholder services (media-hub, space), v0.86-v0.90 Bot (live) and two more (zone, run);
++ // none is retired
++ assert.deepStrictEqual(st.exposure_summary, { live: 15, internal: 11, library: 4, repository: 1, placeholder: 4, retired: 0 });
// The page: no row for a service whose domain serves a placeholder shows a bare "Up".
const html = await fetch(base + '/status').then(x => x.text());