openvibers/openvibe.chat Commits
Resource index: chat for OpenVibe.Services (chat.resource.read) (#57)
Changes
@@@ -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 andLive'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@@@ -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",@@@ -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@@@ -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 servennew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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());
@@ -232,6 +232,12 @@ cutover** (`CHAT_CALLS=1`, `docs/calls-cutover.md`):
@@ -232,6 +232,12 @@ cutover** (`CHAT_CALLS=1`, `docs/calls-cutover.md`):
### Rooms (openvibe.chat)
### 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
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`,
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
and the pages `/rooms`, `/r/:slug`, `/r/:slug/settings`. Public rooms: anyone reads, signed-in people
@@ -22,7 +22,8 @@
@@ -22,7 +22,8 @@
"chat.analysis.read",
"chat.analysis.read",
"chat.moderation.queue.read",
"chat.moderation.queue.read",
"chat.sounds.read",
"chat.sounds.read",
- "chat.sounds.write"
+ "chat.sounds.write",
+ "chat.resource.read"
],
],
"capabilitiesUsed": [
"capabilitiesUsed": [
"live.chat_context.read",
"live.chat_context.read",
@@ -75,6 +75,7 @@ server {
@@ -75,6 +75,7 @@ server {
# Host-only surfaces (the app refuses them from the proxy too).
# Host-only surfaces (the app refuses them from the proxy too).
location ~* ^/metrics(/|$) { return 404; }
location ~* ^/metrics(/|$) { return 404; }
location ~* ^/internal(/|$) { return 404; }
location ~* ^/internal(/|$) { return 404; }
+ location ~* ^/api/v1/resources(/|$) { return 404; }
# Chat's socket.
# Chat's socket.
# A location's proxy_set_header replaces every server-level one, so the client address is set here
# A location's proxy_set_header replaces every server-level one, so the client address is set here
@@ -101,6 +101,9 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
@@ -101,6 +101,9 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
app.use('/internal/calls', express.json({ limit: '64kb' }), require('./calls/internal').createInternalRouter());
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).
// 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'));
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' }));
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):
// 503 only when the required check fails. `db` is Chat's own database, which it cannot serve
// 503 only when the required check fails. `db` is Chat's own database, which it cannot serve
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -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());