openvibers/openvibe.space Commits
Space: the control API — Services can delete a site, deploy or domain for a person, by Space's own roles (contracts 0.170) (#45)
Changes
@@@ -37,6 +37,7 @@ Nothing that is not a forum path redirects.| `/preview/:deployId/*` | Private, sandboxed deploy preview || `/api/v1/projects`, `/api/v1/sites`, `/api/v1/deploys`, `/api/v1/domains` | Hosting API (Bearer tokens, no cookie auth) || `/api/v1/resources` | First-party resource index |++| `POST /api/v1/resources/control` | First-party control: `delete` a site, deploy or domain for a person (Space's own roles decide) || `/limits.json` | Enforced hosting limits || `/auth/login|callback|fedcm|logout|me|refresh` | Space's Network OAuth client || `/robots.txt`, `/llms.txt`, `/llms-full.txt`, `/sitemap.xml` | Discovery for Space's public page |@@@ -48,7 +49,7 @@ Tenant requests are dispatched by Host header before dashboard routes. `deploy/n## Capabilities and events--The hosting API guards `space.site.manage`, `space.site.config`, `space.deploy.create`, `space.domain.manage` and `space.resource.read` (openvibe-contracts 0.149.0, owner `space`); every route names exactly one, and `npx openvibe-contracts-check --service space` checks the code against the release. Service tokens need audience `openvibe.space`, and project membership still applies. The per-actor limits (`server/http/actor-limits.js`) count uploads, domain checks, project creation and writes per person or app; those are limit buckets, not capabilities. The retired `space.forum.*` contracts are unrelated to hosting.++The hosting API guards `space.site.manage`, `space.site.config`, `space.deploy.create`, `space.domain.manage`, `space.resource.read` and `space.resource.control` (openvibe-contracts 0.170.0, owner `space`); every route names exactly one, and `npx openvibe-contracts-check --service space` checks the code against the release. Service tokens need audience `openvibe.space`, and project membership still applies. The per-actor limits (`server/http/actor-limits.js`) count uploads, domain checks, project creation and writes per person or app; those are limit buckets, not capabilities. The retired `space.forum.*` contracts are unrelated to hosting. The control route (`server/registry/resource-control.js`, plan T13 step 10) takes a `common.resource-control-request@1` from OpenVibe.Services and acts as the person in `on_behalf_of` through the same domain code as the DELETE routes: deleting needs maintainer or owner, a non-member gets `resources.unknown_resource`, and the served deploy (`deploy.active`), a default domain (`domain.default`) and anything taken down are refused with Space's own reason. Dry runs; repeats with the same key return the first answer (`space_control_log`).The transactional outbox emits `space.deploy.created`, `space.deploy.activated`, `space.deploy.failed` and `space.domain.verified`; each has a payload schema in openvibe-contracts, and the SDK outbox refuses any other type.nnew file mode 100644@@@ -0,0 +1,12 @@++-- phase: expand++-- ADR-048 control operations (plan T13 step 10, server/registry/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 space_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)++);@@@ -15,7 +15,7 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.149.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.170.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"@@@ -804,9 +804,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.149.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.149.0",-- "integrity": "sha512-7TGh8i3dPHHxQsAblWMSPvkigY//mqaUtm1u7T3QtIiBLlGKRlEwCgc3nTd7agd5f7N8aDxfw6z8Ls8bAg/smA==",++ "version": "0.170.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.170.0",++ "integrity": "sha512-EpOUiqFSYBZ1p7aQTek1IYyHcbD9QT0pdr492+teWvZf425xXSMu7+DQsnzgMP8JdTmdS2VMxoirrBwMUGxjrw==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -27,7 +27,7 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.149.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.170.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,6 +8,7 @@* space.domain.manage add, verify and remove custom domains* space.site.config read, set and reset a site's response headers, redirects and SPA fallback* space.resource.read read the resource index (GET /api/v1/resources; ADR-048 section 3, plan T13 step 8)++ * space.resource.control delete a site, deploy or domain for a person (POST /api/v1/resources/control; plan T13 step 10)** A service token is judged by its capability AND by the project role of the principal it acts* as (domain/access.js). Browsers are judged by their role alone.@@@ -22,6 +23,7 @@ const CAPABILITIES = Object.freeze({DOMAIN_MANAGE: 'space.domain.manage',SITE_CONFIG: 'space.site.config',RESOURCE_READ: 'space.resource.read',++ RESOURCE_CONTROL: 'space.resource.control',});/** → { allowed, code, reason } (capabilities.check(); space.* since openvibe-contracts 0.149.0). */@@@ -93,7 +93,7 @@ function createHosting(app, { db, auth, config: suppliedConfig, now, resolver, fctx.mountRoutes = (app) => {if (forumRedirect) app.use(forumRedirect);app.use(contracts.http.middleware());-- app.use('/api/v1/resources', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), viewers.middleware('api'), resourceIndex.router({ guard: guard('space.resource.read') }));++ app.use('/api/v1/resources', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), viewers.middleware('api'), require('./registry/resource-control').router({ guard: guard('space.resource.control') }), resourceIndex.router({ guard: guard('space.resource.read') }));app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));app.use('/preview', rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), viewers.middleware('dashboard'), (req, res, next) => Promise.resolve(tenant.preview(req, res)).catch(next));app.use(rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), createDashboard(ctx));nnew file mode 100644@@@ -0,0 +1,116 @@++'use strict';++/**++ * Space's control API (ADR-048, plan T13 step 10; capability space.resource.control):++ *++ * POST /api/v1/resources/control common.resource-control-request@1 → common.resource-control-result@1++ *++ * From a first-party service (OpenVibe.Services) acting for the person in on_behalf_of, on a space.site, space.deploy or++ * space.domain named by its OVRN. 200 for every outcome; a malformed request is a 400 problem.++ *++ * delete exactly as the API's DELETE /api/v1/sites/:id, /deploys/:id and /domains/:id, acting as that person: a++ * site stops serving at once and its deploys and domains go with it. Space's own rules refuse the deploy a++ * site is serving (deploy.active), a site's default domain (domain.default) and anything taken down.++ *++ * Any other action is refused `control.unsupported_action`. Space decides by its own project roles (deleting needs++ * maintainer or owner; domain/access.js), so a person who is not a member is refused as not found, never confirmed to++ * exist; and only inside the project the OVRN names. `dry_run` checks the role and those rules without applying. The++ * first answer per (caller, project, idempotency_key) is kept in space_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 { ApiError } = require('../http/errors');++++const SERVICE = 'space';++const CAPABILITY = 'space.resource.control';++const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const TYPES = { site: /^sit_[0-9A-HJKMNP-TV-Z]{26}$/, deploy: /^dpl_[0-9A-HJKMNP-TV-Z]{26}$/, domain: /^dom_[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 router({ guard, now = () => Date.now() }) {++ const r = express.Router();++ 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 });++ const unknown = (request) => result(request, 'refused', { problem: problem(404, 'resources.unknown_resource', `no ${request.resource || request.resource_kind} that this person may delete`) });++++ /** A person's control request, judged by Space's own roles exactly as an API call they made through a service. */++ async function decide(ctx, viewer, request) {++ const parsed = contracts.resources.parse(request.resource || '');++ if (!parsed || parsed.service !== SERVICE || !TYPES[parsed.type] || !TYPES[parsed.type].test(parsed.id)) return unknown(request);++ if (request.action !== 'delete') {++ return result(request, 'refused', { problem: problem(422, 'control.unsupported_action', `a ${parsed.type} takes 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 acting') });++ }++ if (parsed.project_id !== request.project_id) return unknown(request);++ const as = { kind: 'service', service: viewer.service, claims: viewer.claims, subject: person.id, env: viewer.env, staff: false };++ try {++ // Load with the role deleting needs (maintain): a non-member is a 404 there, a lesser role a 403.++ const loaded = parsed.type === 'site' ? await ctx.sites.load(as, parsed.id, 'maintain')++ : parsed.type === 'deploy' ? await ctx.deploys.load(as, parsed.id, 'maintain')++ : await ctx.domains.load(as, parsed.id, 'maintain');++ const row = loaded[parsed.type];++ if (!row || row.project_id !== parsed.project_id) return unknown(request);++ if (request.dry_run) {++ if (parsed.type !== 'domain') await ctx.takedowns.assertDeletable(as, { site: loaded.site || row });++ if (parsed.type === 'deploy' && loaded.site && loaded.site.active_deploy_id === row.id) throw new ApiError(409, 'deploy.active', 'this deploy is being served: activate another one first');++ if (parsed.type === 'domain' && row.kind === 'default') throw new ApiError(409, 'domain.default', 'the default domain goes away only with the site');++ return result(request, 'done', { result: { dry_run: true, would: `delete the ${parsed.type}` } });++ }++ const out = parsed.type === 'site' ? await ctx.sites.remove(as, parsed.id, { deploys: ctx.deploys })++ : parsed.type === 'deploy' ? await ctx.deploys.remove(as, parsed.id)++ : await ctx.domains.remove(as, parsed.id);++ return result(request, 'done', { result: { status: 'deleted', changed: true, ...(out && out.objects_removed != null ? { objects_removed: out.objects_removed } : {}) } });++ } catch (err) {++ if (!(err instanceof ApiError)) throw err;++ if (err.status === 404) return unknown(request);++ return result(request, 'refused', { problem: problem(err.status, err.code, err.message) });++ }++ }++++ /** First-party only: a person holds no service capability (as at the resource index). */++ function firstParty(req, res, next) {++ const v = req.viewer;++ if (v && v.kind === 'service' && String(v.service || '').startsWith('svc:')) return next();++ if (!v || v.kind === 'anonymous') return contracts.http.sendProblem(res, 401, 'auth.required', { detail: 'control requests come from first-party services: present a service token for audience openvibe.space', ctx: req.ov });++ return contracts.http.sendProblem(res, 403, 'capability.denied', { detail: `${CAPABILITY} is first-party and requires a service token`, ctx: req.ov });++ }++++ r.post('/control', firstParty, guard, json, async (req, res, next) => {++ try {++ res.set('Cache-Control', 'no-store');++ const ctx = req.app.locals.ctx;++ const db = ctx.store.db;++ 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.viewer.service;++ const digest = digestOf(request);++ const seen = await db.prepare('SELECT digest, response FROM space_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(ctx, req.viewer, request);++ await db.prepare(`INSERT INTO space_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 r;++}++++module.exports = { router, CAPABILITY, digestOf };nnew file mode 100644@@@ -0,0 +1,94 @@++'use strict';++// Space's control API (ADR-048, plan T13 step 10; capability space.resource.control): OpenVibe.Services deletes a site,++// deploy or domain for a person, judged by Space's own project roles exactly as the API's DELETE routes are.++const assert = require('assert');++const crypto = require('crypto');++const { validate } = require('openvibe-contracts');++const { boot, check, done } = require('./stageb/boot');++++(async () => {++ const t = await boot();++ try {++ const alice = t.user('alice');++ const dora = t.user('dora');++ const eve = t.user('eve');++ const project = (await t.project(alice, 'Control')).id;++ const other = (await t.project(alice, 'Elsewhere')).id;++ await t.api('PUT', `/api/v1/projects/${project}/members/${dora.subject}`, { as: alice, json: { role: 'deployer' } });++ const services = t.network.serviceToken('services', ['space.resource.control']);++ const ovrn = (type, id, prj = project) => `ovrn:space:${prj}:${type}/${id}`;++ const ask = (type, id, who, extra = {}) => ({ action: 'delete', resource: ovrn(type, id), project_id: project,++ idempotency_key: `k-${crypto.randomUUID()}`, on_behalf_of: { type: 'user', id: who.subject }, ...extra });++ const control = async (body, as = services) => {++ const r = await t.api('POST', '/api/v1/resources/control', { as, json: body });++ return { status: r.status, body: r.text ? r.json() : null, text: r.text };++ };++ const valid = (body) => assert.deepStrictEqual(validate('common.resource-control-result@1', body).errors, [], JSON.stringify(body));++ const domainsOf = async (siteId) => (await t.api('GET', `/api/v1/sites/${siteId}`, { as: alice })).json().domains;++++ await check('first-party service tokens with space.resource.control only', async () => {++ const site = await t.site(alice, project, 'rc-gate');++ const body = ask('site', site.id, alice, { dry_run: true });++ assert.strictEqual((await control(body, null)).status, 401);++ assert.strictEqual((await control(body, alice)).status, 403, 'a person holds no service capability');++ assert.strictEqual((await control(body, t.network.serviceToken('services', ['space.resource.read']))).status, 403);++ assert.strictEqual((await control({ action: 'delete' })).status, 400);++ assert.strictEqual((await t.api('GET', `/api/v1/sites/${site.id}`, { as: alice })).status, 200, 'nothing was deleted');++ });++++ await check('an owner deletes a site through it: it stops serving, and a repeat replays the answer', async () => {++ const site = await t.site(alice, project, 'rc-site');++ await t.deploy(alice, site.id, { 'index.html': 'hi' });++ const body = ask('site', site.id, alice);++ const first = await control(body);++ assert.strictEqual(first.status, 200, first.text);++ valid(first.body);++ assert.deepStrictEqual([first.body.state, first.body.result.status, first.body.result.changed], ['done', 'deleted', true]);++ assert.strictEqual((await t.api('GET', `/api/v1/sites/${site.id}`, { as: alice })).status, 404, 'the site is gone');++ assert.deepStrictEqual((await control(body)).body, first.body, 'the same key answers the stored result');++ const conflict = await control({ ...body, dry_run: true });++ assert.deepStrictEqual([conflict.status, conflict.body.code], [409, 'control.idempotency_conflict']);++ });++++ await check('Space\'s own rules decide: a deployer may not, an outsider is not told it exists, the live deploy and the default domain stay', async () => {++ const site = await t.site(alice, project, 'rc-rules');++ const live = await t.deploy(alice, site.id, { 'index.html': 'v1' });++ const asDeployer = await control(ask('site', site.id, dora));++ valid(asDeployer.body);++ assert.strictEqual(asDeployer.body.state, 'refused', 'deleting needs maintainer or owner');++ assert.notStrictEqual(asDeployer.body.problem.code, 'resources.unknown_resource', 'a member learns why');++ const outsider = await control(ask('site', site.id, eve));++ assert.deepStrictEqual([outsider.body.state, outsider.body.problem.code], ['refused', 'resources.unknown_resource']);++ const liveDeploy = await control(ask('deploy', live.id, alice));++ assert.strictEqual(liveDeploy.body.problem.code, 'deploy.active');++ const dryLive = await control(ask('deploy', live.id, alice, { dry_run: true }));++ assert.strictEqual(dryLive.body.problem.code, 'deploy.active', 'a dry run applies the same rule');++ const older = live;++ await t.deploy(alice, site.id, { 'index.html': 'v2' });++ const gone = await control(ask('deploy', older.id, alice));++ assert.deepStrictEqual([gone.body.state, gone.body.result.status], ['done', 'deleted'], 'a deploy no longer served can go');++ const def = (await domainsOf(site.id)).find((d) => d.kind === 'default');++ assert.strictEqual((await control(ask('domain', def.id, alice))).body.problem.code, 'domain.default');++ const custom = (await t.api('POST', `/api/v1/sites/${site.id}/domains`, { as: alice, json: { hostname: 'rc.example.org' } })).json().domain;++ const dry = await control(ask('domain', custom.id, alice, { dry_run: true }));++ assert.deepStrictEqual([dry.body.state, dry.body.result.dry_run], ['done', true]);++ assert.ok((await domainsOf(site.id)).some((d) => d.id === custom.id), 'a dry run applies nothing');++ assert.strictEqual((await control(ask('domain', custom.id, alice))).body.result.status, 'deleted');++ assert.ok(!(await domainsOf(site.id)).some((d) => d.id === custom.id));++ });++++ await check('only delete, only Space\'s kinds, only inside the resource\'s own project', async () => {++ const site = await t.site(alice, other, 'rc-other');++ const elsewhere = await control({ ...ask('site', site.id, alice), resource: ovrn('site', site.id, project) });++ assert.strictEqual(elsewhere.body.problem.code, 'resources.unknown_resource', 'a site of another project is not found here');++ const own = await t.site(alice, project, 'rc-kinds');++ const suspend = await control({ ...ask('site', own.id, alice), action: 'suspend' });++ valid(suspend.body);++ assert.strictEqual(suspend.body.problem.code, 'control.unsupported_action');++ const media = await control({ ...ask('site', own.id, alice), resource: `ovrn:media:${project}:object/med_${'0'.repeat(26)}` });++ assert.strictEqual(media.body.problem.code, 'resources.unknown_resource');++ assert.strictEqual((await t.api('GET', `/api/v1/sites/${own.id}`, { as: alice })).status, 200);++ });++ } finally { await t.close(); }++ done();++})().catch((err) => { console.error(err); process.exit(1); });
@@ -37,6 +37,7 @@ Nothing that is not a forum path redirects.
@@ -37,6 +37,7 @@ Nothing that is not a forum path redirects.
| `/preview/:deployId/*` | Private, sandboxed deploy preview |
| `/preview/:deployId/*` | Private, sandboxed deploy preview |
| `/api/v1/projects`, `/api/v1/sites`, `/api/v1/deploys`, `/api/v1/domains` | Hosting API (Bearer tokens, no cookie auth) |
| `/api/v1/projects`, `/api/v1/sites`, `/api/v1/deploys`, `/api/v1/domains` | Hosting API (Bearer tokens, no cookie auth) |
| `/api/v1/resources` | First-party resource index |
| `/api/v1/resources` | First-party resource index |
+| `POST /api/v1/resources/control` | First-party control: `delete` a site, deploy or domain for a person (Space's own roles decide) |
| `/limits.json` | Enforced hosting limits |
| `/limits.json` | Enforced hosting limits |
| `/auth/login|callback|fedcm|logout|me|refresh` | Space's Network OAuth client |
| `/auth/login|callback|fedcm|logout|me|refresh` | Space's Network OAuth client |
| `/robots.txt`, `/llms.txt`, `/llms-full.txt`, `/sitemap.xml` | Discovery for Space's public page |
| `/robots.txt`, `/llms.txt`, `/llms-full.txt`, `/sitemap.xml` | Discovery for Space's public page |
@@ -48,7 +49,7 @@ Tenant requests are dispatched by Host header before dashboard routes. `deploy/n
@@ -48,7 +49,7 @@ Tenant requests are dispatched by Host header before dashboard routes. `deploy/n
## Capabilities and events
## Capabilities and events
-The hosting API guards `space.site.manage`, `space.site.config`, `space.deploy.create`, `space.domain.manage` and `space.resource.read` (openvibe-contracts 0.149.0, owner `space`); every route names exactly one, and `npx openvibe-contracts-check --service space` checks the code against the release. Service tokens need audience `openvibe.space`, and project membership still applies. The per-actor limits (`server/http/actor-limits.js`) count uploads, domain checks, project creation and writes per person or app; those are limit buckets, not capabilities. The retired `space.forum.*` contracts are unrelated to hosting.
+The hosting API guards `space.site.manage`, `space.site.config`, `space.deploy.create`, `space.domain.manage`, `space.resource.read` and `space.resource.control` (openvibe-contracts 0.170.0, owner `space`); every route names exactly one, and `npx openvibe-contracts-check --service space` checks the code against the release. Service tokens need audience `openvibe.space`, and project membership still applies. The per-actor limits (`server/http/actor-limits.js`) count uploads, domain checks, project creation and writes per person or app; those are limit buckets, not capabilities. The retired `space.forum.*` contracts are unrelated to hosting. The control route (`server/registry/resource-control.js`, plan T13 step 10) takes a `common.resource-control-request@1` from OpenVibe.Services and acts as the person in `on_behalf_of` through the same domain code as the DELETE routes: deleting needs maintainer or owner, a non-member gets `resources.unknown_resource`, and the served deploy (`deploy.active`), a default domain (`domain.default`) and anything taken down are refused with Space's own reason. Dry runs; repeats with the same key return the first answer (`space_control_log`).
The transactional outbox emits `space.deploy.created`, `space.deploy.activated`, `space.deploy.failed` and `space.domain.verified`; each has a payload schema in openvibe-contracts, and the SDK outbox refuses any other type.
The transactional outbox emits `space.deploy.created`, `space.deploy.activated`, `space.deploy.failed` and `space.domain.verified`; each has a payload schema in openvibe-contracts, and the SDK outbox refuses any other type.
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/registry/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 space_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)
+);
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.149.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.170.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"
@@ -804,9 +804,9 @@
@@ -804,9 +804,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.149.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.149.0",
- "integrity": "sha512-7TGh8i3dPHHxQsAblWMSPvkigY//mqaUtm1u7T3QtIiBLlGKRlEwCgc3nTd7agd5f7N8aDxfw6z8Ls8bAg/smA==",
+ "version": "0.170.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.170.0",
+ "integrity": "sha512-EpOUiqFSYBZ1p7aQTek1IYyHcbD9QT0pdr492+teWvZf425xXSMu7+DQsnzgMP8JdTmdS2VMxoirrBwMUGxjrw==",
"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": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.149.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.170.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,6 +8,7 @@
@@ -8,6 +8,7 @@
* space.domain.manage add, verify and remove custom domains
* space.domain.manage add, verify and remove custom domains
* space.site.config read, set and reset a site's response headers, redirects and SPA fallback
* space.site.config read, set and reset a site's response headers, redirects and SPA fallback
* space.resource.read read the resource index (GET /api/v1/resources; ADR-048 section 3, plan T13 step 8)
* space.resource.read read the resource index (GET /api/v1/resources; ADR-048 section 3, plan T13 step 8)
+ * space.resource.control delete a site, deploy or domain for a person (POST /api/v1/resources/control; plan T13 step 10)
*
*
* A service token is judged by its capability AND by the project role of the principal it acts
* A service token is judged by its capability AND by the project role of the principal it acts
* as (domain/access.js). Browsers are judged by their role alone.
* as (domain/access.js). Browsers are judged by their role alone.
@@ -22,6 +23,7 @@ const CAPABILITIES = Object.freeze({
@@ -22,6 +23,7 @@ const CAPABILITIES = Object.freeze({
DOMAIN_MANAGE: 'space.domain.manage',
DOMAIN_MANAGE: 'space.domain.manage',
SITE_CONFIG: 'space.site.config',
SITE_CONFIG: 'space.site.config',
RESOURCE_READ: 'space.resource.read',
RESOURCE_READ: 'space.resource.read',
+ RESOURCE_CONTROL: 'space.resource.control',
});
});
/** → { allowed, code, reason } (capabilities.check(); space.* since openvibe-contracts 0.149.0). */
/** → { allowed, code, reason } (capabilities.check(); space.* since openvibe-contracts 0.149.0). */
@@ -93,7 +93,7 @@ function createHosting(app, { db, auth, config: suppliedConfig, now, resolver, f
@@ -93,7 +93,7 @@ function createHosting(app, { db, auth, config: suppliedConfig, now, resolver, f
ctx.mountRoutes = (app) => {
ctx.mountRoutes = (app) => {
if (forumRedirect) app.use(forumRedirect);
if (forumRedirect) app.use(forumRedirect);
app.use(contracts.http.middleware());
app.use(contracts.http.middleware());
- app.use('/api/v1/resources', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), viewers.middleware('api'), resourceIndex.router({ guard: guard('space.resource.read') }));
+ app.use('/api/v1/resources', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), viewers.middleware('api'), require('./registry/resource-control').router({ guard: guard('space.resource.control') }), resourceIndex.router({ guard: guard('space.resource.read') }));
app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));
app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));
app.use('/preview', rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), viewers.middleware('dashboard'), (req, res, next) => Promise.resolve(tenant.preview(req, res)).catch(next));
app.use('/preview', rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), viewers.middleware('dashboard'), (req, res, next) => Promise.resolve(tenant.preview(req, res)).catch(next));
app.use(rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), createDashboard(ctx));
app.use(rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), createDashboard(ctx));
new file mode 100644
new file mode 100644
@@ -0,0 +1,116 @@
@@ -0,0 +1,116 @@
+'use strict';
+/**
+ * Space's control API (ADR-048, plan T13 step 10; capability space.resource.control):
+ *
+ * POST /api/v1/resources/control common.resource-control-request@1 → common.resource-control-result@1
+ *
+ * From a first-party service (OpenVibe.Services) acting for the person in on_behalf_of, on a space.site, space.deploy or
+ * space.domain named by its OVRN. 200 for every outcome; a malformed request is a 400 problem.
+ *
+ * delete exactly as the API's DELETE /api/v1/sites/:id, /deploys/:id and /domains/:id, acting as that person: a
+ * site stops serving at once and its deploys and domains go with it. Space's own rules refuse the deploy a
+ * site is serving (deploy.active), a site's default domain (domain.default) and anything taken down.
+ *
+ * Any other action is refused `control.unsupported_action`. Space decides by its own project roles (deleting needs
+ * maintainer or owner; domain/access.js), so a person who is not a member is refused as not found, never confirmed to
+ * exist; and only inside the project the OVRN names. `dry_run` checks the role and those rules without applying. The
+ * first answer per (caller, project, idempotency_key) is kept in space_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 { ApiError } = require('../http/errors');
+
+const SERVICE = 'space';
+const CAPABILITY = 'space.resource.control';
+const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const TYPES = { site: /^sit_[0-9A-HJKMNP-TV-Z]{26}$/, deploy: /^dpl_[0-9A-HJKMNP-TV-Z]{26}$/, domain: /^dom_[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 router({ guard, now = () => Date.now() }) {
+ const r = express.Router();
+ 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 });
+ const unknown = (request) => result(request, 'refused', { problem: problem(404, 'resources.unknown_resource', `no ${request.resource || request.resource_kind} that this person may delete`) });
+
+ /** A person's control request, judged by Space's own roles exactly as an API call they made through a service. */
+ async function decide(ctx, viewer, request) {
+ const parsed = contracts.resources.parse(request.resource || '');
+ if (!parsed || parsed.service !== SERVICE || !TYPES[parsed.type] || !TYPES[parsed.type].test(parsed.id)) return unknown(request);
+ if (request.action !== 'delete') {
+ return result(request, 'refused', { problem: problem(422, 'control.unsupported_action', `a ${parsed.type} takes 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 acting') });
+ }
+ if (parsed.project_id !== request.project_id) return unknown(request);
+ const as = { kind: 'service', service: viewer.service, claims: viewer.claims, subject: person.id, env: viewer.env, staff: false };
+ try {
+ // Load with the role deleting needs (maintain): a non-member is a 404 there, a lesser role a 403.
+ const loaded = parsed.type === 'site' ? await ctx.sites.load(as, parsed.id, 'maintain')
+ : parsed.type === 'deploy' ? await ctx.deploys.load(as, parsed.id, 'maintain')
+ : await ctx.domains.load(as, parsed.id, 'maintain');
+ const row = loaded[parsed.type];
+ if (!row || row.project_id !== parsed.project_id) return unknown(request);
+ if (request.dry_run) {
+ if (parsed.type !== 'domain') await ctx.takedowns.assertDeletable(as, { site: loaded.site || row });
+ if (parsed.type === 'deploy' && loaded.site && loaded.site.active_deploy_id === row.id) throw new ApiError(409, 'deploy.active', 'this deploy is being served: activate another one first');
+ if (parsed.type === 'domain' && row.kind === 'default') throw new ApiError(409, 'domain.default', 'the default domain goes away only with the site');
+ return result(request, 'done', { result: { dry_run: true, would: `delete the ${parsed.type}` } });
+ }
+ const out = parsed.type === 'site' ? await ctx.sites.remove(as, parsed.id, { deploys: ctx.deploys })
+ : parsed.type === 'deploy' ? await ctx.deploys.remove(as, parsed.id)
+ : await ctx.domains.remove(as, parsed.id);
+ return result(request, 'done', { result: { status: 'deleted', changed: true, ...(out && out.objects_removed != null ? { objects_removed: out.objects_removed } : {}) } });
+ } catch (err) {
+ if (!(err instanceof ApiError)) throw err;
+ if (err.status === 404) return unknown(request);
+ return result(request, 'refused', { problem: problem(err.status, err.code, err.message) });
+ }
+ }
+
+ /** First-party only: a person holds no service capability (as at the resource index). */
+ function firstParty(req, res, next) {
+ const v = req.viewer;
+ if (v && v.kind === 'service' && String(v.service || '').startsWith('svc:')) return next();
+ if (!v || v.kind === 'anonymous') return contracts.http.sendProblem(res, 401, 'auth.required', { detail: 'control requests come from first-party services: present a service token for audience openvibe.space', ctx: req.ov });
+ return contracts.http.sendProblem(res, 403, 'capability.denied', { detail: `${CAPABILITY} is first-party and requires a service token`, ctx: req.ov });
+ }
+
+ r.post('/control', firstParty, guard, json, async (req, res, next) => {
+ try {
+ res.set('Cache-Control', 'no-store');
+ const ctx = req.app.locals.ctx;
+ const db = ctx.store.db;
+ 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.viewer.service;
+ const digest = digestOf(request);
+ const seen = await db.prepare('SELECT digest, response FROM space_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(ctx, req.viewer, request);
+ await db.prepare(`INSERT INTO space_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 r;
+}
+
+module.exports = { router, CAPABILITY, digestOf };
new file mode 100644
new file mode 100644
@@ -0,0 +1,94 @@
@@ -0,0 +1,94 @@
+'use strict';
+// Space's control API (ADR-048, plan T13 step 10; capability space.resource.control): OpenVibe.Services deletes a site,
+// deploy or domain for a person, judged by Space's own project roles exactly as the API's DELETE routes are.
+const assert = require('assert');
+const crypto = require('crypto');
+const { validate } = require('openvibe-contracts');
+const { boot, check, done } = require('./stageb/boot');
+
+(async () => {
+ const t = await boot();
+ try {
+ const alice = t.user('alice');
+ const dora = t.user('dora');
+ const eve = t.user('eve');
+ const project = (await t.project(alice, 'Control')).id;
+ const other = (await t.project(alice, 'Elsewhere')).id;
+ await t.api('PUT', `/api/v1/projects/${project}/members/${dora.subject}`, { as: alice, json: { role: 'deployer' } });
+ const services = t.network.serviceToken('services', ['space.resource.control']);
+ const ovrn = (type, id, prj = project) => `ovrn:space:${prj}:${type}/${id}`;
+ const ask = (type, id, who, extra = {}) => ({ action: 'delete', resource: ovrn(type, id), project_id: project,
+ idempotency_key: `k-${crypto.randomUUID()}`, on_behalf_of: { type: 'user', id: who.subject }, ...extra });
+ const control = async (body, as = services) => {
+ const r = await t.api('POST', '/api/v1/resources/control', { as, json: body });
+ return { status: r.status, body: r.text ? r.json() : null, text: r.text };
+ };
+ const valid = (body) => assert.deepStrictEqual(validate('common.resource-control-result@1', body).errors, [], JSON.stringify(body));
+ const domainsOf = async (siteId) => (await t.api('GET', `/api/v1/sites/${siteId}`, { as: alice })).json().domains;
+
+ await check('first-party service tokens with space.resource.control only', async () => {
+ const site = await t.site(alice, project, 'rc-gate');
+ const body = ask('site', site.id, alice, { dry_run: true });
+ assert.strictEqual((await control(body, null)).status, 401);
+ assert.strictEqual((await control(body, alice)).status, 403, 'a person holds no service capability');
+ assert.strictEqual((await control(body, t.network.serviceToken('services', ['space.resource.read']))).status, 403);
+ assert.strictEqual((await control({ action: 'delete' })).status, 400);
+ assert.strictEqual((await t.api('GET', `/api/v1/sites/${site.id}`, { as: alice })).status, 200, 'nothing was deleted');
+ });
+
+ await check('an owner deletes a site through it: it stops serving, and a repeat replays the answer', async () => {
+ const site = await t.site(alice, project, 'rc-site');
+ await t.deploy(alice, site.id, { 'index.html': 'hi' });
+ const body = ask('site', site.id, alice);
+ const first = await control(body);
+ assert.strictEqual(first.status, 200, first.text);
+ valid(first.body);
+ assert.deepStrictEqual([first.body.state, first.body.result.status, first.body.result.changed], ['done', 'deleted', true]);
+ assert.strictEqual((await t.api('GET', `/api/v1/sites/${site.id}`, { as: alice })).status, 404, 'the site is gone');
+ assert.deepStrictEqual((await control(body)).body, first.body, 'the same key answers the stored result');
+ const conflict = await control({ ...body, dry_run: true });
+ assert.deepStrictEqual([conflict.status, conflict.body.code], [409, 'control.idempotency_conflict']);
+ });
+
+ await check('Space\'s own rules decide: a deployer may not, an outsider is not told it exists, the live deploy and the default domain stay', async () => {
+ const site = await t.site(alice, project, 'rc-rules');
+ const live = await t.deploy(alice, site.id, { 'index.html': 'v1' });
+ const asDeployer = await control(ask('site', site.id, dora));
+ valid(asDeployer.body);
+ assert.strictEqual(asDeployer.body.state, 'refused', 'deleting needs maintainer or owner');
+ assert.notStrictEqual(asDeployer.body.problem.code, 'resources.unknown_resource', 'a member learns why');
+ const outsider = await control(ask('site', site.id, eve));
+ assert.deepStrictEqual([outsider.body.state, outsider.body.problem.code], ['refused', 'resources.unknown_resource']);
+ const liveDeploy = await control(ask('deploy', live.id, alice));
+ assert.strictEqual(liveDeploy.body.problem.code, 'deploy.active');
+ const dryLive = await control(ask('deploy', live.id, alice, { dry_run: true }));
+ assert.strictEqual(dryLive.body.problem.code, 'deploy.active', 'a dry run applies the same rule');
+ const older = live;
+ await t.deploy(alice, site.id, { 'index.html': 'v2' });
+ const gone = await control(ask('deploy', older.id, alice));
+ assert.deepStrictEqual([gone.body.state, gone.body.result.status], ['done', 'deleted'], 'a deploy no longer served can go');
+ const def = (await domainsOf(site.id)).find((d) => d.kind === 'default');
+ assert.strictEqual((await control(ask('domain', def.id, alice))).body.problem.code, 'domain.default');
+ const custom = (await t.api('POST', `/api/v1/sites/${site.id}/domains`, { as: alice, json: { hostname: 'rc.example.org' } })).json().domain;
+ const dry = await control(ask('domain', custom.id, alice, { dry_run: true }));
+ assert.deepStrictEqual([dry.body.state, dry.body.result.dry_run], ['done', true]);
+ assert.ok((await domainsOf(site.id)).some((d) => d.id === custom.id), 'a dry run applies nothing');
+ assert.strictEqual((await control(ask('domain', custom.id, alice))).body.result.status, 'deleted');
+ assert.ok(!(await domainsOf(site.id)).some((d) => d.id === custom.id));
+ });
+
+ await check('only delete, only Space\'s kinds, only inside the resource\'s own project', async () => {
+ const site = await t.site(alice, other, 'rc-other');
+ const elsewhere = await control({ ...ask('site', site.id, alice), resource: ovrn('site', site.id, project) });
+ assert.strictEqual(elsewhere.body.problem.code, 'resources.unknown_resource', 'a site of another project is not found here');
+ const own = await t.site(alice, project, 'rc-kinds');
+ const suspend = await control({ ...ask('site', own.id, alice), action: 'suspend' });
+ valid(suspend.body);
+ assert.strictEqual(suspend.body.problem.code, 'control.unsupported_action');
+ const media = await control({ ...ask('site', own.id, alice), resource: `ovrn:media:${project}:object/med_${'0'.repeat(26)}` });
+ assert.strictEqual(media.body.problem.code, 'resources.unknown_resource');
+ assert.strictEqual((await t.api('GET', `/api/v1/sites/${own.id}`, { as: alice })).status, 200);
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });