openvibers/openvibe.chat Commits

Resource index: chat for OpenVibe.Services (chat.resource.read) (#57)

OP OpenVibers committed 10 Oct 2026 · 143e52e371 · 6 files, +188 −1

Plan T13 step 8 phase 2 (ADR-048): Chat's authority resource index for OpenVibe.Services, against `chat.resource.read` (openvibe-contracts 0.129.0, planned).

- `GET /api/v1/resources?project=&kind=&cursor=&limit=` returns a common.resource-list-result@1 page of `chat.room` summaries: rooms that are not archived, the slug as id, named by the room's name, state = its visibility, owner its owner_subject. It has an opaque keyset cursor (Host's `[kind, id]` form), a default of 100 rows and a maximum of 1000. A bad `project` is a 400, and another kind gives an empty page.
- `GET /api/v1/resources/:ovrn` returns one summary, or 404. Rooms are person-owned, so there is no OVRN and every name is a 404.
- Service tokens carrying `chat.resource.read` only, through `server/net/service-auth.js` `guard('chat.resource.read')`, which also refuses requests that came through a public proxy. A person's token is refused even where other routes accept one.
- Summaries carry id, kind, service, project/OVRN where they apply, owner (only a `usr_` subject), name, state and timestamps. Nothing else leaves the table.
- The public vhost 404s `/api/v1/resources`; Services calls 127.0.0.1:4400.

Services starts reading it once a Contracts release makes the capability active and Network grants the `services` principal.

`ov test` (files covering the change): 4 passed (production has 0 rooms today, so the index starts empty).

Changes

