openvibers/openvibe.chat Commits

Rooms: call and system kinds, the full role set, Community attachments; openvibe.chat: call rooms, the DM inbox, working with Live down (roadmap WS-I tasks 4 and 8)

OP OpenVibers committed 26 Sept 2026 · 66724bde37 · 20 files, +1633 −98

Room model (server/rooms/rooms.js). Kinds community, call (the room a call happens in: its text chat and the voice channel room-<slug> on /ws/call; anyone starts one) and system (announcements by chat staff, read-only for people; staff only). Roles owner, mod, speaker, participant, member, viewer, blocked, each kind with its own set (community: owner/mod/member/viewer; call: owner/mod/speaker/participant/viewer; system: owner/mod/viewer); access() decides read, post, call join, talk, moderate and manage in one place (README table). Joining gives the kind's role (member; the call room's join_role, participant by default; viewer). Mods are the owner's (or chat staff's) to appoint; roles outside the kind are refused (rooms.role_kind); speaker changes are logged like mod and block changes. Unread stays per room; room_members.last_seen_at (read or post) for moderators. Private rooms answer join and post exactly like a missing room (404; join used to say 403 "invite-only").

Call rooms (server/calls/call-server.js): the channel room-<slug> is made on first use and kept; it stays out of Live's voice-channel list. Readers may join; owner, mods and speakers talk; participants, viewers and people without a role (anonymous too, in public rooms) join listen-only (force-muted, camera forced off, their unmute ignored, a moderator cannot lift it). Room owner and mods moderate the call; a ban in the call is the room's block. Role and room changes reach a running call at once (applyRoomAccess: room-role, force-mute state, peer-updated; dropped when they may no longer read) and /ws/chat followers (room_access, or room_left). GET /api/chat/ice-servers (server/net/turn.js, Live's turn.js logic: STUN, TURN with TURN_URL + TURN_AUTH_SECRET or a static pair).

Attachments: POST /api/chat/rooms/:slug/attachments { service: community, resource: <space>, title } (201, then 200: idempotent), DELETE .../attachments/community/:space (idempotent), GET. Only a person who manages the room, with their own Network token (API tokens refused); detach also by whoever attached. OpenVibe.Community keeps the link and checks the space; no service grant or new capability. Shown only in room settings (managers), never as a public claim.

openvibe.chat: rooms page with kinds (and a staff-only system option), room pages per kind (announcements read-only, viewers read-only, "you are a ..."), an audio call client for call rooms (public/web/call.js, peer to peer over /ws/call, rejoins when a moderator changes your role; shown with CHAT_CALLS=1), room settings with the kind's roles, join role, last seen and attachments with Detach. DM inbox: unread counts in the list and in the navigation (Messages (n), Rooms (n)), refreshed every 15 s with JavaScript; Block on a 1:1 conversation (closes it both ways, composer gone), People you blocked with Unblock; a guessed conversation id is the same 404. Working with Live down: nothing waits on Live (Network-token sign-in from the projection; cosmetics waited for at most 1.5 s in history and sends); the only openvibe.live URLs are profile links.

Tests: room-kinds (the whole matrix per kind, public and private, anonymous, staff, a site ban; kind roles and mods; call rooms over /ws/call: who is in, who talks, live role changes, ban as block, private drops guests; attachments; the site; chat sockets; ICE), live-down (every page, the forms, the core APIs and the socket with Live refusing and with Live hanging, budgets; no page loads anything from openvibe.live), dm-inbox (participants only, unread counts, block both ways, unblock, groups), rooms (private join is a 404). The stub Live can hang (test/helpers.js).

Changes

ddiff --git a/.env.example b/.env.example
iindex 9bbba40..543fd19 100644
---- a/.env.example
++++ b/.env.example
@@@ -89,6 +89,15 @@ SOUNDS_PATH=./data/sounds
#CHAT_CALLS=1
# A direct call nobody answers is missed after this long (ms); the caller hears "no answer".
#CALL_RING_TIMEOUT_MS=45000
++# ICE for calls (GET /api/chat/ice-servers; openvibe.chat's call rooms): STUN always, TURN when set.
++# With TURN_AUTH_SECRET (coturn use-auth-secret, the same value as Live's) credentials expire after an
++# hour; otherwise the static pair. Production: the same TURN_URL / TURN_AUTH_SECRET as live.env.
++#TURN_URL=turn:turn.openvibe.live
++#TURN_AUTH_SECRET=
++#TURN_USERNAME=
++#TURN_CREDENTIAL=
++# Where a room's Community attachments link (room settings page).
++#OV_COMMUNITY_URL=https://openvibe.community
# Optional diagnostics
#DEBUG_DM_DELIVERY=1
ddiff --git a/README.md b/README.md
iindex 7fe4491..cc58287 100644
---- a/README.md
++++ b/README.md
@@@ -179,6 +179,69 @@ cutover** (`CHAT_CALLS=1`, `docs/calls-cutover.md`):
channel's occupancy is a `channel` / `stream` session, `active` while anyone is in, `ended` when it
empties or closes. A restart closes what was open (reason `restart`).
++### Rooms (openvibe.chat)
++
++Rooms people create on openvibe.chat (roadmap WS-I task 4, `server/rooms/`), beside the global room and
++Live's stream and channel rooms: `/api/chat/rooms` (REST), `join_room` / `room_message` on `/ws/chat`,
++and the pages `/rooms`, `/r/:slug`, `/r/:slug/settings`. Public rooms: anyone reads, signed-in people
++join. Private rooms: members only; to anyone else they answer exactly like a room that does not exist
++(read, join, post, members, attachments, the call). Unread counts per room (`last_read_id`) and
++`last_seen_at` per member (moderators see it).
++
++Kinds: **community** (anyone starts one), **call** (the room a call happens in: its text chat, and the
++voice/video channel `room-<slug>` on `/ws/call`; anyone starts one) and **system** (announcements
++written by chat staff, read-only for people; only chat staff create them). Roles, and what they may do
++(`rooms.access()` is the one place this is decided):
++
++| Kind | Role | Read | Post | Join the call | Talk | Moderate | Manage |
++| --- | --- | --- | --- | --- | --- | --- | --- |
++| community | owner / mod / member / viewer | all | owner, mod, member | – | – | owner, mod | owner |
++| call | owner / mod / speaker / participant / viewer | all | owner, mod, speaker, participant | all | owner, mod, speaker | owner, mod | owner |
++| system | owner / mod / viewer | all | owner, mod | – | – | owner, mod | owner |
++| any | blocked | – | – | – | – | – | – |
++| any | no role | public rooms only | – | public call rooms (listening) | – | – | – |
++
++Chat staff (`staff.moderation.chat`) read, moderate and manage every room, post in system rooms and talk in
++calls; a site ban stops posting and talking. Joining gives the kind's role: member, the call room's
++`join_role` (participant by default; the owner may pick speaker or viewer) or viewer. The owner (and chat
++staff) appoint mods; owners and mods set every other role; roles outside the room's kind are refused
++(`rooms.role_kind`). Role changes on someone else are logged (`room_block`, `room_unblock`,
++`room_mod_add|remove`, `room_speaker_add|remove`) through the moderation log. A change reaches open sockets at
++once: `/ws/chat` followers get `room_access { role, can }` (or `room_left`), and a running call follows the
++room (below).
++
++**Call rooms.** The call server makes the channel `room-<slug>` on first use and keeps it; it is not in Live's
++voice-channel list. Who may read the room may join its call; owner, mods and speakers talk; participants,
++viewers and people without a role (anonymous too, in public rooms) join listen-only: force-muted with the
++camera forced off, their own unmute ignored, and a moderator cannot lift it (make them a speaker instead).
++Peers see `roomRole` / `canTalk` on each participant. Promoting, demoting or blocking someone sends them
++`room-role` and the force-mute state, or drops them; a ban inside the call is also the room's block; making
++the room private drops everyone who is not a member. Media is peer to peer, so listen-only is enforced by
++clients honouring force-mute, as every force-mute here. openvibe.chat's room page carries an audio call
++client (`public/web/call.js`), shown when `CHAT_CALLS=1`; ICE comes from `GET /api/chat/ice-servers`
++(`server/net/turn.js`, Live's `turn.js` logic: STUN, plus TURN when `TURN_URL` is set with
++`TURN_AUTH_SECRET` or `TURN_USERNAME`/`TURN_CREDENTIAL`).
++
++**Attached to a Community space.** `POST /api/chat/rooms/:slug/attachments { service: "community",
++resource: <space slug>, title? }` (201 created, 200 already there), `DELETE
++/api/chat/rooms/:slug/attachments/community/:space` (idempotent), `GET …/attachments`. Only a person who
++manages the room (owner or chat staff), with their own Network token, attaches it (API tokens are refused);
++the room's managers or whoever attached it detach. Community keeps the link on the space and checks its own
++side (the space's owner or staff), so one person holding both ends makes the link, and no service grant is
++involved. Chat shows attachments only to the room's managers (room settings, with Detach), never as a public
++claim on the room page.
++
++**openvibe.chat pages.** `/` global chat, `/rooms` and `/r/:slug` (above), `/messages` the DM inbox
++(conversations with unread counts, refreshed every 15 s with JavaScript; who you blocked, with Unblock),
++`/messages/:id` a conversation (participants only; a guessed id is the same 404 as a missing one; Block
++the other person of a 1:1 conversation, which closes it both ways), `/settings`, `/updates`. The navigation
++counts unread messages and room messages. Everything is server-rendered and works without JavaScript.
++
++**Working with Live down.** Nothing on openvibe.chat waits on Live: people sign in from their Network token
++and the `ctx_users` projection, pages read Chat's own tables, cosmetics are waited for at most 1.5 s, and
++the only openvibe.live URLs are profile links. `test/live-down.test.js` renders every page and uses the
++forms, the core APIs and the socket with Live refusing connections and with Live hanging.
++
### VIP member badges
A member's messages in a creator's room (stream or offline channel chat) carry that creator's VIP
ddiff --git a/public/web/call.js b/public/web/call.js
nnew file mode 100644
iindex 0000000..fcb88bc
---- /dev/null
++++ b/public/web/call.js
@@@ -0,0 +1,191 @@
++/* openvibe.chat call rooms: the room's voice call in the page. Audio only, peer to peer (a full mesh, as
++ Live's call client), signalled over /ws/call?channelId=room-<slug> with the protocol of
++ server/calls/call-server.js: the newcomer offers to everyone already in, the others answer. The room's
++ roles decide who talks (the server force-mutes listeners; this client never sends a microphone it may
++ not use). Names are set with textContent. The room's chat works without this file. */
++(function () {
++ 'use strict';
++ var cfg = (window.__OV_PAGE && window.__OV_PAGE.chat) || null;
++ if (!cfg || !cfg.call || !cfg.call.join || !window.RTCPeerConnection || !window.WebSocket) return;
++ var $ = function (id) { return document.getElementById(id); };
++ var joinBtn = $('oc-call-join'), muteBtn = $('oc-call-mute'), leaveBtn = $('oc-call-leave');
++ var statusEl = $('oc-call-status'), peopleEl = $('oc-call-people'), countEl = $('oc-call-count'), howEl = $('oc-call-how');
++ if (!joinBtn) return;
++
++ var canTalk = !!cfg.call.talk;
++ var ws = null, myPeerId = null, localStream = null, muted = false, forceMuted = !canTalk, joined = false, connecting = false;
++ var attempt = 0; // each join; a teardown makes an attempt still starting stale
++ var iceServers = [{ urls: 'stun:stun.l.google.com:19302' }];
++ var peers = new Map(); // peerId → { pc, audio, pending: [candidates] }
++ var people = new Map(); // peerId → participant info from the server
++
++ function status(text) { if (statusEl) statusEl.textContent = text || ''; }
++ function send(o) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(o)); }
++ function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
++
++ function render() {
++ if (!peopleEl) return;
++ peopleEl.textContent = '';
++ if (!people.size) { peopleEl.appendChild(el('li', 'oc-muted', 'Nobody is in the call yet.')); }
++ people.forEach(function (p, id) {
++ var li = el('li', null, (p.displayName || p.username || p.anonId || 'Guest') + (id === myPeerId ? ' (you)' : ''));
++ var state = p.canTalk === false || p.forceMuted ? 'listening' : p.muted ? 'muted' : '';
++ if (state) { li.appendChild(document.createTextNode(' ')); li.appendChild(el('span', 'oc-muted', '(' + state + ')')); }
++ peopleEl.appendChild(li);
++ });
++ if (countEl) countEl.textContent = people.size ? '· ' + people.size + ' in' : '';
++ }
++ function setButtons() {
++ joinBtn.hidden = joined;
++ joinBtn.disabled = connecting;
++ leaveBtn.hidden = !joined;
++ muteBtn.hidden = !joined || !localStream;
++ muteBtn.disabled = forceMuted;
++ muteBtn.textContent = muted || forceMuted ? 'Unmute' : 'Mute';
++ muteBtn.setAttribute('aria-pressed', muted || forceMuted ? 'true' : 'false');
++ }
++ function applyMute() {
++ if (localStream) localStream.getAudioTracks().forEach(function (t) { t.enabled = !(muted || forceMuted); });
++ setButtons();
++ }
++
++ function closePeer(id) {
++ var p = peers.get(id);
++ if (!p) return;
++ try { p.pc.close(); } catch (e) { /* */ }
++ if (p.audio) { p.audio.srcObject = null; p.audio.remove(); }
++ peers.delete(id);
++ }
++ function createPeer(id, initiator) {
++ closePeer(id);
++ var pc = new RTCPeerConnection({ iceServers: iceServers });
++ var p = { pc: pc, audio: null, pending: [] };
++ peers.set(id, p);
++ if (localStream) localStream.getTracks().forEach(function (t) { pc.addTrack(t, localStream); });
++ else if (initiator) pc.addTransceiver('audio', { direction: 'recvonly' });
++ pc.onicecandidate = function (e) { if (e.candidate) send({ type: 'ice-candidate', targetPeerId: id, candidate: e.candidate.toJSON ? e.candidate.toJSON() : e.candidate }); };
++ pc.ontrack = function (e) {
++ if (!p.audio) { p.audio = document.createElement('audio'); p.audio.autoplay = true; p.audio.hidden = true; document.body.appendChild(p.audio); }
++ p.audio.srcObject = (e.streams && e.streams[0]) || new MediaStream([e.track]);
++ };
++ pc.onconnectionstatechange = function () { if (pc.connectionState === 'failed') status('The connection to someone in the call failed. Leave and join again to retry.'); };
++ if (initiator) {
++ pc.createOffer().then(function (o) { return pc.setLocalDescription(o); })
++ .then(function () { send({ type: 'offer', targetPeerId: id, sdp: { type: pc.localDescription.type, sdp: pc.localDescription.sdp } }); })
++ .catch(function () { status('Could not start the call with someone.'); });
++ }
++ return p;
++ }
++ function flush(p) { var c; while ((c = p.pending.shift())) p.pc.addIceCandidate(c).catch(function () {}); }
++
++ function onMessage(m) {
++ switch (m.type) {
++ case 'welcome':
++ myPeerId = m.peerId; joined = true; connecting = false;
++ people.clear();
++ (m.participants || []).forEach(function (p) { people.set(p.peerId, p); });
++ if (typeof m.canTalk === 'boolean' && m.canTalk !== canTalk) { rejoin(m.canTalk); return; }
++ (m.participants || []).forEach(function (p) { if (p.peerId !== myPeerId) createPeer(p.peerId, true); });
++ status(canTalk ? 'You are in the call.' : 'You are listening.');
++ render(); applyMute();
++ break;
++ case 'peer-joined':
++ people.set(m.peerId, m); createPeer(m.peerId, false); render();
++ break;
++ case 'peer-left':
++ people.delete(m.peerId); closePeer(m.peerId); render();
++ break;
++ case 'peer-updated':
++ people.set(m.peerId, Object.assign(people.get(m.peerId) || {}, m)); render();
++ break;
++ case 'peer-muted': case 'peer-force-muted': {
++ var who = people.get(m.peerId);
++ if (who) { if (m.type === 'peer-muted') who.muted = !!m.muted; else who.forceMuted = !!m.forceMuted; render(); }
++ break;
++ }
++ case 'offer': {
++ var p = peers.get(m.fromPeerId) || createPeer(m.fromPeerId, false);
++ p.pc.setRemoteDescription(m.sdp)
++ .then(function () { flush(p); return p.pc.createAnswer(); })
++ .then(function (a) { return p.pc.setLocalDescription(a); })
++ .then(function () { send({ type: 'answer', targetPeerId: m.fromPeerId, sdp: { type: p.pc.localDescription.type, sdp: p.pc.localDescription.sdp } }); })
++ .catch(function () { status('Could not connect to someone in the call.'); });
++ break;
++ }
++ case 'answer': {
++ var q = peers.get(m.fromPeerId);
++ if (q) q.pc.setRemoteDescription(m.sdp).then(function () { flush(q); }).catch(function () {});
++ break;
++ }
++ case 'ice-candidate': {
++ var r = peers.get(m.fromPeerId);
++ if (!r || !m.candidate) break;
++ if (r.pc.remoteDescription) r.pc.addIceCandidate(m.candidate).catch(function () {});
++ else r.pending.push(m.candidate);
++ break;
++ }
++ case 'force-muted':
++ forceMuted = !!m.forceMuted; applyMute();
++ break;
++ case 'room-role':
++ // A moderator changed your role: a new speaker needs the microphone, a new listener gives it up.
++ if (typeof m.canTalk === 'boolean' && m.canTalk !== canTalk) rejoin(m.canTalk);
++ break;
++ case 'kicked': teardown('You were removed from the call.'); break;
++ case 'banned': teardown('You were banned from this call.'); break;
++ case 'call-ended': teardown('The call ended.'); break;
++ case 'replaced': teardown('You joined this call from another tab.'); break;
++ case 'error': teardown(m.message || 'The call is not available.'); break;
++ default: break;
++ }
++ }
++
++ function teardown(text) {
++ attempt++;
++ connecting = false;
++ peers.forEach(function (p, id) { closePeer(id); });
++ people.clear();
++ if (ws) { try { ws.close(); } catch (e) { /* */ } ws = null; }
++ if (localStream) { localStream.getTracks().forEach(function (t) { t.stop(); }); localStream = null; }
++ joined = false; myPeerId = null;
++ render(); setButtons();
++ if (text != null) status(text);
++ }
++ function rejoin(talk) {
++ canTalk = talk; forceMuted = !talk;
++ if (howEl) howEl.textContent = talk ? 'You can talk in this call.' : 'You are listening. The owner or a moderator can make you a speaker.';
++ teardown(talk ? 'You can talk now. Joining with your microphone…' : 'You are a listener now. Rejoining…');
++ join();
++ }
++
++ function join() {
++ var mine = ++attempt;
++ connecting = true; setButtons();
++ status('Joining…');
++ var ice = fetch('/api/chat/ice-servers', { credentials: 'same-origin' })
++ .then(function (r) { return r.ok ? r.json() : null; })
++ .then(function (d) { if (d && Array.isArray(d.iceServers) && d.iceServers.length) iceServers = d.iceServers; })
++ .catch(function () { /* STUN only */ });
++ var mic = canTalk && navigator.mediaDevices && navigator.mediaDevices.getUserMedia
++ ? navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true }, video: false })
++ .then(function (s) { if (mine === attempt) localStream = s; else s.getTracks().forEach(function (t) { t.stop(); }); }, function () { status('No microphone: you will listen until you allow it and join again.'); })
++ : Promise.resolve();
++ Promise.all([ice, mic]).then(function () {
++ if (mine !== attempt) return;
++ ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/call?channelId=' + encodeURIComponent(cfg.call.channel));
++ ws.onmessage = function (ev) { var m; try { m = JSON.parse(ev.data); } catch (e) { return; } onMessage(m); };
++ var sock = ws;
++ ws.onclose = function () { if (sock === ws && mine === attempt) teardown(joined ? 'The call was disconnected. Join again to reconnect.' : 'Could not reach the call.'); };
++ });
++ }
++
++ joinBtn.hidden = false;
++ joinBtn.addEventListener('click', join);
++ leaveBtn.addEventListener('click', function () { teardown('You left the call.'); });
++ muteBtn.addEventListener('click', function () {
++ if (forceMuted) return;
++ muted = !muted; applyMute();
++ send({ type: 'mute', muted: muted });
++ });
++ window.addEventListener('pagehide', function () { if (joined) teardown(null); });
++})();
ddiff --git a/public/web/chat.css b/public/web/chat.css
iindex 1049663..0dd4087 100644
---- a/public/web/chat.css
++++ b/public/web/chat.css
@@@ -91,3 +91,22 @@ button[disabled] { opacity: .6; cursor: default; }
.oc-members li { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; background: var(--oc-panel); border: 1px solid var(--oc-line); border-radius: 10px; padding: 8px 12px; }
.oc-members form { margin-left: auto; }
h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
++/* Room kinds, calls, the inbox */
++.oc-kind-call { color: var(--oc-ok); border-color: rgba(52, 211, 153, .45); }
++.oc-kind-system { color: #fbbf24; border-color: rgba(251, 191, 36, .45); }
++.oc-call { margin: 12px 0; padding: 10px 14px; background: var(--oc-panel); border: 1px solid var(--oc-line); border-radius: 12px; }
++.oc-call h2 { margin: 0 0 4px; font-size: 1.05rem; }
++.oc-call-people { list-style: none; margin: 6px 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px 14px; }
++.oc-call-controls { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0 0; }
++.oc-call-controls button[hidden] { display: none; }
++.oc-call-status:empty { display: none; }
++.oc-tools { display: block; margin-top: 8px; }
++.oc-blocked { margin-top: 20px; }
++.oc-blocked summary { cursor: pointer; color: var(--oc-muted); }
++.oc-blocked .oc-members { margin-top: 8px; }
++.oc-members li form.oc-inline { margin-left: auto; }
++.oc-members select { max-width: 100%; }
++@media (max-width: 560px) {
++ .oc-members form { margin-left: 0; width: 100%; display: flex; flex-wrap: wrap; gap: 6px; }
++ .oc-new select { flex: 1 1 140px; }
++}
ddiff --git a/public/web/chat.js b/public/web/chat.js
iindex 279b4f6..db76500 100644
---- a/public/web/chat.js
++++ b/public/web/chat.js
@@@ -1,6 +1,6 @@
--/* openvibe.chat client: the live global feed over /ws/chat and a quicker composer; DMs refresh every few
-- seconds. Pages work without this file (server-rendered, forms post); it only adds liveness. User text
-- is always set with textContent, never parsed as HTML. */
++/* openvibe.chat client: the live global feed over /ws/chat and a quicker composer; DMs and the inbox
++ refresh every few seconds. Pages work without this file (server-rendered, forms post); it only adds
++ liveness. User text is always set with textContent, never parsed as HTML. */
(function () {
'use strict';
var cfg = (window.__OV_PAGE && window.__OV_PAGE.chat) || null;
@@@ -211,6 +211,9 @@
showError(m.message);
} else if (m.type === 'room_left') {
showError('You can no longer read this room.'); setTimeout(function () { location.href = '/rooms'; }, 1500);
++ } else if (m.type === 'room_access' && m.room === cfg.room && m.can) {
++ // Your role changed (a moderator made you a member, a speaker, a viewer…): the page shows what you may do now.
++ if (!!m.can.post !== !!cfg.post || (m.role || null) !== (cfg.role || null)) { showError('Your role in this room changed.'); setTimeout(function () { location.reload(); }, 1200); }
} else if (m.type === 'auth_revoked') {
showError('You were signed out. Sign in again to keep chatting.');
}
@@@ -238,6 +241,42 @@
});
}
++ // ── The inbox: conversations and unread counts, refreshed while the page is open ──
++ function convItem(c, me) {
++ var li = el('li', 'oc-conv' + (c.unread_count ? ' oc-unread' : ''));
++ var a = el('a'); a.href = '/messages/' + encodeURIComponent(c.id);
++ var others = (c.participants || []).filter(function (p) { return p.id !== me; });
++ a.appendChild(el('strong', null, c.name || others.map(function (p) { return p.display_name || p.username; }).join(', ') || 'Just you'));
++ if (c.unread_count) { a.appendChild(document.createTextNode(' ')); a.appendChild(el('span', 'oc-badge', c.unread_count + ' new')); }
++ a.appendChild(el('span', 'oc-muted oc-last', c.last_message ? String(c.last_message).slice(0, 120) : 'No messages yet'));
++ li.appendChild(a);
++ if (c.last_message_at) {
++ var d = new Date(String(c.last_message_at).indexOf('T') < 0 ? String(c.last_message_at).replace(' ', 'T') + 'Z' : c.last_message_at);
++ if (!isNaN(d)) { var t = el('time', 'oc-time', d.toISOString().slice(0, 10)); t.dateTime = d.toISOString(); li.appendChild(t); }
++ }
++ return li;
++ }
++ function startInbox() {
++ var list = document.getElementById('oc-convs');
++ var status = document.getElementById('oc-inbox-status');
++ var me = Number(cfg.me) || null;
++ function poll() {
++ if (document.hidden || !list) return;
++ fetch('/api/dm/conversations', { credentials: 'same-origin' })
++ .then(function (r) { return r.ok ? r.json() : null; })
++ .then(function (d) {
++ if (!d || !Array.isArray(d.conversations)) return;
++ var unread = 0;
++ list.textContent = '';
++ d.conversations.forEach(function (c) { unread += Number(c.unread_count) || 0; list.appendChild(convItem(c, me)); });
++ if (!d.conversations.length) list.appendChild(el('li', 'oc-muted', 'No conversations yet.'));
++ if (status) status.textContent = unread ? unread + ' unread' : 'All caught up';
++ }).catch(function () {});
++ }
++ setInterval(poll, 15000);
++ document.addEventListener('visibilitychange', poll);
++ }
++
if (input) input.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.submit(); }
});
@@@ -245,4 +284,5 @@
if (cfg.view === 'global') startGlobal();
else if (cfg.view === 'dm') startDm();
else if (cfg.view === 'room') startRoom();
++ else if (cfg.view === 'inbox') startInbox();
})();
ddiff --git a/server/app.js b/server/app.js
iindex 2fdacfa..fc19287 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -209,6 +209,8 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServe
for (const [mount, router] of Object.entries(require('./prefs/routes').ROUTES)) app.use(`/api/chat/${mount}`, router);
// Chat rooms (server/rooms/): before /api/chat, whose /:streamId routes would take /rooms/…
app.use('/api/chat/rooms', require('./rooms/routes'));
++ // ICE servers for call rooms (STUN, and TURN with credentials when TURN_URL is set): server/net/turn.js.
++ app.use('/api/chat/ice-servers', require('./net/turn').createIceRoutes());
app.use('/api/chat', require('./chat/routes'));
app.use('/api/dm', require('./chat/dm-routes'));
app.use('/api/tts', require('./chat/tts-routes'));
ddiff --git a/server/calls/call-server.js b/server/calls/call-server.js
iindex 3ff299a..e49be07 100644
---- a/server/calls/call-server.js
++++ b/server/calls/call-server.js
@@@ -24,6 +24,18 @@
* 'mic' — Microphone only
* 'mic+cam' — Mic + optional camera
* 'cam+mic' — Both mic and camera
++ *
++ * Call rooms (roadmap WS-I task 4, ../rooms/rooms.js): a room of kind `call` has the channel
++ * `room-<slug>`, made when someone first connects to it and kept (permanent) while the room exists.
++ * The room decides who is in: a person who may not read the room is refused (a private room looks
++ * missing), blocked people are refused, and the room's roles decide who talks: owner, mods and
++ * speakers talk; participants, viewers and people without a role (public rooms, anonymous too) join
++ * listen-only — force-muted with the camera forced off, and their own unmute is ignored. The room's
++ * owner and mods (and chat staff) moderate the call. A role change reaches the call at once
++ * (applyRoomAccess: a `room-role` frame, then the force-mute state); a ban in the call also blocks the
++ * person in the room. Room channels are not in the voice-channel list (Live's sidebar): openvibe.chat
++ * lists rooms. Signalling only: media is peer to peer, so listen-only is enforced by the clients
++ * honouring force-mute (as for every force-mute here), and every peer sees who may talk.
*/
'use strict';
@@@ -34,6 +46,7 @@ const { extractWsToken, authenticateWs } = require('../auth/auth');
const permissions = require('../auth/permissions');
const chatServer = require('../chat/chat-server');
const lifecycle = require('./lifecycle');
++const rooms = require('../rooms/rooms');
// Cosmetics are Live's (monetization/cosmetics); read through live-context, warmed when a socket joins.
const cosmetics = { getCosmeticProfile: (userId) => ctx.getCosmeticProfile(userId) };
@@@ -43,6 +56,7 @@ const MAX_SOCKETS_PER_IP = 3; // one household in one channel; never on
const KICK_COOLDOWN_MS = 60 * 1000;
const PUBLIC_CHANNEL_ID = 'public';
const MODES = ['mic', 'mic+cam', 'cam+mic'];
++const ROOM_CHANNEL = /^room-([a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9]))$/;
class CallServer {
constructor() {
@@@ -124,6 +138,7 @@ class CallServer {
listChannels(viewer = null) {
const result = [];
for (const [id, ch] of this.channels) {
++ if (ch.roomSlug) continue; // call rooms are listed as rooms (openvibe.chat), not in the voice sidebar
if (ch.private && !this._canSeePrivate(ch, viewer)) continue;
const room = this.rooms.get(id);
const participants = [];
@@@ -134,6 +149,7 @@ class CallServer {
}
_publicChannel(ch) { const { invited, ...rest } = ch; return rest; }
_canSeePrivate(ch, viewer) {
++ if (ch.roomSlug) { const room = this._roomOf(ch.id); return !!room && rooms.access(room, viewer).read; }
if (!ch.private) return true;
if (!viewer) return false;
if (permissions.can(viewer, 'staff.moderation.calls')) return true;
@@@ -161,6 +177,29 @@ class CallServer {
if (this._notifyTimer.unref) this._notifyTimer.unref();
}
++ /** The call room behind a `room-<slug>` channel id, or null. */
++ _roomOf(channelId) {
++ const m = ROOM_CHANNEL.exec(String(channelId || ''));
++ if (!m) return null;
++ const room = rooms.bySlug(m[1]);
++ return room && room.kind === 'call' ? room : null;
++ }
++
++ /** The channel of a call room, made on first use and named after the room. → channel | null */
++ ensureRoomChannel(channelId) {
++ const room = this._roomOf(channelId);
++ if (!room) return null;
++ let ch = this.channels.get(channelId);
++ if (!ch) {
++ ch = {
++ id: channelId, name: room.name, mode: 'mic+cam', createdBy: room.owner_id, streamId: null, permanent: true,
++ roomSlug: room.slug, createdAt: Date.now(), maxParticipants: MAX_PARTICIPANTS,
++ };
++ this.channels.set(channelId, ch);
++ } else { ch.name = room.name; ch.createdBy = room.owner_id; }
++ return ch;
++ }
++
getChannel(channelId, viewer = null) {
const ch = this.channels.get(channelId);
if (!ch || !this._canSeePrivate(ch, viewer)) return null;
@@@ -252,6 +291,7 @@ class CallServer {
forceMuted: info.forceMuted || false, forceCameraOff: info.forceCameraOff || false,
speaking: info.speaking || false,
nameFX: cosmeticProfile.nameFX || null, particleFX: cosmeticProfile.particleFX || null, hatFX: cosmeticProfile.hatFX || null,
++ ...(info.inRoom ? { roomRole: info.roomRole || null, canTalk: !info.listenOnly } : {}),
};
}
@@@ -271,6 +311,7 @@ class CallServer {
// Legacy: bare number → stream-<N>
const resolvedId = /^\d+$/.test(channelId) ? `stream-${channelId}` : channelId;
++ if (!this.channels.get(resolvedId)) this.ensureRoomChannel(resolvedId);
if (!this.channels.get(resolvedId)) { ws.send(JSON.stringify({ type: 'error', message: 'Voice channel not found' })); ws.close(); return; }
// Sign-in, the anon number and the stream are (cached) calls to Live now. Messages that arrive
@@@ -314,6 +355,16 @@ class CallServer {
if (channel.private && !this._canSeePrivate(channel, user)) { ws.send(JSON.stringify({ type: 'error', message: 'This is a private call' })); ws.close(); return; }
++ // A call room: the room's roles decide who is in and who talks.
++ let roomAccess = null;
++ if (channel.roomSlug) {
++ const room = this._roomOf(resolvedId);
++ roomAccess = room ? rooms.access(room, user) : null;
++ if (!roomAccess || !roomAccess.read) { ws.send(JSON.stringify({ type: 'error', message: 'Voice channel not found' })); ws.close(); return; }
++ if (!roomAccess.join) { ws.send(JSON.stringify({ type: 'error', message: 'You cannot join this call' })); ws.close(); return; }
++ }
++ const listenOnly = !!(roomAccess && !roomAccess.talk);
++
const peerId = this._generatePeerId();
const anonId = user ? null : chatServer.getAnonIdForConnection(ip, resolvedId);
const identity = user ? `u:${user.id}` : (anonId ? `a:${anonId}` : `ip:${ip}`);
@@@ -355,7 +406,10 @@ class CallServer {
const maxP = channel.maxParticipants || MAX_PARTICIPANTS;
if (room.size >= maxP) { ws.send(JSON.stringify({ type: 'error', message: `Channel full (max ${maxP})` })); ws.close(); return; }
-- const clientInfo = { ws, user, anonId, ip, peerId, identity, muted: false, cameraOff: true, forceMuted: false, forceCameraOff: false, speaking: false, isChannelCreator, isStreamer, _msgCount: 0, _msgResetTime: Date.now() };
++ const clientInfo = {
++ ws, user, anonId, ip, peerId, identity, muted: listenOnly, cameraOff: true, forceMuted: listenOnly, forceCameraOff: listenOnly, speaking: false,
++ isChannelCreator, isStreamer, listenOnly, inRoom: !!channel.roomSlug, roomRole: roomAccess ? roomAccess.role : null, _msgCount: 0, _msgResetTime: Date.now(),
++ };
this.clients.set(ws, { channelId: resolvedId, peerId });
room.set(peerId, clientInfo);
@@@ -381,7 +435,12 @@ class CallServer {
participants,
isStreamer: isStreamer || isChannelCreator,
canModerate,
++ ...(channel.roomSlug ? { room: channel.roomSlug, roomRole: clientInfo.roomRole, canTalk: !listenOnly } : {}),
}));
++ if (listenOnly) {
++ ws.send(JSON.stringify({ type: 'force-muted', forceMuted: true }));
++ ws.send(JSON.stringify({ type: 'force-camera-off', forceCameraOff: true }));
++ }
const joinMsg = JSON.stringify({ type: 'peer-joined', ...this._buildParticipantInfo(peerId, clientInfo) });
for (const [pid, info] of room) { if (pid !== peerId && info.ws.readyState === WebSocket.OPEN) info.ws.send(joinMsg); }
@@@ -409,6 +468,7 @@ class CallServer {
if (!user) return false;
if (permissions.can(user, 'staff.moderation.calls')) return true;
const ch = this.channels.get(channelId);
++ if (ch?.roomSlug) { const room = this._roomOf(channelId); return !!room && rooms.access(room, user).moderate; }
if (ch?.createdBy === user.id) return true;
if (ch?.streamId) return permissions.canModerateCall(user, ch.streamId);
return false;
@@@ -429,6 +489,7 @@ class CallServer {
}
case 'mute': {
const c = room.get(peerId); if (!c) break;
++ if (c.listenOnly && !msg.muted) break; // a listener in a call room stays muted
c.muted = !!msg.muted;
const m = JSON.stringify({ type: 'peer-muted', peerId, muted: c.muted });
for (const [pid, info] of room) { if (pid !== peerId && info.ws.readyState === WebSocket.OPEN) info.ws.send(m); }
@@@ -437,6 +498,7 @@ class CallServer {
}
case 'camera-off': {
const c = room.get(peerId); if (!c) break;
++ if (c.listenOnly && !msg.cameraOff) break;
c.cameraOff = !!msg.cameraOff;
const m = JSON.stringify({ type: 'peer-camera', peerId, cameraOff: c.cameraOff });
for (const [pid, info] of room) { if (pid !== peerId && info.ws.readyState === WebSocket.OPEN) info.ws.send(m); }
@@@ -469,10 +531,16 @@ class CallServer {
if (c.ws.readyState === WebSocket.OPEN) { c.ws.send(JSON.stringify({ type: 'error', message: 'Banned' })); c.ws.close(); } break;
}
const ch = this.channels.get(channelId);
++ if (ch?.roomSlug) {
++ const callRoom = this._roomOf(channelId);
++ const a = callRoom ? rooms.access(callRoom, user) : null;
++ if (!a || !a.read || !a.join) { if (c.ws.readyState === WebSocket.OPEN) { c.ws.send(JSON.stringify({ type: 'error', message: 'You cannot join this call' })); c.ws.close(); } break; }
++ }
c.user = user; c.anonId = user ? null : chatServer.getAnonIdForConnection(c.ip, channelId);
c.isChannelCreator = !!(user && ch?.createdBy === user.id);
c.isStreamer = ch?.streamId ? !!(user && ctx.getStreamById(ch.streamId)?.user_id === user.id) : false;
if (user) { try { lifecycle.answeredByJoin(channelId, user.id); } catch { /* */ } }
++ if (ch?.roomSlug) { this._applyRoomAccessTo(channelId, peerId, c); if (room.get(peerId) !== c) break; }
const pInfo = this._buildParticipantInfo(peerId, c);
if (c.ws.readyState === WebSocket.OPEN) c.ws.send(JSON.stringify({
type: 'self-updated',
@@@ -487,6 +555,7 @@ class CallServer {
case 'force-mute': {
const sender = room.get(peerId); if (!sender || !this._canModerate(sender.user, channelId)) break;
const target = room.get(msg.targetPeerId); if (!target || target.isChannelCreator || target.isStreamer) break;
++ if (target.listenOnly && !msg.forceMuted) break; // make them a speaker in the room instead
target.forceMuted = !!msg.forceMuted;
if (target.ws.readyState === WebSocket.OPEN) target.ws.send(JSON.stringify({ type: 'force-muted', forceMuted: target.forceMuted }));
const m = JSON.stringify({ type: 'peer-force-muted', peerId: msg.targetPeerId, forceMuted: target.forceMuted });
@@@ -496,6 +565,7 @@ class CallServer {
case 'force-camera-off': {
const sender = room.get(peerId); if (!sender || !this._canModerate(sender.user, channelId)) break;
const target = room.get(msg.targetPeerId); if (!target || target.isChannelCreator || target.isStreamer) break;
++ if (target.listenOnly && !msg.forceCameraOff) break;
target.forceCameraOff = !!msg.forceCameraOff;
if (target.ws.readyState === WebSocket.OPEN) target.ws.send(JSON.stringify({ type: 'force-camera-off', forceCameraOff: target.forceCameraOff }));
const m = JSON.stringify({ type: 'peer-force-camera-off', peerId: msg.targetPeerId, forceCameraOff: target.forceCameraOff });
@@@ -521,6 +591,11 @@ class CallServer {
if (target.user?.id) {
banSet.add(target.user.id); // backward compatibility with existing entries
banSet.add(`u:${target.user.id}`);
++ // In a call room the ban is the room's block, so it outlives this process.
++ const callRoom = this._roomOf(channelId);
++ if (callRoom && sender.user) {
++ try { rooms.setRole(callRoom, sender.user, target.user.id, 'blocked'); chatServer.refreshRoomAccess(callRoom); } catch (err) { console.warn('[Call] room block:', err.message); }
++ }
} else {
if (target.anonId) banSet.add(`a:${target.anonId}`);
if (target.ip) banSet.add(`ip:${target.ip}`);
@@@ -596,6 +671,60 @@ class CallServer {
getCallBans(channelId) { const b = this.callBans.get(channelId); return b ? [...b] : []; }
++ /**
++ * A call room's roles or visibility changed (../rooms/routes.js, the site): everyone in its call gets
++ * what they may do now. A new speaker may talk, a demoted one is muted, someone who may no longer
++ * read the room (blocked, removed from a private room) is dropped. → peers changed
++ */
++ applyRoomAccess(room) {
++ if (!room || room.kind !== 'call') return 0;
++ const channelId = `room-${room.slug}`;
++ const ch = this.channels.get(channelId);
++ if (ch) { ch.name = room.name; ch.createdBy = room.owner_id; }
++ const r = this.rooms.get(channelId);
++ if (!r) return 0;
++ let changed = 0;
++ for (const [pid, info] of [...r]) if (this._applyRoomAccessTo(channelId, pid, info, room)) changed++;
++ return changed;
++ }
++
++ /** One peer of a call room against the room's current roles. → whether anything changed */
++ _applyRoomAccessTo(channelId, peerId, info, room = null) {
++ const callRoom = room || this._roomOf(channelId);
++ const a = callRoom ? rooms.access(callRoom, info.user || null) : null;
++ const send = (o) => { if (info.ws.readyState === WebSocket.OPEN) info.ws.send(JSON.stringify(o)); };
++ if (!a || !a.read || !a.join) {
++ send({ type: 'error', message: 'You can no longer join this call' });
++ this._handleDisconnect(info.ws, channelId, peerId);
++ try { info.ws.close(); } catch { /* */ }
++ return true;
++ }
++ const listenOnly = !a.talk;
++ if (info.listenOnly === listenOnly && info.roomRole === a.role) return false;
++ info.roomRole = a.role;
++ info.listenOnly = listenOnly;
++ info.isChannelCreator = !!(info.user && callRoom.owner_id === info.user.id);
++ info.forceMuted = listenOnly;
++ info.forceCameraOff = listenOnly;
++ if (listenOnly) { info.muted = true; info.cameraOff = true; info.speaking = false; }
++ send({ type: 'room-role', room: callRoom.slug, role: a.role, canTalk: a.talk, canModerate: this._canModerate(info.user, channelId) });
++ send({ type: 'force-muted', forceMuted: info.forceMuted });
++ send({ type: 'force-camera-off', forceCameraOff: info.forceCameraOff });
++ const r = this.rooms.get(channelId);
++ if (r) {
++ const m1 = JSON.stringify({ type: 'peer-force-muted', peerId, forceMuted: info.forceMuted });
++ const m2 = JSON.stringify({ type: 'peer-force-camera-off', peerId, forceCameraOff: info.forceCameraOff });
++ const m3 = JSON.stringify({ type: 'peer-updated', ...this._buildParticipantInfo(peerId, info) });
++ for (const [pid, other] of r) {
++ if (other.ws.readyState !== WebSocket.OPEN) continue;
++ other.ws.send(m1); other.ws.send(m2);
++ if (pid !== peerId) other.ws.send(m3);
++ }
++ }
++ this._notifyChannelsChanged();
++ return true;
++ }
++
endCall(channelId, reason = 'ended') {
const r = this.rooms.get(channelId); if (!r) return;
const entries = [...r.values()];
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex 39b1930..8e7d2b7 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -516,7 +516,7 @@ class ChatServer {
if (!room || !a.read) { this.sendTo(ws, { type: 'room_error', room: msg.room || null, code: 'rooms.not_found', message: 'No such room' }); break; }
client.roomId = room.id;
client.roomSlug = room.slug;
-- this.sendTo(ws, { type: 'room_joined', room: room.slug, role: a.role, can: { post: a.post, moderate: a.moderate } });
++ this.sendTo(ws, { type: 'room_joined', room: room.slug, kind: a.kind, role: a.role, can: { post: a.post, moderate: a.moderate, manage: a.manage, join: a.join, talk: a.talk } });
break;
}
case 'leave_room':
@@@ -2432,6 +2432,23 @@ class ChatServer {
}
}
++ /**
++ * A room's roles or visibility changed: every socket following it learns what it may do now
++ * (`room_access`), and one that may no longer read it leaves (`room_left`). → sockets told
++ */
++ refreshRoomAccess(room) {
++ const rooms = require('../rooms/rooms');
++ let n = 0;
++ for (const [ws, client] of this.clients) {
++ if (!room || client.roomId !== room.id) continue;
++ const a = rooms.access(room, client.user || null);
++ n++;
++ if (!a.read) { client.roomId = null; client.roomSlug = null; this.sendTo(ws, { type: 'room_left', reason: 'removed' }); continue; }
++ this.sendTo(ws, { type: 'room_access', room: room.slug, kind: a.kind, role: a.role, can: { post: a.post, moderate: a.moderate, manage: a.manage, join: a.join, talk: a.talk } });
++ }
++ return n;
++ }
++
/** A person may no longer read a room (blocked, removed from a private room, left): stop their feed. */
removeFromRoom(roomId, userId) {
let n = 0;
ddiff --git a/server/chat/routes.js b/server/chat/routes.js
iindex 95acbd6..5ed9af2 100644
---- a/server/chat/routes.js
++++ b/server/chat/routes.js
@@@ -59,10 +59,16 @@ function publicRows(rows) {
return rows;
}
--/** Load the cosmetics/tags of every author in these rows (one batched call for the misses). */
++/**
++ * Load the cosmetics/tags of every author in these rows (one batched call for the misses), waiting at
++ * most DECOR_WAIT_MS: they are Live's, and history and sending never wait on a slow or hung Live.
++ */
++const DECOR_WAIT_MS = 1500;
async function loadDecor(rows) {
const ids = (rows || []).map((m) => m && m.user_id).filter(Boolean);
-- if (ids.length) { try { await ctx.ensureDecor(ids); } catch { /* history still renders */ } }
++ if (!ids.length) return;
++ let timer;
++ try { await Promise.race([ctx.ensureDecor(ids), new Promise((r) => { timer = setTimeout(r, DECOR_WAIT_MS); if (timer.unref) timer.unref(); })]); } catch { /* history still renders */ } finally { clearTimeout(timer); }
}
/**
@@@ -272,7 +278,9 @@ router.post('/send', requireAuth, async (req, res) => {
}
const chatServer = require('./chat-server');
-- try { await ctx.ensureDecor([req.user.id]); } catch { /* non-critical */ }
++ // Cosmetics are Live's and only decorate the line: wait for them briefly, never for a slow or
++ // hanging Live (openvibe.chat keeps working with Live down).
++ await loadDecor([{ user_id: req.user.id }]);
// Word filter
const wordFilter = require('./word-filter');
ddiff --git a/server/config.js b/server/config.js
iindex 1d0ea9b..5a28c92 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -51,6 +51,8 @@ module.exports = {
// openvibe.chat, the site (server/web/): global chat, messages and settings, served by Chat itself.
web: {
baseUrl: strip(process.env.CHAT_WEB_URL || (isProduction ? 'https://openvibe.chat' : 'http://localhost:4400')),
++ // Where a room's Community attachments link (a space is <communityUrl>/s/<slug>).
++ communityUrl: strip(process.env.OV_COMMUNITY_URL || 'https://openvibe.community'),
},
cookies: { secure: process.env.COOKIE_SECURE ? process.env.COOKIE_SECURE === 'true' : isProduction },
ddiff --git a/server/net/turn.js b/server/net/turn.js
nnew file mode 100644
iindex 0000000..d11f714
---- /dev/null
++++ b/server/net/turn.js
@@@ -0,0 +1,72 @@
++'use strict';
++/**
++ * ICE servers for calls (openvibe.chat's call rooms; the same logic as OpenVibe.Live server/net/turn.js
++ * and its GET /api/auth/ice-servers, so both sites hand out the same kind of list).
++ *
++ * GET /api/chat/ice-servers → { iceServers: [{ urls }, { urls, username, credential }…] }
++ *
++ * One STUN server, plus the TURN server when TURN_URL is set and can be authenticated. With
++ * TURN_AUTH_SECRET (coturn `use-auth-secret` with the same `static-auth-secret`) every list carries a
++ * credential that expires (TURN REST API: username `<expiry>:<tag>`, credential base64 HMAC-SHA1 of the
++ * username); otherwise the static TURN_USERNAME / TURN_CREDENTIAL pair; a turn: URL with neither is left
++ * out (a browser refuses such an entry outright). Nothing here asks another service.
++ */
++const crypto = require('crypto');
++const express = require('express');
++
++const TTL_SECONDS = 3600;
++const STUN = { urls: 'stun:stun.l.google.com:19302' };
++
++/** turn:host[:port][?query] or turns:…, no credentials in it; '' when unusable. */
++function normalizeTurnUrl(raw) {
++ const v = String(raw || '').trim();
++ if (!v) return '';
++ const m = /^(turns?):(?:\/\/)?([^/?#@\s]+)([?][^#\s]*)?$/i.exec(v);
++ if (!m) return '';
++ return `${m[1].toLowerCase()}:${m[2]}${m[3] || ''}`;
++}
++
++function turnConfig(env = process.env) {
++ return {
++ url: normalizeTurnUrl(env.TURN_URL),
++ secret: String(env.TURN_AUTH_SECRET || '').trim(),
++ username: String(env.TURN_USERNAME || '').trim(),
++ credential: String(env.TURN_CREDENTIAL || '').trim(),
++ };
++}
++
++function turnCredentials(tag = 'anon', cfg = turnConfig(), now = Date.now()) {
++ if (cfg.secret) {
++ const username = `${Math.floor(now / 1000) + TTL_SECONDS}:${String(tag).replace(/[^A-Za-z0-9_-]/g, '')}`;
++ return { username, credential: crypto.createHmac('sha1', cfg.secret).update(username).digest('base64'), ephemeral: true };
++ }
++ if (cfg.username && cfg.credential) return { username: cfg.username, credential: cfg.credential, ephemeral: false };
++ return null;
++}
++
++/** The list for a viewer (tag: u<id> or anon). */
++function iceServers(tag = 'anon', cfg = turnConfig(), now = Date.now()) {
++ const servers = [STUN];
++ let ephemeral = false;
++ if (cfg.url) {
++ const c = turnCredentials(tag, cfg, now);
++ if (c) {
++ const tcp = cfg.url.includes('?') ? `${cfg.url}&transport=tcp` : `${cfg.url}?transport=tcp`;
++ servers.push({ urls: cfg.url, username: c.username, credential: c.credential }, { urls: tcp, username: c.username, credential: c.credential });
++ ephemeral = c.ephemeral;
++ }
++ }
++ return { iceServers: servers, ephemeral };
++}
++
++function createIceRoutes() {
++ const { optionalAuth } = require('../auth/auth');
++ const router = express.Router();
++ router.get('/', optionalAuth, (req, res) => {
++ const { iceServers: list, ephemeral } = iceServers(req.user ? `u${req.user.id}` : 'anon');
++ res.set('Cache-Control', ephemeral ? 'private, max-age=300' : 'private, no-store').json({ iceServers: list });
++ });
++ return router;
++}
++
++module.exports = { createIceRoutes, iceServers, turnCredentials, normalizeTurnUrl, turnConfig, TTL_SECONDS };
ddiff --git a/server/rooms/rooms.js b/server/rooms/rooms.js
iindex 2518af0..e331cce 100644
---- a/server/rooms/rooms.js
++++ b/server/rooms/rooms.js
@@@ -1,18 +1,47 @@
'use strict';
/**
-- * Chat rooms (roadmap WS-I task 4, first release): rooms anyone can create on openvibe.chat, beside the
-- * global room and the stream/channel rooms Live's chat already has.
++ * Chat rooms (roadmap WS-I task 4): rooms people create on openvibe.chat, beside the global room and
++ * the stream/channel rooms Live's chat already has. Three kinds:
*
-- * rooms slug (URL id), name, topic, kind (community | system | call), visibility
-- * (public: anyone reads, signed-in people join; private: members only), owner, slow mode
-- * room_members role: owner | mod | member | blocked; last_read_id for unread counts
-- * room_messages the messages (soft-deleted by their author, a room mod or chat staff)
++ * community a conversation anyone can start (public or invite-only)
++ * call the room a call happens in: its text chat, and a voice/video channel on /ws/call
++ * (channel id `room-<slug>`, server/calls/call-server.js) where the room's roles decide who
++ * talks and who listens
++ * system platform-authored: announcements written by chat staff, read-only for people
*
-- * Moderation: the owner and mods delete messages and block members; site bans and chat staff
-- * (staff.moderation.chat) apply in every room. Every staff/mod action is logged through
-- * db.logModerationAction (scope room:<slug>), which also reports it to Network's audit log.
-- * Nothing here talks to Live except the ban check (ctx.isUserBanned), so rooms keep working with
-- * Live down. chat-server.js delivers messages live to sockets that joined the room (join_room).
++ * rooms slug (URL id), name, topic, kind, visibility (public: anyone reads, signed-in
++ * people join; private: members only), owner, slow mode, join_role (call rooms:
++ * the role people get when they join: speaker, participant or viewer)
++ * room_members role (below); last_read_id for unread counts; last_seen_at (last read or post)
++ * room_messages the messages (soft-deleted by their author, a room mod or chat staff)
++ * room_attachments where a room is attached elsewhere (a Community space): the room's side of the
++ * link, made by a person who manages the room (the other service keeps its own)
++ *
++ * Roles and what they may do (access() is the only place this is decided):
++ *
++ * kind role read post call: join talk moderate manage
++ * community owner yes yes - - yes yes
++ * mod yes yes - - yes -
++ * member yes yes - - - -
++ * viewer yes - - - - - (read-only member)
++ * call owner yes yes yes yes yes yes
++ * mod yes yes yes yes yes -
++ * speaker yes yes yes yes - -
++ * participant yes yes yes - - - (listens, writes in the text chat)
++ * viewer yes - yes - - - (listens and reads)
++ * system owner yes yes - - yes yes (only chat staff create them)
++ * mod yes yes - - yes -
++ * viewer yes - - - - - (everyone who joins)
++ * any blocked - - - - - -
++ * no role public room: read (and listen in a call room); private room: nothing, not even that it exists
++ * chat staff (staff.moderation.chat): read, moderate and manage every room, post in system rooms, talk in calls
++ * a site ban: no posting and no talking, whatever the role
++ *
++ * Owners and chat staff appoint mods; owners and mods set every other role. Every staff/mod action
++ * on someone else is logged through db.logModerationAction (scope room:<slug>), which also reports it to
++ * Network's audit log. Nothing here talks to Live except the ban check (ctx.isUserBanned, a cached
++ * read), so rooms keep working with Live down. chat-server.js delivers messages live to sockets that
++ * joined the room (join_room).
*/
const db = require('../db/database');
const ctx = require('../live-context');
@@@ -20,9 +49,27 @@ const wordFilter = require('../chat/word-filter');
const permissions = require('../auth/permissions');
const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])$/;
--const RESERVED = new Set(['global', 'new', 'rooms', 'settings', 'messages', 'updates', 'auth', 'api', 'admin', 'staff', 'help', 'system']);
--const ROLES = ['owner', 'mod', 'member', 'blocked'];
--const KINDS = ['community', 'system', 'call'];
++const RESERVED = new Set(['global', 'new', 'rooms', 'settings', 'messages', 'updates', 'auth', 'api', 'admin', 'staff', 'help', 'system', 'ice-servers']);
++const KINDS = ['community', 'call', 'system'];
++const ROLES = ['owner', 'mod', 'speaker', 'participant', 'member', 'viewer', 'blocked'];
++/** The roles each kind has (owner is never assigned: a room has one, its creator). */
++const KIND_ROLES = Object.freeze({
++ community: ['owner', 'mod', 'member', 'viewer', 'blocked'],
++ call: ['owner', 'mod', 'speaker', 'participant', 'viewer', 'blocked'],
++ system: ['owner', 'mod', 'viewer', 'blocked'],
++});
++/** What joining a public room makes you (a call room's owner picks one of JOIN_ROLES_CALL). */
++const JOIN_ROLE = Object.freeze({ community: 'member', call: 'participant', system: 'viewer' });
++const JOIN_ROLES_CALL = ['speaker', 'participant', 'viewer'];
++const POSTERS = Object.freeze({
++ community: new Set(['owner', 'mod', 'member']),
++ call: new Set(['owner', 'mod', 'speaker', 'participant']),
++ system: new Set(['owner', 'mod']),
++});
++const TALKERS = new Set(['owner', 'mod', 'speaker']);
++const ROLE_ORDER = { owner: 0, mod: 1, speaker: 2, participant: 3, member: 4, viewer: 5, blocked: 6 };
++/** Services a room can be attached to, and what names the resource there. */
++const ATTACH_SERVICES = Object.freeze({ community: { resource: /^[a-z0-9][a-z0-9-]{1,39}$/, noun: 'space' } });
const MAX_TEXT = 2000;
const MAX_ROOMS_PER_OWNER = 10;
const RATE = { max: 6, windowMs: 10_000 };
@@@ -35,7 +82,8 @@ const fail = (status, code, message) => { throw new RoomError(status, code, mess
let ready = false;
function ensureSchema() {
if (ready) return;
-- db.getDb().exec(`
++ const d = db.getDb();
++ d.exec(`
CREATE TABLE IF NOT EXISTS rooms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT UNIQUE NOT NULL,
@@@ -71,13 +119,29 @@ function ensureSchema() {
deleted_by INTEGER
);
CREATE INDEX IF NOT EXISTS idx_room_messages_room ON room_messages(room_id, id);
++ CREATE TABLE IF NOT EXISTS room_attachments (
++ room_id INTEGER NOT NULL,
++ service TEXT NOT NULL,
++ resource TEXT NOT NULL,
++ title TEXT,
++ attached_by INTEGER NOT NULL,
++ attached_by_subject TEXT,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ PRIMARY KEY (room_id, service, resource)
++ );
`);
++ // Additive columns on tables the first release created (ADD COLUMN is a metadata change: safe live).
++ const cols = (t) => new Set(d.prepare(`PRAGMA table_info(${t})`).all().map((c) => c.name));
++ if (!cols('rooms').has('join_role')) d.exec('ALTER TABLE rooms ADD COLUMN join_role TEXT');
++ if (!cols('room_members').has('last_seen_at')) d.exec('ALTER TABLE room_members ADD COLUMN last_seen_at DATETIME');
ready = true;
}
const isStaff = (user) => !!user && permissions.can(user, 'staff.moderation.chat');
const roleOf = (roomId, userId) => (userId ? (db.get('SELECT role FROM room_members WHERE room_id = ? AND user_id = ?', [roomId, userId]) || {}).role || null : null);
const siteBanned = (user) => !!user && (user.is_banned || (() => { try { return ctx.isUserBanned(user.id, null); } catch { return false; } })());
++const kindOf = (room) => (KINDS.includes(room && room.kind) ? room.kind : 'community');
++const joinRoleOf = (room) => (kindOf(room) === 'call' && JOIN_ROLES_CALL.includes(room.join_role) ? room.join_role : JOIN_ROLE[kindOf(room)]);
function slugify(name) {
return String(name || '').toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32).replace(/-+$/g, '');
@@@ -86,7 +150,8 @@ function slugify(name) {
function publicRoom(r, extra = {}) {
if (!r) return null;
return {
-- id: r.id, slug: r.slug, name: r.name, topic: r.topic || null, kind: r.kind, visibility: r.visibility,
++ id: r.id, slug: r.slug, name: r.name, topic: r.topic || null, kind: kindOf(r), visibility: r.visibility,
++ ...(kindOf(r) === 'call' ? { join_role: joinRoleOf(r), call_channel: `room-${r.slug}` } : {}),
slow_seconds: r.slow_seconds, message_count: r.message_count, last_message_at: r.last_message_at,
created_at: r.created_at, members: (db.get("SELECT COUNT(*) AS n FROM room_members WHERE room_id = ? AND role != 'blocked'", [r.id]) || {}).n || 0,
...extra,
@@@ -98,17 +163,26 @@ function bySlug(slug) {
return db.get('SELECT * FROM rooms WHERE slug = ? AND archived_at IS NULL', [String(slug || '').toLowerCase()]) || null;
}
--/** What `user` may do in `room`: { read, post, moderate, role }. */
++/**
++ * What `user` may do in `room`: { role, read, post, join, talk, moderate, manage } — the matrix above.
++ * `join` and `talk` are the room's call (call rooms only).
++ */
function access(room, user) {
++ const kind = kindOf(room);
const role = user ? roleOf(room.id, user.id) : null;
const staff = isStaff(user);
const blocked = role === 'blocked';
const member = !!role && !blocked;
++ const banned = siteBanned(user);
const read = !blocked && (room.visibility === 'public' || member || staff);
-- return { role, read, post: !!user && member && !siteBanned(user), moderate: !!user && (role === 'owner' || role === 'mod' || staff), manage: !!user && (role === 'owner' || staff) };
++ const moderate = !!user && !blocked && (role === 'owner' || role === 'mod' || staff);
++ const post = !!user && read && !banned && ((member && POSTERS[kind].has(role)) || (kind === 'system' && staff));
++ const join = kind === 'call' && read;
++ const talk = join && !!user && !banned && ((member && TALKERS.has(role)) || staff);
++ return { role, kind, read, post, join, talk, moderate, manage: !!user && !blocked && (role === 'owner' || staff) };
}
--function create(user, { name, slug, topic, visibility = 'public', kind = 'community' } = {}) {
++function create(user, { name, slug, topic, visibility = 'public', kind = 'community', join_role = null } = {}) {
ensureSchema();
if (!user) fail(401, 'rooms.sign_in', 'Sign in to create a room');
if (siteBanned(user)) fail(403, 'rooms.banned', 'Your account is banned from chat');
@@@ -117,15 +191,18 @@ function create(user, { name, slug, topic, visibility = 'public', kind = 'commun
const s = slug ? String(slug).toLowerCase().trim() : slugify(n);
if (!SLUG_RE.test(s) || RESERVED.has(s)) fail(422, 'rooms.slug', 'Room addresses are 3 to 32 lowercase letters, numbers and dashes (and not a reserved word)');
if (!['public', 'private'].includes(visibility)) fail(422, 'rooms.visibility', 'Visibility is public or private');
-- if (!KINDS.includes(kind) || (kind !== 'community' && !isStaff(user))) fail(422, 'rooms.kind', 'Only chat staff create system and call rooms');
++ if (!KINDS.includes(kind)) fail(422, 'rooms.kind', 'A room is a community, call or system room');
++ if (kind === 'system' && !isStaff(user)) fail(403, 'rooms.kind', 'Only chat staff create system rooms');
++ if (join_role != null && join_role !== '' && (kind !== 'call' || !JOIN_ROLES_CALL.includes(join_role))) fail(422, 'rooms.join_role', 'People join a call room as speakers, participants or viewers');
if (wordFilter.check(n).safe === false) fail(422, 'rooms.name_filtered', 'That room name is not allowed');
const owned = db.get('SELECT COUNT(*) AS n FROM rooms WHERE owner_id = ? AND archived_at IS NULL', [user.id]).n;
if (owned >= MAX_ROOMS_PER_OWNER && !isStaff(user)) fail(429, 'rooms.too_many', `You can own up to ${MAX_ROOMS_PER_OWNER} rooms`);
if (bySlug(s)) fail(409, 'rooms.taken', 'That room address is taken');
const t = topic ? String(topic).replace(/\s+/g, ' ').trim().slice(0, 200) : null;
const id = db.transaction(() => {
-- const r = db.run('INSERT INTO rooms (slug, name, topic, kind, visibility, owner_id, owner_subject) VALUES (?, ?, ?, ?, ?, ?, ?)', [s, n, t, kind, visibility, user.id, user.subject_id || null]);
-- db.run("INSERT INTO room_members (room_id, user_id, role) VALUES (?, ?, 'owner')", [r.lastInsertRowid, user.id]);
++ const r = db.run('INSERT INTO rooms (slug, name, topic, kind, visibility, owner_id, owner_subject, join_role) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
++ [s, n, t, kind, visibility, user.id, user.subject_id || null, kind === 'call' ? (join_role || JOIN_ROLE.call) : null]);
++ db.run("INSERT INTO room_members (room_id, user_id, role, last_seen_at) VALUES (?, ?, 'owner', CURRENT_TIMESTAMP)", [r.lastInsertRowid, user.id]);
return Number(r.lastInsertRowid);
});
return publicRoom(db.get('SELECT * FROM rooms WHERE id = ?', [id]), { role: 'owner' });
@@@ -136,23 +213,35 @@ function list(user, { limit = 50 } = {}) {
ensureSchema();
const lim = Math.min(100, Math.max(1, Number(limit) || 50));
const pub = db.all("SELECT * FROM rooms WHERE visibility = 'public' AND archived_at IS NULL ORDER BY COALESCE(last_message_at, created_at) DESC LIMIT ?", [lim]).map((r) => publicRoom(r));
-- const mine = user ? db.all(`SELECT r.*, m.role, m.last_read_id,
++ const mine = user ? db.all(`SELECT r.*, m.role, m.last_read_id, m.last_seen_at,
(SELECT COUNT(*) FROM room_messages x WHERE x.room_id = r.id AND x.id > m.last_read_id AND x.is_deleted = 0 AND x.user_id != ?) AS unread
FROM room_members m JOIN rooms r ON r.id = m.room_id
WHERE m.user_id = ? AND m.role != 'blocked' AND r.archived_at IS NULL
-- ORDER BY COALESCE(r.last_message_at, r.created_at) DESC`, [user.id, user.id]).map((r) => publicRoom(r, { role: r.role, unread: r.unread })) : [];
++ ORDER BY COALESCE(r.last_message_at, r.created_at) DESC`, [user.id, user.id]).map((r) => publicRoom(r, { role: r.role, unread: r.unread, last_seen_at: r.last_seen_at || null })) : [];
return { public: pub, mine };
}
++/** Unread messages across the rooms `user` belongs to (the site's navigation shows it). */
++function unreadTotal(user) {
++ if (!user) return 0;
++ ensureSchema();
++ return (db.get(`SELECT COUNT(*) AS n FROM room_members m JOIN rooms r ON r.id = m.room_id AND r.archived_at IS NULL
++ JOIN room_messages x ON x.room_id = m.room_id AND x.id > m.last_read_id AND x.is_deleted = 0 AND x.user_id != m.user_id
++ WHERE m.user_id = ? AND m.role != 'blocked'`, [user.id]) || {}).n || 0;
++}
++
++/** Join a public room (staff: any room) with the kind's join role; returns the caller's role. */
function join(room, user) {
if (!user) fail(401, 'rooms.sign_in', 'Sign in to join a room');
const role = roleOf(room.id, user.id);
if (role === 'blocked') fail(403, 'rooms.blocked', 'You are blocked from this room');
if (role) return role;
-- if (room.visibility !== 'public' && !isStaff(user)) fail(403, 'rooms.private', 'This room is invite-only');
++ // A private room is not there for someone it has not invited (the same answer as a missing room).
++ if (room.visibility !== 'public' && !isStaff(user)) fail(404, 'rooms.not_found', 'No such room');
const last = (db.get('SELECT MAX(id) AS id FROM room_messages WHERE room_id = ?', [room.id]) || {}).id || 0;
-- db.run("INSERT INTO room_members (room_id, user_id, role, last_read_id) VALUES (?, ?, 'member', ?)", [room.id, user.id, last]);
-- return 'member';
++ const r = joinRoleOf(room);
++ db.run('INSERT INTO room_members (room_id, user_id, role, last_read_id, last_seen_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)', [room.id, user.id, r, last]);
++ return r;
}
function leave(room, user) {
@@@ -212,7 +301,11 @@ function post(room, user, text, { now = Date.now() } = {}) {
const a = access(room, user);
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) fail(403, room.visibility === 'public' ? 'rooms.join_first' : 'rooms.private', room.visibility === 'public' ? 'Join the room to post' : 'This room is invite-only');
++ if (!a.post) {
++ if (!a.read) fail(404, 'rooms.not_found', 'No such room');
++ if (!a.role) fail(403, 'rooms.join_first', a.kind === 'system' ? 'Only OpenVibe staff post announcements here' : 'Join the room to post');
++ fail(403, 'rooms.read_only', a.kind === 'system' ? 'Only OpenVibe staff post announcements here' : 'Your role in this room is read-only');
++ }
const body = String(text || '').replace(/\r\n/g, '\n').trim();
if (!body) fail(422, 'rooms.empty', 'Write something first');
if (body.length > MAX_TEXT) fail(422, 'rooms.too_long', `Messages are at most ${MAX_TEXT} characters`);
@@@ -227,7 +320,7 @@ function post(room, user, text, { now = Date.now() } = {}) {
const id = db.transaction(() => {
const r = db.run('INSERT INTO room_messages (room_id, user_id, subject_id, message) VALUES (?, ?, ?, ?)', [room.id, user.id, user.subject_id || null, message]);
db.run('UPDATE rooms SET message_count = message_count + 1, last_message_at = CURRENT_TIMESTAMP WHERE id = ?', [room.id]);
-- db.run('UPDATE room_members SET last_read_id = ? WHERE room_id = ? AND user_id = ?', [r.lastInsertRowid, room.id, user.id]);
++ db.run('UPDATE room_members SET last_read_id = ?, last_seen_at = CURRENT_TIMESTAMP WHERE room_id = ? AND user_id = ?', [r.lastInsertRowid, room.id, user.id]);
announceMessage(room, user, Number(r.lastInsertRowid), message, now);
return Number(r.lastInsertRowid);
});
@@@ -252,7 +345,7 @@ function markRead(room, user, lastId) {
if (!user) return;
const top = (db.get('SELECT MAX(id) AS id FROM room_messages WHERE room_id = ?', [room.id]) || {}).id || 0;
const to = Math.min(top, Number(lastId) || top);
-- db.run('UPDATE room_members SET last_read_id = MAX(last_read_id, ?) WHERE room_id = ? AND user_id = ?', [to, room.id, user.id]);
++ db.run('UPDATE room_members SET last_read_id = MAX(last_read_id, ?), last_seen_at = CURRENT_TIMESTAMP WHERE room_id = ? AND user_id = ?', [to, room.id, user.id]);
}
function deleteMessage(room, user, messageId) {
@@@ -269,36 +362,50 @@ function deleteMessage(room, user, messageId) {
return m.id;
}
--/** Owner/mod actions on a member: invite (by user id), set a role (mod/member), block or unblock. */
++/**
++ * Owner/mod actions on a person: add them (a role, by user id), change their role, block, unblock or
++ * remove them ('none'). The role must be one of the room kind's (KIND_ROLES); mods are the owner's (or
++ * chat staff's) to appoint and remove; everything else is any moderator's.
++ */
function setRole(room, actor, targetUserId, role) {
++ const kind = kindOf(room);
if (!ROLES.includes(role) && role !== 'none') fail(422, 'rooms.role', 'Unknown role');
++ if (role === 'owner') fail(422, 'rooms.role', 'A room has one owner');
++ if (role !== 'none' && !KIND_ROLES[kind].includes(role)) fail(422, 'rooms.role_kind', `A ${kind} room has no ${role} role (its roles: ${KIND_ROLES[kind].filter((r) => r !== 'owner').join(', ')})`);
const a = access(room, actor);
++ if (!a.moderate) fail(403, 'rooms.not_mod', 'Only the room\'s owner and moderators manage members');
const current = roleOf(room.id, targetUserId);
if (current === 'owner') fail(403, 'rooms.owner', 'The owner\'s role does not change');
-- if (role === 'owner') fail(422, 'rooms.role', 'A room has one owner');
if (role === 'mod' && !a.manage) fail(403, 'rooms.not_owner', 'Only the owner appoints moderators');
-- if (!a.moderate) fail(403, 'rooms.not_mod', 'Only the room\'s owner and moderators manage members');
if (current === 'mod' && !a.manage) fail(403, 'rooms.not_owner', 'Only the owner changes a moderator');
const target = ctx.getUserById(targetUserId);
if (!target) fail(404, 'rooms.no_user', 'No such person');
if (role === 'none') { if (current && current !== 'blocked') db.run('DELETE FROM room_members WHERE room_id = ? AND user_id = ?', [room.id, targetUserId]); }
else if (current) db.run('UPDATE room_members SET role = ? WHERE room_id = ? AND user_id = ?', [role, room.id, targetUserId]);
else db.run('INSERT INTO room_members (room_id, user_id, role) VALUES (?, ?, ?)', [room.id, targetUserId, role]);
-- if (role === 'blocked' || current === 'blocked' || role === 'mod' || current === 'mod') {
-- db.logModerationAction({ scope_type: 'room', scope_id: room.slug, actor_user_id: actor.id, target_user_id: targetUserId, action_type: role === 'blocked' ? 'room_block' : current === 'blocked' ? 'room_unblock' : role === 'mod' ? 'room_mod_add' : 'room_mod_remove', details: {} });
-- }
++ const action = role === 'blocked' && current !== 'blocked' ? 'room_block'
++ : current === 'blocked' && role !== 'blocked' ? 'room_unblock'
++ : role === 'mod' && current !== 'mod' ? 'room_mod_add'
++ : current === 'mod' && role !== 'mod' ? 'room_mod_remove'
++ : role === 'speaker' && current !== 'speaker' ? 'room_speaker_add'
++ : current === 'speaker' && role !== 'speaker' ? 'room_speaker_remove' : null;
++ if (action) db.logModerationAction({ scope_type: 'room', scope_id: room.slug, actor_user_id: actor.id, target_user_id: targetUserId, action_type: action, details: { from: current, to: role } });
return role;
}
--function update(room, actor, { name, topic, visibility, slow_seconds } = {}) {
++function update(room, actor, { name, topic, visibility, slow_seconds, join_role } = {}) {
if (!access(room, actor).manage) fail(403, 'rooms.not_owner', 'Only the owner changes the room');
const next = { ...room };
if (name != null) { const n = String(name).replace(/\s+/g, ' ').trim(); if (n.length < 3 || n.length > 40 || !wordFilter.check(n).safe) fail(422, 'rooms.name', 'Room names are 3 to 40 characters'); next.name = n; }
if (topic != null) next.topic = String(topic).replace(/\s+/g, ' ').trim().slice(0, 200) || null;
if (visibility != null) { if (!['public', 'private'].includes(visibility)) fail(422, 'rooms.visibility', 'Visibility is public or private'); next.visibility = visibility; }
if (slow_seconds != null) { const v = Number(slow_seconds); if (!Number.isInteger(v) || v < 0 || v > 600) fail(422, 'rooms.slow', 'Slow mode is 0 to 600 seconds'); next.slow_seconds = v; }
++ if (join_role != null && join_role !== '') {
++ if (kindOf(room) !== 'call' || !JOIN_ROLES_CALL.includes(join_role)) fail(422, 'rooms.join_role', 'People join a call room as speakers, participants or viewers');
++ next.join_role = join_role;
++ }
db.transaction(() => {
-- db.run('UPDATE rooms SET name = ?, topic = ?, visibility = ?, slow_seconds = ? WHERE id = ?', [next.name, next.topic, next.visibility, next.slow_seconds, room.id]);
++ db.run('UPDATE rooms SET name = ?, topic = ?, visibility = ?, slow_seconds = ?, join_role = ? WHERE id = ?', [next.name, next.topic, next.visibility, next.slow_seconds, next.join_role == null ? null : next.join_role, room.id]);
// Public → private: everything the room published stops being replayable on the realtime plane.
if (room.visibility === 'public' && next.visibility === 'private') {
announceDeleted(room, db.all('SELECT id FROM room_messages WHERE room_id = ? AND is_deleted = 0 ORDER BY id', [room.id]).map((r) => r.id));
@@@ -307,11 +414,61 @@ function update(room, actor, { name, topic, visibility, slow_seconds } = {}) {
return publicRoom(db.get('SELECT * FROM rooms WHERE id = ?', [room.id]));
}
--function members(room) {
-- return db.all(`SELECT m.user_id, m.role, u.username, u.display_name FROM room_members m LEFT JOIN ctx_users u ON u.id = m.user_id
-- WHERE m.room_id = ? ORDER BY CASE m.role WHEN 'owner' THEN 0 WHEN 'mod' THEN 1 WHEN 'member' THEN 2 ELSE 3 END, u.username COLLATE NOCASE LIMIT 500`, [room.id]);
++/** The room's people, by role. `seen` adds last_seen_at (moderators only: it is presence). */
++function members(room, { seen = false } = {}) {
++ const order = `CASE m.role ${Object.entries(ROLE_ORDER).map(([r, n]) => `WHEN '${r}' THEN ${n}`).join(' ')} ELSE 9 END`;
++ return db.all(`SELECT m.user_id, m.role, u.username, u.display_name${seen ? ', m.last_seen_at' : ''} FROM room_members m LEFT JOIN ctx_users u ON u.id = m.user_id
++ WHERE m.room_id = ? ORDER BY ${order}, u.username COLLATE NOCASE LIMIT 500`, [room.id]);
++}
++
++// ── Attachments: the room's side of a link from another service (a Community space) ──
++// The other service keeps the link (Community stores the room on the space) and checks its own side
++// (the space's owner or staff); Chat checks that the person attaching manages this room. So one person
++// holding both ends makes the link, with their own Network token: no service grant is involved.
++
++function attachmentShape(r) {
++ return { service: r.service, resource: r.resource, title: r.title || null, attached_by: r.attached_by, created_at: r.created_at };
++}
++
++/** Attach the room to { service, resource, title? }. Idempotent: → { attachment, created }. Managers only. */
++function attach(room, user, { service, resource, title } = {}) {
++ ensureSchema();
++ if (!user) fail(401, 'rooms.sign_in', 'Sign in first');
++ if (!access(room, user).manage) fail(403, 'rooms.not_owner', 'Only the room\'s owner attaches it elsewhere');
++ const svc = ATTACH_SERVICES[service];
++ if (!svc) fail(422, 'rooms.attach_service', `A room attaches to: ${Object.keys(ATTACH_SERVICES).join(', ')}`);
++ const res = String(resource || '').toLowerCase();
++ if (!svc.resource.test(res)) fail(422, 'rooms.attach_resource', `That is not a ${service} ${svc.noun}`);
++ const t = title == null ? null : String(title).replace(/[\u0000-\u001f\u007f]/g, '').replace(/\s+/g, ' ').trim().slice(0, 80) || null;
++ const existing = db.get('SELECT * FROM room_attachments WHERE room_id = ? AND service = ? AND resource = ?', [room.id, service, res]);
++ if (existing) {
++ if (t && t !== existing.title) db.run('UPDATE room_attachments SET title = ? WHERE room_id = ? AND service = ? AND resource = ?', [t, room.id, service, res]);
++ return { attachment: attachmentShape({ ...existing, title: t || existing.title }), created: false };
++ }
++ db.run('INSERT INTO room_attachments (room_id, service, resource, title, attached_by, attached_by_subject) VALUES (?, ?, ?, ?, ?, ?)', [room.id, service, res, t, user.id, user.subject_id || null]);
++ return { attachment: attachmentShape(db.get('SELECT * FROM room_attachments WHERE room_id = ? AND service = ? AND resource = ?', [room.id, service, res])), created: true };
++}
++
++/** Remove an attachment: the room's managers, or the person who made it. Idempotent: → { removed }. */
++function detach(room, user, service, resource) {
++ ensureSchema();
++ if (!user) fail(401, 'rooms.sign_in', 'Sign in first');
++ const row = db.get('SELECT * FROM room_attachments WHERE room_id = ? AND service = ? AND resource = ?', [room.id, String(service || ''), String(resource || '').toLowerCase()]);
++ if (!row) return { removed: false };
++ if (!access(room, user).manage && row.attached_by !== user.id) fail(403, 'rooms.not_owner', 'Only the room\'s owner, or whoever attached it, detaches it');
++ db.run('DELETE FROM room_attachments WHERE room_id = ? AND service = ? AND resource = ?', [room.id, row.service, row.resource]);
++ return { removed: true };
++}
++
++function attachments(room) {
++ ensureSchema();
++ return db.all('SELECT * FROM room_attachments WHERE room_id = ? ORDER BY created_at, service, resource', [room.id]).map(attachmentShape);
}
function _reset() { ready = false; _rate.clear(); }
--module.exports = { ensureSchema, create, list, bySlug, access, join, leave, post, history, markRead, deleteMessage, setRole, update, members, publicRoom, messageById, RoomError, SLUG_RE, _reset };
++module.exports = {
++ ensureSchema, create, list, bySlug, access, join, leave, post, history, markRead, deleteMessage, setRole, update, members, publicRoom, messageById,
++ attach, detach, attachments, joinRoleOf, unreadTotal,
++ RoomError, SLUG_RE, KINDS, ROLES, KIND_ROLES, JOIN_ROLE, JOIN_ROLES_CALL, ATTACH_SERVICES, _reset,
++};
ddiff --git a/server/rooms/routes.js b/server/rooms/routes.js
iindex 50e97d7..6e26110 100644
---- a/server/rooms/routes.js
++++ b/server/rooms/routes.js
@@@ -4,15 +4,23 @@
* chat-server.js carries the same rooms live (join_room / room_message).
*
* GET / public rooms by activity + the caller's rooms (with unread counts)
-- * POST / create { name, slug?, topic?, visibility? }
-- * GET /:slug the room and what the caller may do (404 when they may not read it)
-- * PATCH /:slug owner: { name?, topic?, visibility?, slow_seconds? }
++ * POST / create { name, slug?, topic?, visibility?, kind?: community | call | system (staff),
++ * join_role?: speaker | participant | viewer (call rooms) }
++ * GET /:slug the room, what the caller may do (can: read/post/join/talk/moderate/manage)
++ * and, for a call room, its call (404 when they may not read it)
++ * PATCH /:slug owner: { name?, topic?, visibility?, slow_seconds?, join_role? }
* GET /:slug/messages ?before=<id> | ?after=<id>, &limit
* POST /:slug/messages { message }
* DELETE /:slug/messages/:id the author, a room moderator or chat staff
* POST /:slug/join | /leave | /read { last_id? }
-- * GET /:slug/members
-- * POST /:slug/members owner/mods: { username, role: member | mod | blocked | none }
++ * GET /:slug/members (moderators also get last_seen_at)
++ * POST /:slug/members owner/mods: { username, role } — one of the kind's roles (rooms.KIND_ROLES) or none
++ * GET /:slug/attachments managers: where the room is attached (a Community space)
++ * POST /:slug/attachments managers, with their own Network token: { service: 'community', resource: <space slug>, title? }
++ * DELETE /:slug/attachments/:service/:resource managers or whoever attached it (both idempotent)
++ *
++ * Role and room changes reach a running call at once (call-server.js applyRoomAccess): a new speaker
++ * may talk, a demoted one is muted, a blocked person is dropped.
*/
const express = require('express');
const { requireAuth, optionalAuth } = require('../auth/auth');
@@@ -27,6 +35,25 @@ function send(res, err) {
return res.status(500).json({ error: 'Something went wrong' });
}
const chatServer = () => require('../chat/chat-server');
++/**
++ * After a role or room change: sockets following the room learn what they may do now, and a running
++ * call in it follows the room's roles (no-op when calls are off or nobody is in).
++ */
++function applyRoomChange(room) {
++ const fresh = rooms.bySlug(room.slug) || room;
++ try { chatServer().refreshRoomAccess(fresh); } catch (err) { console.warn('[Rooms] socket update:', err.message); }
++ try { require('../calls/call-server').applyRoomAccess(fresh); } catch (err) { console.warn('[Rooms] call update:', err.message); }
++}
++/** The room's call as the room page shows it: on or off, and who is in. */
++function callOf(room) {
++ if (room.kind !== 'call') return undefined;
++ const enabled = !!require('../config').calls.enabled;
++ let people = [];
++ try { people = enabled ? require('../calls/call-server').getParticipants(`room-${room.slug}`) : []; } catch { people = []; }
++ return { enabled, channel: `room-${room.slug}`, participants: people.length, people: people.map((p) => ({ username: p.username, display_name: p.displayName, muted: !!p.muted, speaking: !!p.speaking, listening: !!p.forceMuted })) };
++}
++/** People only: attaching speaks for the person (API tokens and services cannot). */
++const personOnly = (req, res, next) => (req.authSource === 'api_token' ? res.status(403).json({ error: 'Attach rooms with your own sign-in, not an API token', code: 'rooms.person_only' }) : next());
/** The room behind :slug when the caller may read it (else a 404 that says nothing about private rooms). */
function readable(req, res) {
@@@ -46,12 +73,16 @@ router.post('/', requireAuth, (req, res) => {
router.get('/:slug', optionalAuth, (req, res) => {
const room = readable(req, res); if (!room) return;
const a = rooms.access(room, req.user || null);
-- res.set('Cache-Control', 'private, no-store').json({ room: rooms.publicRoom(room, { role: a.role }), can: { post: a.post, moderate: a.moderate, manage: a.manage } });
++ res.set('Cache-Control', 'private, no-store').json({
++ room: rooms.publicRoom(room, { role: a.role }),
++ can: { read: a.read, post: a.post, join: a.join, talk: a.talk, moderate: a.moderate, manage: a.manage },
++ ...(room.kind === 'call' ? { call: callOf(room) } : {}),
++ });
});
router.patch('/:slug', requireAuth, (req, res) => {
const room = readable(req, res); if (!room) return;
-- try { res.json({ room: rooms.update(room, req.user, req.body || {}) }); } catch (err) { send(res, err); }
++ try { const out = rooms.update(room, req.user, req.body || {}); applyRoomChange(room); res.json({ room: out }); } catch (err) { send(res, err); }
});
router.get('/:slug/messages', optionalAuth, (req, res) => {
@@@ -83,12 +114,17 @@ router.delete('/:slug/messages/:id', requireAuth, (req, res) => {
router.post('/:slug/join', requireAuth, (req, res) => {
const room = rooms.bySlug(req.params.slug);
if (!room) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });
-- try { res.json({ ok: true, role: rooms.join(room, req.user) }); } catch (err) { send(res, err); }
++ try { const role = rooms.join(room, req.user); applyRoomChange(room); res.json({ ok: true, role }); } catch (err) { send(res, err); }
});
router.post('/:slug/leave', requireAuth, (req, res) => {
const room = readable(req, res); if (!room) return;
-- try { rooms.leave(room, req.user); chatServer().removeFromRoom(room.id, req.user.id); res.json({ ok: true }); } catch (err) { send(res, err); }
++ try {
++ rooms.leave(room, req.user);
++ chatServer().removeFromRoom(room.id, req.user.id);
++ applyRoomChange(room);
++ res.json({ ok: true });
++ } catch (err) { send(res, err); }
});
router.post('/:slug/read', requireAuth, (req, res) => {
@@@ -98,7 +134,8 @@ router.post('/:slug/read', requireAuth, (req, res) => {
router.get('/:slug/members', optionalAuth, (req, res) => {
const room = readable(req, res); if (!room) return;
-- res.set('Cache-Control', 'private, no-store').json({ members: rooms.members(room).filter((m) => m.role !== 'blocked' || rooms.access(room, req.user || null).moderate) });
++ const moderate = rooms.access(room, req.user || null).moderate;
++ res.set('Cache-Control', 'private, no-store').json({ members: rooms.members(room, { seen: moderate }).filter((m) => m.role !== 'blocked' || moderate) });
});
router.post('/:slug/members', requireAuth, async (req, res) => {
@@@ -109,9 +146,32 @@ router.post('/:slug/members', requireAuth, async (req, res) => {
if (!target && /^[A-Za-z0-9_]{3,24}$/.test(name)) target = await ctx.ensureUserByUsername(name).catch(() => null);
if (!target) return res.status(404).json({ error: `Nobody called ${name.slice(0, 24)} on OpenVibe`, code: 'rooms.no_user' });
const role = rooms.setRole(room, req.user, target.id, String((req.body && req.body.role) || 'member'));
-- if (role === 'blocked' || role === 'none' || (room.visibility === 'private' && role === 'none')) chatServer().removeFromRoom(room.id, target.id);
++ if (role === 'blocked' || (room.visibility === 'private' && role === 'none')) chatServer().removeFromRoom(room.id, target.id);
++ applyRoomChange(room);
res.json({ ok: true, user_id: target.id, username: target.username, role });
} catch (err) { send(res, err); }
});
++router.get('/:slug/attachments', requireAuth, (req, res) => {
++ const room = readable(req, res); if (!room) return;
++ if (!rooms.access(room, req.user).manage) return res.status(403).json({ error: 'Only the room\'s owner sees where it is attached', code: 'rooms.not_owner' });
++ res.set('Cache-Control', 'private, no-store').json({ attachments: rooms.attachments(room) });
++});
++
++router.post('/:slug/attachments', requireAuth, personOnly, (req, res) => {
++ const room = readable(req, res); if (!room) return;
++ try {
++ const b = req.body || {};
++ const out = rooms.attach(room, req.user, { service: b.service, resource: b.resource, title: b.title });
++ res.status(out.created ? 201 : 200).json({ ...out, room: rooms.publicRoom(room) });
++ } catch (err) { send(res, err); }
++});
++
++router.delete('/:slug/attachments/:service/:resource', requireAuth, personOnly, (req, res) => {
++ const room = readable(req, res); if (!room) return;
++ try { res.json({ ok: true, ...rooms.detach(room, req.user, req.params.service, req.params.resource) }); } catch (err) { send(res, err); }
++});
++
module.exports = router;
++module.exports.callOf = callOf;
++module.exports.applyRoomChange = applyRoomChange;
ddiff --git a/server/web/layout.js b/server/web/layout.js
iindex d15544e..b395bbe 100644
---- a/server/web/layout.js
++++ b/server/web/layout.js
@@@ -35,11 +35,17 @@ function asset(rel) {
return `/web/${rel}?v=${hashes.get(rel)}`;
}
++/** The site's links, with the signed-in person's unread counts (o.counts: { rooms, messages }). */
++function links(o) {
++ const counts = o.counts || {};
++ return LINKS.map((l) => ({ key: l.key, href: l.href, label: counts[l.key] > 0 ? `${l.label} (${counts[l.key] > 99 ? '99+' : counts[l.key]})` : l.label }));
++}
++
function navConfig(o, config) {
return {
service: 'chat',
apiBase: NETWORK_URL,
-- links: LINKS.map((l) => ({ label: l.label, href: l.href, active: o.active === l.key })),
++ links: links(o).map((l) => ({ label: l.label, href: l.href, active: o.active === l.key })),
history: { type: 'page', title: o.title || SITE_NAME },
silentLogin: `${config.web.baseUrl}/auth/login?silent=1&next={url}`,
sessionUrl: '/auth/me',
@@@ -50,7 +56,8 @@ function navConfig(o, config) {
/**
* o: title, description, path, robots (required), body, active, actor, config, page (JSON for chat.js),
-- * prefs (the person's chat preferences: timestamps, compact, font_scale, show_badges)
++ * prefs (the person's chat preferences: timestamps, compact, font_scale, show_badges), counts (unread
++ * rooms/messages for the navigation), script / callScript (public/web/chat.js, call.js)
*/
function renderPage(o) {
const { config } = o;
@@@ -79,11 +86,12 @@ ${appIcon.headTags({ site: 'network' })}
<script src="${ovServe.url('navbar.js')}" defer></script>
<script src="${ovServe.url('footer.js')}" defer></script>
${o.script ? `<script src="${asset('chat.js')}" defer></script>` : ''}
++${o.callScript ? `<script src="${asset('call.js')}" defer></script>` : ''}
</head>
<body class="${cls}"${scale}>
<a class="oc-skip" href="#main">Skip to content</a>
<div id="navbar-mount"></div>
--${frame.noscriptNav({ name: SITE_NAME, home: '/', links: LINKS.map(({ label, href }) => ({ label, href })) })}
++${frame.noscriptNav({ name: SITE_NAME, home: '/', links: links(o).map(({ label, href }) => ({ label, href })) })}
<main id="main" class="oc-main">
<noscript><p class="oc-account">${who}</p></noscript>
${o.body || ''}
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 77a2cb7..44d36b5 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -6,8 +6,12 @@
* GET / global chat: the last messages server-rendered (readable without
* JavaScript); public/web/chat.js adds the live feed over /ws/chat
* POST /send the composer without JavaScript (the same handler as POST /api/chat/send)
-- * GET /messages your conversations; POST /messages/new starts one by username
-- * GET /messages/:id one conversation (participants only); POST /messages/:id replies
++ * GET /messages your inbox: conversations with unread counts, people you blocked;
++ * POST /messages/new starts one by username; POST /messages/unblock/:userId
++ * GET /messages/:id one conversation (participants only); POST /messages/:id replies;
++ * POST /messages/:id/block blocks the other person of a 1:1 conversation
++ * GET /rooms, /r/:slug rooms: community, call (a voice/video call in the page, public/web/call.js)
++ * and system (staff announcements); /r/:slug/settings for moderators
* GET /settings your portable chat settings (Network module chat.preferences); POST saves
* GET /updates what shipped on openvibe.chat (the shared changelog)
* GET /robots.txt, /sitemap.xml
@@@ -16,6 +20,11 @@
* Forms go through the same API handlers the JavaScript client calls (apiBridge), so rate limits,
* bans, blocks, spam checks and DM participation are enforced in one place. Posts must come from this
* site (Origin check; the session cookie is SameSite=Lax).
++ *
++ * Working with Live down: nothing here waits on OpenVibe.Live. People are signed in from their Network
++ * token and the ctx_users projection (auth/network-session.js), and every page reads Chat's own
++ * tables; the only openvibe.live URLs are links to people's profiles (navigation, never fetched).
++ * test/live-down.test.js renders every page and uses the core APIs with Live refusing and hanging.
*/
const path = require('path');
const express = require('express');
@@@ -110,10 +119,15 @@ function createWebRoutes({ config }) {
if (o && o !== siteOrigin && o !== 'null') return res.status(403).type('text').send('Forms are only accepted from this site.');
next();
};
++ const countsOf = (actor) => {
++ if (actor.kind !== 'user') return {};
++ const n = (fn) => { try { return Number(fn()) || 0; } catch { return 0; } };
++ return { messages: n(() => dm.getTotalUnread(actor.user.id)), rooms: n(() => require('../rooms/rooms').unreadTotal(actor.user)) };
++ };
const page = async (req, res, status, o) => {
const actor = o.actor || await viewerOf(req);
res.status(status).set('Cache-Control', o.cache || 'private, no-store').type('html')
-- .send(renderPage({ config, actor, prefs: await prefsOf(actor), ...o }));
++ .send(renderPage({ config, actor, prefs: await prefsOf(actor), counts: countsOf(actor), ...o }));
};
const needUser = async (req, res) => {
const actor = await viewerOf(req);
@@@ -179,14 +193,29 @@ ${composer}
return `<li class="oc-conv${c.unread_count ? ' oc-unread' : ''}"><a href="/messages/${c.id}"><strong>${esc(title)}</strong>${c.unread_count ? ` <span class="oc-badge">${c.unread_count} new</span>` : ''}<span class="oc-muted oc-last">${c.last_message ? esc(String(c.last_message).slice(0, 120)) : 'No messages yet'}</span></a>${t.text ? `<time class="oc-time" datetime="${t.iso}">${t.iso.slice(0, 10)}</time>` : ''}</li>`;
});
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
++ const done = req.query.unblocked ? notice('ok', 'Unblocked. They can message you again, and you them.') : '';
++ const blocked = dm.getBlockedUsers(me.id);
++ const blockedList = blocked.length ? `<details class="oc-blocked"><summary>People you blocked (${blocked.length})</summary><ul class="oc-members">${blocked.map((b) => `<li><strong>${esc(b.display_name || b.username)}</strong> <span class="oc-muted">@${esc(b.username || '')}</span><form class="oc-inline" method="post" action="/messages/unblock/${Number(b.id)}"><button type="submit" class="oc-link">Unblock</button></form></li>`).join('')}</ul></details>` : '';
++ const unread = convs.reduce((n, c) => n + (Number(c.unread_count) || 0), 0);
await page(req, res, 200, {
-- actor, title: 'Messages', path: '/messages', active: 'messages', robots: 'noindex, nofollow',
-- body: `<h1>Messages</h1>${error}
++ actor, title: 'Messages', path: '/messages', active: 'messages', robots: 'noindex, nofollow', script: true,
++ page: { view: 'inbox', unread, me: me.id },
++ body: `<h1>Messages</h1>${error}${done}
++<p class="oc-muted" id="oc-inbox-status" aria-live="polite">${unread ? `${unread} unread` : 'All caught up'}</p>
<form class="oc-new" method="post" action="/messages/new"><label for="oc-to">Message someone</label> <input id="oc-to" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="their username" autocomplete="off"> <button type="submit">Start</button></form>
--<ul class="oc-convs">${rows.join('\n') || '<li class="oc-muted">No conversations yet.</li>'}</ul>`,
++<ul class="oc-convs" id="oc-convs">${rows.join('\n') || '<li class="oc-muted">No conversations yet.</li>'}</ul>
++${blockedList}`,
});
});
++ router.post('/messages/unblock/:userId', formLimit, sameSite, async (req, res) => {
++ const actor = await needUser(req, res); if (!actor) return;
++ const id = parseInt(req.params.userId, 10);
++ if (Number.isInteger(id) && id > 0) dm.unblockUser(actor.user.id, id);
++ const back = /^\/messages\/\d+$/.test(String(req.body && req.body.back)) ? req.body.back : '/messages?unblocked=1';
++ res.redirect(303, back);
++ });
++
router.post('/messages/new', formLimit, sameSite, async (req, res, next) => {
const actor = await needUser(req, res); if (!actor) return;
const name = String((req.body && req.body.username) || '').trim();
@@@ -212,15 +241,36 @@ ${composer}
const msgs = dm.getMessages(id, 60).slice().sort((a, b) => a.id - b.id);
try { dm.markRead(id, me.id); } catch { /* best effort */ }
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
++ // A 1:1 conversation: block or unblock the other person here; a block (either way) closes the composer.
++ const other = conv && !conv.is_group && others.length === 1 ? others[0] : null;
++ const iBlocked = other ? dm.hasBlocked(me.id, other.id) : false;
++ const closed = other ? dm.isBlockedEither(me.id, other.id) : false;
++ const otherName = other ? esc(other.display_name || other.username) : '';
++ let composer = `<form class="oc-compose" method="post" action="/messages/${id}" id="oc-compose"><label for="oc-input" class="oc-sr">Reply</label><textarea id="oc-input" name="message" maxlength="2000" rows="2" required placeholder="Write a message"></textarea><button type="submit">Send</button></form>`;
++ if (iBlocked) composer = `${notice('error', `You blocked ${other.display_name || other.username}. Neither of you can message the other.`)}<form class="oc-inline" method="post" action="/messages/unblock/${Number(other.id)}"><input type="hidden" name="back" value="/messages/${id}"><button type="submit">Unblock ${otherName}</button></form>`;
++ else if (closed) composer = notice('error', 'You cannot message this person.');
++ const tools = other && !iBlocked ? `<form class="oc-inline oc-tools" method="post" action="/messages/${id}/block"><button type="submit" class="oc-link">Block ${otherName}</button></form>` : '';
await page(req, res, 200, {
actor, title, path: `/messages/${id}`, active: 'messages', robots: 'noindex, nofollow', script: true,
page: { view: 'dm', conversation: id, latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me.id },
body: `<p><a href="/messages">← Messages</a></p><h1>${esc(title)}</h1>${error}
<ol class="oc-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => dmMessage(m, me)).join('\n') || '<li class="oc-empty oc-muted">No messages yet.</li>'}</ol>
--<form class="oc-compose" method="post" action="/messages/${id}" id="oc-compose"><label for="oc-input" class="oc-sr">Reply</label><textarea id="oc-input" name="message" maxlength="2000" rows="2" required placeholder="Write a message"></textarea><button type="submit">Send</button></form>`,
++${composer}${tools}`,
});
});
++ router.post('/messages/:id/block', formLimit, sameSite, async (req, res) => {
++ const actor = await needUser(req, res); if (!actor) return;
++ const me = actor.user;
++ const id = parseInt(req.params.id, 10);
++ if (!Number.isInteger(id) || !dm.isParticipant(id, me.id)) return res.redirect(303, '/messages');
++ const conv = dm.getConversation(id);
++ const others = dm.getParticipants(id).filter((p) => p.id !== me.id);
++ if (!conv || conv.is_group || others.length !== 1) return res.redirect(303, `/messages/${id}?error=${encodeURIComponent('Only a one-to-one conversation has someone to block')}`);
++ dm.blockUser(me.id, others[0].id);
++ res.redirect(303, `/messages/${id}`);
++ });
++
router.post('/messages/:id', formLimit, sameSite, async (req, res, next) => {
if (!(await needUser(req, res))) return;
const id = parseInt(req.params.id, 10);
@@@ -304,34 +354,46 @@ ${volume('tts_sound_volume', 'Chat sound volume', tts.sound_volume)}
// ── Rooms (server/rooms/) ──
const rooms = require('../rooms/rooms');
++ const roomRoutes = require('../rooms/routes');
const roomsOrError = (fn) => async (req, res, next) => {
try { return await fn(req, res, next); } catch (err) {
if (err instanceof rooms.RoomError) return res.redirect(303, `${req.roomBack || '/rooms'}?error=${encodeURIComponent(err.message)}`);
return next(err);
}
};
-- const roomCard = (r) => `<li class="oc-conv${r.unread ? ' oc-unread' : ''}"><a href="/r/${esc(r.slug)}"><strong>${esc(r.name)}</strong>${r.visibility === 'private' ? ' <span class="oc-badge">private</span>' : ''}${r.unread ? ` <span class="oc-badge">${r.unread} new</span>` : ''}<span class="oc-muted oc-last">${esc(r.topic || `${r.members} member${r.members === 1 ? '' : 's'}`)}</span></a></li>`;
++ const KIND_LABEL = { community: '', call: 'call', system: 'announcements' };
++ const ROLE_LABEL = { owner: 'owner', mod: 'moderator', speaker: 'speaker', participant: 'participant', member: 'member', viewer: 'viewer', blocked: 'blocked' };
++ const kindBadge = (r) => (KIND_LABEL[r.kind] ? ` <span class="oc-badge oc-kind-${esc(r.kind)}">${KIND_LABEL[r.kind]}</span>` : '');
++ const roomCard = (r) => `<li class="oc-conv${r.unread ? ' oc-unread' : ''}"><a href="/r/${esc(r.slug)}"><strong>${esc(r.name)}</strong>${kindBadge(r)}${r.visibility === 'private' ? ' <span class="oc-badge">private</span>' : ''}${r.unread ? ` <span class="oc-badge">${r.unread} new</span>` : ''}<span class="oc-muted oc-last">${esc(r.topic || `${r.members} member${r.members === 1 ? '' : 's'}`)}</span></a></li>`;
const roomMessage = (m, can, slug, me) => {
const t = hhmm(m.created_at);
const del = (can.moderate || (me && m.user_id === me.id)) ? `<form class="oc-del" method="post" action="/r/${esc(slug)}/delete/${Number(m.id)}"><button type="submit" title="Delete this message" aria-label="Delete this message">×</button></form>` : '';
-- return `<li class="oc-msg" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> <a class="oc-name" href="${LIVE}/@${encodeURIComponent(m.username || '')}"${/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? ` style="--nc:${/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? esc(m.profile_color) : 'inherit'}"` : ''}>${esc(m.display_name || m.username || 'someone')}</a>${roleBadge(m.user_role)} <span class="oc-text">${linkify(m.message)}</span>${del}</li>`;
++ return `<li class="oc-msg" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> <a class="oc-name" href="${LIVE}/@${encodeURIComponent(m.username || '')}"${/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? ` style="--nc:${esc(m.profile_color)}"` : ''}>${esc(m.display_name || m.username || 'someone')}</a>${roleBadge(m.user_role)} <span class="oc-text">${linkify(m.message)}</span>${del}</li>`;
};
++ const applyRoomChange = (room) => roomRoutes.applyRoomChange(room);
router.get('/rooms', async (req, res) => {
const actor = await viewerOf(req);
const me = actor.kind === 'user' ? actor.user : null;
const { public: pub, mine } = rooms.list(me);
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
++ const staff = !!me && permissions.can(me, 'staff.moderation.chat');
const create = me ? `<form class="oc-form oc-create" method="post" action="/rooms/new">
<h2>Start a room</h2>
<label>Name <input name="name" required minlength="3" maxlength="40" placeholder="Night Owls"></label>
<label>Topic <input name="topic" maxlength="200" placeholder="What it is about (optional)"></label>
++<fieldset class="oc-fieldset"><legend>Kind</legend>
++<label class="oc-check"><input type="radio" name="kind" value="community" checked> Chat room: people write to each other</label>
++<label class="oc-check"><input type="radio" name="kind" value="call"> Call room: a voice call with a text chat beside it</label>
++${staff ? '<label class="oc-check"><input type="radio" name="kind" value="system"> Announcements: only OpenVibe staff post (staff)</label>' : ''}
++</fieldset>
++<label>In a call room, people who join <select name="join_role"><option value="participant">listen and write (participants)</option><option value="speaker">can talk (speakers)</option><option value="viewer">listen and read (viewers)</option></select></label>
<label class="oc-check"><input type="checkbox" name="private" value="1"> Private: only people you invite can read and post</label>
<p><button type="submit">Create room</button></p>
</form>` : `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=%2Frooms">Sign in with OpenVibe</a> to start or join a room.</p>`;
await page(req, res, 200, {
actor, title: 'Rooms', path: '/rooms', active: 'rooms', robots: 'index, follow',
-- description: 'Chat rooms on OpenVibe: start one about anything, invite people, or join a public room.',
++ description: 'Chat and call rooms on OpenVibe: start one about anything, invite people, or join a public room.',
body: `<h1>Rooms</h1>${error}
${mine.length ? `<h2>Your rooms</h2><ul class="oc-convs">${mine.map(roomCard).join('')}</ul>` : ''}
<h2>Public rooms</h2><ul class="oc-convs">${pub.map(roomCard).join('') || '<li class="oc-muted">No rooms yet. Start the first one.</li>'}</ul>
@@@ -342,7 +404,8 @@ ${create}`,
router.post('/rooms/new', formLimit, sameSite, roomsOrError(async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const b = req.body || {};
-- const room = rooms.create(actor.user, { name: b.name, topic: b.topic, visibility: b.private === '1' ? 'private' : 'public' });
++ const kind = rooms.KINDS.includes(b.kind) ? b.kind : 'community';
++ const room = rooms.create(actor.user, { name: b.name, topic: b.topic, visibility: b.private === '1' ? 'private' : 'public', kind, join_role: kind === 'call' ? b.join_role || null : null });
res.redirect(303, `/r/${room.slug}`);
}));
@@@ -356,28 +419,55 @@ ${create}`,
return { actor, me, room, can };
};
++ /** A call room's call: who is in, and the button public/web/call.js turns into the call. */
++ function callPanel(room, can, me) {
++ const call = roomRoutes.callOf(room) || { enabled: false, participants: 0, people: [] };
++ if (!call.enabled) return `<section class="oc-call" aria-labelledby="oc-call-h"><h2 id="oc-call-h">Call</h2><p class="oc-muted">Calls are not available right now. The room's chat works as usual.</p></section>`;
++ const people = call.people.length
++ ? `<ul class="oc-call-people" id="oc-call-people">${call.people.map((p) => `<li>${esc(p.display_name || p.username || 'Guest')}${p.listening ? ' <span class="oc-muted">(listening)</span>' : ''}</li>`).join('')}</ul>`
++ : '<ul class="oc-call-people" id="oc-call-people"><li class="oc-muted">Nobody is in the call yet.</li></ul>';
++ const how = can.talk ? 'You can talk in this call.' : me ? 'You join listening. The owner or a moderator can make you a speaker.' : 'Anyone can listen. Sign in to be made a speaker.';
++ return `<section class="oc-call" aria-labelledby="oc-call-h" id="oc-call">
++<h2 id="oc-call-h">Call <span class="oc-muted oc-call-count" id="oc-call-count">${call.participants ? `· ${call.participants} in` : ''}</span></h2>
++<p class="oc-muted" id="oc-call-how">${how}</p>
++${people}
++<p class="oc-call-controls"><button type="button" id="oc-call-join" hidden>${can.talk ? 'Join the call' : 'Listen in'}</button> <button type="button" id="oc-call-mute" hidden aria-pressed="false">Mute</button> <button type="button" id="oc-call-leave" hidden>Leave the call</button></p>
++<p class="oc-muted oc-call-status" id="oc-call-status" role="status"></p>
++<noscript><p class="oc-muted">The call needs JavaScript; the room's chat works without it.</p></noscript>
++</section>`;
++ }
++
router.get('/r/:slug', async (req, res) => {
const x = await loadRoom(req, res); if (!x) return;
const { actor, me, room, can } = x;
const msgs = rooms.history(room, { limit: 60 });
if (me && can.role) rooms.markRead(room, me);
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
++ const joinLabel = room.kind === 'system' ? 'Follow these announcements' : 'Join this room';
let composer;
if (!me) composer = `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=${encodeURIComponent(`/r/${room.slug}`)}">Sign in with OpenVibe</a> to join the conversation.</p>`;
-- else if (can.post) composer = `<form class="oc-compose" method="post" action="/r/${esc(room.slug)}" id="oc-compose"><label for="oc-input" class="oc-sr">Message to ${esc(room.name)}</label><textarea id="oc-input" name="message" maxlength="2000" rows="2" required placeholder="Message ${esc(room.name)}"></textarea><button type="submit">Send</button></form>`;
-- else if (!can.role) composer = `<form method="post" action="/r/${esc(room.slug)}/join"><button type="submit">Join this room</button></form>`;
++ else if (can.post) composer = `<form class="oc-compose" method="post" action="/r/${esc(room.slug)}" id="oc-compose"><label for="oc-input" class="oc-sr">Message to ${esc(room.name)}</label><textarea id="oc-input" name="message" maxlength="2000" rows="2" required placeholder="${room.kind === 'system' ? 'Post an announcement' : `Message ${esc(room.name)}`}"></textarea><button type="submit">Send</button></form>`;
++ else if (!can.role) composer = `<form method="post" action="/r/${esc(room.slug)}/join"><button type="submit">${joinLabel}</button></form>`;
++ else if (room.kind === 'system') composer = `<p class="oc-muted">Announcements from OpenVibe staff. You will see new ones counted in Rooms.</p>`;
++ else if (can.role === 'viewer') composer = `<p class="oc-muted">You can read this room but not write in it (viewer).</p>`;
else composer = notice('error', 'You cannot post here right now.');
const tools = me && can.role && can.role !== 'owner' ? `<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/leave"><button type="submit" class="oc-link">Leave room</button></form>` : '';
const manage = can.moderate ? ` · <a href="/r/${esc(room.slug)}/settings">${can.manage ? 'Room settings' : 'Members'}</a>` : '';
++ const YOU_ARE = { owner: 'the owner', mod: 'a moderator', speaker: 'a speaker', participant: 'a participant', member: 'a member', viewer: 'a viewer' };
++ const yourRole = me && YOU_ARE[can.role] ? ` · you are ${YOU_ARE[can.role]}` : '';
++ const isCall = room.kind === 'call';
++ const callReady = isCall && !!require('../config').calls.enabled;
await page(req, res, 200, {
-- actor, title: room.name, path: `/r/${room.slug}`, active: 'rooms', robots: room.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true,
-- description: room.topic || `${room.name}, a chat room on OpenVibe.`,
-- page: { view: 'room', room: room.slug, latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me ? me.id : null, moderate: can.moderate },
++ actor, title: room.name, path: `/r/${room.slug}`, active: 'rooms', robots: room.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady,
++ description: room.topic || `${room.name}, a ${isCall ? 'call' : room.kind === 'system' ? 'announcements' : 'chat'} room on OpenVibe.`,
++ page: { view: 'room', room: room.slug, kind: room.kind, latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me ? me.id : null, moderate: can.moderate, post: can.post, role: can.role,
++ ...(callReady ? { call: { channel: `room-${room.slug}`, join: can.join, talk: can.talk } } : {}) },
body: `<p><a href="/rooms">← Rooms</a></p>
--<h1>${esc(room.name)}${room.visibility === 'private' ? ' <span class="oc-badge">private</span>' : ''}</h1>
--<p class="oc-muted">${room.topic ? `${esc(room.topic)} · ` : ''}${rooms.publicRoom(room).members} members${room.slow_seconds ? ` · slow mode ${room.slow_seconds}s` : ''}${manage} <span class="oc-live" id="oc-live" hidden>● live</span></p>
++<h1>${esc(room.name)}${kindBadge(room)}${room.visibility === 'private' ? ' <span class="oc-badge">private</span>' : ''}</h1>
++<p class="oc-muted">${room.topic ? `${esc(room.topic)} · ` : ''}${rooms.publicRoom(room).members} members${room.slow_seconds ? ` · slow mode ${room.slow_seconds}s` : ''}${yourRole}${manage} <span class="oc-live" id="oc-live" hidden>● live</span></p>
${error}
--<ol class="oc-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => roomMessage(m, can, room.slug, me)).join('\n') || '<li class="oc-empty oc-muted">No messages yet. Say hello.</li>'}</ol>
++${isCall ? callPanel(room, can, me) : ''}
++<ol class="oc-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => roomMessage(m, can, room.slug, me)).join('\n') || `<li class="oc-empty oc-muted">${room.kind === 'system' ? 'No announcements yet.' : 'No messages yet. Say hello.'}</li>`}</ol>
${composer}${tools}`,
});
});
@@@ -385,7 +475,7 @@ ${composer}${tools}`,
router.post('/r/:slug', formLimit, sameSite, roomsOrError(async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const room = rooms.bySlug(req.params.slug);
-- if (!room) return res.redirect(303, '/rooms');
++ if (!room || !rooms.access(room, actor.user).read) return res.redirect(303, '/rooms');
req.roomBack = `/r/${room.slug}`;
const message = rooms.post(room, actor.user, req.body && req.body.message);
require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });
@@@ -395,9 +485,10 @@ ${composer}${tools}`,
router.post('/r/:slug/join', formLimit, sameSite, roomsOrError(async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const room = rooms.bySlug(req.params.slug);
-- if (!room) return res.redirect(303, '/rooms');
++ if (!room || !rooms.access(room, actor.user).read) return res.redirect(303, '/rooms');
req.roomBack = `/r/${room.slug}`;
rooms.join(room, actor.user);
++ applyRoomChange(room);
res.redirect(303, `/r/${room.slug}`);
}));
@@@ -408,13 +499,14 @@ ${composer}${tools}`,
req.roomBack = `/r/${room.slug}`;
rooms.leave(room, actor.user);
require('../chat/chat-server').removeFromRoom(room.id, actor.user.id);
++ applyRoomChange(room);
res.redirect(303, '/rooms');
}));
router.post('/r/:slug/delete/:id', formLimit, sameSite, roomsOrError(async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const room = rooms.bySlug(req.params.slug);
-- if (!room) return res.redirect(303, '/rooms');
++ if (!room || !rooms.access(room, actor.user).read) return res.redirect(303, '/rooms');
req.roomBack = `/r/${room.slug}`;
const id = rooms.deleteMessage(room, actor.user, parseInt(req.params.id, 10));
require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message_deleted', room: room.slug, id });
@@@ -427,8 +519,18 @@ ${composer}${tools}`,
if (!can.moderate) return res.redirect(303, `/r/${room.slug}`);
const saved = req.query.saved ? notice('ok', 'Saved.') : '';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
-- const members = rooms.members(room);
-- const roleForm = (m) => m.role === 'owner' ? '<span class="oc-muted">owner</span>' : `<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/members"><input type="hidden" name="username" value="${esc(m.username || '')}"><select name="role" aria-label="Role for ${esc(m.username || '')}">${[...(can.manage ? ['mod'] : []), 'member', 'blocked', 'none'].map((r) => `<option value="${r}"${r === m.role ? ' selected' : ''}>${r === 'none' ? 'remove' : r}</option>`).join('')}</select> <button type="submit">Set</button></form>`;
++ const members = rooms.members(room, { seen: true });
++ const kindRoles = rooms.KIND_ROLES[room.kind] || rooms.KIND_ROLES.community;
++ const choices = [...kindRoles.filter((r) => r !== 'owner' && (r !== 'mod' || can.manage)), 'none'];
++ const roleOption = (r, current) => `<option value="${r}"${r === current ? ' selected' : ''}>${r === 'none' ? 'remove' : ROLE_LABEL[r] || r}</option>`;
++ const seen = (m) => { const t = hhmm(m.last_seen_at); return t.iso ? ` <span class="oc-muted">· seen <time datetime="${t.iso}">${t.iso.slice(0, 10)}</time></span>` : ''; };
++ const roleForm = (m) => m.role === 'owner' ? '<span class="oc-muted">owner</span>'
++ : (m.role === 'mod' && !can.manage) ? '<span class="oc-muted">moderator</span>'
++ : `<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/members"><input type="hidden" name="username" value="${esc(m.username || '')}"><select name="role" aria-label="Role for ${esc(m.username || '')}">${choices.map((r) => roleOption(r, m.role)).join('')}</select> <button type="submit">Set</button></form>`;
++ const addRole = rooms.joinRoleOf(room);
++ const attachments = can.manage ? rooms.attachments(room) : [];
++ const communityUrl = config.web.communityUrl;
++ const attachList = can.manage ? `<h2>Attached to</h2>${attachments.length ? `<ul class="oc-members">${attachments.map((a) => `<li><a href="${esc(a.service === 'community' ? `${communityUrl}/s/${encodeURIComponent(a.resource)}` : '#')}">${esc(a.title || a.resource)}</a> <span class="oc-muted">${esc(a.service === 'community' ? 'OpenVibe.Community space' : a.service)}</span><form class="oc-inline" method="post" action="/r/${esc(room.slug)}/attachments/${encodeURIComponent(a.service)}/${encodeURIComponent(a.resource)}/detach"><button type="submit" class="oc-link">Detach</button></form></li>`).join('')}</ul>` : '<p class="oc-muted">Not attached anywhere. A Community space you own can attach this room from the space\'s page.</p>'}` : '';
await page(req, res, 200, {
actor, title: `${room.name}: settings`, path: `/r/${room.slug}/settings`, active: 'rooms', robots: 'noindex, nofollow',
body: `<p><a href="/r/${esc(room.slug)}">← ${esc(room.name)}</a></p><h1>Room settings</h1>${saved}${error}
@@@ -436,35 +538,55 @@ ${can.manage ? `<form class="oc-form" method="post" action="/r/${esc(room.slug)}
<label>Name <input name="name" required minlength="3" maxlength="40" value="${esc(room.name)}"></label>
<label>Topic <input name="topic" maxlength="200" value="${esc(room.topic || '')}"></label>
<label>Who can read <select name="visibility"><option value="public"${room.visibility === 'public' ? ' selected' : ''}>Anyone (public)</option><option value="private"${room.visibility === 'private' ? ' selected' : ''}>Members only (private)</option></select></label>
++${room.kind === 'call' ? `<label>People who join <select name="join_role">${rooms.JOIN_ROLES_CALL.map((r) => `<option value="${r}"${r === addRole ? ' selected' : ''}>${{ speaker: 'can talk (speakers)', participant: 'listen and write (participants)', viewer: 'listen and read (viewers)' }[r]}</option>`).join('')}</select></label>` : ''}
<label>Slow mode (seconds between messages, 0 = off) <input name="slow_seconds" type="number" min="0" max="600" value="${Number(room.slow_seconds) || 0}"></label>
<p><button type="submit">Save</button></p></form>` : ''}
<h2>Members</h2>
--<form class="oc-new" method="post" action="/r/${esc(room.slug)}/members"><label for="oc-invite">Add someone</label> <input id="oc-invite" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="their username"><input type="hidden" name="role" value="member"> <button type="submit">Add</button></form>
--<ul class="oc-members">${members.map((m) => `<li><strong>${esc(m.display_name || m.username || '?')}</strong> <span class="oc-muted">@${esc(m.username || '')}</span> ${roleForm(m)}</li>`).join('')}</ul>`,
++<p class="oc-muted">${esc(roleHelp(room.kind))}</p>
++<form class="oc-new" method="post" action="/r/${esc(room.slug)}/members"><label for="oc-invite">Add someone</label> <input id="oc-invite" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="their username"> <select name="role" aria-label="Their role">${choices.filter((r) => r !== 'none' && r !== 'blocked').map((r) => roleOption(r, addRole)).join('')}</select> <button type="submit">Add</button></form>
++<ul class="oc-members">${members.map((m) => `<li><strong>${esc(m.display_name || m.username || '?')}</strong> <span class="oc-muted">@${esc(m.username || '')}</span>${seen(m)} ${roleForm(m)}</li>`).join('')}</ul>
++${attachList}`,
});
});
++ function roleHelp(kind) {
++ if (kind === 'call') return 'Speakers, moderators and the owner talk in the call; participants listen and write in the chat; viewers listen and read.';
++ if (kind === 'system') return 'Only the owner and moderators post; everyone else reads (viewers).';
++ return 'Members write; viewers only read; moderators delete messages and manage members.';
++ }
++
router.post('/r/:slug/settings', formLimit, sameSite, roomsOrError(async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const room = rooms.bySlug(req.params.slug);
-- if (!room) return res.redirect(303, '/rooms');
++ if (!room || !rooms.access(room, actor.user).read) return res.redirect(303, '/rooms');
req.roomBack = `/r/${room.slug}/settings`;
const b = req.body || {};
-- rooms.update(room, actor.user, { name: b.name, topic: b.topic, visibility: b.visibility, slow_seconds: b.slow_seconds === undefined ? undefined : Number(b.slow_seconds) });
++ rooms.update(room, actor.user, { name: b.name, topic: b.topic, visibility: b.visibility, slow_seconds: b.slow_seconds === undefined ? undefined : Number(b.slow_seconds), join_role: room.kind === 'call' ? b.join_role : undefined });
++ applyRoomChange(room);
res.redirect(303, `/r/${room.slug}/settings?saved=1`);
}));
router.post('/r/:slug/members', formLimit, sameSite, roomsOrError(async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const room = rooms.bySlug(req.params.slug);
-- if (!room) return res.redirect(303, '/rooms');
++ if (!room || !rooms.access(room, actor.user).read) return res.redirect(303, '/rooms');
req.roomBack = `/r/${room.slug}/settings`;
const name = String((req.body && req.body.username) || '').trim();
let target = /^[A-Za-z0-9_]{3,24}$/.test(name) ? ctx.getUserByUsername(name) : null;
if (!target && /^[A-Za-z0-9_]{3,24}$/.test(name)) target = await ctx.ensureUserByUsername(name).catch(() => null);
if (!target) return res.redirect(303, `/r/${room.slug}/settings?error=${encodeURIComponent(`Nobody called ${name.slice(0, 24)} on OpenVibe`)}`);
-- const role = rooms.setRole(room, actor.user, target.id, String((req.body && req.body.role) || 'member'));
-- if (role === 'blocked' || role === 'none') require('../chat/chat-server').removeFromRoom(room.id, target.id);
++ const role = rooms.setRole(room, actor.user, target.id, String((req.body && req.body.role) || rooms.joinRoleOf(room)));
++ if (role === 'blocked' || (room.visibility === 'private' && role === 'none')) require('../chat/chat-server').removeFromRoom(room.id, target.id);
++ applyRoomChange(room);
++ res.redirect(303, `/r/${room.slug}/settings?saved=1`);
++ }));
++
++ router.post('/r/:slug/attachments/:service/:resource/detach', formLimit, sameSite, roomsOrError(async (req, res) => {
++ const actor = await needUser(req, res); if (!actor) return;
++ const room = rooms.bySlug(req.params.slug);
++ if (!room || !rooms.access(room, actor.user).read) return res.redirect(303, '/rooms');
++ req.roomBack = `/r/${room.slug}/settings`;
++ rooms.detach(room, actor.user, req.params.service, req.params.resource);
res.redirect(303, `/r/${room.slug}/settings?saved=1`);
}));
ddiff --git a/test/dm-inbox.test.js b/test/dm-inbox.test.js
nnew file mode 100644
iindex 0000000..d2f8a3c
---- /dev/null
++++ b/test/dm-inbox.test.js
@@@ -0,0 +1,128 @@
++'use strict';
++/**
++ * openvibe.chat's DM inbox (roadmap WS-I task 8, server/web/pages.js): the inbox lists your conversations
++ * with unread counts (and the navigation counts unread messages and room messages); a conversation is
++ * for its participants only, and a guessed id looks exactly like one that does not exist; blocking the
++ * other person of a 1:1 conversation closes it both ways (neither can send, nor start a new one, and the
++ * user search hides them), the inbox lists who you blocked, and unblocking opens it again; groups have
++ * no one to block.
++ */
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, suite } = require('./helpers');
++
++const t = suite('dm inbox');
++let h, alice, bob, carol, conv;
++const SITE = 'https://openvibe.chat';
++const cookie = (u) => ({ cookie: `ov_token=${u.token}` });
++const req = async (method, path, { headers = {}, raw } = {}) => {
++ const r = await fetch(`${h.base}${path}`, { method, headers, body: raw, redirect: 'manual' });
++ return { status: r.status, headers: r.headers, text: await r.text() };
++};
++const form = (u, fields, extra = {}) => ({ headers: { ...cookie(u), 'content-type': 'application/x-www-form-urlencoded', origin: SITE, ...extra }, raw: new URLSearchParams(fields).toString() });
++const dmApi = (method, path, u, body) => h.http(method, `/api/dm${path}`, { token: u && u.token, body });
++const navLabel = (page, key) => {
++ const cfg = JSON.parse(page.match(/window\.__OV_PAGE = (.*);\n/)[1]);
++ return cfg.navbar.links.find((l) => l.href === key).label;
++};
++
++t('boot', async () => {
++ h = await boot({ env: { CHAT_WEB_URL: SITE } });
++ alice = h.addUser('alice', { subject: ids.newId('user') });
++ bob = h.addUser('bob', { subject: ids.newId('user') });
++ carol = h.addUser('carol', { subject: ids.newId('user') });
++ for (const u of [alice, bob, carol]) { h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); }
++});
++
++t('the inbox: your conversations only, unread counts in the list and the navigation', async () => {
++ let r = await req('POST', '/messages/new', form(alice, { username: 'bob' }));
++ conv = Number(/^\/messages\/(\d+)$/.exec(r.headers.get('location'))[1]);
++ await req('POST', `/messages/${conv}`, form(alice, { message: 'hi bob' }));
++ await req('POST', `/messages/${conv}`, form(alice, { message: 'are you there?' }));
++ let inbox = await req('GET', '/messages', { headers: cookie(bob) });
++ assert.strictEqual(inbox.status, 200);
++ assert.match(inbox.text, /<span class="oc-badge">2 new<\/span>/);
++ assert.match(inbox.text, /id="oc-inbox-status"[^>]*>2 unread</);
++ assert.strictEqual(navLabel(inbox.text, '/messages'), 'Messages (2)');
++ assert.ok(inbox.text.includes('are you there?'), 'the last message as a preview');
++ const carolInbox = await req('GET', '/messages', { headers: cookie(carol) });
++ assert.ok(!carolInbox.text.includes(`/messages/${conv}`), 'not in anyone else\'s inbox');
++ assert.strictEqual(navLabel(carolInbox.text, '/messages'), 'Messages');
++ assert.ok(!(await dmApi('GET', '/conversations', carol)).body.conversations.some((c) => c.id === conv));
++
++ // Opening it reads it.
++ assert.strictEqual((await req('GET', `/messages/${conv}`, { headers: cookie(bob) })).status, 200);
++ inbox = await req('GET', '/messages', { headers: cookie(bob) });
++ assert.ok(!/\d+ new<\/span>/.test(inbox.text));
++ assert.strictEqual(navLabel(inbox.text, '/messages'), 'Messages');
++ assert.strictEqual((await dmApi('GET', '/unread', bob)).body.unread, 0);
++
++ // Rooms count too.
++ await h.http('POST', '/api/chat/rooms', { token: alice.token, body: { name: 'Book Club' } });
++ await h.http('POST', '/api/chat/rooms/book-club/join', { token: bob.token, body: {} });
++ for (const m of ['chapter one', 'chapter two', 'chapter three']) await h.http('POST', '/api/chat/rooms/book-club/messages', { token: alice.token, body: { message: m } });
++ assert.strictEqual(navLabel((await req('GET', '/', { headers: cookie(bob) })).text, '/rooms'), 'Rooms (3)');
++});
++
++t('participants only: a guessed conversation looks like no conversation', async () => {
++ const theirs = await req('GET', `/messages/${conv}`, { headers: cookie(carol) });
++ const none = await req('GET', '/messages/999999', { headers: cookie(carol) });
++ assert.deepStrictEqual([theirs.status, none.status], [404, 404]);
++ const h1 = (x) => (x.text.match(/<h1>(.*?)<\/h1>/) || [])[1];
++ assert.strictEqual(h1(theirs), h1(none), 'the same page either way');
++ assert.ok(!theirs.text.includes('hi bob'));
++ assert.strictEqual((await dmApi('GET', `/conversations/${conv}/messages`, carol)).status, 403);
++ assert.strictEqual((await dmApi('GET', `/conversations/${conv}`, carol)).status, 403);
++ const r = await req('POST', `/messages/${conv}`, form(carol, { message: 'let me in' }));
++ assert.match(r.headers.get('location'), /error=/);
++ assert.ok(!h.db.all('SELECT message FROM dm_messages WHERE conversation_id = ?', [conv]).some((m) => m.message === 'let me in'));
++ assert.strictEqual((await req('POST', `/messages/${conv}/block`, form(carol, {}))).headers.get('location'), '/messages', 'nor block from it');
++});
++
++t('block: the conversation closes both ways; the inbox lists who you blocked; unblock opens it again', async () => {
++ assert.strictEqual((await req('POST', `/messages/${conv}/block`, form(bob, {}, { origin: 'https://evil.example' }))).status, 403, 'forms from elsewhere are refused');
++ let page = await req('GET', `/messages/${conv}`, { headers: cookie(bob) });
++ assert.match(page.text, /Block Alice<\/button>/);
++ let r = await req('POST', `/messages/${conv}/block`, form(bob, {}));
++ assert.strictEqual(r.headers.get('location'), `/messages/${conv}`);
++
++ page = await req('GET', `/messages/${conv}`, { headers: cookie(bob) });
++ assert.match(page.text, /You blocked Alice/);
++ assert.match(page.text, /Unblock Alice<\/button>/);
++ assert.ok(!page.text.includes('id="oc-compose"'), 'no composer');
++ page = await req('GET', `/messages/${conv}`, { headers: cookie(alice) });
++ assert.match(page.text, /You cannot message this person\./);
++ assert.ok(!page.text.includes('id="oc-compose"'));
++ assert.ok(!page.text.includes('blocked'), 'the other side is not told who blocked whom');
++
++ r = await dmApi('POST', `/conversations/${conv}/messages`, alice, { message: 'still there?' });
++ assert.strictEqual(r.status, 403, 'blocked people cannot send');
++ r = await req('POST', `/messages/${conv}`, form(alice, { message: 'still there?' }));
++ assert.match(r.headers.get('location'), /error=/);
++ r = await req('POST', '/messages/new', form(alice, { username: 'bob' }));
++ assert.match(r.headers.get('location'), /^\/messages\?error=/, 'nor start a new conversation');
++ assert.strictEqual((await dmApi('POST', '/conversations', bob, { user_ids: [alice.id] })).status, 403, 'the blocker cannot either');
++ assert.ok(!(await dmApi('GET', '/users/search?q=bo', alice)).body.users.some((u) => u.id === bob.id), 'the user search hides them');
++
++ const inbox = await req('GET', '/messages', { headers: cookie(bob) });
++ assert.match(inbox.text, /People you blocked \(1\)/);
++ assert.ok(inbox.text.includes(`action="/messages/unblock/${alice.id}"`));
++ r = await req('POST', `/messages/unblock/${alice.id}`, form(bob, { back: `/messages/${conv}` }));
++ assert.strictEqual(r.headers.get('location'), `/messages/${conv}`);
++ r = await req('POST', `/messages/unblock/${alice.id}`, form(bob, { back: 'https://evil.example/' }));
++ assert.strictEqual(r.headers.get('location'), '/messages?unblocked=1', 'back goes to a conversation or the inbox, nowhere else');
++ assert.strictEqual((await dmApi('POST', `/conversations/${conv}/messages`, alice, { message: 'back again' })).status, 200);
++ assert.match((await req('GET', `/messages/${conv}`, { headers: cookie(bob) })).text, /back again/);
++});
++
++t('groups have no one to block', async () => {
++ const r = await dmApi('POST', '/conversations', alice, { user_ids: [bob.id, carol.id], name: 'Trio' });
++ assert.strictEqual(r.status, 200, r.text);
++ const gid = r.body.conversation.id;
++ const page = await req('GET', `/messages/${gid}`, { headers: cookie(alice) });
++ assert.ok(!/Block /.test(page.text));
++ assert.match((await req('POST', `/messages/${gid}/block`, form(alice, {}))).headers.get('location'), new RegExp(`^/messages/${gid}\\?error=`));
++ assert.strictEqual(h.db.get('SELECT COUNT(*) AS n FROM dm_blocks WHERE blocker_id = ?', [alice.id]).n, 0);
++});
++
++t.run(async () => { if (h && h.close) await h.close(); });
ddiff --git a/test/helpers.js b/test/helpers.js
iindex 00fce23..8ca7b33 100644
---- a/test/helpers.js
++++ b/test/helpers.js
@@@ -130,7 +130,8 @@ async function boot({ env = {} } = {}) {
effects: [], // { name, body }
mirror: [], // changes
mirrorStatus: 200,
-- down: false,
++ down: false, // Live refuses: every connection is dropped at once
++ hang: false, // Live hangs: connections are accepted and never answered
nextAnon: 1,
mediaState: { queue: [], now_playing: null },
seenTokens: [],
@@@ -156,6 +157,7 @@ async function boot({ env = {} } = {}) {
live.requests.push(p);
const send = (status, data) => { res.statusCode = status; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(data)); };
if (live.down) { req.socket.destroy(); return; }
++ if (live.hang) { live.hung = (live.hung || 0) + 1; return; }
if (p.startsWith('/internal/chat-context/')) {
const bad = verify(req, 'live.chat_context.read');
if (bad) return send(401, { error: bad });
@@@ -430,7 +432,7 @@ async function boot({ env = {} } = {}) {
try { h.chatServer.close(); } catch { /* */ }
try { h.ctx.stop(); h.mirrorRelay.stop(); h.eventsRelay.stop(); h.eventsConsumer.stop(); h.subscriptions.stop(); } catch { /* */ }
await new Promise((r) => h.server.close(() => r()));
-- network.close(); liveServer.close();
++ network.close(); liveServer.close(); if (liveServer.closeAllConnections) liveServer.closeAllConnections();
try { h.db.close(); } catch { /* */ }
fs.rmSync(tmp, { recursive: true, force: true });
};
ddiff --git a/test/live-down.test.js b/test/live-down.test.js
nnew file mode 100644
iindex 0000000..163acf6
---- /dev/null
++++ b/test/live-down.test.js
@@@ -0,0 +1,148 @@
++'use strict';
++/**
++ * openvibe.chat with OpenVibe.Live down (roadmap WS-I task 8): every page of the site renders and the core
++ * APIs work while Live refuses connections, and again while Live accepts them and never answers (a hung
++ * Live must not hold a page past the budget). People sign in with their Network token, resolved from
++ * the ctx_users projection, so nothing asks Live. Pages load nothing from openvibe.live (profile links
++ * are the only Live URLs, and they are links).
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const { ids } = require('openvibe-contracts');
++const { boot, suite, b64url } = require('./helpers');
++
++const t = suite('live down');
++let h, alice, bob, carol, aliceJwt, bobJwt, carolJwt, convId;
++const SITE = 'https://openvibe.chat';
++const now = () => Math.floor(Date.now() / 1000);
++const jwt = (u) => {
++ const head = `${b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))}.${b64url(JSON.stringify({ iss: h.ISS, sub: String(u.id + 7000), subject_id: u.subject_id, username: u.username, role: u.role, iat: now() - 5, exp: now() + 3600 }))}`;
++ return `${head}.${b64url(crypto.sign('RSA-SHA256', Buffer.from(head), h.keys.privateKey))}`;
++};
++const req = async (method, path, { token, headers = {}, raw, json } = {}) => {
++ const started = Date.now();
++ const r = await fetch(`${h.base}${path}`, {
++ method, redirect: 'manual',
++ headers: { ...(token ? { cookie: `ov_token=${token}` } : {}), ...(json !== undefined ? { 'content-type': 'application/json', authorization: `Bearer ${token}` } : {}), ...headers },
++ body: json !== undefined ? JSON.stringify(json) : raw,
++ });
++ const text = await r.text();
++ let body = null; try { body = JSON.parse(text); } catch { /* html */ }
++ return { status: r.status, headers: r.headers, text, body, ms: Date.now() - started };
++};
++const form = (token, fields) => ({ token, headers: { 'content-type': 'application/x-www-form-urlencoded', origin: SITE }, raw: new URLSearchParams(fields).toString() });
++const html = { accept: 'text/html' };
++
++/** No page may load anything from Live: scripts, styles, images, frames, form targets, fetches. */
++function assertNoLiveResources(page, path) {
++ for (const re of [/<(script|img|iframe|audio|video|source)[^>]+src="https?:\/\/(www\.)?openvibe\.live/i, /<link[^>]+href="https?:\/\/(www\.)?openvibe\.live/i, /<form[^>]+action="https?:\/\/(www\.)?openvibe\.live/i]) {
++ assert.ok(!re.test(page), `${path} loads something from openvibe.live (${re})`);
++ }
++}
++
++t('boot, and some of everything while Live is up', async () => {
++ h = await boot({ env: { CHAT_WEB_URL: SITE } });
++ alice = h.addUser('alice', { subject: ids.newId('user') });
++ bob = h.addUser('bob', { subject: ids.newId('user') });
++ carol = h.addUser('carol', { subject: ids.newId('user') });
++ for (const u of [alice, bob, carol]) { h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); }
++ [aliceJwt, bobJwt, carolJwt] = [alice, bob, carol].map(jwt);
++ let r = await req('POST', '/api/dm/conversations', { token: aliceJwt, json: { user_ids: [bob.id] } });
++ assert.strictEqual(r.status, 200, r.text);
++ convId = r.body.conversation.id;
++ await req('POST', `/api/dm/conversations/${convId}/messages`, { token: aliceJwt, json: { message: 'before the outage' } });
++ assert.strictEqual((await req('POST', '/api/chat/rooms', { token: aliceJwt, json: { name: 'Night Shift', topic: 'while Live sleeps' } })).status, 201);
++ assert.strictEqual((await req('POST', '/api/chat/rooms', { token: aliceJwt, json: { name: 'Quiet Corner', visibility: 'private' } })).status, 201);
++ assert.strictEqual((await req('POST', '/api/chat/send', { token: aliceJwt, json: { message: 'global before the outage' } })).status, 200);
++});
++
++async function everyPageAndApi(label, budgetMs) {
++ const pages = [
++ ['/', null], ['/', aliceJwt], ['/rooms', null], ['/rooms', aliceJwt], ['/r/night-shift', null], ['/r/night-shift', bobJwt],
++ ['/r/quiet-corner', aliceJwt], ['/r/night-shift/settings', aliceJwt], ['/messages', aliceJwt], [`/messages/${convId}`, bobJwt],
++ ['/settings', aliceJwt], ['/updates', null], ['/robots.txt', null], ['/sitemap.xml', null], ['/auth/me', aliceJwt],
++ ];
++ for (const [path, token] of pages) {
++ const r = await req('GET', path, { token, headers: html });
++ assert.strictEqual(r.status, 200, `${label}: GET ${path} → ${r.status}`);
++ assert.ok(r.ms < budgetMs, `${label}: GET ${path} took ${r.ms} ms`);
++ assertNoLiveResources(r.text, path);
++ }
++ assert.match((await req('GET', '/', { token: aliceJwt })).text, /id="oc-compose"/, `${label}: signed in from the Network token alone`);
++ assert.match((await req('GET', `/messages/${convId}`, { token: bobJwt })).text, /before the outage/);
++ assert.strictEqual((await req('GET', '/r/quiet-corner', { token: bobJwt })).status, 404, `${label}: private rooms stay private`);
++ assert.strictEqual((await req('GET', `/messages/${convId}`, { token: carolJwt })).status, 404, `${label}: so do conversations`);
++ assert.strictEqual((await req('GET', '/no-such-page', { headers: html })).status, 404);
++
++ // The forms, without JavaScript.
++ const posts = [
++ ['/send', aliceJwt, { message: `global during ${label}` }, /^\/#oc-compose$/],
++ ['/r/night-shift', aliceJwt, { message: `room during ${label}` }, /^\/r\/night-shift#oc-compose$/],
++ [`/messages/${convId}`, bobJwt, { message: `dm during ${label}` }, new RegExp(`^/messages/${convId}#oc-compose$`)],
++ ['/messages/new', carolJwt, { username: 'alice' }, /^\/messages\/\d+$/],
++ ];
++ for (const [path, token, fields, want] of posts) {
++ const r = await req('POST', path, form(token, fields));
++ assert.strictEqual(r.status, 303, `${label}: POST ${path} → ${r.status}`);
++ assert.match(r.headers.get('location'), want, `${label}: POST ${path} → ${r.headers.get('location')}`);
++ assert.ok(r.ms < budgetMs, `${label}: POST ${path} took ${r.ms} ms`);
++ }
++ const room = (await req('GET', '/r/night-shift')).text;
++ assert.ok(room.includes(`room during ${label}`));
++ assert.ok((await req('GET', '/')).text.includes(`global during ${label}`));
++
++ // The APIs the site's script and apps use.
++ const apis = [
++ ['GET', '/api/chat/rooms', aliceJwt], ['GET', '/api/chat/rooms/night-shift/messages', null], ['GET', '/api/chat/global/history?limit=20', null],
++ ['GET', '/api/dm/conversations', aliceJwt], ['GET', `/api/dm/conversations/${convId}/messages`, aliceJwt], ['GET', '/api/dm/unread', bobJwt],
++ ['GET', '/api/chat/ice-servers', null],
++ ];
++ for (const [method, path, token] of apis) {
++ const r = await req(method, path, { headers: token ? { authorization: `Bearer ${token}` } : {} });
++ assert.strictEqual(r.status, 200, `${label}: ${method} ${path} → ${r.status} ${r.text.slice(0, 120)}`);
++ assert.ok(r.ms < budgetMs, `${label}: ${method} ${path} took ${r.ms} ms`);
++ }
++ for (const [path, token, json] of [
++ ['/api/chat/rooms/night-shift/messages', bobJwt, null],
++ [`/api/dm/conversations/${convId}/messages`, aliceJwt, { message: `api dm during ${label}` }],
++ ['/api/chat/send', bobJwt, { message: `api global during ${label}` }],
++ ]) {
++ if (!json) await req('POST', '/api/chat/rooms/night-shift/join', { token, json: {} });
++ const r = await req('POST', path, { token, json: json || { message: `api room during ${label}` } });
++ assert.ok(r.status === 200 || r.status === 201, `${label}: POST ${path} → ${r.status} ${r.text.slice(0, 160)}`);
++ assert.ok(r.ms < budgetMs, `${label}: POST ${path} took ${r.ms} ms`);
++ }
++ const hist = await req('GET', '/api/chat/global/history?limit=50');
++ assert.ok(hist.body.messages.some((m) => m.message === `api global during ${label}`));
++
++ // The live feed: a signed-in socket (the browser's cookie, as on openvibe.chat) joins global chat and a
++ // room and posts there. Joining warms Live's caches for at most live-context's WARM_TIMEOUT_MS (2.5 s)
++ // per step, so a hung Live delays the feed by seconds; messages sent meanwhile wait, in order.
++ const socketBudget = label === 'hung' ? 9000 : budgetMs;
++ const ws = await h.ws({ origin: 'https://openvibe.live', bearer: aliceJwt });
++ ws.sendJson({ type: 'join' });
++ const auth = await ws.next((m) => m.type === 'auth', socketBudget);
++ assert.deepStrictEqual([auth.authenticated, auth.core_username], [true, 'alice'], `${label}: the socket signs in from the Network token`);
++ ws.sendJson({ type: 'join_room', room: 'night-shift' });
++ await ws.next((m) => m.type === 'room_joined', socketBudget);
++ ws.sendJson({ type: 'room_message', message: `socket during ${label}` });
++ await ws.next((m) => m.type === 'room_message' && m.message.message === `socket during ${label}`, socketBudget);
++ ws.close();
++}
++
++t('Live refuses connections: every page renders, the forms and the core APIs work', async () => {
++ h.live.down = true;
++ const before = h.live.requests.length;
++ await everyPageAndApi('refused', 2500);
++ h.live.down = false;
++ assert.ok(h.live.requests.length >= before, 'Live was down the whole time');
++});
++
++t('Live hangs (accepts, never answers): nothing waits on it past the budget', async () => {
++ h.live.hang = true;
++ await everyPageAndApi('hung', 3000);
++ assert.ok(h.live.hung > 0, 'Chat did ask the hung Live (and did not wait for it)');
++ h.live.hang = false;
++});
++
++t.run(async () => { if (h && h.close) await h.close(); });
ddiff --git a/test/room-kinds.test.js b/test/room-kinds.test.js
nnew file mode 100644
iindex 0000000..0157079
---- /dev/null
++++ b/test/room-kinds.test.js
@@@ -0,0 +1,356 @@
++'use strict';
++/**
++ * Room kinds and roles (WS-I task 4, server/rooms/rooms.js): community, call and system rooms; the
++ * roles owner, mod, speaker, participant, member, viewer and blocked with what each may do per kind
++ * (the whole matrix, public and private, signed out, staff, a site ban); roles belong to their kind and
++ * mods are the owner's; joining gives the kind's role. Call rooms (server/calls/call-server.js): the
++ * room decides who is in the call (private rooms look missing, blocked people are refused) and who
++ * talks (listeners are force-muted and cannot unmute); a role change reaches a running call at once;
++ * a ban in the call blocks the person in the room; room calls stay out of the voice-channel list.
++ * Attachments: a room's manager attaches it to a Community space with their own token, idempotently,
++ * and detaches it; nobody else can, and a private room stays a 404 to outsiders.
++ */
++const assert = require('assert');
++const WebSocket = require('ws');
++const { ids } = require('openvibe-contracts');
++const { boot, suite } = require('./helpers');
++
++const t = suite('room kinds');
++let h, rooms;
++let own, md, spk, par, mem, vw, blk, out, staff, banned, bot;
++const api = (method, path, u, body) => h.http(method, `/api/chat/rooms${path}`, { token: u && u.token, body });
++
++function callWs({ channelId, token = null, ip = '203.0.113.30' } = {}) {
++ return new Promise((resolve, reject) => {
++ const qs = new URLSearchParams({ channelId });
++ if (token) qs.set('token', token);
++ const ws = new WebSocket(`ws://127.0.0.1:${h.port}/ws/call?${qs}`, { headers: { 'cf-connecting-ip': ip, origin: 'https://openvibe.live' } });
++ const all = [];
++ const waiters = [];
++ ws.on('message', (d) => {
++ let m; try { m = JSON.parse(d.toString()); } catch { return; }
++ all.push(m);
++ for (const w of [...waiters]) if (w.pred(m)) { waiters.splice(waiters.indexOf(w), 1); clearTimeout(w.timer); w.resolve(m); }
++ });
++ ws.all = all;
++ ws.next = (pred, ms = 3000) => {
++ const hit = all.find((m) => pred(m) && !m.__taken);
++ if (hit) { hit.__taken = true; return Promise.resolve(hit); }
++ return new Promise((res, rej) => {
++ const w = { pred: (m) => { if (pred(m)) { m.__taken = true; return true; } return false; }, resolve: res };
++ w.timer = setTimeout(() => { waiters.splice(waiters.indexOf(w), 1); rej(new Error(`timed out waiting (got: ${JSON.stringify(all.map((x) => x.type).slice(-12))})`)); }, ms);
++ waiters.push(w);
++ });
++ };
++ ws.none = async (pred, ms = 300) => { await new Promise((r) => setTimeout(r, ms)); return !all.some(pred); };
++ ws.sendJson = (o) => ws.send(JSON.stringify(o));
++ ws.closed = new Promise((r) => ws.on('close', () => r(true)));
++ ws.on('open', () => resolve(ws));
++ ws.on('error', reject);
++ });
++}
++async function joinCall(opts) {
++ const ws = await callWs(opts);
++ const m = await ws.next((x) => x.type === 'welcome' || x.type === 'error');
++ ws.welcome = m.type === 'welcome' ? m : null;
++ ws.error = m.type === 'error' ? m.message : null;
++ return ws;
++}
++const bye = async (...sockets) => { for (const ws of sockets) if (ws && ws.readyState === WebSocket.OPEN) { ws.close(); await ws.closed; } await h.sleep(30); };
++
++t('boot (CHAT_CALLS=1)', async () => {
++ h = await boot({ env: { CHAT_CALLS: '1' } });
++ rooms = require('../server/rooms/rooms');
++ const mk = (name, extra = {}) => h.addUser(name, { subject: ids.newId('user'), ...extra });
++ own = mk('owner1'); md = mk('mod1'); spk = mk('speaker1'); par = mk('part1'); mem = mk('member1'); vw = mk('viewer1');
++ blk = mk('blocked1'); out = mk('outsider1'); staff = mk('staff1', { role: 'global_mod' }); banned = mk('banned1');
++ bot = h.addUser('bot1', { apiScopes: ['chat'] });
++ for (const u of [own, md, spk, par, mem, vw, blk, out, staff, banned, bot]) h.ctx.upsertUser(h.live.users.get(u.id));
++});
++
++t('the matrix: what every role may do in every kind, public and private', async () => {
++ // read, post, call join, talk, moderate, manage
++ const EXPECT = {
++ community: { owner: 'rp--mM', mod: 'rp--m-', member: 'rp----', viewer: 'r-----', blocked: '------' },
++ call: { owner: 'rpjtmM', mod: 'rpjtm-', speaker: 'rpjt--', participant: 'rpj---', viewer: 'r-j---', blocked: '------' },
++ system: { owner: 'rp--mM', mod: 'rp--m-', viewer: 'r-----', blocked: '------' },
++ };
++ const byRole = { mod: md, speaker: spk, participant: par, member: mem, viewer: vw, blocked: blk };
++ const flags = (a) => ['read', 'post', 'join', 'talk', 'moderate', 'manage'].map((k, i) => (a[k] ? 'rpjtmM'[i] : '-')).join('');
++ for (const kind of ['community', 'call', 'system']) {
++ for (const visibility of ['public', 'private']) {
++ const owner = kind === 'system' ? staff : own;
++ const room = rooms.bySlug(rooms.create(owner, { name: `M ${kind} ${visibility}`, kind, visibility }).slug);
++ assert.deepStrictEqual(rooms.KIND_ROLES[kind], Object.keys(EXPECT[kind]), `${kind}: its roles`);
++ for (const role of Object.keys(EXPECT[kind])) if (role !== 'owner') rooms.setRole(room, owner, byRole[role].id, role);
++ for (const [role, want] of Object.entries(EXPECT[kind])) {
++ const who = role === 'owner' ? owner : byRole[role];
++ const a = rooms.access(room, who);
++ assert.strictEqual(a.role, role, `${kind}/${visibility}: ${who.username} is ${role}`);
++ // A system room's owner is chat staff: they may post and manage anyway.
++ assert.strictEqual(flags(a), want, `${kind}/${visibility}/${role}`);
++ }
++ const pub = visibility === 'public';
++ const listen = kind === 'call' && pub ? 'j' : '-';
++ assert.strictEqual(flags(rooms.access(room, out)), pub ? `r-${listen}---` : '------', `${kind}/${visibility}: signed in, no role`);
++ assert.strictEqual(flags(rooms.access(room, null)), pub ? `r-${listen}---` : '------', `${kind}/${visibility}: signed out`);
++ if (kind !== 'system') assert.strictEqual(flags(rooms.access(room, staff)), `r-${kind === 'call' ? 'jt' : '--'}mM`, `${kind}/${visibility}: chat staff without a role`);
++ else assert.strictEqual(flags(rooms.access(room, staff)), 'rp--mM', 'system: staff post');
++ }
++ }
++ // A site ban: reading stays, posting and talking go, whatever the role.
++ const room = rooms.bySlug(rooms.create(own, { name: 'Banned Test', kind: 'call' }).slug);
++ rooms.setRole(room, own, banned.id, 'speaker');
++ assert.strictEqual(flags(rooms.access(room, banned)), 'rpjt--');
++ h.live.addBan({ user_id: banned.id });
++ await h.ctx.invalidateBans();
++ assert.strictEqual(flags(rooms.access(room, banned)), 'r-j---', 'banned: reads and listens only');
++ h.live.clearBans();
++ await h.ctx.invalidateBans();
++});
++
++t('roles belong to their kind, mods are the owner\'s, and joining gives the kind\'s role', async () => {
++ let r = await api('POST', '/', own, { name: 'Open Stage', kind: 'call' });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.deepStrictEqual([r.body.room.kind, r.body.room.join_role, r.body.room.call_channel], ['call', 'participant', 'room-open-stage']);
++ assert.deepStrictEqual([(await api('POST', '/', own, { name: 'My News', kind: 'system' })).status, (await api('POST', '/', own, { name: 'My News', kind: 'system' })).body.code], [403, 'rooms.kind'], 'people do not make system rooms');
++ assert.strictEqual((await api('POST', '/', staff, { name: 'OpenVibe News', kind: 'system' })).status, 201);
++ assert.strictEqual((await api('POST', '/', own, { name: 'Odd Join', join_role: 'speaker' })).body.code, 'rooms.join_role', 'join_role is for call rooms');
++ assert.strictEqual((await api('POST', '/', own, { name: 'Tea Talk' })).status, 201);
++
++ r = await api('POST', '/tea-talk/members', own, { username: 'speaker1', role: 'speaker' });
++ assert.deepStrictEqual([r.status, r.body.code], [422, 'rooms.role_kind'], 'no speakers in a community room');
++ assert.strictEqual((await api('POST', '/open-stage/members', own, { username: 'member1', role: 'member' })).body.code, 'rooms.role_kind', 'no members in a call room');
++ assert.strictEqual((await api('POST', '/openvibe-news/members', staff, { username: 'part1', role: 'participant' })).body.code, 'rooms.role_kind');
++
++ // Joining: community → member, call → the room's join role, system → viewer.
++ assert.strictEqual((await api('POST', '/tea-talk/join', mem)).body.role, 'member');
++ assert.strictEqual((await api('POST', '/open-stage/join', par)).body.role, 'participant');
++ assert.strictEqual((await api('POST', '/openvibe-news/join', out)).body.role, 'viewer');
++ r = await api('PATCH', '/open-stage', own, { join_role: 'speaker' });
++ assert.strictEqual(r.body.room.join_role, 'speaker');
++ assert.strictEqual((await api('POST', '/open-stage/join', spk)).body.role, 'speaker');
++ assert.strictEqual((await api('PATCH', '/tea-talk', own, { join_role: 'speaker' })).body.code, 'rooms.join_role');
++ await api('PATCH', '/open-stage', own, { join_role: 'participant' });
++
++ // Mods: the owner appoints them; a mod sets the other roles but not mods.
++ assert.strictEqual((await api('POST', '/open-stage/members', own, { username: 'mod1', role: 'mod' })).body.role, 'mod');
++ r = await api('POST', '/open-stage/members', md, { username: 'part1', role: 'speaker' });
++ assert.deepStrictEqual([r.status, r.body.role], [200, 'speaker'], 'a mod makes speakers');
++ assert.strictEqual((await api('POST', '/open-stage/members', md, { username: 'member1', role: 'mod' })).status, 403, 'only the owner appoints mods');
++ assert.strictEqual((await api('POST', '/open-stage/members', par, { username: 'member1', role: 'viewer' })).status, 403, 'a speaker is not a moderator');
++ await api('POST', '/open-stage/members', md, { username: 'part1', role: 'participant' });
++ const log = h.db.all("SELECT action_type, details FROM moderation_actions WHERE scope_type = 'room' AND scope_id = 'open-stage' ORDER BY id").map((x) => x.action_type);
++ assert.deepStrictEqual(log, ['room_mod_add', 'room_speaker_add', 'room_speaker_remove']);
++
++ // Posting follows the role: viewers and system-room viewers read only; staff post in system rooms.
++ assert.strictEqual((await api('POST', '/openvibe-news/messages', out, { message: 'can I?' })).body.code, 'rooms.read_only');
++ assert.strictEqual((await api('POST', '/openvibe-news/messages', staff, { message: 'Maintenance tonight at 22:00 UTC.' })).status, 201);
++ await api('POST', '/tea-talk/members', own, { username: 'viewer1', role: 'viewer' });
++ assert.strictEqual((await api('POST', '/tea-talk/messages', vw, { message: 'hello' })).body.code, 'rooms.read_only');
++ assert.strictEqual((await api('POST', '/open-stage/messages', par, { message: 'participants write' })).status, 201);
++ assert.strictEqual((await api('POST', '/open-stage/join', vw)).status, 200);
++ await api('POST', '/open-stage/members', own, { username: 'viewer1', role: 'viewer' });
++ assert.strictEqual((await api('POST', '/open-stage/messages', vw, { message: 'viewers do not' })).body.code, 'rooms.read_only');
++
++ // What the room page reads: the caller's powers and the call.
++ r = await api('GET', '/open-stage', par);
++ assert.deepStrictEqual(r.body.can, { read: true, post: true, join: true, talk: false, moderate: false, manage: false });
++ assert.deepStrictEqual([r.body.call.enabled, r.body.call.channel, r.body.call.participants], [true, 'room-open-stage', 0]);
++ assert.strictEqual((await api('GET', '/tea-talk', null)).body.call, undefined, 'community rooms have no call');
++
++ // last_seen_at: moderators see it, others do not.
++ const seenMod = (await api('GET', '/open-stage/members', own)).body.members.find((m) => m.username === 'part1');
++ assert.ok(seenMod.last_seen_at, 'posting counts as seen');
++ assert.ok(!('last_seen_at' in (await api('GET', '/open-stage/members', par)).body.members[0]), 'presence is for moderators');
++});
++
++t('call rooms: the room decides who is in the call and who talks', async () => {
++ const ownWs = await joinCall({ channelId: 'room-open-stage', token: own.token });
++ assert.ok(ownWs.welcome, ownWs.error);
++ assert.deepStrictEqual([ownWs.welcome.room, ownWs.welcome.roomRole, ownWs.welcome.canTalk, ownWs.welcome.canModerate], ['open-stage', 'owner', true, true]);
++
++ const parWs = await joinCall({ channelId: 'room-open-stage', token: par.token, ip: '203.0.113.31' });
++ assert.deepStrictEqual([parWs.welcome.roomRole, parWs.welcome.canTalk, parWs.welcome.canModerate], ['participant', false, false]);
++ await parWs.next((m) => m.type === 'force-muted' && m.forceMuted === true);
++ const joined = await ownWs.next((m) => m.type === 'peer-joined');
++ assert.deepStrictEqual([joined.roomRole, joined.canTalk, joined.forceMuted], ['participant', false, true], 'everyone sees who may talk');
++
++ // Signalling works both ways; a listener's own unmute is ignored.
++ parWs.sendJson({ type: 'offer', targetPeerId: ownWs.welcome.peerId, sdp: { type: 'offer', sdp: 'v=0 listener' } });
++ assert.strictEqual((await ownWs.next((m) => m.type === 'offer')).sdp.sdp, 'v=0 listener');
++ parWs.sendJson({ type: 'mute', muted: false });
++ assert.ok(await ownWs.none((m) => m.type === 'peer-muted' && m.peerId === parWs.welcome.peerId), 'a listener stays muted');
++
++ // Anyone may listen in a public call room; a moderator cannot lift a listener's mute (make them a speaker instead).
++ const anonWs = await joinCall({ channelId: 'room-open-stage', ip: '203.0.113.32' });
++ assert.deepStrictEqual([anonWs.welcome.canTalk, anonWs.welcome.roomRole], [false, null]);
++ ownWs.sendJson({ type: 'force-mute', targetPeerId: anonWs.welcome.peerId, forceMuted: false });
++ assert.ok(await anonWs.none((m) => m.type === 'force-muted' && m.forceMuted === false));
++
++ // Room calls are not in Live's voice-channel list, but the room's own page shows them.
++ const list = await h.http('GET', '/api/streams/voice-channels');
++ assert.ok(!JSON.stringify(list.body).includes('room-open-stage'), 'not in the voice sidebar');
++ assert.strictEqual((await api('GET', '/open-stage', null)).body.call.participants, 3);
++
++ // Private call rooms look missing to outsiders; invited people get in; blocked people never do.
++ await api('POST', '/', own, { name: 'Back Stage', kind: 'call', visibility: 'private' });
++ let ws = await joinCall({ channelId: 'room-back-stage', token: out.token, ip: '203.0.113.33' });
++ assert.strictEqual(ws.error, 'Voice channel not found');
++ await api('POST', '/back-stage/members', own, { username: 'outsider1', role: 'participant' });
++ ws = await joinCall({ channelId: 'room-back-stage', token: out.token, ip: '203.0.113.33' });
++ assert.ok(ws.welcome, 'invited');
++ await bye(ws);
++ await api('POST', '/back-stage/members', own, { username: 'outsider1', role: 'blocked' });
++ ws = await joinCall({ channelId: 'room-back-stage', token: out.token, ip: '203.0.113.33' });
++ assert.strictEqual(ws.error, 'Voice channel not found');
++ ws = await joinCall({ channelId: 'room-tea-talk', token: own.token, ip: '203.0.113.34' });
++ assert.strictEqual(ws.error, 'Voice channel not found', 'only call rooms have a call');
++ await bye(anonWs);
++ h._stage = { ownWs, parWs };
++});
++
++t('a role change reaches the running call at once; a ban in the call blocks in the room', async () => {
++ const { ownWs, parWs } = h._stage;
++ let r = await api('POST', '/open-stage/members', own, { username: 'part1', role: 'speaker' });
++ assert.strictEqual(r.body.role, 'speaker');
++ const role = await parWs.next((m) => m.type === 'room-role');
++ assert.deepStrictEqual([role.role, role.canTalk], ['speaker', true]);
++ await parWs.next((m) => m.type === 'force-muted' && m.forceMuted === false);
++ const upd = await ownWs.next((m) => m.type === 'peer-updated' && m.peerId === parWs.welcome.peerId);
++ assert.deepStrictEqual([upd.roomRole, upd.canTalk], ['speaker', true]);
++ parWs.sendJson({ type: 'mute', muted: false });
++ assert.strictEqual((await ownWs.next((m) => m.type === 'peer-muted' && m.peerId === parWs.welcome.peerId)).muted, false, 'a speaker unmutes');
++
++ await api('POST', '/open-stage/members', md, { username: 'part1', role: 'viewer' });
++ assert.strictEqual((await parWs.next((m) => m.type === 'room-role')).canTalk, false, 'demoted: listening again');
++ await parWs.next((m) => m.type === 'force-muted' && m.forceMuted === true);
++
++ await api('POST', '/open-stage/members', own, { username: 'part1', role: 'blocked' });
++ await parWs.closed;
++ assert.ok(parWs.all.some((m) => m.type === 'error'), 'told before being dropped');
++ await ownWs.next((m) => m.type === 'peer-left' && m.peerId === parWs.welcome.peerId);
++
++ // A ban from inside the call is the room's block.
++ await api('POST', '/open-stage/members', own, { username: 'speaker1', role: 'speaker' });
++ const spkWs = await joinCall({ channelId: 'room-open-stage', token: spk.token, ip: '203.0.113.35' });
++ assert.strictEqual(spkWs.welcome.canTalk, true);
++ ownWs.sendJson({ type: 'ban', targetPeerId: spkWs.welcome.peerId });
++ await spkWs.closed;
++ const room = rooms.bySlug('open-stage');
++ assert.strictEqual(rooms.access(room, spk).role, 'blocked');
++ assert.strictEqual((await api('GET', '/open-stage', spk)).status, 404, 'blocked: the room is gone for them');
++
++ // Turning the room private drops people who are not members (a guest listening).
++ const guest = await joinCall({ channelId: 'room-open-stage', ip: '203.0.113.36' });
++ assert.ok(guest.welcome);
++ r = await api('PATCH', '/open-stage', own, { visibility: 'private' });
++ assert.strictEqual(r.status, 200, r.text);
++ await guest.closed;
++ assert.ok(ownWs.readyState === WebSocket.OPEN, 'the owner stays');
++ await bye(ownWs);
++});
++
++t('attachments: a manager attaches with their own token, idempotently; detach; nobody else can', async () => {
++ let r = await api('POST', '/tea-talk/attachments', own, { service: 'community', resource: 'general', title: 'General' });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.deepStrictEqual([r.body.created, r.body.attachment.service, r.body.attachment.resource, r.body.room.slug], [true, 'community', 'general', 'tea-talk']);
++ r = await api('POST', '/tea-talk/attachments', own, { service: 'community', resource: 'general', title: 'General' });
++ assert.deepStrictEqual([r.status, r.body.created], [200, false], 'the same attachment again is a no-op');
++ assert.strictEqual(h.db.get("SELECT COUNT(*) AS n FROM room_attachments WHERE resource = 'general'").n, 1);
++ assert.strictEqual((await api('POST', '/tea-talk/attachments', mem, { service: 'community', resource: 'help' })).status, 403, 'members do not attach');
++ assert.strictEqual((await api('POST', '/back-stage/attachments', out, { service: 'community', resource: 'help' })).status, 404, 'a private room stays missing');
++ assert.strictEqual((await api('POST', '/tea-talk/attachments', bot, { service: 'community', resource: 'help' })).status, 403, 'API tokens do not attach');
++ assert.strictEqual((await api('POST', '/tea-talk/attachments', own, { service: 'discord', resource: 'x' })).body.code, 'rooms.attach_service');
++ assert.strictEqual((await api('POST', '/tea-talk/attachments', own, { service: 'community', resource: '../evil' })).body.code, 'rooms.attach_resource');
++ assert.strictEqual((await api('POST', '/tea-talk/attachments', null, { service: 'community', resource: 'help' })).status, 401);
++ r = await api('GET', '/tea-talk/attachments', own);
++ assert.deepStrictEqual(r.body.attachments.map((a) => [a.service, a.resource, a.title]), [['community', 'general', 'General']]);
++ assert.strictEqual((await api('GET', '/tea-talk/attachments', mem)).status, 403);
++ assert.strictEqual((await api('DELETE', '/tea-talk/attachments/community/general', mem)).status, 403, 'nor detach');
++ r = await api('DELETE', '/tea-talk/attachments/community/general', own);
++ assert.deepStrictEqual([r.status, r.body.removed], [200, true]);
++ r = await api('DELETE', '/tea-talk/attachments/community/general', own);
++ assert.deepStrictEqual([r.status, r.body.removed], [200, false], 'detaching twice is fine');
++ // Chat staff manage every room, so they may attach one too; whoever attached may detach.
++ assert.strictEqual((await api('POST', '/tea-talk/attachments', staff, { service: 'community', resource: 'help' })).status, 201);
++ assert.strictEqual((await api('DELETE', '/tea-talk/attachments/community/help', staff)).body.removed, true);
++});
++
++t('the site: kinds on the rooms page, the call panel, roles and attachments in settings', async () => {
++ const page = async (path, u) => { const r = await fetch(`${h.base}${path}`, { headers: u ? { cookie: `ov_token=${u.token}` } : {}, redirect: 'manual' }); return { status: r.status, text: await r.text() }; };
++ let r = await page('/rooms', own);
++ assert.match(r.text, /name="kind" value="call"/);
++ assert.ok(!r.text.includes('value="system"'), 'people are not offered system rooms');
++ assert.match((await page('/rooms', staff)).text, /name="kind" value="system"/);
++ assert.match((await page('/rooms', null)).text, /<span class="oc-badge oc-kind-system">announcements<\/span>/);
++
++ r = await page('/r/open-stage', own);
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /<span class="oc-badge oc-kind-call">call<\/span>/);
++ assert.match(r.text, /id="oc-call-join" hidden>Join the call</);
++ assert.match(r.text, /\/web\/call\.js\?v=/);
++ const cfg = JSON.parse(r.text.match(/window\.__OV_PAGE = (.*);\n/)[1]);
++ assert.deepStrictEqual(cfg.chat.call, { channel: 'room-open-stage', join: true, talk: true });
++ assert.strictEqual((await page('/r/open-stage', out)).status, 404, 'private now: not there for outsiders');
++ await api('POST', '/open-stage/members', own, { username: 'member1', role: 'participant' });
++ r = await page('/r/open-stage', mem);
++ assert.match(r.text, /id="oc-call-join" hidden>Listen in</);
++ assert.match(r.text, /you are a participant/);
++
++ r = await page('/r/open-stage/settings', own);
++ for (const role of ['moderator', 'speaker', 'participant', 'viewer', 'blocked']) assert.ok(r.text.includes(`>${role}</option>`), role);
++ assert.ok(!r.text.includes('>member</option>'), 'call rooms have no members, they have participants');
++ assert.match(r.text, /name="join_role"/);
++ assert.match(r.text, /Speakers, moderators and the owner talk in the call/);
++ assert.match(r.text, /· seen <time/, 'moderators see when people were last here');
++ await api('POST', '/tea-talk/attachments', own, { service: 'community', resource: 'general', title: 'General <b>' });
++ r = await page('/r/tea-talk/settings', own);
++ assert.match(r.text, /<a href="https:\/\/openvibe\.community\/s\/general">General &lt;b&gt;<\/a>/);
++ assert.match(r.text, /action="\/r\/tea-talk\/attachments\/community\/general\/detach"/);
++ assert.ok(!(await page('/r/tea-talk', own)).text.includes('openvibe.community/s/general'), 'the room page itself makes no claim about the space');
++
++ r = await page('/r/openvibe-news', out);
++ assert.match(r.text, /Announcements from OpenVibe staff/);
++ assert.ok(!r.text.includes('id="oc-compose"'));
++ assert.match((await page('/r/openvibe-news', staff)).text, /placeholder="Post an announcement"/);
++});
++
++t('chat sockets following a room learn their new role at once, or leave', async () => {
++ const ws = await h.ws({ token: vw.token });
++ ws.sendJson({ type: 'join_room', room: 'tea-talk' });
++ const joined = await ws.next((m) => m.type === 'room_joined');
++ assert.deepStrictEqual([joined.kind, joined.role, joined.can.post], ['community', 'viewer', false]);
++ ws.sendJson({ type: 'room_message', message: 'viewers cannot' });
++ assert.strictEqual((await ws.next((m) => m.type === 'room_error')).code, 'rooms.read_only');
++ await api('POST', '/tea-talk/members', own, { username: 'viewer1', role: 'member' });
++ const acc = await ws.next((m) => m.type === 'room_access');
++ assert.deepStrictEqual([acc.role, acc.can.post], ['member', true]);
++ ws.sendJson({ type: 'room_message', message: 'members can' });
++ await ws.next((m) => m.type === 'room_message' && m.message.message === 'members can');
++ await api('PATCH', '/tea-talk', own, { visibility: 'private' });
++ assert.strictEqual((await ws.next((m) => m.type === 'room_access')).role, 'member', 'private now, but a member: still reading');
++ await api('POST', '/tea-talk/members', own, { username: 'viewer1', role: 'none' });
++ await ws.next((m) => m.type === 'room_left');
++ ws.close();
++});
++
++t('ICE servers for calls: STUN, and TURN only with credentials (short-lived with TURN_AUTH_SECRET)', async () => {
++ const crypto = require('crypto');
++ const turn = require('../server/net/turn');
++ assert.deepStrictEqual((await h.http('GET', '/api/chat/ice-servers')).body.iceServers, [{ urls: 'stun:stun.l.google.com:19302' }], 'no TURN configured: STUN only');
++ const at = Date.UTC(2026, 8, 26, 12);
++ const eph = turn.iceServers('u7', turn.turnConfig({ TURN_URL: 'turn:turn.example.org:3478', TURN_AUTH_SECRET: 's3cret' }), at);
++ const user = `${Math.floor(at / 1000) + turn.TTL_SECONDS}:u7`;
++ assert.deepStrictEqual(eph.iceServers.slice(1), [
++ { urls: 'turn:turn.example.org:3478', username: user, credential: crypto.createHmac('sha1', 's3cret').update(user).digest('base64') },
++ { urls: 'turn:turn.example.org:3478?transport=tcp', username: user, credential: crypto.createHmac('sha1', 's3cret').update(user).digest('base64') },
++ ]);
++ assert.strictEqual(eph.ephemeral, true);
++ assert.strictEqual(turn.iceServers('anon', turn.turnConfig({ TURN_URL: 'turn://turn.example.org' })).iceServers.length, 1, 'a turn: URL without credentials is left out');
++ assert.strictEqual(turn.iceServers('anon', turn.turnConfig({ TURN_URL: 'turn:turn.example.org', TURN_USERNAME: 'u', TURN_CREDENTIAL: 'p' })).iceServers[1].username, 'u');
++ assert.strictEqual(turn.normalizeTurnUrl('turn:user:[email protected]'), '', 'no credentials inside the URL');
++ assert.strictEqual(turn.normalizeTurnUrl('https://turn.example.org'), '');
++});
++
++t.run(async () => { if (h && h.close) await h.close(); });
ddiff --git a/test/rooms.test.js b/test/rooms.test.js
iindex 60a8658..62d1820 100644
---- a/test/rooms.test.js
++++ b/test/rooms.test.js
@@@ -53,7 +53,9 @@ t('private rooms: invisible to outsiders, members by invitation', async () => {
assert.strictEqual(r.status, 201);
assert.strictEqual((await api('GET', '/secret-club', bob)).status, 404);
assert.strictEqual((await api('GET', '/secret-club/messages', null)).status, 404);
-- assert.strictEqual((await api('POST', '/secret-club/join', bob)).status, 403);
++ assert.strictEqual((await api('POST', '/secret-club/join', bob)).status, 404, 'joining says no more than a missing room would');
++ assert.strictEqual((await api('POST', '/no-such-room/join', bob)).status, 404);
++ assert.strictEqual((await api('POST', '/secret-club/messages', bob, { message: 'hi' })).status, 404);
assert.ok(!(await api('GET', '/', bob)).body.public.some((x) => x.slug === 'secret-club'), 'not listed');
assert.strictEqual((await api('POST', '/secret-club/members', bob, { username: 'cat' })).status, 404, 'a non-member cannot even see it to invite');
r = await api('POST', '/secret-club/members', ann, { username: 'bob', role: 'member' });