openvibers/openvibe.services Commits
Route resource control requests to owning authorities with confirmations (#54)
Changes
@@@ -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.@@@ -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 (defa malformed page or a Network that cannot mint its token makes that authority partial and leaves the rest of the pageintact.++## 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 twoaccount 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`.@@@ -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",nnew file mode 100644@@@ -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);@@@ -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",@@@ -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"@@@ -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@@@ -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) ──@@@ -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 };@@@ -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.fetcreturn { 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. */@@@ -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,@@@ -30,8 +30,11 @@ function createAuthorityTokens(config, { authorities, fetchImpl = globalThis.fetreturn {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); },nnew file mode 100644@@@ -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 };@@@ -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). */nnew file mode 100644@@@ -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 };@@@ -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 };@@@ -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 };@@@ -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.nnew file mode 100644@@@ -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();++})();nnew file mode 100644@@@ -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();++})();@@@ -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)); });@@@ -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.musers: 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.mif (!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.msetQuota(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,};@@@ -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)',
@@ -4,6 +4,13 @@ Notable changes to OpenVibe.Services. The service is unreleased; everything belo
@@ -4,6 +4,13 @@ Notable changes to OpenVibe.Services. The service is unreleased; everything belo
## Unreleased
## 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).
- 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.
- **Deleted:** the person's playground and recipe runs.
- **Authorless:** manifests and releases stay with their project, without the person.
- **Authorless:** manifests and releases stay with their project, without the person.
@@ -38,6 +38,7 @@ A place where a developer outside the network goes from an OpenVibe account to a
@@ -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. |
| 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. |
| 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 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. |
| 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. |
| 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. |
| 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
@@ -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
a malformed page or a Network that cannot mint its token makes that authority partial and leaves the rest of the page
intact.
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
## The authority registry
`server/authorities/index.js` builds the registry at boot from the **pinned openvibe-contracts service manifests**:
`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
@@ -98,11 +127,11 @@ There is no polling loop: the index reads the authorities on each request (step
## Depends on
## 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.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`).
- **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).
- **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).
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
@@ -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`.
- OAuth client `services`, name `OpenVibe.Services`, redirect URI `https://openvibe.services/auth/callback`.
- Service grant `['services', 'events.event.publish', 'openvibe.events', []]` (the outbox relay).
- 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 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,
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`.
`network.account.export.contribute` and `network.account.deletion.confirm` for audience `openvibe.network`.
@@ -7,21 +7,22 @@
@@ -7,21 +7,22 @@
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db)",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db)",
"port": 4930,
"port": 4930,
"domain": "openvibe.services",
"domain": "openvibe.services",
- "contracts": "openvibe-contracts v0.150.0",
+ "contracts": "openvibe-contracts v0.154.0",
"sdk": "openvibe-sdk v0.44.0",
"sdk": "openvibe-sdk v0.44.0",
"shared": "openvibe-shared v4.1.3",
"shared": "openvibe-shared v4.1.3",
"packages": [
"packages": [
- "openvibe-contracts v0.150.0",
+ "openvibe-contracts v0.154.0",
"openvibe-sdk v0.44.0",
"openvibe-sdk v0.44.0",
"openvibe-shared v4.1.3"
"openvibe-shared v4.1.3"
],
],
"capabilities": [
"capabilities": [
"services.resource.read",
"services.resource.read",
"services.release.read",
"services.release.read",
- "services.release.manage"
+ "services.release.manage",
+ "services.resource.control"
],
],
"capabilitiesRegistered": true,
"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": [
"events": [
"services.app.published",
"services.app.published",
"services.app.deprecated",
"services.app.deprecated",
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -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);
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -890,9 +890,9 @@
@@ -890,9 +890,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -27,7 +27,7 @@
@@ -27,7 +27,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -8,9 +8,18 @@
@@ -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 | 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/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) |
* | 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
* 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
* narrows), or a person's user token — scoped to ONE project they own or belong to, verified with their
@@ -22,8 +31,9 @@
@@ -22,8 +31,9 @@
* network.unavailable.
* network.unavailable.
*/
*/
const express = require('express');
const express = require('express');
-const { http } = require('openvibe-contracts');
+const { http, resources } = require('openvibe-contracts');
const { ServiceError } = require('../util');
const { ServiceError } = require('../util');
+const { httpStatusOf } = require('../domain/control');
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const SERVICE_RE = /^[a-z][a-z0-9-]{0,31}$/;
const SERVICE_RE = /^[a-z][a-z0-9-]{0,31}$/;
@@ -59,7 +69,7 @@ function listQuery(query, config) {
@@ -59,7 +69,7 @@ function listQuery(query, config) {
return out;
return out;
}
}
-function v1Router({ config, apiAuth, index, authorities }) {
+function v1Router({ config, apiAuth, index, authorities, control }) {
const r = express.Router();
const r = express.Router();
// One merged page over every authority (or the one ?service= names). A failing authority's rows are
// 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 }) {
@@ -75,6 +85,24 @@ function v1Router({ config, apiAuth, index, authorities }) {
: { resources: page.resources, next_cursor: page.next_cursor });
: { 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),
// 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
// 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
// member of that project; a service token needs services.resource.read. Nothing is asked of the
@@ -11,12 +11,13 @@
@@ -11,12 +11,13 @@
* /projects the signed-in console over Network's projects API (http/portal.js)
* /projects the signed-in console over Network's projects API (http/portal.js)
* /releases/:id/* release actions (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, /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)
* /api/v1 the release API and the docs' JSON (http/api.js)
* /auth/* Network SSO with PKCE (auth/sso.js)
* /auth/* Network SSO with PKCE (auth/sso.js)
* /api/health, /api/ready, /release.json, /metrics (loopback only)
* /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 path = require('path');
const express = require('express');
const express = require('express');
@@ -40,6 +41,8 @@ const { createAuthorityTokens } = require('./authorities/tokens');
@@ -40,6 +41,8 @@ const { createAuthorityTokens } = require('./authorities/tokens');
const { createResourceIndexService } = require('./resources');
const { createResourceIndexService } = require('./resources');
const { createSelfAuthority } = require('./registry/self-authority');
const { createSelfAuthority } = require('./registry/self-authority');
const { v1Router } = require('./api/v1');
const { v1Router } = require('./api/v1');
+const { createConfirmations } = require('./clients/confirmations');
+const { createControl } = require('./domain/control');
const { generate } = require('./docs/generate');
const { generate } = require('./docs/generate');
const { createTrust } = require('./domain/trust');
const { createTrust } = require('./domain/trust');
const { createReleases } = require('./domain/releases');
const { createReleases } = require('./domain/releases');
@@ -103,8 +106,12 @@ async function createApp(opts = {}) {
@@ -103,8 +106,12 @@ async function createApp(opts = {}) {
const userAuth = createUserAuth(config, keys);
const userAuth = createUserAuth(config, keys);
const membership = opts.membership || createMembership(config, { fetchImpl: fetchImpl || globalThis.fetch });
const membership = opts.membership || createMembership(config, { fetchImpl: fetchImpl || globalThis.fetch });
const apiAuth = createApiAuth({ config, keys, userAuth, membership });
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();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -184,7 +191,7 @@ async function createApp(opts = {}) {
@@ -184,7 +191,7 @@ async function createApp(opts = {}) {
express.json({ limit: INDEX_JSON_LIMIT }),
express.json({ limit: INDEX_JSON_LIMIT }),
(req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); },
(req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); },
apiAuth.middleware,
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()));
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) ──
// ── Who is asking (verified offline; refreshed when expired) ──
@@ -14,10 +14,10 @@
@@ -14,10 +14,10 @@
*
*
* A request that presents a token is judged on that token alone: a bad one is refused, never downgraded.
* 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 { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
const { ServiceError } = require('../util');
const { ServiceError } = require('../util');
@@ -27,11 +27,14 @@ const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -27,11 +27,14 @@ const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const ANON = Object.freeze({ kind: 'anonymous' });
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. */
/** One capability against a token's claims: contracts' manifest and grant rules decide; an unknown id is refused. */
function capabilityDecision(claims, id) {
function capabilityDecision(claims, id) {
@@ -125,6 +128,38 @@ function createApiAuth({ config, keys, userAuth, membership }) {
@@ -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). */
/** The first-party gate: a service token with services.resource.read (the authority registry). */
function requireServiceRead(req) {
function requireServiceRead(req) {
requireRead(req);
requireRead(req);
@@ -152,7 +187,7 @@ function createApiAuth({ config, keys, userAuth, membership }) {
@@ -152,7 +187,7 @@ function createApiAuth({ config, keys, userAuth, membership }) {
return { project };
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 };
module.exports = { createApiAuth, keyFor, capabilityDecision, userPrincipal, verifyService, decodePayload, projectOf, CAPS, PROJECT_RE, PRINCIPAL_SUB };
@@ -12,7 +12,12 @@
@@ -12,7 +12,12 @@
* (ADR-046 section 6) — one authority's failure never fails the whole page.
* (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
* get(ovrn) GET /api/v1/resources/:ovrn → { status, body }; a transport failure is
* resources.authority_unavailable (502) in the caller's terms.
* 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 { ServiceError } = require('../util');
const trim = (u) => String(u || '').replace(/\/+$/, '');
const trim = (u) => String(u || '').replace(/\/+$/, '');
@@ -46,7 +51,17 @@ function createAuthorityAdapter(authority, { tokens, fetchImpl = globalThis.fetc
@@ -46,7 +51,17 @@ function createAuthorityAdapter(authority, { tokens, fetchImpl = globalThis.fetc
return { status: res.status, body };
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. */
/** The one fetch createResourceIndex fans out through: the URL's own authority adds its token. */
@@ -34,6 +34,10 @@ function createAuthorities(config, { log = console } = {}) {
@@ -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`);
log.warn(`[Services] ${m.id} lists ${capId} but names no internalOrigin: it is left out of the registry`);
continue;
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({
byId.set(m.id, Object.freeze({
id: m.id,
id: m.id,
name: m.name || m.id,
name: m.name || m.id,
@@ -41,6 +45,7 @@ function createAuthorities(config, { log = console } = {}) {
@@ -41,6 +45,7 @@ function createAuthorities(config, { log = console } = {}) {
publicOrigin: m.publicOrigin || null,
publicOrigin: m.publicOrigin || null,
audience: `openvibe.${m.id}`,
audience: `openvibe.${m.id}`,
capability: capId,
capability: capId,
+ control: controlCap && controlCap.status === 'active' ? controlId : null,
manifestVersion: m.version || null,
manifestVersion: m.version || null,
contractsRange: (m.contractRanges || {})['openvibe-contracts'] || null,
contractsRange: (m.contractRanges || {})['openvibe-contracts'] || null,
status: m.status,
status: m.status,
@@ -30,8 +30,11 @@ function createAuthorityTokens(config, { authorities, fetchImpl = globalThis.fet
@@ -30,8 +30,11 @@ function createAuthorityTokens(config, { authorities, fetchImpl = globalThis.fet
return {
return {
enabled,
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. */
/** 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) }),
status: () => ({ enabled, client_id: config.oauth.clientId, audiences: authorities.list().map((a) => a.audience) }),
invalidate: (ctx) => { if (client) client.invalidate(ctx); },
invalidate: (ctx) => { if (client) client.invalidate(ctx); },
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -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 };
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
* the button: they stay. created_by is NOT NULL there, so it becomes `deleted` (the resource
* 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
* index then reports no owner); published_by, deprecated_by and revoked_by become NULL; the
* resource index's owner becomes NULL
* 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
* member set: the audit trail of who published, revoked, reviewed or changed what, kept for
* accountability (counted as retained)
* accountability (counted as retained)
*
*
@@ -32,6 +32,7 @@ const TABLES = [
@@ -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', 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: '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_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). */
/** Manifests and releases they created: exported, then made authorless in extraErase (created_by is NOT NULL). */
new file mode 100644
new file mode 100644
@@ -0,0 +1,183 @@
@@ -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 };
@@ -58,7 +58,27 @@ function createMembership(config, { fetchImpl = globalThis.fetch } = {}) {
@@ -58,7 +58,27 @@ function createMembership(config, { fetchImpl = globalThis.fetch } = {}) {
if (res.status === 401 || res.status === 403) return 'no';
if (res.status === 401 || res.status === 403) return 'no';
return 'unavailable';
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 };
module.exports = { createUserAuth, createMembership };
@@ -22,6 +22,14 @@
@@ -22,6 +22,14 @@
*
*
* One resource is never merged: /api/v1/resources/:ovrn parses the name with the one parser
* 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.
* (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 contracts = require('openvibe-contracts');
const { createResourceIndex } = require('openvibe-sdk/resources');
const { createResourceIndex } = require('openvibe-sdk/resources');
@@ -40,6 +48,21 @@ const SEP = '\u0000';
@@ -40,6 +48,21 @@ const SEP = '\u0000';
const PARTIAL_CODES = Object.freeze({ 'sdk.timeout': 'resources.authority_timeout', 'sdk.network_error': 'resources.authority_unavailable' });
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';
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). */
/** 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 sortKeyOf = (r) => `${r.service}${SEP}${r.ovrn || ''}${SEP}${r.id}`;
const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
@@ -146,10 +169,50 @@ function createResourceIndexService({ config, authorities, tokens, self = null,
@@ -146,10 +169,50 @@ function createResourceIndexService({ config, authorities, tokens, self = null,
return body;
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). */
/** Drop the cached SDK indexes (an authority set changed at runtime — boot-time registry today). */
function reset() { indexes.clear(); }
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 };
@@ -14,6 +14,8 @@ const { MANAGE, READ } = require('../server/http/api');
@@ -14,6 +14,8 @@ const { MANAGE, READ } = require('../server/http/api');
const { RESOURCE_READ } = require('../server/registry/resource-index');
const { RESOURCE_READ } = require('../server/registry/resource-index');
const { check, done } = require('./helpers/boot');
const { check, done } = require('./helpers/boot');
+const CONTROL = 'services.resource.control';
+
(async () => {
(async () => {
const manifest = contracts.services.get('services');
const manifest = contracts.services.get('services');
@@ -30,7 +32,7 @@ const { check, done } = require('./helpers/boot');
@@ -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 () => {
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
// 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.
// 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]) {
for (const id of [MANAGE, READ]) {
const c = contracts.capabilities.get(id);
const c = contracts.capabilities.get(id);
assert.ok(c, id);
assert.ok(c, id);
@@ -40,6 +42,12 @@ const { check, done } = require('./helpers/boot');
@@ -40,6 +42,12 @@ const { check, done } = require('./helpers/boot');
const resource = contracts.capabilities.get(RESOURCE_READ);
const resource = contracts.capabilities.get(RESOURCE_READ);
assert.ok(resource, RESOURCE_READ);
assert.ok(resource, RESOURCE_READ);
assert.strictEqual(resource.owner, 'services');
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');
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
// 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.
// createCapabilityAccess is the one factory the release routes and the resource index both guard with.
new file mode 100644
new file mode 100644
@@ -0,0 +1,172 @@
@@ -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();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,254 @@
@@ -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();
+})();
@@ -44,7 +44,7 @@ async function boot(opts = {}) {
@@ -44,7 +44,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const { createStore } = require('../../server/db');
// One database per boot (PGlite, or SERVICES_TEST_STORE=pg: the containers), dropped when the boot closes.
// One database per boot (PGlite, or SERVICES_TEST_STORE=pg: the containers), dropped when the boot closes.
const testdb = await require('./db').testDb();
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();
await built.ctx.keys.ensure();
if (opts.relay) built.ctx.outbox.start();
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)); });
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
@@ -13,6 +13,10 @@
@@ -13,6 +13,10 @@
* answers what setUsage() stored for the project, or an empty network.project-usage-result@1.
* 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,
* 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.
* 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;
* 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
* 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
* like Events (opaque cursors: after=, next_cursor, latest_cursor); addAppEvent() seeds it
@@ -63,6 +67,7 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
@@ -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(),
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(),
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,
catalog: null, down: false, requests: [], tokenRequests: [], exportTokens: [], exportTtl: 300,
+ agents: new Map(), confirmations: new Map(), standingRules: new Set(),
};
};
let issuer = null;
let issuer = null;
let nextUserId = 1;
let nextUserId = 1;
@@ -248,6 +253,41 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
@@ -248,6 +253,41 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
}
}
const res204 = (json) => json(204, {});
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) {
function mintApp(a, audience, cap) {
const now = Math.floor(Date.now() / 1000);
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);
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
@@ -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}` });
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(' ') });
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')) {
if (url.pathname.startsWith('/api/v1/projects')) {
const u = userFrom(req);
const u = userFrom(req);
if (u === 'expired') return problem(json, 401, 'auth.expired', 'access token expired; refresh it');
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
@@ -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() }); },
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; },
setCatalog(list) { st.catalog = list; },
setDown(v) { st.down = v; },
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); },
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,
close: srv.close,
};
};
@@ -54,6 +54,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
@@ -54,6 +54,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
const REVIEWED = {
const REVIEWED = {
'server/auth/keys.js': 'no request itself; openvibe-sdk/auth fetches the JWKS',
'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/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/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/auth/sso.js': 'Network OAuth token and revoke (configured)',
'server/domain/limits.js': 'the services\' /limits.json (configured internal URLs)',
'server/domain/limits.js': 'the services\' /limits.json (configured internal URLs)',