ddiff --git a/README.md b/README.md
iindex 1dec868..88efca0 100644
---- a/README.md
++++ b/README.md
@@@ -232,6 +232,12 @@ cutover** (`CHAT_CALLS=1`, `docs/calls-cutover.md`):
### Rooms (openvibe.chat)
++Chat's authority resource index serves `GET /api/v1/resources` (with `project`, `kind`, `cursor`,
++and `limit` filters) and `GET /api/v1/resources/:ovrn` to first-party service tokens carrying
++`chat.resource.read`. It lists active rooms as `chat.room` summaries. Rooms belong to people,
++so they have no project ID or OVRN; the named-resource route always answers 404. The index is
++loopback only and is not forwarded by the public nginx vhosts.
++
Rooms people create on openvibe.chat (roadmap WS-I task 4, `server/rooms/`), beside the global room and
Live's stream and channel rooms: `/api/chat/rooms` (REST), `join_room` / `room_message` on `/ws/chat`,
and the pages `/rooms`, `/r/:slug`, `/r/:slug/settings`. Public rooms: anyone reads, signed-in people
ddiff --git a/STATUS.json b/STATUS.json
iindex 2317adb..2dae737 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -22,7 +22,8 @@
"chat.analysis.read",
"chat.moderation.queue.read",
"chat.sounds.read",
-- "chat.sounds.write"
++ "chat.sounds.write",
++ "chat.resource.read"
],
"capabilitiesUsed": [
"live.chat_context.read",
ddiff --git a/deploy/nginx/openvibe.chat.conf b/deploy/nginx/openvibe.chat.conf
iindex 2d76edd..af6d96f 100644
---- a/deploy/nginx/openvibe.chat.conf
++++ b/deploy/nginx/openvibe.chat.conf
@@@ -75,6 +75,7 @@ server {
# Host-only surfaces (the app refuses them from the proxy too).
location ~* ^/metrics(/|$) { return 404; }
location ~* ^/internal(/|$) { return 404; }
++ location ~* ^/api/v1/resources(/|$) { return 404; }
# Chat's socket.
# A location's proxy_set_header replaces every server-level one, so the client address is set here
ddiff --git a/server/app.js b/server/app.js
iindex dd1f232..8b55829 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -101,6 +101,9 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
app.use('/internal/calls', express.json({ limit: '64kb' }), require('./calls/internal').createInternalRouter());
// Live's read API for the chat tables Chat owns (plan T3; capability chat.moderation.read).
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.
++ 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):
// 503 only when the required check fails. `db` is Chat's own database, which it cannot serve
ddiff --git a/server/registry/resource-index.js b/server/registry/resource-index.js
nnew file mode 100644
iindex 0000000..1dde656
---- /dev/null
++++ b/server/registry/resource-index.js
@@@ -0,0 +1,91 @@
++/**
++ * Chat's authority resource index (ADR-048, plan T13 step 8). Rooms are person-owned,
++ * so their summaries have no project or OVRN. This router is mounted on the loopback API.
++ */
++'use strict';
++
++const express = require('express');
++const contracts = require('openvibe-contracts');
++const db = require('../db/database');
++const serviceAuth = require('../net/service-auth');
++
++const SERVICE = 'chat';
++const ROOM_KIND = 'chat.room';
++const DEFAULT_LIMIT = 100;
++const MAX_LIMIT = 1000;
++const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
++const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++/** The room fields allowed by common.resource-summary@1. */
++function roomSummary(room) {
++ const summary = {
++ id: room.slug,
++ kind: ROOM_KIND,
++ service: SERVICE,
++ ...(USER_SUBJECT_RE.test(String(room.owner_subject || '')) ? { owner: { type: 'user', id: room.owner_subject } } : {}),
++ name: room.name,
++ state: room.visibility,
++ // rooms.created_at is nullable (a default, not a constraint): a row without one reads as the epoch.
++ created_at: new Date(room.created_at ? `${String(room.created_at).replace(' ', 'T')}Z` : 0).toISOString(),
++ };
++ const ovrn = contracts.resources.nameOf(summary);
++ return ovrn ? { ...summary, ovrn } : summary;
++}
++
++/** An opaque [kind, id] keyset position, in the same form as Host's cursor. */
++const encodeCursor = (summary) => Buffer.from(JSON.stringify([summary.kind, summary.id])).toString('base64url');
++function decodeCursor(raw) {
++ let value;
++ try { value = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8')); } catch { return null; }
++ return Array.isArray(value) && value.length === 2 && value[0] === ROOM_KIND && typeof value[1] === 'string' &&
++ encodeCursor({ kind: value[0], id: value[1] }) === raw ? value[1] : null;
++}
++
++/** Unknown kinds and valid project filters match no person-owned rooms. */
++function filtersOf(query) {
++ const project = typeof query.project === 'string' && query.project !== '' ? query.project : null;
++ if (project && !PROJECT_ID_RE.test(project)) return { error: 'project must be a prj_ id' };
++ const kind = typeof query.kind === 'string' && query.kind !== '' ? query.kind : null;
++ let limit = DEFAULT_LIMIT;
++ if (typeof query.limit === 'string' && query.limit !== '') {
++ if (!/^\d+$/.test(query.limit) || Number(query.limit) < 1 || Number(query.limit) > MAX_LIMIT) return { error: `limit must be an integer 1-${MAX_LIMIT}` };
++ limit = Number(query.limit);
++ }
++ let cursor = null;
++ if (typeof query.cursor === 'string' && query.cursor !== '') {
++ cursor = decodeCursor(query.cursor);
++ if (cursor === null) return { error: 'cursor is not one this index issued' };
++ }
++ return { project, kind, limit, cursor };
++}
++
++async function page(req, res, next) {
++ try {
++ const filters = filtersOf(req.query);
++ if (filters.error) return contracts.http.sendProblem(res, 400, 'resources.bad_query', { detail: filters.error });
++ if (filters.project || (filters.kind && filters.kind !== ROOM_KIND)) {
++ return res.set('Cache-Control', 'private, max-age=60').json({ resources: [], next_cursor: null });
++ }
++ const rows = await db.all(`SELECT slug, name, visibility, owner_subject, created_at FROM rooms
++ WHERE archived_at IS NULL AND slug > ? COLLATE "C" ORDER BY slug COLLATE "C" LIMIT ?`,
++ [filters.cursor || '', filters.limit + 1]);
++ const resources = rows.slice(0, filters.limit).map(roomSummary);
++ const next_cursor = rows.length > filters.limit ? encodeCursor(resources[resources.length - 1]) : null;
++ return res.set('Cache-Control', 'private, max-age=60').json({ resources, next_cursor });
++ } catch (err) { return next(err); }
++}
++
++/** No Chat room has an OVRN: a name can never identify a person-owned room. */
++function one(req, res) {
++ return contracts.http.sendProblem(res, 404, 'resources.unknown_resource', { detail: `no resource named ${req.params.ovrn}` });
++}
++
++function router() {
++ const r = express.Router();
++ r.use(serviceAuth.guard('chat.resource.read'));
++ r.get('/', page);
++ r.get('/:ovrn', one);
++ return r;
++}
++
++module.exports = { router, roomSummary, filtersOf, encodeCursor, SERVICE, ROOM_KIND, DEFAULT_LIMIT, MAX_LIMIT };
ddiff --git a/test/resource-index.test.js b/test/resource-index.test.js
nnew file mode 100644
iindex 0000000..334ea77
---- /dev/null
++++ b/test/resource-index.test.js
@@@ -0,0 +1,85 @@
++'use strict';
++/** Chat's person-owned authority resource index (ADR-048, plan T13 step 8). */
++const assert = require('assert');
++const contracts = require('openvibe-contracts');
++const { boot, suite } = require('./helpers');
++
++const t = suite('resource-index');
++let h, token, user;
++const get = (path, opts = {}) => h.http('GET', `/api/v1/resources${path}`, { token, ...opts });
++
++t('boot and seed rooms', async () => {
++ h = await boot();
++ token = h.serviceToken(['chat.resource.read']);
++ user = h.addUser('index-owner', { subject: contracts.ids.newId('user') });
++ for (const [slug, visibility, archived] of [
++ ['index-alpha', 'public', false], ['index-beta', 'private', false],
++ ['index-gamma', 'public', false], ['index-archived', 'public', true],
++ ]) {
++ await h.db.run('INSERT INTO rooms (slug, name, visibility, owner_id, owner_subject, archived_at) VALUES (?, ?, ?, ?, ?, ?)',
++ [slug, `Name ${slug}`, visibility, user.id, user.subject_id, archived ? '2026-10-01 00:00:00' : null]);
++ }
++});
++
++t('page validates and contains only allowed fields', async () => {
++ const response = await get('/');
++ assert.strictEqual(response.status, 200, response.text);
++ const result = contracts.validate('common.resource-list-result@1', response.body);
++ assert.ok(result.valid, JSON.stringify(result.errors));
++ const rooms = response.body.resources.filter((r) => r.id.startsWith('index-'));
++ assert.deepStrictEqual(rooms.map((r) => r.id), ['index-alpha', 'index-beta', 'index-gamma']);
++ assert.deepStrictEqual(rooms.map((r) => r.state), ['public', 'private', 'public']);
++ for (const room of rooms) {
++ assert.deepStrictEqual(Object.keys(room).sort(), ['created_at', 'id', 'kind', 'name', 'owner', 'service', 'state'].sort());
++ assert.deepStrictEqual(room.owner, { type: 'user', id: user.subject_id });
++ assert.strictEqual(contracts.resources.nameOf(room), null);
++ assert.strictEqual(room.kind, 'chat.room');
++ }
++});
++
++t('limit-one cursor walks each active room exactly once', async () => {
++ const expected = (await get('/')).body.resources.map((r) => r.id);
++ const seen = [];
++ let cursor = null;
++ do {
++ const response = await get(`/?limit=1${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`);
++ assert.strictEqual(response.status, 200, response.text);
++ assert.ok(contracts.validate('common.resource-list-result@1', response.body).valid);
++ seen.push(...response.body.resources.map((r) => r.id));
++ cursor = response.body.next_cursor;
++ } while (cursor);
++ assert.deepStrictEqual(seen, expected);
++ assert.strictEqual(new Set(seen).size, seen.length);
++});
++
++t('project and kind filters, and bad queries', async () => {
++ const project = 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';
++ assert.deepStrictEqual((await get(`/?project=${project}`)).body.resources, []);
++ assert.deepStrictEqual((await get('/?kind=other.kind')).body.resources, []);
++ assert.deepStrictEqual((await get('/?kind=chat.room')).body.resources, (await get('/')).body.resources);
++ for (const query of ['project=bad', 'limit=0', 'limit=1001', 'cursor=bad']) {
++ const response = await get(`/?${query}`);
++ assert.strictEqual(response.status, 400, response.text);
++ assert.strictEqual(response.body.code, 'resources.bad_query');
++ }
++});
++
++t('service guard rejects missing, user, insufficient, and proxied tokens', async () => {
++ const absent = await get('/', { token: null });
++ assert.deepStrictEqual([absent.status, absent.body.code], [401, 'token.missing']);
++ const person = await get('/', { token: user.token });
++ assert.strictEqual(person.status, 401);
++ const insufficient = await get('/', { token: h.serviceToken(['chat.message.send']) });
++ assert.deepStrictEqual([insufficient.status, insufficient.body.code], [403, 'capability.denied']);
++ const proxied = await get('/', { headers: { 'X-Forwarded-For': '203.0.113.4' } });
++ assert.deepStrictEqual([proxied.status, proxied.body.code], [403, 'capability.denied']);
++});
++
++t('named lookup always returns 404, including names for existing slugs', async () => {
++ for (const name of ['ovrn:chat:prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3:room/index-alpha', 'index-alpha', 'missing']) {
++ const response = await get(`/${encodeURIComponent(name)}`);
++ assert.deepStrictEqual([response.status, response.body.code], [404, 'resources.unknown_resource']);
++ }
++});
++
++t.run(() => h && h.close());