openvibers/openvibe.watch Commits
Watch: the control API — Services suspends, resumes and deletes a person's watch by its OVRN (ADR-048) (#57)
Changes
@@@ -296,6 +296,7 @@ an app or module may not.| `watch.observation.read` | `GET /api/v1/watches/:id/observations?before=&limit=`, `GET /api/v1/watches/:id/checks?before=&limit=` || `watch.check.run` | `POST /api/v1/watches/:id/check` (404 unknown, 409 `watch.busy` while one runs, 409 `watch.disabled` for a paused watch, 422 `watch.carrier_unavailable`, or 422 `watch.event_awaited` for an event source) || `watch.resource.read` | `GET /api/v1/resources?project=&kind=&cursor=&limit=`, `GET /api/v1/resources/:ovrn` (first-party service token only) |++| `watch.resource.control` | `POST /api/v1/resources/control` (first-party service token only; ADR-048, [server/api/resource-control.js](server/api/resource-control.js)): a `common.resource-control-request@1` for a watch from OpenVibe.Services acting for its owner (`on_behalf_of`) — `suspend` (pause), `resume`, `delete`; other actions refused `control.unsupported_action`; only the owner's watch in the OVRN's project (otherwise `resources.unknown_resource`); `dry_run` applies nothing; the first answer per (caller, project, `idempotency_key`) is kept (`watch_control_log`, migration 0009) and a key reused for another request is 409. Loopback only: the public vhost 404s `/api/v1/resources*`. |The loopback-only authority index lists non-deleted `watch.watch` resources as`common.resource-list-result@1`. It orders watches by id, pages with an opaque cursor, andnnew file mode 100644@@@ -0,0 +1,12 @@++-- phase: expand++-- ADR-048 control operations (plan T13 step 10, server/api/resource-control.js): the first answer to each++-- (caller, project, idempotency_key), returned again for a repeat so a retried control request never applies twice.++CREATE TABLE watch_control_log (++ caller text COLLATE "C" NOT NULL, -- svc:… that sent it++ project_id text COLLATE "C" NOT NULL,++ idempotency_key text COLLATE "C" NOT NULL,++ digest text COLLATE "C" NOT NULL, -- sha256 of the request without its delivery fields++ response jsonb NOT NULL, -- the common.resource-control-result@1 answered++ created_at bigint NOT NULL,++ PRIMARY KEY (caller, project_id, idempotency_key)++);@@@ -12,7 +12,7 @@"dotenv": "^16.4.7","express": "^4.21.2","fast-xml-parser": "^4.5.3",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.153.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.165.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"@@@ -769,9 +769,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.153.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.153.0",-- "integrity": "sha512-WQ7p7ATKtTky9nI9yE9Agkm3rNKuyWljmrMCdZaqxg+FjVaziTqrFz0a3YMBTXJmNf5JTGXn/ioOmWjO95/lCA==",++ "version": "0.165.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.165.0",++ "integrity": "sha512-IfBo0pI0lAqSGvtjA7PWjzf2apWMOrY1wAvHB9miGUgVOsjlLbkE8ND8SSutExoLGmZRsf+pePcCw2QbGjTjIA==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -24,7 +24,7 @@"dotenv": "^16.4.7","express": "^4.21.2","fast-xml-parser": "^4.5.3",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.153.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.165.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"nnew file mode 100644@@@ -0,0 +1,101 @@++'use strict';++/**++ * Watch's control API (ADR-048, plan T13 step 10): POST /api/v1/resources/control — one++ * common.resource-control-request@1 from a first-party service (OpenVibe.Services) holding watch.resource.control,++ * acting for the person in on_behalf_of, on a watch.watch named by its OVRN. The answer is a++ * common.resource-control-result@1 (200 for every outcome; a malformed request is a 400 problem).++ *++ * suspend pause the watch: nothing is checked until it resumes (registry.patch → watch.watch.paused)++ * resume make a paused watch active again (registry.patch → watch.watch.updated)++ * delete remove it, exactly as DELETE /api/v1/watches/:id does (registry.remove → watch.watch.removed)++ *++ * Any other action is refused `control.unsupported_action`. Only the watch's owner may act on it (on_behalf_of names++ * them; a watch of another person is refused as not found, never confirmed to exist), and only inside the project the++ * OVRN names. `dry_run` decides and explains without applying. The first answer per (caller, project,++ * idempotency_key) is kept in watch_control_log and returned for a repeat; the same key with another request is++ * refused `control.idempotency_conflict`.++ */++const crypto = require('crypto');++const express = require('express');++const contracts = require('openvibe-contracts');++++const SERVICE = 'watch';++const CAPABILITY = 'watch.resource.control';++const ACTIONS = { suspend: 'paused', resume: 'active' };++const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++++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 = (request) => crypto.createHash('sha256')++ .update(canonical({ ...request, idempotency_key: undefined, confirmation_id: undefined, trace_id: undefined })).digest('hex');++++function resourceControlRouter({ db, auth, registry, now = () => Date.now() }) {++ const router = express.Router();++ const guard = auth.requireCap(CAPABILITY);++ const json = express.json({ limit: '16kb' });++ const iso = () => new Date(now()).toISOString();++ const result = (request, state, extra = {}) => ({ action: request.action, ...(request.resource ? { resource: request.resource } : {}), state, at: iso(), ...extra });++ const problem = (status, code, detail) => ({ type: `https://openvibe.network/problems/${code}`, title: code, status, code, detail });++++ async function decide(request, principal) {++ const parsed = contracts.resources.parse(request.resource || '');++ if (!parsed || parsed.service !== SERVICE || parsed.type !== 'watch') {++ return result(request, 'refused', { problem: problem(404, 'resources.unknown_resource', `no watch named ${request.resource || request.resource_kind}`) });++ }++ if (!(request.action in ACTIONS) && request.action !== 'delete') {++ return result(request, 'refused', { problem: problem(422, 'control.unsupported_action', `a watch takes suspend, resume and delete, not ${request.action}`) });++ }++ const person = request.on_behalf_of;++ if (!person || person.type !== 'user' || !USER_SUBJECT_RE.test(String(person.id || ''))) {++ return result(request, 'refused', { problem: problem(403, 'control.no_person', 'on_behalf_of must name the person who owns the watch') });++ }++ const owner = { subject: person.id };++ const row = await registry.get(parsed.id, owner);++ if (!row || row.deleted_at || row.project_id !== parsed.project_id || parsed.project_id !== request.project_id) {++ return result(request, 'refused', { problem: problem(404, 'resources.unknown_resource', `no watch named ${request.resource} of this person in this project`) });++ }++ if (request.action === 'delete') {++ if (request.dry_run) return result(request, 'done', { result: { dry_run: true, would: 'delete the watch' } });++ await registry.remove(row.id, owner);++ return result(request, 'done', { result: { deleted: true } });++ }++ const target = ACTIONS[request.action];++ if (row.status === target) return result(request, 'done', { result: { status: row.status, changed: false } });++ if (request.action === 'resume' && row.status !== 'paused') {++ return result(request, 'refused', { problem: problem(409, 'control.not_paused', `a ${row.status} watch is not resumed here; edit it on openvibe.watch`) });++ }++ if (request.dry_run) return result(request, 'done', { result: { dry_run: true, would: `make the watch ${target}` } });++ const updated = await registry.patch(row.id, { status: target }, owner);++ return result(request, 'done', { result: { status: updated.status, changed: true } });++ }++++ router.post('/api/v1/resources/control', guard, json, async (req, res, next) => {++ try {++ if (req.principal.kind !== 'service' || !/^svc:/.test(req.principal.sub)) {++ return contracts.http.sendProblem(res, 403, 'capability.denied', { detail: `${CAPABILITY} is first-party`, ctx: req.ov });++ }++ const request = req.body;++ const check = contracts.resources.checkControlRequest(request || {});++ if (!check.valid) return contracts.http.sendProblem(res, 400, 'control.bad_request', { detail: JSON.stringify(check.errors).slice(0, 500), ctx: req.ov });++ const caller = req.principal.sub;++ const digest = digestOf(request);++ const seen = await db.prepare('SELECT digest, response FROM watch_control_log WHERE caller = ? AND project_id = ? AND idempotency_key = ?')++ .get(caller, request.project_id, request.idempotency_key);++ if (seen) {++ if (seen.digest !== digest) return contracts.http.sendProblem(res, 409, 'control.idempotency_conflict', { detail: 'this idempotency_key was used for another request', ctx: req.ov });++ return res.json(typeof seen.response === 'string' ? JSON.parse(seen.response) : seen.response);++ }++ const answer = await decide(request, req.principal);++ await db.prepare(`INSERT INTO watch_control_log (caller, project_id, idempotency_key, digest, response, created_at)++ VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`).run(caller, request.project_id, request.idempotency_key, digest, JSON.stringify(answer), now());++ return res.json(answer);++ } catch (err) { return next(err); }++ });++++ return router;++}++++module.exports = { resourceControlRouter, CAPABILITY, digestOf };@@@ -22,6 +22,7 @@ const { createSso } = require('./auth/sso');const { createPageRoutes } = require('./http/pages');const { watchesRouter } = require('./api/watches');const { resourceIndexRouter } = require('./api/resource-index');++const { resourceControlRouter } = require('./api/resource-control');const { checksRouter } = require('./api/checks');const { createActorLimits } = require('./api/actor-limits');const { assetVersion, send, setRelease, SITE_NAME, TAGLINE } = require('./render/layout');@@@ -125,6 +126,8 @@ function createApp({ config, db, registry, check, observations, scheduler, auth,const limits = createActorLimits({ config, now: limitsNow || (() => Date.now()), registry: metrics.registry, log });// OpenVibe.Services' resource index (watch.resource.read, service tokens only): read on loopback; the public vhost 404s it.app.use(resourceIndexRouter({ db, auth }));++ // ...and its control API (watch.resource.control, ADR-048): suspend, resume and delete a person's watch.++ app.use(resourceControlRouter({ db, auth, registry, now }));app.use(watchesRouter({ registry, auth, limits, webhookSources }));app.use(checksRouter({ registry, check, observations, auth, limits }));app.use('/api', (req, res) => http.sendProblem(res, 404, 'watch.not_found', { detail: `no route ${req.method} ${req.path}`, ctx: req.ov }));nnew file mode 100644@@@ -0,0 +1,84 @@++'use strict';++/**++ * Watch's control API (ADR-048, plan T13 step 10): OpenVibe.Services suspends, resumes and deletes a person's watch by++ * its OVRN. Only a first-party service holding watch.resource.control, only for the watch's owner and inside the OVRN's++ * project; other actions refused; dry runs apply nothing; a repeat returns the first answer, the same key with another++ * request is a conflict; every answer is a common.resource-control-result@1.++ */++const assert = require('assert');++const contracts = require('openvibe-contracts');++const { boot, request, serviceToken, userToken, newUser, watchDef, suite } = require('./helpers');++++const t = suite('resource-control');++let svc;++const owner = newUser();++const stranger = newUser();++const project = contracts.ids.newId('project');++const otherProject = contracts.ids.newId('project');++const token = serviceToken('services', ['watch.resource.control']);++let watch;++let n = 0;++const key = () => `ctl-test-${++n}-${Date.now()}`;++const control = async (body, bearer = token) => request(svc.base, 'POST', '/api/v1/resources/control', { token: bearer, body });++const ovrn = (w, prj = project) => contracts.resources.format({ service: 'watch', project_id: prj, type: 'watch', id: w.id });++const ask = (action, extra = {}) => ({ action, resource: ovrn(watch), project_id: project, idempotency_key: key(), on_behalf_of: { type: 'user', id: owner }, ...extra });++const valid = (body) => {++ const v = contracts.validate('common.resource-control-result@1', body);++ assert.ok(v.valid, JSON.stringify(v.errors));++ return body;++};++const statusOf = async () => (await svc.db.prepare('SELECT status, deleted_at FROM watches WHERE id = ?').get(watch.id));++++t('boot with a person\'s watch in a project', async () => {++ svc = await boot();++ watch = await svc.registry.create(watchDef({ name: 'controlled' }), { sub: owner });++ await svc.db.prepare('UPDATE watches SET project_id = ? WHERE id = ?').run(project, watch.id);++});++++t('only a first-party service with the capability may call it', async () => {++ assert.strictEqual((await control(ask('suspend'), serviceToken('services', ['watch.resource.read']))).status, 403);++ assert.strictEqual((await control(ask('suspend'), userToken(owner))).status, 403);++ assert.strictEqual((await control({ action: 'suspend' })).status, 400, 'not a control request');++});++++t('suspend, a dry run first, then a repeat answered from the log', async () => {++ const dry = valid((await control(ask('suspend', { dry_run: true }))).body);++ assert.deepStrictEqual([dry.state, dry.result.dry_run], ['done', true]);++ assert.strictEqual((await statusOf()).status, 'active', 'a dry run applies nothing');++ const body = ask('suspend');++ const r = await control(body);++ assert.strictEqual(r.status, 200);++ valid(r.body);++ assert.deepStrictEqual([r.body.state, r.body.result.status], ['done', 'paused']);++ assert.strictEqual((await statusOf()).status, 'paused');++ const again = await control(body);++ assert.deepStrictEqual(again.body, r.body, 'the first answer, again');++ const conflict = await control({ ...body, action: 'resume' });++ assert.strictEqual(conflict.status, 409);++});++++t('resume; unsupported actions, another person and another project are refused', async () => {++ const r = valid((await control(ask('resume'))).body);++ assert.deepStrictEqual([r.state, r.result.status], ['done', 'active']);++ const resize = valid((await control(ask('resize'))).body);++ assert.deepStrictEqual([resize.state, resize.problem.code], ['refused', 'control.unsupported_action']);++ const theirs = valid((await control(ask('suspend', { on_behalf_of: { type: 'user', id: stranger } }))).body);++ assert.deepStrictEqual([theirs.state, theirs.problem.code], ['refused', 'resources.unknown_resource']);++ const elsewhere = valid((await control({ ...ask('suspend'), resource: ovrn(watch, otherProject), project_id: otherProject })).body);++ assert.deepStrictEqual([elsewhere.state, elsewhere.problem.code], ['refused', 'resources.unknown_resource']);++ const nobody = valid((await control(ask('suspend', { on_behalf_of: undefined }))).body);++ assert.deepStrictEqual([nobody.state, nobody.problem.code], ['refused', 'control.no_person']);++ assert.strictEqual((await statusOf()).status, 'active');++});++++t('delete removes it as DELETE /api/v1/watches/:id does', async () => {++ const r = valid((await control(ask('delete'))).body);++ assert.deepStrictEqual([r.state, r.result.deleted], ['done', true]);++ assert.ok((await statusOf()).deleted_at);++ const gone = valid((await control(ask('resume'))).body);++ assert.strictEqual(gone.problem.code, 'resources.unknown_resource');++});++++t('done', async () => { await svc.stop(); });++++t.run();
@@ -296,6 +296,7 @@ an app or module may not.
@@ -296,6 +296,7 @@ an app or module may not.
| `watch.observation.read` | `GET /api/v1/watches/:id/observations?before=&limit=`, `GET /api/v1/watches/:id/checks?before=&limit=` |
| `watch.observation.read` | `GET /api/v1/watches/:id/observations?before=&limit=`, `GET /api/v1/watches/:id/checks?before=&limit=` |
| `watch.check.run` | `POST /api/v1/watches/:id/check` (404 unknown, 409 `watch.busy` while one runs, 409 `watch.disabled` for a paused watch, 422 `watch.carrier_unavailable`, or 422 `watch.event_awaited` for an event source) |
| `watch.check.run` | `POST /api/v1/watches/:id/check` (404 unknown, 409 `watch.busy` while one runs, 409 `watch.disabled` for a paused watch, 422 `watch.carrier_unavailable`, or 422 `watch.event_awaited` for an event source) |
| `watch.resource.read` | `GET /api/v1/resources?project=&kind=&cursor=&limit=`, `GET /api/v1/resources/:ovrn` (first-party service token only) |
| `watch.resource.read` | `GET /api/v1/resources?project=&kind=&cursor=&limit=`, `GET /api/v1/resources/:ovrn` (first-party service token only) |
+| `watch.resource.control` | `POST /api/v1/resources/control` (first-party service token only; ADR-048, [server/api/resource-control.js](server/api/resource-control.js)): a `common.resource-control-request@1` for a watch from OpenVibe.Services acting for its owner (`on_behalf_of`) — `suspend` (pause), `resume`, `delete`; other actions refused `control.unsupported_action`; only the owner's watch in the OVRN's project (otherwise `resources.unknown_resource`); `dry_run` applies nothing; the first answer per (caller, project, `idempotency_key`) is kept (`watch_control_log`, migration 0009) and a key reused for another request is 409. Loopback only: the public vhost 404s `/api/v1/resources*`. |
The loopback-only authority index lists non-deleted `watch.watch` resources as
The loopback-only authority index lists non-deleted `watch.watch` resources as
`common.resource-list-result@1`. It orders watches by id, pages with an opaque cursor, and
`common.resource-list-result@1`. It orders watches by id, pages with an opaque cursor, and
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+-- phase: expand
+-- ADR-048 control operations (plan T13 step 10, server/api/resource-control.js): the first answer to each
+-- (caller, project, idempotency_key), returned again for a repeat so a retried control request never applies twice.
+CREATE TABLE watch_control_log (
+ caller text COLLATE "C" NOT NULL, -- svc:… that sent it
+ project_id text COLLATE "C" NOT NULL,
+ idempotency_key text COLLATE "C" NOT NULL,
+ digest text COLLATE "C" NOT NULL, -- sha256 of the request without its delivery fields
+ response jsonb NOT NULL, -- the common.resource-control-result@1 answered
+ created_at bigint NOT NULL,
+ PRIMARY KEY (caller, project_id, idempotency_key)
+);
@@ -12,7 +12,7 @@
@@ -12,7 +12,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"fast-xml-parser": "^4.5.3",
"fast-xml-parser": "^4.5.3",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.153.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.165.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"
@@ -769,9 +769,9 @@
@@ -769,9 +769,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.153.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.153.0",
- "integrity": "sha512-WQ7p7ATKtTky9nI9yE9Agkm3rNKuyWljmrMCdZaqxg+FjVaziTqrFz0a3YMBTXJmNf5JTGXn/ioOmWjO95/lCA==",
+ "version": "0.165.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.165.0",
+ "integrity": "sha512-IfBo0pI0lAqSGvtjA7PWjzf2apWMOrY1wAvHB9miGUgVOsjlLbkE8ND8SSutExoLGmZRsf+pePcCw2QbGjTjIA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"fast-xml-parser": "^4.5.3",
"fast-xml-parser": "^4.5.3",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.153.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.165.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"
new file mode 100644
new file mode 100644
@@ -0,0 +1,101 @@
@@ -0,0 +1,101 @@
+'use strict';
+/**
+ * Watch's control API (ADR-048, plan T13 step 10): POST /api/v1/resources/control — one
+ * common.resource-control-request@1 from a first-party service (OpenVibe.Services) holding watch.resource.control,
+ * acting for the person in on_behalf_of, on a watch.watch named by its OVRN. The answer is a
+ * common.resource-control-result@1 (200 for every outcome; a malformed request is a 400 problem).
+ *
+ * suspend pause the watch: nothing is checked until it resumes (registry.patch → watch.watch.paused)
+ * resume make a paused watch active again (registry.patch → watch.watch.updated)
+ * delete remove it, exactly as DELETE /api/v1/watches/:id does (registry.remove → watch.watch.removed)
+ *
+ * Any other action is refused `control.unsupported_action`. Only the watch's owner may act on it (on_behalf_of names
+ * them; a watch of another person is refused as not found, never confirmed to exist), and only inside the project the
+ * OVRN names. `dry_run` decides and explains without applying. The first answer per (caller, project,
+ * idempotency_key) is kept in watch_control_log and returned for a repeat; the same key with another request is
+ * refused `control.idempotency_conflict`.
+ */
+const crypto = require('crypto');
+const express = require('express');
+const contracts = require('openvibe-contracts');
+
+const SERVICE = 'watch';
+const CAPABILITY = 'watch.resource.control';
+const ACTIONS = { suspend: 'paused', resume: 'active' };
+const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+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 = (request) => crypto.createHash('sha256')
+ .update(canonical({ ...request, idempotency_key: undefined, confirmation_id: undefined, trace_id: undefined })).digest('hex');
+
+function resourceControlRouter({ db, auth, registry, now = () => Date.now() }) {
+ const router = express.Router();
+ const guard = auth.requireCap(CAPABILITY);
+ const json = express.json({ limit: '16kb' });
+ const iso = () => new Date(now()).toISOString();
+ const result = (request, state, extra = {}) => ({ action: request.action, ...(request.resource ? { resource: request.resource } : {}), state, at: iso(), ...extra });
+ const problem = (status, code, detail) => ({ type: `https://openvibe.network/problems/${code}`, title: code, status, code, detail });
+
+ async function decide(request, principal) {
+ const parsed = contracts.resources.parse(request.resource || '');
+ if (!parsed || parsed.service !== SERVICE || parsed.type !== 'watch') {
+ return result(request, 'refused', { problem: problem(404, 'resources.unknown_resource', `no watch named ${request.resource || request.resource_kind}`) });
+ }
+ if (!(request.action in ACTIONS) && request.action !== 'delete') {
+ return result(request, 'refused', { problem: problem(422, 'control.unsupported_action', `a watch takes suspend, resume and delete, not ${request.action}`) });
+ }
+ const person = request.on_behalf_of;
+ if (!person || person.type !== 'user' || !USER_SUBJECT_RE.test(String(person.id || ''))) {
+ return result(request, 'refused', { problem: problem(403, 'control.no_person', 'on_behalf_of must name the person who owns the watch') });
+ }
+ const owner = { subject: person.id };
+ const row = await registry.get(parsed.id, owner);
+ if (!row || row.deleted_at || row.project_id !== parsed.project_id || parsed.project_id !== request.project_id) {
+ return result(request, 'refused', { problem: problem(404, 'resources.unknown_resource', `no watch named ${request.resource} of this person in this project`) });
+ }
+ if (request.action === 'delete') {
+ if (request.dry_run) return result(request, 'done', { result: { dry_run: true, would: 'delete the watch' } });
+ await registry.remove(row.id, owner);
+ return result(request, 'done', { result: { deleted: true } });
+ }
+ const target = ACTIONS[request.action];
+ if (row.status === target) return result(request, 'done', { result: { status: row.status, changed: false } });
+ if (request.action === 'resume' && row.status !== 'paused') {
+ return result(request, 'refused', { problem: problem(409, 'control.not_paused', `a ${row.status} watch is not resumed here; edit it on openvibe.watch`) });
+ }
+ if (request.dry_run) return result(request, 'done', { result: { dry_run: true, would: `make the watch ${target}` } });
+ const updated = await registry.patch(row.id, { status: target }, owner);
+ return result(request, 'done', { result: { status: updated.status, changed: true } });
+ }
+
+ router.post('/api/v1/resources/control', guard, json, async (req, res, next) => {
+ try {
+ if (req.principal.kind !== 'service' || !/^svc:/.test(req.principal.sub)) {
+ return contracts.http.sendProblem(res, 403, 'capability.denied', { detail: `${CAPABILITY} is first-party`, ctx: req.ov });
+ }
+ const request = req.body;
+ const check = contracts.resources.checkControlRequest(request || {});
+ if (!check.valid) return contracts.http.sendProblem(res, 400, 'control.bad_request', { detail: JSON.stringify(check.errors).slice(0, 500), ctx: req.ov });
+ const caller = req.principal.sub;
+ const digest = digestOf(request);
+ const seen = await db.prepare('SELECT digest, response FROM watch_control_log WHERE caller = ? AND project_id = ? AND idempotency_key = ?')
+ .get(caller, request.project_id, request.idempotency_key);
+ if (seen) {
+ if (seen.digest !== digest) return contracts.http.sendProblem(res, 409, 'control.idempotency_conflict', { detail: 'this idempotency_key was used for another request', ctx: req.ov });
+ return res.json(typeof seen.response === 'string' ? JSON.parse(seen.response) : seen.response);
+ }
+ const answer = await decide(request, req.principal);
+ await db.prepare(`INSERT INTO watch_control_log (caller, project_id, idempotency_key, digest, response, created_at)
+ VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`).run(caller, request.project_id, request.idempotency_key, digest, JSON.stringify(answer), now());
+ return res.json(answer);
+ } catch (err) { return next(err); }
+ });
+
+ return router;
+}
+
+module.exports = { resourceControlRouter, CAPABILITY, digestOf };
@@ -22,6 +22,7 @@ const { createSso } = require('./auth/sso');
@@ -22,6 +22,7 @@ const { createSso } = require('./auth/sso');
const { createPageRoutes } = require('./http/pages');
const { createPageRoutes } = require('./http/pages');
const { watchesRouter } = require('./api/watches');
const { watchesRouter } = require('./api/watches');
const { resourceIndexRouter } = require('./api/resource-index');
const { resourceIndexRouter } = require('./api/resource-index');
+const { resourceControlRouter } = require('./api/resource-control');
const { checksRouter } = require('./api/checks');
const { checksRouter } = require('./api/checks');
const { createActorLimits } = require('./api/actor-limits');
const { createActorLimits } = require('./api/actor-limits');
const { assetVersion, send, setRelease, SITE_NAME, TAGLINE } = require('./render/layout');
const { assetVersion, send, setRelease, SITE_NAME, TAGLINE } = require('./render/layout');
@@ -125,6 +126,8 @@ function createApp({ config, db, registry, check, observations, scheduler, auth,
@@ -125,6 +126,8 @@ function createApp({ config, db, registry, check, observations, scheduler, auth,
const limits = createActorLimits({ config, now: limitsNow || (() => Date.now()), registry: metrics.registry, log });
const limits = createActorLimits({ config, now: limitsNow || (() => Date.now()), registry: metrics.registry, log });
// OpenVibe.Services' resource index (watch.resource.read, service tokens only): read on loopback; the public vhost 404s it.
// OpenVibe.Services' resource index (watch.resource.read, service tokens only): read on loopback; the public vhost 404s it.
app.use(resourceIndexRouter({ db, auth }));
app.use(resourceIndexRouter({ db, auth }));
+ // ...and its control API (watch.resource.control, ADR-048): suspend, resume and delete a person's watch.
+ app.use(resourceControlRouter({ db, auth, registry, now }));
app.use(watchesRouter({ registry, auth, limits, webhookSources }));
app.use(watchesRouter({ registry, auth, limits, webhookSources }));
app.use(checksRouter({ registry, check, observations, auth, limits }));
app.use(checksRouter({ registry, check, observations, auth, limits }));
app.use('/api', (req, res) => http.sendProblem(res, 404, 'watch.not_found', { detail: `no route ${req.method} ${req.path}`, ctx: req.ov }));
app.use('/api', (req, res) => http.sendProblem(res, 404, 'watch.not_found', { detail: `no route ${req.method} ${req.path}`, ctx: req.ov }));
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -0,0 +1,84 @@
+'use strict';
+/**
+ * Watch's control API (ADR-048, plan T13 step 10): OpenVibe.Services suspends, resumes and deletes a person's watch by
+ * its OVRN. Only a first-party service holding watch.resource.control, only for the watch's owner and inside the OVRN's
+ * project; other actions refused; dry runs apply nothing; a repeat returns the first answer, the same key with another
+ * request is a conflict; every answer is a common.resource-control-result@1.
+ */
+const assert = require('assert');
+const contracts = require('openvibe-contracts');
+const { boot, request, serviceToken, userToken, newUser, watchDef, suite } = require('./helpers');
+
+const t = suite('resource-control');
+let svc;
+const owner = newUser();
+const stranger = newUser();
+const project = contracts.ids.newId('project');
+const otherProject = contracts.ids.newId('project');
+const token = serviceToken('services', ['watch.resource.control']);
+let watch;
+let n = 0;
+const key = () => `ctl-test-${++n}-${Date.now()}`;
+const control = async (body, bearer = token) => request(svc.base, 'POST', '/api/v1/resources/control', { token: bearer, body });
+const ovrn = (w, prj = project) => contracts.resources.format({ service: 'watch', project_id: prj, type: 'watch', id: w.id });
+const ask = (action, extra = {}) => ({ action, resource: ovrn(watch), project_id: project, idempotency_key: key(), on_behalf_of: { type: 'user', id: owner }, ...extra });
+const valid = (body) => {
+ const v = contracts.validate('common.resource-control-result@1', body);
+ assert.ok(v.valid, JSON.stringify(v.errors));
+ return body;
+};
+const statusOf = async () => (await svc.db.prepare('SELECT status, deleted_at FROM watches WHERE id = ?').get(watch.id));
+
+t('boot with a person\'s watch in a project', async () => {
+ svc = await boot();
+ watch = await svc.registry.create(watchDef({ name: 'controlled' }), { sub: owner });
+ await svc.db.prepare('UPDATE watches SET project_id = ? WHERE id = ?').run(project, watch.id);
+});
+
+t('only a first-party service with the capability may call it', async () => {
+ assert.strictEqual((await control(ask('suspend'), serviceToken('services', ['watch.resource.read']))).status, 403);
+ assert.strictEqual((await control(ask('suspend'), userToken(owner))).status, 403);
+ assert.strictEqual((await control({ action: 'suspend' })).status, 400, 'not a control request');
+});
+
+t('suspend, a dry run first, then a repeat answered from the log', async () => {
+ const dry = valid((await control(ask('suspend', { dry_run: true }))).body);
+ assert.deepStrictEqual([dry.state, dry.result.dry_run], ['done', true]);
+ assert.strictEqual((await statusOf()).status, 'active', 'a dry run applies nothing');
+ const body = ask('suspend');
+ const r = await control(body);
+ assert.strictEqual(r.status, 200);
+ valid(r.body);
+ assert.deepStrictEqual([r.body.state, r.body.result.status], ['done', 'paused']);
+ assert.strictEqual((await statusOf()).status, 'paused');
+ const again = await control(body);
+ assert.deepStrictEqual(again.body, r.body, 'the first answer, again');
+ const conflict = await control({ ...body, action: 'resume' });
+ assert.strictEqual(conflict.status, 409);
+});
+
+t('resume; unsupported actions, another person and another project are refused', async () => {
+ const r = valid((await control(ask('resume'))).body);
+ assert.deepStrictEqual([r.state, r.result.status], ['done', 'active']);
+ const resize = valid((await control(ask('resize'))).body);
+ assert.deepStrictEqual([resize.state, resize.problem.code], ['refused', 'control.unsupported_action']);
+ const theirs = valid((await control(ask('suspend', { on_behalf_of: { type: 'user', id: stranger } }))).body);
+ assert.deepStrictEqual([theirs.state, theirs.problem.code], ['refused', 'resources.unknown_resource']);
+ const elsewhere = valid((await control({ ...ask('suspend'), resource: ovrn(watch, otherProject), project_id: otherProject })).body);
+ assert.deepStrictEqual([elsewhere.state, elsewhere.problem.code], ['refused', 'resources.unknown_resource']);
+ const nobody = valid((await control(ask('suspend', { on_behalf_of: undefined }))).body);
+ assert.deepStrictEqual([nobody.state, nobody.problem.code], ['refused', 'control.no_person']);
+ assert.strictEqual((await statusOf()).status, 'active');
+});
+
+t('delete removes it as DELETE /api/v1/watches/:id does', async () => {
+ const r = valid((await control(ask('delete'))).body);
+ assert.deepStrictEqual([r.state, r.result.deleted], ['done', true]);
+ assert.ok((await statusOf()).deleted_at);
+ const gone = valid((await control(ask('resume'))).body);
+ assert.strictEqual(gone.problem.code, 'resources.unknown_resource');
+});
+
+t('done', async () => { await svc.stop(); });
+
+t.run();