openvibers/openvibe.chat Commits

Chat: servers, with text and voice channels, roles and invites (#104)

OP OpenVibers committed 28 days ago · b7fade3b3a · 15 files, +1429 −21

The owner's Q14 after friend groups and couples: communities with servers, channels and voice. A server
(migration 0013) is a group of rooms with one membership and one set of roles: text channels are community
rooms, voice channels call rooms, so messages, live delivery, unread counts, calls and AI members work in them
unchanged; rooms.access() asks the server for any room with server_id. Public servers are read by anyone and
joined by signed-in people; private ones are a 404 to outsiders and joined through invites (uses, expiry,
revoke). The owner appoints admins, admins appoint moderators and manage channels and invites, moderators remove
and ban members, never upward; logged. openvibe.chat: /servers, /s/<server>/<channel> (the server rail, the
channels, the channel, the people by rank; phones fold the channel list), settings by rank, /join/<code>;
/r/<channel> goes to its server. API /api/chat/servers. Account export and erasure cover servers.

Changes

ddiff --git a/README.md b/README.md
iindex dd3cb0d..12e0d2a 100644
---- a/README.md
++++ b/README.md
@@@ -308,11 +308,33 @@ only; members see names and handles, managers the set-up); the settings page man
member can also be added by account subject: `POST /api/chat/rooms/:slug/members { subject: "usr_…" }`. Deleting an
account keeps the AI members it added (they belong to the room) without its subject on them, and exports them.
--**openvibe.chat pages.** `/` global chat, `/rooms` and `/r/:slug` (above), `/messages` the DM inbox
++**Servers** (`server/rooms/servers.js`, `server/rooms/server-routes.js`, `server/web/server-pages.js`,
++`migrations/0013_chat_servers.sql`; the owner's Q14: friend groups and couples first, then communities with servers,
++channels and voice). A server is a group of rooms, its channels, with one membership and one set of roles: a text
++channel is a community room and a voice channel a call room (its call is `room-<slug>`), so messages, live delivery,
++unread counts, calls and AI members work in channels unchanged. `rooms.access()` asks the server for any room with
++`server_id`: the owner is the channel's owner, admins and moderators its moderators, members its members (in a voice
++channel: speakers by default, or listeners when the channel says participant); a public server is read by anyone
++and joined by signed-in people, a private one is a 404 to outsiders and joined only through an invite
++(`/join/<code>`: any number of uses or 1/10/100, for a day, a week, a month or forever, revocable). The owner appoints
++admins, admins appoint moderators and manage channels, settings and invites, moderators remove and ban members;
++nobody acts on their own rank or above, and every action is logged (scope `server:<slug>`). A server starts with
++`#general` and a voice Lounge; a person owns up to 5, a server has up to 50 channels and 25 live invites and keeps one
++text channel. A channel's room slug is random (`ch…`); its page is `/s/<server>/<channel>` and `/r/<slug>` goes
++there. Channels follow the server's visibility (going private redacts what they had published); the rooms API refuses
++room-level joins, roles and visibility there, and `/rooms` leaves channels out. The page: your servers down the side
++with unread counts, the channels, the channel (its call for a voice channel) and the people by rank; on a phone the
++servers run across the top and the channel list folds away. API: `/api/chat/servers` (list, create, read, update,
++delete, join `{ invite? }`, leave, members, channels, invites, `GET /invites/:code`). Deleting an account removes its
++memberships; a server it owned stays without an owner while anyone else is in it, and goes with its channels when
++nobody is.
++
++**openvibe.chat pages.** `/` global chat, `/rooms` and `/r/:slug` (above), `/servers`, `/s/:slug[/:channel]`,
++`/s/:slug/settings` and `/join/:code` (servers, 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.
++counts unread messages, room messages and server 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
@@@ -469,7 +491,8 @@ docs/ calls-cutover.md, parity.md, chat-ingress.md, capabil
## Owns
--- room types: global, stream, channel, community, DM, group-DM, call, system
++- room types: global, stream, channel, community, DM, group-DM, call, system; servers (groups of rooms as text and
++ voice channels, with server roles and invites)
- messages, replies/rich payloads, membership/roles, unread/read state, presence/typing (ephemeral)
- bans, timeouts, delete/purge, filters, slow/member-only modes
- TTS/audio/soundboard/media-request queues with skip/clear/failure lifecycle
ddiff --git a/migrations/0013_chat_servers.sql b/migrations/0013_chat_servers.sql
nnew file mode 100644
iindex 0000000..5958110
---- /dev/null
++++ b/migrations/0013_chat_servers.sql
@@@ -0,0 +1,50 @@
++-- phase: expand
++-- Servers (owner 2026-09-28 Q14: friend groups and couples first, then communities with servers, channels and voice):
++-- a server is a group of rooms, its channels, with one membership and one set of roles for all of them
++-- (server/rooms/servers.js). A channel is an ordinary room with server_id set: text channels are community rooms,
++-- voice channels are call rooms, so messages, live delivery, calls and AI members work in them unchanged. A
++-- channel's room_members rows only keep where each person read up to; what they may do comes from the server.
++CREATE TABLE IF NOT EXISTS chat_servers (
++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
++ slug text COLLATE "C" UNIQUE NOT NULL, -- /s/<slug>
++ name text COLLATE "C" NOT NULL, -- 2-40 characters
++ description text COLLATE "C", -- <= 300 characters
++ visibility text COLLATE "C" NOT NULL DEFAULT 'public',-- public: listed, anyone reads, signed-in people join; private: invites only
++ owner_id bigint NOT NULL,
++ owner_subject text COLLATE "C", -- usr_… (NULL once that account is deleted)
++ member_count integer NOT NULL DEFAULT 0,
++ created_at text COLLATE "C" DEFAULT ov_now(),
++ updated_at text COLLATE "C" DEFAULT ov_now(),
++ archived_at text COLLATE "C",
++ CHECK (visibility IN ('public', 'private'))
++);
++CREATE INDEX IF NOT EXISTS idx_chat_servers_public ON chat_servers (visibility, member_count DESC) WHERE archived_at IS NULL;
++CREATE INDEX IF NOT EXISTS idx_chat_servers_owner ON chat_servers (owner_id);
++
++CREATE TABLE IF NOT EXISTS chat_server_members (
++ server_id bigint NOT NULL REFERENCES chat_servers(id) ON DELETE CASCADE,
++ user_id bigint NOT NULL,
++ role text COLLATE "C" NOT NULL DEFAULT 'member', -- owner | admin | mod | member | blocked
++ joined_at text COLLATE "C" DEFAULT ov_now(),
++ PRIMARY KEY (server_id, user_id),
++ CHECK (role IN ('owner', 'admin', 'mod', 'member', 'blocked'))
++);
++CREATE INDEX IF NOT EXISTS idx_chat_server_members_user ON chat_server_members (user_id);
++
++CREATE TABLE IF NOT EXISTS chat_server_invites (
++ code text COLLATE "C" PRIMARY KEY, -- 10 characters, /join/<code>
++ server_id bigint NOT NULL REFERENCES chat_servers(id) ON DELETE CASCADE,
++ created_by bigint NOT NULL,
++ created_by_subject text COLLATE "C",
++ uses integer NOT NULL DEFAULT 0,
++ max_uses integer, -- NULL: no limit
++ expires_at text COLLATE "C", -- NULL: never
++ revoked_at text COLLATE "C",
++ created_at text COLLATE "C" DEFAULT ov_now()
++);
++CREATE INDEX IF NOT EXISTS idx_chat_server_invites_server ON chat_server_invites (server_id);
++
++ALTER TABLE rooms ADD COLUMN IF NOT EXISTS server_id bigint REFERENCES chat_servers(id) ON DELETE CASCADE;
++ALTER TABLE rooms ADD COLUMN IF NOT EXISTS channel_slug text COLLATE "C"; -- the channel's name in /s/<server>/<channel>
++ALTER TABLE rooms ADD COLUMN IF NOT EXISTS position integer NOT NULL DEFAULT 0;
++CREATE UNIQUE INDEX IF NOT EXISTS idx_rooms_server_channel ON rooms (server_id, channel_slug) WHERE server_id IS NOT NULL AND archived_at IS NULL;
ddiff --git a/public/web/chat.css b/public/web/chat.css
iindex c76f8cb..e27a7ba 100644
---- a/public/web/chat.css
++++ b/public/web/chat.css
@@@ -124,3 +124,98 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
.oc-ai-member h3 { margin: 0 0 6px; font-size: 1rem; }
.oc-ai-form textarea { width: 100%; box-sizing: border-box; }
@media (prefers-color-scheme: light) { :root:not([data-theme="dark"]) .oc-badge.oc-ai { color: #6d28d9; border-color: rgba(109, 40, 217, .45); } :root:not([data-theme="dark"]) .oc-ai-name { color: #5b21b6; } }
++
++/* Servers (server/web/server-pages.js): your servers down the side, the channels, the channel, the people. */
++.oc-main--wide { max-width: 1440px; }
++.oc-srv { display: grid; grid-template-columns: 64px 232px minmax(0, 1fr) 212px; height: min(80vh, 900px); min-height: 480px; background: var(--oc-panel); border: 1px solid var(--oc-line); border-radius: 16px; overflow: hidden; }
++.oc-srv-rail { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 12px 0; background: var(--oc-bg); border-right: 1px solid var(--oc-line); overflow-y: auto; scrollbar-width: none; }
++.oc-srv-icon { position: relative; display: grid; place-items: center; width: 44px; height: 44px; flex: 0 0 auto; border-radius: 50%; background: hsl(var(--oc-hue, 215) 52% 34%); color: #fff; font-weight: 800; font-size: .92rem; letter-spacing: .02em; text-decoration: none; transition: border-radius .15s ease, background-color .15s ease; }
++.oc-srv-icon:hover, .oc-srv-icon:focus-visible, .oc-srv-icon.is-active { border-radius: 14px; }
++.oc-srv-rail .oc-srv-icon.is-active::before { content: ''; position: absolute; left: -10px; top: 9px; bottom: 9px; width: 4px; border-radius: 0 4px 4px 0; background: var(--oc-text); }
++.oc-srv-add, .oc-srv-explore { background: var(--oc-panel-2); color: var(--oc-ok); font-size: 1.35rem; font-weight: 500; }
++.oc-srv-explore { color: var(--oc-accent); font-size: 1.15rem; }
++.oc-srv-add:hover, .oc-srv-explore:hover { background: var(--oc-panel); }
++.oc-srv-dot, .oc-chan-dot { min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; background: var(--oc-red); color: #1a0505; font-size: .66rem; font-weight: 800; line-height: 18px; text-align: center; }
++.oc-srv-dot { position: absolute; right: -5px; bottom: -4px; border: 2px solid var(--oc-bg); line-height: 14px; }
++.oc-srv-side { display: flex; flex-direction: column; gap: 2px; min-width: 0; padding: 14px 10px; overflow-y: auto; border-right: 1px solid var(--oc-line); }
++.oc-srv-head { padding: 0 6px 10px; margin-bottom: 4px; border-bottom: 1px solid var(--oc-line); }
++.oc-srv-head .oc-badge { margin-left: 0; }
++.oc-srv-name { font-size: 1.08rem; line-height: 1.3; margin: 0 0 2px; overflow-wrap: anywhere; }
++.oc-srv-meta { margin: 2px 0 0; font-size: .82rem; }
++.oc-srv-desc { margin: 6px 0 0; font-size: .86rem; color: var(--oc-muted); }
++.oc-srv-channels > summary { display: none; }
++.oc-srv-group { margin: 14px 8px 4px; font-size: .7rem; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--oc-muted); }
++.oc-srv-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 1px; }
++.oc-chan { display: flex; align-items: center; gap: 7px; padding: 5px 8px; border-radius: 8px; color: var(--oc-muted); text-decoration: none; }
++.oc-chan:hover, .oc-chan:focus-visible { background: var(--oc-panel-2); color: var(--oc-text); }
++.oc-chan.is-active { background: color-mix(in srgb, var(--oc-accent) 18%, transparent); color: var(--oc-text); }
++.oc-chan.is-unread { color: var(--oc-text); font-weight: 700; }
++.oc-chan-hash { width: 16px; text-align: center; font-weight: 700; opacity: .75; }
++.oc-chan-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.oc-chan-in { font-size: .78rem; }
++.oc-ico { flex: 0 0 auto; vertical-align: -3px; }
++.oc-srv-join, .oc-srv-invite { margin: 14px 6px 0; }
++.oc-srv-join button, .oc-srv-invite .oc-button { width: 100%; text-align: center; }
++.oc-button--quiet { background: var(--oc-panel-2); color: var(--oc-text); border: 1px solid var(--oc-line); }
++.oc-button--quiet:hover { border-color: var(--oc-accent); }
++.oc-srv-tools { margin: auto 6px 0; padding-top: 14px; font-size: .86rem; }
++.oc-srv-main { display: flex; flex-direction: column; min-width: 0; min-height: 0; background: color-mix(in srgb, var(--oc-panel-2) 60%, var(--oc-panel)); }
++.oc-srv-main .oc-msg:hover { background: color-mix(in srgb, var(--oc-text) 5%, transparent); }
++.oc-chan-head { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; padding: 12px 18px; border-bottom: 1px solid var(--oc-line); }
++.oc-chan-head h2 { display: flex; align-items: center; gap: 6px; margin: 0; font-size: 1.04rem; }
++.oc-chan-topic { min-width: 0; font-size: .88rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.oc-srv-main > .oc-notice, .oc-srv-main > .oc-ai-here { margin: 10px 18px 0; }
++.oc-srv-main > .oc-call { margin: 12px 18px 0; }
++.oc-srv-feed { flex: 1; min-height: 0; max-height: none; margin: 0; padding: 10px 8px; background: transparent; border: 0; border-radius: 0; }
++.oc-srv-main > .oc-compose, .oc-srv-main > .oc-signin, .oc-srv-main > form { margin: 0; padding: 12px 18px 16px; border-top: 1px solid var(--oc-line); }
++.oc-srv-people { min-width: 0; padding: 14px 12px; overflow-y: auto; border-left: 1px solid var(--oc-line); }
++.oc-srv-people > details > summary { display: none; }
++.oc-srv-people .oc-srv-group { margin-top: 4px; }
++.oc-srv-people .oc-srv-group ~ .oc-srv-group { margin-top: 16px; }
++.oc-srv-people-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 1px; }
++.oc-srv-people-list a { display: block; padding: 3px 8px; border-radius: 6px; text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.oc-srv-people-list a:hover { background: var(--oc-panel-2); }
++/* The directory, an invite, settings */
++.oc-srv-cards { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 10px; }
++.oc-srv-card a { display: flex; align-items: center; gap: 12px; height: 100%; padding: 12px 14px; background: var(--oc-panel); border: 1px solid var(--oc-line); border-radius: 12px; color: var(--oc-text); text-decoration: none; }
++.oc-srv-card a:hover, .oc-srv-card a:focus-visible { border-color: var(--oc-accent); }
++.oc-srv-card-text { display: flex; flex-direction: column; min-width: 0; }
++.oc-srv-card-text .oc-muted { font-size: .88em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.oc-srv-icon--big { width: 72px; height: 72px; margin: 0 auto 6px; border-radius: 22px; font-size: 1.5rem; }
++.oc-invite-card { max-width: 440px; margin: 32px auto; padding: 28px 24px; text-align: center; background: var(--oc-panel); border: 1px solid var(--oc-line); border-radius: 16px; }
++.oc-invite-card h1 { margin-top: 0; }
++.oc-invite-card form, .oc-invite-card .oc-notice { margin-top: 16px; }
++.oc-invite-url { font-size: .85em; overflow-wrap: anywhere; }
++.oc-chan-rows li { align-items: flex-start; }
++.oc-chan-rows li > span { flex: 1 1 200px; min-width: 0; }
++.oc-chan-edit { flex-basis: 100%; }
++.oc-chan-edit summary { cursor: pointer; color: var(--oc-accent); font-size: .9rem; }
++.oc-chan-edit .oc-form { margin-top: 8px; }
++.oc-danger { background: var(--oc-red); color: #1a0505; }
++.oc-link.oc-danger-link { color: var(--oc-red); margin-left: 8px; }
++@media (max-width: 1180px) {
++ .oc-srv { grid-template-columns: 64px 220px minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) auto; height: auto; min-height: 0; }
++ .oc-srv-rail, .oc-srv-side, .oc-srv-main { height: min(78vh, 860px); }
++ .oc-srv-people { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--oc-line); max-height: 260px; }
++ .oc-srv-people > details > summary { display: list-item; cursor: pointer; font-weight: 700; margin-bottom: 6px; }
++ .oc-srv-people-list { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
++}
++@media (max-width: 760px) {
++ .oc-srv { grid-template-columns: minmax(0, 1fr); border-radius: 12px; }
++ .oc-srv-rail, .oc-srv-side, .oc-srv-main { height: auto; }
++ .oc-srv-rail { flex-direction: row; justify-content: flex-start; padding: 10px 12px; gap: 10px; overflow-x: auto; overflow-y: hidden; border-right: 0; border-bottom: 1px solid var(--oc-line); }
++ .oc-srv-rail .oc-srv-icon.is-active::before { left: 9px; right: 9px; top: auto; bottom: -10px; width: auto; height: 4px; border-radius: 4px 4px 0 0; }
++ .oc-srv-side { border-right: 0; border-bottom: 1px solid var(--oc-line); padding: 12px; }
++ .oc-srv-channels > summary { display: list-item; cursor: pointer; font-weight: 700; padding: 4px 6px; }
++ .oc-srv-tools { margin-top: 8px; }
++ .oc-srv-feed { max-height: 62vh; min-height: 220px; }
++ .oc-chan-head, .oc-srv-main > .oc-compose, .oc-srv-main > .oc-signin, .oc-srv-main > form { padding-left: 12px; padding-right: 12px; }
++ .oc-srv-main > .oc-notice, .oc-srv-main > .oc-ai-here, .oc-srv-main > .oc-call { margin-left: 12px; margin-right: 12px; }
++}
++
++.oc-srv-card-title { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 6px; }
++.oc-srv-card-title .oc-badge { margin-left: 0; }
++.oc-badge-new { color: var(--oc-text); border-color: var(--oc-red); }
++.oc-moves { display: inline-flex; gap: 4px; margin-left: auto; }
++.oc-move { width: 30px; height: 30px; padding: 0; border-radius: 8px; background: var(--oc-panel-2); color: var(--oc-text); border: 1px solid var(--oc-line); font-weight: 700; line-height: 1; }
++.oc-move:hover { border-color: var(--oc-accent); }
ddiff --git a/public/web/chat.js b/public/web/chat.js
iindex 5f71d84..3dcb851 100644
---- a/public/web/chat.js
++++ b/public/web/chat.js
@@@ -259,7 +259,7 @@
} else if (m.type === 'room_error' && m.message) {
showError(m.message);
} else if (m.type === 'room_left') {
-- showError('You can no longer read this room.'); setTimeout(function () { location.href = '/rooms'; }, 1500);
++ showError('You can no longer read this room.'); setTimeout(function () { location.href = cfg.back || '/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); }
@@@ -329,6 +329,9 @@
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(); }
});
++ // A server's channel list folds away on a phone: the channel's own header says where you are.
++ var chans = document.querySelector ? document.querySelector('.oc-srv-channels') : null;
++ if (chans && window.matchMedia && window.matchMedia('(max-width: 760px)').matches) chans.open = false;
toBottom();
if (cfg.view === 'global') startGlobal();
else if (cfg.view === 'dm') startDm();
ddiff --git a/server/app.js b/server/app.js
iindex fcff1ed..4766c20 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -242,6 +242,7 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
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'));
++ app.use('/api/chat/servers', require('./rooms/server-routes'));
// Channel moderators & moderation settings (/api/chat/channels/:id/…): before /api/chat too.
app.use('/api/chat/channels', require('./chat/channel-mod-routes'));
// ICE servers for call rooms (STUN, and TURN with credentials when TURN_URL is set): server/net/turn.js.
ddiff --git a/server/chat/account-data.js b/server/chat/account-data.js
iindex 0dbcdf1..de8ae44 100644
---- a/server/chat/account-data.js
++++ b/server/chat/account-data.js
@@@ -12,7 +12,8 @@
* - chat, room and direct messages they sent, their DM memberships (a
* conversation left with nobody goes), room memberships and attachments;
* - rooms they own go when nobody else is in them, and otherwise stay without an
-- * owner; calls they made or took, and blocks both ways;
++ * owner (servers they own too, with their channels); their server memberships;
++ * calls they made or took, and blocks both ways;
* - their channel's emotes, sounds, moderators, first-chat stats and audio
* requests, their tags, and the AI summaries and timeline about them;
* - the channel, stream and user mirrors of their Live account (the user mirror
@@@ -47,7 +48,10 @@ const EXPORTS = [
['room_messages.json', `SELECT * FROM room_messages WHERE subject_id = ANY(@s) OR user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
['direct_messages.json', `SELECT * FROM dm_messages WHERE sender_subject_id = ANY(@s) OR sender_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
['conversations.json', `SELECT * FROM dm_participants WHERE subject_id = ANY(@s) OR user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
-- ['rooms_owned.json', `SELECT * FROM rooms WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
++ ['rooms_owned.json', `SELECT * FROM rooms WHERE (owner_subject = ANY(@s) OR owner_id = ANY(@ids)) AND server_id IS NULL ORDER BY id DESC LIMIT ${LIMIT}`],
++ ['servers_owned.json', `SELECT * FROM chat_servers WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
++ ['server_memberships.json', `SELECT m.server_id, s.slug, s.name, m.role, m.joined_at FROM chat_server_members m JOIN chat_servers s ON s.id = m.server_id WHERE m.user_id = ANY(@ids) ORDER BY m.joined_at DESC LIMIT ${LIMIT}`],
++ ['server_invites_made.json', `SELECT code, server_id, uses, max_uses, expires_at, revoked_at, created_at FROM chat_server_invites WHERE created_by_subject = ANY(@s) OR created_by = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],
['calls.json', `SELECT * FROM calls WHERE created_by_subject = ANY(@s) OR target_subject = ANY(@s) OR created_by = ANY(@ids) OR target_user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
['blocks.json', `SELECT * FROM network_blocks WHERE blocker_subject = ANY(@s) ORDER BY updated_at DESC, blocked_subject DESC LIMIT ${LIMIT}`],
['dm_blocks.json', `SELECT * FROM dm_blocks WHERE blocker_subject_id = ANY(@s) OR blocker_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
@@@ -117,7 +121,22 @@ async function erase(d, subjects) {
const del = async ([key, text]) => { const st = d.prepare(text); add(erased, key, (await (text.includes('@') ? st.run(p) : st.run())).changes); };
await d.tx(async () => {
for (const e of ERASE_FIRST) await del(e);
-- for (const r of await d.prepare('SELECT id FROM rooms WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids)').all(p)) {
++ // Servers (migration 0013): their memberships go; a server they own stays, without an owner, while anyone else
++ // is in it, and goes with its channels when nobody is; invites they made stay, without their name.
++ add(erased, 'server_memberships', (await d.prepare("DELETE FROM chat_server_members WHERE user_id = ANY(@ids) AND role != 'owner'").run(p)).changes);
++ for (const sv of await d.prepare('SELECT id FROM chat_servers WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids)').all(p)) {
++ const others = await d.prepare("SELECT 1 FROM chat_server_members WHERE server_id = ? AND role != 'owner' AND role != 'blocked' LIMIT 1").get(sv.id);
++ await d.prepare("DELETE FROM chat_server_members WHERE server_id = ? AND role = 'owner'").run(sv.id);
++ if (others) { await d.prepare('UPDATE chat_servers SET owner_subject = NULL WHERE id = ?').run(sv.id); add(retained, 'servers_without_owner', 1); }
++ else {
++ await d.prepare('DELETE FROM room_messages WHERE room_id IN (SELECT id FROM rooms WHERE server_id = ?)').run(sv.id);
++ await d.prepare('DELETE FROM rooms WHERE server_id = ?').run(sv.id);
++ await d.prepare('DELETE FROM chat_servers WHERE id = ?').run(sv.id);
++ add(erased, 'servers', 1);
++ }
++ }
++ await d.prepare('UPDATE chat_server_invites SET created_by_subject = NULL WHERE created_by_subject = ANY(@s)').run(p);
++ for (const r of await d.prepare('SELECT id FROM rooms WHERE (owner_subject = ANY(@s) OR owner_id = ANY(@ids)) AND server_id IS NULL').all(p)) {
const others = await d.prepare('SELECT 1 FROM room_members WHERE room_id = ? LIMIT 1').get(r.id);
if (others) { await d.prepare('UPDATE rooms SET owner_subject = NULL WHERE id = ?').run(r.id); add(retained, 'rooms_without_owner', 1); }
else {
ddiff --git a/server/rooms/rooms.js b/server/rooms/rooms.js
iindex 47e370d..2d9adef 100644
---- a/server/rooms/rooms.js
++++ b/server/rooms/rooms.js
@@@ -99,6 +99,7 @@ async function publicRoom(r, extra = {}) {
...(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: (await db.get("SELECT COUNT(*) AS n FROM room_members WHERE room_id = ? AND role != 'blocked'", [r.id]) || {}).n || 0,
++ ...(r.server_id ? { server: { slug: (await db.get('SELECT slug FROM chat_servers WHERE id = ?', [r.server_id]) || {}).slug || null, channel: r.channel_slug } } : {}),
...extra,
};
}
@@@ -113,6 +114,8 @@ async function bySlug(slug) {
* `join` and `talk` are the room's call (call rooms only).
*/
async function access(room, user) {
++ // A server's channel: the server decides (server/rooms/servers.js).
++ if (room.server_id) return await require('./servers').channelAccess(room, user);
const kind = kindOf(room);
const role = user ? await roleOf(room.id, user.id) : null;
const staff = isStaff(user);
@@@ -140,7 +143,7 @@ async function create(user, { name, slug, topic, visibility = 'public', kind = '
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 = (await db.get('SELECT COUNT(*) AS n FROM rooms WHERE owner_id = ? AND archived_at IS NULL', [user.id])).n;
++ const owned = (await db.get('SELECT COUNT(*) AS n FROM rooms WHERE owner_id = ? AND archived_at IS NULL AND server_id 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 (await bySlug(s)) fail(409, 'rooms.taken', 'That room address is taken');
const t = topic ? String(topic).replace(/\s+/g, ' ').trim().slice(0, 200) : null;
@@@ -157,20 +160,22 @@ async function create(user, { name, slug, topic, visibility = 'public', kind = '
async function list(user, { limit = 50 } = {}) {
await ensureSchema();
const lim = Math.min(100, Math.max(1, Number(limit) || 50));
-- const pub = (await Promise.all((await 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(async (r) => await publicRoom(r))));
++ // A server's channels are listed with their server (server/rooms/servers.js), not here.
++ const pub = (await Promise.all((await db.all("SELECT * FROM rooms WHERE visibility = 'public' AND archived_at IS NULL AND server_id IS NULL ORDER BY COALESCE(last_message_at, created_at) DESC LIMIT ?", [lim])).map(async (r) => await publicRoom(r))));
const mine = user ? (await Promise.all((await 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
++ WHERE m.user_id = ? AND m.role != 'blocked' AND r.archived_at IS NULL AND r.server_id IS NULL
ORDER BY COALESCE(r.last_message_at, r.created_at) DESC`, [user.id, user.id])).map(async (r) => await 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). */
--async function unreadTotal(user) {
++async function unreadTotal(user, { servers = false } = {}) {
if (!user) return 0;
await ensureSchema();
-- return (await 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
++ // Rooms count their own unread messages; servers (servers: true) count their channels'.
++ return (await 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 AND r.server_id IS ${servers ? 'NOT ' : ''}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;
}
@@@ -178,6 +183,7 @@ async function unreadTotal(user) {
/** Join a public room (staff: any room) with the kind's join role; returns the caller's role. */
async function join(room, user) {
if (!user) fail(401, 'rooms.sign_in', 'Sign in to join a room');
++ if (room.server_id) fail(409, 'rooms.server_channel', 'This is a server\'s channel: join the server to post in it');
const role = await roleOf(room.id, user.id);
if (role === 'blocked') fail(403, 'rooms.blocked', 'You are blocked from this room');
if (role) return role;
@@@ -190,6 +196,7 @@ async function join(room, user) {
}
async function leave(room, user) {
++ if (room.server_id) fail(409, 'rooms.server_channel', 'This is a server\'s channel: leave the server instead');
const role = await roleOf(room.id, user && user.id);
if (role === 'owner') fail(409, 'rooms.owner_leaves', 'The owner cannot leave their room');
if (role && role !== 'blocked') await db.run('DELETE FROM room_members WHERE room_id = ? AND user_id = ?', [room.id, user.id]);
@@@ -310,6 +317,10 @@ async function markRead(room, user, lastId) {
if (!user) return;
const top = (await 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);
++ // A server channel keeps a read row for each member (made on joining; a channel made later gets one here).
++ if (room.server_id && (await access(room, user)).role) {
++ await db.run("INSERT INTO room_members (room_id, user_id, role, last_read_id, last_seen_at) VALUES (?, ?, 'member', ?, ov_now()) ON CONFLICT (room_id, user_id) DO NOTHING", [room.id, user.id, to]);
++ }
await db.run('UPDATE room_members SET last_read_id = GREATEST(last_read_id, ?), last_seen_at = ov_now() WHERE room_id = ? AND user_id = ?', [to, room.id, user.id]);
}
@@@ -333,6 +344,7 @@ async function deleteMessage(room, user, messageId) {
* chat staff's) to appoint and remove; everything else is any moderator's.
*/
async function setRole(room, actor, targetUserId, role) {
++ if (room.server_id) fail(409, 'rooms.server_channel', 'Roles in a server\'s channels are set for the whole server');
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');
@@@ -358,10 +370,12 @@ async function setRole(room, actor, targetUserId, role) {
return role;
}
--async function update(room, actor, { name, topic, visibility, slow_seconds, join_role } = {}) {
++async function update(room, actor, { name, topic, visibility, slow_seconds, join_role } = {}, { fromServer = false, minName = 3 } = {}) {
if (!(await access(room, actor)).manage) fail(403, 'rooms.not_owner', 'Only the owner changes the room');
++ // A server's channel follows the server's visibility, and is changed through the server (servers.js).
++ if (room.server_id && !fromServer && visibility != null && visibility !== room.visibility) fail(409, 'rooms.server_channel', 'A channel is as public as its server: change the server instead');
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 (name != null) { const n = String(name).replace(/\s+/g, ' ').trim(); if (n.length < minName || n.length > 40 || !wordFilter.check(n).safe) fail(422, 'rooms.name', `Room names are ${minName} 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; }
@@@ -381,6 +395,13 @@ async function update(room, actor, { name, topic, visibility, slow_seconds, join
/** The room's people, by role. `seen` adds last_seen_at (moderators only: it is presence). */
async function members(room, { seen = false } = {}) {
++ // A server's channel: the server's people, with the role each has in the channel.
++ if (room.server_id) {
++ const server = await require('./servers').byId(room.server_id);
++ if (!server) return [];
++ const as = (r) => (r === 'owner' ? 'owner' : r === 'admin' || r === 'mod' ? 'mod' : kindOf(room) === 'call' ? joinRoleOf(room) : 'member');
++ return (await require('./servers').members(server)).map((m) => ({ user_id: m.user_id, role: as(m.role), server_role: m.role, username: m.username, display_name: m.display_name }));
++ }
const order = `CASE m.role ${Object.entries(ROLE_ORDER).map(([r, n]) => `WHEN '${r}' THEN ${n}`).join(' ')} ELSE 9 END`;
return await 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}, lower(u.username) LIMIT 500`, [room.id]);
@@@ -437,6 +458,6 @@ function _reset() { _rate.clear(); }
module.exports = {
postAsAi,
ensureSchema, create, list, bySlug, access, join, leave, post, history, markRead, deleteMessage, setRole, update, members, publicRoom, messageById,
-- attach, detach, attachments, joinRoleOf, unreadTotal,
++ attach, detach, attachments, joinRoleOf, unreadTotal, isStaff, siteBanned, POSTERS, TALKERS,
RoomError, SLUG_RE, KINDS, ROLES, KIND_ROLES, JOIN_ROLE, JOIN_ROLES_CALL, ATTACH_SERVICES, _reset,
};
ddiff --git a/server/rooms/server-routes.js b/server/rooms/server-routes.js
nnew file mode 100644
iindex 0000000..b5bfb7c
---- /dev/null
++++ b/server/rooms/server-routes.js
@@@ -0,0 +1,181 @@
++'use strict';
++/**
++ * /api/chat/servers — servers over REST (server/rooms/servers.js); openvibe.chat and bots use it. A channel is a room,
++ * so its messages, history, reads and calls are /api/chat/rooms/<channel.room> and /ws/chat join_room as usual.
++ *
++ * GET / public servers by size + the caller's servers (with unread counts)
++ * POST / create { name, slug?, description?, visibility? } (with #general and a voice Lounge)
++ * GET /:slug the server, its channels (members: unread counts), what the caller may do
++ * PATCH /:slug owner/admins: { name?, description?, visibility? } (channels follow it)
++ * DELETE /:slug the owner (or chat staff): the server and its channels are archived
++ * POST /:slug/join { invite? }: a public server, or any with a valid invite → { role }
++ * POST /:slug/leave
++ * GET /:slug/members (moderators also see who is banned)
++ * POST /:slug/members moderators: { username | subject, role: admin | mod | member | blocked | none }
++ * POST /:slug/channels owner/admins: { name, kind: text | voice, topic? }
++ * PATCH /:slug/channels/:channel owner/admins: { name?, topic?, slow_seconds?, join_role?, move?: up | down }
++ * DELETE /:slug/channels/:channel owner/admins (a server keeps one text channel)
++ * GET /:slug/invites owner/admins: the live invites
++ * POST /:slug/invites { max_uses?, expires_hours? } → { invite: { code, … }, url }
++ * DELETE /:slug/invites/:code
++ * GET /invites/:code what an invite opens (anyone with the code): the server's name and size
++ *
++ * A private server is a 404 to anyone who is not in it, whatever they ask.
++ */
++const express = require('express');
++const cache = require('openvibe-shared/cache-policy');
++const { requireAuth, optionalAuth } = require('../auth/auth');
++const { limits } = require('../net/actor-limits');
++const rooms = require('./rooms');
++const servers = require('./servers');
++const ctx = require('../live-context');
++
++const router = express.Router();
++const createLimit = limits('chat.server.create', { minute: 5, hour: 20 });
++const manageLimit = limits('chat.server.manage', { minute: 30, hour: 300 });
++const joinLimit = limits('chat.server.join', { minute: 20, hour: 200 });
++
++function send(res, err) {
++ if (err instanceof rooms.RoomError) return res.status(err.status).json({ error: err.message, code: err.code });
++ console.error('[Servers]', err && err.message);
++ return res.status(500).json({ error: 'Something went wrong' });
++}
++const chatServer = () => require('../chat/chat-server');
++const roomRoutes = () => require('./routes');
++const siteUrl = () => { try { return require('../config').web.baseUrl; } catch { return 'https://openvibe.chat'; } };
++
++/** After a membership, role or visibility change: every channel's sockets and calls follow the server. */
++async function applyServerChange(server) {
++ for (const r of await require('../db/database').all('SELECT * FROM rooms WHERE server_id = ? AND archived_at IS NULL', [server.id])) {
++ try { await roomRoutes().applyRoomChange(r); } catch (err) { console.warn('[Servers] room update:', err.message); }
++ }
++}
++
++async function readable(req, res) {
++ const server = await servers.bySlug(req.params.slug);
++ if (!server || !(await servers.access(server, req.user || null)).read) { res.status(404).json({ error: 'No such server', code: 'servers.not_found' }); return null; }
++ return server;
++}
++async function channelOf(req, res, server) {
++ const room = await servers.channelBySlug(server, req.params.channel);
++ if (!room) { res.status(404).json({ error: 'No such channel', code: 'servers.no_channel' }); return null; }
++ return room;
++}
++const canOf = (a) => ({ read: a.read, member: a.member, moderate: a.moderate, manage: a.manage, owner: a.owner });
++
++router.get('/', optionalAuth, async (req, res) => {
++ try { res.set('Cache-Control', cache.htmlHeaders({ private: true })).json(await servers.list(req.user || null, { limit: req.query.limit })); } catch (err) { send(res, err); }
++});
++
++router.post('/', requireAuth, createLimit, async (req, res) => {
++ try { res.status(201).json({ server: await servers.create(req.user, req.body || {}) }); } catch (err) { send(res, err); }
++});
++
++router.get('/invites/:code', optionalAuth, async (req, res) => {
++ const found = await servers.inviteByCode(req.params.code);
++ if (!found) return res.status(404).json({ error: 'That invite has expired or was used up', code: 'servers.invite_invalid' });
++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ server: servers.publicServer(found.server), invite: { code: found.invite.code, expires_at: found.invite.expires_at || null } });
++});
++
++router.get('/:slug', optionalAuth, async (req, res) => {
++ const server = await readable(req, res); if (!server) return;
++ const a = await servers.access(server, req.user || null);
++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({
++ server: servers.publicServer(server, { role: a.role }),
++ channels: await servers.channels(server, a.member ? req.user : null),
++ can: canOf(a),
++ });
++});
++
++router.patch('/:slug', requireAuth, manageLimit, async (req, res) => {
++ const server = await readable(req, res); if (!server) return;
++ try { const out = await servers.update(server, req.user, req.body || {}); await applyServerChange(out); res.json({ server: servers.publicServer(out) }); } catch (err) { send(res, err); }
++});
++
++router.delete('/:slug', requireAuth, manageLimit, async (req, res) => {
++ const server = await readable(req, res); if (!server) return;
++ try { await servers.archive(server, req.user); await applyServerChange(server); res.status(204).end(); } catch (err) { send(res, err); }
++});
++
++router.post('/:slug/join', requireAuth, joinLimit, async (req, res) => {
++ const server = await servers.bySlug(req.params.slug);
++ if (!server) return res.status(404).json({ error: 'No such server', code: 'servers.not_found' });
++ try { const role = await servers.join(server, req.user, { invite: req.body && req.body.invite }); await applyServerChange(server); res.json({ ok: true, role }); } catch (err) { send(res, err); }
++});
++
++router.post('/:slug/leave', requireAuth, manageLimit, async (req, res) => {
++ const server = await readable(req, res); if (!server) return;
++ try { await servers.leave(server, req.user); await applyServerChange(server); res.json({ ok: true }); } catch (err) { send(res, err); }
++});
++
++router.get('/:slug/members', optionalAuth, async (req, res) => {
++ const server = await readable(req, res); if (!server) return;
++ const a = await servers.access(server, req.user || null);
++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ members: await servers.members(server, { blocked: a.moderate }) });
++});
++
++router.post('/:slug/members', requireAuth, manageLimit, async (req, res) => {
++ const server = await readable(req, res); if (!server) return;
++ try {
++ const name = String((req.body && req.body.username) || '').trim();
++ const subject = String((req.body && req.body.subject) || '').replace(/^user:/, '').trim();
++ let target = null;
++ if (/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject)) target = await ctx.getUserBySubject(subject);
++ else if (/^[A-Za-z0-9_]{3,24}$/.test(name)) target = await ctx.getUserByUsername(name) || await ctx.ensureUserByUsername(name).catch(() => null);
++ if (!target) return res.status(404).json({ error: subject ? 'No such OpenVibe account' : `Nobody called ${name.slice(0, 24)} on OpenVibe`, code: 'servers.no_user' });
++ const role = await servers.setRole(server, req.user, target.id, String((req.body && req.body.role) || 'member'));
++ if (role === 'blocked' || role == null) {
++ for (const r of await require('../db/database').all('SELECT id FROM rooms WHERE server_id = ?', [server.id])) chatServer().removeFromRoom(r.id, target.id);
++ }
++ await applyServerChange(server);
++ res.json({ ok: true, user_id: target.id, username: target.username, role });
++ } catch (err) { send(res, err); }
++});
++
++router.post('/:slug/channels', requireAuth, manageLimit, async (req, res) => {
++ const server = await readable(req, res); if (!server) return;
++ try {
++ const b = req.body || {};
++ const room = await servers.createChannel(server, req.user, { name: b.name, kind: b.kind || 'text', topic: b.topic });
++ res.status(201).json({ channel: servers.publicChannel(room) });
++ } catch (err) { send(res, err); }
++});
++
++router.patch('/:slug/channels/:channel', requireAuth, manageLimit, async (req, res) => {
++ const server = await readable(req, res); if (!server) return;
++ const room = await channelOf(req, res, server); if (!room) return;
++ try {
++ const out = await servers.updateChannel(server, req.user, room, req.body || {});
++ await roomRoutes().applyRoomChange(out);
++ res.json({ channel: servers.publicChannel(out) });
++ } catch (err) { send(res, err); }
++});
++
++router.delete('/:slug/channels/:channel', requireAuth, manageLimit, async (req, res) => {
++ const server = await readable(req, res); if (!server) return;
++ const room = await channelOf(req, res, server); if (!room) return;
++ try { await servers.deleteChannel(server, req.user, room); res.status(204).end(); } catch (err) { send(res, err); }
++});
++
++router.get('/:slug/invites', requireAuth, async (req, res) => {
++ const server = await readable(req, res); if (!server) return;
++ const a = await servers.access(server, req.user);
++ if (!a.moderate) return res.status(403).json({ error: 'Only the server\'s owner, admins and moderators see its invites', code: 'servers.not_mod' });
++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ invites: await servers.invites(server) });
++});
++
++router.post('/:slug/invites', requireAuth, manageLimit, async (req, res) => {
++ const server = await readable(req, res); if (!server) return;
++ try {
++ const invite = await servers.createInvite(server, req.user, req.body || {});
++ res.status(201).json({ invite, url: `${siteUrl()}/join/${invite.code}` });
++ } catch (err) { send(res, err); }
++});
++
++router.delete('/:slug/invites/:code', requireAuth, manageLimit, async (req, res) => {
++ const server = await readable(req, res); if (!server) return;
++ try { await servers.revokeInvite(server, req.user, req.params.code); res.status(204).end(); } catch (err) { send(res, err); }
++});
++
++module.exports = router;
++module.exports.applyServerChange = applyServerChange;
ddiff --git a/server/rooms/servers.js b/server/rooms/servers.js
nnew file mode 100644
iindex 0000000..636d6dc
---- /dev/null
++++ b/server/rooms/servers.js
@@@ -0,0 +1,417 @@
++'use strict';
++/**
++ * Servers (migration 0013; owner 2026-09-28 Q14: friend groups and couples first, then communities with servers,
++ * channels and voice). A server is a group of rooms, its channels, with one membership and one set of roles:
++ *
++ * chat_servers slug (/s/<slug>), name, description, visibility (public: listed, anyone reads, signed-in
++ * people join; private: only people with an invite), owner
++ * chat_server_members role: owner (the creator, one), admin, mod, member, blocked (banned from the server)
++ * chat_server_invites /join/<code>: any number of uses or a cap, never expiring or after a while, revocable
++ * rooms.server_id a channel: a text channel is a community room, a voice channel a call room (its call is
++ * the room's call, so voice works like any call room: who talks follows the role)
++ *
++ * What a person may do in a channel comes from the server (channelAccess, which rooms.access() calls for any room
++ * with a server): the room's own room_members rows only keep where each person read up to.
++ *
++ * server role read post voice: talk moderate (delete, kick, ban) manage (channels, settings, invites)
++ * owner yes yes yes yes yes, and appoints admins
++ * admin yes yes yes yes yes, and appoints mods
++ * mod yes yes yes yes (members only) -
++ * member yes yes as the channel says (speakers by default) - -
++ * none public server: read and listen; private server: nothing, not even that it exists
++ * blocked nothing
++ * chat staff (staff.moderation.chat): read, moderate and manage every server; a site ban: no posting, no talking
++ *
++ * Every action on another person is logged with db.logModerationAction (scope server:<slug>).
++ */
++const crypto = require('crypto');
++const db = require('../db/database');
++const ctx = require('../live-context');
++const wordFilter = require('../chat/word-filter');
++const rooms = require('./rooms');
++
++const { RoomError } = rooms;
++const fail = (status, code, message) => { throw new RoomError(status, code, message); };
++
++const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])$/;
++const RESERVED = new Set(['new', 'servers', 'settings', 'join', 'invite', 'invites', 'api', 'admin', 'staff', 'help', 'explore', 'discover', 'openvibe']);
++const ROLES = ['owner', 'admin', 'mod', 'member', 'blocked'];
++const RANK = { owner: 0, admin: 1, mod: 2, member: 3, blocked: 4 };
++const CHANNEL_KINDS = { text: 'community', voice: 'call' };
++/** Channel names the server's own pages use (/s/<server>/settings …). */
++const CHANNEL_RESERVED = new Set(['settings', 'join', 'leave', 'channels', 'invites', 'members', 'delete']);
++const MAX_SERVERS_PER_OWNER = 5;
++const MAX_CHANNELS = 50;
++const MAX_INVITES = 25;
++const INVITE_ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789';
++
++const isStaff = (user) => rooms.isStaff(user);
++
++function slugify(name) {
++ return String(name || '').toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32).replace(/-+$/g, '');
++}
++const cleanName = (v, min, max) => {
++ const n = String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim();
++ return n.length >= min && n.length <= max ? n : null;
++};
++const randomCode = (n) => Array.from(crypto.randomBytes(n), (b) => INVITE_ALPHABET[b % INVITE_ALPHABET.length]).join('');
++
++async function bySlug(slug) { return await db.get('SELECT * FROM chat_servers WHERE slug = ? AND archived_at IS NULL', [String(slug || '').toLowerCase()]) || null; }
++async function byId(id) { return await db.get('SELECT * FROM chat_servers WHERE id = ? AND archived_at IS NULL', [Number(id)]) || null; }
++async function roleIn(serverId, userId) {
++ if (!userId) return null;
++ return (await db.get('SELECT role FROM chat_server_members WHERE server_id = ? AND user_id = ?', [serverId, userId]) || {}).role || null;
++}
++
++/** What `user` may do in the server itself: { role, read, member, moderate, manage, owner }. */
++async function access(server, user) {
++ const role = user ? await roleIn(server.id, user.id) : null;
++ const staff = isStaff(user);
++ const blocked = role === 'blocked';
++ const member = !!role && !blocked;
++ return {
++ role, member, staff,
++ read: !blocked && (server.visibility === 'public' || member || staff),
++ moderate: !!user && !blocked && (['owner', 'admin', 'mod'].includes(role) || staff),
++ manage: !!user && !blocked && (['owner', 'admin'].includes(role) || staff),
++ owner: !!user && role === 'owner',
++ };
++}
++
++/**
++ * What `user` may do in a channel (a room with server_id): the room's access shape (rooms.access), decided by the
++ * server role. A channel of a server that is gone reads as nothing.
++ */
++async function channelAccess(room, user) {
++ const kind = room.kind === 'call' ? 'call' : 'community';
++ const server = await byId(room.server_id);
++ if (!server) return { role: null, kind, read: false, post: false, join: false, talk: false, moderate: false, manage: false, server: null, server_role: null };
++ const a = await access(server, user);
++ const srole = a.role;
++ // Server owner → room owner; admins and moderators → room moderators; members → the channel's member role.
++ const vrole = !a.member ? null : srole === 'owner' ? 'owner' : RANK[srole] <= RANK.mod ? 'mod' : kind === 'call' ? rooms.joinRoleOf(room) : 'member';
++ const banned = rooms.siteBanned(user);
++ const post = !!user && a.read && !banned && a.member && rooms.POSTERS[kind].has(vrole);
++ const join = kind === 'call' && a.read;
++ const talk = join && !!user && !banned && ((a.member && rooms.TALKERS.has(vrole)) || a.staff);
++ return { role: vrole, kind, read: a.read, post, join, talk, moderate: a.moderate, manage: a.manage, server: server.slug, server_role: srole };
++}
++
++function publicServer(s, extra = {}) {
++ if (!s) return null;
++ return {
++ id: Number(s.id), slug: s.slug, name: s.name, description: s.description || null, visibility: s.visibility,
++ members: Number(s.member_count) || 0, created_at: s.created_at, ...extra,
++ };
++}
++
++/** A channel as the server shows it. */
++function publicChannel(r, extra = {}) {
++ return {
++ slug: r.channel_slug, room: r.slug, name: r.name, topic: r.topic || null, kind: r.kind === 'call' ? 'voice' : 'text',
++ position: Number(r.position) || 0, message_count: Number(r.message_count) || 0, last_message_at: r.last_message_at || null,
++ ...(r.kind === 'call' ? { call_channel: `room-${r.slug}`, join_role: rooms.joinRoleOf(r) } : {}), ...extra,
++ };
++}
++
++/** The server's channels, text first, each kind in its order. `user` adds unread counts (members). */
++async function channels(server, user = null) {
++ const rows = await db.all(`SELECT r.* ${user ? ', m.last_read_id' : ''} FROM rooms r
++ ${user ? 'LEFT JOIN room_members m ON m.room_id = r.id AND m.user_id = ?' : ''}
++ WHERE r.server_id = ? AND r.archived_at IS NULL ORDER BY CASE r.kind WHEN 'call' THEN 1 ELSE 0 END, r.position, r.id`,
++ user ? [user.id, server.id] : [server.id]);
++ if (!user) return rows.map((r) => publicChannel(r));
++ const out = [];
++ for (const r of rows) {
++ const unread = r.kind === 'call' && !r.message_count ? 0 : Number((await db.get(
++ 'SELECT COUNT(*) AS n FROM room_messages WHERE room_id = ? AND id > ? AND is_deleted = 0 AND user_id != ?', [r.id, Number(r.last_read_id) || 0, user.id]) || {}).n) || 0;
++ out.push(publicChannel(r, { unread: r.last_read_id == null ? Math.min(unread, 99) : unread }));
++ }
++ return out;
++}
++async function channelBySlug(server, channelSlug) {
++ return await db.get('SELECT * FROM rooms WHERE server_id = ? AND channel_slug = ? AND archived_at IS NULL', [server.id, String(channelSlug || '').toLowerCase()]) || null;
++}
++
++async function insertChannel(server, user, { name, kind, topic = null, position = null }) {
++ const n = cleanName(name, 1, 40);
++ if (!n) fail(422, 'servers.channel_name', 'Channel names are 1 to 40 characters');
++ if (wordFilter.check(n).safe === false) fail(422, 'servers.channel_name_filtered', 'That channel name is not allowed');
++ const roomKind = CHANNEL_KINDS[kind];
++ if (!roomKind) fail(422, 'servers.channel_kind', 'A channel is text or voice');
++ const count = Number((await db.get('SELECT COUNT(*) AS n FROM rooms WHERE server_id = ? AND archived_at IS NULL', [server.id]) || {}).n) || 0;
++ if (count >= MAX_CHANNELS) fail(429, 'servers.too_many_channels', `A server has up to ${MAX_CHANNELS} channels`);
++ let cs = slugify(n) || (kind === 'voice' ? 'voice' : 'chat');
++ if (cs.length < 2 || CHANNEL_RESERVED.has(cs)) cs = `${cs}-${kind}`;
++ for (let i = 2; await channelBySlug(server, cs); i++) cs = `${slugify(n).slice(0, 28) || kind}-${i}`;
++ let roomSlug;
++ do { roomSlug = `ch${randomCode(12)}`; } while (await db.get('SELECT 1 FROM rooms WHERE slug = ?', [roomSlug]));
++ const pos = position != null ? position : Number((await db.get('SELECT COALESCE(MAX(position), -1) + 1 AS p FROM rooms WHERE server_id = ? AND kind = ?', [server.id, roomKind]) || {}).p) || 0;
++ const t = topic ? String(topic).replace(/\s+/g, ' ').trim().slice(0, 200) || null : null;
++ // The server owns its channels: owner_subject stays empty, so a person's own-room tools never act on one.
++ const r = await db.run(`INSERT INTO rooms (slug, name, topic, kind, visibility, owner_id, owner_subject, join_role, server_id, channel_slug, position)
++ VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?)`,
++ [roomSlug, n, t, roomKind, server.visibility, server.owner_id, roomKind === 'call' ? 'speaker' : null, server.id, cs, pos]);
++ return await db.get('SELECT * FROM rooms WHERE id = ?', [r.lastInsertRowid]);
++}
++
++async function create(user, { name, slug, description, visibility = 'public' } = {}) {
++ if (!user) fail(401, 'servers.sign_in', 'Sign in to create a server');
++ if (rooms.siteBanned(user)) fail(403, 'servers.banned', 'Your account is banned from chat');
++ const n = cleanName(name, 2, 40);
++ if (!n) fail(422, 'servers.name', 'Server names are 2 to 40 characters');
++ if (wordFilter.check(n).safe === false) fail(422, 'servers.name_filtered', 'That server name is not allowed');
++ const s = slug ? String(slug).toLowerCase().trim() : slugify(n);
++ if (!SLUG_RE.test(s) || RESERVED.has(s)) fail(422, 'servers.slug', 'Server addresses are 3 to 32 lowercase letters, numbers and dashes (and not a reserved word)');
++ if (!['public', 'private'].includes(visibility)) fail(422, 'servers.visibility', 'A server is public or private');
++ const d = description ? String(description).replace(/\s+/g, ' ').trim().slice(0, 300) || null : null;
++ if (d && wordFilter.check(d).safe === false) fail(422, 'servers.description_filtered', 'That description is not allowed');
++ const owned = Number((await db.get('SELECT COUNT(*) AS n FROM chat_servers WHERE owner_id = ? AND archived_at IS NULL', [user.id]) || {}).n) || 0;
++ if (owned >= MAX_SERVERS_PER_OWNER && !isStaff(user)) fail(429, 'servers.too_many', `You can own up to ${MAX_SERVERS_PER_OWNER} servers`);
++ if (await db.get('SELECT 1 FROM chat_servers WHERE slug = ?', [s])) fail(409, 'servers.taken', 'That server address is taken');
++ const id = await db.tx(async () => {
++ const r = await db.run('INSERT INTO chat_servers (slug, name, description, visibility, owner_id, owner_subject, member_count) VALUES (?, ?, ?, ?, ?, ?, 1)',
++ [s, n, d, visibility, user.id, user.subject_id || null]);
++ const server = await db.get('SELECT * FROM chat_servers WHERE id = ?', [r.lastInsertRowid]);
++ await db.run("INSERT INTO chat_server_members (server_id, user_id, role) VALUES (?, ?, 'owner')", [server.id, user.id]);
++ await insertChannel(server, user, { name: 'general', kind: 'text', topic: 'Say hello', position: 0 });
++ await insertChannel(server, user, { name: 'Lounge', kind: 'voice', position: 0 });
++ await db.run("INSERT INTO room_members (room_id, user_id, role, last_read_id, last_seen_at) SELECT id, ?, 'member', 0, ov_now() FROM rooms WHERE server_id = ?", [user.id, server.id]);
++ return Number(server.id);
++ });
++ return publicServer(await byId(id), { role: 'owner' });
++}
++
++/** The servers `user` is in (with unread counts), and public servers by size. */
++async function list(user, { limit = 50 } = {}) {
++ const lim = Math.min(100, Math.max(1, Number(limit) || 50));
++ const pub = (await db.all("SELECT * FROM chat_servers WHERE visibility = 'public' AND archived_at IS NULL ORDER BY member_count DESC, id DESC LIMIT ?", [lim])).map((s) => publicServer(s));
++ if (!user) return { public: pub, mine: [] };
++ const rows = await db.all(`SELECT s.*, m.role FROM chat_server_members m JOIN chat_servers s ON s.id = m.server_id
++ WHERE m.user_id = ? AND m.role != 'blocked' AND s.archived_at IS NULL ORDER BY m.joined_at, s.id`, [user.id]);
++ const mine = [];
++ for (const s of rows) {
++ const unread = Number((await db.get(`SELECT COUNT(*) AS n FROM rooms r JOIN room_members rm ON rm.room_id = r.id AND rm.user_id = ?
++ JOIN room_messages x ON x.room_id = r.id AND x.id > rm.last_read_id AND x.is_deleted = 0 AND x.user_id != ?
++ WHERE r.server_id = ? AND r.archived_at IS NULL`, [user.id, user.id, s.id]) || {}).n) || 0;
++ mine.push(publicServer(s, { role: s.role, unread }));
++ }
++ return { public: pub, mine };
++}
++
++async function syncCount(server) {
++ await db.run("UPDATE chat_servers SET member_count = (SELECT COUNT(*) FROM chat_server_members WHERE server_id = ? AND role != 'blocked'), updated_at = ov_now() WHERE id = ?", [server.id, server.id]);
++}
++
++/** A valid invite by its code (with its server), or null. */
++async function inviteByCode(code) {
++ const c = String(code || '').toLowerCase();
++ if (!/^[a-z0-9]{6,16}$/.test(c)) return null;
++ const inv = await db.get('SELECT * FROM chat_server_invites WHERE code = ? AND revoked_at IS NULL', [c]);
++ if (!inv) return null;
++ if (inv.expires_at && Date.parse(inv.expires_at) <= Date.now()) return null;
++ if (inv.max_uses != null && inv.uses >= inv.max_uses) return null;
++ const server = await byId(inv.server_id);
++ return server ? { invite: inv, server } : null;
++}
++
++/** Join: a public server, or any server with a valid invite. → the caller's role. */
++async function join(server, user, { invite = null } = {}) {
++ if (!user) fail(401, 'servers.sign_in', 'Sign in to join a server');
++ const role = await roleIn(server.id, user.id);
++ if (role === 'blocked') fail(403, 'servers.blocked', 'You are banned from this server');
++ if (role) return role;
++ let inv = null;
++ if (invite) {
++ const found = await inviteByCode(invite);
++ if (!found || Number(found.server.id) !== Number(server.id)) fail(410, 'servers.invite_invalid', 'That invite has expired or was used up');
++ inv = found.invite;
++ }
++ // A private server is not there for someone without an invite (the same answer as a missing one).
++ if (server.visibility !== 'public' && !inv && !isStaff(user)) fail(404, 'servers.not_found', 'No such server');
++ await db.tx(async () => {
++ await db.run("INSERT INTO chat_server_members (server_id, user_id, role) VALUES (?, ?, 'member') ON CONFLICT DO NOTHING", [server.id, user.id]);
++ // Everything already in the channels counts as read: a new member starts with nothing unread.
++ await db.run(`INSERT INTO room_members (room_id, user_id, role, last_read_id, last_seen_at)
++ SELECT r.id, ?, 'member', COALESCE((SELECT MAX(x.id) FROM room_messages x WHERE x.room_id = r.id), 0), ov_now()
++ FROM rooms r WHERE r.server_id = ? AND r.archived_at IS NULL
++ ON CONFLICT (room_id, user_id) DO NOTHING`, [user.id, server.id]);
++ if (inv) await db.run('UPDATE chat_server_invites SET uses = uses + 1 WHERE code = ?', [inv.code]);
++ await syncCount(server);
++ });
++ return 'member';
++}
++
++async function dropFromChannels(server, userId) {
++ await db.run('DELETE FROM room_members WHERE user_id = ? AND room_id IN (SELECT id FROM rooms WHERE server_id = ?)', [userId, server.id]);
++}
++
++async function leave(server, user) {
++ const role = await roleIn(server.id, user && user.id);
++ if (role === 'owner') fail(409, 'servers.owner_leaves', 'The owner cannot leave their server');
++ if (!role || role === 'blocked') return;
++ await db.tx(async () => {
++ await db.run('DELETE FROM chat_server_members WHERE server_id = ? AND user_id = ?', [server.id, user.id]);
++ await dropFromChannels(server, user.id);
++ await syncCount(server);
++ });
++}
++
++/**
++ * Change someone's role: admin, mod, member, blocked (a ban) or none (kick). The owner appoints and removes admins;
++ * admins appoint mods; mods kick and ban members. Nobody acts on someone of their own rank or above, and nobody
++ * changes the owner.
++ */
++async function setRole(server, actor, targetUserId, role) {
++ if (!ROLES.includes(role) && role !== 'none') fail(422, 'servers.role', 'Unknown role');
++ if (role === 'owner') fail(422, 'servers.role', 'A server has one owner');
++ const a = await access(server, actor);
++ if (!a.moderate) fail(403, 'servers.not_mod', 'Only the server\'s owner, admins and moderators manage members');
++ const target = await ctx.getUserById(Number(targetUserId));
++ if (!target) fail(404, 'servers.no_user', 'No such person');
++ const current = await roleIn(server.id, target.id);
++ if (current === 'owner') fail(403, 'servers.owner', 'The owner\'s role does not change');
++ const mine = a.staff ? -1 : RANK[a.role];
++ const need = (r) => (r === 'admin' ? RANK.owner : r === 'mod' ? RANK.admin : RANK.mod);
++ const RANK_REFUSAL = { admin: 'Only the owner appoints admins', mod: 'Only the owner and admins appoint moderators' };
++ if (mine > need(role === 'none' || role === 'blocked' || role === 'member' ? 'member' : role)) fail(403, 'servers.rank', RANK_REFUSAL[role] || 'Only the owner, admins and moderators manage members');
++ if (current && current !== 'blocked' && current !== 'member' && mine >= RANK[current]) fail(403, 'servers.rank', 'You cannot change someone of your own rank or above');
++ if (!current && role !== 'blocked' && role !== 'none' && server.visibility !== 'public' && !a.manage) fail(403, 'servers.invite_first', 'Invite them first');
++ await db.tx(async () => {
++ if (role === 'none') {
++ if (current && current !== 'blocked') { await db.run('DELETE FROM chat_server_members WHERE server_id = ? AND user_id = ?', [server.id, target.id]); await dropFromChannels(server, target.id); }
++ else if (current === 'blocked') await db.run('DELETE FROM chat_server_members WHERE server_id = ? AND user_id = ?', [server.id, target.id]);
++ } else if (current) {
++ await db.run('UPDATE chat_server_members SET role = ? WHERE server_id = ? AND user_id = ?', [role, server.id, target.id]);
++ if (role === 'blocked') await dropFromChannels(server, target.id);
++ } else {
++ await db.run('INSERT INTO chat_server_members (server_id, user_id, role) VALUES (?, ?, ?)', [server.id, target.id, role]);
++ }
++ await syncCount(server);
++ });
++ const action = role === 'blocked' && current !== 'blocked' ? 'server_ban'
++ : current === 'blocked' && role !== 'blocked' ? 'server_unban'
++ : role === 'none' && current ? 'server_kick'
++ : role !== current ? `server_role_${role}` : null;
++ if (action) await db.logModerationAction({ scope_type: 'server', scope_id: server.slug, actor_user_id: actor.id, target_user_id: target.id, action_type: action, details: { from: current, to: role } });
++ return role === 'none' ? null : role;
++}
++
++/** The server's people by rank (blocked last, only for moderators). */
++async function members(server, { blocked = false, limit = 1000 } = {}) {
++ const order = `CASE m.role ${Object.entries(RANK).map(([r, n]) => `WHEN '${r}' THEN ${n}`).join(' ')} ELSE 9 END`;
++ return await db.all(`SELECT m.user_id, m.role, m.joined_at, u.username, u.display_name, u.profile_color FROM chat_server_members m
++ LEFT JOIN ctx_users u ON u.id = m.user_id WHERE m.server_id = ? ${blocked ? '' : "AND m.role != 'blocked'"}
++ ORDER BY ${order}, lower(u.username) LIMIT ?`, [server.id, Math.min(5000, Math.max(1, Number(limit) || 1000))]);
++}
++
++async function update(server, actor, { name, description, visibility } = {}) {
++ if (!(await access(server, actor)).manage) fail(403, 'servers.not_admin', 'Only the server\'s owner and admins change it');
++ const next = { name: server.name, description: server.description, visibility: server.visibility };
++ if (name != null) { const n = cleanName(name, 2, 40); if (!n || wordFilter.check(n).safe === false) fail(422, 'servers.name', 'Server names are 2 to 40 characters'); next.name = n; }
++ if (description != null) next.description = String(description).replace(/\s+/g, ' ').trim().slice(0, 300) || null;
++ if (visibility != null) { if (!['public', 'private'].includes(visibility)) fail(422, 'servers.visibility', 'A server is public or private'); next.visibility = visibility; }
++ await db.run('UPDATE chat_servers SET name = ?, description = ?, visibility = ?, updated_at = ov_now() WHERE id = ?', [next.name, next.description, next.visibility, server.id]);
++ // The channels follow the server: going private redacts what public channels had published (rooms.update).
++ if (next.visibility !== server.visibility) {
++ for (const r of await db.all('SELECT * FROM rooms WHERE server_id = ? AND archived_at IS NULL', [server.id])) {
++ await rooms.update(r, actor, { visibility: next.visibility }, { fromServer: true });
++ }
++ }
++ return await byId(server.id);
++}
++
++async function createChannel(server, actor, { name, kind = 'text', topic = null } = {}) {
++ if (!(await access(server, actor)).manage) fail(403, 'servers.not_admin', 'Only the server\'s owner and admins add channels');
++ const room = await insertChannel(server, actor, { name, kind, topic });
++ // Every member reads the new channel from its first message on.
++ await db.run(`INSERT INTO room_members (room_id, user_id, role, last_read_id, last_seen_at)
++ SELECT ?, user_id, 'member', 0, NULL FROM chat_server_members WHERE server_id = ? AND role != 'blocked'
++ ON CONFLICT (room_id, user_id) DO NOTHING`, [room.id, server.id]);
++ return room;
++}
++
++/** Rename, retopic, slow mode, a voice channel's join role, or move it up/down within its kind. */
++async function updateChannel(server, actor, room, { name, topic, slow_seconds, join_role, move } = {}) {
++ if (!(await access(server, actor)).manage) fail(403, 'servers.not_admin', 'Only the server\'s owner and admins change channels');
++ if (Number(room.server_id) !== Number(server.id)) fail(404, 'servers.no_channel', 'No such channel');
++ if (name != null) {
++ const n = cleanName(name, 1, 40);
++ if (!n || wordFilter.check(n).safe === false) fail(422, 'servers.channel_name', 'Channel names are 1 to 40 characters');
++ }
++ await rooms.update(room, actor, { name: name != null ? name : null, topic, slow_seconds, join_role }, { fromServer: true, minName: 1 });
++ if (move === 'up' || move === 'down') {
++ const siblings = await db.all('SELECT id, position FROM rooms WHERE server_id = ? AND kind = ? AND archived_at IS NULL ORDER BY position, id', [server.id, room.kind]);
++ const i = siblings.findIndex((x) => Number(x.id) === Number(room.id));
++ const j = move === 'up' ? i - 1 : i + 1;
++ if (i >= 0 && j >= 0 && j < siblings.length) {
++ const order = siblings.map((x) => x.id);
++ [order[i], order[j]] = [order[j], order[i]];
++ await db.tx(async () => { for (let k = 0; k < order.length; k++) await db.run('UPDATE rooms SET position = ? WHERE id = ?', [k, order[k]]); });
++ }
++ }
++ return await db.get('SELECT * FROM rooms WHERE id = ?', [room.id]);
++}
++
++/** Delete a channel (archived: its page goes, its messages stay for moderation). A server keeps one text channel. */
++async function deleteChannel(server, actor, room) {
++ if (!(await access(server, actor)).manage) fail(403, 'servers.not_admin', 'Only the server\'s owner and admins delete channels');
++ if (Number(room.server_id) !== Number(server.id)) fail(404, 'servers.no_channel', 'No such channel');
++ if (room.kind !== 'call') {
++ const texts = Number((await db.get("SELECT COUNT(*) AS n FROM rooms WHERE server_id = ? AND kind != 'call' AND archived_at IS NULL", [server.id]) || {}).n) || 0;
++ if (texts <= 1) fail(409, 'servers.last_channel', 'A server keeps at least one text channel');
++ }
++ await db.run('UPDATE rooms SET archived_at = ov_now() WHERE id = ? AND archived_at IS NULL', [room.id]);
++ await db.logModerationAction({ scope_type: 'server', scope_id: server.slug, actor_user_id: actor.id, target_user_id: null, action_type: 'server_channel_delete', details: { channel: room.channel_slug } });
++}
++
++function publicInvite(i) {
++ return { code: i.code, uses: Number(i.uses) || 0, max_uses: i.max_uses == null ? null : Number(i.max_uses), expires_at: i.expires_at || null, created_at: i.created_at, revoked: !!i.revoked_at };
++}
++
++/** A new invite: `max_uses` (null or 1..1000) and `expires_hours` (null or 1..720). Managers make them; mods too in a public server. */
++async function createInvite(server, actor, { max_uses = null, expires_hours = null } = {}) {
++ const a = await access(server, actor);
++ if (!(a.manage || (a.moderate && server.visibility === 'public'))) fail(403, 'servers.not_admin', 'Only the server\'s owner and admins invite people');
++ const uses = max_uses == null || max_uses === '' ? null : Number(max_uses);
++ if (uses != null && (!Number.isInteger(uses) || uses < 1 || uses > 1000)) fail(422, 'servers.invite_uses', 'An invite is used 1 to 1000 times, or without a limit');
++ const hours = expires_hours == null || expires_hours === '' ? null : Number(expires_hours);
++ if (hours != null && (!Number.isInteger(hours) || hours < 1 || hours > 720)) fail(422, 'servers.invite_expiry', 'An invite lasts 1 to 720 hours, or forever');
++ const live = Number((await db.get('SELECT COUNT(*) AS n FROM chat_server_invites WHERE server_id = ? AND revoked_at IS NULL', [server.id]) || {}).n) || 0;
++ if (live >= MAX_INVITES) fail(429, 'servers.too_many_invites', `A server has up to ${MAX_INVITES} invites: revoke one first`);
++ let code;
++ do { code = randomCode(10); } while (await db.get('SELECT 1 FROM chat_server_invites WHERE code = ?', [code]));
++ const expires = hours ? new Date(Date.now() + hours * 3600_000).toISOString() : null;
++ await db.run('INSERT INTO chat_server_invites (code, server_id, created_by, created_by_subject, max_uses, expires_at) VALUES (?, ?, ?, ?, ?, ?)',
++ [code, server.id, actor.id, actor.subject_id || null, uses, expires]);
++ return publicInvite(await db.get('SELECT * FROM chat_server_invites WHERE code = ?', [code]));
++}
++async function invites(server) {
++ return (await db.all('SELECT * FROM chat_server_invites WHERE server_id = ? AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 100', [server.id])).map(publicInvite);
++}
++async function revokeInvite(server, actor, code) {
++ const a = await access(server, actor);
++ const inv = await db.get('SELECT * FROM chat_server_invites WHERE code = ? AND server_id = ?', [String(code || '').toLowerCase(), server.id]);
++ if (!inv) fail(404, 'servers.no_invite', 'No such invite');
++ if (!a.manage && !(a.moderate && inv.created_by === actor.id)) fail(403, 'servers.not_admin', 'Only the server\'s owner and admins revoke invites');
++ await db.run('UPDATE chat_server_invites SET revoked_at = ov_now() WHERE code = ? AND revoked_at IS NULL', [inv.code]);
++}
++
++/** Delete the whole server (the owner, or chat staff): it and its channels are archived. */
++async function archive(server, actor) {
++ const a = await access(server, actor);
++ if (!a.owner && !a.staff) fail(403, 'servers.not_owner', 'Only the owner deletes the server');
++ await db.tx(async () => {
++ await db.run('UPDATE rooms SET archived_at = ov_now() WHERE server_id = ? AND archived_at IS NULL', [server.id]);
++ await db.run('UPDATE chat_server_invites SET revoked_at = ov_now() WHERE server_id = ? AND revoked_at IS NULL', [server.id]);
++ await db.run('UPDATE chat_servers SET archived_at = ov_now() WHERE id = ?', [server.id]);
++ });
++ if (!a.owner) await db.logModerationAction({ scope_type: 'server', scope_id: server.slug, actor_user_id: actor.id, target_user_id: server.owner_id, action_type: 'server_delete', details: {} });
++}
++
++module.exports = {
++ bySlug, byId, roleIn, access, channelAccess, publicServer, publicChannel, channels, channelBySlug,
++ create, list, join, leave, setRole, members, update, createChannel, updateChannel, deleteChannel,
++ createInvite, invites, revokeInvite, inviteByCode, archive,
++ SLUG_RE, ROLES, RANK, MAX_SERVERS_PER_OWNER, MAX_CHANNELS,
++};
ddiff --git a/server/web/discovery.js b/server/web/discovery.js
iindex 469a921..820cd6c 100644
---- a/server/web/discovery.js
++++ b/server/web/discovery.js
@@@ -29,11 +29,16 @@ async function publicPages() {
const out = [
{ path: '/', changefreq: 'hourly', priority: 1.0, lastmod: async () => await newestGlobalMessage() },
{ path: '/rooms', changefreq: 'daily', priority: 0.8, lastmod: () => newestRoomActivity(pages) },
++ { path: '/servers', changefreq: 'daily', priority: 0.8 },
{ path: '/updates', changefreq: 'daily', priority: 0.5 },
];
for (const r of pages) {
out.push({ path: `/r/${r.slug}`, changefreq: 'daily', priority: 0.6, lastmod: () => dayOf(r.last_message_at || r.created_at) });
}
++ // Public servers (server/rooms/servers.js): their front page, the first text channel.
++ for (const sv of (await require('../rooms/servers').list(null, { limit: 100 })).public) {
++ out.push({ path: `/s/${sv.slug}`, changefreq: 'daily', priority: 0.6, lastmod: () => dayOf(sv.created_at) });
++ }
return out;
}
@@@ -78,8 +83,8 @@ function createDiscoveryRoutes({ config }) {
// the sitemap. Every previous Disallow is kept.
res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.robotsTxt({
sitemaps: [abs('/sitemap.xml')],
-- allow: ['/', '/updates', '/rooms', '/r/'],
-- disallow: ['/r/*/settings', '/messages', '/settings', '/auth/', '/api/'],
++ allow: ['/', '/updates', '/rooms', '/r/', '/servers', '/s/'],
++ disallow: ['/r/*/settings', '/s/*/settings', '/join/', '/messages', '/settings', '/auth/', '/api/'],
}));
});
@@@ -92,6 +97,7 @@ function createDiscoveryRoutes({ config }) {
{ title: 'Start here', links: [
{ title: 'Global chat', url: abs('/'), note: 'one room for everyone on OpenVibe, newest messages at the end' },
{ title: 'Rooms', url: abs('/rooms'), note: 'every public room, with its topic and activity' },
++ { title: 'Servers', url: abs('/servers'), note: 'public servers: groups with text and voice channels, roles and invites' },
{ title: 'What shipped on OpenVibe.Chat', url: abs('/updates') },
] },
{ title: 'Machine-readable', links: [
ddiff --git a/server/web/layout.js b/server/web/layout.js
iindex 82baf31..00c3075 100644
---- a/server/web/layout.js
++++ b/server/web/layout.js
@@@ -20,6 +20,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public', 'web');
const LINKS = [
{ key: 'global', label: 'Global chat', href: '/' },
{ key: 'rooms', label: 'Rooms', href: '/rooms' },
++ { key: 'servers', label: 'Servers', href: '/servers' },
{ key: 'messages', label: 'Messages', href: '/messages' },
{ key: 'settings', label: 'Settings', href: '/settings' },
];
@@@ -102,7 +103,7 @@ ${o.callScript ? `<script src="${asset('call.js')}" defer></script>` : ''}
<a class="oc-skip" href="#main">Skip to content</a>
<div id="navbar-mount"></div>
${frame.noscriptNav({ name: SITE_NAME, home: '/', links: links(o).map(({ label, href }) => ({ label, href })) })}
--<main id="main" class="oc-main">
++<main id="main" class="oc-main${o.wide ? ' oc-main--wide' : ''}">
<noscript><p class="oc-account">${who}</p></noscript>
${o.body || ''}
</main>
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex c6543d1..0d7ddb4 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -142,7 +142,12 @@ function createWebRoutes({ config }) {
const countsOf = async (actor) => {
if (actor.kind !== 'user') return {};
const n = async (fn) => { try { return Number(await fn()) || 0; } catch { return 0; } };
-- return { messages: await n(async () => await dm.getTotalUnread(actor.user.id)), rooms: await n(async () => await require('../rooms/rooms').unreadTotal(actor.user)) };
++ const rooms = require('../rooms/rooms');
++ return {
++ messages: await n(async () => await dm.getTotalUnread(actor.user.id)),
++ rooms: await n(async () => await rooms.unreadTotal(actor.user)),
++ servers: await n(async () => await rooms.unreadTotal(actor.user, { servers: true })),
++ };
};
// Under the global room on the front page: what openvibe.chat is for (openvibe-shared/showcase), each card a real
// place to go. Every line is true today (calls run since CHAT_CALLS=1; rooms, messages and settings are pages here).
@@@ -152,7 +157,7 @@ function createWebRoutes({ config }) {
items: [
{ icon: 'ov:chat', title: 'One room for the network', text: 'Global chat reaches everyone on Live, Tools, Games and the rest, and reads without an account.', href: '#oc-h' },
{ icon: 'ov:live', title: 'Stream chat on Live', text: 'Every channel on OpenVibe.Live has its room, with the streamer\'s moderators, timeouts, bans, emotes and text to speech.', href: 'https://openvibe.live/' },
-- { icon: 'ov:community', title: 'Rooms of your own', text: 'Start a public room anyone can read, or a private one that stays invisible to everyone else.', href: '/rooms' },
++ { icon: 'ov:community', title: 'Servers and rooms', text: 'Start a server with text and voice channels, roles and invite links, or a single room, public or private.', href: '/servers' },
{ icon: 'ov:account', title: 'Direct messages', text: 'One-to-one and group conversations. You choose who may start one, and whether groups may add you.', href: signedIn ? '/messages' : '/auth/login?next=%2Fmessages' },
{ icon: 'ov:stream', title: 'Voice and video calls', text: 'Call a person, open a call room, or join a stream\'s call: up to eight people, with mute, camera and kick controls.', href: '/rooms' },
{ icon: 'ov:theme', title: 'Your preferences everywhere', text: 'Compact mode, timestamps, text size, badges and emotes follow your account to every site.', href: signedIn ? '/settings' : '/auth/login?next=%2Fsettings' },
@@@ -503,9 +508,16 @@ ${people}
</section>`;
}
++ // A server's channel has one page, in its server (server-pages.js).
++ const channelUrl = async (room, rest = '') => {
++ const s = await require('../rooms/servers').byId(room.server_id);
++ return s ? `/s/${s.slug}${rest || `/${room.channel_slug}`}` : '/servers';
++ };
++
router.get('/r/:slug', async (req, res) => {
const x = await loadRoom(req, res); if (!x) return;
const { actor, me, room, can } = x;
++ if (room.server_id) return res.redirect(302, await channelUrl(room));
const msgs = await rooms.history(room, { limit: 60 });
if (me && can.role) await rooms.markRead(room, me);
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
@@@ -584,6 +596,7 @@ ${composer}${tools}`,
router.get('/r/:slug/settings', async (req, res) => {
const x = await loadRoom(req, res); if (!x) return;
const { actor, room, can } = x;
++ if (room.server_id) return res.redirect(302, await channelUrl(room, '/settings'));
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)) : '';
@@@ -691,6 +704,9 @@ ${attachList}`,
}));
// ── What shipped, robots, sitemap ──
++ // ── Servers (server/rooms/servers.js): text and voice channels, roles, invites ──
++ require('./server-pages').mountServerPages(router, { page, needUser, viewerOf, sameSite, formLimit, notice, roomMessage, callPanel, applyRoomChange, config });
++
router.get('/updates', async (req, res) => await page(req, res, 200, {
title: `What shipped on ${SITE_NAME}`, path: '/updates', robots: 'index, follow', cache: cache.htmlHeaders({ maxAge: 300 }),
body: frame.updatesBody({ service: 'chat', siteName: SITE_NAME }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,
ddiff --git a/server/web/server-pages.js b/server/web/server-pages.js
nnew file mode 100644
iindex 0000000..039d5a8
---- /dev/null
++++ b/server/web/server-pages.js
@@@ -0,0 +1,351 @@
++'use strict';
++/**
++ * Servers on openvibe.chat (server/rooms/servers.js), mounted by pages.js:
++ *
++ * GET /servers your servers, public servers, and the form to start one
++ * GET /s/:slug[/:channel] the server: your servers down the side, its channels, the channel (a text channel's
++ * messages, or a voice channel's call and its chat), and its people by rank
++ * GET /s/:slug/settings overview, channels, invites, members and bans, delete (by what the role may do)
++ * GET /join/:code an invite: the server it opens, and the button to join
++ * POST /servers/new, /s/:slug/join | /leave | /settings | /channels | /channels/:channel | /invites | /invites/:code/revoke
++ * /s/:slug/members | /delete, /s/:slug/:channel (a message), /join/:code
++ *
++ * Like every page here it reads and posts without JavaScript; chat.js adds live messages in a channel exactly as in a
++ * room (a channel is a room), and call.js the voice channel's call.
++ */
++const { esc } = require('./layout');
++const rooms = require('../rooms/rooms');
++const servers = require('../rooms/servers');
++const ctx = require('../live-context');
++
++const LIVE = 'https://openvibe.live';
++const RANK_TITLE = { owner: 'Owner', admin: 'Admins', mod: 'Moderators', member: 'Members' };
++const ROLE_NAME = { owner: 'owner', admin: 'admin', mod: 'moderator', member: 'member', blocked: 'banned' };
++const YOU_ARE = { owner: 'the owner', admin: 'an admin', mod: 'a moderator', member: 'a member' };
++const SEARCH_ICON = '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false"><path fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" d="M10.5 4a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13zM20 20l-4.8-4.8"/></svg>';
++const VOICE_ICON = '<svg class="oc-ico" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false"><path fill="currentColor" d="M3 9v6h4l5 4V5L7 9H3zm13.5 3a4.5 4.5 0 0 0-2.5-4v8a4.5 4.5 0 0 0 2.5-4zM14 3.2v2.1a7 7 0 0 1 0 13.4v2.1a9 9 0 0 0 0-17.6z"/></svg>';
++
++const initials = (name) => String(name || '?').split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0]).join('').toUpperCase() || '?';
++/** A steady hue per server, so its icon is recognisable in the rail. */
++const hueOf = (slug) => { let h = 0; for (const c of String(slug)) h = (h * 31 + c.charCodeAt(0)) % 360; return h; };
++
++function mountServerPages(router, h) {
++ const { page, needUser, viewerOf, sameSite, formLimit, notice, roomMessage, callPanel, applyRoomChange, config } = h;
++ const site = config.web.baseUrl;
++ const serverRoutes = () => require('../rooms/server-routes');
++ const orError = (fn) => async (req, res, next) => {
++ try { return await fn(req, res, next); } catch (err) {
++ if (err instanceof rooms.RoomError) return res.redirect(303, `${req.back || '/servers'}?error=${encodeURIComponent(err.message)}`);
++ return next(err);
++ }
++ };
++ const userOf = (actor) => (actor.kind === 'user' ? actor.user : null);
++ const errorOf = (req) => (req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '');
++
++ const icon = (s, active) => `<a class="oc-srv-icon${active ? ' is-active' : ''}" href="/s/${esc(s.slug)}" title="${esc(s.name)}" style="--oc-hue:${hueOf(s.slug)}"${active ? ' aria-current="page"' : ''}><span aria-hidden="true">${esc(initials(s.name))}</span><span class="oc-sr">${esc(s.name)}</span>${s.unread ? `<span class="oc-srv-dot" aria-label="${s.unread} unread">${s.unread > 99 ? '99+' : s.unread}</span>` : ''}</a>`;
++ const serverCard = (s) => `<li class="oc-srv-card"><a href="/s/${esc(s.slug)}"><span class="oc-srv-icon oc-srv-icon--card" style="--oc-hue:${hueOf(s.slug)}" aria-hidden="true">${esc(initials(s.name))}</span><span class="oc-srv-card-text"><span class="oc-srv-card-title"><strong>${esc(s.name)}</strong>${s.visibility === 'private' ? '<span class="oc-badge">private</span>' : ''}${s.unread ? `<span class="oc-badge oc-badge-new">${s.unread} new</span>` : ''}</span><span class="oc-muted">${esc(s.description || `${s.members} member${s.members === 1 ? '' : 's'}`)}</span></span></a></li>`;
++
++ // ── The directory ──
++ router.get('/servers', async (req, res) => {
++ const actor = await viewerOf(req);
++ const me = userOf(actor);
++ const { public: pub, mine } = await servers.list(me);
++ const create = me ? `<form class="oc-form oc-create" id="oc-new-server" method="post" action="/servers/new">
++<h2>Start a server</h2>
++<p class="oc-muted">A server is a place for a group: text channels to write in and voice channels to talk in, one membership and one set of roles for all of them. It starts with #general and a voice Lounge.</p>
++<label>Name <input name="name" required minlength="2" maxlength="40" placeholder="Saturday Raiders"></label>
++<label>Description <input name="description" maxlength="300" placeholder="What it is for (optional)"></label>
++<label class="oc-check"><input type="checkbox" name="private" value="1"> Private: only people with an invite can find, read or join it</label>
++<p><button type="submit">Create server</button></p>
++</form>` : `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=%2Fservers">Sign in with OpenVibe</a> to start or join a server.</p>`;
++ await page(req, res, 200, {
++ actor, title: 'Servers', path: '/servers', active: 'servers', robots: 'index, follow',
++ description: 'Servers on OpenVibe.Chat: a place for a group, with text and voice channels, roles and invites.',
++ body: `<h1>Servers</h1>${errorOf(req)}
++${mine.length ? `<h2>Your servers</h2><ul class="oc-srv-cards">${mine.map(serverCard).join('')}</ul>` : ''}
++<h2>Public servers</h2><ul class="oc-srv-cards">${pub.map(serverCard).join('') || '<li class="oc-muted">No public servers yet. Start the first one.</li>'}</ul>
++${create}`,
++ });
++ });
++
++ router.post('/servers/new', formLimit, sameSite, orError(async (req, res) => {
++ const actor = await needUser(req, res); if (!actor) return;
++ const b = req.body || {};
++ const s = await servers.create(actor.user, { name: b.name, description: b.description, visibility: b.private === '1' ? 'private' : 'public' });
++ res.redirect(303, `/s/${s.slug}`);
++ }));
++
++ /** The server behind :slug for this viewer (404 page when they may not read it). */
++ async function loadServer(req, res) {
++ const actor = await viewerOf(req);
++ const me = userOf(actor);
++ const server = await servers.bySlug(req.params.slug);
++ const can = server ? await servers.access(server, me) : null;
++ if (!server || !can.read) {
++ await page(req, res, 404, { actor, title: 'No such server', path: req.path, robots: 'noindex', body: '<h1>No such server</h1><p><a href="/servers">All servers</a></p>' });
++ return null;
++ }
++ return { actor, me, server, can };
++ }
++
++ // ── A server and one of its channels ──
++ async function serverPage(req, res) {
++ const x = await loadServer(req, res); if (!x) return;
++ const { actor, me, server, can } = x;
++ const chans = await servers.channels(server, can.member ? me : null);
++ const pick = req.params.channel ? chans.find((c) => c.slug === String(req.params.channel).toLowerCase()) : chans.find((c) => c.kind === 'text') || chans[0];
++ if (!pick) return page(req, res, 404, { actor, title: 'No such channel', path: req.path, robots: 'noindex', body: `<h1>No such channel</h1><p><a href="/s/${esc(server.slug)}">${esc(server.name)}</a></p>` });
++ const room = await rooms.bySlug(pick.room);
++ const rcan = await rooms.access(room, me);
++ const msgs = await rooms.history(room, { limit: 60 });
++ if (me && rcan.role) await rooms.markRead(room, me);
++ const mine = me ? (await servers.list(me)).mine : [];
++ const people = await servers.members(server, { limit: 300 });
++ const isVoice = room.kind === 'call';
++ const callReady = isVoice && !!require('../config').calls.enabled;
++ const callOf = (c) => { try { return require('../rooms/routes').callOf({ kind: 'call', slug: c.room }) || null; } catch { return null; } };
++ const chanLink = (c) => {
++ const here = c.slug === pick.slug;
++ const unread = !here && c.unread ? `<span class="oc-chan-dot" aria-label="${c.unread} unread">${c.unread > 99 ? '99+' : c.unread}</span>` : '';
++ const inCall = c.kind === 'voice' ? (callOf(c) || {}).participants : 0;
++ return `<li><a class="oc-chan${c.kind === 'voice' ? ' oc-chan-voice' : ''}${here ? ' is-active' : ''}${unread ? ' is-unread' : ''}" href="/s/${esc(server.slug)}/${esc(c.slug)}"${here ? ' aria-current="page"' : ''}>${c.kind === 'voice' ? VOICE_ICON : '<span class="oc-chan-hash" aria-hidden="true">#</span>'}<span class="oc-chan-name">${esc(c.name)}</span>${inCall ? `<span class="oc-muted oc-chan-in">${inCall} in</span>` : ''}${unread}</a></li>`;
++ };
++ const texts = chans.filter((c) => c.kind === 'text');
++ const voices = chans.filter((c) => c.kind === 'voice');
++ let composer;
++ const back = `/s/${server.slug}/${pick.slug}`;
++ if (!me) composer = `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=${encodeURIComponent(back)}">Sign in with OpenVibe</a> to join the conversation.</p>`;
++ else if (rcan.post) composer = `<form class="oc-compose" method="post" action="${esc(back)}" id="oc-compose"><label for="oc-input" class="oc-sr">Message ${isVoice ? '' : '#'}${esc(room.name)}</label><textarea id="oc-input" name="message" maxlength="2000" rows="2" required placeholder="Message ${isVoice ? '' : '#'}${esc(room.name)}"></textarea><button type="submit">Send</button></form>`;
++ else if (!can.member) composer = `<form method="post" action="/s/${esc(server.slug)}/join"><button type="submit">Join ${esc(server.name)}</button></form>`;
++ else composer = notice('error', 'You cannot write here right now.');
++ const ais = require('../rooms/ai-members').enabled() ? (await require('../rooms/ai-members').list(room)).filter((a) => Number(a.enabled)) : [];
++ const aiHere = ais.length ? `<p class="oc-ai-here">${ais.map((a) => `<span class="oc-badge oc-ai">AI</span> <strong>${esc(a.name)}</strong>`).join(' · ')} <span class="oc-muted">· write ${ais.map((a) => `<code>@${esc(a.handle)}</code>`).join(' or ')} to ask</span></p>` : '';
++ const byRank = {};
++ for (const p of people) (byRank[p.role] = byRank[p.role] || []).push(p);
++ const person = (p) => `<li><a class="oc-name" href="${LIVE}/@${encodeURIComponent(p.username || '')}"${/^#[0-9a-f]{3,8}$/i.test(p.profile_color || '') ? ` style="--nc:${esc(p.profile_color)}"` : ''}>${esc(p.display_name || p.username || '?')}</a></li>`;
++ const peopleHtml = ['owner', 'admin', 'mod', 'member'].filter((r) => byRank[r]).map((r) => `<h3 class="oc-srv-group">${RANK_TITLE[r]} <span class="oc-muted">${byRank[r].length}</span></h3><ul class="oc-srv-people-list">${byRank[r].map(person).join('')}</ul>`).join('');
++ const tools = [
++ can.moderate ? `<a href="/s/${esc(server.slug)}/settings">${can.manage ? 'Server settings' : 'Members and invites'}</a>` : '',
++ can.member && can.role !== 'owner' ? `<form class="oc-inline" method="post" action="/s/${esc(server.slug)}/leave"><button type="submit" class="oc-link">Leave server</button></form>` : '',
++ ].filter(Boolean).join(' · ');
++ const canInvite = can.manage || (can.moderate && server.visibility === 'public');
++ await page(req, res, 200, {
++ actor, wide: true, title: `${isVoice ? '' : '#'}${room.name} · ${server.name}`, path: back, active: 'servers',
++ robots: server.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady,
++ description: server.description || `${server.name}, a server on OpenVibe.Chat with ${server.member_count} member${Number(server.member_count) === 1 ? '' : 's'}.`,
++ page: {
++ view: 'room', room: room.slug, back: `/s/${server.slug}`, kind: room.kind, ai: ais.map((a) => a.handle), latest: msgs.length ? msgs[msgs.length - 1].id : 0,
++ me: me ? me.id : null, moderate: rcan.moderate, post: rcan.post, role: rcan.role,
++ ...(callReady ? { call: { channel: `room-${room.slug}`, join: rcan.join, talk: rcan.talk } } : {}),
++ },
++ body: `<div class="oc-srv">
++<nav class="oc-srv-rail" aria-label="Your servers">${mine.map((s) => icon(s, s.slug === server.slug)).join('')}${mine.some((s) => s.slug === server.slug) ? '' : icon(servers.publicServer(server), true)}
++<a class="oc-srv-icon oc-srv-add" href="/servers#oc-new-server" title="Start a server"><span aria-hidden="true">+</span><span class="oc-sr">Start a server</span></a>
++<a class="oc-srv-icon oc-srv-explore" href="/servers" title="Find servers">${SEARCH_ICON}<span class="oc-sr">Find servers</span></a></nav>
++<aside class="oc-srv-side" aria-label="${esc(server.name)}">
++<header class="oc-srv-head"><h1 class="oc-srv-name">${esc(server.name)}</h1>${server.visibility === 'private' ? '<span class="oc-badge">private</span>' : ''}
++<p class="oc-muted oc-srv-meta">${Number(server.member_count) || 0} member${Number(server.member_count) === 1 ? '' : 's'}${YOU_ARE[can.role] ? ` · you are ${YOU_ARE[can.role]}` : ''}</p>
++${server.description ? `<p class="oc-srv-desc">${esc(server.description)}</p>` : ''}</header>
++<details class="oc-srv-channels" open><summary>Channels</summary>
++<h2 class="oc-srv-group">Text channels</h2><ul class="oc-srv-list">${texts.map(chanLink).join('')}</ul>
++${voices.length ? `<h2 class="oc-srv-group">Voice channels</h2><ul class="oc-srv-list">${voices.map(chanLink).join('')}</ul>` : ''}
++</details>
++${!can.member && me ? `<form class="oc-srv-join" method="post" action="/s/${esc(server.slug)}/join"><button type="submit">Join ${esc(server.name)}</button></form>` : ''}
++${canInvite ? `<p class="oc-srv-invite"><a class="oc-button oc-button--quiet" href="/s/${esc(server.slug)}/settings#oc-invites">Invite people</a></p>` : ''}
++${tools ? `<p class="oc-srv-tools">${tools}</p>` : ''}
++</aside>
++<section class="oc-srv-main" aria-labelledby="oc-chan-h">
++<header class="oc-chan-head"><h2 id="oc-chan-h">${isVoice ? VOICE_ICON : '<span class="oc-chan-hash" aria-hidden="true">#</span>'}${esc(room.name)}</h2>${room.topic ? `<span class="oc-muted oc-chan-topic">${esc(room.topic)}</span>` : ''}<span class="oc-live" id="oc-live" hidden>● live</span></header>
++${aiHere}${errorOf(req)}
++${isVoice ? callPanel(room, rcan, me) : ''}
++<ol class="oc-feed oc-srv-feed" id="oc-feed" aria-live="polite" aria-label="Messages in ${esc(room.name)}">${msgs.map((m) => roomMessage(m, rcan, room.slug, me)).join('\n') || `<li class="oc-empty oc-muted">${isVoice ? 'The voice channel\'s chat is empty.' : `This is the start of #${esc(room.name)}.`}</li>`}</ol>
++${composer}
++</section>
++<aside class="oc-srv-people" aria-label="People in ${esc(server.name)}"><details open><summary>People <span class="oc-muted">${Number(server.member_count) || 0}</span></summary>${peopleHtml}${people.length >= 300 ? '<p class="oc-muted">And more.</p>' : ''}</details></aside>
++</div>`,
++ });
++ }
++ router.get('/s/:slug', serverPage);
++
++ router.post('/s/:slug/join', formLimit, sameSite, orError(async (req, res) => {
++ const actor = await needUser(req, res); if (!actor) return;
++ const server = await servers.bySlug(req.params.slug);
++ if (!server) return res.redirect(303, '/servers');
++ req.back = `/s/${server.slug}`;
++ await servers.join(server, actor.user);
++ await serverRoutes().applyServerChange(server);
++ res.redirect(303, `/s/${server.slug}`);
++ }));
++
++ router.post('/s/:slug/leave', formLimit, sameSite, orError(async (req, res) => {
++ const actor = await needUser(req, res); if (!actor) return;
++ const server = await servers.bySlug(req.params.slug);
++ if (!server) return res.redirect(303, '/servers');
++ req.back = `/s/${server.slug}`;
++ await servers.leave(server, actor.user);
++ await serverRoutes().applyServerChange(server);
++ res.redirect(303, '/servers');
++ }));
++
++ // ── Settings: what each rank may do (overview and channels: owner/admins; invites: owner/admins, and mods in a
++ // public server; members: owner/admins/mods; delete: the owner) ──
++ router.get('/s/:slug/settings', async (req, res) => {
++ const x = await loadServer(req, res); if (!x) return;
++ const { actor, me, server, can } = x;
++ if (!can.moderate) return res.redirect(303, `/s/${server.slug}`);
++ const saved = req.query.saved ? notice('ok', 'Saved.') : '';
++ const chans = await servers.channels(server);
++ const people = await servers.members(server, { blocked: true, limit: 2000 });
++ const canInvite = can.manage || server.visibility === 'public';
++ const invites = canInvite ? await servers.invites(server) : [];
++ const myRank = can.staff ? -1 : servers.RANK[can.role];
++ const choices = (target) => {
++ const list = [];
++ if (myRank <= servers.RANK.owner) list.push('admin');
++ if (myRank <= servers.RANK.admin) list.push('mod');
++ list.push('member', 'blocked', 'none');
++ if (target && target.role !== 'member' && target.role !== 'blocked' && myRank >= servers.RANK[target.role]) return null;
++ return list;
++ };
++ const roleForm = (p) => {
++ if (p.role === 'owner') return '<span class="oc-muted">owner</span>';
++ const opts = choices(p);
++ if (!opts || Number(p.user_id) === Number(me.id)) return `<span class="oc-muted">${ROLE_NAME[p.role]}</span>`;
++ return `<form class="oc-inline" method="post" action="/s/${esc(server.slug)}/members"><input type="hidden" name="username" value="${esc(p.username || '')}"><select name="role" aria-label="Role for ${esc(p.username || '')}">${opts.map((r) => `<option value="${r}"${r === p.role ? ' selected' : ''}>${r === 'none' ? 'remove from the server' : r === 'blocked' ? 'ban' : ROLE_NAME[r]}</option>`).join('')}</select> <button type="submit">Set</button></form>`;
++ };
++ const active = people.filter((p) => p.role !== 'blocked');
++ const banned = people.filter((p) => p.role === 'blocked');
++ const chanRow = (c) => `<li><span>${c.kind === 'voice' ? VOICE_ICON : '#'} <strong>${esc(c.name)}</strong>${c.topic ? ` <span class="oc-muted">${esc(c.topic)}</span>` : ''}</span>
++<form class="oc-inline oc-moves" method="post" action="/s/${esc(server.slug)}/channels/${esc(c.slug)}"><button type="submit" name="move" value="up" class="oc-move" aria-label="Move ${esc(c.name)} up" title="Move up">↑</button><button type="submit" name="move" value="down" class="oc-move" aria-label="Move ${esc(c.name)} down" title="Move down">↓</button></form>
++<details class="oc-chan-edit"><summary>Edit</summary><form class="oc-form" method="post" action="/s/${esc(server.slug)}/channels/${esc(c.slug)}">
++<label>Name <input name="name" required maxlength="40" value="${esc(c.name)}"></label>
++<label>Topic <input name="topic" maxlength="200" value="${esc(c.topic || '')}"></label>
++${c.kind === 'voice' ? `<label>Members in the call <select name="join_role"><option value="speaker"${c.join_role === 'speaker' ? ' selected' : ''}>can talk</option><option value="participant"${c.join_role === 'participant' ? ' selected' : ''}>listen and write (moderators talk)</option></select></label>` : ''}
++<p><button type="submit">Save</button> <button type="submit" name="delete" value="1" class="oc-link oc-danger-link">Delete this channel</button></p></form></details></li>`;
++ const inviteRow = (i) => `<li><code class="oc-invite-url">${esc(`${site}/join/${i.code}`)}</code> <span class="oc-muted">${i.uses} use${i.uses === 1 ? '' : 's'}${i.max_uses != null ? ` of ${i.max_uses}` : ''}${i.expires_at ? ` · until <time datetime="${esc(i.expires_at)}">${esc(i.expires_at.slice(0, 16).replace('T', ' '))} UTC</time>` : ' · never expires'}</span>
++<form class="oc-inline" method="post" action="/s/${esc(server.slug)}/invites/${esc(i.code)}/revoke"><button type="submit" class="oc-link">Revoke</button></form></li>`;
++ await page(req, res, 200, {
++ actor, title: `${server.name}: settings`, path: `/s/${server.slug}/settings`, active: 'servers', robots: 'noindex, nofollow',
++ body: `<p><a href="/s/${esc(server.slug)}">← ${esc(server.name)}</a></p><h1>Server settings</h1>${saved}${errorOf(req)}
++${can.manage ? `<h2>Overview</h2><form class="oc-form" method="post" action="/s/${esc(server.slug)}/settings">
++<label>Name <input name="name" required minlength="2" maxlength="40" value="${esc(server.name)}"></label>
++<label>Description <input name="description" maxlength="300" value="${esc(server.description || '')}"></label>
++<label>Who can find and read it <select name="visibility"><option value="public"${server.visibility === 'public' ? ' selected' : ''}>Anyone: listed, and signed-in people join (public)</option><option value="private"${server.visibility === 'private' ? ' selected' : ''}>Only people with an invite (private)</option></select></label>
++<p><button type="submit">Save</button></p></form>
++<h2>Channels</h2><ul class="oc-members oc-chan-rows">${chans.map(chanRow).join('')}</ul>
++<form class="oc-new" method="post" action="/s/${esc(server.slug)}/channels"><label for="oc-chan-new">Add a channel</label> <input id="oc-chan-new" name="name" required maxlength="40" placeholder="announcements"> <select name="kind" aria-label="Kind of channel"><option value="text">Text</option><option value="voice">Voice</option></select> <button type="submit">Add</button></form>` : ''}
++${canInvite ? `<h2 id="oc-invites">Invites</h2><p class="oc-muted">${server.visibility === 'private' ? 'A private server is joined only through an invite.' : 'Anyone can join a public server; an invite link is a handy way to bring people straight to it.'}</p>
++<form class="oc-new" method="post" action="/s/${esc(server.slug)}/invites"><label for="oc-inv-uses">New invite</label> <select id="oc-inv-uses" name="max_uses" aria-label="How many times it can be used"><option value="">any number of uses</option><option value="1">1 use</option><option value="10">10 uses</option><option value="100">100 uses</option></select> <select name="expires_hours" aria-label="When it expires"><option value="168">for 7 days</option><option value="24">for 1 day</option><option value="720">for 30 days</option><option value="">forever</option></select> <button type="submit">Make an invite</button></form>
++<ul class="oc-members">${invites.map(inviteRow).join('') || '<li class="oc-muted">No invites yet.</li>'}</ul>` : ''}
++<h2>Members <span class="oc-muted">${active.length}</span></h2>
++<p class="oc-muted">Admins manage channels, invites and members; moderators delete messages and remove or ban members. The owner appoints admins, admins appoint moderators.</p>
++<ul class="oc-members">${active.map((p) => `<li><strong>${esc(p.display_name || p.username || '?')}</strong> <span class="oc-muted">@${esc(p.username || '')}</span> ${roleForm(p)}</li>`).join('')}</ul>
++${banned.length ? `<details class="oc-blocked"><summary>Banned (${banned.length})</summary><ul class="oc-members">${banned.map((p) => `<li><strong>${esc(p.display_name || p.username || '?')}</strong> <span class="oc-muted">@${esc(p.username || '')}</span><form class="oc-inline" method="post" action="/s/${esc(server.slug)}/members"><input type="hidden" name="username" value="${esc(p.username || '')}"><input type="hidden" name="role" value="none"><button type="submit" class="oc-link">Lift the ban</button></form></li>`).join('')}</ul></details>` : ''}
++<form class="oc-new" method="post" action="/s/${esc(server.slug)}/members"><label for="oc-ban">Ban someone</label> <input id="oc-ban" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="their username"><input type="hidden" name="role" value="blocked"> <button type="submit">Ban</button></form>
++${can.owner || can.staff ? `<h2>Delete the server</h2><form class="oc-form" method="post" action="/s/${esc(server.slug)}/delete"><label class="oc-check"><input type="checkbox" name="confirm" value="1" required> Delete ${esc(server.name)} and all its channels</label><p><button type="submit" class="oc-danger">Delete server</button></p></form>` : ''}`,
++ });
++ });
++
++ const settingsAction = (fn) => [formLimit, sameSite, orError(async (req, res) => {
++ const actor = await needUser(req, res); if (!actor) return;
++ const server = await servers.bySlug(req.params.slug);
++ if (!server || !(await servers.access(server, actor.user)).read) return res.redirect(303, '/servers');
++ req.back = `/s/${server.slug}/settings`;
++ await fn(req, res, server, actor.user);
++ })];
++
++ router.post('/s/:slug/settings', ...settingsAction(async (req, res, server, user) => {
++ const b = req.body || {};
++ const out = await servers.update(server, user, { name: b.name, description: b.description, visibility: b.visibility });
++ await serverRoutes().applyServerChange(out);
++ res.redirect(303, `/s/${server.slug}/settings?saved=1`);
++ }));
++ router.post('/s/:slug/channels', ...settingsAction(async (req, res, server, user) => {
++ const b = req.body || {};
++ const room = await servers.createChannel(server, user, { name: b.name, kind: b.kind === 'voice' ? 'voice' : 'text' });
++ res.redirect(303, `/s/${server.slug}/${room.channel_slug}`);
++ }));
++ router.post('/s/:slug/channels/:channel', ...settingsAction(async (req, res, server, user) => {
++ const room = await servers.channelBySlug(server, req.params.channel);
++ if (!room) return res.redirect(303, `/s/${server.slug}/settings?error=${encodeURIComponent('No such channel')}`);
++ const b = req.body || {};
++ if (b.delete === '1') await servers.deleteChannel(server, user, room);
++ else {
++ const out = await servers.updateChannel(server, user, room, { name: b.name, topic: b.topic, join_role: room.kind === 'call' ? b.join_role : undefined, move: b.move });
++ await applyRoomChange(out);
++ }
++ res.redirect(303, `/s/${server.slug}/settings?saved=1`);
++ }));
++ router.post('/s/:slug/invites', ...settingsAction(async (req, res, server, user) => {
++ const b = req.body || {};
++ await servers.createInvite(server, user, { max_uses: b.max_uses || null, expires_hours: b.expires_hours || null });
++ res.redirect(303, `/s/${server.slug}/settings?saved=1#oc-invites`);
++ }));
++ router.post('/s/:slug/invites/:code/revoke', ...settingsAction(async (req, res, server, user) => {
++ await servers.revokeInvite(server, user, req.params.code);
++ res.redirect(303, `/s/${server.slug}/settings?saved=1#oc-invites`);
++ }));
++ router.post('/s/:slug/members', ...settingsAction(async (req, res, server, user) => {
++ const name = String((req.body && req.body.username) || '').trim();
++ let target = /^[A-Za-z0-9_]{3,24}$/.test(name) ? await 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, `/s/${server.slug}/settings?error=${encodeURIComponent(`Nobody called ${name.slice(0, 24)} on OpenVibe`)}`);
++ const role = await servers.setRole(server, user, target.id, String((req.body && req.body.role) || 'member'));
++ if (role === 'blocked' || role == null) {
++ for (const r of await require('../db/database').all('SELECT id FROM rooms WHERE server_id = ?', [server.id])) require('../chat/chat-server').removeFromRoom(r.id, target.id);
++ }
++ await serverRoutes().applyServerChange(server);
++ res.redirect(303, `/s/${server.slug}/settings?saved=1`);
++ }));
++ router.post('/s/:slug/delete', ...settingsAction(async (req, res, server, user) => {
++ if ((req.body || {}).confirm !== '1') return res.redirect(303, `/s/${server.slug}/settings`);
++ await servers.archive(server, user);
++ await serverRoutes().applyServerChange(server);
++ res.redirect(303, '/servers');
++ }));
++
++ // ── An invite ──
++ router.get('/join/:code', async (req, res) => {
++ const actor = await viewerOf(req);
++ const me = userOf(actor);
++ const found = await servers.inviteByCode(req.params.code);
++ if (!found) return page(req, res, 404, { actor, title: 'Invite not valid', path: req.path, robots: 'noindex, nofollow', body: '<h1>This invite is not valid</h1><p class="oc-muted">It has expired, was used up, or was revoked. Ask whoever sent it for a new one.</p><p><a href="/servers">Find servers</a></p>' });
++ const { server } = found;
++ const role = me ? await servers.roleIn(server.id, me.id) : null;
++ const action = !me ? `<p><a class="oc-button" href="/auth/login?next=${encodeURIComponent(req.path)}">Sign in with OpenVibe to join</a></p>`
++ : role === 'blocked' ? notice('error', 'You are banned from this server.')
++ : role ? `<p><a class="oc-button" href="/s/${esc(server.slug)}">Open ${esc(server.name)}</a> <span class="oc-muted">You are already in it.</span></p>`
++ : `<form method="post" action="/join/${esc(found.invite.code)}"><button type="submit">Join ${esc(server.name)}</button></form>`;
++ await page(req, res, 200, {
++ actor, title: `Join ${server.name}`, path: req.path, robots: 'noindex, nofollow',
++ body: `<section class="oc-invite-card"><span class="oc-srv-icon oc-srv-icon--big" style="--oc-hue:${hueOf(server.slug)}" aria-hidden="true">${esc(initials(server.name))}</span>
++<p class="oc-muted">You are invited to</p><h1>${esc(server.name)}</h1>${server.description ? `<p>${esc(server.description)}</p>` : ''}
++<p class="oc-muted">${Number(server.member_count) || 0} member${Number(server.member_count) === 1 ? '' : 's'}${server.visibility === 'private' ? ' · private server' : ''}</p>
++${errorOf(req)}${action}</section>`,
++ });
++ });
++ router.post('/join/:code', formLimit, sameSite, orError(async (req, res) => {
++ const actor = await needUser(req, res); if (!actor) return;
++ req.back = `/join/${encodeURIComponent(req.params.code)}`;
++ const found = await servers.inviteByCode(req.params.code);
++ if (!found) return res.redirect(303, req.back);
++ await servers.join(found.server, actor.user, { invite: found.invite.code });
++ await serverRoutes().applyServerChange(found.server);
++ res.redirect(303, `/s/${found.server.slug}`);
++ }));
++
++ // A channel's page and its no-JavaScript message form (after the fixed /s/:slug/… routes above).
++ router.get('/s/:slug/:channel', serverPage);
++ router.post('/s/:slug/:channel', formLimit, sameSite, orError(async (req, res) => {
++ const actor = await needUser(req, res); if (!actor) return;
++ const server = await servers.bySlug(req.params.slug);
++ const room = server ? await servers.channelBySlug(server, req.params.channel) : null;
++ if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/servers');
++ req.back = `/s/${server.slug}/${room.channel_slug}`;
++ const message = await rooms.post(room, actor.user, req.body && req.body.message);
++ require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });
++ res.redirect(303, `/s/${server.slug}/${room.channel_slug}#oc-compose`);
++ }));
++}
++
++module.exports = { mountServerPages };
ddiff --git a/test/pg-schema.test.js b/test/pg-schema.test.js
iindex b2b3baf..999f83b 100644
---- a/test/pg-schema.test.js
++++ b/test/pg-schema.test.js
@@@ -27,7 +27,7 @@ const TABLES = [
'channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events',
'ctx_users', 'ctx_streams', 'ctx_managed_streams', 'ctx_channels', 'ctx_sync', 'events_outbox', 'service_outbox',
'chat_ingress_applied', 'audio_requests', 'import_hold', 'import_runs', 'chat_meta', 'deploy_releases',
-- 'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'token_revocations', 'chat_control_log',
++ 'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'chat_servers', 'chat_server_members', 'chat_server_invites', 'token_revocations', 'chat_control_log',
'network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages', 'telemetry_samples',
];
const INDEXES = [
ddiff --git a/test/servers.test.js b/test/servers.test.js
nnew file mode 100644
iindex 0000000..b9bf76b
---- /dev/null
++++ b/test/servers.test.js
@@@ -0,0 +1,224 @@
++'use strict';
++/**
++ * Servers (migration 0013, server/rooms/servers.js): a server starts with #general and a voice Lounge; a public one is
++ * read by anyone and joined by signed-in people, a private one is a 404 to outsiders and is joined through an invite
++ * (uses, expiry, revoke). One membership and one set of roles for every channel: the owner appoints admins, admins
++ * appoint moderators and manage channels and invites, moderators remove and ban members, nobody acts on their own rank
++ * or above; it is all logged. A channel is a room: messages over REST and the socket, unread counts, the voice
++ * channel's call roles; the rooms API refuses room-level joins and roles there, and rooms lists leave channels out.
++ * The site: the directory, a server's page, settings by rank, an invite page, /r/<channel> going to its server.
++ * Deleting an account removes its memberships; a server it owned stays without an owner while anyone is in it.
++ */
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, suite } = require('./helpers');
++
++const t = suite('servers');
++let h, ann, bob, cat, dee, eve, staff;
++const api = (method, path, u, body) => h.http(method, `/api/chat/servers${path}`, { token: u && u.token, body });
++const roomApi = (method, path, u, body) => h.http(method, `/api/chat/rooms${path}`, { token: u && u.token, body });
++const page = async (path, u, { method = 'GET', form = null } = {}) => {
++ const headers = { ...(u ? { cookie: `ov_token=${u.token}` } : {}), ...(form ? { 'content-type': 'application/x-www-form-urlencoded' } : {}) };
++ const r = await fetch(`${h.base}${path}`, { method, headers, body: form ? new URLSearchParams(form).toString() : undefined, redirect: 'manual' });
++ return { status: r.status, text: await r.text(), location: r.headers.get('location') };
++};
++const ev = (type, payload) => ({ event_id: ids.newId('event'), event_type: type, version: 1, source: 'network', actor: { type: 'user', id: payload.subject },
++ timestamp: new Date().toISOString(), visibility: 'internal', subject: { type: 'user', id: payload.subject }, payload });
++let general, lounge;
++
++t('boot', async () => {
++ h = await boot();
++ ann = h.addUser('ann', { subject: ids.newId('user') });
++ bob = h.addUser('bob', { subject: ids.newId('user') });
++ cat = h.addUser('cat', { subject: ids.newId('user') });
++ dee = h.addUser('dee', { subject: ids.newId('user') });
++ eve = h.addUser('eve', { subject: ids.newId('user') });
++ staff = h.addUser('staffer', { subject: ids.newId('user'), role: 'global_mod' });
++ for (const u of [ann, bob, cat, dee, eve, staff]) await h.ctx.upsertUser(h.live.users.get(u.id));
++});
++
++t('create: #general and a voice Lounge, the owner, and the rules on names', async () => {
++ const r = await api('POST', '/', ann, { name: 'Saturday Raiders', description: 'Raids on Saturdays' });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.deepStrictEqual([r.body.server.slug, r.body.server.role, r.body.server.members], ['saturday-raiders', 'owner', 1]);
++ assert.strictEqual((await api('POST', '/', bob, { name: 'Saturday Raiders' })).status, 409);
++ assert.strictEqual((await api('POST', '/', bob, { name: 'x' })).status, 422);
++ assert.strictEqual((await api('POST', '/', bob, { name: 'Settings', slug: 'settings' })).status, 422, 'reserved');
++ assert.strictEqual((await api('POST', '/', null, { name: 'Anon' })).status, 401);
++ const s = (await api('GET', '/saturday-raiders', ann)).body;
++ assert.deepStrictEqual(s.channels.map((c) => [c.slug, c.kind]), [['general', 'text'], ['lounge', 'voice']]);
++ general = s.channels[0].room; lounge = s.channels[1].room;
++ assert.match(general, /^ch[a-z2-9]{12}$/);
++ assert.deepStrictEqual(s.can, { read: true, member: true, moderate: true, manage: true, owner: true });
++ assert.ok(!(await roomApi('GET', '/', ann)).body.public.some((x) => x.slug === general), 'channels are not in the rooms list');
++});
++
++t('a public server: anyone reads, signed-in people join and write; unread counts; the rooms API defers to the server', async () => {
++ assert.strictEqual((await api('GET', '/saturday-raiders', null)).status, 200);
++ assert.strictEqual((await roomApi('GET', `/${general}/messages`, null)).status, 200, 'its channels read signed out');
++ let r = await roomApi('POST', `/${general}/messages`, bob, { message: 'hi' });
++ assert.deepStrictEqual([r.status, r.body.code], [403, 'rooms.join_first']);
++ assert.deepStrictEqual([(await roomApi('POST', `/${general}/join`, bob)).status, (await roomApi('POST', `/${general}/join`, bob)).body.code], [409, 'rooms.server_channel']);
++ assert.strictEqual((await api('POST', '/saturday-raiders/join', bob)).body.role, 'member');
++ assert.strictEqual((await roomApi('POST', `/${general}/messages`, bob, { message: 'hello raiders' })).status, 201);
++ assert.strictEqual((await roomApi('POST', `/${general}/messages`, bob, { message: 'anyone on tonight?' })).status, 201);
++ let mine = (await api('GET', '/', ann)).body.mine;
++ assert.strictEqual(mine.find((x) => x.slug === 'saturday-raiders').unread, 2);
++ assert.strictEqual((await api('GET', '/saturday-raiders', ann)).body.channels[0].unread, 2);
++ await roomApi('POST', `/${general}/read`, ann, {});
++ mine = (await api('GET', '/', ann)).body.mine;
++ assert.strictEqual(mine.find((x) => x.slug === 'saturday-raiders').unread, 0);
++ assert.strictEqual((await api('GET', '/', null)).body.public.find((x) => x.slug === 'saturday-raiders').members, 2);
++ r = await roomApi('GET', `/${general}/members`, ann);
++ assert.deepStrictEqual(r.body.members.map((m) => [m.username, m.role]), [['ann', 'owner'], ['bob', 'member']], 'a channel\'s people are the server\'s');
++ assert.strictEqual((await roomApi('POST', `/${general}/members`, ann, { username: 'cat', role: 'member' })).body.code, 'rooms.server_channel');
++});
++
++t('the socket: a member joins a channel like a room and gets its messages live', async () => {
++ const annWs = await h.ws({ token: ann.token }); annWs.sendJson({ type: 'join_room', room: general });
++ assert.strictEqual((await annWs.next((m) => m.type === 'room_joined')).role, 'owner');
++ const bobWs = await h.ws({ token: bob.token }); bobWs.sendJson({ type: 'join_room', room: general });
++ assert.strictEqual((await bobWs.next((m) => m.type === 'room_joined')).role, 'member');
++ bobWs.sendJson({ type: 'room_message', message: 'live in #general' });
++ const got = await annWs.next((m) => m.type === 'room_message' && m.message.message === 'live in #general');
++ assert.strictEqual(got.room, general);
++ annWs.close(); bobWs.close();
++});
++
++t('roles: the owner appoints admins, admins appoint mods, mods remove and ban members, never upward; logged', async () => {
++ for (const u of [cat, dee, eve]) await api('POST', '/saturday-raiders/join', u);
++ assert.strictEqual((await api('POST', '/saturday-raiders/members', bob, { username: 'cat', role: 'mod' })).status, 403, 'a member manages nobody');
++ assert.strictEqual((await api('POST', '/saturday-raiders/members', ann, { username: 'bob', role: 'admin' })).body.role, 'admin');
++ assert.strictEqual((await api('POST', '/saturday-raiders/members', bob, { username: 'cat', role: 'admin' })).status, 403, 'only the owner appoints admins');
++ assert.strictEqual((await api('POST', '/saturday-raiders/members', bob, { username: 'cat', role: 'mod' })).body.role, 'mod');
++ assert.strictEqual((await api('POST', '/saturday-raiders/members', cat, { username: 'dee', role: 'mod' })).status, 403, 'mods do not appoint mods');
++ assert.strictEqual((await api('POST', '/saturday-raiders/members', cat, { username: 'bob', role: 'none' })).status, 403, 'a mod cannot remove an admin');
++ assert.strictEqual((await api('POST', '/saturday-raiders/members', bob, { username: 'ann', role: 'member' })).status, 403, 'nobody changes the owner');
++ assert.strictEqual((await api('POST', '/saturday-raiders/members', cat, { username: 'dee', role: 'none' })).body.role, null, 'a mod removes a member');
++ assert.strictEqual((await api('POST', '/saturday-raiders/members', cat, { username: 'eve', role: 'blocked' })).body.role, 'blocked');
++ assert.deepStrictEqual([(await api('POST', '/saturday-raiders/join', eve)).status, (await api('POST', '/saturday-raiders/join', eve)).body.code], [403, 'servers.blocked']);
++ assert.strictEqual((await roomApi('GET', `/${general}/messages`, eve)).status, 404, 'banned: not even reading');
++ const lounges = (await roomApi('GET', `/${lounge}`, cat)).body.can;
++ assert.ok(lounges.talk && lounges.moderate, 'a mod talks and moderates in voice');
++ assert.ok((await roomApi('GET', `/${lounge}`, bob)).body.can.manage, 'an admin manages the channels');
++ const log = await require('../server/db/database').all("SELECT action_type FROM moderation_actions WHERE scope_type = 'server' ORDER BY id");
++ assert.deepStrictEqual(log.map((x) => x.action_type), ['server_role_admin', 'server_role_mod', 'server_kick', 'server_ban']);
++ assert.strictEqual((await api('GET', '/saturday-raiders/members', bob)).body.members.some((m) => m.role === 'blocked'), true, 'moderators see bans');
++ assert.strictEqual((await api('GET', '/saturday-raiders/members', null)).body.members.some((m) => m.role === 'blocked'), false);
++});
++
++t('channels: admins add, rename, move and delete them; a server keeps one text channel; voice follows the join role', async () => {
++ assert.strictEqual((await api('POST', '/saturday-raiders/channels', cat, { name: 'mods only' })).status, 403, 'mods do not add channels');
++ let r = await api('POST', '/saturday-raiders/channels', bob, { name: 'Announcements', topic: 'News' });
++ assert.deepStrictEqual([r.status, r.body.channel.slug, r.body.channel.kind], [201, 'announcements', 'text']);
++ r = await api('POST', '/saturday-raiders/channels', bob, { name: 'Settings', kind: 'voice' });
++ assert.strictEqual(r.body.channel.slug, 'settings-voice', 'a channel never takes a page\'s address');
++ await api('PATCH', '/saturday-raiders/channels/announcements', bob, { move: 'up' });
++ assert.deepStrictEqual((await api('GET', '/saturday-raiders', bob)).body.channels.filter((c) => c.kind === 'text').map((c) => c.slug), ['announcements', 'general']);
++ r = await api('PATCH', '/saturday-raiders/channels/lounge', bob, { join_role: 'participant' });
++ assert.strictEqual(r.body.channel.join_role, 'participant');
++ assert.strictEqual((await roomApi('GET', `/${lounge}`, cat)).body.can.talk, true, 'mods still talk');
++ const dee2 = (await api('POST', '/saturday-raiders/join', dee)).body.role;
++ assert.strictEqual(dee2, 'member');
++ assert.strictEqual((await roomApi('GET', `/${lounge}`, dee)).body.can.talk, false, 'members listen in a participant channel');
++ assert.strictEqual((await api('DELETE', '/saturday-raiders/channels/announcements', bob)).status, 204);
++ assert.deepStrictEqual([(await api('DELETE', '/saturday-raiders/channels/general', bob)).status, (await api('DELETE', '/saturday-raiders/channels/general', bob)).body.code], [409, 'servers.last_channel']);
++ assert.strictEqual((await roomApi('PATCH', `/${general}`, ann, { visibility: 'private' })).body.code, 'rooms.server_channel', 'a channel is as public as its server');
++});
++
++t('private servers: invisible to outsiders, joined through an invite with uses and expiry, revocable', async () => {
++ const r = await api('POST', '/', ann, { name: 'Ann and Bob', visibility: 'private' });
++ assert.strictEqual(r.status, 201);
++ const room = (await api('GET', '/ann-and-bob', ann)).body.channels[0].room;
++ assert.strictEqual((await api('GET', '/ann-and-bob', bob)).status, 404);
++ assert.strictEqual((await api('POST', '/ann-and-bob/join', bob)).status, 404, 'as missing as a server that does not exist');
++ assert.strictEqual((await roomApi('GET', `/${room}/messages`, bob)).status, 404);
++ assert.ok(!(await api('GET', '/', bob)).body.public.some((x) => x.slug === 'ann-and-bob'), 'not listed');
++ const inv = await api('POST', '/ann-and-bob/invites', ann, { max_uses: 1, expires_hours: 24 });
++ assert.strictEqual(inv.status, 201, inv.text);
++ assert.match(inv.body.url, /\/join\/[a-z2-9]{10}$/);
++ const code = inv.body.invite.code;
++ assert.strictEqual((await api('GET', `/invites/${code}`, null)).body.server.name, 'Ann and Bob', 'the code shows what it opens');
++ assert.strictEqual((await api('POST', '/ann-and-bob/join', bob, { invite: code })).body.role, 'member');
++ assert.strictEqual((await roomApi('POST', `/${room}/messages`, bob, { message: 'thanks for the invite' })).status, 201);
++ const used = await api('POST', '/ann-and-bob/join', cat, { invite: code });
++ assert.deepStrictEqual([used.status, used.body.code], [410, 'servers.invite_invalid'], 'used up (the code already told cat the server exists)');
++ assert.strictEqual((await api('GET', `/invites/${code}`, null)).status, 404);
++ const again = (await api('POST', '/ann-and-bob/invites', ann, {})).body.invite.code;
++ assert.strictEqual((await api('POST', '/ann-and-bob/invites', bob, {})).status, 403, 'members do not invite to a private server');
++ assert.strictEqual((await api('DELETE', `/ann-and-bob/invites/${again}`, ann)).status, 204);
++ assert.strictEqual((await api('POST', '/ann-and-bob/join', cat, { invite: again })).status, 410, 'revoked');
++ assert.strictEqual((await api('GET', '/ann-and-bob', cat)).status, 404, 'still not there for cat');
++ assert.strictEqual((await api('GET', '/ann-and-bob', staff)).status, 200, 'chat staff can look in');
++});
++
++t('going private: the channels follow and outsiders lose them; deleting the server takes everything', async () => {
++ assert.strictEqual((await api('PATCH', '/saturday-raiders', bob, { visibility: 'private' })).body.server.visibility, 'private');
++ assert.strictEqual((await require('../server/db/database').get('SELECT visibility FROM rooms WHERE slug = ?', [general])).visibility, 'private');
++ assert.strictEqual((await roomApi('GET', `/${general}/messages`, null)).status, 404);
++ assert.strictEqual((await roomApi('GET', `/${general}/messages`, dee)).status, 200, 'members still read');
++ await api('PATCH', '/saturday-raiders', bob, { visibility: 'public' });
++ const tmp = (await api('POST', '/', bob, { name: 'Short Lived' })).body.server;
++ assert.strictEqual((await api('DELETE', `/${tmp.slug}`, ann)).status, 403, 'only its owner deletes a server');
++ assert.strictEqual((await api('DELETE', `/${tmp.slug}`, bob)).status, 204);
++ assert.strictEqual((await api('GET', `/${tmp.slug}`, bob)).status, 404);
++});
++
++t('the site: directory, a server page, settings by rank, an invite page, /r/<channel> goes to its server', async () => {
++ let r = await page('/servers', null);
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /Saturday Raiders/);
++ assert.doesNotMatch(r.text, /Ann and Bob/, 'private servers are not listed');
++ r = await page('/s/saturday-raiders', dee);
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /class="oc-srv"/);
++ assert.match(r.text, /aria-current="page"[^>]*>.*general/s);
++ assert.match(r.text, /Voice channels/);
++ assert.match(r.text, /<h3 class="oc-srv-group">Owner/);
++ assert.match(r.text, /id="oc-compose"/, 'a member writes');
++ assert.doesNotMatch(r.text, /Server settings/, 'a member sees no settings');
++ r = await page('/s/saturday-raiders/lounge', null);
++ assert.match(r.text, /<section class="oc-call"/, 'a voice channel shows its call (here: calls are off in tests)');
++ assert.match(r.text, /Sign in with OpenVibe/);
++ r = await page(`/r/${general}`, dee);
++ assert.deepStrictEqual([r.status, r.location], [302, '/s/saturday-raiders/general']);
++ r = await page('/s/saturday-raiders/settings', ann);
++ assert.match(r.text, /<h2>Channels<\/h2>/);
++ assert.match(r.text, /id="oc-invites"/);
++ assert.match(r.text, /Delete the server/);
++ r = await page('/s/saturday-raiders/settings', cat);
++ assert.doesNotMatch(r.text, /<h2>Channels<\/h2>/, 'a mod sees members and invites, not channels');
++ assert.match(r.text, /<h2>Members/);
++ assert.strictEqual((await page('/s/saturday-raiders/settings', dee)).status, 303, 'a member is sent back');
++ r = await page('/s/saturday-raiders/general', dee, { method: 'POST', form: { message: 'from the form' } });
++ assert.strictEqual(r.status, 303);
++ assert.match((await page('/s/saturday-raiders/general', dee)).text, /from the form/);
++ r = await page('/servers/new', cat, { method: 'POST', form: { name: 'Book Club', private: '1' } });
++ assert.deepStrictEqual([r.status, r.location], [303, '/s/book-club']);
++ const code = (await api('POST', '/book-club/invites', cat, {})).body.invite.code;
++ r = await page(`/join/${code}`, dee);
++ assert.match(r.text, /You are invited to/);
++ assert.match(r.text, /Book Club/);
++ r = await page(`/join/${code}`, dee, { method: 'POST', form: {} });
++ assert.deepStrictEqual([r.status, r.location], [303, '/s/book-club']);
++ assert.strictEqual((await page('/join/nosuchcode', dee)).status, 404);
++ assert.match((await page('/sitemap.xml', null)).text, /\/s\/saturday-raiders/);
++ assert.doesNotMatch((await page('/sitemap.xml', null)).text, /book-club/);
++});
++
++t('account deletion: memberships go; an owned server stays without an owner while people are in it', async () => {
++ const accountData = require('../server/chat/account-data');
++ const d = require('../server/db/database');
++ const sent = [];
++ const send = async (p, body) => { sent.push({ path: p, body }); return { ok: true, status: 200 }; };
++ const solo = (await api('POST', '/', ann, { name: 'Just Ann' })).body.server;
++ const del = ev('network.account.deleted', { deletion_id: `del_${ids.ulid()}`, subject: ann.subject_id, aliases: [], requested_at: new Date().toISOString(), deleted_at: new Date().toISOString() });
++ assert.strictEqual(await accountData.apply(del, { send }), 'erased');
++ const raiders = await d.get("SELECT owner_subject, archived_at FROM chat_servers WHERE slug = 'saturday-raiders'");
++ assert.deepStrictEqual([raiders.owner_subject, raiders.archived_at], [null, null], 'the server stays for its people');
++ assert.strictEqual(await d.get('SELECT 1 FROM chat_servers WHERE id = ?', [solo.id]), undefined, 'a server with nobody else goes');
++ assert.strictEqual((await d.all('SELECT * FROM chat_server_members WHERE user_id = ?', [ann.id])).length, 0);
++ assert.ok(sent[0].body.erased.servers >= 1 && sent[0].body.retained.servers_without_owner >= 1, JSON.stringify(sent[0].body));
++});
++
++t.run(async () => { if (h && h.close) await h.close(); });