openvibers/openvibe.community Commits

Community: a paste's owner deletes it from OpenVibe.Services (community.resource.control, contracts 0.176.0) (#73)

OP OpenVibers committed 29 days ago · 4d10c5d34f · 6 files, +175 −5

A paste's owner can delete it from OpenVibe.Services ("Your resources"), through Community's control API (contracts 0.176.0, ADR-048 amendment: person-owned resources).

- **`POST /api/v1/resources/control`** (`server/registry/resource-control.js`, capability `community.resource.control`, first-party `svc:` tokens holding it). It takes one `common.owned-resource-control-request@1` naming a `community.paste` by slug and its owner, and answers `common.resource-control-result@1` without `resource`:
  - `delete` takes the owner's own deletion path, `pastes.remove` as the owner (exactly `DELETE /api/pastes/:slug`): soft delete, content dropped, live viewers told, IndexNow pinged. No moderation record is written, since it is the owner's own act.
- **Refusals:**
  - another action: `control.unsupported_action`;
  - another person's paste, one already gone, or another kind (spaces have no actions yet): refused as unknown, never confirmed to exist;
  - a project-scoped request: 400.
- **dry_run** explains without applying.
- **Idempotency:** the first answer per (caller, key) is kept in `community_control_log` (migration 0008, expand). The same key with another request gets 409.
- **Mounting:** the control router sits before the index (`community.resource.read`). The public vhost already answers 404 for `/api/v1/resources*`.
- **Pin:** `openvibe-contracts` v0.143.0 → v0.176.0.

**Tests:** `test/resource-control.test.js`:
- a dry run, delete once (content dropped) and its repeat, the key conflict, a gone paste answering unknown;
- another person's paste, another action, a space, a person acting for someone else and a project request all refused;
- a read-only service token and no token refused.

**Checks:** the full suite passes 59/59.

Changes

ddiff --git a/migrations/0008_control_log.sql b/migrations/0008_control_log.sql
nnew file mode 100644
iindex 0000000..8729f8d
---- /dev/null
++++ b/migrations/0008_control_log.sql
@@@ -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)
++);
ddiff --git a/package-lock.json b/package-lock.json
iindex 71a65a3..b58e7c7 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 31dfac4..bce4c53 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/server/app.js b/server/app.js
iindex b5609df..f6d0dc1 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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 }));
ddiff --git a/server/registry/resource-control.js b/server/registry/resource-control.js
nnew file mode 100644
iindex 0000000..b2e2f09
---- /dev/null
++++ b/server/registry/resource-control.js
@@@ -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 };
ddiff --git a/test/resource-control.test.js b/test/resource-control.test.js
nnew file mode 100644
iindex 0000000..55b2b11
---- /dev/null
++++ b/test/resource-control.test.js
@@@ -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); });