openvibers/openvibe.chat Commits

Agent stream chat for public and unlisted viewers (#519)

OP OpenVibers committed 3 days ago · bba74c3303 · 10 files, +208 −20

* Keep agent stream chat private when unlisted and embed its room

* Validate agent chat audience against Live on every request

* Reserve the next Chat migration after server commands

* Use the room slug index for agent chat reads

* Review agent room Live access request in SSRF ratchet

Changes

ddiff --git a/migrations/0076_agent_room_visibility.sql b/migrations/0076_agent_room_visibility.sql
nnew file mode 100644
iindex 0000000..2f57850
---- /dev/null
++++ b/migrations/0076_agent_room_visibility.sql
@@@ -0,0 +1,6 @@
++-- phase: expand
++-- An unlisted agent room follows the stream's link secret. Public room lists and
++-- the ordinary room API must not expose it; the agent embed checks this hash.
++ALTER TABLE agent_room_identities ADD COLUMN IF NOT EXISTS link_secret_hash text;
++UPDATE rooms SET slug = lower(slug) WHERE kind = 'agent_stream' AND slug <> lower(slug);
++UPDATE rooms SET visibility = 'private' WHERE kind = 'agent_stream' AND visibility <> 'private';
ddiff --git a/public/web/room-embed.js b/public/web/room-embed.js
iindex e43f55c..f02f7c0 100644
---- a/public/web/room-embed.js
++++ b/public/web/room-embed.js
@@@ -8,6 +8,9 @@
var root = document.getElementById('re');
if (!root) return;
var slug = root.getAttribute('data-room');
++ var agentId = root.getAttribute('data-agent');
++ var secret = root.getAttribute('data-k') || '';
++ var agentBase = agentId ? '/api/chat/agent-rooms/' + encodeURIComponent(agentId) + '?k=' + encodeURIComponent(secret) : null;
var KEY = 'ov-embed-token';
var thread = document.getElementById('re-thread');
var form = document.getElementById('re-form');
@@@ -60,7 +63,7 @@
form.hidden = !(token && can.post);
dock.textContent = '';
if (!token) { dock.appendChild(signinBtn); signinBtn.disabled = !!polling; signinBtn.textContent = polling ? 'Finish signing in in the window…' : 'Sign in with OpenVibe to chat'; }
-- else if (!can.post && !role) { var j = el('button', 're-signin', 'Join this room to chat'); j.type = 'button'; j.addEventListener('click', join); dock.appendChild(j); }
++ else if (!can.post && !role && !agentId) { var j = el('button', 're-signin', 'Join this room to chat'); j.type = 'button'; j.addEventListener('click', join); dock.appendChild(j); }
else if (!can.post) dock.appendChild(el('p', 'em-system', 'You can read this room but not write in it.'));
whoEl.textContent = '';
if (token) {
@@@ -71,6 +74,11 @@
}
function connect() {
++ if (agentId) {
++ loadAgent();
++ setInterval(catchUp, 2500);
++ return;
++ }
if (!window.WebSocket) return;
ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/chat');
ws.onopen = function () { delay = 1000; ws.send(JSON.stringify({ type: 'join_room', room: slug, token: token || undefined })); catchUp(); };
@@@ -82,10 +90,17 @@
};
ws.onclose = function () { setTimeout(connect, delay); delay = Math.min(delay * 2, 30000); };
}
-- function reconnect() { if (ws) { ws.onclose = null; try { ws.close(); } catch (e) { /* closed */ } } connect(); }
++ function reconnect() { if (ws) { ws.onclose = null; try { ws.close(); } catch (e) { /* closed */ } } if (agentId) loadAgent(); else connect(); }
++ function loadAgent() {
++ fetch(agentBase, { credentials: 'omit', headers: token ? { Authorization: 'Bearer ' + token } : {} })
++ .then(function (r) { return r.ok ? r.json() : null; }).then(function (d) {
++ can = d && d.can || {}; role = d && d.role || (token && can.post ? 'member' : null); dockState();
++ }).catch(function () { error('The chat is unavailable right now.'); });
++ }
function catchUp() {
-- if (!latest) return;
-- fetch('/api/chat/rooms/' + encodeURIComponent(slug) + '/messages?after=' + latest, { credentials: 'omit' })
++ if (!latest && !agentId) return;
++ var url = agentId ? agentBase + '&after=' + latest : '/api/chat/rooms/' + encodeURIComponent(slug) + '/messages?after=' + latest;
++ fetch(url, { credentials: 'omit' })
.then(function (r) { return r.ok ? r.json() : null; }).then(function (d) { if (d && d.messages) d.messages.forEach(render); }).catch(function () {});
}
@@@ -126,6 +141,14 @@
form.addEventListener('submit', function (e) {
e.preventDefault();
var text = input.value.trim();
++ if (agentId) {
++ if (!text || !token) return;
++ fetch(agentBase.replace('?k=', '/messages?k='), { method: 'POST', credentials: 'omit', headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' }, body: JSON.stringify({ message: text }) })
++ .then(function (r) { if (r.status === 401) { signOut(); return null; } return r.json().then(function (d) { if (!r.ok) { error(d.error || 'That did not work.'); return null; } return d; }); })
++ .then(function (d) { if (d && d.message) { render(d.message); input.value = ''; grow(); bottom(); } })
++ .catch(function () { error('You seem to be offline.'); });
++ return;
++ }
if (!text || !ws || ws.readyState !== 1) return;
error('');
ws.send(JSON.stringify({ type: 'room_message', message: text }));
ddiff --git a/server/app.js b/server/app.js
iindex bc04a45..5e51106 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -349,6 +349,7 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
// Chat rooms (server/rooms/): before /api/chat, whose /:streamId routes would take /rooms/…
// The people of a Co-pilot room vote on the agent's poll (Agent Streams S5).
app.use('/api/chat/rooms', require('./chat/agent-poll-routes'));
++ app.use('/api/chat/agent-rooms', require('./chat/agent-room-view').router);
// The chat plays queue of a room (Agent Streams S6): the votes and the moderators' hand.
app.use('/api/chat/rooms', require('./chat/play-queue-routes').member);
app.use('/api/chat/rooms', require('./rooms/routes'));
ddiff --git a/server/chat/agent-room-internal.js b/server/chat/agent-room-internal.js
iindex 554cf5b..77c1669 100644
---- a/server/chat/agent-room-internal.js
++++ b/server/chat/agent-room-internal.js
@@@ -11,6 +11,7 @@
const express = require('express');
const serviceAuth = require('../net/service-auth');
const room = require('./agent-room');
++const ctx = require('../live-context');
const router = express.Router();
@@@ -19,11 +20,22 @@ router.post('/agent-room', serviceAuth.guard('chat.live_bridge.write'), async (r
const body = req.body || {};
const id = room.roomIdOf(body.agent_stream_id);
if (!id) return res.status(400).json({ error: 'That is not an agent stream id', code: 'agent_room.bad_stream_id' });
++ if (body.close === true) {
++ const existing = await room.roomByAgentId(id);
++ if (existing) await require('../db/database').run('UPDATE rooms SET archived_at = ov_now() WHERE id = ?', [existing.id]);
++ return res.json({ room: existing ? existing.slug : null, archived: true });
++ }
const owner = String(body.owner_subject || '').trim() || null;
const name = String(body.name || '').trim().slice(0, 40) || id;
-- const made = await room.ensureRoom(id, { name, subject: owner });
-- if (body.close === true) await require('../db/database').run('UPDATE rooms SET archived_at = ov_now() WHERE id = ? AND archived_at IS NULL', [made.id]);
-- res.status(200).json({ room: made.slug, id: made.id, banner: room.BANNER, kind: made.kind, archived: body.close === true });
++ const visibility = ['public', 'unlisted', 'private'].includes(body.visibility) ? body.visibility : 'public';
++ const linkSecretHash = /^[a-f0-9]{64}$/.test(String(body.link_secret_hash || '')) ? body.link_secret_hash : null;
++ if (visibility === 'unlisted' && !linkSecretHash) return res.status(400).json({ error: 'The unlisted link is missing', code: 'agent_room.link' });
++ const existing = await room.roomByAgentId(id);
++ if (visibility === 'private' && !existing) return res.json({ room: null, archived: false });
++ let user = owner ? await ctx.getUserBySubject(owner) : null;
++ if (!user && body.owner_username) user = await ctx.ensureUserByUsername(String(body.owner_username));
++ const made = await room.ensureRoom(id, { name, subject: owner, owner: user, visibility, linkSecretHash });
++ res.status(200).json({ room: made.slug, id: made.id, banner: room.BANNER, kind: made.kind, archived: false });
} catch (err) {
console.error('[Chat] the agent room failed:', err.message);
res.status(500).json({ error: 'The room could not be made', code: 'agent_room.failed' });
@@@ -31,4 +43,3 @@ router.post('/agent-room', serviceAuth.guard('chat.live_bridge.write'), async (r
});
module.exports = router;
--
ddiff --git a/server/chat/agent-room-view.js b/server/chat/agent-room-view.js
nnew file mode 100644
iindex 0000000..606b27d
---- /dev/null
++++ b/server/chat/agent-room-view.js
@@@ -0,0 +1,80 @@
++'use strict';
++/** The unlisted agent room's link gated read and post path. Chat keeps the
++ * stream's link hash, so rotating the Live link immediately revokes old views. */
++const crypto = require('node:crypto');
++const express = require('express');
++const { optionalAuth, requireAuth } = require('../auth/auth');
++const db = require('../db/database');
++const rooms = require('../rooms/rooms');
++const agent = require('./agent-room');
++const config = require('../config');
++
++const router = express.Router();
++const defaultAccessCheck = async (id, secret) => {
++ const url = `${config.live.internalUrl}/api/streams/agent/${encodeURIComponent(id)}/chat-access${secret ? `?k=${encodeURIComponent(secret)}` : ''}`;
++ try {
++ const res = await fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(config.live.requestTimeoutMs), cache: 'no-store' });
++ return res.ok ? (await res.json()).visibility : null;
++ } catch { return null; }
++};
++let accessCheck = defaultAccessCheck;
++
++async function visible(req, res) {
++ const id = agent.roomIdOf(req.params.id);
++ const room = id && await agent.roomByAgentId(id);
++ if (!room) { res.status(404).json({ error: 'No such room' }); return null; }
++ const secret = String(req.query.k || '');
++ const liveVisibility = await accessCheck(req.params.id, secret);
++ if (!['public', 'unlisted'].includes(liveVisibility)) { res.status(404).json({ error: 'No such room' }); return null; }
++ const access = await rooms.access(room, req.user || null);
++ if (liveVisibility === 'unlisted' && !access.read) {
++ const saved = await db.get('SELECT link_secret_hash FROM agent_room_identities WHERE room_id = ?', [room.id]);
++ const hash = crypto.createHash('sha256').update(secret).digest('hex');
++ if (!/^[A-Za-z0-9_-]{22}$/.test(secret) || !saved || !/^[a-f0-9]{64}$/.test(String(saved.link_secret_hash || '')) ||
++ !crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(saved.link_secret_hash))) {
++ res.status(404).json({ error: 'No such room' }); return null;
++ }
++ }
++ if (access.role === 'blocked') { res.status(404).json({ error: 'No such room' }); return null; }
++ res.set('Cache-Control', 'no-store');
++ return { room, access };
++}
++
++function error(res, err) {
++ if (err instanceof rooms.RoomError) return res.status(err.status).json({ error: err.message, code: err.code });
++ console.error('[Agent room]', err && err.message);
++ return res.status(500).json({ error: 'The room could not be read' });
++}
++
++router.get('/:id', optionalAuth, async (req, res) => {
++ try {
++ const found = await visible(req, res); if (!found) return;
++ res.json({ room: found.room.slug, can: { read: true, post: !!req.user && !rooms.siteBanned(req.user) }, role: found.access.role || null });
++ } catch (err) { error(res, err); }
++});
++
++router.get('/:id/messages', optionalAuth, async (req, res) => {
++ try {
++ const found = await visible(req, res); if (!found) return;
++ const messages = await rooms.history(found.room, { after: req.query.after || null, limit: 100 });
++ res.json({ messages });
++ } catch (err) { error(res, err); }
++});
++
++router.post('/:id/messages', requireAuth, async (req, res) => {
++ try {
++ const found = await visible(req, res); if (!found) return;
++ const message = await rooms.post(found.room, req.user, req.body && req.body.message, { agentView: true });
++ res.status(201).json({ message });
++ } catch (err) { error(res, err); }
++});
++
++router.delete('/:id/messages/:message', requireAuth, async (req, res) => {
++ try {
++ const found = await visible(req, res); if (!found) return;
++ const id = await rooms.deleteMessage(found.room, req.user, Number(req.params.message));
++ res.json({ ok: true, id });
++ } catch (err) { error(res, err); }
++});
++
++module.exports = { router, visible, _setAccessCheck: (fn) => { accessCheck = fn || defaultAccessCheck; } };
ddiff --git a/server/chat/agent-room.js b/server/chat/agent-room.js
iindex 4f5fd5d..dde5439 100644
---- a/server/chat/agent-room.js
++++ b/server/chat/agent-room.js
@@@ -113,24 +113,35 @@ function guard(capability) {
/** The Co-pilot room of an agent stream, by its id, or null. */
async function roomByAgentId(agentRoomId) {
-- return await db.get("SELECT * FROM rooms WHERE slug = ? AND kind = 'agent_stream' AND archived_at IS NULL", [String(agentRoomId || '')]) || null;
++ return await db.get("SELECT * FROM rooms WHERE slug = ? AND kind = 'agent_stream' AND archived_at IS NULL", [String(agentRoomId || '').toLowerCase()]) || null;
}
/**
* The room for an agent stream, made if it is not there yet (Live calls this when the stream becomes public
* or unlisted). The banner is the room's purpose, said before anyone types.
*/
--async function ensureRoom(agentRoomId, { owner, name, subject } = {}) {
++async function ensureRoom(agentRoomId, { owner, name, subject, visibility = 'public', linkSecretHash = null } = {}) {
++ if (!owner && subject) owner = await ctx.getUserBySubject(subject);
const existing = await roomByAgentId(agentRoomId);
-- if (existing) return existing;
-- const slug = String(agentRoomId || '');
++ // The stream's current Live audience is checked by the dedicated agent API
++ // on every read and post. A private storage room cannot leak during sync.
++ const audience = 'private';
++ if (existing) {
++ await db.run('UPDATE rooms SET visibility = ?, owner_id = COALESCE(NULLIF(owner_id, 0), ?), owner_subject = COALESCE(owner_subject, ?) WHERE id = ?', [audience, owner ? Number(owner.id) || 0 : 0, subject || null, existing.id]);
++ await db.run('UPDATE agent_room_identities SET link_secret_hash = ? WHERE room_id = ?', [visibility === 'unlisted' ? linkSecretHash : null, existing.id]);
++ if (audience === 'private') await db.run("DELETE FROM room_members WHERE room_id = ? AND role NOT IN ('owner', 'mod', 'blocked')", [existing.id]);
++ if (owner) await db.run("INSERT INTO room_members (room_id, user_id, role) VALUES (?, ?, 'owner') ON CONFLICT (room_id, user_id) DO NOTHING", [existing.id, owner.id]);
++ return await db.get('SELECT * FROM rooms WHERE id = ?', [existing.id]);
++ }
++ const slug = String(agentRoomId || '').toLowerCase();
const id = await db.tx(async () => {
const r = await db.run(
-- "INSERT INTO rooms (slug, name, topic, kind, visibility, owner_id, owner_subject) VALUES (?, ?, ?, 'agent_stream', 'public', ?, ?)",
-- [slug, String(name || slug).slice(0, 40), BANNER, owner ? Number(owner.id) || 0 : 0, subject || null]
++ "INSERT INTO rooms (slug, name, topic, kind, visibility, owner_id, owner_subject) VALUES (?, ?, ?, 'agent_stream', ?, ?, ?)",
++ [slug, String(name || slug).slice(0, 40), BANNER, audience, owner ? Number(owner.id) || 0 : 0, subject || null]
);
const roomId = Number(r.lastInsertRowid);
-- await db.run("INSERT INTO agent_room_identities (room_id) VALUES (?) ON CONFLICT (room_id) DO NOTHING", [roomId]);
++ await db.run('INSERT INTO agent_room_identities (room_id, link_secret_hash) VALUES (?, ?)', [roomId, visibility === 'unlisted' ? linkSecretHash : null]);
++ if (owner) await db.run("INSERT INTO room_members (room_id, user_id, role) VALUES (?, ?, 'owner')", [roomId, owner.id]);
return roomId;
});
return await db.get('SELECT * FROM rooms WHERE id = ?', [id]);
ddiff --git a/server/rooms/rooms.js b/server/rooms/rooms.js
iindex e7395c1..5b6a9a0 100644
---- a/server/rooms/rooms.js
++++ b/server/rooms/rooms.js
@@@ -150,7 +150,11 @@ async function access(room, user) {
if (!staff) return { role: null, kind, read: false, post: false, join: false, talk: false, moderate: false, manage: false };
if (!(await roleOf(room.id, user.id))) await db.run("INSERT INTO room_members (room_id, user_id, role, last_seen_at) VALUES (?, ?, 'member', ov_now()) ON CONFLICT (room_id, user_id) DO NOTHING", [room.id, user.id]);
}
-- const role = user ? await roleOf(room.id, user.id) : null;
++ let role = user ? await roleOf(room.id, user.id) : null;
++ if (kind === 'agent_stream' && user && role !== 'blocked' && role !== 'owner') {
++ const channel = await ctx.getChannelByUserId(room.owner_id);
++ if (channel && await ctx.isChannelModerator(user.id, channel.id)) role = 'mod';
++ }
const blocked = role === 'blocked';
const member = !!role && !blocked;
const banned = siteBanned(user);
@@@ -319,8 +323,12 @@ async function editMessage(room, user, messageId, text, { now = Date.now() } = {
return updated;
}
--async function post(room, user, text, { now = Date.now(), ai = true, timezone = null, reply_to_id = null, files = [] } = {}) {
++async function post(room, user, text, { now = Date.now(), ai = true, timezone = null, reply_to_id = null, files = [], agentView = false } = {}) {
const a = await access(room, user);
++ if (agentView && kindOf(room) === 'agent_stream' && a.role !== 'blocked' && user && !siteBanned(user)) {
++ a.read = true;
++ a.post = true;
++ }
if (!user) fail(401, 'rooms.sign_in', 'Sign in to chat');
if (siteBanned(user)) fail(403, 'rooms.banned', 'Your account is banned from chat');
if (!a.post) {
ddiff --git a/server/web/room-embed-pages.js b/server/web/room-embed-pages.js
iindex dc29beb..ea8c439 100644
---- a/server/web/room-embed-pages.js
++++ b/server/web/room-embed-pages.js
@@@ -43,7 +43,7 @@ function messageHtml(m) {
return `<li class="re-msg${ai ? ' re-ai' : ''}" data-id="${Number(m.id) || 0}"><time datetime="${esc(hhmm(m.created_at))}"></time><b>${esc(m.display_name || m.username || 'someone')}</b>${ai ? '<span class="re-badge">AI</span>' : ''} <span class="re-text">${esc(m.message)}</span></li>`;
}
--function embedHtml(room, members, msgs, { bubble }) {
++function embedHtml(room, members, msgs, { bubble, agentId = null, secret = '' }) {
return `<!DOCTYPE html>
<html lang="en">
<head>
@@@ -55,10 +55,10 @@ function embedHtml(room, members, msgs, { bubble }) {
<script src="${asset('room-embed.js')}" defer></script>
</head>
<body style="--accent:#3b82f6">
--<div class="em re" id="re" data-room="${esc(room.slug)}"${bubble ? ' data-bubble="1"' : ''}>
++<div class="em re" id="re" data-room="${esc(room.slug)}"${agentId ? ` data-agent="${esc(agentId)}" data-k="${esc(secret)}"` : ''}${bubble ? ' data-bubble="1"' : ''}>
<header class="em-head"><span class="em-mark" aria-hidden="true">#</span>
<div><b>${esc(room.name)}</b><span>${members} ${members === 1 ? 'member' : 'members'}${room.topic ? ` · ${esc(room.topic)}` : ''}</span></div>
--<a class="em-x" href="https://openvibe.chat/r/${esc(room.slug)}" target="_blank" rel="noopener" aria-label="Open the room on OpenVibe" title="Open on OpenVibe">&#8599;</a>${bubble ? '<button class="em-x" type="button" data-close aria-label="Close the chat">&times;</button>' : ''}</header>
++${agentId ? '' : `<a class="em-x" href="https://openvibe.chat/r/${esc(room.slug)}" target="_blank" rel="noopener" aria-label="Open the room on OpenVibe" title="Open on OpenVibe">&#8599;</a>`}${bubble ? '<button class="em-x" type="button" data-close aria-label="Close the chat">&times;</button>' : ''}</header>
<ol class="em-thread re-thread" id="re-thread" aria-live="polite" aria-label="Messages">${msgs.map(messageHtml).join('') || '<li class="em-system" id="re-empty">No messages yet. Say hello.</li>'}</ol>
<p class="em-error" role="alert" hidden></p>
<form class="em-composer" id="re-form" hidden><label class="em-sr" for="re-input" hidden>Your message</label>
@@@ -76,6 +76,19 @@ function mountRoomEmbedPages(router, { viewerOf, sameSite, config }) {
const base = config.web.baseUrl;
const ticketLimit = rateLimit({ windowMs: 60_000, max: 90, standardHeaders: true, legacyHeaders: false });
++ router.get('/embed/agent/:id', async (req, res) => {
++ const viewer = await viewerOf(req);
++ req.user = viewer && viewer.kind === 'user' ? viewer.user : null;
++ const found = await require('../chat/agent-room-view').visible(req, res);
++ if (!found) return;
++ const messages = await rooms.history(found.room, { limit: 50 });
++ res.set('Content-Security-Policy', [
++ "default-src 'self'", "script-src 'self'", "style-src 'self' 'unsafe-inline'", "img-src 'self' data: https:",
++ `connect-src 'self' ${base.replace(/^http/, 'ws')}`, "frame-ancestors https://openvibe.live", "base-uri 'self'", "form-action 'self'", "object-src 'none'",
++ ].join('; ')).set('Cache-Control', 'no-store').set('Referrer-Policy', 'no-referrer').set('X-Robots-Tag', 'noindex').type('html')
++ .send(embedHtml(found.room, (await rooms.publicRoom(found.room)).members, messages, { bubble: false, agentId: req.params.id, secret: req.query.k || '' }));
++ });
++
router.get('/embed/room/:slug', async (req, res) => {
const room = await rooms.bySlug(req.params.slug);
if (!(await embeddable(room))) return res.status(404).set('Cache-Control', 'no-store').type('html').send('<!DOCTYPE html><meta charset="utf-8"><title>Room not found</title><p style="font-family:system-ui;padding:16px">This room is not available here.</p>');
ddiff --git a/test/agent-copilot.test.js b/test/agent-copilot.test.js
iindex 1771a9d..6d11828 100644
---- a/test/agent-copilot.test.js
++++ b/test/agent-copilot.test.js
@@@ -9,6 +9,7 @@
* and the owner or a moderator can close or veto.
*/
const assert = require('assert');
++const crypto = require('node:crypto');
const { ids, serviceAuth } = require('openvibe-contracts');
const { boot, suite } = require('./helpers');
@@@ -52,6 +53,39 @@ t('boot and the room', async () => {
await db.run('INSERT INTO room_member_timeouts (room_id, user_id, until_at) VALUES (?, ?, now() + interval \'1 hour\')', [room.id, muted.id]);
});
++t('an unlisted room follows its link, and a public room opens without one', async () => {
++ const agentRoom = require('../server/chat/agent-room');
++ const id = ids.newId('ags');
++ const first = 'abcdefghijklmnopqrstuv';
++ const second = 'zyxwvutsrqponmlkjihgfed';
++ const hash = (s) => crypto.createHash('sha256').update(s).digest('hex');
++ const view = require('../server/chat/agent-room-view');
++ let liveVisibility = 'unlisted', liveSecret = first;
++ view._setAccessCheck(async (_id, key) => liveVisibility === 'public' || (liveVisibility === 'unlisted' && key === liveSecret) ? liveVisibility : null);
++ try {
++ let hidden = await agentRoom.ensureRoom(`agent:${id}`, { name: 'A private link', subject: ann.subject_id, visibility: 'unlisted', linkSecretHash: hash(first) });
++ assert.strictEqual(hidden.visibility, 'private');
++ assert.strictEqual(hidden.owner_id, ann.id);
++ assert.strictEqual((await h.http('GET', `/api/chat/rooms/${hidden.slug}`)).status, 404);
++ assert.strictEqual((await h.http('GET', `/api/chat/agent-rooms/${id}`)).status, 404);
++ assert.strictEqual((await h.http('GET', `/api/chat/agent-rooms/${id}?k=${first}`)).status, 200);
++ assert.strictEqual((await h.http('GET', `/embed/agent/${id}?k=${first}`)).status, 200);
++ const line = await h.http('POST', `/api/chat/agent-rooms/${id}/messages?k=${first}`, { token: young.token, body: { message: 'The checkout looks good.' } });
++ assert.strictEqual(line.status, 201, line.text);
++ assert.strictEqual((await h.http('GET', `/api/chat/agent-rooms/${id}/messages?k=${first}`)).body.messages.at(-1).message, 'The checkout looks good.');
++ hidden = await agentRoom.ensureRoom(`agent:${id}`, { subject: ann.subject_id, visibility: 'unlisted', linkSecretHash: hash(second) });
++ liveSecret = second;
++ assert.strictEqual((await h.http('GET', `/api/chat/agent-rooms/${id}?k=${first}`)).status, 404);
++ assert.strictEqual((await h.http('GET', `/api/chat/agent-rooms/${id}?k=${second}`)).status, 200);
++ await agentRoom.ensureRoom(`agent:${id}`, { subject: ann.subject_id, visibility: 'public' });
++ liveVisibility = 'public';
++ assert.strictEqual((await h.http('GET', `/api/chat/rooms/${hidden.slug}`)).status, 404, 'the storage room stays private during visibility changes');
++ assert.strictEqual((await h.http('GET', `/api/chat/agent-rooms/${id}`)).status, 200);
++ liveVisibility = 'private';
++ assert.strictEqual((await h.http('GET', `/api/chat/agent-rooms/${id}`)).status, 404);
++ } finally { view._setAccessCheck(null); }
++});
++
t('the digest: who is left out, domains, and the caps', async () => {
await message(ann, 'What does the flaky test do?', { upvotes: 3 });
await message(ann, 'Please try parseRows instead');
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 979a39b..7dfde22 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -119,6 +119,7 @@ t('ratchet: every file that makes an outbound request itself is reviewed', () =>
'server/rooms/community-forum.js': 'Community API (configured internal URL), fixed space/thread endpoints; slugs and ids are validated and request paths are not user URLs',
'server/net/service-auth.js': 'Network JWKS (configured)',
'server/chat/account-data.js': 'Network internal API (configured), fixed paths: export parts and deletion confirmations',
++ 'server/chat/agent-room-view.js': 'Live internal API (configured), fixed chat-access path with an encoded stream id and optional link key; configured timeout (4 s default), fail-closed',
'server/prefs/network-modules.js': 'Network user modules (configured)',
'server/presence.js': 'Network internal presence (configured internal URL), fixed /internal/presence path, the subject list in the query, a service token, 1.5 s, held 30 s',
'server/net/consent.js': 'Network internal consent check (configured internal URL), fixed /internal/consent/check path, up to 500 subject ids in the body (never a URL), a service token, 5 s, fail-closed',