openvibers/openvibe.chat Commits
Chat: a room's owner archives it from OpenVibe.Services (chat.resource.control, contracts 0.176.0) (#85)
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 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)++);@@@ -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",@@@ -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",@@@ -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):nnew file mode 100644@@@ -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 };@@@ -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 tconst 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 tconst 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'));});@@@ -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 = [nnew file mode 100644@@@ -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());
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 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)
+);
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.0.2",
"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-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",
@@ -1020,9 +1020,9 @@
@@ -1020,9 +1020,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -28,7 +28,7 @@
@@ -28,7 +28,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.0.2",
"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-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",
@@ -103,6 +103,8 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
@@ -103,6 +103,8 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
app.use('/internal/moderation', express.json({ limit: '64kb' }), require('./chat/internal-moderation'));
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
// 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.
// 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.use('/api/v1/resources', require('./registry/resource-index').router());
app.get('/health', (req, res) => res.json({ ok: true, service: 'chat' }));
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):
// Readiness in the openvibe-shared/ready shape (status ready/degraded/not_ready, named checks):
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -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 };
@@ -183,9 +183,14 @@ function sse(pathAndQuery, headers = {}) {
@@ -183,9 +183,14 @@ function sse(pathAndQuery, headers = {}) {
const c = {
const c = {
status: res.statusCode,
status: res.statusCode,
body: () => body,
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) {
async waitFor(pred, ms = 3000) {
const until = Date.now() + ms;
const until = Date.now() + ms;
while (!pred(c)) { if (Date.now() > until) throw new Error(`sse timeout: ${body}`); await h.sleep(20); }
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
@@ -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);
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);
await c.waitFor((x) => x.events().length === 2);
const [created, deleted] = c.events();
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.event_type, 'chat.message.created');
assert.strictEqual(created.event.payload.redacted, true, who);
assert.strictEqual(created.event.payload.redacted, true, who);
assert.deepStrictEqual(created.event.subject, { type: 'chat_message', id: String(msgId) });
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
@@ -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)}`, {
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' })}` },
headers: { Authorization: `Bearer ${h.serviceToken(['events.event.read'], { aud: 'openvibe.events', sub: 'svc:search' })}` },
}).then((r) => r.json());
}).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'));
assert.ok(!JSON.stringify(pull).includes('555-0199'));
});
});
@@ -27,7 +27,7 @@ const TABLES = [
@@ -27,7 +27,7 @@ const TABLES = [
'channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events',
'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',
'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_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',
'network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages',
];
];
const INDEXES = [
const INDEXES = [
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -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());