openvibers/openvibe.services Commits

Route resource control requests to owning authorities with confirmations (#54)

OP OpenVibers committed 30 days ago · 5c6fed3fb4 · 23 files, +995 −37

Asked: Part of a cross-repository initiative (init-t13-step-10-adr-048-control-1013T2256): T13 step 10 (ADR-048 control operations): OpenVibe.Services accepts a common.resource-control-request@1 at POST /v1/resources/control behind a new first-party capability services.resource.control registered in OpenV…
Harness job init-t13-step-10-adr-2256-s2-233827 (plan T13).

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 73a6c57..002d3f3 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,13 @@ Notable changes to OpenVibe.Services. The service is unreleased; everything belo
## Unreleased
++- Control operations (ADR-048, plan T13 step 10): `POST /api/v1/resources/control` behind `services.resource.control` (contracts v0.154.0, was v0.150.0).
++ - It takes a `common.resource-control-request@1` and answers the owning authority's `common.resource-control-result@1`. The request is forwarded through `openvibe-sdk/resources` `createResourceClient`.
++ - Callers: a first-party service token, or a person who owns or administers the project.
++ - Idempotent on `idempotency_key` (`services_control_log`).
++ - Authorities without a control endpoint answer `failed` / `resources.authority_unsupported`. Today that is all of them.
++ - An agent's delete, money or publishing request is refused with `confirmation_required` until its owner approves through Network's WS-Z2 confirmations.
++ - One `services_audit` row per request. Migration `0004_control.sql`, `test/control.test.js`, `test/control-confirmations.test.js`.
- Account export and deletion (ADR-033): `POST /internal/events` (loopback, `SERVICES_EVENTS_SECRET`) answers network.account.export_requested and network.account.deleted through `server/domain/account-data.js` over `openvibe-sdk/account-data` (SDK v0.37.0, was v0.35.1).
- **Deleted:** the person's playground and recipe runs.
- **Authorless:** manifests and releases stay with their project, without the person.
ddiff --git a/README.md b/README.md
iindex cb75709..526b245 100644
---- a/README.md
++++ b/README.md
@@@ -38,6 +38,7 @@ A place where a developer outside the network goes from an OpenVibe account to a
| Playgrounds | `/projects/:project/apps/:app/playground` | Media upload (sandbox, `media.object.upload`) and Events publish (`events.app.publish`: types `app.<project_key>.*`, source `app-<app ULID>`), run **as the app** with its own token (from its secret typed for that one request, or a pasted token). Refused — with the missing grant named, and nothing requested or called — unless Network lists the grant as approved. Sandbox apps only. |
| Manifests | `/manifests/validate`, `/projects/:project/apps/:app/releases/new` | Validate `services.app-manifest@1` and `mods.mod-manifest@1` with openvibe-contracts; create, publish, deprecate and revoke releases. |
| Resource index | `GET /api/v1/resources`, `GET /api/v1/resources/:ovrn`, `GET /api/v1/authorities` | The merged index over every authority (ADR-048, `services.resource.read`): see [The merged index](#the-merged-index) below. |
++| Resource control | `POST /api/v1/resources/control` | One `common.resource-control-request@1`, routed to the authority that owns the resource (ADR-048, `services.resource.control`): see [Control operations](#control-operations) below. |
| Policy | `/policy/*` | Proposal process and the decision record; compatibility and deprecation policy rendered from ADR-002 and ADR-016 as published; licensing read from package metadata; transparency (what Services stores and does not). The community documents — code of conduct, contributing, the contributor ladder and the moderation policy — are OpenVibe.Codes' (`https://openvibe.codes/policy/<slug>`); their old addresses here redirect there. |
| API | `/api/v1/*` | Public release reads (`services.release.read`: no token needed; a presented token must hold it), manifest validation, docs versions; release management by an app with its own token (`services.release.manage`); Services' own resource index (`services.resource.read`, first-party). Guarded with openvibe-contracts `requireCapability`. RFC 9457 problems. |
| Machine | `/api/health`, `/api/ready`, `/release.json`, `/metrics` | Readiness is truthful (db and docs required; Network, JWKS, OAuth client and events relay reported as optional checks). `/metrics` answers loopback callers only. |
@@@ -83,6 +84,34 @@ An authority is never required to answer for the index to answer: a timeout (def
a malformed page or a Network that cannot mint its token makes that authority partial and leaves the rest of the page
intact.
++## Control operations
++
++`POST /api/v1/resources/control` (plan T13 step 10) takes one `common.resource-control-request@1` and answers the
++owning authority's `common.resource-control-result@1` (`200`, or `202` when `pending`). Services applies nothing:
++
++- **Who may call.** A first-party `svc:` token holding `services.resource.control` (any project), or a person's own user
++ token for a project they **own or administer** (Network answers the role; not a member is `404`, a member below admin
++ `403`). A person acts for themself only: `on_behalf_of` is absent or names them, and Services sends it on.
++- **Routing.** The body is checked with `contracts.resources.checkControlRequest` (`400 control.bad_request`; the
++ contract ties `project_id` to the resource's own project). The authority is the OVRN's service, or `resource_kind`'s
++ for `create` (`400 resources.unknown_service` otherwise), and the request is forwarded through `openvibe-sdk/resources`
++ `createResourceClient` with Services' token for that authority's own `<id>.resource.control`. An authority whose
++ manifest lists no active `<id>.resource.control`, or whose control path answers 404/405/501, is `failed` with
++ `resources.authority_unsupported`. Today that is every authority (none ships a control endpoint yet), and Services'
++ own resources too. An answer that is not a valid result, or names another project's resource, is `failed` with
++ `resources.authority_bad_response`. An authority's own `refused` (with its `confirmation_required`) passes through.
++- **Idempotency.** `services_control_log` holds one row per `idempotency_key` (UNIQUE). A repeat gets the stored answer
++ and the authority is not asked again. A repeat while the first is still running is `409 control.in_progress`. The key
++ reused by another caller or for another request is `409 control.idempotency_conflict`.
++- **Sensitive actions (WS-Z2).** A request made for an agent (`on_behalf_of: { type: 'agent' }`) that deletes, spends
++ money (a money field in `params`) or publishes is `refused` with `confirmation_required: { confirmation_id, reason }`.
++ Network's confirmations (`/internal/confirmations`, `network.confirmation.manage`) mint the id and ask the agent's
++ owner. The retry carries `confirmation_id`. Services spends the approval once, for exactly that request (its
++ digest), before the authority is asked. A standing rule's approval proceeds at once. A dry run, and a person acting
++ with their own token, are not gated here.
++- **Record.** One `services_audit` row per accepted request (a replay too), written with the log row's answer
++ (migration `0004_control.sql`). Neither table stores `params` (only their digest) or a token.
++
## The authority registry
`server/authorities/index.js` builds the registry at boot from the **pinned openvibe-contracts service manifests**:
@@@ -98,11 +127,11 @@ There is no polling loop: the index reads the authorities on each request (step
## Depends on
--- **OpenVibe.Network** — SSO (OAuth client `services`, PKCE S256), JWKS, `/api/v1/projects` (called server-side with the person's Network access token), the registry (`/api/v1/registry/services`, `/.well-known/openvibe`), client-credentials tokens (Services' own for the events relay; the app's own in playgrounds).
++- **OpenVibe.Network** — SSO (OAuth client `services`, PKCE S256), JWKS, `/api/v1/projects` (called server-side with the person's Network access token), the registry (`/api/v1/registry/services`, `/.well-known/openvibe`), client-credentials tokens (Services' own for the events relay; the app's own in playgrounds), and the WS-Z2 confirmations (`/internal/confirmations`) for sensitive control operations.
- **OpenVibe.Events** — the outbox relay publishes `services.app.*` with Services' service token (`events.event.publish`); the Events playground calls it with the app's token (`events.app.publish`); the project archive pulls the project's app events with a Network export token (`events.app.read`).
- **OpenVibe.Media** — the Media playground uploads with the app's token into the project's namespace; the project archive lists the project's objects, namespaces and download URLs with a Network export token (`media.object.list`, `media.object.read`).
- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional).
--- **openvibe-contracts v0.150.0**, **openvibe-sdk v0.35.1**, **openvibe-shared v4.1.3** (pinned tag tarballs; the docs show the tag and the package version, and say so when they differ).
++- **openvibe-contracts v0.154.0**, **openvibe-sdk v0.44.0**, **openvibe-shared v4.1.3** (pinned tag tarballs; the docs show the tag and the package version, and say so when they differ).
No path in Services sends or accepts a shared loopback key (tested by grep and at runtime).
@@@ -125,6 +154,10 @@ In Network (`server/identity/principals.js` / `server/db/database.js`, as for co
- OAuth client `services`, name `OpenVibe.Services`, redirect URI `https://openvibe.services/auth/callback`.
- Service grant `['services', 'events.event.publish', 'openvibe.events', []]` (the outbox relay).
++- Control operations (plan T13 step 10): `['services', 'network.confirmation.manage', 'openvibe.network', []]` (the
++ sensitive-action gate; until it is granted, a gated request fails with `confirmations.unavailable` and nothing is
++ applied), and `['services', '<id>.resource.control', 'openvibe.<id>', []]` for each authority once it ships a control
++ endpoint and registers that capability.
- Account export and deletion (ADR-033): `['services', 'events.subscription.manage', 'openvibe.events', []]` (the two
account subscriptions, created at boot), then, last and once the release is live,
`network.account.export.contribute` and `network.account.deletion.confirm` for audience `openvibe.network`.
ddiff --git a/STATUS.json b/STATUS.json
iindex f24cad3..e4e3b63 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -7,21 +7,22 @@
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db)",
"port": 4930,
"domain": "openvibe.services",
-- "contracts": "openvibe-contracts v0.150.0",
++ "contracts": "openvibe-contracts v0.154.0",
"sdk": "openvibe-sdk v0.44.0",
"shared": "openvibe-shared v4.1.3",
"packages": [
-- "openvibe-contracts v0.150.0",
++ "openvibe-contracts v0.154.0",
"openvibe-sdk v0.44.0",
"openvibe-shared v4.1.3"
],
"capabilities": [
"services.resource.read",
"services.release.read",
-- "services.release.manage"
++ "services.release.manage",
++ "services.resource.control"
],
"capabilitiesRegistered": true,
-- "capabilitiesNote": "All three are registered in the pinned openvibe-contracts v0.113.0: services.resource.read (first-party, the merged index), services.release.read (public) and services.release.manage (an app managing its own releases). They replaced codes.resource.read and codes.release.read|manage when the portal moved here. The later services.resource.control, services.recipe.run, services.usage.read and services.cost.read arrive with their plan T13 steps.",
++ "capabilitiesNote": "services.resource.read, services.release.read and services.release.manage are registered since openvibe-contracts v0.113.0: services.resource.read (first-party, the merged index), services.release.read (public) and services.release.manage (an app managing its own releases). They replaced codes.resource.read and codes.release.read|manage when the portal moved here. services.resource.control (first-party, POST /api/v1/resources/control, plan T13 step 10) is registered in v0.154.0. The later services.recipe.run, services.usage.read and services.cost.read arrive with their plan T13 steps.",
"events": [
"services.app.published",
"services.app.deprecated",
ddiff --git a/migrations/0004_control.sql b/migrations/0004_control.sql
nnew file mode 100644
iindex 0000000..cf6843d
---- /dev/null
++++ b/migrations/0004_control.sql
@@@ -0,0 +1,19 @@
++-- phase: expand
++-- Control operations (ADR-048, plan T13 step 10): POST /api/v1/resources/control. services_control_log (migration 0001)
++-- already holds one row per idempotency key: the request (params as a digest only), the answer and its state. This adds
++-- the console's per-project read of that log, and services_audit: one row per control request Services accepted
++-- (a replay of an idempotency key too), naming who asked, what, for which project and what came of it. Neither table
++-- holds a credential, a token or the request's params (test/secrets.test.js); the authorities' own audit stays theirs.
++
++CREATE INDEX IF NOT EXISTS services_control_log_project_at ON services_control_log (project_id, at DESC);
++
++CREATE TABLE IF NOT EXISTS services_audit (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ at timestamptz NOT NULL DEFAULT now(),
++ actor text NOT NULL, -- user:usr_… or svc:…
++ action text NOT NULL, -- resource.control
++ target text NOT NULL, -- the OVRN, or kind:<service.type> for create
++ project_id text NOT NULL,
++ detail jsonb NOT NULL DEFAULT '{}'::jsonb -- control action, state, problem code, control-log id, replayed
++);
++CREATE INDEX IF NOT EXISTS services_audit_project_at ON services_audit (project_id, at DESC);
ddiff --git a/package-lock.json b/package-lock.json
iindex 10c7c95..88db693 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -16,7 +16,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.150.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.154.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0"
@@@ -890,9 +890,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.150.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.150.0",
-- "integrity": "sha512-CNKm1UpEynr56yIK9ppnpSAM2JXT+YLuPbLGzw4tB9A3pX/rfSIKlo8Pj3agEkoNwwlavXDdXv9az1S0Gg2H7g==",
++ "version": "0.154.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.154.0",
++ "integrity": "sha512-QRGUnH85VczGeVDDNlogKjKYUd7/BJno0sHP7pycj8h/YcdnrzjbuAtsKErHTk7LY54zBBa1n7sKhRyRaBQ42A==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex d2bc1a8..346eff5 100644
---- a/package.json
++++ b/package.json
@@@ -27,7 +27,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.150.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.154.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0"
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 584e8fe..3adaf08 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -8,9 +8,18 @@
* | GET /api/v1/resources | services.resource.read | ?project=&kind=&service=&cursor=&limit= → common.resource-list-result@1 (merged over every authority; a page that could not read one names it in `partial: [{ service, code }]`) |
* | GET /api/v1/resources/:ovrn| services.resource.read | common.no-body@1 → common.resource-summary@1 from the authority that owns it |
* | GET /api/v1/authorities | services.resource.read | common.no-body@1 → { authorities: […] } (Services-local; no contract yet) |
++ * | POST /api/v1/resources/control | services.resource.control | common.resource-control-request@1 → common.resource-control-result@1 from the owning authority (200; 202 when pending) |
*
-- * services.resource.read is registered in the pinned openvibe-contracts (status active, visibility
-- * first-party): contracts' manifest decides it, and an id contracts does not know is refused, never allowed.
++ * services.resource.read and services.resource.control are registered in the pinned openvibe-contracts (status
++ * active, visibility first-party): contracts' manifest decides them, and an id contracts does not know is refused,
++ * never allowed.
++ *
++ * Control (plan T13 step 10): a service token holding services.resource.control (any project), or a person's user
++ * token for a project they own or administer (Network answers the role). The body is checked with
++ * contracts.resources.checkControlRequest (400 control.bad_request); idempotency, the sensitive-action gate and the
++ * control-log and audit rows are server/domain/control.js; the forwarding is server/resources.js. 409
++ * control.idempotency_conflict / control.in_progress for a reused or running idempotency_key. An authority's refusal or
++ * failure is a result with state refused or failed, never an HTTP error.
*
* Who may call: a Network service token holding services.resource.read (any project, ?project= only
* narrows), or a person's user token — scoped to ONE project they own or belong to, verified with their
@@@ -22,8 +31,9 @@
* network.unavailable.
*/
const express = require('express');
--const { http } = require('openvibe-contracts');
++const { http, resources } = require('openvibe-contracts');
const { ServiceError } = require('../util');
++const { httpStatusOf } = require('../domain/control');
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const SERVICE_RE = /^[a-z][a-z0-9-]{0,31}$/;
@@@ -59,7 +69,7 @@ function listQuery(query, config) {
return out;
}
--function v1Router({ config, apiAuth, index, authorities }) {
++function v1Router({ config, apiAuth, index, authorities, control }) {
const r = express.Router();
// One merged page over every authority (or the one ?service= names). A failing authority's rows are
@@@ -75,6 +85,24 @@ function v1Router({ config, apiAuth, index, authorities }) {
: { resources: page.resources, next_cursor: page.next_cursor });
});
++ // One control operation, routed to the authority that owns the resource. The body is validated, the caller authorized
++ // for its project_id (which the contract ties to the resource's own project) and only then is anything claimed,
++ // gated or forwarded.
++ r.post('/resources/control', async (req, res) => {
++ apiAuth.requireControl(req);
++ const request = req.body;
++ const check = request && typeof request === 'object' && !Array.isArray(request)
++ ? resources.checkControlRequest(request)
++ : { valid: false, errors: [{ path: '', message: 'the body is a JSON object' }] };
++ if (!check.valid) {
++ throw new ServiceError(400, 'control.bad_request', 'the body is not a valid common.resource-control-request@1',
++ { errors: check.errors.slice(0, 20).map((e) => ({ path: String(e.path || e.instancePath || ''), message: String(e.message || 'invalid') })) });
++ }
++ await apiAuth.authorizeControl(req, request.project_id);
++ const result = await control.run(request, req.principal);
++ res.status(httpStatusOf(result)).json(result);
++ });
++
// One resource by its OVRN, straight from the authority that owns it. The OVRN is parsed first (400),
// the owning service resolved (400), and the caller authorized for its project — a person must be a
// member of that project; a service token needs services.resource.read. Nothing is asked of the
ddiff --git a/server/app.js b/server/app.js
iindex 4bd3556..2e6cd38 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -11,12 +11,13 @@
* /projects the signed-in console over Network's projects API (http/portal.js)
* /releases/:id/* release actions (http/portal.js)
* /api/v1/resources, /api/v1/authorities the merged resource index over every authority (api/v1.js)
++ * /api/v1/resources/control one control operation, routed to the owning authority (api/v1.js)
* /api/v1 the release API and the docs' JSON (http/api.js)
* /auth/* Network SSO with PKCE (auth/sso.js)
* /api/health, /api/ready, /release.json, /metrics (loopback only)
*
-- * createApp({ config, store, now, fetchImpl, log, keys, membership, authorities, tokens, index, registry,
-- * limitsNow, actorLimits, indexnow, valkey }) → { app, ctx }
++ * createApp({ config, store, now, fetchImpl, log, keys, membership, authorities, tokens, index, confirmations,
++ * registry, limitsNow, actorLimits, indexnow, valkey }) → { app, ctx }
*/
const path = require('path');
const express = require('express');
@@@ -40,6 +41,8 @@ const { createAuthorityTokens } = require('./authorities/tokens');
const { createResourceIndexService } = require('./resources');
const { createSelfAuthority } = require('./registry/self-authority');
const { v1Router } = require('./api/v1');
++const { createConfirmations } = require('./clients/confirmations');
++const { createControl } = require('./domain/control');
const { generate } = require('./docs/generate');
const { createTrust } = require('./domain/trust');
const { createReleases } = require('./domain/releases');
@@@ -103,8 +106,12 @@ async function createApp(opts = {}) {
const userAuth = createUserAuth(config, keys);
const membership = opts.membership || createMembership(config, { fetchImpl: fetchImpl || globalThis.fetch });
const apiAuth = createApiAuth({ config, keys, userAuth, membership });
++ // Control operations (plan T13 step 10): forwarded through the index's authority adapters, sensitive ones gated on
++ // Network's confirmations, every request in services_control_log and services_audit.
++ const confirmations = opts.confirmations || createConfirmations(config, { tokens, fetchImpl: fetchImpl || globalThis.fetch });
++ const control = createControl({ store, index, confirmations, log });
-- const ctx = { config, store, docs, keys, sso, network, outbox, trust, releases, playground, archiver, limits, indexnow, authorities, tokens, index, apiAuth, log };
++ const ctx = { config, store, docs, keys, sso, network, outbox, trust, releases, playground, archiver, limits, indexnow, authorities, tokens, index, apiAuth, control, log };
const app = express();
app.disable('x-powered-by');
@@@ -184,7 +191,7 @@ async function createApp(opts = {}) {
express.json({ limit: INDEX_JSON_LIMIT }),
(req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); },
apiAuth.middleware,
-- v1Router({ config, apiAuth, index, authorities }));
++ v1Router({ config, apiAuth, index, authorities, control }));
app.use('/api/v1', (req, res, next) => (/^\/(resources|authorities)(\/|$)/.test(req.path) ? kernel(req, res, next) : next()));
// ── Who is asking (verified offline; refreshed when expired) ──
ddiff --git a/server/auth/api.js b/server/auth/api.js
iindex c2b16cd..355e56a 100644
---- a/server/auth/api.js
++++ b/server/auth/api.js
@@@ -14,10 +14,10 @@
*
* A request that presents a token is judged on that token alone: a bad one is refused, never downgraded.
*
-- * Capabilities: services.resource.read is registered in the pinned openvibe-contracts (status active,
-- * visibility first-party), so contracts' manifest and grant rules decide it (wildcards included) — and
-- * an id contracts does not know is refused, never allowed. Services enforces exactly the id the manifest
-- * names. services.resource.control arrives with plan T13 step 10.
++ * Capabilities: services.resource.read and services.resource.control are registered in the pinned
++ * openvibe-contracts (status active, visibility first-party), so contracts' manifest and grant rules decide
++ * them (wildcards included) — and an id contracts does not know is refused, never allowed. Services enforces
++ * exactly the ids the manifest names.
*/
const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
const { ServiceError } = require('../util');
@@@ -27,11 +27,14 @@ const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const ANON = Object.freeze({ kind: 'anonymous' });
/**
-- * The capabilities Services enforces. services.resource.read — active and first-party in the pinned
-- * openvibe-contracts — is the whole read side of the resource index (list, one, the authority list).
-- * services.resource.control arrives with plan T13 step 10.
++ * The capabilities Services enforces, both active and first-party in the pinned openvibe-contracts:
++ * services.resource.read is the whole read side of the resource index (list, one, the authority list);
++ * services.resource.control is POST /api/v1/resources/control (plan T13 step 10).
*/
--const CAPS = Object.freeze({ resourceRead: 'services.resource.read' });
++const CAPS = Object.freeze({ resourceRead: 'services.resource.read', resourceControl: 'services.resource.control' });
++
++/** The project roles that may control a project's resources with their own user token. */
++const CONTROL_ROLES = new Set(['owner', 'admin']);
/** One capability against a token's claims: contracts' manifest and grant rules decide; an unknown id is refused. */
function capabilityDecision(claims, id) {
@@@ -125,6 +128,38 @@ function createApiAuth({ config, keys, userAuth, membership }) {
}
}
++ /**
++ * The control gate (POST /api/v1/resources/control): anonymous 401; a service token needs
++ * services.resource.control and must be a svc: token (first-party only, like the read side). A user token passes
++ * here; authorizeControl decides their project.
++ */
++ function requireControl(req) {
++ const p = req.principal;
++ if (!p || p.kind === 'anonymous') throw new ServiceError(401, 'token.missing', `${CAPS.resourceControl} needs a Network service or user token`);
++ if (p.kind === 'service') {
++ const d = capabilityDecision(p.claims, CAPS.resourceControl);
++ if (!d.allowed) throw new ServiceError(403, d.code || 'capability.denied', d.reason || `${CAPS.resourceControl} not granted`);
++ if (!String(p.sub || '').startsWith('svc:')) {
++ throw new ServiceError(403, 'capability.denied', `${CAPS.resourceControl} is first-party: a svc: service token or a person's own user token, never an app, mod or agent token`);
++ }
++ }
++ }
++
++ /**
++ * Whether this caller may control resources of `project`: a service token any project; a person only a project
++ * they own or administer (Network answers the role with their own token). Not a member: 404 (existence is not
++ * disclosed); a member below admin: 403.
++ */
++ async function authorizeControl(req, project) {
++ const p = req.principal;
++ if (p.kind === 'service') return { project };
++ const role = await membership.projectRole(p.token, project);
++ if (role === 'unavailable') throw new ServiceError(503, 'network.unavailable', 'OpenVibe.Network could not answer whether this account may control that project');
++ if (!role) throw new ServiceError(404, 'resources.project_not_found', `no project ${project} for this account`);
++ if (!CONTROL_ROLES.has(role)) throw new ServiceError(403, 'capability.denied', `controlling ${project}'s resources needs the owner or admin role`);
++ return { project, role };
++ }
++
/** The first-party gate: a service token with services.resource.read (the authority registry). */
function requireServiceRead(req) {
requireRead(req);
@@@ -152,7 +187,7 @@ function createApiAuth({ config, keys, userAuth, membership }) {
return { project };
}
-- return { middleware, resolve, capabilityDecision, requireRead, requireServiceRead, authorizeProject, projectOf };
++ return { middleware, resolve, capabilityDecision, requireRead, requireServiceRead, requireControl, authorizeProject, authorizeControl, projectOf };
}
module.exports = { createApiAuth, keyFor, capabilityDecision, userPrincipal, verifyService, decodePayload, projectOf, CAPS, PROJECT_RE, PRINCIPAL_SUB };
ddiff --git a/server/authorities/adapter.js b/server/authorities/adapter.js
iindex 6d74b1d..ad286a9 100644
---- a/server/authorities/adapter.js
++++ b/server/authorities/adapter.js
@@@ -12,7 +12,12 @@
* (ADR-046 section 6) — one authority's failure never fails the whole page.
* get(ovrn) GET /api/v1/resources/:ovrn → { status, body }; a transport failure is
* resources.authority_unavailable (502) in the caller's terms.
++ * control(request) POST /api/v1/resources/control through openvibe-sdk/resources' createResourceClient, with a
++ * token scoped to the authority's own <id>.resource.control → its common.resource-control-result@1;
++ * null when the authority takes no control requests (no active control capability). SDK errors
++ * (OpenVibeError: status/code, sdk.timeout, sdk.network_error) propagate to server/resources.js.
*/
++const { createResourceClient } = require('openvibe-sdk/resources');
const { ServiceError } = require('../util');
const trim = (u) => String(u || '').replace(/\/+$/, '');
@@@ -46,7 +51,17 @@ function createAuthorityAdapter(authority, { tokens, fetchImpl = globalThis.fetc
return { status: res.status, body };
}
-- return { authority, origin: base, fetchAs, get };
++ let client = null;
++ /** One control request to the authority; null when it has no control capability (the caller answers unsupported). */
++ async function control(request) {
++ if (!authority.control) return null;
++ client = client || createResourceClient({
++ origin: base, token: () => tokens.get(authority, authority.control), fetch: fetchImpl, timeoutMs, retries: 1,
++ });
++ return client.control(request);
++ }
++
++ return { authority, origin: base, fetchAs, get, control };
}
/** The one fetch createResourceIndex fans out through: the URL's own authority adds its token. */
ddiff --git a/server/authorities/index.js b/server/authorities/index.js
iindex bc577d8..6ba2aa2 100644
---- a/server/authorities/index.js
++++ b/server/authorities/index.js
@@@ -34,6 +34,10 @@ function createAuthorities(config, { log = console } = {}) {
log.warn(`[Services] ${m.id} lists ${capId} but names no internalOrigin: it is left out of the registry`);
continue;
}
++ // The control side (ADR-048, plan T13 step 10): an authority takes control requests only when its manifest lists
++ // <id>.resource.control and contracts has it active. Otherwise Services answers its control requests unsupported.
++ const controlId = `${m.id}.resource.control`;
++ const controlCap = m.capabilities.includes(controlId) ? contracts.capabilities.get(controlId) : null;
byId.set(m.id, Object.freeze({
id: m.id,
name: m.name || m.id,
@@@ -41,6 +45,7 @@ function createAuthorities(config, { log = console } = {}) {
publicOrigin: m.publicOrigin || null,
audience: `openvibe.${m.id}`,
capability: capId,
++ control: controlCap && controlCap.status === 'active' ? controlId : null,
manifestVersion: m.version || null,
contractsRange: (m.contractRanges || {})['openvibe-contracts'] || null,
status: m.status,
ddiff --git a/server/authorities/tokens.js b/server/authorities/tokens.js
iindex 9cef46c..e73f803 100644
---- a/server/authorities/tokens.js
++++ b/server/authorities/tokens.js
@@@ -30,8 +30,11 @@ function createAuthorityTokens(config, { authorities, fetchImpl = globalThis.fet
return {
enabled,
-- /** The bearer token for one authority's audience; throws when Network cannot mint it. */
-- get: async (authority) => (client ? client.getToken({ audience: authority.audience }) : null),
++ /**
++ * The bearer token for one authority's audience (its resource.read, or `scope` when named: the control
++ * capability, network.confirmation.manage); throws when Network cannot mint it.
++ */
++ get: async (authority, scope) => (client ? client.getToken({ audience: authority.audience, ...(scope ? { scope } : {}) }) : null),
/** What a caller may show without a token: the audiences it can mint for, and whether it can. */
status: () => ({ enabled, client_id: config.oauth.clientId, audiences: authorities.list().map((a) => a.audience) }),
invalidate: (ctx) => { if (client) client.invalidate(ctx); },
ddiff --git a/server/clients/confirmations.js b/server/clients/confirmations.js
nnew file mode 100644
iindex 0000000..359867a
---- /dev/null
++++ b/server/clients/confirmations.js
@@@ -0,0 +1,57 @@
++'use strict';
++
++/**
++ * Network's confirmations, service side (roadmap WS-Z2; OpenVibe.Network server/developer/confirmations.js, its
++ * /internal/confirmations router behind network.confirmation.manage). Services owns services.resource.control, so it is
++ * the service that asks an agent's owner to approve one sensitive control request and then spends that approval once:
++ *
++ * create({ agentId, summary, details, requestDigest }) POST /internal/confirmations → network.confirmation-request@1
++ * (pending: the owner is asked; approved: a standing rule said yes)
++ * consume(id, requestDigest) POST /internal/confirmations/:id/consume → spends the approval;
++ * Network re-checks it is approved, unused, unexpired and for
++ * this exact request (the digest)
++ *
++ * Network mints the confirmation id, derives the owner from the agent and keeps the decision; Services never mints or
++ * approves one. The token is Services' own client-credentials token for audience openvibe.network, scoped to
++ * network.confirmation.manage. Network's refusal comes back as { ok: false, status, code, detail } so the caller can put it
++ * in a result; a transport or token failure is 503 confirmations.unavailable.
++ */
++const NETWORK = Object.freeze({ id: 'network', audience: 'openvibe.network' });
++const CAP = 'network.confirmation.manage';
++const CONTROL_CAP = 'services.resource.control';
++
++const trim = (u) => String(u || '').replace(/\/+$/, '');
++
++function createConfirmations(config, { tokens, fetchImpl = globalThis.fetch, timeoutMs = 5000 } = {}) {
++ const base = `${trim(config.network.internalUrl)}/internal/confirmations`;
++
++ async function call(path, body) {
++ let res;
++ try {
++ const token = await tokens.get(NETWORK, CAP);
++ res = await fetchImpl(`${base}${path}`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
++ body: JSON.stringify(body),
++ signal: AbortSignal.timeout(timeoutMs),
++ });
++ } catch (err) {
++ return { ok: false, status: 503, code: 'confirmations.unavailable', detail: `OpenVibe.Network's confirmations did not answer (${err.message})` };
++ }
++ const data = await res.json().catch(() => null);
++ if (res.ok && data && data.confirmation) return { ok: true, confirmation: data.confirmation };
++ if (res.status >= 500 || res.status === 401 || !data || typeof data.code !== 'string') {
++ return { ok: false, status: 503, code: 'confirmations.unavailable', detail: `OpenVibe.Network's confirmations answered HTTP ${res.status}` };
++ }
++ return { ok: false, status: res.status, code: data.code, detail: data.detail || data.title || data.code };
++ }
++
++ return {
++ create: ({ agentId, summary, details, requestDigest }) => call('', {
++ requested_by: { type: 'agent', id: agentId }, capability: CONTROL_CAP, summary, details, request_digest: requestDigest,
++ }),
++ consume: (id, requestDigest) => call(`/${encodeURIComponent(id)}/consume`, { request_digest: requestDigest }),
++ };
++}
++
++module.exports = { createConfirmations, NETWORK, CAP };
ddiff --git a/server/domain/account-data.js b/server/domain/account-data.js
iindex 51da924..287809d 100644
---- a/server/domain/account-data.js
++++ b/server/domain/account-data.js
@@@ -9,7 +9,7 @@
* the button: they stay. created_by is NOT NULL there, so it becomes `deleted` (the resource
* index then reports no owner); published_by, deprecated_by and revoked_by become NULL; the
* resource index's owner becomes NULL
-- * kept release_log (append-only by trigger), services_control_log and the trust tiers a staff
++ * kept release_log (append-only by trigger), services_control_log, services_audit and the trust tiers a staff
* member set: the audit trail of who published, revoked, reviewed or changed what, kept for
* accountability (counted as retained)
*
@@@ -32,6 +32,7 @@ const TABLES = [
{ table: 'trust', subject: 'set_by', value: user, file: null, erase: { keep: 'a trust tier names the staff member who set it, for accountability' } },
{ table: 'trust_history', subject: 'set_by', value: user, file: null, erase: { keep: 'the trust history names the staff member who set each tier, for accountability' } },
{ table: 'services_control_log', subject: 'actor_subject', value: (usr) => [usr, user(usr)], file: null, erase: { keep: 'the control-plane audit log, kept for accountability' } },
++ { table: 'services_audit', subject: 'actor', value: user, file: null, erase: { keep: 'the control-plane audit log, kept for accountability' } },
];
/** Manifests and releases they created: exported, then made authorless in extraErase (created_by is NOT NULL). */
ddiff --git a/server/domain/control.js b/server/domain/control.js
nnew file mode 100644
iindex 0000000..431fc3b
---- /dev/null
++++ b/server/domain/control.js
@@@ -0,0 +1,183 @@
++'use strict';
++
++/**
++ * Control operations (ADR-048, plan T13 step 10): POST /api/v1/resources/control, one common.resource-control-request@1
++ * in, one common.resource-control-result@1 out. Services decides none of it: the owning authority applies or refuses
++ * (server/resources.js `control`, through openvibe-sdk/resources' createResourceClient). What this module adds around that:
++ *
++ * - Idempotency on idempotency_key: services_control_log has one row per key (UNIQUE). The first request claims it
++ * (status pending, no answer yet), a repeat with the same key gets the stored answer and the authority is not asked
++ * again, a repeat while the first is still running is 409 control.in_progress, and the key reused by another caller
++ * or for another request (action, resource, project, params, on_behalf_of, dry_run) is 409
++ * control.idempotency_conflict. A claim left unanswered for 5 minutes (a crash mid-call) may be taken over; the
++ * authority's own idempotency on the same key keeps that safe.
++ * - The sensitive-action gate (roadmap WS-Z2): a request an agent makes (on_behalf_of { type: 'agent' }) that deletes,
++ * spends money (a money field in params) or publishes (params.publish / public / visibility 'public') is refused
++ * with confirmation_required until the agent's owner approves it. Network's confirmations
++ * (server/clients/confirmations.js) mint the confirmation id, ask the owner and keep the decision; the retry carries
++ * confirmation_id (with the same key or a new one) and Services spends the approval once, for exactly this request
++ * (its digest), before the authority is asked. A standing rule's approval proceeds at once. A person acting with
++ * their own token, and a dry run, are not gated here; the authority's own gate still applies, and its
++ * confirmation_required answers pass through unchanged.
++ * - The record: the log row's answer, and one services_audit row for every request accepted here (a replay too),
++ * written together. Neither stores params (only their digest) or any token.
++ */
++const crypto = require('crypto');
++const { controlResult } = require('../resources');
++const { ServiceError } = require('../util');
++
++const STALE_MS = 5 * 60_000;
++const MONEY_KEY = /(^|_)(amount|price|budget|spend|cost|payment|charge|credits?|cents|currency|billing)(_|$)/;
++
++/** Why a request is sensitive (deleting, money, publishing), or null. A dry run applies nothing, so it is never gated. */
++function sensitiveReason(request) {
++ if (request.dry_run === true) return null;
++ if (request.action === 'delete') return 'deleting a resource';
++ const params = request.params || {};
++ if (Object.keys(params).some((k) => MONEY_KEY.test(k.toLowerCase()))) return 'spending money';
++ if (params.publish === true || params.public === true || params.visibility === 'public') return 'publishing';
++ return null;
++}
++
++/** JSON with sorted object keys, so equal requests digest equally whatever their key order. */
++function canonical(v) {
++ if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;
++ if (v && typeof v === 'object') return `{${Object.keys(v).sort().filter((k) => v[k] !== undefined).map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(',')}}`;
++ return JSON.stringify(v);
++}
++const digestOf = (v) => crypto.createHash('sha256').update(canonical(v)).digest('hex');
++
++/** What the request asks for, without its delivery fields (idempotency_key, confirmation_id, trace_id). */
++const ACTION_FIELDS = ['action', 'resource', 'resource_kind', 'project_id', 'params', 'on_behalf_of', 'dry_run'];
++const actionOf = (request) => Object.fromEntries(ACTION_FIELDS.filter((k) => request[k] !== undefined).map((k) => [k, request[k]]));
++
++const actorOf = (principal) => (principal.kind === 'user' ? `user:${principal.subject}` : principal.sub);
++
++/** The HTTP status a result is sent with: 202 for pending (the authority finishes later), 200 for every other state. */
++const httpStatusOf = (result) => (result.state === 'pending' ? 202 : 200);
++
++function createControl({ store, index, confirmations, log = console }) {
++ const db = store.db;
++
++ async function audit({ actor, target, project, detail }) {
++ await db.query('INSERT INTO services_audit (at, actor, action, target, project_id, detail) VALUES ($1, $2, $3, $4, $5, $6::jsonb)',
++ [store.iso(), actor, 'resource.control', target, project, JSON.stringify(detail)]);
++ }
++
++ /**
++ * The sensitive-action gate, then the authority. `forward` is the request as the authority gets it (no
++ * confirmation_id of Services' own); `digest` names exactly this action for Network's confirmation.
++ */
++ async function decide(forward, confirmationId, onBehalfOf, digest) {
++ const reason = sensitiveReason(forward);
++ if (!reason || !onBehalfOf || onBehalfOf.type !== 'agent') {
++ return index.control(confirmationId ? { ...forward, confirmation_id: confirmationId } : forward);
++ }
++ // Network unreachable: nothing was decided, so failed; Network said no: refused with its problem.
++ const notConfirmed = (r) => controlResult(forward, r.status === 503 ? 'failed' : 'refused', { problem: r });
++ let id = confirmationId;
++ if (!id) {
++ const named = forward.resource || forward.resource_kind;
++ const c = await confirmations.create({
++ agentId: onBehalfOf.id,
++ summary: `${forward.action} ${named}`.slice(0, 500),
++ details: { action: forward.action, [forward.resource ? 'resource' : 'resource_kind']: named, project_id: forward.project_id, reason },
++ requestDigest: digest,
++ });
++ if (!c.ok) return notConfirmed(c);
++ if (c.confirmation.state !== 'approved') {
++ return controlResult(forward, 'refused', { confirmationRequired: { confirmation_id: c.confirmation.id, reason: `${reason} needs its owner's approval` } });
++ }
++ id = c.confirmation.id; // a standing rule approved it at creation
++ }
++ const spent = await confirmations.consume(id, digest);
++ // Not approved (yet): the answer keeps confirmation_required, so a later retry with the same key and id still counts.
++ if (!spent.ok && spent.code === 'confirmation.not_pending') {
++ return controlResult(forward, 'refused', { problem: spent, confirmationRequired: { confirmation_id: id, reason: `${reason} needs its owner's approval` } });
++ }
++ if (!spent.ok) return notConfirmed(spent);
++ return index.control(forward);
++ }
++
++ /**
++ * One control request from an authorized principal (server/api/v1.js has validated it and authorized its project)
++ * → its common.resource-control-result@1. Throws ServiceError for what is the caller's mistake, never the authority's.
++ */
++ async function run(request, principal) {
++ index.controlOwner(request); // 400 for a service Services does not read, before any row
++ const actor = actorOf(principal);
++ let onBehalfOf = request.on_behalf_of;
++ if (principal.kind === 'user') {
++ if (onBehalfOf && !(onBehalfOf.type === 'user' && onBehalfOf.id === principal.subject)) {
++ throw new ServiceError(403, 'capability.denied', 'a user token acts for that person only: on_behalf_of is absent or names them');
++ }
++ onBehalfOf = { type: 'user', id: principal.subject };
++ }
++ const forward = { ...request };
++ delete forward.confirmation_id;
++ if (onBehalfOf) forward.on_behalf_of = onBehalfOf;
++ const action = actionOf(forward);
++ const digest = digestOf(action);
++ const project = request.project_id;
++ const target = request.resource || `kind:${request.resource_kind}`;
++ const key = request.idempotency_key;
++
++ // 1) The idempotency key: claim it, replay its answer, or refuse.
++ let row = await db.maybe('SELECT * FROM services_control_log WHERE idempotency_key = $1', [key]);
++ let id = null;
++ if (!row) {
++ const logged = { ...action, params: undefined, params_sha256: request.params ? digestOf(request.params) : undefined, digest };
++ id = store.newId('ctl');
++ const claimed = await db.maybe(`INSERT INTO services_control_log (id, ovrn, action, actor_subject, project_id, idempotency_key, request, status, at)
++ VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, 'pending', $8) ON CONFLICT (idempotency_key) DO NOTHING RETURNING id`,
++ [id, request.resource || null, request.action, actor, project, key, JSON.stringify(logged), store.iso()]);
++ if (!claimed) { id = null; row = await db.maybe('SELECT * FROM services_control_log WHERE idempotency_key = $1', [key]); }
++ }
++ if (row) {
++ if (row.actor_subject !== actor || !row.request || row.request.digest !== digest) {
++ throw new ServiceError(409, 'control.idempotency_conflict', 'this idempotency_key was already used for another request');
++ }
++ const gate = row.response && row.response.confirmation_required;
++ if (request.confirmation_id && row.status === 'refused' && gate && gate.confirmation_id === request.confirmation_id) {
++ // The retry of a gated request, with the confirmation it was given: claimed again by exactly one writer.
++ const got = await db.maybe(`UPDATE services_control_log SET response = NULL, status = 'pending', at = $3
++ WHERE id = $1 AND status = 'refused' AND response->'confirmation_required'->>'confirmation_id' = $2 RETURNING id`, [row.id, request.confirmation_id, store.iso()]);
++ if (!got) throw new ServiceError(409, 'control.in_progress', 'a request with this idempotency_key is still running');
++ id = row.id;
++ } else if (row.response != null) {
++ await audit({ actor, target, project, detail: { action: request.action, state: row.status, control_id: row.id, replayed: true } });
++ return row.response;
++ } else {
++ const got = await db.maybe(`UPDATE services_control_log SET at = $3 WHERE id = $1 AND response IS NULL AND at < $2 RETURNING id`,
++ [row.id, new Date(store.now() - STALE_MS).toISOString(), store.iso()]);
++ if (!got) throw new ServiceError(409, 'control.in_progress', 'a request with this idempotency_key is still running');
++ id = row.id;
++ }
++ }
++
++ // 2) The gate and the authority. Every outcome becomes a result, so the claim is always answered.
++ let result;
++ try {
++ result = await decide(forward, request.confirmation_id || null, onBehalfOf, digest);
++ } catch (err) {
++ log.error(`[Services] control ${request.action} ${target}: ${err.message}`);
++ result = controlResult(forward, 'failed', { problem: { status: 500, code: 'control.internal_error', detail: 'Services could not complete this control request' } });
++ }
++
++ // 3) The record: the log row's answer and one audit row, in one transaction.
++ await store.tx(async () => {
++ await db.query('UPDATE services_control_log SET response = $2::jsonb, status = $3, ovrn = COALESCE(ovrn, $4) WHERE id = $1',
++ [id, JSON.stringify(result), result.state, result.resource || null]);
++ const detail = { action: request.action, state: result.state, control_id: id };
++ if (result.problem) detail.code = result.problem.code;
++ if (result.confirmation_required) detail.confirmation_id = result.confirmation_required.confirmation_id;
++ else if (request.confirmation_id) detail.confirmation_id = request.confirmation_id;
++ await audit({ actor, target: result.resource || target, project, detail });
++ });
++ return result;
++ }
++
++ return { run };
++}
++
++module.exports = { createControl, sensitiveReason, digestOf, httpStatusOf };
ddiff --git a/server/network.js b/server/network.js
iindex 8a47339..b8f71ae 100644
---- a/server/network.js
++++ b/server/network.js
@@@ -58,7 +58,27 @@ function createMembership(config, { fetchImpl = globalThis.fetch } = {}) {
if (res.status === 401 || res.status === 403) return 'no';
return 'unavailable';
}
-- return { projectMember };
++ /**
++ * The caller's role in a project ('owner' | 'admin' | 'developer' | 'viewer'), asked of Network with their own
++ * token: null when they are not a member (404, 401, 403), 'unavailable' when Network cannot answer.
++ */
++ async function projectRole(token, projectId) {
++ const url = `${config.network.url}/api/v1/projects/${encodeURIComponent(projectId)}`;
++ let res;
++ try {
++ res = await fetchImpl(url, {
++ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
++ signal: AbortSignal.timeout(config.index.networkTimeoutMs),
++ });
++ } catch {
++ return 'unavailable';
++ }
++ if (res.status === 404 || res.status === 401 || res.status === 403) { try { await res.body?.cancel(); } catch { /* not needed */ } return null; }
++ if (res.status !== 200) { try { await res.body?.cancel(); } catch { /* not needed */ } return 'unavailable'; }
++ const body = await res.json().catch(() => null);
++ return body && typeof body.role === 'string' ? body.role : null;
++ }
++ return { projectMember, projectRole };
}
module.exports = { createUserAuth, createMembership };
ddiff --git a/server/resources.js b/server/resources.js
iindex a3f9e79..1081332 100644
---- a/server/resources.js
++++ b/server/resources.js
@@@ -22,6 +22,14 @@
*
* One resource is never merged: /api/v1/resources/:ovrn parses the name with the one parser
* (contracts.resources.parse), resolves the owning service and asks that authority directly.
++ *
++ * Control (plan T13 step 10): `control(request)` resolves the owning authority from the OVRN (or, for create, the
++ * resource_kind's service) and forwards the common.resource-control-request@1 to it through openvibe-sdk/resources'
++ * createResourceClient (server/authorities/adapter.js). Services never applies anything itself. An authority with no
++ * control endpoint (no active <id>.resource.control, or 404/405/501 from it) answers state `failed` with
++ * resources.authority_unsupported. Services' own resources have no control endpoint yet either. An authority's answer
++ * that is not a common.resource-control-result@1, or that names a resource of another project, is `failed` with
++ * resources.authority_bad_response. The gate, idempotency and logging are server/domain/control.js.
*/
const contracts = require('openvibe-contracts');
const { createResourceIndex } = require('openvibe-sdk/resources');
@@@ -40,6 +48,21 @@ const SEP = '\u0000';
const PARTIAL_CODES = Object.freeze({ 'sdk.timeout': 'resources.authority_timeout', 'sdk.network_error': 'resources.authority_unavailable' });
const partialCodeOf = (stale) => PARTIAL_CODES[stale.code] || stale.code || 'resources.authority_unavailable';
++/** An authority status that means "no control endpoint here". */
++const UNSUPPORTED_STATUS = new Set([404, 405, 501]);
++
++/** A common.resource-control-result@1 Services itself writes: `state` with a problem (refused, failed) or without (done, pending). */
++function controlResult(request, state, { problem = null, confirmationRequired = null, result = null, at = new Date().toISOString() } = {}) {
++ const out = { action: request.action };
++ if (request.resource) out.resource = request.resource;
++ out.state = state;
++ if (result) out.result = result;
++ if (confirmationRequired) out.confirmation_required = confirmationRequired;
++ if (problem) out.problem = contracts.http.problem(problem.status, problem.code, { detail: problem.detail });
++ out.at = at;
++ return out;
++}
++
/** The order a page is cut in: service, then the OVRN when the summary has one (else its id). */
const sortKeyOf = (r) => `${r.service}${SEP}${r.ovrn || ''}${SEP}${r.id}`;
const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
@@@ -146,10 +169,50 @@ function createResourceIndexService({ config, authorities, tokens, self = null,
return body;
}
++ /**
++ * The authority that owns what a control request names (the OVRN's service, or resource_kind's for create) →
++ * { service, adapter }, or ServiceError 400 resources.unknown_service.
++ */
++ function controlOwner(request) {
++ const service = request.resource ? parseOvrn(request.resource).service : String(request.resource_kind || '').split('.')[0];
++ const adapter = adapterById.get(service);
++ if (!adapter) throw new ServiceError(400, 'resources.unknown_service', `${service} is not an authority Services reads`);
++ return { service, adapter };
++ }
++
++ /**
++ * One control request, forwarded to the owning authority → its common.resource-control-result@1. Throws ServiceError
++ * 400 resources.unknown_service (no authority of that id); every authority-side outcome is a result, never a throw.
++ */
++ async function control(request) {
++ const { service, adapter } = controlOwner(request);
++ const unsupported = () => controlResult(request, 'failed', { problem: { status: 501, code: 'resources.authority_unsupported', detail: `${service} takes no control requests` } });
++ if (typeof adapter.control !== 'function') return unsupported();
++ let answer;
++ try {
++ answer = await adapter.control(request);
++ } catch (err) {
++ if (UNSUPPORTED_STATUS.has(err.status)) return unsupported();
++ const code = err.code === 'sdk.timeout' ? 'resources.authority_timeout' : err.code === 'sdk.network_error' ? 'resources.authority_unavailable' : 'resources.authority_error';
++ const status = code === 'resources.authority_timeout' ? 504 : 502;
++ log.warn(`[Services] control ${request.action} at ${service}: ${err.code || err.message}`);
++ return controlResult(request, 'failed', { problem: { status, code, detail: err.status ? `${service} answered HTTP ${err.status}` : `${service} did not answer` } });
++ }
++ if (answer === null) return unsupported();
++ const bad = (detail) => controlResult(request, 'failed', { problem: { status: 502, code: 'resources.authority_bad_response', detail } });
++ if (!contracts.resources.checkControlResult(answer).valid) return bad(`${service} answered something that is not a common.resource-control-result@1`);
++ if (answer.resource) {
++ const named = contracts.resources.parse(answer.resource);
++ if (!named || named.project_id !== request.project_id || named.service !== service) return bad(`${service} answered for a resource outside ${request.project_id}`);
++ if (request.resource && answer.resource !== request.resource) return bad(`${service} answered for another resource than ${request.resource}`);
++ }
++ return answer;
++ }
++
/** Drop the cached SDK indexes (an authority set changed at runtime — boot-time registry today). */
function reset() { indexes.clear(); }
-- return { list, get, resolve, reset, adapters };
++ return { list, get, resolve, control, controlOwner, reset, adapters };
}
--module.exports = { createResourceIndexService, encodeCursor, decodeCursor, sortKeyOf };
++module.exports = { createResourceIndexService, controlResult, encodeCursor, decodeCursor, sortKeyOf };
ddiff --git a/test/contracts.test.js b/test/contracts.test.js
iindex cbf50a5..bff245a 100644
---- a/test/contracts.test.js
++++ b/test/contracts.test.js
@@@ -14,6 +14,8 @@ const { MANAGE, READ } = require('../server/http/api');
const { RESOURCE_READ } = require('../server/registry/resource-index');
const { check, done } = require('./helpers/boot');
++const CONTROL = 'services.resource.control';
++
(async () => {
const manifest = contracts.services.get('services');
@@@ -30,7 +32,7 @@ const { check, done } = require('./helpers/boot');
await check('its capabilities exist, are owned by services, and are the ones the API guards', async () => {
// services.resource.read (ADR-048, the resource index) is released too. It stays 'planned' until the
// contracts follow-up flips it after this ships, so only the two release capabilities are active.
-- assert.deepStrictEqual([...manifest.capabilities].sort(), [MANAGE, READ, RESOURCE_READ].sort());
++ assert.deepStrictEqual([...manifest.capabilities].sort(), [MANAGE, READ, RESOURCE_READ, CONTROL].sort());
for (const id of [MANAGE, READ]) {
const c = contracts.capabilities.get(id);
assert.ok(c, id);
@@@ -40,6 +42,12 @@ const { check, done } = require('./helpers/boot');
const resource = contracts.capabilities.get(RESOURCE_READ);
assert.ok(resource, RESOURCE_READ);
assert.strictEqual(resource.owner, 'services');
++ // services.resource.control (plan T13 step 10) is first-party and guards POST /api/v1/resources/control.
++ const control = contracts.capabilities.get(CONTROL);
++ assert.ok(control, CONTROL);
++ assert.strictEqual(control.owner, 'services');
++ assert.strictEqual(control.status, 'active');
++ assert.match(fs.readFileSync(path.join(__dirname, '..', 'server', 'auth', 'api.js'), 'utf8'), /resourceControl: 'services\.resource\.control'/);
const api = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'api.js'), 'utf8');
// The guards are serviceAuth.requireCapability built per request with the key the token names; api.js's
// createCapabilityAccess is the one factory the release routes and the resource index both guard with.
ddiff --git a/test/control-confirmations.test.js b/test/control-confirmations.test.js
nnew file mode 100644
iindex 0000000..80ad2aa
---- /dev/null
++++ b/test/control-confirmations.test.js
@@@ -0,0 +1,172 @@
++'use strict';
++// The sensitive-action gate of POST /api/v1/resources/control (ADR-048, roadmap WS-Z2, plan T13 step 10): a request an
++// agent makes that deletes, spends money or publishes is refused with confirmation_required until the agent's owner
++// approves it through Network's confirmations (OpenVibe.Network server/developer/confirmations.js; the Network mock
++// serves its /internal/confirmations create and consume). The retry carries the confirmation_id; Services spends the
++// approval once, for exactly that request, and only then asks the authority. Network mints and decides every
++// confirmation; Services never does.
++const assert = require('assert');
++const crypto = require('crypto');
++const http = require('http');
++const { serviceAuth, ids, resources } = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const seen = [];
++ const server = http.createServer((req, res) => {
++ const chunks = [];
++ req.on('data', (c) => chunks.push(c));
++ req.on('end', () => {
++ const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
++ seen.push(body);
++ res.writeHead(200, { 'Content-Type': 'application/json' });
++ res.end(JSON.stringify({ action: body.action, resource: body.resource, state: 'done', at: new Date().toISOString() }));
++ });
++ });
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const origin = `http://127.0.0.1:${server.address().port}`;
++ const media = Object.freeze({ id: 'media', name: 'media', internalOrigin: origin, publicOrigin: null, audience: 'openvibe.media', capability: 'media.resource.read',
++ control: 'media.resource.control', manifestVersion: '0.1.0', contractsRange: null, status: 'beta' });
++ const t = await boot({ authorities: { list: () => [media], get: (id) => (id === 'media' ? media : null), ids: () => ['media'], size: () => 1 } });
++ const db = t.ctx.store.db;
++
++ const now = () => Math.floor(Date.now() / 1000);
++ const svc = `Bearer ${serviceAuth.signServiceToken({ iss: t.network.url, sub: 'svc:actor', actor_type: 'service', aud: ['openvibe.services'], cap: ['services.resource.control'], iat: now(), exp: now() + 600, jti: `tok_${crypto.randomBytes(8).toString('hex')}` }, t.network.privatePem)}`;
++ const control = (body, authorization = svc) => t.get('/api/v1/resources/control', { json: body, headers: { authorization } });
++ const key = () => `ctl-${crypto.randomBytes(8).toString('hex')}`;
++ const valid = (r) => { const c = resources.checkControlResult(r); assert.ok(c.valid, JSON.stringify(c.errors)); };
++
++ const owner = t.network.addUser('agent-owner');
++ const agent = t.network.addAgent(owner.subject);
++ const forAgent = { type: 'agent', id: agent };
++ const prj = ids.newId('project');
++ const object = () => resources.format({ service: 'media', project_id: prj, type: 'object', id: ids.newId('media') });
++ const cnf = (id) => t.network.state.confirmations.get(id);
++
++ const target = object();
++ const k = key();
++ const del = { action: 'delete', resource: target, project_id: prj, idempotency_key: k, on_behalf_of: forAgent };
++ let confirmationId = null;
++
++ await check('an agent\'s delete is refused with confirmation_required; the authority is not asked', async () => {
++ const r = await control(del);
++ assert.strictEqual(r.status, 200, r.text);
++ valid(r.json());
++ assert.strictEqual(r.json().state, 'refused');
++ confirmationId = r.json().confirmation_required.confirmation_id;
++ assert.match(confirmationId, /^cnf_[0-9A-HJKMNP-TV-Z]{26}$/, 'Network minted the id');
++ assert.match(r.json().confirmation_required.reason, /deleting/);
++ assert.strictEqual(seen.length, 0);
++ const c = cnf(confirmationId);
++ assert.strictEqual(c.state, 'pending');
++ assert.strictEqual(c.agent_id, agent);
++ assert.strictEqual(c.owner, owner.subject, 'Network asks the agent\'s owner');
++ assert.strictEqual(c.capability, 'services.resource.control');
++ assert.strictEqual(c.audience, 'openvibe.services');
++ assert.match(c.request_digest, /^[0-9a-f]{64}$/);
++ assert.strictEqual(c.details.resource, target);
++ const row = await db.maybe('SELECT status FROM services_control_log WHERE idempotency_key = $1', [k]);
++ assert.strictEqual(row.status, 'refused');
++ });
++
++ await check('a retry before the owner decides stays refused and keeps the confirmation', async () => {
++ const r = await control({ ...del, confirmation_id: confirmationId });
++ valid(r.json());
++ assert.strictEqual(r.json().state, 'refused');
++ assert.strictEqual(r.json().problem.code, 'confirmation.not_pending');
++ assert.strictEqual(r.json().confirmation_required.confirmation_id, confirmationId);
++ assert.strictEqual(seen.length, 0);
++ });
++
++ await check('the owner approves through Network\'s confirmations; the retry with confirmation_id is done, once', async () => {
++ t.network.decideConfirmation(confirmationId, 'approved');
++ const r = await control({ ...del, confirmation_id: confirmationId });
++ assert.strictEqual(r.status, 200, r.text);
++ valid(r.json());
++ assert.strictEqual(r.json().state, 'done');
++ assert.strictEqual(seen.length, 1);
++ assert.strictEqual(seen[0].action, 'delete');
++ assert.strictEqual(seen[0].confirmation_id, undefined, 'Services\' confirmation is spent here, never sent on');
++ assert.deepStrictEqual(seen[0].on_behalf_of, forAgent);
++ assert.ok(cnf(confirmationId).used_at, 'the approval was spent');
++ const row = await db.maybe('SELECT status, response FROM services_control_log WHERE idempotency_key = $1', [k]);
++ assert.strictEqual(row.status, 'done');
++ const replay = await control({ ...del, confirmation_id: confirmationId });
++ assert.strictEqual(replay.json().state, 'done');
++ assert.strictEqual(seen.length, 1, 'a replay is answered from the log');
++ });
++
++ await check('an approval is spent once and only for its own request', async () => {
++ const reused = await control({ ...del, idempotency_key: key(), confirmation_id: confirmationId });
++ assert.strictEqual(reused.json().state, 'refused');
++ assert.strictEqual(reused.json().problem.code, 'confirmation.used');
++ const first = await control({ action: 'delete', resource: object(), project_id: prj, idempotency_key: key(), on_behalf_of: forAgent });
++ const id = first.json().confirmation_required.confirmation_id;
++ t.network.decideConfirmation(id, 'approved');
++ const other = await control({ action: 'delete', resource: object(), project_id: prj, idempotency_key: key(), on_behalf_of: forAgent, confirmation_id: id });
++ assert.strictEqual(other.json().state, 'refused');
++ assert.strictEqual(other.json().problem.code, 'confirmation.mismatch', 'the approval is for another resource');
++ assert.strictEqual(seen.length, 1);
++ });
++
++ await check('a denied confirmation never reaches the authority', async () => {
++ const body = { action: 'delete', resource: object(), project_id: prj, idempotency_key: key(), on_behalf_of: forAgent };
++ const id = (await control(body)).json().confirmation_required.confirmation_id;
++ t.network.decideConfirmation(id, 'denied');
++ const r = await control({ ...body, confirmation_id: id });
++ assert.strictEqual(r.json().state, 'refused');
++ assert.match(r.json().problem.detail, /denied/);
++ assert.strictEqual(seen.length, 1);
++ });
++
++ await check('money and publishing are gated; a dry run, a plain update and a person\'s own request are not', async () => {
++ for (const params of [{ amount_cents: 500 }, { budget: 10 }, { visibility: 'public' }, { publish: true }]) {
++ const r = await control({ action: 'update', resource: object(), project_id: prj, idempotency_key: key(), on_behalf_of: forAgent, params });
++ assert.strictEqual(r.json().state, 'refused', JSON.stringify(params));
++ assert.ok(r.json().confirmation_required, JSON.stringify(params));
++ }
++ assert.strictEqual(seen.length, 1);
++ const dry = await control({ ...del, idempotency_key: key(), resource: object(), dry_run: true });
++ assert.strictEqual(dry.json().state, 'done');
++ const plain = await control({ action: 'update', resource: object(), project_id: prj, idempotency_key: key(), on_behalf_of: forAgent, params: { title: 'x' } });
++ assert.strictEqual(plain.json().state, 'done');
++ const p = await t.project(owner, 'Owner project');
++ const mine = await control({ action: 'delete', resource: resources.format({ service: 'media', project_id: p, type: 'object', id: ids.newId('media') }), project_id: p, idempotency_key: key() },
++ `Bearer ${t.network.userToken(owner)}`);
++ assert.strictEqual(mine.json().state, 'done', 'the owner acting with their own token is the one who would confirm');
++ assert.strictEqual(seen.length, 4);
++ });
++
++ await check('a standing rule approves at creation: the request proceeds at once and spends it', async () => {
++ t.network.setStandingRule(agent, 'services.resource.control');
++ const r = await control({ action: 'delete', resource: object(), project_id: prj, idempotency_key: key(), on_behalf_of: forAgent });
++ assert.strictEqual(r.json().state, 'done');
++ const latest = [...t.network.state.confirmations.values()].at(-1);
++ assert.strictEqual(latest.state, 'approved');
++ assert.ok(latest.used_at);
++ t.network.state.standingRules.clear();
++ });
++
++ await check('Network unreachable: the sensitive request fails and nothing is applied', async () => {
++ const before = seen.length;
++ t.network.setDown(true);
++ const r = await control({ action: 'delete', resource: object(), project_id: prj, idempotency_key: key(), on_behalf_of: forAgent });
++ t.network.setDown(false);
++ valid(r.json());
++ assert.strictEqual(r.json().state, 'failed');
++ assert.strictEqual(r.json().problem.code, 'confirmations.unavailable');
++ assert.strictEqual(seen.length, before);
++ });
++
++ await check('every request left one control-log row per key and one audit row, naming the confirmation', async () => {
++ const audit = await db.many('SELECT * FROM services_audit WHERE target = $1 ORDER BY id', [target]);
++ assert.deepStrictEqual(audit.map((a) => a.detail.state), ['refused', 'refused', 'done', 'done', 'refused'], 'the first, the early retry, the approved retry, the replay, the reuse');
++ assert.ok(audit.slice(0, 3).every((a) => a.detail.confirmation_id === confirmationId));
++ assert.strictEqual(audit[3].detail.replayed, true);
++ assert.strictEqual(await db.value('SELECT count(*)::int FROM services_control_log WHERE idempotency_key = $1', [k]), 1);
++ });
++
++ await t.close();
++ await new Promise((r) => server.close(r));
++ done();
++})();
ddiff --git a/test/control.test.js b/test/control.test.js
nnew file mode 100644
iindex 0000000..f1356d6
---- /dev/null
++++ b/test/control.test.js
@@@ -0,0 +1,254 @@
++'use strict';
++// Control operations (ADR-048, plan T13 step 10): POST /api/v1/resources/control takes a common.resource-control-request@1
++// behind services.resource.control, resolves the owning authority from the OVRN (or the resource_kind for create),
++// forwards it through openvibe-sdk/resources' createResourceClient with a token scoped to that authority's
++// <id>.resource.control, and answers its common.resource-control-result@1. It is idempotent on idempotency_key
++// (services_control_log), an authority without a control endpoint answers failed resources.authority_unsupported, and
++// every accepted request leaves one services_audit row. The gate for sensitive actions is test/control-confirmations.test.js.
++const assert = require('assert');
++const crypto = require('crypto');
++const http = require('http');
++const { serviceAuth, ids, resources } = require('openvibe-contracts');
++const { createAuthorities } = require('../server/authorities');
++const { boot, check, done } = require('./helpers/boot');
++
++/** A media authority with a control endpoint; /watch/… answers 404 like an authority that has none. */
++async function startAuthority() {
++ const seen = [];
++ let network = null;
++ const server = http.createServer((req, res) => {
++ const chunks = [];
++ req.on('data', (c) => chunks.push(c));
++ req.on('end', () => {
++ const send = (status, body) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); };
++ if (req.url !== '/api/v1/resources/control' || req.method !== 'POST') return send(404, { type: 'about:blank', title: 'Not found', status: 404, code: 'route.not_found' });
++ const token = String(req.headers.authorization || '').replace(/^Bearer /, '');
++ const v = serviceAuth.verifyServiceToken(token, { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.media' });
++ const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
++ seen.push({ body, claims: v.ok ? v.claims : null });
++ if (!v.ok || !(v.claims.cap || []).includes('media.resource.control')) return send(403, { type: 'about:blank', title: 'Forbidden', status: 403, code: 'capability.denied' });
++ const at = new Date().toISOString();
++ if (body.action === 'create') {
++ const ovrn = resources.format({ service: 'media', project_id: body.project_id, type: 'object', id: ids.newId('media') });
++ return send(200, { action: 'create', resource: ovrn, state: 'done', result: { id: ovrn.split('/').pop() }, at });
++ }
++ if (body.action === 'start') return send(202, { action: 'start', resource: body.resource, state: 'pending', at });
++ if (body.action === 'rotate') {
++ return send(200, { action: 'rotate', resource: resources.format({ service: 'media', project_id: ids.newId('project'), type: 'object', id: ids.newId('media') }), state: 'done', at });
++ }
++ if (body.action === 'stop') {
++ return send(409, { action: 'stop', resource: body.resource, state: 'refused', confirmation_required: { confirmation_id: 'media-cnf-1', reason: 'the authority asks' }, at });
++ }
++ return send(200, { action: body.action, resource: body.resource, state: 'done', result: { applied: body.action }, at });
++ });
++ });
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ return {
++ url: `http://127.0.0.1:${server.address().port}`, seen,
++ bind(n) { network = n; },
++ close: () => new Promise((r) => server.close(r)),
++ };
++}
++
++const authority = (id, origin, control) => Object.freeze({
++ id, name: id, internalOrigin: origin, publicOrigin: null, audience: `openvibe.${id}`, capability: `${id}.resource.read`,
++ control, manifestVersion: '0.1.0', contractsRange: null, status: 'alpha',
++});
++function registryOf(list) {
++ const byId = new Map(list.map((a) => [a.id, a]));
++ return { list: () => [...byId.values()], get: (id) => byId.get(id) || null, ids: () => [...byId.keys()], size: () => byId.size };
++}
++
++(async () => {
++ const media = await startAuthority();
++ const t = await boot({
++ authorities: registryOf([
++ authority('media', media.url, 'media.resource.control'),
++ authority('watch', `${media.url}/watch`, 'watch.resource.control'),
++ authority('events', 'http://127.0.0.1:9', null),
++ ]),
++ });
++ media.bind(t.network);
++ const db = t.ctx.store.db;
++
++ const token = (cap, { sub = 'svc:actor', aud = 'openvibe.services', extra = {} } = {}) => {
++ const now = Math.floor(Date.now() / 1000);
++ return serviceAuth.signServiceToken({ iss: t.network.url, sub, actor_type: sub.startsWith('app:') ? 'app' : 'service', aud: [aud], cap, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}`, ...extra }, t.network.privatePem);
++ };
++ const svc = `Bearer ${token(['services.resource.control'])}`;
++ const control = (body, authorization = svc) => t.get('/api/v1/resources/control', { json: body, headers: authorization ? { authorization } : {} });
++ const key = () => `ctl-${crypto.randomBytes(8).toString('hex')}`;
++ const audits = async () => db.many('SELECT * FROM services_audit ORDER BY id');
++ const valid = (r) => { const c = resources.checkControlResult(r); assert.ok(c.valid, JSON.stringify(c.errors)); };
++
++ const prj = ids.newId('project');
++ const object = resources.format({ service: 'media', project_id: prj, type: 'object', id: ids.newId('media') });
++
++ await check('every real authority without an active <id>.resource.control takes no control requests', async () => {
++ const real = createAuthorities(t.config, { log: { warn() {} } });
++ assert.ok(real.size() > 0);
++ for (const a of real.list()) assert.strictEqual(a.control, null, `${a.id} lists no active control capability in the pinned contracts`);
++ });
++
++ await check('the gate: anonymous 401, no capability 403, an app token 403, a malformed request 400 — nothing forwarded', async () => {
++ const body = { action: 'update', resource: object, project_id: prj, idempotency_key: key() };
++ assert.strictEqual((await control(body, null)).status, 401);
++ const noCap = await control(body, `Bearer ${token(['services.resource.read'])}`);
++ assert.strictEqual(noCap.status, 403);
++ const app = await control(body, `Bearer ${token(['services.resource.control'], { sub: `app:${ids.newId('app')}`, extra: { project_id: prj, ns: [prj], env: 'production' } })}`);
++ assert.strictEqual(app.status, 403, app.text);
++ assert.strictEqual(app.json().code, 'capability.denied');
++ for (const bad of [
++ { action: 'delete', project_id: prj, idempotency_key: key() }, // no resource
++ { action: 'update', resource: object, project_id: ids.newId('project'), idempotency_key: key() }, // another project
++ { action: 'create', resource_kind: 'media.object', project_id: prj }, // no key
++ { action: 'explode', resource: object, project_id: prj, idempotency_key: key() },
++ ]) {
++ const r = await control(bad);
++ assert.strictEqual(r.status, 400, JSON.stringify(bad));
++ assert.strictEqual(r.json().code, 'control.bad_request');
++ assert.ok(Array.isArray(r.json().errors) && r.json().errors.length);
++ }
++ assert.strictEqual(media.seen.length, 0);
++ assert.strictEqual((await audits()).length, 0, 'a refused request is not a control request');
++ });
++
++ await check('create, update and delete are forwarded to the owning authority with its control token; its answer is returned', async () => {
++ const created = await control({ action: 'create', resource_kind: 'media.object', project_id: prj, idempotency_key: key(), params: { name: 'clip.mp4' } });
++ assert.strictEqual(created.status, 200, created.text);
++ valid(created.json());
++ assert.strictEqual(created.json().state, 'done');
++ assert.strictEqual(resources.parse(created.json().resource).project_id, prj);
++ const sent = media.seen.at(-1);
++ assert.strictEqual(sent.body.resource_kind, 'media.object');
++ assert.deepStrictEqual(sent.body.params, { name: 'clip.mp4' });
++ assert.deepStrictEqual(sent.claims.cap, ['media.resource.control'], 'the token is scoped to the authority\'s own control capability');
++ assert.strictEqual(sent.claims.sub, 'svc:services');
++
++ const k = key();
++ const updated = await control({ action: 'update', resource: object, project_id: prj, idempotency_key: k, params: { title: 'x' } });
++ assert.strictEqual(updated.json().state, 'done');
++ assert.strictEqual(media.seen.at(-1).body.idempotency_key, k, 'the caller\'s key reaches the authority');
++ const deleted = await control({ action: 'delete', resource: object, project_id: prj, idempotency_key: key() });
++ assert.strictEqual(deleted.json().state, 'done', 'a first-party delete not made for an agent is the authority\'s to gate');
++ assert.strictEqual(media.seen.at(-1).body.action, 'delete');
++
++ const row = await db.maybe('SELECT * FROM services_control_log WHERE idempotency_key = $1', [k]);
++ assert.strictEqual(row.status, 'done');
++ assert.strictEqual(row.actor_subject, 'svc:actor');
++ assert.strictEqual(row.project_id, prj);
++ assert.strictEqual(row.ovrn, object);
++ assert.strictEqual(row.request.params, undefined, 'params are kept as a digest only');
++ assert.match(row.request.params_sha256, /^[0-9a-f]{64}$/);
++ assert.deepStrictEqual(row.response, updated.json());
++ });
++
++ await check('pending is 202; an authority\'s own refusal (confirmation_required) passes through unchanged', async () => {
++ const started = await control({ action: 'start', resource: object, project_id: prj, idempotency_key: key() });
++ assert.strictEqual(started.status, 202);
++ assert.strictEqual(started.json().state, 'pending');
++ const stopped = await control({ action: 'stop', resource: object, project_id: prj, idempotency_key: key() });
++ assert.strictEqual(stopped.status, 200);
++ assert.strictEqual(stopped.json().state, 'refused');
++ assert.deepStrictEqual(stopped.json().confirmation_required, { confirmation_id: 'media-cnf-1', reason: 'the authority asks' });
++ const retried = await control({ action: 'stop', resource: object, project_id: prj, idempotency_key: key(), confirmation_id: 'media-cnf-1' });
++ assert.strictEqual(media.seen.at(-1).body.confirmation_id, 'media-cnf-1', 'an authority\'s confirmation id goes back to it');
++ assert.strictEqual(retried.json().state, 'refused');
++ });
++
++ await check('idempotency: the same key answers the stored result without asking again; another body or caller is 409', async () => {
++ const k = key();
++ const body = { action: 'update', resource: object, project_id: prj, idempotency_key: k, params: { b: 2, a: 1 } };
++ const first = await control(body);
++ const calls = media.seen.length;
++ const again = await control({ ...body, params: { a: 1, b: 2 } });
++ assert.strictEqual(again.status, 200);
++ assert.deepStrictEqual(again.json(), first.json(), 'the stored answer, byte for byte');
++ assert.strictEqual(media.seen.length, calls, 'the authority is not asked twice');
++ const other = await control({ ...body, params: { a: 2 } });
++ assert.strictEqual(other.status, 409);
++ assert.strictEqual(other.json().code, 'control.idempotency_conflict');
++ const stranger = await control(body, `Bearer ${token(['services.resource.control'], { sub: 'svc:zone' })}`);
++ assert.strictEqual(stranger.status, 409, 'another caller never reads this caller\'s answer');
++ assert.strictEqual(await db.value('SELECT count(*)::int FROM services_control_log WHERE idempotency_key = $1', [k]), 1);
++ // A claim still running is not run twice.
++ const running = `run-${crypto.randomBytes(8).toString('hex')}`;
++ await db.query(`INSERT INTO services_control_log (id, action, actor_subject, project_id, idempotency_key, request, status, at)
++ VALUES ($1, 'update', 'svc:actor', $2, $3, $4::jsonb, 'pending', now())`,
++ [`ctl_${ids.ulid()}`, prj, running, JSON.stringify({ digest: require('../server/domain/control').digestOf({ action: 'update', resource: object, project_id: prj }) })]);
++ const busy = await control({ action: 'update', resource: object, project_id: prj, idempotency_key: running });
++ assert.strictEqual(busy.status, 409);
++ assert.strictEqual(busy.json().code, 'control.in_progress');
++ });
++
++ await check('an authority with no control endpoint answers failed resources.authority_unsupported', async () => {
++ const before = media.seen.length;
++ const queue = resources.format({ service: 'events', project_id: prj, type: 'queue', id: `evq_${ids.ulid()}` });
++ const none = await control({ action: 'delete', resource: queue, project_id: prj, idempotency_key: key() });
++ assert.strictEqual(none.status, 200);
++ valid(none.json());
++ assert.strictEqual(none.json().state, 'failed');
++ assert.strictEqual(none.json().problem.code, 'resources.authority_unsupported');
++ const watch = resources.format({ service: 'watch', project_id: prj, type: 'watch', id: `wch_${ids.ulid()}` });
++ const notFound = await control({ action: 'update', resource: watch, project_id: prj, idempotency_key: key() });
++ assert.strictEqual(notFound.json().state, 'failed', 'a 404 from the control path is no control endpoint');
++ assert.strictEqual(notFound.json().problem.code, 'resources.authority_unsupported');
++ const own = await control({ action: 'update', resource: `ovrn:services:${prj}:release/${ids.newId('release')}`, project_id: prj, idempotency_key: key() });
++ assert.strictEqual(own.json().problem.code, 'resources.authority_unsupported', 'Services\' own resources have no control endpoint yet');
++ assert.strictEqual(media.seen.length, before);
++ const unknown = await control({ action: 'create', resource_kind: 'nowhere.thing', project_id: prj, idempotency_key: key() });
++ assert.strictEqual(unknown.status, 400);
++ assert.strictEqual(unknown.json().code, 'resources.unknown_service');
++ });
++
++ await check('an answer for a resource of another project is failed resources.authority_bad_response', async () => {
++ const r = await control({ action: 'rotate', resource: object, project_id: prj, idempotency_key: key() });
++ valid(r.json());
++ assert.strictEqual(r.json().state, 'failed');
++ assert.strictEqual(r.json().problem.code, 'resources.authority_bad_response');
++ });
++
++ await check('a person controls only a project they own or administer, and only for themself', async () => {
++ const owner = t.network.addUser('ctl-owner');
++ const dev = t.network.addUser('ctl-dev');
++ const outsider = t.network.addUser('ctl-outsider');
++ const project = await t.project(owner, 'Control project');
++ t.network.state.members.get(project).set(dev.subject, 'developer');
++ const res = resources.format({ service: 'media', project_id: project, type: 'object', id: ids.newId('media') });
++ const as = (u) => `Bearer ${t.network.userToken(u)}`;
++ const body = () => ({ action: 'update', resource: res, project_id: project, idempotency_key: key() });
++ const ok = await control(body(), as(owner));
++ assert.strictEqual(ok.status, 200, ok.text);
++ assert.strictEqual(ok.json().state, 'done');
++ assert.deepStrictEqual(media.seen.at(-1).body.on_behalf_of, { type: 'user', id: owner.subject }, 'the authority checks the person\'s grant');
++ assert.strictEqual((await control(body(), as(dev))).status, 403);
++ assert.strictEqual((await control(body(), as(outsider))).status, 404);
++ const forOther = await control({ ...body(), on_behalf_of: { type: 'user', id: dev.subject } }, as(owner));
++ assert.strictEqual(forOther.status, 403);
++ const row = await db.maybe('SELECT actor_subject FROM services_control_log WHERE project_id = $1', [project]);
++ assert.strictEqual(row.actor_subject, `user:${owner.subject}`);
++ });
++
++ await check('one audit row per accepted request (a replay too); neither table holds params or a token', async () => {
++ const before = (await audits()).length;
++ const k = key();
++ const body = { action: 'update', resource: object, project_id: prj, idempotency_key: k, params: { secret_note: 'do-not-store-me' } };
++ await control(body);
++ await control(body);
++ const rows = (await audits()).slice(before);
++ assert.strictEqual(rows.length, 2);
++ assert.deepStrictEqual(rows.map((r) => [r.actor, r.action, r.target, r.project_id]), [['svc:actor', 'resource.control', object, prj], ['svc:actor', 'resource.control', object, prj]]);
++ assert.strictEqual(rows[0].detail.state, 'done');
++ assert.strictEqual(rows[0].detail.replayed, undefined);
++ assert.strictEqual(rows[1].detail.replayed, true);
++ assert.strictEqual(rows[0].detail.control_id, rows[1].detail.control_id);
++ const dump = await t.dbDump();
++ assert.ok(!dump.includes('do-not-store-me'), 'params are never stored');
++ assert.ok(!dump.includes(svc.slice(7)), 'the caller\'s token is never stored');
++ assert.strictEqual(await db.value('SELECT count(*)::int FROM services_control_log WHERE response IS NULL AND idempotency_key LIKE $1', ['ctl-%']), 0, 'every claim was answered');
++ });
++
++ await t.close();
++ await media.close();
++ done();
++})();
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 0ba60e3..5f8b0ed 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -44,7 +44,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
// One database per boot (PGlite, or SERVICES_TEST_STORE=pg: the containers), dropped when the boot closes.
const testdb = await require('./db').testDb();
-- const built = await createApp({ config, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true, indexnow: opts.indexnow, accountSend: opts.accountSend });
++ const built = await createApp({ config, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true, indexnow: opts.indexnow, accountSend: opts.accountSend, authorities: opts.authorities });
await built.ctx.keys.ensure();
if (opts.relay) built.ctx.outbox.start();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
iindex 7ec11a8..5f86979 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -13,6 +13,10 @@
* answers what setUsage() stored for the project, or an empty network.project-usage-result@1.
* POST /api/v1/projects/:project/export-tokens as Network mints them (owner/admin only,
* sub app:app_<project ULID>, read-only caps, purpose export), recorded in exportTokens.
++ * /internal/confirmations (WS-Z2, Network's server/developer/confirmations.js internalRouter): create and
++ * consume with a service token for openvibe.network holding network.confirmation.manage, each service
++ * seeing only its own; addAgent(owner) seeds an agent, decideConfirmation(id, state) is the owner's
++ * approve/deny in their inbox, setStandingRule(agent, capability) an 'always' rule.
* Events POST /api/v1/events: verifies the app token (openvibe-contracts), needs the capability;
* GET /api/v1/events (pull, events.app.read): the token's project and env only, paged
* like Events (opaque cursors: after=, next_cursor, latest_cursor); addAppEvent() seeds it
@@@ -63,6 +67,7 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
users: new Map(), byUsername: new Map(), projects: new Map(), members: new Map(), apps: new Map(), creds: new Map(),
grants: new Map(), quotas: new Map(), usage: new Map(), audit: [], services: new Map(), refresh: new Map(),
catalog: null, down: false, requests: [], tokenRequests: [], exportTokens: [], exportTtl: 300,
++ agents: new Map(), confirmations: new Map(), standingRules: new Set(),
};
let issuer = null;
let nextUserId = 1;
@@@ -248,6 +253,41 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
}
const res204 = (json) => json(204, {});
++ // Network's /internal/confirmations, the parts a service uses: create and consume (server/developer/confirmations.js).
++ const confirmationView = (c) => ({ id: c.id, owner: { type: 'user', id: c.owner }, requested_by: { type: 'agent', id: c.agent_id }, capability: c.capability,
++ summary: c.summary, details: c.details, resources: [], state: c.state, expires_at: c.expires_at, created_at: c.created_at });
++ function confirmationsApi(req, raw, json, url) {
++ const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').replace(/^Bearer /, ''), { publicKey: publicPem, issuer, audience: 'openvibe.network' });
++ if (!v.ok) return problem(json, 401, v.code || 'token.invalid', 'a service token for openvibe.network');
++ if (!(v.claims.cap || []).includes('network.confirmation.manage')) return problem(json, 403, 'capability.denied', 'network.confirmation.manage not granted');
++ const audience = `openvibe.${String(v.claims.sub).replace(/^svc:/, '')}`;
++ let body = {};
++ try { body = raw.length ? JSON.parse(raw.toString('utf8')) : {}; } catch { return problem(json, 400, 'request.malformed_json', 'bad JSON'); }
++ const parts = url.pathname.replace(/^\/internal\/confirmations\/?/, '').split('/').filter(Boolean);
++ if (!parts.length) {
++ const by = body.requested_by || {};
++ if (by.type !== 'agent') return problem(json, 422, 'confirmation.invalid', "requested_by is { type: 'agent', id: 'agt_…' }");
++ const agent = st.agents.get(by.id);
++ if (!agent) return problem(json, 404, 'agent.not_found', 'no such agent');
++ if (`openvibe.${String(body.capability).split('.')[0]}` !== audience) return problem(json, 403, 'confirmation.wrong_audience', "the capability is not the caller's");
++ if (!/^[0-9a-f]{64}$/.test(String(body.request_digest))) return problem(json, 422, 'confirmation.invalid', 'request_digest is a lowercase hex SHA-256');
++ const ruled = st.standingRules.has(`${agent.id} ${body.capability}`);
++ const now = new Date().toISOString();
++ const c = { id: ids.newId('confirmation'), agent_id: agent.id, owner: agent.owner, capability: body.capability, audience, summary: body.summary, details: body.details || {},
++ request_digest: body.request_digest, state: ruled ? 'approved' : 'pending', used_at: null, created_at: now, expires_at: new Date(Date.now() + 900e3).toISOString() };
++ st.confirmations.set(c.id, c);
++ return json(ruled ? 200 : 201, { confirmation: confirmationView(c) });
++ }
++ const c = st.confirmations.get(parts[0]);
++ if (!c || c.audience !== audience) return problem(json, 404, 'confirmation.not_found', 'no such confirmation');
++ if (parts[1] !== 'consume') return problem(json, 404, 'route.not_found', 'no such route');
++ if (c.used_at) return problem(json, 409, 'confirmation.used', 'the confirmation was already used');
++ if (c.state !== 'approved') return problem(json, 409, 'confirmation.not_pending', `confirmation is ${c.state}`);
++ if (c.request_digest !== String(body.request_digest)) return problem(json, 409, 'confirmation.mismatch', 'the approval is for another action');
++ c.used_at = new Date().toISOString();
++ return json(200, { confirmation: confirmationView(c), used_at: c.used_at });
++ }
++
function mintApp(a, audience, cap) {
const now = Math.floor(Date.now() / 1000);
return serviceAuth.signServiceToken({ iss: issuer, sub: `app:${a.id}`, actor_type: 'app', aud: [audience], cap, ns: [a.project_id], project_id: a.project_id, env: a.environment, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}` }, privatePem);
@@@ -305,6 +345,7 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
if (!wanted.length) return json(400, { error: 'invalid_scope', error_description: `no grants for audience ${body.audience}` });
return json(200, { access_token: mintApp(a, body.audience, wanted), token_type: 'Bearer', expires_in: 300, scope: wanted.join(' ') });
}
++ if (url.pathname.startsWith('/internal/confirmations') && req.method === 'POST') return confirmationsApi(req, raw, json, url);
if (url.pathname.startsWith('/api/v1/projects')) {
const u = userFrom(req);
if (u === 'expired') return problem(json, 401, 'auth.expired', 'access token expired; refresh it');
@@@ -328,6 +369,11 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
setQuota(projectId, capability, limit, window, unit) { st.quotas.set(`${projectId} ${capability}`, { project_id: projectId, capability, limit, window, unit, updated_at: new Date().toISOString() }); },
setCatalog(list) { st.catalog = list; },
setDown(v) { st.down = v; },
++ /** An agent its owner (usr_…) delegated grants to; the owner decides its confirmations. */
++ addAgent(owner) { const id = ids.newId('agent'); st.agents.set(id, { id, owner }); return id; },
++ /** The owner's decision in their inbox: 'approved' or 'denied'. */
++ decideConfirmation(id, state) { st.confirmations.get(id).state = state; },
++ setStandingRule(agentId, capability) { st.standingRules.add(`${agentId} ${capability}`); },
mintApp: (appId, audience, cap, extra = {}) => { const a = st.apps.get(appId); const now = Math.floor(Date.now() / 1000); return serviceAuth.signServiceToken({ iss: issuer, sub: `app:${a.id}`, actor_type: 'app', aud: [audience], cap, ns: [a.project_id], project_id: a.project_id, env: a.environment, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}`, ...extra }, privatePem); },
close: srv.close,
};
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 2140754..ad00eb6 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -54,6 +54,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
const REVIEWED = {
'server/auth/keys.js': 'no request itself; openvibe-sdk/auth fetches the JWKS',
'server/authorities/adapter.js': 'each authority\'s /api/v1/resources at the internalOrigin of its pinned contracts manifest (or a SERVICES_<ID>_URL override): never a caller-chosen URL',
++ 'server/clients/confirmations.js': 'Network\'s /internal/confirmations (configured OV_NETWORK internal URL), with Services\' own service token',
'server/network.js': 'Network\'s developer projects API (configured OV_NETWORK_URL), with the caller\'s own token',
'server/auth/sso.js': 'Network OAuth token and revoke (configured)',
'server/domain/limits.js': 'the services\' /limits.json (configured internal URLs)',