openvibers/openvibe.community Commits
Community: a paste's owner deletes it from OpenVibe.Services (community.resource.control, contracts 0.176.0) (#73)
Changes
nnew file mode 100644@@@ -0,0 +1,12 @@++-- phase: expand++-- ADR-048 amendment 2026-10-15 (server/registry/resource-control.js): the first answer to each control request++-- OpenVibe.Services sent for a person's paste, per (caller, idempotency_key), so a retry is never applied twice.++CREATE TABLE community_control_log (++ caller text COLLATE "C" NOT NULL, -- the service that asked (svc:services)++ idempotency_key text COLLATE "C" NOT NULL,++ owner_subject text COLLATE "C" NOT NULL, -- the paste's owner the request named++ digest text COLLATE "C" NOT NULL, -- the request without its delivery fields++ response text NOT NULL,++ created_at bigint NOT NULL,++ PRIMARY KEY (caller, idempotency_key)++);@@@ -17,7 +17,7 @@"highlight.js": "^11.11.1","iovalkey": "^0.4.0","multer": "^2.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.176.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"@@@ -917,9 +917,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.143.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",-- "integrity": "sha512-Cdbf+9RoPAUBibpM2zXQvtsiKMugb55dKubTt8pFZvAmxsLtGvX+zaC++yL1vOSub8FX3+GgqcGtoJS6tklrUg==",++ "version": "0.176.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.176.0",++ "integrity": "sha512-COLEoRbjs6he+Tb2LvazezNWvHzB6CEWflebrgEyMaTd1hhE5zWJAAL/mdvR0LOzVDAX6vb1GPdTw83AulLfCg==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -28,7 +28,7 @@"highlight.js": "^11.11.1","iovalkey": "^0.4.0","multer": "^2.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.176.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"@@@ -241,6 +241,8 @@ async function createApp(opts = {}) {app.use('/api/v1/relay', createRelayApi({ relay, db, viewers, inbound: relayInbound, limits }));// OpenVibe.Services reads a person's pastes and spaces here (community.resource.read, a service token on loopback;// nginx answers 404 for the path on the public vhost).++ // Control first (community.resource.control: delete a person's paste), then the index.++ app.use('/api/v1/resources/control', require('./registry/resource-control').router({ db, viewers, pastes: app.locals.pastes }));app.use('/api/v1/resources', require('./registry/resource-index').router({ db, viewers }));app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-community', version: VERSION }));nnew file mode 100644@@@ -0,0 +1,85 @@++'use strict';++/**++ * Community's control API for person-owned pastes (ADR-048 amendment 2026-10-15, step 8 phase 2; capability++ * community.resource.control): POST /api/v1/resources/control takes one common.owned-resource-control-request@1 from a++ * first-party service (OpenVibe.Services) naming a community.paste by its slug and its owner, and answers a++ * common.resource-control-result@1 (200 for every outcome, without `resource`: pastes have no OVRN; a malformed request++ * is a 400 problem).++ *++ * delete the paste goes through Community's own deletion path, exactly as its owner deleting it on++ * openvibe.community (DELETE /api/pastes/:slug): soft-deleted, its content dropped, live viewers told++ *++ * Any other action is refused `control.unsupported_action`. Only the owner Community holds may be named: a paste of++ * another person (or one already gone) is refused as unknown, never confirmed to exist. dry_run decides and explains++ * without applying. The first answer per (caller, idempotency_key) is kept in community_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 { hasCap } = require('../identity/viewer');++const store = require('../pastes/store');++++const CAPABILITY = 'community.resource.control';++const KIND = 'community.paste';++++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({ db, viewers, pastes, now = () => Date.now() }) {++ const r = express.Router();++ const send = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });++ const problemOf = (status, code, detail) => ({ type: `https://openvibe.network/problems/${code}`, title: code, status, code, detail });++ const result = (request, state, extra = {}) => ({ action: request.action, state, at: new Date(now()).toISOString(), ...extra });++++ async function decide(request, caller) {++ if (request.owned.kind !== KIND) return result(request, 'refused', { problem: problemOf(404, 'resources.unknown_resource', `no paste ${request.owned.kind} ${request.owned.id}`) });++ if (request.action !== 'delete') return result(request, 'refused', { problem: problemOf(422, 'control.unsupported_action', `a paste takes delete, not ${request.action}`) });++ const p = await store.getBySlug(db, request.owned.id);++ if (!p || p.owner_subject !== request.owner.id) {++ return result(request, 'refused', { problem: problemOf(404, 'resources.unknown_resource', `no paste ${request.owned.id} of this person`) });++ }++ if (request.dry_run) return result(request, 'done', { result: { dry_run: true, would: 'delete the paste' } });++ // The owner's own deletion path, as the owner (the service acts for them; their subject is checked above).++ await pastes.remove({ kind: 'service', service: caller, subject: request.owner.id, claims: {}, staff: false }, p.slug);++ return result(request, 'done', { result: { deleted: true } });++ }++++ r.use(viewers.middleware());++ r.use((req, res, next) => {++ const v = req.viewer || { kind: 'anonymous' };++ if (v.kind === 'service' && /^svc:/.test(String(v.service || '')) && hasCap(v, CAPABILITY)) return next();++ if (v.kind === 'anonymous') return send(req, res, 401, 'token.missing', `call with a service token holding ${CAPABILITY}`);++ return send(req, res, 403, 'capability.denied', `${CAPABILITY} is first-party and requires a service token that holds it`);++ });++ r.post('/', express.json({ limit: '16kb' }), async (req, res, next) => {++ try {++ const request = req.body;++ if (!request || typeof request !== 'object' || request.owned === undefined) {++ // Community holds no project resources: a project-scoped request names nothing here.++ return send(req, res, 400, 'control.bad_request', 'pastes are person-owned: send common.owned-resource-control-request@1');++ }++ const check = contracts.resources.checkControlRequest(request);++ if (!check.valid) return send(req, res, 400, 'control.bad_request', JSON.stringify(check.errors).slice(0, 500));++ const caller = req.viewer.service;++ const digest = digestOf(request);++ const seen = await db.prepare('SELECT digest, response FROM community_control_log WHERE caller = ? AND idempotency_key = ?').get(caller, request.idempotency_key);++ if (seen) {++ if (seen.digest !== digest) return send(req, res, 409, 'control.idempotency_conflict', 'this idempotency_key was used for another request');++ return res.json(typeof seen.response === 'string' ? JSON.parse(seen.response) : seen.response);++ }++ const answer = await decide(request, caller);++ await db.prepare(`INSERT INTO community_control_log (caller, idempotency_key, owner_subject, digest, response, created_at)++ VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`).run(caller, request.idempotency_key, request.owner.id, 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,71 @@++'use strict';++/**++ * ADR-048 amendment 2026-10-15: OpenVibe.Services deletes a person's paste through Community's control API++ * (community.resource.control, common.owned-resource-control-request@1), by the owner's own deletion path.++ */++const assert = require('assert');++const { ids, resources } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/app');++++(async () => {++ const t = await boot();++ const db = t.app.locals.db || t.db;++ const alice = ids.newId('user');++ const bob = ids.newId('user');++ const paste = async (slug, owner, visibility = 'public') => db.prepare(`INSERT INTO pastes (slug, owner_subject, title, content, visibility, created_at, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(slug, owner, `Paste ${slug}`, 'the content', visibility, '2026-10-01 10:00:00', '2026-10-01 10:00:00');++ await paste('rc-mine', alice, 'private');++ await paste('rc-theirs', bob);++ const services = t.network.signService({ sub: 'svc:services', cap: ['community.resource.control'] });++ let n = 0;++ const send = (body, token = services) => t.get('/api/v1/resources/control', { method: 'POST', body: JSON.stringify(body),++ headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) } });++ const req = (action, over = {}) => ({++ action, owned: { kind: 'community.paste', id: 'rc-mine' }, owner: { type: 'user', id: alice },++ on_behalf_of: { type: 'user', id: alice }, idempotency_key: `idem_paste_${++n}_${Date.now()}`, ...over,++ });++ const gone = async (slug) => !!(await db.prepare('SELECT deleted_at FROM pastes WHERE slug = ?').get(slug)).deleted_at;++++ await check('a dry run applies nothing; delete takes the owner\'s deletion path once; a repeat answers the first result', async () => {++ let r = await send(req('delete', { dry_run: true }));++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual([r.json().state, r.json().result.dry_run], ['done', true]);++ assert.strictEqual(await gone('rc-mine'), false);++ const first = req('delete');++ r = await send(first);++ const body = r.json();++ assert.ok(resources.checkControlResult(body).valid, JSON.stringify(body));++ assert.deepStrictEqual([body.state, body.result, body.resource], ['done', { deleted: true }, undefined]);++ assert.strictEqual(await gone('rc-mine'), true);++ assert.strictEqual((await db.prepare('SELECT content FROM pastes WHERE slug = ?').get('rc-mine')).content, null, 'its content is dropped, as the owner\'s delete does');++ assert.deepStrictEqual((await send(first)).json(), body, 'the same key answers the first result');++ const conflict = await send({ ...first, dry_run: true });++ assert.deepStrictEqual([conflict.status, conflict.json().code], [409, 'control.idempotency_conflict']);++ r = await send(req('delete'));++ assert.deepStrictEqual([r.json().state, r.json().problem.code], ['refused', 'resources.unknown_resource'], 'a paste already gone is unknown');++ });++++ await check('only the owner Community holds; other actions and kinds are refused', async () => {++ let r = await send(req('delete', { owned: { kind: 'community.paste', id: 'rc-theirs' } }));++ assert.deepStrictEqual([r.json().state, r.json().problem.code], ['refused', 'resources.unknown_resource'], 'another person\'s paste is not confirmed to exist');++ assert.strictEqual(await gone('rc-theirs'), false);++ r = await send(req('archive', { owned: { kind: 'community.paste', id: 'rc-theirs' } }));++ assert.strictEqual(r.json().problem.code, 'control.unsupported_action');++ r = await send(req('delete', { owned: { kind: 'community.space', id: 'a-space' } }));++ assert.strictEqual(r.json().problem.code, 'resources.unknown_resource');++ r = await send(req('delete', { on_behalf_of: { type: 'user', id: bob } }));++ assert.deepStrictEqual([r.status, r.json().code], [400, 'control.bad_request'], 'a person acts only on what they own');++ r = await send({ action: 'delete', resource: 'ovrn:community:prj_01J8ZQ4Y7N3M2K1H0G9F8E7D6C:paste/pst_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', project_id: 'prj_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', idempotency_key: 'idem_project_1' });++ assert.deepStrictEqual([r.status, r.json().code], [400, 'control.bad_request'], 'Community holds no project resources');++ });++++ await check('first-party only, with community.resource.control', async () => {++ let r = await send(req('delete', { owned: { kind: 'community.paste', id: 'rc-theirs' } }), t.network.signService({ sub: 'svc:services', cap: ['community.resource.read'] }));++ assert.strictEqual(r.status, 403);++ r = await send(req('delete', { owned: { kind: 'community.paste', id: 'rc-theirs' } }), null);++ assert.strictEqual(r.status, 401);++ assert.strictEqual(await gone('rc-theirs'), false);++ });++++ await done(t);++})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+-- phase: expand
+-- ADR-048 amendment 2026-10-15 (server/registry/resource-control.js): the first answer to each control request
+-- OpenVibe.Services sent for a person's paste, per (caller, idempotency_key), so a retry is never applied twice.
+CREATE TABLE community_control_log (
+ caller text COLLATE "C" NOT NULL, -- the service that asked (svc:services)
+ idempotency_key text COLLATE "C" NOT NULL,
+ owner_subject text COLLATE "C" NOT NULL, -- the paste's owner the request named
+ digest text COLLATE "C" NOT NULL, -- the request without its delivery fields
+ response text NOT NULL,
+ created_at bigint NOT NULL,
+ PRIMARY KEY (caller, idempotency_key)
+);
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"highlight.js": "^11.11.1",
"highlight.js": "^11.11.1",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.4.0",
"multer": "^2.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.176.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"
@@ -917,9 +917,9 @@
@@ -917,9 +917,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.143.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",
- "integrity": "sha512-Cdbf+9RoPAUBibpM2zXQvtsiKMugb55dKubTt8pFZvAmxsLtGvX+zaC++yL1vOSub8FX3+GgqcGtoJS6tklrUg==",
+ "version": "0.176.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.176.0",
+ "integrity": "sha512-COLEoRbjs6he+Tb2LvazezNWvHzB6CEWflebrgEyMaTd1hhE5zWJAAL/mdvR0LOzVDAX6vb1GPdTw83AulLfCg==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -28,7 +28,7 @@
@@ -28,7 +28,7 @@
"highlight.js": "^11.11.1",
"highlight.js": "^11.11.1",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.4.0",
"multer": "^2.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.176.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"
@@ -241,6 +241,8 @@ async function createApp(opts = {}) {
@@ -241,6 +241,8 @@ async function createApp(opts = {}) {
app.use('/api/v1/relay', createRelayApi({ relay, db, viewers, inbound: relayInbound, limits }));
app.use('/api/v1/relay', createRelayApi({ relay, db, viewers, inbound: relayInbound, limits }));
// OpenVibe.Services reads a person's pastes and spaces here (community.resource.read, a service token on loopback;
// OpenVibe.Services reads a person's pastes and spaces here (community.resource.read, a service token on loopback;
// nginx answers 404 for the path on the public vhost).
// nginx answers 404 for the path on the public vhost).
+ // Control first (community.resource.control: delete a person's paste), then the index.
+ app.use('/api/v1/resources/control', require('./registry/resource-control').router({ db, viewers, pastes: app.locals.pastes }));
app.use('/api/v1/resources', require('./registry/resource-index').router({ db, viewers }));
app.use('/api/v1/resources', require('./registry/resource-index').router({ db, viewers }));
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-community', version: VERSION }));
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-community', version: VERSION }));
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -0,0 +1,85 @@
+'use strict';
+/**
+ * Community's control API for person-owned pastes (ADR-048 amendment 2026-10-15, step 8 phase 2; capability
+ * community.resource.control): POST /api/v1/resources/control takes one common.owned-resource-control-request@1 from a
+ * first-party service (OpenVibe.Services) naming a community.paste by its slug and its owner, and answers a
+ * common.resource-control-result@1 (200 for every outcome, without `resource`: pastes have no OVRN; a malformed request
+ * is a 400 problem).
+ *
+ * delete the paste goes through Community's own deletion path, exactly as its owner deleting it on
+ * openvibe.community (DELETE /api/pastes/:slug): soft-deleted, its content dropped, live viewers told
+ *
+ * Any other action is refused `control.unsupported_action`. Only the owner Community holds may be named: a paste of
+ * another person (or one already gone) is refused as unknown, never confirmed to exist. dry_run decides and explains
+ * without applying. The first answer per (caller, idempotency_key) is kept in community_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 { hasCap } = require('../identity/viewer');
+const store = require('../pastes/store');
+
+const CAPABILITY = 'community.resource.control';
+const KIND = 'community.paste';
+
+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({ db, viewers, pastes, now = () => Date.now() }) {
+ const r = express.Router();
+ const send = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });
+ const problemOf = (status, code, detail) => ({ type: `https://openvibe.network/problems/${code}`, title: code, status, code, detail });
+ const result = (request, state, extra = {}) => ({ action: request.action, state, at: new Date(now()).toISOString(), ...extra });
+
+ async function decide(request, caller) {
+ if (request.owned.kind !== KIND) return result(request, 'refused', { problem: problemOf(404, 'resources.unknown_resource', `no paste ${request.owned.kind} ${request.owned.id}`) });
+ if (request.action !== 'delete') return result(request, 'refused', { problem: problemOf(422, 'control.unsupported_action', `a paste takes delete, not ${request.action}`) });
+ const p = await store.getBySlug(db, request.owned.id);
+ if (!p || p.owner_subject !== request.owner.id) {
+ return result(request, 'refused', { problem: problemOf(404, 'resources.unknown_resource', `no paste ${request.owned.id} of this person`) });
+ }
+ if (request.dry_run) return result(request, 'done', { result: { dry_run: true, would: 'delete the paste' } });
+ // The owner's own deletion path, as the owner (the service acts for them; their subject is checked above).
+ await pastes.remove({ kind: 'service', service: caller, subject: request.owner.id, claims: {}, staff: false }, p.slug);
+ return result(request, 'done', { result: { deleted: true } });
+ }
+
+ r.use(viewers.middleware());
+ r.use((req, res, next) => {
+ const v = req.viewer || { kind: 'anonymous' };
+ if (v.kind === 'service' && /^svc:/.test(String(v.service || '')) && hasCap(v, CAPABILITY)) return next();
+ if (v.kind === 'anonymous') return send(req, res, 401, 'token.missing', `call with a service token holding ${CAPABILITY}`);
+ return send(req, res, 403, 'capability.denied', `${CAPABILITY} is first-party and requires a service token that holds it`);
+ });
+ r.post('/', express.json({ limit: '16kb' }), async (req, res, next) => {
+ try {
+ const request = req.body;
+ if (!request || typeof request !== 'object' || request.owned === undefined) {
+ // Community holds no project resources: a project-scoped request names nothing here.
+ return send(req, res, 400, 'control.bad_request', 'pastes are person-owned: send common.owned-resource-control-request@1');
+ }
+ const check = contracts.resources.checkControlRequest(request);
+ if (!check.valid) return send(req, res, 400, 'control.bad_request', JSON.stringify(check.errors).slice(0, 500));
+ const caller = req.viewer.service;
+ const digest = digestOf(request);
+ const seen = await db.prepare('SELECT digest, response FROM community_control_log WHERE caller = ? AND idempotency_key = ?').get(caller, request.idempotency_key);
+ if (seen) {
+ if (seen.digest !== digest) return send(req, res, 409, 'control.idempotency_conflict', 'this idempotency_key was used for another request');
+ return res.json(typeof seen.response === 'string' ? JSON.parse(seen.response) : seen.response);
+ }
+ const answer = await decide(request, caller);
+ await db.prepare(`INSERT INTO community_control_log (caller, idempotency_key, owner_subject, digest, response, created_at)
+ VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`).run(caller, request.idempotency_key, request.owner.id, 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,71 @@
@@ -0,0 +1,71 @@
+'use strict';
+/**
+ * ADR-048 amendment 2026-10-15: OpenVibe.Services deletes a person's paste through Community's control API
+ * (community.resource.control, common.owned-resource-control-request@1), by the owner's own deletion path.
+ */
+const assert = require('assert');
+const { ids, resources } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/app');
+
+(async () => {
+ const t = await boot();
+ const db = t.app.locals.db || t.db;
+ const alice = ids.newId('user');
+ const bob = ids.newId('user');
+ const paste = async (slug, owner, visibility = 'public') => db.prepare(`INSERT INTO pastes (slug, owner_subject, title, content, visibility, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(slug, owner, `Paste ${slug}`, 'the content', visibility, '2026-10-01 10:00:00', '2026-10-01 10:00:00');
+ await paste('rc-mine', alice, 'private');
+ await paste('rc-theirs', bob);
+ const services = t.network.signService({ sub: 'svc:services', cap: ['community.resource.control'] });
+ let n = 0;
+ const send = (body, token = services) => t.get('/api/v1/resources/control', { method: 'POST', body: JSON.stringify(body),
+ headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) } });
+ const req = (action, over = {}) => ({
+ action, owned: { kind: 'community.paste', id: 'rc-mine' }, owner: { type: 'user', id: alice },
+ on_behalf_of: { type: 'user', id: alice }, idempotency_key: `idem_paste_${++n}_${Date.now()}`, ...over,
+ });
+ const gone = async (slug) => !!(await db.prepare('SELECT deleted_at FROM pastes WHERE slug = ?').get(slug)).deleted_at;
+
+ await check('a dry run applies nothing; delete takes the owner\'s deletion path once; a repeat answers the first result', async () => {
+ let r = await send(req('delete', { dry_run: true }));
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual([r.json().state, r.json().result.dry_run], ['done', true]);
+ assert.strictEqual(await gone('rc-mine'), false);
+ const first = req('delete');
+ r = await send(first);
+ const body = r.json();
+ assert.ok(resources.checkControlResult(body).valid, JSON.stringify(body));
+ assert.deepStrictEqual([body.state, body.result, body.resource], ['done', { deleted: true }, undefined]);
+ assert.strictEqual(await gone('rc-mine'), true);
+ assert.strictEqual((await db.prepare('SELECT content FROM pastes WHERE slug = ?').get('rc-mine')).content, null, 'its content is dropped, as the owner\'s delete does');
+ assert.deepStrictEqual((await send(first)).json(), body, 'the same key answers the first result');
+ const conflict = await send({ ...first, dry_run: true });
+ assert.deepStrictEqual([conflict.status, conflict.json().code], [409, 'control.idempotency_conflict']);
+ r = await send(req('delete'));
+ assert.deepStrictEqual([r.json().state, r.json().problem.code], ['refused', 'resources.unknown_resource'], 'a paste already gone is unknown');
+ });
+
+ await check('only the owner Community holds; other actions and kinds are refused', async () => {
+ let r = await send(req('delete', { owned: { kind: 'community.paste', id: 'rc-theirs' } }));
+ assert.deepStrictEqual([r.json().state, r.json().problem.code], ['refused', 'resources.unknown_resource'], 'another person\'s paste is not confirmed to exist');
+ assert.strictEqual(await gone('rc-theirs'), false);
+ r = await send(req('archive', { owned: { kind: 'community.paste', id: 'rc-theirs' } }));
+ assert.strictEqual(r.json().problem.code, 'control.unsupported_action');
+ r = await send(req('delete', { owned: { kind: 'community.space', id: 'a-space' } }));
+ assert.strictEqual(r.json().problem.code, 'resources.unknown_resource');
+ r = await send(req('delete', { on_behalf_of: { type: 'user', id: bob } }));
+ assert.deepStrictEqual([r.status, r.json().code], [400, 'control.bad_request'], 'a person acts only on what they own');
+ r = await send({ action: 'delete', resource: 'ovrn:community:prj_01J8ZQ4Y7N3M2K1H0G9F8E7D6C:paste/pst_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', project_id: 'prj_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', idempotency_key: 'idem_project_1' });
+ assert.deepStrictEqual([r.status, r.json().code], [400, 'control.bad_request'], 'Community holds no project resources');
+ });
+
+ await check('first-party only, with community.resource.control', async () => {
+ let r = await send(req('delete', { owned: { kind: 'community.paste', id: 'rc-theirs' } }), t.network.signService({ sub: 'svc:services', cap: ['community.resource.read'] }));
+ assert.strictEqual(r.status, 403);
+ r = await send(req('delete', { owned: { kind: 'community.paste', id: 'rc-theirs' } }), null);
+ assert.strictEqual(r.status, 401);
+ assert.strictEqual(await gone('rc-theirs'), false);
+ });
+
+ await done(t);
+})().catch((e) => { console.error(e); process.exit(1); });