openvibers/openvibe.chat Commits
Agent stream chat for public and unlisted viewers (#519)
Changes
nnew file mode 100644@@@ -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';@@@ -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 }));@@@ -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'));@@@ -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 (rconst 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;--nnew file mode 100644@@@ -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; } };@@@ -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]);@@@ -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) {@@@ -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">↗</a>${bubble ? '<button class="em-x" type="button" data-close aria-label="Close the chat">×</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">↗</a>`}${bubble ? '<button class="em-x" type="button" data-close aria-label="Close the chat">×</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>');@@@ -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');@@@ -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',
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -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';
@@ -8,6 +8,9 @@
@@ -8,6 +8,9 @@
var root = document.getElementById('re');
var root = document.getElementById('re');
if (!root) return;
if (!root) return;
var slug = root.getAttribute('data-room');
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 KEY = 'ov-embed-token';
var thread = document.getElementById('re-thread');
var thread = document.getElementById('re-thread');
var form = document.getElementById('re-form');
var form = document.getElementById('re-form');
@@ -60,7 +63,7 @@
@@ -60,7 +63,7 @@
form.hidden = !(token && can.post);
form.hidden = !(token && can.post);
dock.textContent = '';
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'; }
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.'));
else if (!can.post) dock.appendChild(el('p', 'em-system', 'You can read this room but not write in it.'));
whoEl.textContent = '';
whoEl.textContent = '';
if (token) {
if (token) {
@@ -71,6 +74,11 @@
@@ -71,6 +74,11 @@
}
}
function connect() {
function connect() {
+ if (agentId) {
+ loadAgent();
+ setInterval(catchUp, 2500);
+ return;
+ }
if (!window.WebSocket) return;
if (!window.WebSocket) return;
ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/chat');
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(); };
ws.onopen = function () { delay = 1000; ws.send(JSON.stringify({ type: 'join_room', room: slug, token: token || undefined })); catchUp(); };
@@ -82,10 +90,17 @@
@@ -82,10 +90,17 @@
};
};
ws.onclose = function () { setTimeout(connect, delay); delay = Math.min(delay * 2, 30000); };
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() {
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 () {});
.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 @@
@@ -126,6 +141,14 @@
form.addEventListener('submit', function (e) {
form.addEventListener('submit', function (e) {
e.preventDefault();
e.preventDefault();
var text = input.value.trim();
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;
if (!text || !ws || ws.readyState !== 1) return;
error('');
error('');
ws.send(JSON.stringify({ type: 'room_message', message: text }));
ws.send(JSON.stringify({ type: 'room_message', message: text }));
@@ -349,6 +349,7 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
@@ -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/…
// 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).
// 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/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.
// 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('./chat/play-queue-routes').member);
app.use('/api/chat/rooms', require('./rooms/routes'));
app.use('/api/chat/rooms', require('./rooms/routes'));
@@ -11,6 +11,7 @@
@@ -11,6 +11,7 @@
const express = require('express');
const express = require('express');
const serviceAuth = require('../net/service-auth');
const serviceAuth = require('../net/service-auth');
const room = require('./agent-room');
const room = require('./agent-room');
+const ctx = require('../live-context');
const router = express.Router();
const router = express.Router();
@@ -19,11 +20,22 @@ router.post('/agent-room', serviceAuth.guard('chat.live_bridge.write'), async (r
@@ -19,11 +20,22 @@ router.post('/agent-room', serviceAuth.guard('chat.live_bridge.write'), async (r
const body = req.body || {};
const body = req.body || {};
const id = room.roomIdOf(body.agent_stream_id);
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 (!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 owner = String(body.owner_subject || '').trim() || null;
const name = String(body.name || '').trim().slice(0, 40) || id;
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) {
} catch (err) {
console.error('[Chat] the agent room failed:', err.message);
console.error('[Chat] the agent room failed:', err.message);
res.status(500).json({ error: 'The room could not be made', code: 'agent_room.failed' });
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
@@ -31,4 +43,3 @@ router.post('/agent-room', serviceAuth.guard('chat.live_bridge.write'), async (r
});
});
module.exports = router;
module.exports = router;
-
new file mode 100644
new file mode 100644
@@ -0,0 +1,80 @@
@@ -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; } };
@@ -113,24 +113,35 @@ function guard(capability) {
@@ -113,24 +113,35 @@ function guard(capability) {
/** The Co-pilot room of an agent stream, by its id, or null. */
/** The Co-pilot room of an agent stream, by its id, or null. */
async function roomByAgentId(agentRoomId) {
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
* 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.
* 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);
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 id = await db.tx(async () => {
const r = await db.run(
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);
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 roomId;
});
});
return await db.get('SELECT * FROM rooms WHERE id = ?', [id]);
return await db.get('SELECT * FROM rooms WHERE id = ?', [id]);
@@ -150,7 +150,11 @@ async function access(room, user) {
@@ -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 (!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]);
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 blocked = role === 'blocked';
const member = !!role && !blocked;
const member = !!role && !blocked;
const banned = siteBanned(user);
const banned = siteBanned(user);
@@ -319,8 +323,12 @@ async function editMessage(room, user, messageId, text, { now = Date.now() } = {
@@ -319,8 +323,12 @@ async function editMessage(room, user, messageId, text, { now = Date.now() } = {
return updated;
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);
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 (!user) fail(401, 'rooms.sign_in', 'Sign in to chat');
if (siteBanned(user)) fail(403, 'rooms.banned', 'Your account is banned from chat');
if (siteBanned(user)) fail(403, 'rooms.banned', 'Your account is banned from chat');
if (!a.post) {
if (!a.post) {
@@ -43,7 +43,7 @@ function messageHtml(m) {
@@ -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>`;
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>
return `<!DOCTYPE html>
<html lang="en">
<html lang="en">
<head>
<head>
@@ -55,10 +55,10 @@ function embedHtml(room, members, msgs, { bubble }) {
@@ -55,10 +55,10 @@ function embedHtml(room, members, msgs, { bubble }) {
<script src="${asset('room-embed.js')}" defer></script>
<script src="${asset('room-embed.js')}" defer></script>
</head>
</head>
<body style="--accent:#3b82f6">
<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>
<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>
<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">↗</a>${bubble ? '<button class="em-x" type="button" data-close aria-label="Close the chat">×</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">↗</a>`}${bubble ? '<button class="em-x" type="button" data-close aria-label="Close the chat">×</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>
<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>
<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>
<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 }) {
@@ -76,6 +76,19 @@ function mountRoomEmbedPages(router, { viewerOf, sameSite, config }) {
const base = config.web.baseUrl;
const base = config.web.baseUrl;
const ticketLimit = rateLimit({ windowMs: 60_000, max: 90, standardHeaders: true, legacyHeaders: false });
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) => {
router.get('/embed/room/:slug', async (req, res) => {
const room = await rooms.bySlug(req.params.slug);
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>');
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>');
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
* and the owner or a moderator can close or veto.
* and the owner or a moderator can close or veto.
*/
*/
const assert = require('assert');
const assert = require('assert');
+const crypto = require('node:crypto');
const { ids, serviceAuth } = require('openvibe-contracts');
const { ids, serviceAuth } = require('openvibe-contracts');
const { boot, suite } = require('./helpers');
const { boot, suite } = require('./helpers');
@@ -52,6 +53,39 @@ t('boot and the room', async () => {
@@ -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]);
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 () => {
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, 'What does the flaky test do?', { upvotes: 3 });
await message(ann, 'Please try parseRows instead');
await message(ann, 'Please try parseRows instead');
@@ -119,6 +119,7 @@ t('ratchet: every file that makes an outbound request itself is reviewed', () =>
@@ -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/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/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/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/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/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',
'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',