openvibers/openvibe.community Commits
A space can attach a chat room on OpenVibe.Chat (roadmap WS-I task 4): its owner (the space's creator, or the creator whose members' space it is) or discussion staff attach one room by address or link, from the space page without JavaScript (POST /s/:space/chat-room, /chat-room/detach) or PUT/DELETE /api/v1/spaces/:space/chat-room; the space page (feed and forum style) links it with its kind and "members only" for a private room. Each side checks its own end: Community the space, Chat that the same person manages the room, because Community asks Chat with the person's own Network token (server/chat-rooms.js: POST OV_CHAT_INTERNAL_URL/api/chat/rooms/:room/attachments { service: community, resource, title }), so no service grant or capability is involved and services cannot attach (moderator services may detach). Chat's refusals come back as chat_room.not_owner (403), chat_room.not_found (404, also a private room the person is not in) and chat_room.unavailable (503/502), shown as a notice on the page. Attaching the same room again is a no-op, another room replaces it (Chat is told the space let go of the old one, best effort), detach is idempotent and asks Chat to drop its side when the person may. The link is kept in space_chat_rooms (CREATE TABLE IF NOT EXISTS: the room's id and slug, and its name, kind and visibility as Chat answered); spaces in the API carry chat_room. No event yet (no contract has one). Test: chat-room.test.js against a stub Chat
Changes
@@@ -42,6 +42,11 @@ OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100# Without it, or with VIP down, only the owner and moderators get in (fails closed).OV_VIP_INTERNAL_URL=http://127.0.0.1:4620OV_VIP_URL=https://openvibe.vip++++# OpenVibe.Chat: a space's chat room. Community asks Chat with the signed-in person's own token whether++# they manage the room (no service grant needed); the space page links OV_CHAT_URL/r/<room>.++OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400++OV_CHAT_URL=https://openvibe.chat# How long a VIP yes / no / failure is cached (ms); the yes TTL bounds how long access outlives a membership.# VIP_CACHE_TTL_MS=30000# VIP_CACHE_DENY_TTL_MS=10000@@@ -32,6 +32,7 @@ Pastes moved to Community in roadmap Wave 5. `PASTES_AUTHORITY` picks who owns t| Raw text, screenshots | OpenVibe.Media public host | 302 from `/p/:slug/raw`, `/p/:slug/screenshot` || Shared chrome, themes | `https://openvibe.network/shared/*.js` | loaded in every page || VIP memberships (members-only spaces/threads) | **OpenVibe.VIP** (`POST /api/v1/policies/evaluate`) | `OV_VIP_INTERNAL_URL`, service token (audience `openvibe.vip`, `vip.resource.policy.evaluate`) |++| A space's chat room | **OpenVibe.Chat** (`POST/DELETE /api/chat/rooms/:room/attachments`) | `OV_CHAT_INTERNAL_URL`, the signed-in person's own Network token (no service grant) |Why go through Live and not straight to Media: the visitor's JWT names the account by its**Network** id, while the `user_id` Media stores for pastes is **Live's** own — different@@@ -371,8 +372,28 @@ API (`/api/v1/spaces`, `/api/v1/posts`, problem+json errors):| `PUT /spaces/:space/threads/:slug/state` `{ pinned?, locked? }` | Moderators || `PUT /spaces/:space/members-only` `{ owner: 'usr_…' \| null }` | Moderators: gate a space to a creator's VIP members (or open it) || `PUT /spaces/:space/threads/:slug/members-only` `{ owner: 'usr_…' \| true \| null }` | The author (to their own members) or moderators (any creator) |++| `PUT /spaces/:space/chat-room` `{ room: slug \| https://openvibe.chat/r/<slug> }` | The space's owner or staff, signed in themselves: attach a chat room (201; the same room again 200) |++| `DELETE /spaces/:space/chat-room` | The space's owner or staff (moderator services too): detach it (idempotent) || `PUT /posts/:id` `{ body }` · `DELETE /posts/:id` · `GET /posts/:id/versions` | Author or moderator |++### A space's chat room (OpenVibe.Chat)++++A space can have one chat room on [OpenVibe.Chat](https://github.com/OpenVibers/OpenVibe.Chat) (roadmap WS-I++task 4, `server/chat-rooms.js`). Its owner (the space's creator, or the creator whose members' space it is)++or discussion staff attach one by its address or link, from the space page (no JavaScript) or++`PUT /api/v1/spaces/:space/chat-room`; the space page (feed and forum style) then links it, with its kind++(chat, call, announcements) and "members only" for a private room. Each side checks its own end:++Community checks the space; Chat checks that the same person manages the room, because Community asks it++with the person's own Network token (`POST ${OV_CHAT_INTERNAL_URL}/api/chat/rooms/:room/attachments`,++`{ service: 'community', resource: <space>, title }`). So services cannot attach (a service has no++person's token; moderator services may detach), and no capability or grant is involved. Chat's refusals++come back as `chat_room.not_owner` (403), `chat_room.not_found` (404: no such room, or a private one the++person is not in) and `chat_room.unavailable` (503/502); on the page they are a notice. Attaching the same++room again is a no-op; another room replaces it (Chat is told the space let go of the old one, best++effort); detaching removes Community's link and asks Chat to drop its side when the person may. Community++keeps the link in `space_chat_rooms` (the room's id, slug, and its name, kind and visibility as Chat++answered). No event is published for it (no contract has one yet).++### Members-only spaces and threads (OpenVibe.VIP)A space or a single thread can be for one creator's [OpenVibe.VIP](https://github.com/OpenVibers/OpenVibe.VIP)@@@ -537,6 +558,9 @@ Copy `.env.example` to `.env` (production: `/etc/openvibe/community.env`, mode 0| `VIEW_HASH_SECRET` | derived from the client secret | Salt for hashed visitor ids in view counts || `COOKIE_SECURE` | `true` in production | Set `false` for plain-http local dev || `OV_VIP_INTERNAL_URL` | `http://127.0.0.1:4620` | OpenVibe.VIP's API (members-only spaces and threads) |++| `OV_CHAT_INTERNAL_URL` | `http://127.0.0.1:4400` | OpenVibe.Chat's API (a space's chat room) |++| `OV_CHAT_URL` | `https://openvibe.chat` | Public Chat site, for the room links |++| `CHAT_TIMEOUT_MS` | `4000` | One Chat call || `OV_VIP_URL` | `https://openvibe.vip` | Public VIP site, for join links || `VIP_TIMEOUT_MS` | `2000` | One VIP call || `VIP_CACHE_TTL_MS` / `VIP_CACHE_DENY_TTL_MS` / `VIP_CACHE_UNAVAILABLE_TTL_MS` | `30000` / `10000` / `2000` | How long a yes / no / failure is cached (the yes TTL is the convergence bound) |@@@ -263,6 +263,8 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-.badge-sticky { font-size: .65rem; border-color: var(--accent); color: var(--accent-light); }.board-toolbar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin: 8px 0 12px; }.space-settings, .crosspost-form { margin: 18px 0; }++.chat-room { margin: 12px 0 16px; padding: 10px 14px; border: 1px solid var(--border-light); border-radius: 10px; background: var(--bg-card); }++.chat-room h2 { margin: 0 0 6px; }.space-settings summary, .crosspost-form summary { cursor: pointer; color: var(--text-secondary); font-weight: 600; }.discuss-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }.postbits { display: grid; gap: 12px; margin: 12px 0; }@@@ -149,7 +149,9 @@ function createApp(opts = {}) {const vip = opts.vip || createVipGate({ config, ...(opts.vipOptions || {}) });// Images on posts go to OpenVibe.Media's Object API as med_ objects (media/objects.js).const mediaObjects = opts.mediaObjects || require('./media/objects').createMediaObjects({ config });-- const forum = createForumService({ db, network, pulse, relay, vip, media: mediaObjects, limits: opts.forumLimits });++ // A space's chat room on OpenVibe.Chat (chat-rooms.js): attached with the person's own token.++ const chatRooms = opts.chatRooms || require('./chat-rooms').createChatRooms({ config });++ const forum = createForumService({ db, network, pulse, relay, vip, media: mediaObjects, chatRooms, limits: opts.forumLimits });const community = config.pastesAuthority === 'community';const comments = createCommentService({ db, network, pastesLocal: community, limits: opts.commentLimits });seo.useForum(forum);nnew file mode 100644@@@ -0,0 +1,89 @@++'use strict';++/**++ * OpenVibe.Chat rooms for spaces (roadmap WS-I task 4): a space's owner or staff attach a chat room to++ * the space; the space page links it.++ *++ * Each side checks its own end. Community decides who may change the space (forum/service.js); Chat++ * decides who may attach the room: the person must manage it (its owner, or chat staff). Community asks++ * Chat on the person's behalf with the person's OWN Network token (the ov_token they are signed in with),++ * so no service grant or new capability is involved:++ *++ * POST <chat>/api/chat/rooms/:room/attachments { service: 'community', resource: <space slug>, title }++ * 201 attached, 200 already attached (idempotent) → { room: { id, slug, name, kind, visibility } }++ * DELETE <chat>/api/chat/rooms/:room/attachments/community/:space (the room's managers or who attached it)++ *++ * Chat's answers become Community's: 404 (no such room, or a private room the person is not in: Chat++ * says nothing more), 403 (not the room's owner), 401 (the token expired). Chat unreachable: 503.++ */++const { fail } = require('./http/v1');++++const ROOM_SLUG = /^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])$/;++++/** A room slug from "night-owls", "/r/night-owls" or "https://openvibe.chat/r/night-owls"; null otherwise. */++function parseRoomRef(input) {++ const s = String(input == null ? '' : input).trim();++ if (!s || s.length > 300) return null;++ const m = s.match(/^(?:https?:\/\/[^/\s]+)?\/r\/([^/?#\s]+)\/?(?:[?#].*)?$/i);++ const slug = (m ? m[1] : s).toLowerCase();++ return ROOM_SLUG.test(slug) ? slug : null;++}++++function createChatRooms({ config, fetchImpl = globalThis.fetch } = {}) {++ const chat = (config && config.chat) || {};++ const base = chat.internalUrl || '';++ const timeoutMs = chat.timeoutMs || 4000;++ const publicUrl = chat.url || 'https://openvibe.chat';++++ async function call(method, path, token, body) {++ if (!base) fail(503, 'chat_room.unavailable', 'Chat rooms cannot be attached right now');++ let res;++ try {++ res = await fetchImpl(`${base}${path}`, {++ method,++ headers: { authorization: `Bearer ${token}`, accept: 'application/json', ...(body ? { 'content-type': 'application/json' } : {}) },++ body: body ? JSON.stringify(body) : undefined,++ signal: AbortSignal.timeout(timeoutMs),++ });++ } catch (err) {++ fail(503, 'chat_room.unavailable', 'OpenVibe.Chat did not answer. Try again in a minute');++ }++ let data = null;++ try { data = await res.json(); } catch { data = null; }++ return { status: res.status, data };++ }++++ /** Ask Chat to attach `room` to `space` as the person holding `token`. → { room, created } */++ async function attach({ token, room, space, title }) {++ if (!token) fail(403, 'chat_room.person_only', 'Attach a chat room while signed in with your own account');++ const r = await call('POST', `/api/chat/rooms/${encodeURIComponent(room)}/attachments`, token, { service: 'community', resource: space, title: title || undefined });++ if ((r.status === 200 || r.status === 201) && r.data && r.data.room && r.data.room.slug) {++ const x = r.data.room;++ return {++ created: r.status === 201,++ room: { id: Number(x.id) || null, slug: String(x.slug), name: String(x.name || x.slug).slice(0, 80), kind: String(x.kind || 'community'), visibility: x.visibility === 'private' ? 'private' : 'public' },++ };++ }++ if (r.status === 404) fail(404, 'chat_room.not_found', 'No such chat room (or it is private and you are not in it)');++ if (r.status === 403) fail(403, 'chat_room.not_owner', 'Only the chat room\'s owner can attach it to a space');++ if (r.status === 401) fail(401, 'auth.required', 'Sign in again to attach a chat room');++ if (r.status === 422) fail(400, 'chat_room.invalid', (r.data && r.data.error) || 'Chat refused that attachment');++ fail(502, 'chat_room.unavailable', 'OpenVibe.Chat could not attach the room right now');++ }++++ /** Tell Chat the space let go of the room; best effort. → 'detached' | 'not_allowed' | 'unavailable' */++ async function detach({ token, room, space }) {++ if (!token || !base) return 'unavailable';++ try {++ const r = await call('DELETE', `/api/chat/rooms/${encodeURIComponent(room)}/attachments/community/${encodeURIComponent(space)}`, token);++ if (r.status === 200) return 'detached';++ if (r.status === 403 || r.status === 404) return 'not_allowed';++ return 'unavailable';++ } catch { return 'unavailable'; }++ }++++ const roomUrl = (slug) => `${publicUrl}/r/${encodeURIComponent(slug)}`;++++ return { attach, detach, roomUrl, parseRoomRef };++}++++module.exports = { createChatRooms, parseRoomRef, ROOM_SLUG };@@@ -76,6 +76,14 @@ module.exports = {unavailableTtlMs: parseInt(process.env.VIP_CACHE_UNAVAILABLE_TTL_MS, 10) || 2_000,},++ // OpenVibe.Chat — a space can attach a chat room (server/chat-rooms.js). Community asks Chat, with the++ // signed-in person's own Network token, whether they manage the room; the space page links it.++ chat: {++ url: (process.env.OV_CHAT_URL || 'https://openvibe.chat').replace(/\/$/, ''),++ internalUrl: (process.env.OV_CHAT_INTERNAL_URL || 'http://127.0.0.1:4400').replace(/\/$/, ''),++ timeoutMs: parseInt(process.env.CHAT_TIMEOUT_MS, 10) || 4000,++ },++// Discord relay (server/relay; docs/discord-relay.md). Off by default, and inert without an owner's// webhook variables, mappings and (for inbound) bot token.// out threads and replies in mapped public spaces → the mapping's Discord webhook; edits and deletes follow@@@ -316,6 +316,20 @@ CREATE TABLE IF NOT EXISTS categories (UNIQUE (space_id, slug));++-- A space's chat room on OpenVibe.Chat (WS-I task 4; server/chat-rooms.js), one per space. room_id and++-- room_slug are Chat's; name, kind and visibility are what Chat answered when it was attached (or last++-- re-attached). attached_by is the usr_ subject who attached it.++CREATE TABLE IF NOT EXISTS space_chat_rooms (++ space_id INTEGER PRIMARY KEY REFERENCES spaces(id) ON DELETE CASCADE,++ room_id INTEGER,++ room_slug TEXT NOT NULL,++ room_name TEXT NOT NULL,++ room_kind TEXT NOT NULL DEFAULT 'community',++ room_visibility TEXT NOT NULL DEFAULT 'public',++ attached_by TEXT,++ attached_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP++);++-- ── Discord relay (server/relay; DISCORD_RELAY_ENABLED) ──────-- webhook_url_ref is the NAME of an environment variable holding the webhook URL: secrets never-- live in the database. migrate() adds discord_channel_id (the channel the webhook posts into;@@@ -27,6 +27,9 @@* POST /spaces { slug, name, description?, style?: feed|forum, votes?, reactions?, group?, parent?, visibility?, kind? } moderators* PUT /spaces/:space/settings { name?, description?, style?, votes?, reactions?, group?, parent?, position?, kind? } moderators* POST /spaces/:space/threads/:slug/crosspost { to: slug } another space gets a thread linking back (people)++ * PUT /spaces/:space/chat-room { room: slug | https://openvibe.chat/r/<slug> } the space's owner or staff, signed in++ * themselves (Chat checks they manage the room) → { chat_room, created }++ * DELETE /spaces/:space/chat-room the space's owner or staff (idempotent) → { detached, chat }* POST /posts/:id/reactions { reaction: agree|winner|funny|informative|friendly|sympathy|dumb|disgusting|bad_reading|late|null }* PUT /posts/:id { body } DELETE /posts/:id GET /posts/:id/versions*@@@ -76,6 +79,9 @@ function createSpacesApi({ forum, viewers }) {router.put('/:space/threads/:slug/state', serviceCap(MOD), jsonBody, run((req) => forum.moderateThread(req.viewer, p(req).space, p(req).slug, req.body || {})));router.put('/:space/threads/:slug/members-only', writeOrMod, jsonBody, run((req) => forum.setThreadMembersOnly(req.viewer, p(req).space, p(req).slug, req.body || {})));router.put('/:space/members-only', serviceCap(MOD), jsonBody, run((req) => forum.setSpaceMembersOnly(req.viewer, p(req).space, req.body || {})));++ // A chat room (OpenVibe.Chat): people attach with their own token; moderator services may only detach.++ router.put('/:space/chat-room', jsonBody, run((req) => forum.attachChatRoom(req.viewer, p(req).space, req.body || {}), (out) => (out.created ? 201 : 200)));++ router.delete('/:space/chat-room', serviceCap(MOD), run((req) => forum.detachChatRoom(req.viewer, p(req).space)));router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));return router;@@@ -10,6 +10,7 @@* GET /s/:space/new POST /s/:space/new start a thread* GET /s/:space/t/:slug a thread (?page=)* POST /s/:space/t/:slug/reply | /vote | /state | /delete | /members-only no-JS forms++ * POST /s/:space/chat-room, /s/:space/chat-room/detach the space's chat room (its owner or staff)** A members-only (OpenVibe.VIP) space or thread the viewer may not use renders a teaser with the* creator's join link (403), never its posts.@@@ -116,8 +117,9 @@ function createForumRoutes({ forum, viewers, config }) {try {const out = await forum.listThreads(req.viewer, req.params.space, { sort: req.query.sort, page: req.query.page, category: req.query.category, status: req.query.status });if (out.page > out.pages) throw new ApiError(404, 'page.not_found', 'There is no page with that number.');-- if (out.space.style === 'forum') return html(res, boardPages.forumSpacePage({ ...out, user: req.user }));-- html(res, forumPages.spacePage({ ...out, user: req.user, footer: out.viewer.can_moderate ? boardPages.settingsForm(out.space, out.groups) : '' }));++ const chatRoom = forumPages.chatRoomBox(out.space, { canManage: out.viewer.can_manage_chat_room, error: typeof req.query.chat_error === 'string' ? req.query.chat_error.slice(0, 200) : null });++ if (out.space.style === 'forum') return html(res, boardPages.forumSpacePage({ ...out, user: req.user, chatRoom }));++ html(res, forumPages.spacePage({ ...out, user: req.user, chatRoom, footer: out.viewer.can_moderate ? boardPages.settingsForm(out.space, out.groups) : '' }));} catch (err) { failPage(req, res, err, next); }}));@@@ -245,6 +247,24 @@ function createForumRoutes({ forum, viewers, config }) {} catch (err) { failPage(req, res, err, next); }}));++ // The space's chat room (no JS): attach by address or link, detach. A refusal comes back as a notice on the space.++ const chatBack = (req, error) => `/s/${encodeURIComponent(req.params.space)}${error ? `?chat_error=${encodeURIComponent(error)}` : ''}#chat-room`;++ router.post('/s/:space/chat-room', withViewer, sameOrigin, form, wrap(async (req, res, next) => {++ try {++ await forum.attachChatRoom(req.viewer, req.params.space, { room: String((req.body || {}).room || '').slice(0, 300) });++ res.redirect(303, chatBack(req));++ } catch (err) {++ if (err instanceof ApiError && String(err.code).startsWith('chat_room.')) return res.redirect(303, chatBack(req, err.message));++ failPage(req, res, err, next);++ }++ }));++ router.post('/s/:space/chat-room/detach', withViewer, sameOrigin, form, wrap(async (req, res, next) => {++ try {++ await forum.detachChatRoom(req.viewer, req.params.space);++ res.redirect(303, chatBack(req));++ } catch (err) { failPage(req, res, err, next); }++ }));++// Status (moderators) and category (the author or moderators), no JS.router.post('/s/:space/t/:slug/status', withViewer, sameOrigin, form, wrap(async (req, res, next) => {try {@@@ -29,6 +29,11 @@* thread's title with its members_only flag, never a body; gated things never reach Pulse, the Discord* relay, sitemaps or feeds. Moderators gate spaces (for any creator) and threads; a thread's author* gates it to their own members.++ *++ * A chat room (OpenVibe.Chat, WS-I task 4): a space's owner (its creator, or the creator whose members'++ * space it is) or discussion staff attach one room to the space; the space page links it. Chat checks++ * that the same person manages the room, with their own token (chat-rooms.js), so services cannot attach++ * (moderator services can detach). Community keeps the link: space_chat_rooms.*/const store = require('./store');const events = require('../events');@@@ -92,7 +97,7 @@ const TITLE_MIN = 3, TITLE_MAX = 200;const BODY_MAX = 40_000;const THREADS_PER_DAY = 20;--function createForumService({ db, network = null, pulse = null, relay = null, vip = null, media = null, limits = {} } = {}) {++function createForumService({ db, network = null, pulse = null, relay = null, vip = null, media = null, chatRooms = null, limits = {} } = {}) {const authors = createAuthors({ db, network });const threadLimiter = createPersonLimiter({ cooldownSec: 30, perMinute: 3, noun: 'threads', ...(limits.threads || {}) });const postLimiter = createPersonLimiter({ cooldownSec: 10, perMinute: 6, noun: 'posts', ...(limits.posts || {}) });@@@ -107,6 +112,16 @@ function createForumService({ db, network = null, pulse = null, relay = null, viconst moderator = (v) => discussionModerator(v);const person = (v) => !!(v && v.subject);++ // ── a space's chat room (OpenVibe.Chat) ──────────────────++ const chatRoomRow = (spaceId) => (spaceId == null ? null : db.prepare('SELECT * FROM space_chat_rooms WHERE space_id = ?').get(spaceId) || null);++ const shapeChatRoom = (r) => (r ? {++ slug: r.room_slug, name: r.room_name, kind: r.room_kind, visibility: r.room_visibility,++ url: chatRooms ? chatRooms.roomUrl(r.room_slug) : `https://openvibe.chat/r/${encodeURIComponent(r.room_slug)}`,++ attached_at: isoTime(r.attached_at),++ } : null);++ /** The space's owner (its creator, or the creator whose VIP members' space it is) or discussion staff. */++ const canManageChatRoom = (v, space) => moderator(v) || (person(v) && (space.created_by === v.subject || space.members_only_owner === v.subject));++// ── access ───────────────────────────────────────────────function canRead(v, space) {if (space.visibility === 'public') return true;@@@ -310,6 +325,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vimembers_only: s.members_only_owner ? (mo || { owner: s.members_only_owner, owner_username: null, join_url: vip ? vip.joinUrl(s.members_only_owner) : null }) : null,thread_count: s.thread_count != null ? s.thread_count : undefined,last_activity_at: s.last_activity_at !== undefined ? isoTime(s.last_activity_at) : undefined,++ chat_room: shapeChatRoom(chatRoomRow(s.id)),};}@@@ -479,6 +495,45 @@ function createForumService({ db, network = null, pulse = null, relay = null, vireturn { space: shapeSpace(s, await membersOnly(s.members_only_owner)) };},++ /**++ * Attach a chat room to the space { room: slug or openvibe.chat link } — the space's owner or staff,++ * signed in themselves (Chat checks they manage the room). Attaching the same room again is a no-op;++ * another room replaces it (Chat is told the space let go of the old one). → { chat_room, created }++ */++ async attachChatRoom(v, spaceSlug, body = {}) {++ const space = spaceFor(v, spaceSlug);++ if (!person(v) && !moderator(v)) fail(401, 'auth.required', 'Sign in with your OpenVibe account');++ if (!canManageChatRoom(v, space)) fail(403, 'capability.denied', 'Only the space\'s owner or staff attach a chat room');++ if (!v || v.kind !== 'user' || !v.token) fail(403, 'chat_room.person_only', 'Attach a chat room while signed in with your own account');++ if (!chatRooms) fail(503, 'chat_room.unavailable', 'Chat rooms cannot be attached right now');++ const slug = chatRooms.parseRoomRef(body.room);++ if (!slug) fail(400, 'chat_room.invalid', 'Name the room by its address (night-owls) or its link (https://openvibe.chat/r/night-owls)');++ const before = chatRoomRow(space.id);++ const out = await chatRooms.attach({ token: v.token, room: slug, space: space.slug, title: space.name });++ const same = !!before && before.room_slug === out.room.slug;++ db.prepare(`INSERT INTO space_chat_rooms (space_id, room_id, room_slug, room_name, room_kind, room_visibility, attached_by) VALUES (?, ?, ?, ?, ?, ?, ?)++ ON CONFLICT (space_id) DO UPDATE SET room_id = excluded.room_id, room_slug = excluded.room_slug, room_name = excluded.room_name,++ room_kind = excluded.room_kind, room_visibility = excluded.room_visibility,++ attached_by = CASE WHEN space_chat_rooms.room_slug = excluded.room_slug THEN space_chat_rooms.attached_by ELSE excluded.attached_by END,++ attached_at = CASE WHEN space_chat_rooms.room_slug = excluded.room_slug THEN space_chat_rooms.attached_at ELSE CURRENT_TIMESTAMP END`)++ .run(space.id, out.room.id, out.room.slug, out.room.name, out.room.kind, out.room.visibility, v.subject || null);++ if (before && !same) await chatRooms.detach({ token: v.token, room: before.room_slug, space: space.slug });++ return { space: { slug: space.slug, name: space.name, url: `/s/${space.slug}` }, chat_room: shapeChatRoom(chatRoomRow(space.id)), created: !same };++ },++++ /** Detach the space's chat room — the space's owner or staff (moderator services too). Idempotent. → { detached, chat } */++ async detachChatRoom(v, spaceSlug) {++ const space = spaceFor(v, spaceSlug);++ if (!person(v) && !moderator(v)) fail(401, 'auth.required', 'Sign in with your OpenVibe account');++ if (!canManageChatRoom(v, space)) fail(403, 'capability.denied', 'Only the space\'s owner or staff detach its chat room');++ const before = chatRoomRow(space.id);++ if (!before) return { detached: false, chat: null };++ db.prepare('DELETE FROM space_chat_rooms WHERE space_id = ?').run(space.id);++ // Chat's side of the link goes too when the person may remove it there (best effort: the space no longer shows it either way).++ const chat = chatRooms && v.kind === 'user' && v.token ? await chatRooms.detach({ token: v.token, room: before.room_slug, space: space.slug }) : 'unavailable';++ return { detached: true, chat };++ },++/** A page of threads. ?sort=hot|new|top&page=&category=<slug>&status=<status> */async listThreads(v, spaceSlug, q = {}) {const space = spaceFor(v, spaceSlug);@@@ -499,7 +554,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vispace: { ...shapeSpace(space, await membersOnly(space.members_only_owner)), group: self.group || null, parent: self.parent || null },sort, page, per_page: perPage, total, pages: Math.max(Math.ceil(total / perPage), 1),categories: store.listCategories(db, space.id).map(shapeCategory), category: category ? category.slug : null, status: q.status || null,-- viewer: { can_start: space.thread_kind !== 'roadmap' || moderator(v), can_moderate: moderator(v), signed_in: person(v) },++ viewer: { can_start: space.thread_kind !== 'roadmap' || moderator(v), can_moderate: moderator(v), signed_in: person(v), can_manage_chat_room: canManageChatRoom(v, space) },children: index.spaces.filter((c) => c.parent && c.parent.slug === space.slug),groups: moderator(v) ? store.listGroups(db).map((g) => ({ slug: g.slug, name: g.name })) : undefined,threads: await shapeThreads(rows, () => space, v),@@@ -109,7 +109,7 @@ function settingsForm(space, groups = []) {}// ── /s/:space (forum style) ──────────────────────────────────--function forumSpacePage({ space, threads, page, pages, total, user, categories = [], category = null, status = null, viewer = {}, children = [], groups = [] }) {++function forumSpacePage({ space, threads, page, pages, total, user, categories = [], category = null, status = null, viewer = {}, children = [], groups = [], chatRoom = '' }) {const filtered = !!(category || status);const indexable = space.visibility === 'public' && !space.members_only && !filtered;const href = (o = {}) => f.spaceHref(space.slug, { sort: 'active', category, status, ...o }).replace(/[?&]sort=active/, '').replace(/\?&/, '?').replace(/\?$/, '');@@@ -127,6 +127,7 @@ function forumSpacePage({ space, threads, page, pages, total, user, categories =${space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}</header>${children.length ? `<section class="board-group" aria-label="Child boards">${boardTable(children, 'Child boards')}</section>` : ''}++${chatRoom}<div class="board-toolbar">${start}${user ? '' : ' <span class="muted small">Sign in with your OpenVibe account to post.</span>'}</div>${tags}${statuses}<section data-results>@@@ -90,7 +90,27 @@ function threadRow(t, space) {</li>`;}--function spacePage({ space, threads, sort, page, pages, total, user, categories = [], category = null, status = null, viewer = {}, footer = '' }) {++/**++ * The space's chat room on OpenVibe.Chat (a link), and for the space's owner or staff the no-JS forms to++ * attach one (by address or link) or detach it. Empty when there is none and the viewer cannot attach.++ */++function chatRoomBox(space, { canManage = false, error = null } = {}) {++ const r = space.chat_room;++ if (!r && !canManage) return '';++ const kind = r ? ({ call: 'Call room', system: 'Announcements' }[r.kind] || 'Chat room') : '';++ const link = r ? `<p><a class="btn" href="${esc(r.url)}" rel="noopener"><i class="fa-solid fa-comments" aria-hidden="true"></i> ${esc(r.name)}</a> <span class="muted small">${kind} on OpenVibe.Chat${r.visibility === 'private' ? ' · members only' : ''}</span></p>` : '';++ const manage = canManage ? `<details class="space-settings"${error ? ' open' : ''}><summary>${r ? 'Change or detach the chat room' : 'Attach a chat room'}</summary>++ ${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}++ <form class="paste-form" method="post" action="/s/${esc(space.slug)}/chat-room">++ <label class="field"><span>Room address or link (you must own the room on OpenVibe.Chat)</span><input name="room" required maxlength="300" placeholder="night-owls or https://openvibe.chat/r/night-owls" value="${r ? esc(r.slug) : ''}"></label>++ <div class="form-actions"><button class="btn btn-primary" type="submit">${r ? 'Attach this room instead' : 'Attach'}</button></div>++ </form>++ ${r ? `<form class="inline-form" method="post" action="/s/${esc(space.slug)}/chat-room/detach"><button class="btn btn-sm btn-ghost" type="submit">Detach the chat room</button></form>` : ''}++</details>` : '';++ return `<section class="chat-room" id="chat-room" aria-labelledby="chat-room-h"><h2 class="h3" id="chat-room-h">Chat room</h2>${link}${manage}</section>`;++}++++function spacePage({ space, threads, sort, page, pages, total, user, categories = [], category = null, status = null, viewer = {}, footer = '', chatRoom = '' }) {const tabs = Object.keys(SORT_LABELS).filter((s) => s !== 'top' || space.votes !== false).map((s) => `<a class="tab${s === sort ? ' active' : ''}" href="${esc(spaceHref(space.slug, { sort: s, category, status }))}"${s === sort ? ' aria-current="page"' : ''}>${SORT_LABELS[s]}</a>`).join('');const filtered = !!(category || status);const indexable = space.visibility === 'public' && !space.members_only && !filtered;@@@ -109,6 +129,7 @@ function spacePage({ space, threads, sort, page, pages, total, user, categories${space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}${start}</header>++${chatRoom}${categoryNav}${statusNav}<nav class="tabs sort-tabs" aria-label="Sort threads">${tabs}</nav><section data-results>@@@ -339,6 +360,6 @@ function membersOnlyPage({ space, thread = null, members_only: mo = null, reason}module.exports = {-- spacesPage, spacePage, threadPage, newThreadPage, membersOnlyPage, spaceHref, who,++ spacesPage, spacePage, threadPage, newThreadPage, membersOnlyPage, spaceHref, who, chatRoomBox,pager, visBadge, vipBadge, statusBadge, categoryBadge, voteForm, modActions, reactionBar, postImages, imageField, pasteField, pasteCards, crosspostBlock, STATUS_LABELS,};nnew file mode 100644@@@ -0,0 +1,188 @@++'use strict';++/**++ * A space's chat room (roadmap WS-I task 4; server/chat-rooms.js, forum/service.js attachChatRoom):++ * the space's owner (its creator, or the creator whose members' space it is) or staff attach a room on++ * OpenVibe.Chat; Community asks Chat with the person's own token and Chat answers whether they manage the++ * room. Attaching again is a no-op, another room replaces it, detach is idempotent; others, services and++ * signed-out visitors cannot attach; Chat's refusals (not the owner, a private room, Chat down) come back++ * as problems and as notices on the page; the space page links the room. A stub Chat stands in for++ * OpenVibe.Chat's POST/DELETE /api/chat/rooms/:slug/attachments.++ */++const assert = require('assert');++const http = require('http');++const { ids } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/app');++++/** Stub Chat: rooms with owners (by Network subject), private rooms with members, attachments. */++function startStubChat() {++ const rooms = new Map();++ const attachments = new Map(); // `${room}|${space}` → attached_by subject++ const calls = [];++ const state = { down: false };++ const subjectOf = (auth) => {++ const tok = String(auth || '').replace(/^Bearer /, '');++ try { return JSON.parse(Buffer.from(tok.split('.')[1], 'base64url').toString()).subject_id || null; } catch { return null; }++ };++ const server = http.createServer((req, res) => {++ let raw = '';++ req.on('data', (c) => { raw += c; });++ req.on('end', () => {++ if (state.down) { req.socket.destroy(); return; }++ const send = (status, body) => { res.statusCode = status; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(body)); };++ const who = subjectOf(req.headers.authorization);++ let body = {}; try { body = raw ? JSON.parse(raw) : {}; } catch { /* */ }++ calls.push({ method: req.method, url: req.url, who, auth: req.headers.authorization || null, body });++ let m = /^\/api\/chat\/rooms\/([^/]+)\/attachments$/.exec(req.url);++ if (m && req.method === 'POST') {++ const room = rooms.get(m[1]);++ if (!who) return send(401, { error: 'Authentication required' });++ if (!room || (room.visibility === 'private' && !room.members.includes(who))) return send(404, { error: 'No such room', code: 'rooms.not_found' });++ if (room.owner !== who) return send(403, { error: 'Only the room\'s owner attaches it elsewhere', code: 'rooms.not_owner' });++ const key = `${room.slug}|${body.resource}`;++ const created = !attachments.has(key);++ if (created) attachments.set(key, who);++ return send(created ? 201 : 200, { attachment: { service: body.service, resource: body.resource, title: body.title }, created, room: { id: room.id, slug: room.slug, name: room.name, kind: room.kind, visibility: room.visibility } });++ }++ m = /^\/api\/chat\/rooms\/([^/]+)\/attachments\/community\/([^/]+)$/.exec(req.url);++ if (m && req.method === 'DELETE') {++ const room = rooms.get(m[1]);++ const key = `${m[1]}|${m[2]}`;++ if (!room) return send(404, { error: 'No such room' });++ if (!attachments.has(key)) return send(200, { ok: true, removed: false });++ if (room.owner !== who && attachments.get(key) !== who) return send(403, { error: 'no' });++ attachments.delete(key);++ return send(200, { ok: true, removed: true });++ }++ send(404, { error: 'Not found' });++ });++ });++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({++ url: `http://127.0.0.1:${server.address().port}`, rooms, attachments, calls, state,++ addRoom: (slug, o) => rooms.set(slug, { id: rooms.size + 1, slug, name: slug, kind: 'community', visibility: 'public', members: [], ...o }),++ close: () => new Promise((r) => server.close(r)),++ })));++}++++(async () => {++ const chat = await startStubChat();++ process.env.OV_CHAT_INTERNAL_URL = chat.url;++ process.env.OV_CHAT_URL = 'https://openvibe.chat';++ const t = await boot({ authority: 'community' });++ const net = t.network;++ const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });++ const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });++ const bossSubject = ids.newId('user');++ const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });++ const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });++ const bossJwt = net.sign({ id: 1, subject_id: bossSubject, username: 'boss', display_name: 'Boss', role: 'admin' });++ // Alex owns the Showcase space (its creator) and the rooms night-owls and secret; Sam owns sams-room; Boss (staff) owns staff-room.++ t.db.prepare("UPDATE spaces SET created_by = ? WHERE slug = 'showcase'").run(alex.subject_id);++ t.db.prepare("UPDATE spaces SET members_only_owner = ? WHERE slug = 'off-topic'").run(sam.subject_id);++ chat.addRoom('night-owls', { name: 'Night Owls', owner: alex.subject_id });++ chat.addRoom('secret', { name: 'Secret', owner: sam.subject_id, visibility: 'private', members: [sam.subject_id] });++ chat.addRoom('stage', { name: 'Stage', kind: 'call', owner: sam.subject_id });++ chat.addRoom('sams-room', { name: 'Sam\'s Room', owner: sam.subject_id });++ chat.addRoom('staff-room', { name: 'Staff Room', owner: bossSubject });++ const api = (method, path, jwt, body) => t.get(`/api/v1/spaces${path}`, { method, headers: { ...(jwt ? { authorization: `Bearer ${jwt}` } : {}), ...(body !== undefined ? { 'content-type': 'application/json' } : {}) }, body: body !== undefined ? JSON.stringify(body) : undefined });++ const form = (jwt, path, fields, origin = 'https://openvibe.community') => t.get(path, { method: 'POST', cookies: [`ov_token=${jwt}`], headers: { 'content-type': 'application/x-www-form-urlencoded', origin }, body: new URLSearchParams(fields).toString() });++ const rows = () => t.db.prepare('SELECT * FROM space_chat_rooms ORDER BY space_id').all();++++ await check('only the space\'s owner or staff attach, and only as themselves', async () => {++ const before = chat.calls.length;++ assert.strictEqual((await api('PUT', '/showcase/chat-room', null, { room: 'night-owls' })).status, 401);++ let r = await api('PUT', '/showcase/chat-room', samJwt, { room: 'sams-room' });++ assert.deepStrictEqual([r.status, r.json().code], [403, 'capability.denied'], 'not the space\'s owner');++ const svc = net.signService({ sub: 'svc:live', cap: ['community.comment.moderate', 'community.post.create'] });++ r = await api('PUT', '/showcase/chat-room', svc, { room: 'night-owls' });++ assert.deepStrictEqual([r.status, r.json().code], [403, 'chat_room.person_only'], 'a service has no person\'s token to show Chat');++ r = await api('PUT', '/showcase/chat-room', alexJwt, { room: '../../admin' });++ assert.deepStrictEqual([r.status, r.json().code], [400, 'chat_room.invalid']);++ assert.strictEqual(chat.calls.length, before, 'Chat was not asked for any of these');++ assert.deepStrictEqual(rows(), []);++ });++++ await check('the owner attaches by link; Chat is asked with their own token; again is a no-op', async () => {++ let r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'https://openvibe.chat/r/night-owls' });++ assert.strictEqual(r.status, 201, r.text);++ assert.deepStrictEqual(r.json().chat_room, { slug: 'night-owls', name: 'Night Owls', kind: 'community', visibility: 'public', url: 'https://openvibe.chat/r/night-owls', attached_at: r.json().chat_room.attached_at });++ const call = chat.calls[chat.calls.length - 1];++ assert.deepStrictEqual([call.method, call.url, call.auth, call.body], ['POST', '/api/chat/rooms/night-owls/attachments', `Bearer ${alexJwt}`, { service: 'community', resource: 'showcase', title: 'Showcase' }]);++ r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'night-owls' });++ assert.deepStrictEqual([r.status, r.json().created], [200, false]);++ assert.strictEqual(rows().length, 1);++ assert.strictEqual(rows()[0].attached_by, alex.subject_id);++ assert.strictEqual(chat.attachments.size, 1);++ assert.strictEqual((await api('GET', '/showcase')).json().space.chat_room.slug, 'night-owls', 'the space says which room');++ });++++ await check('Chat\'s refusals: not the room\'s owner, a private room, Chat down; nothing changes', async () => {++ let r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'stage' });++ assert.deepStrictEqual([r.status, r.json().code], [403, 'chat_room.not_owner']);++ r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'secret' });++ assert.deepStrictEqual([r.status, r.json().code], [404, 'chat_room.not_found'], 'a private room is no more than a missing one');++ r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'no-such-room' });++ assert.strictEqual(r.json().code, 'chat_room.not_found');++ chat.state.down = true;++ r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'night-owls' });++ assert.deepStrictEqual([r.status, r.json().code], [503, 'chat_room.unavailable']);++ const f = await form(alexJwt, '/s/showcase/chat-room', { room: 'night-owls' });++ assert.strictEqual(f.status, 303);++ assert.match(f.headers.get('location'), /^\/s\/showcase\?chat_error=.*#chat-room$/);++ const page = await t.get(f.headers.get('location').replace(/#.*$/, ''), { cookies: [`ov_token=${alexJwt}`] });++ assert.match(page.text, /<p class="alert alert-error" role="alert">OpenVibe.Chat did not answer/);++ chat.state.down = false;++ assert.strictEqual(rows()[0].room_slug, 'night-owls');++ });++++ await check('the space page links the room; its owner gets the forms; others only the link', async () => {++ let page = await t.get('/s/showcase');++ assert.strictEqual(page.status, 200);++ assert.match(page.text, /<section class="chat-room" id="chat-room"/);++ assert.match(page.text, /<a class="btn" href="https:\/\/openvibe\.chat\/r\/night-owls" rel="noopener">.*Night Owls<\/a>/);++ assert.ok(!page.text.includes('action="/s/showcase/chat-room"'), 'no forms for a visitor');++ page = await t.get('/s/showcase', { cookies: [`ov_token=${samJwt}`] });++ assert.ok(!page.text.includes('action="/s/showcase/chat-room"'), 'nor for another person');++ page = await t.get('/s/showcase', { cookies: [`ov_token=${alexJwt}`] });++ assert.ok(page.text.includes('action="/s/showcase/chat-room"') && page.text.includes('action="/s/showcase/chat-room/detach"'));++ page = await t.get('/s/general');++ assert.ok(!page.text.includes('id="chat-room"'), 'a space without a room shows nothing');++ page = await t.get('/s/general', { cookies: [`ov_token=${bossJwt}`] });++ assert.match(page.text, /Attach a chat room/, 'staff may attach one anywhere');++ // Forum-style spaces show it too.++ await api('PUT', '/general/chat-room', bossJwt, { room: 'staff-room' });++ assert.match((await t.get('/s/general')).text, /href="https:\/\/openvibe\.chat\/r\/staff-room"/);++ assert.strictEqual((await form(alexJwt, '/s/showcase/chat-room', { room: 'night-owls' }, 'https://evil.example')).status, 403, 'forms from elsewhere are refused');++ });++++ await check('replace, then detach: idempotent, and Chat is told when the person may remove its side', async () => {++ // Staff replace the Showcase room with their own; Chat is told the space let go of night-owls (best effort).++ let r = await api('PUT', '/showcase/chat-room', bossJwt, { room: 'staff-room' });++ assert.deepStrictEqual([r.status, r.json().created, r.json().chat_room.slug], [201, true, 'staff-room']);++ assert.ok(chat.calls.some((c) => c.method === 'DELETE' && c.url === '/api/chat/rooms/night-owls/attachments/community/showcase'));++ assert.strictEqual(chat.attachments.has('night-owls|showcase'), true, 'Chat keeps the old side when the caller may not remove it');++ assert.strictEqual(rows().find((x) => x.room_slug === 'staff-room' && x.attached_by === bossSubject) !== undefined, true);++ // The owner puts theirs back and detaches it without JavaScript: both sides go.++ r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'night-owls' });++ assert.strictEqual(r.status, 201);++ const f = await form(alexJwt, '/s/showcase/chat-room/detach', {});++ assert.deepStrictEqual([f.status, f.headers.get('location')], [303, '/s/showcase#chat-room']);++ assert.ok(!rows().some((x) => x.room_slug === 'night-owls'));++ assert.strictEqual(chat.attachments.has('night-owls|showcase'), false, 'Chat\'s side went too');++ r = await api('DELETE', '/showcase/chat-room', alexJwt);++ assert.deepStrictEqual([r.status, r.json().detached], [200, false], 'detaching twice is fine');++ assert.strictEqual((await api('DELETE', '/general/chat-room', samJwt)).status, 403, 'only the owner or staff detach');++ const svc = net.signService({ sub: 'svc:live', cap: ['community.comment.moderate'] });++ r = await api('DELETE', '/general/chat-room', svc);++ assert.deepStrictEqual([r.status, r.json().detached, r.json().chat], [200, true, 'unavailable'], 'a moderator service may detach (Chat keeps its side)');++ });++++ await check('the creator whose members\' space it is may attach their room', async () => {++ const r = await api('PUT', '/off-topic/chat-room', samJwt, { room: 'sams-room' });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.json().chat_room.name, 'Sam\'s Room');++ });++++ await chat.close();++ await t.close();++ done();++})();
@@ -42,6 +42,11 @@ OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100
@@ -42,6 +42,11 @@ OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100
# Without it, or with VIP down, only the owner and moderators get in (fails closed).
# Without it, or with VIP down, only the owner and moderators get in (fails closed).
OV_VIP_INTERNAL_URL=http://127.0.0.1:4620
OV_VIP_INTERNAL_URL=http://127.0.0.1:4620
OV_VIP_URL=https://openvibe.vip
OV_VIP_URL=https://openvibe.vip
+
+# OpenVibe.Chat: a space's chat room. Community asks Chat with the signed-in person's own token whether
+# they manage the room (no service grant needed); the space page links OV_CHAT_URL/r/<room>.
+OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400
+OV_CHAT_URL=https://openvibe.chat
# How long a VIP yes / no / failure is cached (ms); the yes TTL bounds how long access outlives a membership.
# How long a VIP yes / no / failure is cached (ms); the yes TTL bounds how long access outlives a membership.
# VIP_CACHE_TTL_MS=30000
# VIP_CACHE_TTL_MS=30000
# VIP_CACHE_DENY_TTL_MS=10000
# VIP_CACHE_DENY_TTL_MS=10000
@@ -32,6 +32,7 @@ Pastes moved to Community in roadmap Wave 5. `PASTES_AUTHORITY` picks who owns t
@@ -32,6 +32,7 @@ Pastes moved to Community in roadmap Wave 5. `PASTES_AUTHORITY` picks who owns t
| Raw text, screenshots | OpenVibe.Media public host | 302 from `/p/:slug/raw`, `/p/:slug/screenshot` |
| Raw text, screenshots | OpenVibe.Media public host | 302 from `/p/:slug/raw`, `/p/:slug/screenshot` |
| Shared chrome, themes | `https://openvibe.network/shared/*.js` | loaded in every page |
| Shared chrome, themes | `https://openvibe.network/shared/*.js` | loaded in every page |
| VIP memberships (members-only spaces/threads) | **OpenVibe.VIP** (`POST /api/v1/policies/evaluate`) | `OV_VIP_INTERNAL_URL`, service token (audience `openvibe.vip`, `vip.resource.policy.evaluate`) |
| VIP memberships (members-only spaces/threads) | **OpenVibe.VIP** (`POST /api/v1/policies/evaluate`) | `OV_VIP_INTERNAL_URL`, service token (audience `openvibe.vip`, `vip.resource.policy.evaluate`) |
+| A space's chat room | **OpenVibe.Chat** (`POST/DELETE /api/chat/rooms/:room/attachments`) | `OV_CHAT_INTERNAL_URL`, the signed-in person's own Network token (no service grant) |
Why go through Live and not straight to Media: the visitor's JWT names the account by its
Why go through Live and not straight to Media: the visitor's JWT names the account by its
**Network** id, while the `user_id` Media stores for pastes is **Live's** own — different
**Network** id, while the `user_id` Media stores for pastes is **Live's** own — different
@@ -371,8 +372,28 @@ API (`/api/v1/spaces`, `/api/v1/posts`, problem+json errors):
@@ -371,8 +372,28 @@ API (`/api/v1/spaces`, `/api/v1/posts`, problem+json errors):
| `PUT /spaces/:space/threads/:slug/state` `{ pinned?, locked? }` | Moderators |
| `PUT /spaces/:space/threads/:slug/state` `{ pinned?, locked? }` | Moderators |
| `PUT /spaces/:space/members-only` `{ owner: 'usr_…' \| null }` | Moderators: gate a space to a creator's VIP members (or open it) |
| `PUT /spaces/:space/members-only` `{ owner: 'usr_…' \| null }` | Moderators: gate a space to a creator's VIP members (or open it) |
| `PUT /spaces/:space/threads/:slug/members-only` `{ owner: 'usr_…' \| true \| null }` | The author (to their own members) or moderators (any creator) |
| `PUT /spaces/:space/threads/:slug/members-only` `{ owner: 'usr_…' \| true \| null }` | The author (to their own members) or moderators (any creator) |
+| `PUT /spaces/:space/chat-room` `{ room: slug \| https://openvibe.chat/r/<slug> }` | The space's owner or staff, signed in themselves: attach a chat room (201; the same room again 200) |
+| `DELETE /spaces/:space/chat-room` | The space's owner or staff (moderator services too): detach it (idempotent) |
| `PUT /posts/:id` `{ body }` · `DELETE /posts/:id` · `GET /posts/:id/versions` | Author or moderator |
| `PUT /posts/:id` `{ body }` · `DELETE /posts/:id` · `GET /posts/:id/versions` | Author or moderator |
+### A space's chat room (OpenVibe.Chat)
+
+A space can have one chat room on [OpenVibe.Chat](https://github.com/OpenVibers/OpenVibe.Chat) (roadmap WS-I
+task 4, `server/chat-rooms.js`). Its owner (the space's creator, or the creator whose members' space it is)
+or discussion staff attach one by its address or link, from the space page (no JavaScript) or
+`PUT /api/v1/spaces/:space/chat-room`; the space page (feed and forum style) then links it, with its kind
+(chat, call, announcements) and "members only" for a private room. Each side checks its own end:
+Community checks the space; Chat checks that the same person manages the room, because Community asks it
+with the person's own Network token (`POST ${OV_CHAT_INTERNAL_URL}/api/chat/rooms/:room/attachments`,
+`{ service: 'community', resource: <space>, title }`). So services cannot attach (a service has no
+person's token; moderator services may detach), and no capability or grant is involved. Chat's refusals
+come back as `chat_room.not_owner` (403), `chat_room.not_found` (404: no such room, or a private one the
+person is not in) and `chat_room.unavailable` (503/502); on the page they are a notice. Attaching the same
+room again is a no-op; another room replaces it (Chat is told the space let go of the old one, best
+effort); detaching removes Community's link and asks Chat to drop its side when the person may. Community
+keeps the link in `space_chat_rooms` (the room's id, slug, and its name, kind and visibility as Chat
+answered). No event is published for it (no contract has one yet).
+
### Members-only spaces and threads (OpenVibe.VIP)
### Members-only spaces and threads (OpenVibe.VIP)
A space or a single thread can be for one creator's [OpenVibe.VIP](https://github.com/OpenVibers/OpenVibe.VIP)
A space or a single thread can be for one creator's [OpenVibe.VIP](https://github.com/OpenVibers/OpenVibe.VIP)
@@ -537,6 +558,9 @@ Copy `.env.example` to `.env` (production: `/etc/openvibe/community.env`, mode 0
@@ -537,6 +558,9 @@ Copy `.env.example` to `.env` (production: `/etc/openvibe/community.env`, mode 0
| `VIEW_HASH_SECRET` | derived from the client secret | Salt for hashed visitor ids in view counts |
| `VIEW_HASH_SECRET` | derived from the client secret | Salt for hashed visitor ids in view counts |
| `COOKIE_SECURE` | `true` in production | Set `false` for plain-http local dev |
| `COOKIE_SECURE` | `true` in production | Set `false` for plain-http local dev |
| `OV_VIP_INTERNAL_URL` | `http://127.0.0.1:4620` | OpenVibe.VIP's API (members-only spaces and threads) |
| `OV_VIP_INTERNAL_URL` | `http://127.0.0.1:4620` | OpenVibe.VIP's API (members-only spaces and threads) |
+| `OV_CHAT_INTERNAL_URL` | `http://127.0.0.1:4400` | OpenVibe.Chat's API (a space's chat room) |
+| `OV_CHAT_URL` | `https://openvibe.chat` | Public Chat site, for the room links |
+| `CHAT_TIMEOUT_MS` | `4000` | One Chat call |
| `OV_VIP_URL` | `https://openvibe.vip` | Public VIP site, for join links |
| `OV_VIP_URL` | `https://openvibe.vip` | Public VIP site, for join links |
| `VIP_TIMEOUT_MS` | `2000` | One VIP call |
| `VIP_TIMEOUT_MS` | `2000` | One VIP call |
| `VIP_CACHE_TTL_MS` / `VIP_CACHE_DENY_TTL_MS` / `VIP_CACHE_UNAVAILABLE_TTL_MS` | `30000` / `10000` / `2000` | How long a yes / no / failure is cached (the yes TTL is the convergence bound) |
| `VIP_CACHE_TTL_MS` / `VIP_CACHE_DENY_TTL_MS` / `VIP_CACHE_UNAVAILABLE_TTL_MS` | `30000` / `10000` / `2000` | How long a yes / no / failure is cached (the yes TTL is the convergence bound) |
@@ -263,6 +263,8 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-
@@ -263,6 +263,8 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-
.badge-sticky { font-size: .65rem; border-color: var(--accent); color: var(--accent-light); }
.badge-sticky { font-size: .65rem; border-color: var(--accent); color: var(--accent-light); }
.board-toolbar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin: 8px 0 12px; }
.board-toolbar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin: 8px 0 12px; }
.space-settings, .crosspost-form { margin: 18px 0; }
.space-settings, .crosspost-form { margin: 18px 0; }
+.chat-room { margin: 12px 0 16px; padding: 10px 14px; border: 1px solid var(--border-light); border-radius: 10px; background: var(--bg-card); }
+.chat-room h2 { margin: 0 0 6px; }
.space-settings summary, .crosspost-form summary { cursor: pointer; color: var(--text-secondary); font-weight: 600; }
.space-settings summary, .crosspost-form summary { cursor: pointer; color: var(--text-secondary); font-weight: 600; }
.discuss-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
.discuss-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
.postbits { display: grid; gap: 12px; margin: 12px 0; }
.postbits { display: grid; gap: 12px; margin: 12px 0; }
@@ -149,7 +149,9 @@ function createApp(opts = {}) {
@@ -149,7 +149,9 @@ function createApp(opts = {}) {
const vip = opts.vip || createVipGate({ config, ...(opts.vipOptions || {}) });
const vip = opts.vip || createVipGate({ config, ...(opts.vipOptions || {}) });
// Images on posts go to OpenVibe.Media's Object API as med_ objects (media/objects.js).
// Images on posts go to OpenVibe.Media's Object API as med_ objects (media/objects.js).
const mediaObjects = opts.mediaObjects || require('./media/objects').createMediaObjects({ config });
const mediaObjects = opts.mediaObjects || require('./media/objects').createMediaObjects({ config });
- const forum = createForumService({ db, network, pulse, relay, vip, media: mediaObjects, limits: opts.forumLimits });
+ // A space's chat room on OpenVibe.Chat (chat-rooms.js): attached with the person's own token.
+ const chatRooms = opts.chatRooms || require('./chat-rooms').createChatRooms({ config });
+ const forum = createForumService({ db, network, pulse, relay, vip, media: mediaObjects, chatRooms, limits: opts.forumLimits });
const community = config.pastesAuthority === 'community';
const community = config.pastesAuthority === 'community';
const comments = createCommentService({ db, network, pastesLocal: community, limits: opts.commentLimits });
const comments = createCommentService({ db, network, pastesLocal: community, limits: opts.commentLimits });
seo.useForum(forum);
seo.useForum(forum);
new file mode 100644
new file mode 100644
@@ -0,0 +1,89 @@
@@ -0,0 +1,89 @@
+'use strict';
+/**
+ * OpenVibe.Chat rooms for spaces (roadmap WS-I task 4): a space's owner or staff attach a chat room to
+ * the space; the space page links it.
+ *
+ * Each side checks its own end. Community decides who may change the space (forum/service.js); Chat
+ * decides who may attach the room: the person must manage it (its owner, or chat staff). Community asks
+ * Chat on the person's behalf with the person's OWN Network token (the ov_token they are signed in with),
+ * so no service grant or new capability is involved:
+ *
+ * POST <chat>/api/chat/rooms/:room/attachments { service: 'community', resource: <space slug>, title }
+ * 201 attached, 200 already attached (idempotent) → { room: { id, slug, name, kind, visibility } }
+ * DELETE <chat>/api/chat/rooms/:room/attachments/community/:space (the room's managers or who attached it)
+ *
+ * Chat's answers become Community's: 404 (no such room, or a private room the person is not in: Chat
+ * says nothing more), 403 (not the room's owner), 401 (the token expired). Chat unreachable: 503.
+ */
+const { fail } = require('./http/v1');
+
+const ROOM_SLUG = /^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])$/;
+
+/** A room slug from "night-owls", "/r/night-owls" or "https://openvibe.chat/r/night-owls"; null otherwise. */
+function parseRoomRef(input) {
+ const s = String(input == null ? '' : input).trim();
+ if (!s || s.length > 300) return null;
+ const m = s.match(/^(?:https?:\/\/[^/\s]+)?\/r\/([^/?#\s]+)\/?(?:[?#].*)?$/i);
+ const slug = (m ? m[1] : s).toLowerCase();
+ return ROOM_SLUG.test(slug) ? slug : null;
+}
+
+function createChatRooms({ config, fetchImpl = globalThis.fetch } = {}) {
+ const chat = (config && config.chat) || {};
+ const base = chat.internalUrl || '';
+ const timeoutMs = chat.timeoutMs || 4000;
+ const publicUrl = chat.url || 'https://openvibe.chat';
+
+ async function call(method, path, token, body) {
+ if (!base) fail(503, 'chat_room.unavailable', 'Chat rooms cannot be attached right now');
+ let res;
+ try {
+ res = await fetchImpl(`${base}${path}`, {
+ method,
+ headers: { authorization: `Bearer ${token}`, accept: 'application/json', ...(body ? { 'content-type': 'application/json' } : {}) },
+ body: body ? JSON.stringify(body) : undefined,
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+ } catch (err) {
+ fail(503, 'chat_room.unavailable', 'OpenVibe.Chat did not answer. Try again in a minute');
+ }
+ let data = null;
+ try { data = await res.json(); } catch { data = null; }
+ return { status: res.status, data };
+ }
+
+ /** Ask Chat to attach `room` to `space` as the person holding `token`. → { room, created } */
+ async function attach({ token, room, space, title }) {
+ if (!token) fail(403, 'chat_room.person_only', 'Attach a chat room while signed in with your own account');
+ const r = await call('POST', `/api/chat/rooms/${encodeURIComponent(room)}/attachments`, token, { service: 'community', resource: space, title: title || undefined });
+ if ((r.status === 200 || r.status === 201) && r.data && r.data.room && r.data.room.slug) {
+ const x = r.data.room;
+ return {
+ created: r.status === 201,
+ room: { id: Number(x.id) || null, slug: String(x.slug), name: String(x.name || x.slug).slice(0, 80), kind: String(x.kind || 'community'), visibility: x.visibility === 'private' ? 'private' : 'public' },
+ };
+ }
+ if (r.status === 404) fail(404, 'chat_room.not_found', 'No such chat room (or it is private and you are not in it)');
+ if (r.status === 403) fail(403, 'chat_room.not_owner', 'Only the chat room\'s owner can attach it to a space');
+ if (r.status === 401) fail(401, 'auth.required', 'Sign in again to attach a chat room');
+ if (r.status === 422) fail(400, 'chat_room.invalid', (r.data && r.data.error) || 'Chat refused that attachment');
+ fail(502, 'chat_room.unavailable', 'OpenVibe.Chat could not attach the room right now');
+ }
+
+ /** Tell Chat the space let go of the room; best effort. → 'detached' | 'not_allowed' | 'unavailable' */
+ async function detach({ token, room, space }) {
+ if (!token || !base) return 'unavailable';
+ try {
+ const r = await call('DELETE', `/api/chat/rooms/${encodeURIComponent(room)}/attachments/community/${encodeURIComponent(space)}`, token);
+ if (r.status === 200) return 'detached';
+ if (r.status === 403 || r.status === 404) return 'not_allowed';
+ return 'unavailable';
+ } catch { return 'unavailable'; }
+ }
+
+ const roomUrl = (slug) => `${publicUrl}/r/${encodeURIComponent(slug)}`;
+
+ return { attach, detach, roomUrl, parseRoomRef };
+}
+
+module.exports = { createChatRooms, parseRoomRef, ROOM_SLUG };
@@ -76,6 +76,14 @@ module.exports = {
@@ -76,6 +76,14 @@ module.exports = {
unavailableTtlMs: parseInt(process.env.VIP_CACHE_UNAVAILABLE_TTL_MS, 10) || 2_000,
unavailableTtlMs: parseInt(process.env.VIP_CACHE_UNAVAILABLE_TTL_MS, 10) || 2_000,
},
},
+ // OpenVibe.Chat — a space can attach a chat room (server/chat-rooms.js). Community asks Chat, with the
+ // signed-in person's own Network token, whether they manage the room; the space page links it.
+ chat: {
+ url: (process.env.OV_CHAT_URL || 'https://openvibe.chat').replace(/\/$/, ''),
+ internalUrl: (process.env.OV_CHAT_INTERNAL_URL || 'http://127.0.0.1:4400').replace(/\/$/, ''),
+ timeoutMs: parseInt(process.env.CHAT_TIMEOUT_MS, 10) || 4000,
+ },
+
// Discord relay (server/relay; docs/discord-relay.md). Off by default, and inert without an owner's
// Discord relay (server/relay; docs/discord-relay.md). Off by default, and inert without an owner's
// webhook variables, mappings and (for inbound) bot token.
// webhook variables, mappings and (for inbound) bot token.
// out threads and replies in mapped public spaces → the mapping's Discord webhook; edits and deletes follow
// out threads and replies in mapped public spaces → the mapping's Discord webhook; edits and deletes follow
@@ -316,6 +316,20 @@ CREATE TABLE IF NOT EXISTS categories (
@@ -316,6 +316,20 @@ CREATE TABLE IF NOT EXISTS categories (
UNIQUE (space_id, slug)
UNIQUE (space_id, slug)
);
);
+-- A space's chat room on OpenVibe.Chat (WS-I task 4; server/chat-rooms.js), one per space. room_id and
+-- room_slug are Chat's; name, kind and visibility are what Chat answered when it was attached (or last
+-- re-attached). attached_by is the usr_ subject who attached it.
+CREATE TABLE IF NOT EXISTS space_chat_rooms (
+ space_id INTEGER PRIMARY KEY REFERENCES spaces(id) ON DELETE CASCADE,
+ room_id INTEGER,
+ room_slug TEXT NOT NULL,
+ room_name TEXT NOT NULL,
+ room_kind TEXT NOT NULL DEFAULT 'community',
+ room_visibility TEXT NOT NULL DEFAULT 'public',
+ attached_by TEXT,
+ attached_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
-- ── Discord relay (server/relay; DISCORD_RELAY_ENABLED) ──────
-- ── Discord relay (server/relay; DISCORD_RELAY_ENABLED) ──────
-- webhook_url_ref is the NAME of an environment variable holding the webhook URL: secrets never
-- webhook_url_ref is the NAME of an environment variable holding the webhook URL: secrets never
-- live in the database. migrate() adds discord_channel_id (the channel the webhook posts into;
-- live in the database. migrate() adds discord_channel_id (the channel the webhook posts into;
@@ -27,6 +27,9 @@
@@ -27,6 +27,9 @@
* POST /spaces { slug, name, description?, style?: feed|forum, votes?, reactions?, group?, parent?, visibility?, kind? } moderators
* POST /spaces { slug, name, description?, style?: feed|forum, votes?, reactions?, group?, parent?, visibility?, kind? } moderators
* PUT /spaces/:space/settings { name?, description?, style?, votes?, reactions?, group?, parent?, position?, kind? } moderators
* PUT /spaces/:space/settings { name?, description?, style?, votes?, reactions?, group?, parent?, position?, kind? } moderators
* POST /spaces/:space/threads/:slug/crosspost { to: slug } another space gets a thread linking back (people)
* POST /spaces/:space/threads/:slug/crosspost { to: slug } another space gets a thread linking back (people)
+ * PUT /spaces/:space/chat-room { room: slug | https://openvibe.chat/r/<slug> } the space's owner or staff, signed in
+ * themselves (Chat checks they manage the room) → { chat_room, created }
+ * DELETE /spaces/:space/chat-room the space's owner or staff (idempotent) → { detached, chat }
* POST /posts/:id/reactions { reaction: agree|winner|funny|informative|friendly|sympathy|dumb|disgusting|bad_reading|late|null }
* POST /posts/:id/reactions { reaction: agree|winner|funny|informative|friendly|sympathy|dumb|disgusting|bad_reading|late|null }
* PUT /posts/:id { body } DELETE /posts/:id GET /posts/:id/versions
* PUT /posts/:id { body } DELETE /posts/:id GET /posts/:id/versions
*
*
@@ -76,6 +79,9 @@ function createSpacesApi({ forum, viewers }) {
@@ -76,6 +79,9 @@ function createSpacesApi({ forum, viewers }) {
router.put('/:space/threads/:slug/state', serviceCap(MOD), jsonBody, run((req) => forum.moderateThread(req.viewer, p(req).space, p(req).slug, req.body || {})));
router.put('/:space/threads/:slug/state', serviceCap(MOD), jsonBody, run((req) => forum.moderateThread(req.viewer, p(req).space, p(req).slug, req.body || {})));
router.put('/:space/threads/:slug/members-only', writeOrMod, jsonBody, run((req) => forum.setThreadMembersOnly(req.viewer, p(req).space, p(req).slug, req.body || {})));
router.put('/:space/threads/:slug/members-only', writeOrMod, jsonBody, run((req) => forum.setThreadMembersOnly(req.viewer, p(req).space, p(req).slug, req.body || {})));
router.put('/:space/members-only', serviceCap(MOD), jsonBody, run((req) => forum.setSpaceMembersOnly(req.viewer, p(req).space, req.body || {})));
router.put('/:space/members-only', serviceCap(MOD), jsonBody, run((req) => forum.setSpaceMembersOnly(req.viewer, p(req).space, req.body || {})));
+ // A chat room (OpenVibe.Chat): people attach with their own token; moderator services may only detach.
+ router.put('/:space/chat-room', jsonBody, run((req) => forum.attachChatRoom(req.viewer, p(req).space, req.body || {}), (out) => (out.created ? 201 : 200)));
+ router.delete('/:space/chat-room', serviceCap(MOD), run((req) => forum.detachChatRoom(req.viewer, p(req).space)));
router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
return router;
return router;
@@ -10,6 +10,7 @@
@@ -10,6 +10,7 @@
* GET /s/:space/new POST /s/:space/new start a thread
* GET /s/:space/new POST /s/:space/new start a thread
* GET /s/:space/t/:slug a thread (?page=)
* GET /s/:space/t/:slug a thread (?page=)
* POST /s/:space/t/:slug/reply | /vote | /state | /delete | /members-only no-JS forms
* POST /s/:space/t/:slug/reply | /vote | /state | /delete | /members-only no-JS forms
+ * POST /s/:space/chat-room, /s/:space/chat-room/detach the space's chat room (its owner or staff)
*
*
* A members-only (OpenVibe.VIP) space or thread the viewer may not use renders a teaser with the
* A members-only (OpenVibe.VIP) space or thread the viewer may not use renders a teaser with the
* creator's join link (403), never its posts.
* creator's join link (403), never its posts.
@@ -116,8 +117,9 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -116,8 +117,9 @@ function createForumRoutes({ forum, viewers, config }) {
try {
try {
const out = await forum.listThreads(req.viewer, req.params.space, { sort: req.query.sort, page: req.query.page, category: req.query.category, status: req.query.status });
const out = await forum.listThreads(req.viewer, req.params.space, { sort: req.query.sort, page: req.query.page, category: req.query.category, status: req.query.status });
if (out.page > out.pages) throw new ApiError(404, 'page.not_found', 'There is no page with that number.');
if (out.page > out.pages) throw new ApiError(404, 'page.not_found', 'There is no page with that number.');
- if (out.space.style === 'forum') return html(res, boardPages.forumSpacePage({ ...out, user: req.user }));
- html(res, forumPages.spacePage({ ...out, user: req.user, footer: out.viewer.can_moderate ? boardPages.settingsForm(out.space, out.groups) : '' }));
+ const chatRoom = forumPages.chatRoomBox(out.space, { canManage: out.viewer.can_manage_chat_room, error: typeof req.query.chat_error === 'string' ? req.query.chat_error.slice(0, 200) : null });
+ if (out.space.style === 'forum') return html(res, boardPages.forumSpacePage({ ...out, user: req.user, chatRoom }));
+ html(res, forumPages.spacePage({ ...out, user: req.user, chatRoom, footer: out.viewer.can_moderate ? boardPages.settingsForm(out.space, out.groups) : '' }));
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
}));
}));
@@ -245,6 +247,24 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -245,6 +247,24 @@ function createForumRoutes({ forum, viewers, config }) {
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
}));
}));
+ // The space's chat room (no JS): attach by address or link, detach. A refusal comes back as a notice on the space.
+ const chatBack = (req, error) => `/s/${encodeURIComponent(req.params.space)}${error ? `?chat_error=${encodeURIComponent(error)}` : ''}#chat-room`;
+ router.post('/s/:space/chat-room', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
+ try {
+ await forum.attachChatRoom(req.viewer, req.params.space, { room: String((req.body || {}).room || '').slice(0, 300) });
+ res.redirect(303, chatBack(req));
+ } catch (err) {
+ if (err instanceof ApiError && String(err.code).startsWith('chat_room.')) return res.redirect(303, chatBack(req, err.message));
+ failPage(req, res, err, next);
+ }
+ }));
+ router.post('/s/:space/chat-room/detach', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
+ try {
+ await forum.detachChatRoom(req.viewer, req.params.space);
+ res.redirect(303, chatBack(req));
+ } catch (err) { failPage(req, res, err, next); }
+ }));
+
// Status (moderators) and category (the author or moderators), no JS.
// Status (moderators) and category (the author or moderators), no JS.
router.post('/s/:space/t/:slug/status', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
router.post('/s/:space/t/:slug/status', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
try {
try {
@@ -29,6 +29,11 @@
@@ -29,6 +29,11 @@
* thread's title with its members_only flag, never a body; gated things never reach Pulse, the Discord
* thread's title with its members_only flag, never a body; gated things never reach Pulse, the Discord
* relay, sitemaps or feeds. Moderators gate spaces (for any creator) and threads; a thread's author
* relay, sitemaps or feeds. Moderators gate spaces (for any creator) and threads; a thread's author
* gates it to their own members.
* gates it to their own members.
+ *
+ * A chat room (OpenVibe.Chat, WS-I task 4): a space's owner (its creator, or the creator whose members'
+ * space it is) or discussion staff attach one room to the space; the space page links it. Chat checks
+ * that the same person manages the room, with their own token (chat-rooms.js), so services cannot attach
+ * (moderator services can detach). Community keeps the link: space_chat_rooms.
*/
*/
const store = require('./store');
const store = require('./store');
const events = require('../events');
const events = require('../events');
@@ -92,7 +97,7 @@ const TITLE_MIN = 3, TITLE_MAX = 200;
@@ -92,7 +97,7 @@ const TITLE_MIN = 3, TITLE_MAX = 200;
const BODY_MAX = 40_000;
const BODY_MAX = 40_000;
const THREADS_PER_DAY = 20;
const THREADS_PER_DAY = 20;
-function createForumService({ db, network = null, pulse = null, relay = null, vip = null, media = null, limits = {} } = {}) {
+function createForumService({ db, network = null, pulse = null, relay = null, vip = null, media = null, chatRooms = null, limits = {} } = {}) {
const authors = createAuthors({ db, network });
const authors = createAuthors({ db, network });
const threadLimiter = createPersonLimiter({ cooldownSec: 30, perMinute: 3, noun: 'threads', ...(limits.threads || {}) });
const threadLimiter = createPersonLimiter({ cooldownSec: 30, perMinute: 3, noun: 'threads', ...(limits.threads || {}) });
const postLimiter = createPersonLimiter({ cooldownSec: 10, perMinute: 6, noun: 'posts', ...(limits.posts || {}) });
const postLimiter = createPersonLimiter({ cooldownSec: 10, perMinute: 6, noun: 'posts', ...(limits.posts || {}) });
@@ -107,6 +112,16 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -107,6 +112,16 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const moderator = (v) => discussionModerator(v);
const moderator = (v) => discussionModerator(v);
const person = (v) => !!(v && v.subject);
const person = (v) => !!(v && v.subject);
+ // ── a space's chat room (OpenVibe.Chat) ──────────────────
+ const chatRoomRow = (spaceId) => (spaceId == null ? null : db.prepare('SELECT * FROM space_chat_rooms WHERE space_id = ?').get(spaceId) || null);
+ const shapeChatRoom = (r) => (r ? {
+ slug: r.room_slug, name: r.room_name, kind: r.room_kind, visibility: r.room_visibility,
+ url: chatRooms ? chatRooms.roomUrl(r.room_slug) : `https://openvibe.chat/r/${encodeURIComponent(r.room_slug)}`,
+ attached_at: isoTime(r.attached_at),
+ } : null);
+ /** The space's owner (its creator, or the creator whose VIP members' space it is) or discussion staff. */
+ const canManageChatRoom = (v, space) => moderator(v) || (person(v) && (space.created_by === v.subject || space.members_only_owner === v.subject));
+
// ── access ───────────────────────────────────────────────
// ── access ───────────────────────────────────────────────
function canRead(v, space) {
function canRead(v, space) {
if (space.visibility === 'public') return true;
if (space.visibility === 'public') return true;
@@ -310,6 +325,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -310,6 +325,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
members_only: s.members_only_owner ? (mo || { owner: s.members_only_owner, owner_username: null, join_url: vip ? vip.joinUrl(s.members_only_owner) : null }) : null,
members_only: s.members_only_owner ? (mo || { owner: s.members_only_owner, owner_username: null, join_url: vip ? vip.joinUrl(s.members_only_owner) : null }) : null,
thread_count: s.thread_count != null ? s.thread_count : undefined,
thread_count: s.thread_count != null ? s.thread_count : undefined,
last_activity_at: s.last_activity_at !== undefined ? isoTime(s.last_activity_at) : undefined,
last_activity_at: s.last_activity_at !== undefined ? isoTime(s.last_activity_at) : undefined,
+ chat_room: shapeChatRoom(chatRoomRow(s.id)),
};
};
}
}
@@ -479,6 +495,45 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -479,6 +495,45 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
return { space: shapeSpace(s, await membersOnly(s.members_only_owner)) };
return { space: shapeSpace(s, await membersOnly(s.members_only_owner)) };
},
},
+ /**
+ * Attach a chat room to the space { room: slug or openvibe.chat link } — the space's owner or staff,
+ * signed in themselves (Chat checks they manage the room). Attaching the same room again is a no-op;
+ * another room replaces it (Chat is told the space let go of the old one). → { chat_room, created }
+ */
+ async attachChatRoom(v, spaceSlug, body = {}) {
+ const space = spaceFor(v, spaceSlug);
+ if (!person(v) && !moderator(v)) fail(401, 'auth.required', 'Sign in with your OpenVibe account');
+ if (!canManageChatRoom(v, space)) fail(403, 'capability.denied', 'Only the space\'s owner or staff attach a chat room');
+ if (!v || v.kind !== 'user' || !v.token) fail(403, 'chat_room.person_only', 'Attach a chat room while signed in with your own account');
+ if (!chatRooms) fail(503, 'chat_room.unavailable', 'Chat rooms cannot be attached right now');
+ const slug = chatRooms.parseRoomRef(body.room);
+ if (!slug) fail(400, 'chat_room.invalid', 'Name the room by its address (night-owls) or its link (https://openvibe.chat/r/night-owls)');
+ const before = chatRoomRow(space.id);
+ const out = await chatRooms.attach({ token: v.token, room: slug, space: space.slug, title: space.name });
+ const same = !!before && before.room_slug === out.room.slug;
+ db.prepare(`INSERT INTO space_chat_rooms (space_id, room_id, room_slug, room_name, room_kind, room_visibility, attached_by) VALUES (?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT (space_id) DO UPDATE SET room_id = excluded.room_id, room_slug = excluded.room_slug, room_name = excluded.room_name,
+ room_kind = excluded.room_kind, room_visibility = excluded.room_visibility,
+ attached_by = CASE WHEN space_chat_rooms.room_slug = excluded.room_slug THEN space_chat_rooms.attached_by ELSE excluded.attached_by END,
+ attached_at = CASE WHEN space_chat_rooms.room_slug = excluded.room_slug THEN space_chat_rooms.attached_at ELSE CURRENT_TIMESTAMP END`)
+ .run(space.id, out.room.id, out.room.slug, out.room.name, out.room.kind, out.room.visibility, v.subject || null);
+ if (before && !same) await chatRooms.detach({ token: v.token, room: before.room_slug, space: space.slug });
+ return { space: { slug: space.slug, name: space.name, url: `/s/${space.slug}` }, chat_room: shapeChatRoom(chatRoomRow(space.id)), created: !same };
+ },
+
+ /** Detach the space's chat room — the space's owner or staff (moderator services too). Idempotent. → { detached, chat } */
+ async detachChatRoom(v, spaceSlug) {
+ const space = spaceFor(v, spaceSlug);
+ if (!person(v) && !moderator(v)) fail(401, 'auth.required', 'Sign in with your OpenVibe account');
+ if (!canManageChatRoom(v, space)) fail(403, 'capability.denied', 'Only the space\'s owner or staff detach its chat room');
+ const before = chatRoomRow(space.id);
+ if (!before) return { detached: false, chat: null };
+ db.prepare('DELETE FROM space_chat_rooms WHERE space_id = ?').run(space.id);
+ // Chat's side of the link goes too when the person may remove it there (best effort: the space no longer shows it either way).
+ const chat = chatRooms && v.kind === 'user' && v.token ? await chatRooms.detach({ token: v.token, room: before.room_slug, space: space.slug }) : 'unavailable';
+ return { detached: true, chat };
+ },
+
/** A page of threads. ?sort=hot|new|top&page=&category=<slug>&status=<status> */
/** A page of threads. ?sort=hot|new|top&page=&category=<slug>&status=<status> */
async listThreads(v, spaceSlug, q = {}) {
async listThreads(v, spaceSlug, q = {}) {
const space = spaceFor(v, spaceSlug);
const space = spaceFor(v, spaceSlug);
@@ -499,7 +554,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -499,7 +554,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
space: { ...shapeSpace(space, await membersOnly(space.members_only_owner)), group: self.group || null, parent: self.parent || null },
space: { ...shapeSpace(space, await membersOnly(space.members_only_owner)), group: self.group || null, parent: self.parent || null },
sort, page, per_page: perPage, total, pages: Math.max(Math.ceil(total / perPage), 1),
sort, page, per_page: perPage, total, pages: Math.max(Math.ceil(total / perPage), 1),
categories: store.listCategories(db, space.id).map(shapeCategory), category: category ? category.slug : null, status: q.status || null,
categories: store.listCategories(db, space.id).map(shapeCategory), category: category ? category.slug : null, status: q.status || null,
- viewer: { can_start: space.thread_kind !== 'roadmap' || moderator(v), can_moderate: moderator(v), signed_in: person(v) },
+ viewer: { can_start: space.thread_kind !== 'roadmap' || moderator(v), can_moderate: moderator(v), signed_in: person(v), can_manage_chat_room: canManageChatRoom(v, space) },
children: index.spaces.filter((c) => c.parent && c.parent.slug === space.slug),
children: index.spaces.filter((c) => c.parent && c.parent.slug === space.slug),
groups: moderator(v) ? store.listGroups(db).map((g) => ({ slug: g.slug, name: g.name })) : undefined,
groups: moderator(v) ? store.listGroups(db).map((g) => ({ slug: g.slug, name: g.name })) : undefined,
threads: await shapeThreads(rows, () => space, v),
threads: await shapeThreads(rows, () => space, v),
@@ -109,7 +109,7 @@ function settingsForm(space, groups = []) {
@@ -109,7 +109,7 @@ function settingsForm(space, groups = []) {
}
}
// ── /s/:space (forum style) ──────────────────────────────────
// ── /s/:space (forum style) ──────────────────────────────────
-function forumSpacePage({ space, threads, page, pages, total, user, categories = [], category = null, status = null, viewer = {}, children = [], groups = [] }) {
+function forumSpacePage({ space, threads, page, pages, total, user, categories = [], category = null, status = null, viewer = {}, children = [], groups = [], chatRoom = '' }) {
const filtered = !!(category || status);
const filtered = !!(category || status);
const indexable = space.visibility === 'public' && !space.members_only && !filtered;
const indexable = space.visibility === 'public' && !space.members_only && !filtered;
const href = (o = {}) => f.spaceHref(space.slug, { sort: 'active', category, status, ...o }).replace(/[?&]sort=active/, '').replace(/\?&/, '?').replace(/\?$/, '');
const href = (o = {}) => f.spaceHref(space.slug, { sort: 'active', category, status, ...o }).replace(/[?&]sort=active/, '').replace(/\?&/, '?').replace(/\?$/, '');
@@ -127,6 +127,7 @@ function forumSpacePage({ space, threads, page, pages, total, user, categories =
@@ -127,6 +127,7 @@ function forumSpacePage({ space, threads, page, pages, total, user, categories =
${space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}
${space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}
</header>
</header>
${children.length ? `<section class="board-group" aria-label="Child boards">${boardTable(children, 'Child boards')}</section>` : ''}
${children.length ? `<section class="board-group" aria-label="Child boards">${boardTable(children, 'Child boards')}</section>` : ''}
+${chatRoom}
<div class="board-toolbar">${start}${user ? '' : ' <span class="muted small">Sign in with your OpenVibe account to post.</span>'}</div>
<div class="board-toolbar">${start}${user ? '' : ' <span class="muted small">Sign in with your OpenVibe account to post.</span>'}</div>
${tags}${statuses}
${tags}${statuses}
<section data-results>
<section data-results>
@@ -90,7 +90,27 @@ function threadRow(t, space) {
@@ -90,7 +90,27 @@ function threadRow(t, space) {
</li>`;
</li>`;
}
}
-function spacePage({ space, threads, sort, page, pages, total, user, categories = [], category = null, status = null, viewer = {}, footer = '' }) {
+/**
+ * The space's chat room on OpenVibe.Chat (a link), and for the space's owner or staff the no-JS forms to
+ * attach one (by address or link) or detach it. Empty when there is none and the viewer cannot attach.
+ */
+function chatRoomBox(space, { canManage = false, error = null } = {}) {
+ const r = space.chat_room;
+ if (!r && !canManage) return '';
+ const kind = r ? ({ call: 'Call room', system: 'Announcements' }[r.kind] || 'Chat room') : '';
+ const link = r ? `<p><a class="btn" href="${esc(r.url)}" rel="noopener"><i class="fa-solid fa-comments" aria-hidden="true"></i> ${esc(r.name)}</a> <span class="muted small">${kind} on OpenVibe.Chat${r.visibility === 'private' ? ' · members only' : ''}</span></p>` : '';
+ const manage = canManage ? `<details class="space-settings"${error ? ' open' : ''}><summary>${r ? 'Change or detach the chat room' : 'Attach a chat room'}</summary>
+ ${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
+ <form class="paste-form" method="post" action="/s/${esc(space.slug)}/chat-room">
+ <label class="field"><span>Room address or link (you must own the room on OpenVibe.Chat)</span><input name="room" required maxlength="300" placeholder="night-owls or https://openvibe.chat/r/night-owls" value="${r ? esc(r.slug) : ''}"></label>
+ <div class="form-actions"><button class="btn btn-primary" type="submit">${r ? 'Attach this room instead' : 'Attach'}</button></div>
+ </form>
+ ${r ? `<form class="inline-form" method="post" action="/s/${esc(space.slug)}/chat-room/detach"><button class="btn btn-sm btn-ghost" type="submit">Detach the chat room</button></form>` : ''}
+</details>` : '';
+ return `<section class="chat-room" id="chat-room" aria-labelledby="chat-room-h"><h2 class="h3" id="chat-room-h">Chat room</h2>${link}${manage}</section>`;
+}
+
+function spacePage({ space, threads, sort, page, pages, total, user, categories = [], category = null, status = null, viewer = {}, footer = '', chatRoom = '' }) {
const tabs = Object.keys(SORT_LABELS).filter((s) => s !== 'top' || space.votes !== false).map((s) => `<a class="tab${s === sort ? ' active' : ''}" href="${esc(spaceHref(space.slug, { sort: s, category, status }))}"${s === sort ? ' aria-current="page"' : ''}>${SORT_LABELS[s]}</a>`).join('');
const tabs = Object.keys(SORT_LABELS).filter((s) => s !== 'top' || space.votes !== false).map((s) => `<a class="tab${s === sort ? ' active' : ''}" href="${esc(spaceHref(space.slug, { sort: s, category, status }))}"${s === sort ? ' aria-current="page"' : ''}>${SORT_LABELS[s]}</a>`).join('');
const filtered = !!(category || status);
const filtered = !!(category || status);
const indexable = space.visibility === 'public' && !space.members_only && !filtered;
const indexable = space.visibility === 'public' && !space.members_only && !filtered;
@@ -109,6 +129,7 @@ function spacePage({ space, threads, sort, page, pages, total, user, categories
@@ -109,6 +129,7 @@ function spacePage({ space, threads, sort, page, pages, total, user, categories
${space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}
${space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}
${start}
${start}
</header>
</header>
+${chatRoom}
${categoryNav}${statusNav}
${categoryNav}${statusNav}
<nav class="tabs sort-tabs" aria-label="Sort threads">${tabs}</nav>
<nav class="tabs sort-tabs" aria-label="Sort threads">${tabs}</nav>
<section data-results>
<section data-results>
@@ -339,6 +360,6 @@ function membersOnlyPage({ space, thread = null, members_only: mo = null, reason
@@ -339,6 +360,6 @@ function membersOnlyPage({ space, thread = null, members_only: mo = null, reason
}
}
module.exports = {
module.exports = {
- spacesPage, spacePage, threadPage, newThreadPage, membersOnlyPage, spaceHref, who,
+ spacesPage, spacePage, threadPage, newThreadPage, membersOnlyPage, spaceHref, who, chatRoomBox,
pager, visBadge, vipBadge, statusBadge, categoryBadge, voteForm, modActions, reactionBar, postImages, imageField, pasteField, pasteCards, crosspostBlock, STATUS_LABELS,
pager, visBadge, vipBadge, statusBadge, categoryBadge, voteForm, modActions, reactionBar, postImages, imageField, pasteField, pasteCards, crosspostBlock, STATUS_LABELS,
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,188 @@
@@ -0,0 +1,188 @@
+'use strict';
+/**
+ * A space's chat room (roadmap WS-I task 4; server/chat-rooms.js, forum/service.js attachChatRoom):
+ * the space's owner (its creator, or the creator whose members' space it is) or staff attach a room on
+ * OpenVibe.Chat; Community asks Chat with the person's own token and Chat answers whether they manage the
+ * room. Attaching again is a no-op, another room replaces it, detach is idempotent; others, services and
+ * signed-out visitors cannot attach; Chat's refusals (not the owner, a private room, Chat down) come back
+ * as problems and as notices on the page; the space page links the room. A stub Chat stands in for
+ * OpenVibe.Chat's POST/DELETE /api/chat/rooms/:slug/attachments.
+ */
+const assert = require('assert');
+const http = require('http');
+const { ids } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/app');
+
+/** Stub Chat: rooms with owners (by Network subject), private rooms with members, attachments. */
+function startStubChat() {
+ const rooms = new Map();
+ const attachments = new Map(); // `${room}|${space}` → attached_by subject
+ const calls = [];
+ const state = { down: false };
+ const subjectOf = (auth) => {
+ const tok = String(auth || '').replace(/^Bearer /, '');
+ try { return JSON.parse(Buffer.from(tok.split('.')[1], 'base64url').toString()).subject_id || null; } catch { return null; }
+ };
+ const server = http.createServer((req, res) => {
+ let raw = '';
+ req.on('data', (c) => { raw += c; });
+ req.on('end', () => {
+ if (state.down) { req.socket.destroy(); return; }
+ const send = (status, body) => { res.statusCode = status; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(body)); };
+ const who = subjectOf(req.headers.authorization);
+ let body = {}; try { body = raw ? JSON.parse(raw) : {}; } catch { /* */ }
+ calls.push({ method: req.method, url: req.url, who, auth: req.headers.authorization || null, body });
+ let m = /^\/api\/chat\/rooms\/([^/]+)\/attachments$/.exec(req.url);
+ if (m && req.method === 'POST') {
+ const room = rooms.get(m[1]);
+ if (!who) return send(401, { error: 'Authentication required' });
+ if (!room || (room.visibility === 'private' && !room.members.includes(who))) return send(404, { error: 'No such room', code: 'rooms.not_found' });
+ if (room.owner !== who) return send(403, { error: 'Only the room\'s owner attaches it elsewhere', code: 'rooms.not_owner' });
+ const key = `${room.slug}|${body.resource}`;
+ const created = !attachments.has(key);
+ if (created) attachments.set(key, who);
+ return send(created ? 201 : 200, { attachment: { service: body.service, resource: body.resource, title: body.title }, created, room: { id: room.id, slug: room.slug, name: room.name, kind: room.kind, visibility: room.visibility } });
+ }
+ m = /^\/api\/chat\/rooms\/([^/]+)\/attachments\/community\/([^/]+)$/.exec(req.url);
+ if (m && req.method === 'DELETE') {
+ const room = rooms.get(m[1]);
+ const key = `${m[1]}|${m[2]}`;
+ if (!room) return send(404, { error: 'No such room' });
+ if (!attachments.has(key)) return send(200, { ok: true, removed: false });
+ if (room.owner !== who && attachments.get(key) !== who) return send(403, { error: 'no' });
+ attachments.delete(key);
+ return send(200, { ok: true, removed: true });
+ }
+ send(404, { error: 'Not found' });
+ });
+ });
+ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({
+ url: `http://127.0.0.1:${server.address().port}`, rooms, attachments, calls, state,
+ addRoom: (slug, o) => rooms.set(slug, { id: rooms.size + 1, slug, name: slug, kind: 'community', visibility: 'public', members: [], ...o }),
+ close: () => new Promise((r) => server.close(r)),
+ })));
+}
+
+(async () => {
+ const chat = await startStubChat();
+ process.env.OV_CHAT_INTERNAL_URL = chat.url;
+ process.env.OV_CHAT_URL = 'https://openvibe.chat';
+ const t = await boot({ authority: 'community' });
+ const net = t.network;
+ const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
+ const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });
+ const bossSubject = ids.newId('user');
+ const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });
+ const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });
+ const bossJwt = net.sign({ id: 1, subject_id: bossSubject, username: 'boss', display_name: 'Boss', role: 'admin' });
+ // Alex owns the Showcase space (its creator) and the rooms night-owls and secret; Sam owns sams-room; Boss (staff) owns staff-room.
+ t.db.prepare("UPDATE spaces SET created_by = ? WHERE slug = 'showcase'").run(alex.subject_id);
+ t.db.prepare("UPDATE spaces SET members_only_owner = ? WHERE slug = 'off-topic'").run(sam.subject_id);
+ chat.addRoom('night-owls', { name: 'Night Owls', owner: alex.subject_id });
+ chat.addRoom('secret', { name: 'Secret', owner: sam.subject_id, visibility: 'private', members: [sam.subject_id] });
+ chat.addRoom('stage', { name: 'Stage', kind: 'call', owner: sam.subject_id });
+ chat.addRoom('sams-room', { name: 'Sam\'s Room', owner: sam.subject_id });
+ chat.addRoom('staff-room', { name: 'Staff Room', owner: bossSubject });
+ const api = (method, path, jwt, body) => t.get(`/api/v1/spaces${path}`, { method, headers: { ...(jwt ? { authorization: `Bearer ${jwt}` } : {}), ...(body !== undefined ? { 'content-type': 'application/json' } : {}) }, body: body !== undefined ? JSON.stringify(body) : undefined });
+ const form = (jwt, path, fields, origin = 'https://openvibe.community') => t.get(path, { method: 'POST', cookies: [`ov_token=${jwt}`], headers: { 'content-type': 'application/x-www-form-urlencoded', origin }, body: new URLSearchParams(fields).toString() });
+ const rows = () => t.db.prepare('SELECT * FROM space_chat_rooms ORDER BY space_id').all();
+
+ await check('only the space\'s owner or staff attach, and only as themselves', async () => {
+ const before = chat.calls.length;
+ assert.strictEqual((await api('PUT', '/showcase/chat-room', null, { room: 'night-owls' })).status, 401);
+ let r = await api('PUT', '/showcase/chat-room', samJwt, { room: 'sams-room' });
+ assert.deepStrictEqual([r.status, r.json().code], [403, 'capability.denied'], 'not the space\'s owner');
+ const svc = net.signService({ sub: 'svc:live', cap: ['community.comment.moderate', 'community.post.create'] });
+ r = await api('PUT', '/showcase/chat-room', svc, { room: 'night-owls' });
+ assert.deepStrictEqual([r.status, r.json().code], [403, 'chat_room.person_only'], 'a service has no person\'s token to show Chat');
+ r = await api('PUT', '/showcase/chat-room', alexJwt, { room: '../../admin' });
+ assert.deepStrictEqual([r.status, r.json().code], [400, 'chat_room.invalid']);
+ assert.strictEqual(chat.calls.length, before, 'Chat was not asked for any of these');
+ assert.deepStrictEqual(rows(), []);
+ });
+
+ await check('the owner attaches by link; Chat is asked with their own token; again is a no-op', async () => {
+ let r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'https://openvibe.chat/r/night-owls' });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.deepStrictEqual(r.json().chat_room, { slug: 'night-owls', name: 'Night Owls', kind: 'community', visibility: 'public', url: 'https://openvibe.chat/r/night-owls', attached_at: r.json().chat_room.attached_at });
+ const call = chat.calls[chat.calls.length - 1];
+ assert.deepStrictEqual([call.method, call.url, call.auth, call.body], ['POST', '/api/chat/rooms/night-owls/attachments', `Bearer ${alexJwt}`, { service: 'community', resource: 'showcase', title: 'Showcase' }]);
+ r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'night-owls' });
+ assert.deepStrictEqual([r.status, r.json().created], [200, false]);
+ assert.strictEqual(rows().length, 1);
+ assert.strictEqual(rows()[0].attached_by, alex.subject_id);
+ assert.strictEqual(chat.attachments.size, 1);
+ assert.strictEqual((await api('GET', '/showcase')).json().space.chat_room.slug, 'night-owls', 'the space says which room');
+ });
+
+ await check('Chat\'s refusals: not the room\'s owner, a private room, Chat down; nothing changes', async () => {
+ let r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'stage' });
+ assert.deepStrictEqual([r.status, r.json().code], [403, 'chat_room.not_owner']);
+ r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'secret' });
+ assert.deepStrictEqual([r.status, r.json().code], [404, 'chat_room.not_found'], 'a private room is no more than a missing one');
+ r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'no-such-room' });
+ assert.strictEqual(r.json().code, 'chat_room.not_found');
+ chat.state.down = true;
+ r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'night-owls' });
+ assert.deepStrictEqual([r.status, r.json().code], [503, 'chat_room.unavailable']);
+ const f = await form(alexJwt, '/s/showcase/chat-room', { room: 'night-owls' });
+ assert.strictEqual(f.status, 303);
+ assert.match(f.headers.get('location'), /^\/s\/showcase\?chat_error=.*#chat-room$/);
+ const page = await t.get(f.headers.get('location').replace(/#.*$/, ''), { cookies: [`ov_token=${alexJwt}`] });
+ assert.match(page.text, /<p class="alert alert-error" role="alert">OpenVibe.Chat did not answer/);
+ chat.state.down = false;
+ assert.strictEqual(rows()[0].room_slug, 'night-owls');
+ });
+
+ await check('the space page links the room; its owner gets the forms; others only the link', async () => {
+ let page = await t.get('/s/showcase');
+ assert.strictEqual(page.status, 200);
+ assert.match(page.text, /<section class="chat-room" id="chat-room"/);
+ assert.match(page.text, /<a class="btn" href="https:\/\/openvibe\.chat\/r\/night-owls" rel="noopener">.*Night Owls<\/a>/);
+ assert.ok(!page.text.includes('action="/s/showcase/chat-room"'), 'no forms for a visitor');
+ page = await t.get('/s/showcase', { cookies: [`ov_token=${samJwt}`] });
+ assert.ok(!page.text.includes('action="/s/showcase/chat-room"'), 'nor for another person');
+ page = await t.get('/s/showcase', { cookies: [`ov_token=${alexJwt}`] });
+ assert.ok(page.text.includes('action="/s/showcase/chat-room"') && page.text.includes('action="/s/showcase/chat-room/detach"'));
+ page = await t.get('/s/general');
+ assert.ok(!page.text.includes('id="chat-room"'), 'a space without a room shows nothing');
+ page = await t.get('/s/general', { cookies: [`ov_token=${bossJwt}`] });
+ assert.match(page.text, /Attach a chat room/, 'staff may attach one anywhere');
+ // Forum-style spaces show it too.
+ await api('PUT', '/general/chat-room', bossJwt, { room: 'staff-room' });
+ assert.match((await t.get('/s/general')).text, /href="https:\/\/openvibe\.chat\/r\/staff-room"/);
+ assert.strictEqual((await form(alexJwt, '/s/showcase/chat-room', { room: 'night-owls' }, 'https://evil.example')).status, 403, 'forms from elsewhere are refused');
+ });
+
+ await check('replace, then detach: idempotent, and Chat is told when the person may remove its side', async () => {
+ // Staff replace the Showcase room with their own; Chat is told the space let go of night-owls (best effort).
+ let r = await api('PUT', '/showcase/chat-room', bossJwt, { room: 'staff-room' });
+ assert.deepStrictEqual([r.status, r.json().created, r.json().chat_room.slug], [201, true, 'staff-room']);
+ assert.ok(chat.calls.some((c) => c.method === 'DELETE' && c.url === '/api/chat/rooms/night-owls/attachments/community/showcase'));
+ assert.strictEqual(chat.attachments.has('night-owls|showcase'), true, 'Chat keeps the old side when the caller may not remove it');
+ assert.strictEqual(rows().find((x) => x.room_slug === 'staff-room' && x.attached_by === bossSubject) !== undefined, true);
+ // The owner puts theirs back and detaches it without JavaScript: both sides go.
+ r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'night-owls' });
+ assert.strictEqual(r.status, 201);
+ const f = await form(alexJwt, '/s/showcase/chat-room/detach', {});
+ assert.deepStrictEqual([f.status, f.headers.get('location')], [303, '/s/showcase#chat-room']);
+ assert.ok(!rows().some((x) => x.room_slug === 'night-owls'));
+ assert.strictEqual(chat.attachments.has('night-owls|showcase'), false, 'Chat\'s side went too');
+ r = await api('DELETE', '/showcase/chat-room', alexJwt);
+ assert.deepStrictEqual([r.status, r.json().detached], [200, false], 'detaching twice is fine');
+ assert.strictEqual((await api('DELETE', '/general/chat-room', samJwt)).status, 403, 'only the owner or staff detach');
+ const svc = net.signService({ sub: 'svc:live', cap: ['community.comment.moderate'] });
+ r = await api('DELETE', '/general/chat-room', svc);
+ assert.deepStrictEqual([r.status, r.json().detached, r.json().chat], [200, true, 'unavailable'], 'a moderator service may detach (Chat keeps its side)');
+ });
+
+ await check('the creator whose members\' space it is may attach their room', async () => {
+ const r = await api('PUT', '/off-topic/chat-room', samJwt, { room: 'sams-room' });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.json().chat_room.name, 'Sam\'s Room');
+ });
+
+ await chat.close();
+ await t.close();
+ done();
+})();