openvibers/openvibe.chat Commits

Chat: a room's owner archives it from OpenVibe.Services (chat.resource.control, contracts 0.176.0) (#85)

OP OpenVibers committed 29 days ago · 2b667f7296 · 8 files, +182 −11

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

- **`POST /api/v1/resources/control`** (`server/registry/resource-control.js`, capability `chat.resource.control`, first-party `svc:` tokens, loopback-only like the index). It takes one `common.owned-resource-control-request@1` naming a `chat.room` by slug and its owner, and answers `common.resource-control-result@1` without `resource` (rooms have no OVRN):
  - `archive` sets `rooms.archived_at`. The room leaves every listing and the index, and takes no new messages (room lookups skip archived rooms). Its history stays.
- **Refusals:**
  - another action: `control.unsupported_action`;
  - a room of another person, or another kind: refused as unknown, never confirmed to exist;
  - a project-scoped request: 400 (Chat holds no project resources).
- **dry_run** explains without applying.
- **Idempotency:** the first answer per (caller, key) is kept in `chat_control_log` (migration 0010, expand). The same key with another request gets 409.
- **Mounting:** the control router sits before the index, which keeps `chat.resource.read`.
- **Pin:** `openvibe-contracts` v0.158.0 → v0.176.0.

**Tests:**
- `test/resource-control.test.js`: a dry run, archive once and its repeat, the key conflict, archive again with no change, the room gone from the index; another person's room, another action or kind, a person acting for someone else and a project request all refused; a read-only service token, the owner's own token and no token all refused.
- **`message-deleted.test.js`:** it boots the sibling OpenVibe.Events, which is cursor-only since 10-11 (no per-event `seq`). The test now reads each SSE message's `id:` cursor and the pull API's `cursor` instead of `seq`, so it also failed on main before this change.
- **`pg-schema.test.js`** lists `chat_control_log`.

**Checks:** the full suite passes locally after these two test fixes.

Changes

ddiff --git a/migrations/0010_control_log.sql b/migrations/0010_control_log.sql
nnew file mode 100644
iindex 0000000..62fe520
---- /dev/null
++++ b/migrations/0010_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 room, per (caller, idempotency_key), so a retry is never applied twice.
++CREATE TABLE chat_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 room'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 4cc4621..270f826 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -16,7 +16,7 @@
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"multer": "^2.0.2",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.158.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",
@@@ -1020,9 +1020,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.158.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.158.0",
-- "integrity": "sha512-kdRf7jW7/qQq80Eyp5QQt2dk6oK5BdJWiDdjUPkZQZjpPA1wFlArGD1OZ95UIRG4KL83wA4R4luE2VSPpmX74w==",
++ "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 84f3a74..54ea80d 100644
---- a/package.json
++++ b/package.json
@@@ -28,7 +28,7 @@
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"multer": "^2.0.2",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.158.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 baf09c4..108f9a4 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -103,6 +103,8 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
app.use('/internal/moderation', express.json({ limit: '64kb' }), require('./chat/internal-moderation'));
// OpenVibe.Services reads Chat's room summaries on loopback (ADR-048); the service guard
// checks chat.resource.read and refuses requests that came through a public proxy.
++ // Control first (chat.resource.control: archive a person's room), then the index for everything else.
++ app.use('/api/v1/resources/control', require('./registry/resource-control').router());
app.use('/api/v1/resources', require('./registry/resource-index').router());
app.get('/health', (req, res) => res.json({ ok: true, service: 'chat' }));
// Readiness in the openvibe-shared/ready shape (status ready/degraded/not_ready, named checks):
ddiff --git a/server/registry/resource-control.js b/server/registry/resource-control.js
nnew file mode 100644
iindex 0000000..c3dcda3
---- /dev/null
++++ b/server/registry/resource-control.js
@@@ -0,0 +1,84 @@
++'use strict';
++/**
++ * Chat's control API for person-owned rooms (ADR-048 amendment 2026-10-15, step 8 phase 2; capability
++ * chat.resource.control): POST /api/v1/resources/control takes one common.owned-resource-control-request@1 from a
++ * first-party service (OpenVibe.Services) naming a chat.room by its slug and its owner, and answers a
++ * common.resource-control-result@1 (200 for every outcome, without `resource`: rooms have no OVRN; a malformed request
++ * is a 400 problem).
++ *
++ * archive the room leaves every listing and takes no new messages (rooms.archived_at); its history stays
++ *
++ * Any other action is refused `control.unsupported_action`. Only the owner Chat holds may be named: a room of another
++ * person 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 chat_control_log and returned for a repeat; the same key with another
++ * request is refused `control.idempotency_conflict`. Loopback only, like the index (the service guard).
++ */
++const crypto = require('crypto');
++const express = require('express');
++const contracts = require('openvibe-contracts');
++const { http } = contracts;
++const db = require('../db/database');
++const serviceAuth = require('../net/service-auth');
++
++const CAPABILITY = 'chat.resource.control';
++const KIND = 'chat.room';
++
++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({ now = () => Date.now() } = {}) {
++ const r = express.Router();
++ const result = (request, state, extra = {}) => ({ action: request.action, state, at: new Date(now()).toISOString(), ...extra });
++ const problem = (status, code, detail) => ({ type: `https://openvibe.network/problems/${code}`, title: code, status, code, detail });
++
++ async function decide(request) {
++ if (request.owned.kind !== KIND) {
++ return result(request, 'refused', { problem: problem(404, 'resources.unknown_resource', `no room ${request.owned.kind} ${request.owned.id}`) });
++ }
++ if (request.action !== 'archive') {
++ return result(request, 'refused', { problem: problem(422, 'control.unsupported_action', `a room takes archive, not ${request.action}`) });
++ }
++ const room = await db.get('SELECT id, slug, owner_subject, archived_at FROM rooms WHERE slug = ?', [String(request.owned.id).toLowerCase()]);
++ if (!room || room.owner_subject !== request.owner.id) {
++ return result(request, 'refused', { problem: problem(404, 'resources.unknown_resource', `no room ${request.owned.id} of this person`) });
++ }
++ if (room.archived_at) return result(request, 'done', { result: { archived: true, changed: false } });
++ if (request.dry_run) return result(request, 'done', { result: { dry_run: true, would: 'archive the room' } });
++ await db.run('UPDATE rooms SET archived_at = ? WHERE id = ? AND archived_at IS NULL', [new Date(now()).toISOString().replace('T', ' ').slice(0, 19), room.id]);
++ return result(request, 'done', { result: { archived: true, changed: true } });
++ }
++
++ r.use(serviceAuth.guard(CAPABILITY));
++ r.post('/', express.json({ limit: '16kb' }), async (req, res, next) => {
++ try {
++ const ctx = http.requestContext(req.headers);
++ if (!/^svc:/.test(String(req.principal.sub || ''))) return http.sendProblem(res, 403, 'capability.denied', { detail: `${CAPABILITY} is first-party`, ctx });
++ const request = req.body;
++ if (!request || typeof request !== 'object' || request.owned === undefined) {
++ // Chat holds no project resources: a project-scoped request names nothing here.
++ return http.sendProblem(res, 400, 'control.bad_request', { detail: 'Chat\'s rooms are person-owned: send common.owned-resource-control-request@1', ctx });
++ }
++ const check = contracts.resources.checkControlRequest(request);
++ if (!check.valid) return http.sendProblem(res, 400, 'control.bad_request', { detail: JSON.stringify(check.errors).slice(0, 500), ctx });
++ const caller = req.principal.sub;
++ const digest = digestOf(request);
++ const seen = await db.get('SELECT digest, response FROM chat_control_log WHERE caller = ? AND idempotency_key = ?', [caller, request.idempotency_key]);
++ if (seen) {
++ if (seen.digest !== digest) return http.sendProblem(res, 409, 'control.idempotency_conflict', { detail: 'this idempotency_key was used for another request', ctx });
++ return res.json(typeof seen.response === 'string' ? JSON.parse(seen.response) : seen.response);
++ }
++ const answer = await decide(request);
++ await db.run(`INSERT INTO chat_control_log (caller, idempotency_key, owner_subject, digest, response, created_at)
++ VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`, [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/message-deleted.test.js b/test/message-deleted.test.js
iindex 67f52c2..389d325 100644
---- a/test/message-deleted.test.js
++++ b/test/message-deleted.test.js
@@@ -183,9 +183,14 @@ function sse(pathAndQuery, headers = {}) {
const c = {
status: res.statusCode,
body: () => body,
-- // Complete blocks only; `event: gap` data has no envelope and is left out.
-- events: () => body.split('\n\n').slice(0, -1).map((b) => b.split('\n').find((l) => l.startsWith('data: ')))
-- .filter(Boolean).map((l) => JSON.parse(l.slice(6))).filter((m) => m.event),
++ // Complete blocks only; `event: gap` data has no envelope and is left out. Each message's position is
++ // its `id:` line, Events' opaque cursor (ADR-042 decision 7: there is no per-event sequence).
++ events: () => body.split('\n\n').slice(0, -1).map((b) => {
++ const lines = b.split('\n');
++ const data = lines.find((l) => l.startsWith('data: '));
++ const id = lines.find((l) => l.startsWith('id: '));
++ return data ? { ...JSON.parse(data.slice(6)), cursor: id ? id.slice(4) : null } : null;
++ }).filter((m) => m && m.event),
async waitFor(pred, ms = 3000) {
const until = Date.now() + ms;
while (!pred(c)) { if (Date.now() > until) throw new Error(`sse timeout: ${body}`); await h.sleep(20); }
@@@ -230,7 +235,7 @@ t('e2e: the relay publishes the message, then its deletion; replay serves only t
const c = await sse(`/realtime/stream?topics=chat.message.*&last_event_id=${await cursorAt(firstSeq - 1)}`, headers);
await c.waitFor((x) => x.events().length === 2);
const [created, deleted] = c.events();
-- assert.deepStrictEqual([created.seq, deleted.seq], [firstSeq, firstSeq + 1], `${who}: sequence intact`);
++ assert.deepStrictEqual([created.cursor, deleted.cursor], [await cursorAt(firstSeq), await cursorAt(firstSeq + 1)], `${who}: sequence intact`);
assert.strictEqual(created.event.event_type, 'chat.message.created');
assert.strictEqual(created.event.payload.redacted, true, who);
assert.deepStrictEqual(created.event.subject, { type: 'chat_message', id: String(msgId) });
@@@ -241,7 +246,7 @@ t('e2e: the relay publishes the message, then its deletion; replay serves only t
const pull = await fetch(`http://127.0.0.1:${eventsPort}/api/v1/events?topic=chat.message.*&after=${await cursorAt(firstSeq - 1)}`, {
headers: { Authorization: `Bearer ${h.serviceToken(['events.event.read'], { aud: 'openvibe.events', sub: 'svc:search' })}` },
}).then((r) => r.json());
-- assert.deepStrictEqual(pull.events.map((e) => e.seq), [firstSeq, firstSeq + 1]);
++ assert.deepStrictEqual(pull.events.map((e) => e.cursor), [await cursorAt(firstSeq), await cursorAt(firstSeq + 1)]);
assert.ok(!JSON.stringify(pull).includes('555-0199'));
});
ddiff --git a/test/pg-schema.test.js b/test/pg-schema.test.js
iindex 012f0a0..58ed27e 100644
---- a/test/pg-schema.test.js
++++ b/test/pg-schema.test.js
@@@ -27,7 +27,7 @@ const TABLES = [
'channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events',
'ctx_users', 'ctx_streams', 'ctx_managed_streams', 'ctx_channels', 'ctx_sync', 'events_outbox', 'service_outbox',
'chat_ingress_applied', 'audio_requests', 'import_hold', 'import_runs', 'chat_meta', 'deploy_releases',
-- 'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'token_revocations',
++ 'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'token_revocations', 'chat_control_log',
'network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages',
];
const INDEXES = [
ddiff --git a/test/resource-control.test.js b/test/resource-control.test.js
nnew file mode 100644
iindex 0000000..40b5217
---- /dev/null
++++ b/test/resource-control.test.js
@@@ -0,0 +1,68 @@
++'use strict';
++/** ADR-048 amendment 2026-10-15: OpenVibe.Services archives a person's room through Chat's control API. */
++const assert = require('assert');
++const contracts = require('openvibe-contracts');
++const { boot, suite } = require('./helpers');
++
++const t = suite('resource-control');
++let h, token, owner, other;
++let n = 0;
++const send = (body, opts = {}) => h.http('POST', '/api/v1/resources/control', { token, body, ...opts });
++const req = (action, over = {}) => ({
++ action, owned: { kind: 'chat.room', id: 'control-lobby' }, owner: { type: 'user', id: owner.subject_id },
++ on_behalf_of: { type: 'user', id: owner.subject_id }, idempotency_key: `idem_room_${++n}_${Date.now()}`, ...over,
++});
++const archived = async (slug = 'control-lobby') => (await h.db.get('SELECT archived_at FROM rooms WHERE slug = ?', [slug])).archived_at;
++
++t('boot and seed rooms', async () => {
++ h = await boot();
++ token = h.serviceToken(['chat.resource.control'], { sub: 'svc:services' });
++ owner = h.addUser('control-owner', { subject: contracts.ids.newId('user') });
++ other = h.addUser('control-other', { subject: contracts.ids.newId('user') });
++ for (const [slug, who] of [['control-lobby', owner], ['control-theirs', other]]) {
++ await h.db.run('INSERT INTO rooms (slug, name, visibility, owner_id, owner_subject) VALUES (?, ?, ?, ?, ?)', [slug, `Room ${slug}`, 'public', who.id, who.subject_id]);
++ }
++});
++
++t('a dry run applies nothing; archive archives once; a repeat answers the first result', async () => {
++ let r = await send(req('archive', { dry_run: true }));
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual([r.body.state, r.body.result.dry_run], ['done', true]);
++ assert.strictEqual(await archived(), null);
++ const first = req('archive');
++ r = await send(first);
++ assert.ok(contracts.resources.checkControlResult(r.body).valid, JSON.stringify(r.body));
++ assert.deepStrictEqual([r.body.state, r.body.result, r.body.resource], ['done', { archived: true, changed: true }, undefined]);
++ assert.ok(await archived(), 'archived_at is set');
++ assert.deepStrictEqual((await send(first)).body, r.body, 'the same key answers the first result');
++ const conflict = await send({ ...first, dry_run: true });
++ assert.deepStrictEqual([conflict.status, conflict.body.code], [409, 'control.idempotency_conflict']);
++ assert.deepStrictEqual((await send(req('archive'))).body.result, { archived: true, changed: false });
++ const index = await h.http('GET', '/api/v1/resources/?owner=' + owner.subject_id, { token: h.serviceToken(['chat.resource.read']) });
++ assert.ok(!index.body.resources.some((x) => x.id === 'control-lobby'), 'an archived room leaves the index');
++});
++
++t('only the owner Chat holds; other actions and kinds are refused', async () => {
++ let r = await send(req('archive', { owned: { kind: 'chat.room', id: 'control-theirs' } }));
++ assert.deepStrictEqual([r.body.state, r.body.problem.code], ['refused', 'resources.unknown_resource'], 'another person\'s room is not confirmed to exist');
++ assert.strictEqual(await archived('control-theirs'), null);
++ r = await send(req('delete', { owned: { kind: 'chat.room', id: 'control-theirs' } }));
++ assert.strictEqual(r.body.problem.code, 'control.unsupported_action');
++ r = await send(req('archive', { owned: { kind: 'chat.message', id: 'x1' } }));
++ assert.strictEqual(r.body.problem.code, 'resources.unknown_resource');
++ r = await send(req('archive', { on_behalf_of: { type: 'user', id: other.subject_id } }));
++ assert.deepStrictEqual([r.status, r.body.code], [400, 'control.bad_request'], 'a person acts only on what they own');
++ r = await send({ action: 'archive', resource: 'ovrn:chat:prj_01J8ZQ4Y7N3M2K1H0G9F8E7D6C:room/roo_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', project_id: 'prj_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', idempotency_key: 'idem_project_1' });
++ assert.deepStrictEqual([r.status, r.body.code], [400, 'control.bad_request'], 'Chat holds no project resources');
++});
++
++t('first-party only, with chat.resource.control', async () => {
++ let r = await send(req('archive', { owned: { kind: 'chat.room', id: 'control-theirs' } }), { token: h.serviceToken(['chat.resource.read'], { sub: 'svc:services' }) });
++ assert.strictEqual(r.status, 403);
++ r = await send(req('archive'), { token: owner.token });
++ assert.ok([401, 403].includes(r.status), `a person's own token is refused (${r.status})`);
++ r = await send(req('archive'), { token: '' });
++ assert.strictEqual(r.status, 401);
++});
++
++t.run(() => h && h.close());