openvibers/openvibe.community Commits
Let each space have its own moderators who moderate only that space
Changes
@@@ -327,6 +327,16 @@ member for now), `staff` (moderators only; looks missing to everyone else).deletes are soft (tombstones keep the numbering; deleting the opening post deletes thethread). Moderators (admin/global_mod browsers; services with `community.comment.moderate`)pin, lock and delete; locked threads take no replies or votes.++- **A space's own moderators** (`space_moderators`, migration 0005): people listed for one space act there as++ moderators do everywhere: thread state, status and category, edits and deletes, replies in locked++ threads, the VIP gate (they pass it), members-only, settings (name, description, style, votes, ratings,++ kind), categories and the chat room. They cannot move the space on the board index (group, parent,++ position), create spaces, manage the board index's groups, add roadmap items or open staff spaces. Those++ stay with staff. Any moderator of a space, or staff, adds and removes that space's moderators. They must++ be Network users (`usr_…`), and nobody is added across a block in either direction. A listing counts only++ for the person signed in themselves, never for a service naming them. Account deletion erases the rows and++ a subject merge moves them. No capability or Contracts change is involved. The cutover runbook is++ `docs/cutover-community-0005-space-moderators.md`.- **Two styles, one system.** Each space is a **forum** or a **feed**. A forum reads like vBulletin or SMF:- the board index at `/s` lists every space under its group, with topics, posts and the last post;@@@ -380,6 +390,8 @@ API (`/api/v1/spaces`, `/api/v1/posts`, problem+json errors):| `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 |++| `GET /spaces/:space/moderators` | The space's own moderators `{ space, moderators: [{ subject, username, display_name, added_by, added_at }] }` (whoever can read the space) |++| `PUT`/`DELETE /spaces/:space/moderators/:subject` | The space's moderators or staff: add (blocks refuse, 403 `community.blocked`) or remove one (idempotent). The no-JS forms are `POST /s/:space/moderators` and `POST /s/:space/moderators/remove` `{ subject }` |### A space's chat room (OpenVibe.Chat)@@@ -30,6 +30,7 @@"pastes: code, text and screenshots, with versions, likes, comments, forks and burn-after-read","typed comment threads embedded by other products","forum: spaces, threads, posts, categories and votes",++ "per-space moderators (migration 0005)","members-only spaces and threads gated by OpenVibe.VIP","a space's chat room on OpenVibe.Chat","Pulse: the network's public activity from Events",@@@ -40,5 +41,5 @@"N-1 compatibility tests","per-actor limits (openvibe-sdk/limits)"],-- "updated": "2026-10-03"++ "updated": "2026-10-04"}nnew file mode 100644@@@ -0,0 +1,53 @@++# Cutover runbook: Community migration 0005 (per-space moderators)++++T10 step 1. `migrations/0005_space_moderators.sql` adds one table, `space_moderators`, and its index. A++person listed there for a space moderates that space (see README, "Forum"). Nothing existing is++changed, and the previous release never reads the table.++++## Overview++++| | |++|---|---|++| Migration | `0005_space_moderators.sql`, `-- phase: migrate`, additive (CREATE TABLE + CREATE INDEX) |++| Applied | At boot, by `server/db.js` (openvibe-sdk/db migrate, owner role on `DATABASE_DIRECT_URL`), in one transaction under the advisory lock |++| Data moved | None. The table starts empty. Staff add the first moderators per space |++| Env / config | None. No new capability and no Contracts change: a space moderator is a data row |++| Owner action | None for the deploy. Afterwards, staff add moderators with `PUT /api/v1/spaces/:space/moderators/:subject` or the space page's form |++++## 0. Rules++++- Ship it with the normal pipeline (`ov deploy OpenVibe.Community`). It needs no window.++- N-1: the previous release runs against the new schema throughout the mixed-version window. It++ ignores the table, so its moderation is still global-only until every process runs the new release.++++## 1. Rehearsal++++1. Restore a recent copy of `ov_community` into a scratch database (the restore-drill copy will do).++2. Boot this release against it (`DATABASE_URL`/`DATABASE_DIRECT_URL` pointing at the copy), or run++ `npm run test:pg` with the copy as the test store. Check that `ov_migrations` lists `0005` and that++ `\d space_moderators` shows the primary key `(space_id, subject_id)`, the foreign key to `spaces`++ (`ON DELETE CASCADE`) and `idx_space_moderators_subject`.++3. Run the moderator tests: `ov test space-moderators forum members-only security-idor chat-room`.++++## 2. Cutover++++1. Deploy. The migration runs at the first boot of the new release.++2. **Verify:**++ - `ov access run openvibe-ovh health community` is green.++ - `ov access run openvibe-ovh journal openvibe-community.service 200` shows `0005_space_moderators`++ applied and no migrate error.++ - `curl -s https://openvibe.community/api/v1/spaces/general/moderators` answers++ `{ "space": "general", "moderators": [] }`.++++## 3. Rollback++++Leave the table where it is. A rollback to the previous release ignores it. Rows added in the meantime++stay, and they apply again when the release comes back. The migration is never reverted or edited++(`ov_migrations` keeps its checksum). If it must be undone by hand, a later contract migration drops the++table after the N-1 window.++++## 4. Afterwards++++- Account deletion erases a person's rows, and `added_by` becomes NULL where they added someone. A++ subject merge moves the rows (`server/identity/account-data.js`, `subject-merge.js`).++- When the forum moves to OpenVibe.Space (T10 step 3), `space_moderators` moves with the forum tables.nnew file mode 100644@@@ -0,0 +1,15 @@++-- phase: migrate++-- T10 step 1: per-space moderators (server/forum). A person listed here for a space moderates that space++-- (thread state and status, deletes, settings, categories, members-only, its chat room) as discussion++-- staff do everywhere; the board index and creating spaces stay with staff. Additive only: one new table++-- and its index, nothing existing is touched; the previous release never reads it. People are Network++-- subjects (usr_…), never a local integer; account deletion erases a row and a subject merge moves it++-- (server/identity/account-data.js, subject-merge.js).++CREATE TABLE space_moderators (++ space_id bigint NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,++ subject_id text COLLATE "C" NOT NULL,++ added_by text COLLATE "C",++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ PRIMARY KEY (space_id, subject_id)++);++CREATE INDEX idx_space_moderators_subject ON space_moderators (subject_id);@@@ -30,11 +30,15 @@* 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 }++ * GET /spaces/:space/moderators the space's own moderators → { space, moderators: [{ subject, username, display_name, added_by, added_at }] }++ * PUT /spaces/:space/moderators/:subject add a person (usr_…) — the space's moderators or staff (idempotent)++ * DELETE /spaces/:space/moderators/:subject remove one — the space's moderators or staff (idempotent)* 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** Services write with community.post.create (as X-OV-Subject, or as AI with X-OV-Origin: ai) and-- * moderate with community.comment.moderate. Errors are problem+json. A members-only space or thread++ * moderate with community.comment.moderate. "moderators" on a /spaces/:space route means discussion staff or++ * one of that space's own moderators (people, signed in themselves). Errors are problem+json. A members-only space or thread* refuses readers and writers without the creator's VIP membership with 403 vip.members_only* ({ reason, gate, members_only: { owner, owner_username, join_url } }).*/@@@ -53,6 +57,15 @@ function createSpacesApi({ forum, viewers, limits }) {const write = serviceCap(POST);const writeOrMod = serviceAnyCap([POST, MOD]);const p = (req) => req.params;++ // A space's moderators: a service needs community.comment.moderate (as before); a browser must be discussion++ // staff or one of the space's own moderators (the service methods decide again, with 404 for a missing space).++ const spaceMod = (req, res, next) => {++ const v = req.viewer;++ if (v && v.kind === 'service') return serviceCap(MOD)(req, res, next);++ return forum.canModerate(v, p(req).space)++ .then((ok) => (ok ? next() : contracts.http.sendProblem(res, 403, 'capability.denied', { detail: "Only this space's moderators", ctx: req.ov })))++ .catch(next);++ };// Per-actor limits (server/actor-limits.js): reads take the defaults. The person limits in service.js// (threads 3 a minute and a daily cap, replies 6, uploads 12, votes 60) keep deciding for people;// these cap requests, refused ones included, and every write that has no content limit.@@@ -62,7 +75,7 @@ function createSpacesApi({ forum, viewers, limits }) {router.get('/', run(async (req) => await forum.listSpaces(req.viewer)));router.post('/', serviceCap(MOD), limits('community.space.create', { minute: 10, hour: 60 }), jsonBody, run(async (req) => await forum.createSpace(req.viewer, req.body || {}), 201));-- router.put('/:space/settings', serviceCap(MOD), configure, jsonBody, run(async (req) => await forum.updateSpaceSettings(req.viewer, p(req).space, req.body || {})));++ router.put('/:space/settings', configure, spaceMod, jsonBody, run(async (req) => await forum.updateSpaceSettings(req.viewer, p(req).space, req.body || {})));router.get('/:space', run(async (req) => await forum.space(req.viewer, p(req).space)));router.get('/:space/threads', run(async (req) => await forum.listThreads(req.viewer, p(req).space, req.query)));router.get('/:space/categories', run(async (req) => await forum.categories(req.viewer, p(req).space)));@@@ -73,22 +86,26 @@ function createSpacesApi({ forum, viewers, limits }) {: next()));// An upload is stored in OpenVibe.Media: 20 a minute, above the 12 a person may keep.router.post('/:space/attachments', write, limits('community.attachment.upload', { minute: 20, hour: 200 }), withFile, run(async (req) => await forum.uploadAttachment(req.viewer, p(req).space, req.file), 201));-- router.put('/:space/categories/:category', serviceCap(MOD), configure, jsonBody, run(async (req) => await forum.putCategory(req.viewer, p(req).space, p(req).category, req.body || {})));-- router.delete('/:space/categories/:category', serviceCap(MOD), configure, run(async (req) => await forum.deleteCategory(req.viewer, p(req).space, p(req).category)));++ router.put('/:space/categories/:category', configure, spaceMod, jsonBody, run(async (req) => await forum.putCategory(req.viewer, p(req).space, p(req).category, req.body || {})));++ router.delete('/:space/categories/:category', configure, spaceMod, run(async (req) => await forum.deleteCategory(req.viewer, p(req).space, p(req).category)));router.put('/:space/threads/:slug/category', writeOrMod, editThread, jsonBody, run(async (req) => await forum.setThreadCategory(req.viewer, p(req).space, p(req).slug, req.body || {})));-- router.put('/:space/threads/:slug/status', serviceCap(MOD), configure, jsonBody, run(async (req) => await forum.setThreadStatus(req.viewer, p(req).space, p(req).slug, req.body || {})));++ router.put('/:space/threads/:slug/status', configure, spaceMod, jsonBody, run(async (req) => await forum.setThreadStatus(req.viewer, p(req).space, p(req).slug, req.body || {})));router.post('/:space/threads/:slug/crosspost', write, limits('community.thread.crosspost', { minute: 10, hour: 60 }), jsonBody, run(async (req) => await forum.crosspost(req.viewer, p(req).space, p(req).slug, req.body || {}), 201));router.post('/:space/threads', write, limits('community.thread.create', { minute: 10, hour: 60 }), jsonBody, run(async (req) => await forum.createThread(req.viewer, p(req).space, req.body || {}), 201));router.get('/:space/threads/:slug', run(async (req) => await forum.getThread(req.viewer, p(req).space, p(req).slug, req.query)));router.delete('/:space/threads/:slug', writeOrMod, editThread, run(async (req) => await forum.deleteThread(req.viewer, p(req).space, p(req).slug)));router.post('/:space/threads/:slug/posts', write, limits('community.post.create', { minute: 20, hour: 300 }), jsonBody, run(async (req) => await forum.reply(req.viewer, p(req).space, p(req).slug, req.body || {}), 201));router.post('/:space/threads/:slug/votes', write, limits('community.thread.vote', { minute: 120, hour: 1200 }), jsonBody, run(async (req) => await forum.voteThread(req.viewer, p(req).space, p(req).slug, req.body || {})));-- router.put('/:space/threads/:slug/state', serviceCap(MOD), configure, jsonBody, run(async (req) => await forum.moderateThread(req.viewer, p(req).space, p(req).slug, req.body || {})));++ router.put('/:space/threads/:slug/state', configure, spaceMod, jsonBody, run(async (req) => await forum.moderateThread(req.viewer, p(req).space, p(req).slug, req.body || {})));router.put('/:space/threads/:slug/members-only', writeOrMod, editThread, jsonBody, run(async (req) => await forum.setThreadMembersOnly(req.viewer, p(req).space, p(req).slug, req.body || {})));-- router.put('/:space/members-only', serviceCap(MOD), configure, jsonBody, run(async (req) => await forum.setSpaceMembersOnly(req.viewer, p(req).space, req.body || {})));++ router.put('/:space/members-only', configure, spaceMod, jsonBody, run(async (req) => await 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', configure, jsonBody, run(async (req) => await forum.attachChatRoom(req.viewer, p(req).space, req.body || {}), (out) => (out.created ? 201 : 200)));router.delete('/:space/chat-room', serviceCap(MOD), configure, run(async (req) => await forum.detachChatRoom(req.viewer, p(req).space)));++ // The space's own moderators (people): listed to its readers, added and removed by its moderators or staff.++ router.get('/:space/moderators', run(async (req) => await forum.moderators(req.viewer, p(req).space)));++ router.put('/:space/moderators/:subject', configure, spaceMod, run(async (req) => await forum.addModerator(req.viewer, p(req).space, p(req).subject)));++ router.delete('/:space/moderators/:subject', configure, spaceMod, run(async (req) => await forum.removeModerator(req.viewer, p(req).space, p(req).subject)));router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));return router;@@@ -252,12 +252,27 @@ function createForumRoutes({ forum, viewers, config }) {router.post('/s/:space/settings', withViewer, sameOrigin, form, wrap(async (req, res, next) => {const b = req.body || {};try {-- await forum.updateSpaceSettings(req.viewer, req.params.space, { name: b.name, description: b.description, style: b.style, votes: b.votes === '1', reactions: b.reactions === '1', group: b.group || null });++ // The group (the board index) is staff's; a space's own moderators change the rest.++ await forum.updateSpaceSettings(req.viewer, req.params.space, { name: b.name, description: b.description, style: b.style, votes: b.votes === '1', reactions: b.reactions === '1', group: forum.isModerator(req.viewer) ? b.group || null : undefined });discovery.resetCaches();res.redirect(303, `/s/${encodeURIComponent(req.params.space)}`);} catch (err) { failPage(req, res, err, next); }}));++ // The space's own moderators (no JS): add one by Network subject, remove one; back to the space.++ router.post('/s/:space/moderators', withViewer, sameOrigin, form, wrap(async (req, res, next) => {++ try {++ await forum.addModerator(req.viewer, req.params.space, String((req.body || {}).subject || '').trim().slice(0, 64));++ res.redirect(303, `/s/${encodeURIComponent(req.params.space)}`);++ } catch (err) { failPage(req, res, err, next); }++ }));++ router.post('/s/:space/moderators/remove', withViewer, sameOrigin, form, wrap(async (req, res, next) => {++ try {++ await forum.removeModerator(req.viewer, req.params.space, String((req.body || {}).subject || '').trim().slice(0, 64));++ res.redirect(303, `/s/${encodeURIComponent(req.params.space)}`);++ } 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) => {@@@ -13,6 +13,10 @@* Locked threads take no replies or votes except from moderators. Nobody replies in a thread whose* author blocked them on the network (identity/blocks.js). Authors edit and delete their* own posts; moderators (identity/capabilities.js discussionModerator) any, and pin/lock.++ * A space also has its own moderators (space_moderators, people only): in that space they act as++ * discussion staff do — thread state and status, deletes and edits, settings, categories, members-only,++ * the chat room — and add or remove its moderators. Creating spaces, the board index (groups, a space's++ * place on it), roadmap items and staff spaces stay with discussion staff.* Edits keep every revision in post_versions.** Side effects of a new thread/post: a Pulse item (public spaces) and a Discord relay delivery per@@@ -125,6 +129,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, viconst moderator = (v) => discussionModerator(v);const person = (v) => !!(v && v.subject);++ /** Global discussion staff, or a person (signed in themselves) this space lists as its moderator. */++ const moderates = async (v, space) => moderator(v)++ || (!!space && person(v) && v.kind === 'user' && await store.moderatesSpace(db, space.id, v.subject));// ── a space's chat room (OpenVibe.Chat) ──────────────────const chatRoomRow = async (spaceId) => (spaceId == null ? null : await db.prepare('SELECT * FROM space_chat_rooms WHERE space_id = ?').get(spaceId) || null);@@@ -134,7 +141,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, viattached_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));++ const canManageChatRoom = async (v, space) => await moderates(v, space) || (person(v) && (space.created_by === v.subject || space.members_only_owner === v.subject));// ── access ───────────────────────────────────────────────function canRead(v, space) {@@@ -157,7 +164,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi* the thread. Moderators pass; the owner passes; the rest is VIP's answer (fail closed).*/async function requireMembership(v, space, thread = null) {-- if (moderator(v)) return;++ if (await moderates(v, space)) return;const gates = [];if (space && space.members_only_owner) gates.push({ type: 'space', id: space.slug, owner: space.members_only_owner });if (thread && thread.members_only_owner) gates.push({ type: 'thread', id: String(thread.id), owner: thread.members_only_owner });@@@ -177,12 +184,12 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi}}-- /** A creator subject a viewer may gate something to: themselves, or anyone for moderators. */-- function gateOwner(v, requested, fallbackOwner) {++ /** A creator subject a viewer may gate something to: themselves, or anyone for moderators (mod: of the space). */++ function gateOwner(v, requested, fallbackOwner, mod) {if (requested === null || requested === false || requested === undefined) return null;const owner = requested === true ? fallbackOwner : (typeof requested === 'object' ? requested.owner : requested);if (!isUserSubject(owner)) fail(400, 'members_only.invalid_owner', 'members_only.owner must be the creator\'s Network subject (usr_…)');-- if (!moderator(v) && !(person(v) && owner === v.subject)) fail(403, 'members_only.not_yours', 'You can only make things members-only for your own VIP members');++ if (!mod && !(person(v) && owner === v.subject)) fail(403, 'members_only.not_yours', 'You can only make things members-only for your own VIP members');return owner;}@@@ -309,7 +316,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vifor (const [i, r] of rows.entries()) await set.run(postId, i, r.media_id);}-- function shapePost(p, v, projections, attachments = null, pastes = null) {++ function shapePost(p, v, projections, attachments = null, pastes = null, mod = moderator(v)) {const deleted = !!p.deleted_at;const mine = person(v) && p.author_subject === v.subject;return {@@@ -321,7 +328,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vibody_html: deleted ? null : renderMarkdown(p.body_markdown),revision: p.revision, deleted,created_at: isoTime(p.created_at), updated_at: isoTime(p.updated_at),-- can_edit: !deleted && (mine || moderator(v)),++ can_edit: !deleted && (mine || mod),};}@@@ -356,16 +363,16 @@ function createForumService({ db, network = null, pulse = null, relay = null, viif (relay) await hook(async () => await relay.hideThreads(threadIds, 'made members-only'));}-- async function removeThread(v, thread) {++ async function removeThread(v, thread, space) {const mine = person(v) && thread.author_subject === v.subject;-- if (!mine && !moderator(v)) fail(403, 'thread.not_yours', 'Only the author or a moderator deletes a thread');++ if (!mine && !await moderates(v, space)) fail(403, 'thread.not_yours', 'Only the author or a moderator deletes a thread');await db.tx(async () => {await store.softDeleteThread(db, thread.id);if (!mine) await events.moderationAction(v, 'thread.deleted', { type: 'thread', id: String(thread.id), owner_subject: thread.author_subject || null });});if (pulse) await hook(async () => { await pulse.threadGone(thread.id); for (const p of await db.prepare('SELECT id FROM posts WHERE thread_id = ?').all(thread.id)) await pulse.postGone(p.id); });if (relay) await hook(async () => await relay.enqueueDelete(thread.id, null, mine ? 'deleted by its author' : 'deleted by a moderator'));-- pingThread(await store.getSpaceById(db, thread.space_id), thread, { withSpace: true });++ pingThread(space, thread, { withSpace: true });return { ok: true, id: thread.id, deleted: 'thread' };}@@@ -398,6 +405,23 @@ function createForumService({ db, network = null, pulse = null, relay = null, vireturn { group: g || null, parent: p || null };}++ /** A space's moderators, with who they are. → [{ subject, username, display_name, added_by, added_at }] */++ async function moderatorList(space) {++ const rows = await store.listModerators(db, space.id);++ const projections = await authors.projectionsFor(rows.map((r) => r.subject_id));++ return rows.map((r) => {++ const a = authors.author(r.subject_id, 'user', projections);++ return { subject: r.subject_id, username: a ? a.username : null, display_name: a ? a.display_name : null, added_by: r.added_by || null, added_at: isoTime(r.created_at) };++ });++ }++++ /** The space, when the viewer moderates it: 404 (no such space), else 403. */++ async function moderatedSpace(v, slug, what) {++ const space = await spaceFor(v, slug);++ if (!await moderates(v, space)) fail(403, 'capability.denied', `Only this space's moderators ${what}`);++ return space;++ }++/** Validated space fields from a settings body (only what was sent). */async function spaceFields(body, space) {const f = {};@@@ -429,6 +453,48 @@ function createForumService({ db, network = null, pulse = null, relay = null, viPOSTS_PER_PAGE,isModerator: moderator,++ /** Whether the viewer moderates this space (global discussion staff, or one of its own moderators). */++ async canModerate(v, slug) {++ if (moderator(v)) return true;++ const space = /^[a-z0-9-]{1,40}$/.test(String(slug)) ? await store.getSpace(db, slug) : null;++ return !!space && await moderates(v, space);++ },++++ /** A space's own moderators — whoever can read the space. → { space, moderators } */++ async moderators(v, spaceSlug) {++ const space = await spaceFor(v, spaceSlug);++ await requireMembership(v, space);++ return { space: space.slug, moderators: await moderatorList(space) };++ },++++ /**++ * Add a person as one of the space's moderators — its moderators or discussion staff. A Network user++ * subject (usr_…); not someone who blocked the one adding them, or whom they blocked. Idempotent.++ */++ async addModerator(v, spaceSlug, subject) {++ const space = await moderatedSpace(v, spaceSlug, 'add moderators');++ if (!isUserSubject(subject)) fail(400, 'moderator.invalid_subject', 'Name the person by their Network subject (usr_…)');++ if (person(v) && v.subject !== subject && (await blocks.hasBlocked(db, subject, v.subject) || await blocks.hasBlocked(db, v.subject, subject))) {++ fail(403, 'community.blocked', 'You cannot add this person as a moderator: one of you blocked the other');++ }++ if (!await store.moderatesSpace(db, space.id, subject)) {++ await db.tx(async () => {++ await store.addModerator(db, space.id, subject, person(v) ? v.subject : (v && v.service) || null);++ await events.moderationAction(v, 'space.moderator_added', { type: 'space', id: space.slug }, { details: { subject } });++ });++ }++ return { space: space.slug, moderators: await moderatorList(space) };++ },++++ /** Remove one of the space's moderators (themselves included) — its moderators or discussion staff. Idempotent. */++ async removeModerator(v, spaceSlug, subject) {++ const space = await moderatedSpace(v, spaceSlug, 'remove moderators');++ if (!isUserSubject(subject)) fail(400, 'moderator.invalid_subject', 'Name the person by their Network subject (usr_…)');++ await db.tx(async () => {++ if (await store.removeModerator(db, space.id, subject)) await events.moderationAction(v, 'space.moderator_removed', { type: 'space', id: space.slug }, { details: { subject } });++ });++ return { space: space.slug, moderators: await moderatorList(space) };++ },/** The board index's groups (for the settings and new-space forms). */groups: async () => (await store.listGroups(db)).map((g) => ({ slug: g.slug, name: g.name })),@@@ -483,8 +549,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi* parent?: slug|null, position?, kind?: discussion|request|roadmap }*/async updateSpaceSettings(v, spaceSlug, body = {}) {-- if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators change a space');-- const space = await spaceFor(v, spaceSlug);++ const space = await moderatedSpace(v, spaceSlug, 'change it');++ // Where a space sits on the board index (its group, parent board and position) is the board index's: staff.++ if (!moderator(v) && ['group', 'parent', 'position'].some((k) => body[k] !== undefined)) fail(403, 'capability.denied', 'Only moderators of the board index move a space on it');const fields = await spaceFields(body, space);const next = await store.updateSpace(db, space.id, fields);const row = (await store.listSpaces(db, ['public', 'members', 'staff'])).find((r) => r.id === next.id) || next;@@@ -524,7 +591,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, viasync attachChatRoom(v, spaceSlug, body = {}) {const space = await 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 (!await 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);@@@ -547,7 +614,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, viasync detachChatRoom(v, spaceSlug) {const space = await 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');++ if (!await canManageChatRoom(v, space)) fail(403, 'capability.denied', 'Only the space\'s owner or staff detach its chat room');const before = await chatRoomRow(space.id);if (!before) return { detached: false, chat: null };await db.prepare('DELETE FROM space_chat_rooms WHERE space_id = ?').run(space.id);@@@ -573,11 +640,13 @@ function createForumService({ db, network = null, pulse = null, relay = null, viconst { rows, total } = await store.listThreads(db, space.id, { sort, limit: perPage, offset: (page - 1) * perPage, now: q.now || new Date(), categoryId: category ? category.id : null, status: q.status || null });const index = await this.listSpaces(v);const self = index.spaces.find((x) => x.slug === space.slug) || {};++ const mod = await moderates(v, space);return {space: { ...await 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: (await 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), can_manage_chat_room: canManageChatRoom(v, space) },++ viewer: { can_start: space.thread_kind !== 'roadmap' || moderator(v), can_moderate: mod, signed_in: person(v), can_manage_chat_room: await canManageChatRoom(v, space) },++ moderators: mod ? await moderatorList(space) : undefined,children: index.spaces.filter((c) => c.parent && c.parent.slug === space.slug),groups: moderator(v) ? (await store.listGroups(db)).map((g) => ({ slug: g.slug, name: g.name })) : undefined,threads: await shapeThreads(rows, () => space, v),@@@ -623,7 +692,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, viconst { post, thread, space } = await postFor(v, postId);if (space.reactions === 0) fail(403, 'space.reactions_off', 'Ratings are off in this space');await requireMembership(v, space, thread);-- if (thread.locked && !moderator(v)) fail(403, 'thread.locked', 'This thread is locked');++ if (thread.locked && !await moderates(v, space)) fail(403, 'thread.locked', 'This thread is locked');if (post.author_subject === v.subject) fail(403, 'reaction.own_post', 'You cannot rate your own post');const reaction = body.reaction == null || body.reaction === '' ? null : String(body.reaction);if (reaction && !reactions.BY_KEY.has(reaction)) fail(400, 'reaction.invalid', `reaction is one of ${reactions.REACTIONS.map((r) => r.key).join(', ')}`);@@@ -694,8 +763,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi/** Create or change a category { slug, name, description?, position? } — moderators. */async putCategory(v, spaceSlug, slug, body = {}) {-- if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators manage categories');-- const space = await spaceFor(v, spaceSlug);++ const space = await moderatedSpace(v, spaceSlug, 'manage categories');if (!CATEGORY_SLUG.test(String(slug || ''))) fail(400, 'category.invalid_slug', 'A category slug is 1 to 40 lowercase letters, digits and dashes');const name = cleanTitle(body.name);if (name.length < 2 || name.length > 40) fail(400, 'category.invalid_name', 'Category names are 2 to 40 characters');@@@ -708,8 +776,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi/** Delete a category — moderators. Its threads stay, without a category. */async deleteCategory(v, spaceSlug, slug) {-- if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators manage categories');-- const space = await spaceFor(v, spaceSlug);++ const space = await moderatedSpace(v, spaceSlug, 'manage categories');if (!await store.deleteCategory(db, space.id, slug)) fail(404, 'category.not_found', 'No such category in this space');pingSpace(space);return { ok: true };@@@ -718,7 +785,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi/** Move a thread to a category { category: slug | null } — its author or a moderator. */async setThreadCategory(v, spaceSlug, threadSlug, body = {}) {const { space, thread } = await threadFor(v, spaceSlug, threadSlug);-- if (!(person(v) && thread.author_subject === v.subject) && !moderator(v)) fail(403, 'thread.not_yours', 'Only the author or a moderator changes the category');++ if (!(person(v) && thread.author_subject === v.subject) && !await moderates(v, space)) fail(403, 'thread.not_yours', 'Only the author or a moderator changes the category');await requireMembership(v, space, thread);const category = body.category ? await store.getCategory(db, space.id, body.category) : null;if (body.category && !category) fail(404, 'category.not_found', 'No such category in this space');@@@ -730,8 +797,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi/** A request's or roadmap item's status { status } — moderators. */async setThreadStatus(v, spaceSlug, threadSlug, body = {}) {-- if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators change a status');const { space, thread } = await threadFor(v, spaceSlug, threadSlug);++ if (!await moderates(v, space)) fail(403, 'capability.denied', 'Only moderators change a status');const statuses = STATUSES[thread.kind] || [];if (!statuses.includes(body.status)) fail(400, 'thread.invalid_status', statuses.length ? `status is one of ${statuses.join(', ')}` : 'This thread has no status');const next = await store.setThreadStatus(db, thread.id, body.status);@@@ -756,16 +823,17 @@ function createForumService({ db, network = null, pulse = null, relay = null, viconst stats = await store.authorStats(db, authorSubjects);const received = space.reactions !== 0 ? await reactions.receivedBy(db, authorSubjects) : new Map();const raterProjections = await authors.projectionsFor([...rated.values()].flatMap((l) => l.flatMap((e) => e.raters)));++ const mod = await moderates(v, space);return {space: { ...await shapeSpace(space, await membersOnly(space.members_only_owner, projections)), ...await placeOf(space) },thread: await shapeThread(thread, space, v, projections, votes),posts: rows.map((p) => {-- const sp = shapePost(p, v, projections, att, pasted);++ const sp = shapePost(p, v, projections, att, pasted, mod);const st = p.author_subject ? stats.get(p.author_subject) : null;sp.author_stats = st ? { posts: st.posts, first_post_at: isoTime(st.first_post_at), ratings: received.get(p.author_subject) || [] } : null;sp.reactions = p.deleted_at ? [] : (rated.get(p.id) || []).map((e) => ({ key: e.key, emoji: e.emoji, label: e.label, group: e.group, count: e.count, mine: e.mine,raters: e.raters.map((sub) => { const pr = raterProjections.get(sub); return pr ? (pr.display_name || pr.username) : null; }).filter(Boolean) }));-- sp.can_react = space.reactions !== 0 && !p.deleted_at && person(v) && p.author_subject !== v.subject && (!thread.locked || moderator(v));++ sp.can_react = space.reactions !== 0 && !p.deleted_at && person(v) && p.author_subject !== v.subject && (!thread.locked || mod);return sp;}),reactions: space.reactions !== 0 ? reactions.REACTIONS : [],@@@ -775,11 +843,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, vipage, per_page: POSTS_PER_PAGE, pages: Math.max(Math.ceil(total / POSTS_PER_PAGE), 1), total,viewer: {signed_in: person(v),-- can_reply: (person(v) || (v && v.origin === 'ai' && v.kind === 'service')) && (!thread.locked || moderator(v)) && (space.visibility !== 'staff' || moderator(v)),++ can_reply: (person(v) || (v && v.origin === 'ai' && v.kind === 'service')) && (!thread.locked || mod) && (space.visibility !== 'staff' || moderator(v)),can_vote: person(v) && !thread.locked && space.votes !== 0,-- can_moderate: moderator(v),-- can_delete: moderator(v) || (person(v) && thread.author_subject === v.subject),-- can_gate: moderator(v) || (person(v) && thread.author_subject === v.subject),++ can_moderate: mod,++ can_delete: mod || (person(v) && thread.author_subject === v.subject),++ can_gate: mod || (person(v) && thread.author_subject === v.subject),},};},@@@ -793,7 +861,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, viconst w = writer(v);mayPostIn(v, space);await requireMembership(v, space);-- const gate = gateOwner(v, body.members_only, w.author);++ const gate = gateOwner(v, body.members_only, w.author, await moderates(v, space));const kind = space.thread_kind || 'discussion';if (kind === 'roadmap' && !moderator(v)) fail(403, 'space.staff_threads', 'Roadmap items are added by staff. Reply to one, or suggest something in Feedback');const category = body.category ? await store.getCategory(db, space.id, body.category) : null;@@@ -825,7 +893,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, viconst w = writer(v);mayPostIn(v, space);await requireMembership(v, space, thread);-- if (thread.locked && !moderator(v)) fail(403, 'thread.locked', 'This thread is locked');++ if (thread.locked && !await moderates(v, space)) fail(403, 'thread.locked', 'This thread is locked');// Platform blocks: nobody replies in a thread whose author blocked them.await blocks.refuseIfBlocked(db, [thread.author_subject], w.author, 'reply in this thread');const text = cleanBody(body.body != null ? body.body : body.body_markdown);@@@ -851,20 +919,21 @@ function createForumService({ db, network = null, pulse = null, relay = null, viconst { post, thread, space } = await postFor(v, postId);await requireMembership(v, space, thread);const mine = person(v) && post.author_subject === v.subject;-- if (!mine && !moderator(v)) fail(403, 'post.not_yours', 'Only the author or a moderator edits a post');-- if (thread.locked && !moderator(v)) fail(403, 'thread.locked', 'This thread is locked');++ const mod = await moderates(v, space);++ if (!mine && !mod) fail(403, 'post.not_yours', 'Only the author or a moderator edits a post');++ if (thread.locked && !mod) fail(403, 'thread.locked', 'This thread is locked');const next = await store.editPost(db, post.id, cleanBody(body.body != null ? body.body : body.body_markdown), v.subject || v.service || null);if (relay && next && next.revision !== post.revision) await hook(async () => await relay.enqueueEdit(next));const projections = await authors.projectionsFor([next.author_subject]);pingThread(space, thread);-- return { post: shapePost(next, v, projections) };++ return { post: shapePost(next, v, projections, null, null, mod) };},/** Edit history — the author or a moderator. */async postVersions(v, postId) {const { post, thread, space } = await postFor(v, postId);await requireMembership(v, space, thread);-- if (!(person(v) && post.author_subject === v.subject) && !moderator(v)) fail(403, 'post.not_yours', 'Only the author or a moderator sees the history');++ if (!(person(v) && post.author_subject === v.subject) && !await moderates(v, space)) fail(403, 'post.not_yours', 'Only the author or a moderator sees the history');const list = (await store.listPostVersions(db, post.id)).map((r) => ({ ...r, created_at: isoTime(r.created_at) }));return { revision: post.revision, versions: list.length ? list : [{ revision: post.revision, body_markdown: post.body_markdown, edited_by: post.author_subject, created_at: isoTime(post.created_at) }] };},@@@ -872,8 +941,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi/** Delete a post — the author or a moderator. Deleting the opening post deletes the thread. */async deletePost(v, postId) {const { post, thread, space } = await postFor(v, postId);-- if (!(person(v) && post.author_subject === v.subject) && !moderator(v)) fail(403, 'post.not_yours', 'Only the author or a moderator deletes a post');-- if (post.is_opening) return await removeThread(v, thread);++ if (!(person(v) && post.author_subject === v.subject) && !await moderates(v, space)) fail(403, 'post.not_yours', 'Only the author or a moderator deletes a post');++ if (post.is_opening) return await removeThread(v, thread, space);await db.tx(async () => {await store.softDeletePost(db, post.id);if (!(person(v) && post.author_subject === v.subject)) await events.moderationAction(v, 'post.deleted', { type: 'post', id: String(post.id), owner_subject: post.author_subject || null }, { details: { thread: String(thread.id) } });@@@ -886,8 +955,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi/** Delete a thread — its author or a moderator. */async deleteThread(v, spaceSlug, threadSlug) {-- const { thread } = await threadFor(v, spaceSlug, threadSlug);-- return await removeThread(v, thread);++ const { space, thread } = await threadFor(v, spaceSlug, threadSlug);++ return await removeThread(v, thread, space);},/** Vote { value: 1 | -1 | 0 } → { score, upvotes, downvotes, my_vote } */@@@ -907,8 +976,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi/** Pin / lock { pinned?, locked? } — moderators. */async moderateThread(v, spaceSlug, threadSlug, body = {}) {-- if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators pin or lock threads');const { space, thread } = await threadFor(v, spaceSlug, threadSlug);++ if (!await moderates(v, space)) fail(403, 'capability.denied', 'Only moderators pin or lock threads');const flags = {};if (body.pinned !== undefined) flags.pinned = !!body.pinned;if (body.locked !== undefined) flags.locked = !!body.locked;@@@ -931,11 +1000,12 @@ function createForumService({ db, network = null, pulse = null, relay = null, viasync setThreadMembersOnly(v, spaceSlug, threadSlug, body = {}) {const { space, thread } = await threadFor(v, spaceSlug, threadSlug);const mine = person(v) && thread.author_subject === v.subject;-- if (!mine && !moderator(v)) fail(403, 'thread.not_yours', 'Only the author or a moderator makes a thread members-only');++ const mod = await moderates(v, space);++ if (!mine && !mod) fail(403, 'thread.not_yours', 'Only the author or a moderator makes a thread members-only');await requireMembership(v, space, null);const requested = body.members_only !== undefined ? body.members_only : body.owner;-- const owner = gateOwner(v, requested === undefined ? null : requested, thread.author_subject);-- if (!owner && thread.members_only_owner && !moderator(v) && thread.members_only_owner !== v.subject) fail(403, 'members_only.not_yours', 'Only the creator it is gated to, or a moderator, opens it again');++ const owner = gateOwner(v, requested === undefined ? null : requested, thread.author_subject, mod);++ if (!owner && thread.members_only_owner && !mod && thread.members_only_owner !== v.subject) fail(403, 'members_only.not_yours', 'Only the creator it is gated to, or a moderator, opens it again');const next = await store.setThreadMembersOnly(db, thread.id, owner);if (owner) await hideGated([thread.id]);const projections = await authors.projectionsFor([next.author_subject, next.members_only_owner]);@@@ -946,10 +1016,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi/** Members-only for a whole space { owner: 'usr_…' | null } — moderators. */async setSpaceMembersOnly(v, spaceSlug, body = {}) {-- if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators make a space members-only');-- const space = await spaceFor(v, spaceSlug);++ const space = await moderatedSpace(v, spaceSlug, 'make it members-only');const requested = body.members_only !== undefined ? body.members_only : body.owner;-- const owner = gateOwner(v, requested === undefined ? null : requested, null);++ const owner = gateOwner(v, requested === undefined ? null : requested, null, true);const next = await store.setSpaceMembersOnly(db, space.id, owner);if (owner) await hideGated((await db.prepare('SELECT id FROM threads WHERE space_id = ?').all(space.id)).map((r) => r.id));pingSpace(space);@@@ -297,6 +297,23 @@ async function softDeletePost(db, id) {return 1;});}++// ── per-space moderators ─────────────────────────────────────++async function listModerators(db, spaceId) {++ return await db.prepare('SELECT subject_id, added_by, created_at FROM space_moderators WHERE space_id = ? ORDER BY created_at, subject_id').all(spaceId);++}++++async function moderatesSpace(db, spaceId, subject) {++ if (!subject) return false;++ return !!await db.prepare('SELECT 1 FROM space_moderators WHERE space_id = ? AND subject_id = ?').get(spaceId, subject);++}++++async function addModerator(db, spaceId, subject, addedBy) {++ await db.prepare('INSERT INTO space_moderators (space_id, subject_id, added_by) VALUES (?, ?, ?) ON CONFLICT (space_id, subject_id) DO NOTHING').run(spaceId, subject, addedBy || null);++}++++async function removeModerator(db, spaceId, subject) {++ return (await db.prepare('DELETE FROM space_moderators WHERE space_id = ? AND subject_id = ?').run(spaceId, subject)).changes > 0;++}async function countPostsSince(db, subject, sinceSql) {return (await db.prepare("SELECT COUNT(*) AS c FROM posts WHERE author_subject = ? AND created_at > datetime('now', ?)").get(subject, sinceSql)).c;@@@ -308,5 +325,6 @@ module.exports = {getThread, getThreadBySlug, createThread, listThreads, recentThreads, setThreadFlags, setThreadMembersOnly, setSpaceMembersOnly, softDeleteThread, countThreadsSince,listCategories, getCategory, getCategoryById, upsertCategory, deleteCategory, setThreadCategory, setThreadStatus, getThreadByKey,lastPosts, listGroups, updateSpace, createSpace, authorStats, bumpViews,++ listModerators, moderatesSpace, addModerator, removeModerator,getPost, addPost, listPosts, editPost, listPostVersions, softDeletePost, countPostsSince,};@@@ -6,7 +6,8 @@** network.account.export_requested Community's part (POST /internal/account-exports/:id/parts with a service token):* pastes (with their content), paste comments, comments, threads, posts,-- * attachments, likes, votes and reactions, game progress, blocks and activity.++ * attachments, likes, votes and reactions, game progress, blocks, activity and++ * the spaces they moderate.* network.account.deleted what the subject (and the accounts merged into it) wrote goes. An item with* someone else's reply anywhere beneath it stays as an authorless tombstone* ("[deleted]"), so the replies keep their place:@@@ -14,8 +15,8 @@* - a comment or paste comment others answered;* - a thread others posted in, and its opening post.* Likes, votes and reactions go and cached counts are recomputed. Game progress,-- * blocks both ways, activity items and the cached profile go. Spaces the person-- * created stay without them. Community then confirms with counts.++ * blocks both ways, activity items, the cached profile and their place as a space's++ * moderator go. Spaces the person created, and moderators they added, stay without them. Community then confirms with counts.*/const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;const EXPORT_RE = /^exp_[0-9A-HJKMNP-TV-Z]{26}$/;@@@ -37,6 +38,7 @@ const EXPORTS = [['paste_likes.json', 'paste_likes', 'subject_id'], ['comment_votes.json', 'comment_votes', 'subject_id'], ['thread_votes.json', 'thread_votes', 'subject_id'],['post_reactions.json', 'post_reactions', 'subject_id'], ['game_progress.json', 'game_progress', 'subject_id'], ['blocks.json', 'network_blocks', 'blocker_subject'],['activity.json', 'pulse_items', 'actor_subject'], ['submissions.json', 'submissions', 'author_subject'],++ ['space_moderators.json', 'space_moderators', 'subject_id'],];async function exportPart(db, subject) {@@@ -138,12 +140,13 @@ async function erase(db, subjects, { now = new Date().toISOString() } = {}) {}for (const [table, where, key] of [['game_progress', `subject_id IN ${S}`, 'game_progress'], ['pulse_items', `actor_subject IN ${S}`, 'activity'],['subject_projection', `subject_id IN ${S}`, 'profile'], ['profile_module_pushes', `subject_id IN ${S}`, 'profile'],-- ['network_blocks', `blocker_subject IN ${S} OR blocked_subject IN ${S}`, 'blocks']]) {++ ['network_blocks', `blocker_subject IN ${S} OR blocked_subject IN ${S}`, 'blocks'], ['space_moderators', `subject_id IN ${S}`, 'space_moderators']]) {if (!await hasTable(db, table)) continue;const params = where.includes(' OR ') ? [...subjects, ...subjects] : subjects;add(erased, key, (await db.prepare(`DELETE FROM ${table} WHERE ${where}`).run(...params)).changes);}for (const table of ['spaces', 'comment_threads']) if (await hasColumn(db, table, 'created_by')) await db.prepare(`UPDATE ${table} SET created_by = NULL WHERE created_by IN ${S}`).run(...subjects);++ if (await hasColumn(db, 'space_moderators', 'added_by')) await db.prepare(`UPDATE space_moderators SET added_by = NULL WHERE added_by IN ${S}`).run(...subjects);// The cached counts and scores follow the rows that stay.for (const [table, items] of recount) {for (const i of items) {@@@ -6,6 +6,7 @@* one per person per item paste likes, comment and thread votes, post reactions: the survivor's stays where both* had one (the other is dropped), then the item's cached count or score is recomputed from its rows* game progress (one row per person) and the block projection: the survivor's stays; blocking oneself is dropped++ * space moderators the folded-in account's spaces become the survivor's (one row per space and person)* Idempotent: the pulse consumer's inbox applies an event once, and a second run finds nothing under `from`.*/const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;@@@ -14,7 +15,7 @@ const MERGE_RE = /^mrg_[0-9A-HJKMNP-TV-Z]{26}$/;const AUTHORSHIP = [['pastes', 'owner_subject'], ['paste_comments', 'author_subject'], ['comments', 'author_subject'], ['threads', 'author_subject'],['posts', 'author_subject'], ['attachments', 'owner_subject'], ['pulse_items', 'actor_subject'],-- ['submissions', 'author_subject'], ['submissions', 'reviewer_subject'],++ ['submissions', 'author_subject'], ['submissions', 'reviewer_subject'], ['space_moderators', 'added_by'],];// Per person per item: [table, item column, recompute(db, itemId)]const PER_ITEM = [@@@ -57,6 +58,10 @@ async function apply(db, { from, into, merge_id: mergeId }, { log = console } =if (await db.prepare('SELECT 1 FROM game_progress WHERE subject_id = ?').get(into)) await db.prepare('DELETE FROM game_progress WHERE subject_id = ?').run(from);else await db.prepare('UPDATE game_progress SET subject_id = ? WHERE subject_id = ?').run(into, from);}++ if (await hasColumn(db, 'space_moderators', 'subject_id')) {++ await db.prepare('DELETE FROM space_moderators WHERE subject_id = ? AND space_id IN (SELECT space_id FROM space_moderators WHERE subject_id = ?)').run(from, into);++ c.moved += (await db.prepare('UPDATE space_moderators SET subject_id = ? WHERE subject_id = ?').run(into, from)).changes;++ }if (await hasColumn(db, 'network_blocks', 'blocker_subject')) {for (const [col, other] of [['blocker_subject', 'blocked_subject'], ['blocked_subject', 'blocker_subject']]) {for (const b of await db.prepare(`SELECT ${other} AS o FROM network_blocks WHERE ${col} = ?`).all(from)) {@@@ -3,11 +3,10 @@* IDOR: nobody acts on someone else's things by swapping ids (roadmap WS-R task 5).** alex owns a public paste, a private one, a screenshot, comments, a thread with a reply, a typed comment,-- * an uploaded image and a space (as its creator; spaces have no per-space moderators, so a space's owner-- * is what "moderator of X" means here: cora owns another).-- *-- * TODO(T10 step 1): migrations/0005_space_moderators.sql adds per-space moderators; revisit the-- * "no per-space moderators" assumption above (and this file's space expectations) when it lands.++ * an uploaded image and a space (as its creator: creating a space does not make you its moderator, so++ * nobody here is listed in space_moderators and a space's owner is what "moderator of X" means here:++ * cora owns another). Per-space moderators, and their refusals in other spaces, are in++ * test/space-moderators.test.js.** sam tries each of alex's ids as himself, and so do first-party services forwarding for him* (svc:live, svc:tools, each with the write grants they hold)nnew file mode 100644@@@ -0,0 +1,163 @@++'use strict';++/**++ * Per-space moderators (T10 step 1, migrations/0005_space_moderators.sql): a person listed for a space moderates++ * that space — thread state, deletes, settings, categories, its moderators — and nothing anywhere else; discussion++ * staff still moderate everywhere; creating spaces and the board index stay with staff. Adding is refused for a++ * non-moderator and across a block; account deletion erases the row and a subject merge moves it.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++++(async () => {++ const t = await boot({++ appOpts: { forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } },++ });++ const net = t.network;++ const mk = (id, username, role = 'user') => {++ const u = net.addUser({ network_user_id: id, username, display_name: username });++ return { ...u, jwt: net.sign({ id, subject_id: u.subject_id, username, display_name: username, role }) };++ };++ const alex = mk(31, 'alex'); // moderates general++ const sam = mk(32, 'sam'); // nobody in particular++ const bob = mk(33, 'bob'); // writes the threads++ const carol = mk(34, 'carol'); // added by alex++ const dave = mk(35, 'dave'); // blocked alex++ const boss = mk(36, 'boss', 'global_mod');++ const call = (path, { method = 'GET', who, json } = {}) => t.get(path, {++ method, headers: json !== undefined ? { 'content-type': 'application/json' } : {}, body: json !== undefined ? JSON.stringify(json) : undefined, cookies: who ? [`ov_token=${who.jwt}`] : [],++ });++ const form = (path, who, fields) => t.get(path, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(fields).toString(), cookies: [`ov_token=${who.jwt}`] });++ const thread = async (space) => {++ const r = await call(`/api/v1/spaces/${space}/threads`, { method: 'POST', who: bob, json: { title: `A thread in ${space}`, body: 'hello there' } });++ assert.strictEqual(r.status, 201, r.text);++ return r.json().thread.slug;++ };++ const mods = async (space) => (await call(`/api/v1/spaces/${space}/moderators`)).json().moderators.map((m) => m.subject);++++ await check('(d) adding a moderator: refused for a non-moderator, works for staff and then for the space\'s moderator', async () => {++ let r = await call(`/api/v1/spaces/general/moderators/${alex.subject_id}`, { method: 'PUT', who: sam });++ assert.strictEqual(r.status, 403, r.text);++ assert.strictEqual(r.json().code, 'capability.denied');++ assert.strictEqual((await call(`/api/v1/spaces/general/moderators/${sam.subject_id}`, { method: 'PUT', who: sam })).status, 403, 'nobody adds themselves');++ assert.strictEqual((await call(`/api/v1/spaces/general/moderators/${alex.subject_id}`, { method: 'PUT' })).status, 403, 'signed out');++ r = await call(`/api/v1/spaces/general/moderators/${alex.subject_id}`, { method: 'PUT', who: boss });++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual(r.json().moderators.map((m) => [m.subject, m.username, m.added_by]), [[alex.subject_id, 'alex', boss.subject_id]]);++ r = await call(`/api/v1/spaces/general/moderators/${carol.subject_id}`, { method: 'PUT', who: alex });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual((await call(`/api/v1/spaces/general/moderators/${carol.subject_id}`, { method: 'PUT', who: alex })).status, 200, 'idempotent');++ assert.deepStrictEqual(await mods('general'), [alex.subject_id, carol.subject_id]);++ assert.strictEqual((await call('/api/v1/spaces/general/moderators/not-a-subject', { method: 'PUT', who: alex })).status, 400);++ assert.strictEqual((await call(`/api/v1/spaces/no-such-space/moderators/${carol.subject_id}`, { method: 'PUT', who: boss })).status, 404);++ assert.strictEqual((await call(`/api/v1/spaces/showcase/moderators/${sam.subject_id}`, { method: 'PUT', who: alex })).status, 403, 'not in another space');++ assert.deepStrictEqual(await mods('showcase'), []);++ });++++ await check('a person who blocked the one adding them (or was blocked by them) cannot be added', async () => {++ await t.db.prepare('INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, 1, 1, ?)').run(dave.subject_id, alex.subject_id, Date.now());++ const r = await call(`/api/v1/spaces/general/moderators/${dave.subject_id}`, { method: 'PUT', who: alex });++ assert.strictEqual(r.status, 403, r.text);++ assert.strictEqual(r.json().code, 'community.blocked');++ assert.ok(!(await mods('general')).includes(dave.subject_id));++ });++++ await check('(a) a moderator of general locks, sets the state of and deletes a thread there; can_moderate shows it', async () => {++ const slug = await thread('general');++ let r = await call(`/api/v1/spaces/general/threads/${slug}/state`, { method: 'PUT', who: alex, json: { locked: true, pinned: true } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().thread.locked, true);++ const page = (await call(`/api/v1/spaces/general/threads/${slug}`, { who: alex })).json();++ assert.strictEqual(page.viewer.can_moderate, true);++ assert.strictEqual(page.viewer.can_delete, true);++ assert.strictEqual(page.posts[0].can_edit, true);++ assert.strictEqual((await call(`/api/v1/spaces/general/threads/${slug}/posts`, { method: 'POST', who: alex, json: { body: 'moderators reply in a locked thread' } })).status, 201);++ assert.strictEqual((await call(`/api/v1/spaces/general/threads/${slug}/posts`, { method: 'POST', who: sam, json: { body: 'others do not' } })).status, 403);++ assert.strictEqual((await call('/api/v1/spaces/general/threads', { who: alex })).json().viewer.can_moderate, true);++ assert.strictEqual((await call('/api/v1/spaces/general/threads', { who: sam })).json().viewer.can_moderate, false);++ r = await call(`/api/v1/spaces/general/threads/${slug}`, { method: 'DELETE', who: alex });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual((await call(`/api/v1/spaces/general/threads/${slug}`, { who: alex })).status, 404);++ });++++ await check('(b) the same moderator gets 403 in showcase', async () => {++ const slug = await thread('showcase');++ assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${slug}/state`, { method: 'PUT', who: alex, json: { locked: true } })).status, 403);++ assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${slug}`, { method: 'DELETE', who: alex })).status, 403);++ assert.strictEqual((await call('/api/v1/spaces/showcase/settings', { method: 'PUT', who: alex, json: { name: 'Mine now' } })).status, 403);++ assert.strictEqual((await call('/api/v1/spaces/showcase/categories/clips', { method: 'PUT', who: alex, json: { name: 'Clips' } })).status, 403);++ assert.strictEqual((await call('/api/v1/spaces/showcase/members-only', { method: 'PUT', who: alex, json: { owner: alex.subject_id } })).status, 403);++ assert.strictEqual((await call('/api/v1/spaces/showcase/threads', { who: alex })).json().viewer.can_moderate, false);++ const page = (await call(`/api/v1/spaces/showcase/threads/${slug}`, { who: alex })).json();++ assert.strictEqual(page.viewer.can_moderate, false);++ assert.strictEqual(page.viewer.can_delete, false);++ assert.strictEqual(page.posts[0].can_edit, false);++ assert.strictEqual((await form(`/s/showcase/t/${slug}/state`, alex, { locked: '1' })).status, 403, 'the no-JS form too');++ assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${slug}`, { who: sam })).json().thread.locked, false);++ });++++ await check('(c) a global moderator still passes in both spaces', async () => {++ for (const space of ['general', 'showcase']) {++ const slug = await thread(space);++ assert.strictEqual((await call(`/api/v1/spaces/${space}/threads/${slug}/state`, { method: 'PUT', who: boss, json: { locked: true } })).status, 200, space);++ assert.strictEqual((await call(`/api/v1/spaces/${space}/threads/${slug}`, { method: 'DELETE', who: boss })).status, 200, space);++ }++ });++++ await check('settings and categories of their own space; never its place on the board index', async () => {++ let r = await call('/api/v1/spaces/general/settings', { method: 'PUT', who: alex, json: { description: 'Run by its moderators' } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().space.description, 'Run by its moderators');++ assert.strictEqual((await call('/api/v1/spaces/general/settings', { method: 'PUT', who: alex, json: { group: null } })).status, 403);++ assert.strictEqual((await call('/api/v1/spaces/general/settings', { method: 'PUT', who: alex, json: { parent: 'showcase' } })).status, 403);++ assert.strictEqual((await call('/api/v1/spaces/general/categories/help', { method: 'PUT', who: alex, json: { name: 'Help' } })).status, 200);++ assert.strictEqual((await call('/api/v1/spaces/general/categories/help', { method: 'DELETE', who: alex })).status, 200);++ const before = (await call('/api/v1/spaces/general')).json().space.group;++ r = await form('/s/general/settings', alex, { name: 'General', description: 'From the form', style: 'forum', reactions: '1', group: '' });++ assert.strictEqual(r.status, 303, r.text);++ assert.deepStrictEqual((await call('/api/v1/spaces/general')).json().space.group, before, 'the form leaves the group alone');++ });++++ await check('(e) a space moderator cannot create a space or change the board index', async () => {++ assert.strictEqual((await call('/api/v1/spaces', { method: 'POST', who: alex, json: { slug: 'alex-space', name: 'Alex space' } })).status, 403);++ assert.strictEqual((await form('/s/new-space', alex, { slug: 'alex-space', name: 'Alex space' })).status, 403);++ assert.strictEqual((await call('/api/v1/space-groups/alex-group', { method: 'PUT', who: alex, json: { name: 'Alex group' } })).status, 403);++ assert.strictEqual((await call('/api/v1/spaces/alex-space')).status, 404);++ });++++ await check('no-JS forms add and remove; a moderator removes another (or themselves)', async () => {++ let r = await form('/s/general/moderators', alex, { subject: sam.subject_id });++ assert.strictEqual(r.status, 303, r.text);++ assert.ok((await mods('general')).includes(sam.subject_id));++ assert.strictEqual((await form('/s/showcase/moderators', alex, { subject: sam.subject_id })).status, 403);++ r = await form('/s/general/moderators/remove', carol, { subject: sam.subject_id });++ assert.strictEqual(r.status, 303, r.text);++ assert.strictEqual((await call(`/api/v1/spaces/general/moderators/${sam.subject_id}`, { method: 'DELETE', who: sam })).status, 403, 'no longer one');++ r = await call(`/api/v1/spaces/general/moderators/${carol.subject_id}`, { method: 'DELETE', who: carol });++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual(await mods('general'), [alex.subject_id]);++ });++++ await check('(f) account deletion erases the row; a subject merge moves it', async () => {++ const accountData = require('../server/identity/account-data');++ const merge = require('../server/identity/subject-merge');++ const ids = async (subject) => (await t.db.prepare('SELECT space_id FROM space_moderators WHERE subject_id = ? ORDER BY space_id').all(subject)).map((r) => Number(r.space_id));++ await call(`/api/v1/spaces/showcase/moderators/${carol.subject_id}`, { method: 'PUT', who: boss });++ await call(`/api/v1/spaces/general/moderators/${carol.subject_id}`, { method: 'PUT', who: boss });++ await call(`/api/v1/spaces/showcase/moderators/${bob.subject_id}`, { method: 'PUT', who: boss });++ // carol folds into bob: general moves over, showcase (both had it) stays bob's alone.++ await merge.apply(t.db, { from: carol.subject_id, into: bob.subject_id, merge_id: 'mrg_01JAB2C3D4E5F6G7H8J9K0MNP4' }, { log: { log() {} } });++ assert.deepStrictEqual(await ids(carol.subject_id), []);++ assert.deepStrictEqual(await ids(bob.subject_id), [1, 3]);++ assert.strictEqual((await call('/api/v1/spaces/general/threads', { who: bob })).json().viewer.can_moderate, true);++ // alex's account is deleted: the row goes, and the moderators alex added stay without them.++ await call(`/api/v1/spaces/general/moderators/${sam.subject_id}`, { method: 'PUT', who: alex });++ const out = await accountData.erase(t.db, [alex.subject_id]);++ assert.strictEqual(out.erased.space_moderators, 1);++ assert.deepStrictEqual(await ids(alex.subject_id), []);++ assert.strictEqual((await t.db.prepare('SELECT added_by FROM space_moderators WHERE subject_id = ? AND space_id = 1').get(sam.subject_id)).added_by, null);++ assert.strictEqual((await call('/api/v1/spaces/general/threads', { who: alex })).json().viewer.can_moderate, false);++ });++++ await t.close();++ done();++})();
@@ -327,6 +327,16 @@ member for now), `staff` (moderators only; looks missing to everyone else).
@@ -327,6 +327,16 @@ member for now), `staff` (moderators only; looks missing to everyone else).
deletes are soft (tombstones keep the numbering; deleting the opening post deletes the
deletes are soft (tombstones keep the numbering; deleting the opening post deletes the
thread). Moderators (admin/global_mod browsers; services with `community.comment.moderate`)
thread). Moderators (admin/global_mod browsers; services with `community.comment.moderate`)
pin, lock and delete; locked threads take no replies or votes.
pin, lock and delete; locked threads take no replies or votes.
+- **A space's own moderators** (`space_moderators`, migration 0005): people listed for one space act there as
+ moderators do everywhere: thread state, status and category, edits and deletes, replies in locked
+ threads, the VIP gate (they pass it), members-only, settings (name, description, style, votes, ratings,
+ kind), categories and the chat room. They cannot move the space on the board index (group, parent,
+ position), create spaces, manage the board index's groups, add roadmap items or open staff spaces. Those
+ stay with staff. Any moderator of a space, or staff, adds and removes that space's moderators. They must
+ be Network users (`usr_…`), and nobody is added across a block in either direction. A listing counts only
+ for the person signed in themselves, never for a service naming them. Account deletion erases the rows and
+ a subject merge moves them. No capability or Contracts change is involved. The cutover runbook is
+ `docs/cutover-community-0005-space-moderators.md`.
- **Two styles, one system.** Each space is a **forum** or a **feed**. A forum reads like vBulletin or SMF:
- **Two styles, one system.** Each space is a **forum** or a **feed**. A forum reads like vBulletin or SMF:
- the board index at `/s` lists every space under its group, with topics, posts and the last post;
- the board index at `/s` lists every space under its group, with topics, posts and the last post;
@@ -380,6 +390,8 @@ API (`/api/v1/spaces`, `/api/v1/posts`, problem+json errors):
@@ -380,6 +390,8 @@ API (`/api/v1/spaces`, `/api/v1/posts`, problem+json errors):
| `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) |
| `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) |
| `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 |
+| `GET /spaces/:space/moderators` | The space's own moderators `{ space, moderators: [{ subject, username, display_name, added_by, added_at }] }` (whoever can read the space) |
+| `PUT`/`DELETE /spaces/:space/moderators/:subject` | The space's moderators or staff: add (blocks refuse, 403 `community.blocked`) or remove one (idempotent). The no-JS forms are `POST /s/:space/moderators` and `POST /s/:space/moderators/remove` `{ subject }` |
### A space's chat room (OpenVibe.Chat)
### A space's chat room (OpenVibe.Chat)
@@ -30,6 +30,7 @@
@@ -30,6 +30,7 @@
"pastes: code, text and screenshots, with versions, likes, comments, forks and burn-after-read",
"pastes: code, text and screenshots, with versions, likes, comments, forks and burn-after-read",
"typed comment threads embedded by other products",
"typed comment threads embedded by other products",
"forum: spaces, threads, posts, categories and votes",
"forum: spaces, threads, posts, categories and votes",
+ "per-space moderators (migration 0005)",
"members-only spaces and threads gated by OpenVibe.VIP",
"members-only spaces and threads gated by OpenVibe.VIP",
"a space's chat room on OpenVibe.Chat",
"a space's chat room on OpenVibe.Chat",
"Pulse: the network's public activity from Events",
"Pulse: the network's public activity from Events",
@@ -40,5 +41,5 @@
@@ -40,5 +41,5 @@
"N-1 compatibility tests",
"N-1 compatibility tests",
"per-actor limits (openvibe-sdk/limits)"
"per-actor limits (openvibe-sdk/limits)"
],
],
- "updated": "2026-10-03"
+ "updated": "2026-10-04"
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
+# Cutover runbook: Community migration 0005 (per-space moderators)
+
+T10 step 1. `migrations/0005_space_moderators.sql` adds one table, `space_moderators`, and its index. A
+person listed there for a space moderates that space (see README, "Forum"). Nothing existing is
+changed, and the previous release never reads the table.
+
+## Overview
+
+| | |
+|---|---|
+| Migration | `0005_space_moderators.sql`, `-- phase: migrate`, additive (CREATE TABLE + CREATE INDEX) |
+| Applied | At boot, by `server/db.js` (openvibe-sdk/db migrate, owner role on `DATABASE_DIRECT_URL`), in one transaction under the advisory lock |
+| Data moved | None. The table starts empty. Staff add the first moderators per space |
+| Env / config | None. No new capability and no Contracts change: a space moderator is a data row |
+| Owner action | None for the deploy. Afterwards, staff add moderators with `PUT /api/v1/spaces/:space/moderators/:subject` or the space page's form |
+
+## 0. Rules
+
+- Ship it with the normal pipeline (`ov deploy OpenVibe.Community`). It needs no window.
+- N-1: the previous release runs against the new schema throughout the mixed-version window. It
+ ignores the table, so its moderation is still global-only until every process runs the new release.
+
+## 1. Rehearsal
+
+1. Restore a recent copy of `ov_community` into a scratch database (the restore-drill copy will do).
+2. Boot this release against it (`DATABASE_URL`/`DATABASE_DIRECT_URL` pointing at the copy), or run
+ `npm run test:pg` with the copy as the test store. Check that `ov_migrations` lists `0005` and that
+ `\d space_moderators` shows the primary key `(space_id, subject_id)`, the foreign key to `spaces`
+ (`ON DELETE CASCADE`) and `idx_space_moderators_subject`.
+3. Run the moderator tests: `ov test space-moderators forum members-only security-idor chat-room`.
+
+## 2. Cutover
+
+1. Deploy. The migration runs at the first boot of the new release.
+2. **Verify:**
+ - `ov access run openvibe-ovh health community` is green.
+ - `ov access run openvibe-ovh journal openvibe-community.service 200` shows `0005_space_moderators`
+ applied and no migrate error.
+ - `curl -s https://openvibe.community/api/v1/spaces/general/moderators` answers
+ `{ "space": "general", "moderators": [] }`.
+
+## 3. Rollback
+
+Leave the table where it is. A rollback to the previous release ignores it. Rows added in the meantime
+stay, and they apply again when the release comes back. The migration is never reverted or edited
+(`ov_migrations` keeps its checksum). If it must be undone by hand, a later contract migration drops the
+table after the N-1 window.
+
+## 4. Afterwards
+
+- Account deletion erases a person's rows, and `added_by` becomes NULL where they added someone. A
+ subject merge moves the rows (`server/identity/account-data.js`, `subject-merge.js`).
+- When the forum moves to OpenVibe.Space (T10 step 3), `space_moderators` moves with the forum tables.
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+-- phase: migrate
+-- T10 step 1: per-space moderators (server/forum). A person listed here for a space moderates that space
+-- (thread state and status, deletes, settings, categories, members-only, its chat room) as discussion
+-- staff do everywhere; the board index and creating spaces stay with staff. Additive only: one new table
+-- and its index, nothing existing is touched; the previous release never reads it. People are Network
+-- subjects (usr_…), never a local integer; account deletion erases a row and a subject merge moves it
+-- (server/identity/account-data.js, subject-merge.js).
+CREATE TABLE space_moderators (
+ space_id bigint NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
+ subject_id text COLLATE "C" NOT NULL,
+ added_by text COLLATE "C",
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ PRIMARY KEY (space_id, subject_id)
+);
+CREATE INDEX idx_space_moderators_subject ON space_moderators (subject_id);
@@ -30,11 +30,15 @@
@@ -30,11 +30,15 @@
* PUT /spaces/:space/chat-room { room: slug | https://openvibe.chat/r/<slug> } the space's owner or staff, signed in
* 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 }
* themselves (Chat checks they manage the room) → { chat_room, created }
* DELETE /spaces/:space/chat-room the space's owner or staff (idempotent) → { detached, chat }
* DELETE /spaces/:space/chat-room the space's owner or staff (idempotent) → { detached, chat }
+ * GET /spaces/:space/moderators the space's own moderators → { space, moderators: [{ subject, username, display_name, added_by, added_at }] }
+ * PUT /spaces/:space/moderators/:subject add a person (usr_…) — the space's moderators or staff (idempotent)
+ * DELETE /spaces/:space/moderators/:subject remove one — the space's moderators or staff (idempotent)
* 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
*
*
* Services write with community.post.create (as X-OV-Subject, or as AI with X-OV-Origin: ai) and
* Services write with community.post.create (as X-OV-Subject, or as AI with X-OV-Origin: ai) and
- * moderate with community.comment.moderate. Errors are problem+json. A members-only space or thread
+ * moderate with community.comment.moderate. "moderators" on a /spaces/:space route means discussion staff or
+ * one of that space's own moderators (people, signed in themselves). Errors are problem+json. A members-only space or thread
* refuses readers and writers without the creator's VIP membership with 403 vip.members_only
* refuses readers and writers without the creator's VIP membership with 403 vip.members_only
* ({ reason, gate, members_only: { owner, owner_username, join_url } }).
* ({ reason, gate, members_only: { owner, owner_username, join_url } }).
*/
*/
@@ -53,6 +57,15 @@ function createSpacesApi({ forum, viewers, limits }) {
@@ -53,6 +57,15 @@ function createSpacesApi({ forum, viewers, limits }) {
const write = serviceCap(POST);
const write = serviceCap(POST);
const writeOrMod = serviceAnyCap([POST, MOD]);
const writeOrMod = serviceAnyCap([POST, MOD]);
const p = (req) => req.params;
const p = (req) => req.params;
+ // A space's moderators: a service needs community.comment.moderate (as before); a browser must be discussion
+ // staff or one of the space's own moderators (the service methods decide again, with 404 for a missing space).
+ const spaceMod = (req, res, next) => {
+ const v = req.viewer;
+ if (v && v.kind === 'service') return serviceCap(MOD)(req, res, next);
+ return forum.canModerate(v, p(req).space)
+ .then((ok) => (ok ? next() : contracts.http.sendProblem(res, 403, 'capability.denied', { detail: "Only this space's moderators", ctx: req.ov })))
+ .catch(next);
+ };
// Per-actor limits (server/actor-limits.js): reads take the defaults. The person limits in service.js
// Per-actor limits (server/actor-limits.js): reads take the defaults. The person limits in service.js
// (threads 3 a minute and a daily cap, replies 6, uploads 12, votes 60) keep deciding for people;
// (threads 3 a minute and a daily cap, replies 6, uploads 12, votes 60) keep deciding for people;
// these cap requests, refused ones included, and every write that has no content limit.
// these cap requests, refused ones included, and every write that has no content limit.
@@ -62,7 +75,7 @@ function createSpacesApi({ forum, viewers, limits }) {
@@ -62,7 +75,7 @@ function createSpacesApi({ forum, viewers, limits }) {
router.get('/', run(async (req) => await forum.listSpaces(req.viewer)));
router.get('/', run(async (req) => await forum.listSpaces(req.viewer)));
router.post('/', serviceCap(MOD), limits('community.space.create', { minute: 10, hour: 60 }), jsonBody, run(async (req) => await forum.createSpace(req.viewer, req.body || {}), 201));
router.post('/', serviceCap(MOD), limits('community.space.create', { minute: 10, hour: 60 }), jsonBody, run(async (req) => await forum.createSpace(req.viewer, req.body || {}), 201));
- router.put('/:space/settings', serviceCap(MOD), configure, jsonBody, run(async (req) => await forum.updateSpaceSettings(req.viewer, p(req).space, req.body || {})));
+ router.put('/:space/settings', configure, spaceMod, jsonBody, run(async (req) => await forum.updateSpaceSettings(req.viewer, p(req).space, req.body || {})));
router.get('/:space', run(async (req) => await forum.space(req.viewer, p(req).space)));
router.get('/:space', run(async (req) => await forum.space(req.viewer, p(req).space)));
router.get('/:space/threads', run(async (req) => await forum.listThreads(req.viewer, p(req).space, req.query)));
router.get('/:space/threads', run(async (req) => await forum.listThreads(req.viewer, p(req).space, req.query)));
router.get('/:space/categories', run(async (req) => await forum.categories(req.viewer, p(req).space)));
router.get('/:space/categories', run(async (req) => await forum.categories(req.viewer, p(req).space)));
@@ -73,22 +86,26 @@ function createSpacesApi({ forum, viewers, limits }) {
@@ -73,22 +86,26 @@ function createSpacesApi({ forum, viewers, limits }) {
: next()));
: next()));
// An upload is stored in OpenVibe.Media: 20 a minute, above the 12 a person may keep.
// An upload is stored in OpenVibe.Media: 20 a minute, above the 12 a person may keep.
router.post('/:space/attachments', write, limits('community.attachment.upload', { minute: 20, hour: 200 }), withFile, run(async (req) => await forum.uploadAttachment(req.viewer, p(req).space, req.file), 201));
router.post('/:space/attachments', write, limits('community.attachment.upload', { minute: 20, hour: 200 }), withFile, run(async (req) => await forum.uploadAttachment(req.viewer, p(req).space, req.file), 201));
- router.put('/:space/categories/:category', serviceCap(MOD), configure, jsonBody, run(async (req) => await forum.putCategory(req.viewer, p(req).space, p(req).category, req.body || {})));
- router.delete('/:space/categories/:category', serviceCap(MOD), configure, run(async (req) => await forum.deleteCategory(req.viewer, p(req).space, p(req).category)));
+ router.put('/:space/categories/:category', configure, spaceMod, jsonBody, run(async (req) => await forum.putCategory(req.viewer, p(req).space, p(req).category, req.body || {})));
+ router.delete('/:space/categories/:category', configure, spaceMod, run(async (req) => await forum.deleteCategory(req.viewer, p(req).space, p(req).category)));
router.put('/:space/threads/:slug/category', writeOrMod, editThread, jsonBody, run(async (req) => await forum.setThreadCategory(req.viewer, p(req).space, p(req).slug, req.body || {})));
router.put('/:space/threads/:slug/category', writeOrMod, editThread, jsonBody, run(async (req) => await forum.setThreadCategory(req.viewer, p(req).space, p(req).slug, req.body || {})));
- router.put('/:space/threads/:slug/status', serviceCap(MOD), configure, jsonBody, run(async (req) => await forum.setThreadStatus(req.viewer, p(req).space, p(req).slug, req.body || {})));
+ router.put('/:space/threads/:slug/status', configure, spaceMod, jsonBody, run(async (req) => await forum.setThreadStatus(req.viewer, p(req).space, p(req).slug, req.body || {})));
router.post('/:space/threads/:slug/crosspost', write, limits('community.thread.crosspost', { minute: 10, hour: 60 }), jsonBody, run(async (req) => await forum.crosspost(req.viewer, p(req).space, p(req).slug, req.body || {}), 201));
router.post('/:space/threads/:slug/crosspost', write, limits('community.thread.crosspost', { minute: 10, hour: 60 }), jsonBody, run(async (req) => await forum.crosspost(req.viewer, p(req).space, p(req).slug, req.body || {}), 201));
router.post('/:space/threads', write, limits('community.thread.create', { minute: 10, hour: 60 }), jsonBody, run(async (req) => await forum.createThread(req.viewer, p(req).space, req.body || {}), 201));
router.post('/:space/threads', write, limits('community.thread.create', { minute: 10, hour: 60 }), jsonBody, run(async (req) => await forum.createThread(req.viewer, p(req).space, req.body || {}), 201));
router.get('/:space/threads/:slug', run(async (req) => await forum.getThread(req.viewer, p(req).space, p(req).slug, req.query)));
router.get('/:space/threads/:slug', run(async (req) => await forum.getThread(req.viewer, p(req).space, p(req).slug, req.query)));
router.delete('/:space/threads/:slug', writeOrMod, editThread, run(async (req) => await forum.deleteThread(req.viewer, p(req).space, p(req).slug)));
router.delete('/:space/threads/:slug', writeOrMod, editThread, run(async (req) => await forum.deleteThread(req.viewer, p(req).space, p(req).slug)));
router.post('/:space/threads/:slug/posts', write, limits('community.post.create', { minute: 20, hour: 300 }), jsonBody, run(async (req) => await forum.reply(req.viewer, p(req).space, p(req).slug, req.body || {}), 201));
router.post('/:space/threads/:slug/posts', write, limits('community.post.create', { minute: 20, hour: 300 }), jsonBody, run(async (req) => await forum.reply(req.viewer, p(req).space, p(req).slug, req.body || {}), 201));
router.post('/:space/threads/:slug/votes', write, limits('community.thread.vote', { minute: 120, hour: 1200 }), jsonBody, run(async (req) => await forum.voteThread(req.viewer, p(req).space, p(req).slug, req.body || {})));
router.post('/:space/threads/:slug/votes', write, limits('community.thread.vote', { minute: 120, hour: 1200 }), jsonBody, run(async (req) => await forum.voteThread(req.viewer, p(req).space, p(req).slug, req.body || {})));
- router.put('/:space/threads/:slug/state', serviceCap(MOD), configure, jsonBody, run(async (req) => await forum.moderateThread(req.viewer, p(req).space, p(req).slug, req.body || {})));
+ router.put('/:space/threads/:slug/state', configure, spaceMod, jsonBody, run(async (req) => await forum.moderateThread(req.viewer, p(req).space, p(req).slug, req.body || {})));
router.put('/:space/threads/:slug/members-only', writeOrMod, editThread, jsonBody, run(async (req) => await forum.setThreadMembersOnly(req.viewer, p(req).space, p(req).slug, req.body || {})));
router.put('/:space/threads/:slug/members-only', writeOrMod, editThread, jsonBody, run(async (req) => await forum.setThreadMembersOnly(req.viewer, p(req).space, p(req).slug, req.body || {})));
- router.put('/:space/members-only', serviceCap(MOD), configure, jsonBody, run(async (req) => await forum.setSpaceMembersOnly(req.viewer, p(req).space, req.body || {})));
+ router.put('/:space/members-only', configure, spaceMod, jsonBody, run(async (req) => await 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.
// A chat room (OpenVibe.Chat): people attach with their own token; moderator services may only detach.
router.put('/:space/chat-room', configure, jsonBody, run(async (req) => await forum.attachChatRoom(req.viewer, p(req).space, req.body || {}), (out) => (out.created ? 201 : 200)));
router.put('/:space/chat-room', configure, jsonBody, run(async (req) => await forum.attachChatRoom(req.viewer, p(req).space, req.body || {}), (out) => (out.created ? 201 : 200)));
router.delete('/:space/chat-room', serviceCap(MOD), configure, run(async (req) => await forum.detachChatRoom(req.viewer, p(req).space)));
router.delete('/:space/chat-room', serviceCap(MOD), configure, run(async (req) => await forum.detachChatRoom(req.viewer, p(req).space)));
+ // The space's own moderators (people): listed to its readers, added and removed by its moderators or staff.
+ router.get('/:space/moderators', run(async (req) => await forum.moderators(req.viewer, p(req).space)));
+ router.put('/:space/moderators/:subject', configure, spaceMod, run(async (req) => await forum.addModerator(req.viewer, p(req).space, p(req).subject)));
+ router.delete('/:space/moderators/:subject', configure, spaceMod, run(async (req) => await forum.removeModerator(req.viewer, p(req).space, p(req).subject)));
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;
@@ -252,12 +252,27 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -252,12 +252,27 @@ function createForumRoutes({ forum, viewers, config }) {
router.post('/s/:space/settings', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
router.post('/s/:space/settings', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
const b = req.body || {};
const b = req.body || {};
try {
try {
- await forum.updateSpaceSettings(req.viewer, req.params.space, { name: b.name, description: b.description, style: b.style, votes: b.votes === '1', reactions: b.reactions === '1', group: b.group || null });
+ // The group (the board index) is staff's; a space's own moderators change the rest.
+ await forum.updateSpaceSettings(req.viewer, req.params.space, { name: b.name, description: b.description, style: b.style, votes: b.votes === '1', reactions: b.reactions === '1', group: forum.isModerator(req.viewer) ? b.group || null : undefined });
discovery.resetCaches();
discovery.resetCaches();
res.redirect(303, `/s/${encodeURIComponent(req.params.space)}`);
res.redirect(303, `/s/${encodeURIComponent(req.params.space)}`);
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
}));
}));
+ // The space's own moderators (no JS): add one by Network subject, remove one; back to the space.
+ router.post('/s/:space/moderators', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
+ try {
+ await forum.addModerator(req.viewer, req.params.space, String((req.body || {}).subject || '').trim().slice(0, 64));
+ res.redirect(303, `/s/${encodeURIComponent(req.params.space)}`);
+ } catch (err) { failPage(req, res, err, next); }
+ }));
+ router.post('/s/:space/moderators/remove', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
+ try {
+ await forum.removeModerator(req.viewer, req.params.space, String((req.body || {}).subject || '').trim().slice(0, 64));
+ res.redirect(303, `/s/${encodeURIComponent(req.params.space)}`);
+ } 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.
// 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`;
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) => {
router.post('/s/:space/chat-room', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
@@ -13,6 +13,10 @@
@@ -13,6 +13,10 @@
* Locked threads take no replies or votes except from moderators. Nobody replies in a thread whose
* Locked threads take no replies or votes except from moderators. Nobody replies in a thread whose
* author blocked them on the network (identity/blocks.js). Authors edit and delete their
* author blocked them on the network (identity/blocks.js). Authors edit and delete their
* own posts; moderators (identity/capabilities.js discussionModerator) any, and pin/lock.
* own posts; moderators (identity/capabilities.js discussionModerator) any, and pin/lock.
+ * A space also has its own moderators (space_moderators, people only): in that space they act as
+ * discussion staff do — thread state and status, deletes and edits, settings, categories, members-only,
+ * the chat room — and add or remove its moderators. Creating spaces, the board index (groups, a space's
+ * place on it), roadmap items and staff spaces stay with discussion staff.
* Edits keep every revision in post_versions.
* Edits keep every revision in post_versions.
*
*
* Side effects of a new thread/post: a Pulse item (public spaces) and a Discord relay delivery per
* Side effects of a new thread/post: a Pulse item (public spaces) and a Discord relay delivery per
@@ -125,6 +129,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -125,6 +129,9 @@ 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);
+ /** Global discussion staff, or a person (signed in themselves) this space lists as its moderator. */
+ const moderates = async (v, space) => moderator(v)
+ || (!!space && person(v) && v.kind === 'user' && await store.moderatesSpace(db, space.id, v.subject));
// ── a space's chat room (OpenVibe.Chat) ──────────────────
// ── a space's chat room (OpenVibe.Chat) ──────────────────
const chatRoomRow = async (spaceId) => (spaceId == null ? null : await db.prepare('SELECT * FROM space_chat_rooms WHERE space_id = ?').get(spaceId) || null);
const chatRoomRow = async (spaceId) => (spaceId == null ? null : await db.prepare('SELECT * FROM space_chat_rooms WHERE space_id = ?').get(spaceId) || null);
@@ -134,7 +141,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -134,7 +141,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
attached_at: isoTime(r.attached_at),
attached_at: isoTime(r.attached_at),
} : null);
} : null);
/** The space's owner (its creator, or the creator whose VIP members' space it is) or discussion staff. */
/** 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));
+ const canManageChatRoom = async (v, space) => await moderates(v, space) || (person(v) && (space.created_by === v.subject || space.members_only_owner === v.subject));
// ── access ───────────────────────────────────────────────
// ── access ───────────────────────────────────────────────
function canRead(v, space) {
function canRead(v, space) {
@@ -157,7 +164,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -157,7 +164,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
* the thread. Moderators pass; the owner passes; the rest is VIP's answer (fail closed).
* the thread. Moderators pass; the owner passes; the rest is VIP's answer (fail closed).
*/
*/
async function requireMembership(v, space, thread = null) {
async function requireMembership(v, space, thread = null) {
- if (moderator(v)) return;
+ if (await moderates(v, space)) return;
const gates = [];
const gates = [];
if (space && space.members_only_owner) gates.push({ type: 'space', id: space.slug, owner: space.members_only_owner });
if (space && space.members_only_owner) gates.push({ type: 'space', id: space.slug, owner: space.members_only_owner });
if (thread && thread.members_only_owner) gates.push({ type: 'thread', id: String(thread.id), owner: thread.members_only_owner });
if (thread && thread.members_only_owner) gates.push({ type: 'thread', id: String(thread.id), owner: thread.members_only_owner });
@@ -177,12 +184,12 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -177,12 +184,12 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
}
}
}
}
- /** A creator subject a viewer may gate something to: themselves, or anyone for moderators. */
- function gateOwner(v, requested, fallbackOwner) {
+ /** A creator subject a viewer may gate something to: themselves, or anyone for moderators (mod: of the space). */
+ function gateOwner(v, requested, fallbackOwner, mod) {
if (requested === null || requested === false || requested === undefined) return null;
if (requested === null || requested === false || requested === undefined) return null;
const owner = requested === true ? fallbackOwner : (typeof requested === 'object' ? requested.owner : requested);
const owner = requested === true ? fallbackOwner : (typeof requested === 'object' ? requested.owner : requested);
if (!isUserSubject(owner)) fail(400, 'members_only.invalid_owner', 'members_only.owner must be the creator\'s Network subject (usr_…)');
if (!isUserSubject(owner)) fail(400, 'members_only.invalid_owner', 'members_only.owner must be the creator\'s Network subject (usr_…)');
- if (!moderator(v) && !(person(v) && owner === v.subject)) fail(403, 'members_only.not_yours', 'You can only make things members-only for your own VIP members');
+ if (!mod && !(person(v) && owner === v.subject)) fail(403, 'members_only.not_yours', 'You can only make things members-only for your own VIP members');
return owner;
return owner;
}
}
@@ -309,7 +316,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -309,7 +316,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
for (const [i, r] of rows.entries()) await set.run(postId, i, r.media_id);
for (const [i, r] of rows.entries()) await set.run(postId, i, r.media_id);
}
}
- function shapePost(p, v, projections, attachments = null, pastes = null) {
+ function shapePost(p, v, projections, attachments = null, pastes = null, mod = moderator(v)) {
const deleted = !!p.deleted_at;
const deleted = !!p.deleted_at;
const mine = person(v) && p.author_subject === v.subject;
const mine = person(v) && p.author_subject === v.subject;
return {
return {
@@ -321,7 +328,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -321,7 +328,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
body_html: deleted ? null : renderMarkdown(p.body_markdown),
body_html: deleted ? null : renderMarkdown(p.body_markdown),
revision: p.revision, deleted,
revision: p.revision, deleted,
created_at: isoTime(p.created_at), updated_at: isoTime(p.updated_at),
created_at: isoTime(p.created_at), updated_at: isoTime(p.updated_at),
- can_edit: !deleted && (mine || moderator(v)),
+ can_edit: !deleted && (mine || mod),
};
};
}
}
@@ -356,16 +363,16 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -356,16 +363,16 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
if (relay) await hook(async () => await relay.hideThreads(threadIds, 'made members-only'));
if (relay) await hook(async () => await relay.hideThreads(threadIds, 'made members-only'));
}
}
- async function removeThread(v, thread) {
+ async function removeThread(v, thread, space) {
const mine = person(v) && thread.author_subject === v.subject;
const mine = person(v) && thread.author_subject === v.subject;
- if (!mine && !moderator(v)) fail(403, 'thread.not_yours', 'Only the author or a moderator deletes a thread');
+ if (!mine && !await moderates(v, space)) fail(403, 'thread.not_yours', 'Only the author or a moderator deletes a thread');
await db.tx(async () => {
await db.tx(async () => {
await store.softDeleteThread(db, thread.id);
await store.softDeleteThread(db, thread.id);
if (!mine) await events.moderationAction(v, 'thread.deleted', { type: 'thread', id: String(thread.id), owner_subject: thread.author_subject || null });
if (!mine) await events.moderationAction(v, 'thread.deleted', { type: 'thread', id: String(thread.id), owner_subject: thread.author_subject || null });
});
});
if (pulse) await hook(async () => { await pulse.threadGone(thread.id); for (const p of await db.prepare('SELECT id FROM posts WHERE thread_id = ?').all(thread.id)) await pulse.postGone(p.id); });
if (pulse) await hook(async () => { await pulse.threadGone(thread.id); for (const p of await db.prepare('SELECT id FROM posts WHERE thread_id = ?').all(thread.id)) await pulse.postGone(p.id); });
if (relay) await hook(async () => await relay.enqueueDelete(thread.id, null, mine ? 'deleted by its author' : 'deleted by a moderator'));
if (relay) await hook(async () => await relay.enqueueDelete(thread.id, null, mine ? 'deleted by its author' : 'deleted by a moderator'));
- pingThread(await store.getSpaceById(db, thread.space_id), thread, { withSpace: true });
+ pingThread(space, thread, { withSpace: true });
return { ok: true, id: thread.id, deleted: 'thread' };
return { ok: true, id: thread.id, deleted: 'thread' };
}
}
@@ -398,6 +405,23 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -398,6 +405,23 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
return { group: g || null, parent: p || null };
return { group: g || null, parent: p || null };
}
}
+ /** A space's moderators, with who they are. → [{ subject, username, display_name, added_by, added_at }] */
+ async function moderatorList(space) {
+ const rows = await store.listModerators(db, space.id);
+ const projections = await authors.projectionsFor(rows.map((r) => r.subject_id));
+ return rows.map((r) => {
+ const a = authors.author(r.subject_id, 'user', projections);
+ return { subject: r.subject_id, username: a ? a.username : null, display_name: a ? a.display_name : null, added_by: r.added_by || null, added_at: isoTime(r.created_at) };
+ });
+ }
+
+ /** The space, when the viewer moderates it: 404 (no such space), else 403. */
+ async function moderatedSpace(v, slug, what) {
+ const space = await spaceFor(v, slug);
+ if (!await moderates(v, space)) fail(403, 'capability.denied', `Only this space's moderators ${what}`);
+ return space;
+ }
+
/** Validated space fields from a settings body (only what was sent). */
/** Validated space fields from a settings body (only what was sent). */
async function spaceFields(body, space) {
async function spaceFields(body, space) {
const f = {};
const f = {};
@@ -429,6 +453,48 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -429,6 +453,48 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
POSTS_PER_PAGE,
POSTS_PER_PAGE,
isModerator: moderator,
isModerator: moderator,
+ /** Whether the viewer moderates this space (global discussion staff, or one of its own moderators). */
+ async canModerate(v, slug) {
+ if (moderator(v)) return true;
+ const space = /^[a-z0-9-]{1,40}$/.test(String(slug)) ? await store.getSpace(db, slug) : null;
+ return !!space && await moderates(v, space);
+ },
+
+ /** A space's own moderators — whoever can read the space. → { space, moderators } */
+ async moderators(v, spaceSlug) {
+ const space = await spaceFor(v, spaceSlug);
+ await requireMembership(v, space);
+ return { space: space.slug, moderators: await moderatorList(space) };
+ },
+
+ /**
+ * Add a person as one of the space's moderators — its moderators or discussion staff. A Network user
+ * subject (usr_…); not someone who blocked the one adding them, or whom they blocked. Idempotent.
+ */
+ async addModerator(v, spaceSlug, subject) {
+ const space = await moderatedSpace(v, spaceSlug, 'add moderators');
+ if (!isUserSubject(subject)) fail(400, 'moderator.invalid_subject', 'Name the person by their Network subject (usr_…)');
+ if (person(v) && v.subject !== subject && (await blocks.hasBlocked(db, subject, v.subject) || await blocks.hasBlocked(db, v.subject, subject))) {
+ fail(403, 'community.blocked', 'You cannot add this person as a moderator: one of you blocked the other');
+ }
+ if (!await store.moderatesSpace(db, space.id, subject)) {
+ await db.tx(async () => {
+ await store.addModerator(db, space.id, subject, person(v) ? v.subject : (v && v.service) || null);
+ await events.moderationAction(v, 'space.moderator_added', { type: 'space', id: space.slug }, { details: { subject } });
+ });
+ }
+ return { space: space.slug, moderators: await moderatorList(space) };
+ },
+
+ /** Remove one of the space's moderators (themselves included) — its moderators or discussion staff. Idempotent. */
+ async removeModerator(v, spaceSlug, subject) {
+ const space = await moderatedSpace(v, spaceSlug, 'remove moderators');
+ if (!isUserSubject(subject)) fail(400, 'moderator.invalid_subject', 'Name the person by their Network subject (usr_…)');
+ await db.tx(async () => {
+ if (await store.removeModerator(db, space.id, subject)) await events.moderationAction(v, 'space.moderator_removed', { type: 'space', id: space.slug }, { details: { subject } });
+ });
+ return { space: space.slug, moderators: await moderatorList(space) };
+ },
/** The board index's groups (for the settings and new-space forms). */
/** The board index's groups (for the settings and new-space forms). */
groups: async () => (await store.listGroups(db)).map((g) => ({ slug: g.slug, name: g.name })),
groups: async () => (await store.listGroups(db)).map((g) => ({ slug: g.slug, name: g.name })),
@@ -483,8 +549,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -483,8 +549,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
* parent?: slug|null, position?, kind?: discussion|request|roadmap }
* parent?: slug|null, position?, kind?: discussion|request|roadmap }
*/
*/
async updateSpaceSettings(v, spaceSlug, body = {}) {
async updateSpaceSettings(v, spaceSlug, body = {}) {
- if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators change a space');
- const space = await spaceFor(v, spaceSlug);
+ const space = await moderatedSpace(v, spaceSlug, 'change it');
+ // Where a space sits on the board index (its group, parent board and position) is the board index's: staff.
+ if (!moderator(v) && ['group', 'parent', 'position'].some((k) => body[k] !== undefined)) fail(403, 'capability.denied', 'Only moderators of the board index move a space on it');
const fields = await spaceFields(body, space);
const fields = await spaceFields(body, space);
const next = await store.updateSpace(db, space.id, fields);
const next = await store.updateSpace(db, space.id, fields);
const row = (await store.listSpaces(db, ['public', 'members', 'staff'])).find((r) => r.id === next.id) || next;
const row = (await store.listSpaces(db, ['public', 'members', 'staff'])).find((r) => r.id === next.id) || next;
@@ -524,7 +591,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -524,7 +591,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
async attachChatRoom(v, spaceSlug, body = {}) {
async attachChatRoom(v, spaceSlug, body = {}) {
const space = await spaceFor(v, spaceSlug);
const space = await spaceFor(v, spaceSlug);
if (!person(v) && !moderator(v)) fail(401, 'auth.required', 'Sign in with your OpenVibe account');
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 (!await 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 (!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');
if (!chatRooms) fail(503, 'chat_room.unavailable', 'Chat rooms cannot be attached right now');
const slug = chatRooms.parseRoomRef(body.room);
const slug = chatRooms.parseRoomRef(body.room);
@@ -547,7 +614,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -547,7 +614,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
async detachChatRoom(v, spaceSlug) {
async detachChatRoom(v, spaceSlug) {
const space = await spaceFor(v, spaceSlug);
const space = await spaceFor(v, spaceSlug);
if (!person(v) && !moderator(v)) fail(401, 'auth.required', 'Sign in with your OpenVibe account');
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');
+ if (!await canManageChatRoom(v, space)) fail(403, 'capability.denied', 'Only the space\'s owner or staff detach its chat room');
const before = await chatRoomRow(space.id);
const before = await chatRoomRow(space.id);
if (!before) return { detached: false, chat: null };
if (!before) return { detached: false, chat: null };
await db.prepare('DELETE FROM space_chat_rooms WHERE space_id = ?').run(space.id);
await db.prepare('DELETE FROM space_chat_rooms WHERE space_id = ?').run(space.id);
@@ -573,11 +640,13 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -573,11 +640,13 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const { rows, total } = await store.listThreads(db, space.id, { sort, limit: perPage, offset: (page - 1) * perPage, now: q.now || new Date(), categoryId: category ? category.id : null, status: q.status || null });
const { rows, total } = await store.listThreads(db, space.id, { sort, limit: perPage, offset: (page - 1) * perPage, now: q.now || new Date(), categoryId: category ? category.id : null, status: q.status || null });
const index = await this.listSpaces(v);
const index = await this.listSpaces(v);
const self = index.spaces.find((x) => x.slug === space.slug) || {};
const self = index.spaces.find((x) => x.slug === space.slug) || {};
+ const mod = await moderates(v, space);
return {
return {
space: { ...await shapeSpace(space, await membersOnly(space.members_only_owner)), group: self.group || null, parent: self.parent || null },
space: { ...await 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: (await store.listCategories(db, space.id)).map(shapeCategory), category: category ? category.slug : null, status: q.status || null,
categories: (await 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), can_manage_chat_room: canManageChatRoom(v, space) },
+ viewer: { can_start: space.thread_kind !== 'roadmap' || moderator(v), can_moderate: mod, signed_in: person(v), can_manage_chat_room: await canManageChatRoom(v, space) },
+ moderators: mod ? await moderatorList(space) : undefined,
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) ? (await store.listGroups(db)).map((g) => ({ slug: g.slug, name: g.name })) : undefined,
groups: moderator(v) ? (await store.listGroups(db)).map((g) => ({ slug: g.slug, name: g.name })) : undefined,
threads: await shapeThreads(rows, () => space, v),
threads: await shapeThreads(rows, () => space, v),
@@ -623,7 +692,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -623,7 +692,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const { post, thread, space } = await postFor(v, postId);
const { post, thread, space } = await postFor(v, postId);
if (space.reactions === 0) fail(403, 'space.reactions_off', 'Ratings are off in this space');
if (space.reactions === 0) fail(403, 'space.reactions_off', 'Ratings are off in this space');
await requireMembership(v, space, thread);
await requireMembership(v, space, thread);
- if (thread.locked && !moderator(v)) fail(403, 'thread.locked', 'This thread is locked');
+ if (thread.locked && !await moderates(v, space)) fail(403, 'thread.locked', 'This thread is locked');
if (post.author_subject === v.subject) fail(403, 'reaction.own_post', 'You cannot rate your own post');
if (post.author_subject === v.subject) fail(403, 'reaction.own_post', 'You cannot rate your own post');
const reaction = body.reaction == null || body.reaction === '' ? null : String(body.reaction);
const reaction = body.reaction == null || body.reaction === '' ? null : String(body.reaction);
if (reaction && !reactions.BY_KEY.has(reaction)) fail(400, 'reaction.invalid', `reaction is one of ${reactions.REACTIONS.map((r) => r.key).join(', ')}`);
if (reaction && !reactions.BY_KEY.has(reaction)) fail(400, 'reaction.invalid', `reaction is one of ${reactions.REACTIONS.map((r) => r.key).join(', ')}`);
@@ -694,8 +763,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -694,8 +763,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
/** Create or change a category { slug, name, description?, position? } — moderators. */
/** Create or change a category { slug, name, description?, position? } — moderators. */
async putCategory(v, spaceSlug, slug, body = {}) {
async putCategory(v, spaceSlug, slug, body = {}) {
- if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators manage categories');
- const space = await spaceFor(v, spaceSlug);
+ const space = await moderatedSpace(v, spaceSlug, 'manage categories');
if (!CATEGORY_SLUG.test(String(slug || ''))) fail(400, 'category.invalid_slug', 'A category slug is 1 to 40 lowercase letters, digits and dashes');
if (!CATEGORY_SLUG.test(String(slug || ''))) fail(400, 'category.invalid_slug', 'A category slug is 1 to 40 lowercase letters, digits and dashes');
const name = cleanTitle(body.name);
const name = cleanTitle(body.name);
if (name.length < 2 || name.length > 40) fail(400, 'category.invalid_name', 'Category names are 2 to 40 characters');
if (name.length < 2 || name.length > 40) fail(400, 'category.invalid_name', 'Category names are 2 to 40 characters');
@@ -708,8 +776,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -708,8 +776,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
/** Delete a category — moderators. Its threads stay, without a category. */
/** Delete a category — moderators. Its threads stay, without a category. */
async deleteCategory(v, spaceSlug, slug) {
async deleteCategory(v, spaceSlug, slug) {
- if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators manage categories');
- const space = await spaceFor(v, spaceSlug);
+ const space = await moderatedSpace(v, spaceSlug, 'manage categories');
if (!await store.deleteCategory(db, space.id, slug)) fail(404, 'category.not_found', 'No such category in this space');
if (!await store.deleteCategory(db, space.id, slug)) fail(404, 'category.not_found', 'No such category in this space');
pingSpace(space);
pingSpace(space);
return { ok: true };
return { ok: true };
@@ -718,7 +785,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -718,7 +785,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
/** Move a thread to a category { category: slug | null } — its author or a moderator. */
/** Move a thread to a category { category: slug | null } — its author or a moderator. */
async setThreadCategory(v, spaceSlug, threadSlug, body = {}) {
async setThreadCategory(v, spaceSlug, threadSlug, body = {}) {
const { space, thread } = await threadFor(v, spaceSlug, threadSlug);
const { space, thread } = await threadFor(v, spaceSlug, threadSlug);
- if (!(person(v) && thread.author_subject === v.subject) && !moderator(v)) fail(403, 'thread.not_yours', 'Only the author or a moderator changes the category');
+ if (!(person(v) && thread.author_subject === v.subject) && !await moderates(v, space)) fail(403, 'thread.not_yours', 'Only the author or a moderator changes the category');
await requireMembership(v, space, thread);
await requireMembership(v, space, thread);
const category = body.category ? await store.getCategory(db, space.id, body.category) : null;
const category = body.category ? await store.getCategory(db, space.id, body.category) : null;
if (body.category && !category) fail(404, 'category.not_found', 'No such category in this space');
if (body.category && !category) fail(404, 'category.not_found', 'No such category in this space');
@@ -730,8 +797,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -730,8 +797,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
/** A request's or roadmap item's status { status } — moderators. */
/** A request's or roadmap item's status { status } — moderators. */
async setThreadStatus(v, spaceSlug, threadSlug, body = {}) {
async setThreadStatus(v, spaceSlug, threadSlug, body = {}) {
- if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators change a status');
const { space, thread } = await threadFor(v, spaceSlug, threadSlug);
const { space, thread } = await threadFor(v, spaceSlug, threadSlug);
+ if (!await moderates(v, space)) fail(403, 'capability.denied', 'Only moderators change a status');
const statuses = STATUSES[thread.kind] || [];
const statuses = STATUSES[thread.kind] || [];
if (!statuses.includes(body.status)) fail(400, 'thread.invalid_status', statuses.length ? `status is one of ${statuses.join(', ')}` : 'This thread has no status');
if (!statuses.includes(body.status)) fail(400, 'thread.invalid_status', statuses.length ? `status is one of ${statuses.join(', ')}` : 'This thread has no status');
const next = await store.setThreadStatus(db, thread.id, body.status);
const next = await store.setThreadStatus(db, thread.id, body.status);
@@ -756,16 +823,17 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -756,16 +823,17 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const stats = await store.authorStats(db, authorSubjects);
const stats = await store.authorStats(db, authorSubjects);
const received = space.reactions !== 0 ? await reactions.receivedBy(db, authorSubjects) : new Map();
const received = space.reactions !== 0 ? await reactions.receivedBy(db, authorSubjects) : new Map();
const raterProjections = await authors.projectionsFor([...rated.values()].flatMap((l) => l.flatMap((e) => e.raters)));
const raterProjections = await authors.projectionsFor([...rated.values()].flatMap((l) => l.flatMap((e) => e.raters)));
+ const mod = await moderates(v, space);
return {
return {
space: { ...await shapeSpace(space, await membersOnly(space.members_only_owner, projections)), ...await placeOf(space) },
space: { ...await shapeSpace(space, await membersOnly(space.members_only_owner, projections)), ...await placeOf(space) },
thread: await shapeThread(thread, space, v, projections, votes),
thread: await shapeThread(thread, space, v, projections, votes),
posts: rows.map((p) => {
posts: rows.map((p) => {
- const sp = shapePost(p, v, projections, att, pasted);
+ const sp = shapePost(p, v, projections, att, pasted, mod);
const st = p.author_subject ? stats.get(p.author_subject) : null;
const st = p.author_subject ? stats.get(p.author_subject) : null;
sp.author_stats = st ? { posts: st.posts, first_post_at: isoTime(st.first_post_at), ratings: received.get(p.author_subject) || [] } : null;
sp.author_stats = st ? { posts: st.posts, first_post_at: isoTime(st.first_post_at), ratings: received.get(p.author_subject) || [] } : null;
sp.reactions = p.deleted_at ? [] : (rated.get(p.id) || []).map((e) => ({ key: e.key, emoji: e.emoji, label: e.label, group: e.group, count: e.count, mine: e.mine,
sp.reactions = p.deleted_at ? [] : (rated.get(p.id) || []).map((e) => ({ key: e.key, emoji: e.emoji, label: e.label, group: e.group, count: e.count, mine: e.mine,
raters: e.raters.map((sub) => { const pr = raterProjections.get(sub); return pr ? (pr.display_name || pr.username) : null; }).filter(Boolean) }));
raters: e.raters.map((sub) => { const pr = raterProjections.get(sub); return pr ? (pr.display_name || pr.username) : null; }).filter(Boolean) }));
- sp.can_react = space.reactions !== 0 && !p.deleted_at && person(v) && p.author_subject !== v.subject && (!thread.locked || moderator(v));
+ sp.can_react = space.reactions !== 0 && !p.deleted_at && person(v) && p.author_subject !== v.subject && (!thread.locked || mod);
return sp;
return sp;
}),
}),
reactions: space.reactions !== 0 ? reactions.REACTIONS : [],
reactions: space.reactions !== 0 ? reactions.REACTIONS : [],
@@ -775,11 +843,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -775,11 +843,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
page, per_page: POSTS_PER_PAGE, pages: Math.max(Math.ceil(total / POSTS_PER_PAGE), 1), total,
page, per_page: POSTS_PER_PAGE, pages: Math.max(Math.ceil(total / POSTS_PER_PAGE), 1), total,
viewer: {
viewer: {
signed_in: person(v),
signed_in: person(v),
- can_reply: (person(v) || (v && v.origin === 'ai' && v.kind === 'service')) && (!thread.locked || moderator(v)) && (space.visibility !== 'staff' || moderator(v)),
+ can_reply: (person(v) || (v && v.origin === 'ai' && v.kind === 'service')) && (!thread.locked || mod) && (space.visibility !== 'staff' || moderator(v)),
can_vote: person(v) && !thread.locked && space.votes !== 0,
can_vote: person(v) && !thread.locked && space.votes !== 0,
- can_moderate: moderator(v),
- can_delete: moderator(v) || (person(v) && thread.author_subject === v.subject),
- can_gate: moderator(v) || (person(v) && thread.author_subject === v.subject),
+ can_moderate: mod,
+ can_delete: mod || (person(v) && thread.author_subject === v.subject),
+ can_gate: mod || (person(v) && thread.author_subject === v.subject),
},
},
};
};
},
},
@@ -793,7 +861,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -793,7 +861,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const w = writer(v);
const w = writer(v);
mayPostIn(v, space);
mayPostIn(v, space);
await requireMembership(v, space);
await requireMembership(v, space);
- const gate = gateOwner(v, body.members_only, w.author);
+ const gate = gateOwner(v, body.members_only, w.author, await moderates(v, space));
const kind = space.thread_kind || 'discussion';
const kind = space.thread_kind || 'discussion';
if (kind === 'roadmap' && !moderator(v)) fail(403, 'space.staff_threads', 'Roadmap items are added by staff. Reply to one, or suggest something in Feedback');
if (kind === 'roadmap' && !moderator(v)) fail(403, 'space.staff_threads', 'Roadmap items are added by staff. Reply to one, or suggest something in Feedback');
const category = body.category ? await store.getCategory(db, space.id, body.category) : null;
const category = body.category ? await store.getCategory(db, space.id, body.category) : null;
@@ -825,7 +893,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -825,7 +893,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const w = writer(v);
const w = writer(v);
mayPostIn(v, space);
mayPostIn(v, space);
await requireMembership(v, space, thread);
await requireMembership(v, space, thread);
- if (thread.locked && !moderator(v)) fail(403, 'thread.locked', 'This thread is locked');
+ if (thread.locked && !await moderates(v, space)) fail(403, 'thread.locked', 'This thread is locked');
// Platform blocks: nobody replies in a thread whose author blocked them.
// Platform blocks: nobody replies in a thread whose author blocked them.
await blocks.refuseIfBlocked(db, [thread.author_subject], w.author, 'reply in this thread');
await blocks.refuseIfBlocked(db, [thread.author_subject], w.author, 'reply in this thread');
const text = cleanBody(body.body != null ? body.body : body.body_markdown);
const text = cleanBody(body.body != null ? body.body : body.body_markdown);
@@ -851,20 +919,21 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -851,20 +919,21 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const { post, thread, space } = await postFor(v, postId);
const { post, thread, space } = await postFor(v, postId);
await requireMembership(v, space, thread);
await requireMembership(v, space, thread);
const mine = person(v) && post.author_subject === v.subject;
const mine = person(v) && post.author_subject === v.subject;
- if (!mine && !moderator(v)) fail(403, 'post.not_yours', 'Only the author or a moderator edits a post');
- if (thread.locked && !moderator(v)) fail(403, 'thread.locked', 'This thread is locked');
+ const mod = await moderates(v, space);
+ if (!mine && !mod) fail(403, 'post.not_yours', 'Only the author or a moderator edits a post');
+ if (thread.locked && !mod) fail(403, 'thread.locked', 'This thread is locked');
const next = await store.editPost(db, post.id, cleanBody(body.body != null ? body.body : body.body_markdown), v.subject || v.service || null);
const next = await store.editPost(db, post.id, cleanBody(body.body != null ? body.body : body.body_markdown), v.subject || v.service || null);
if (relay && next && next.revision !== post.revision) await hook(async () => await relay.enqueueEdit(next));
if (relay && next && next.revision !== post.revision) await hook(async () => await relay.enqueueEdit(next));
const projections = await authors.projectionsFor([next.author_subject]);
const projections = await authors.projectionsFor([next.author_subject]);
pingThread(space, thread);
pingThread(space, thread);
- return { post: shapePost(next, v, projections) };
+ return { post: shapePost(next, v, projections, null, null, mod) };
},
},
/** Edit history — the author or a moderator. */
/** Edit history — the author or a moderator. */
async postVersions(v, postId) {
async postVersions(v, postId) {
const { post, thread, space } = await postFor(v, postId);
const { post, thread, space } = await postFor(v, postId);
await requireMembership(v, space, thread);
await requireMembership(v, space, thread);
- if (!(person(v) && post.author_subject === v.subject) && !moderator(v)) fail(403, 'post.not_yours', 'Only the author or a moderator sees the history');
+ if (!(person(v) && post.author_subject === v.subject) && !await moderates(v, space)) fail(403, 'post.not_yours', 'Only the author or a moderator sees the history');
const list = (await store.listPostVersions(db, post.id)).map((r) => ({ ...r, created_at: isoTime(r.created_at) }));
const list = (await store.listPostVersions(db, post.id)).map((r) => ({ ...r, created_at: isoTime(r.created_at) }));
return { revision: post.revision, versions: list.length ? list : [{ revision: post.revision, body_markdown: post.body_markdown, edited_by: post.author_subject, created_at: isoTime(post.created_at) }] };
return { revision: post.revision, versions: list.length ? list : [{ revision: post.revision, body_markdown: post.body_markdown, edited_by: post.author_subject, created_at: isoTime(post.created_at) }] };
},
},
@@ -872,8 +941,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -872,8 +941,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
/** Delete a post — the author or a moderator. Deleting the opening post deletes the thread. */
/** Delete a post — the author or a moderator. Deleting the opening post deletes the thread. */
async deletePost(v, postId) {
async deletePost(v, postId) {
const { post, thread, space } = await postFor(v, postId);
const { post, thread, space } = await postFor(v, postId);
- if (!(person(v) && post.author_subject === v.subject) && !moderator(v)) fail(403, 'post.not_yours', 'Only the author or a moderator deletes a post');
- if (post.is_opening) return await removeThread(v, thread);
+ if (!(person(v) && post.author_subject === v.subject) && !await moderates(v, space)) fail(403, 'post.not_yours', 'Only the author or a moderator deletes a post');
+ if (post.is_opening) return await removeThread(v, thread, space);
await db.tx(async () => {
await db.tx(async () => {
await store.softDeletePost(db, post.id);
await store.softDeletePost(db, post.id);
if (!(person(v) && post.author_subject === v.subject)) await events.moderationAction(v, 'post.deleted', { type: 'post', id: String(post.id), owner_subject: post.author_subject || null }, { details: { thread: String(thread.id) } });
if (!(person(v) && post.author_subject === v.subject)) await events.moderationAction(v, 'post.deleted', { type: 'post', id: String(post.id), owner_subject: post.author_subject || null }, { details: { thread: String(thread.id) } });
@@ -886,8 +955,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -886,8 +955,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
/** Delete a thread — its author or a moderator. */
/** Delete a thread — its author or a moderator. */
async deleteThread(v, spaceSlug, threadSlug) {
async deleteThread(v, spaceSlug, threadSlug) {
- const { thread } = await threadFor(v, spaceSlug, threadSlug);
- return await removeThread(v, thread);
+ const { space, thread } = await threadFor(v, spaceSlug, threadSlug);
+ return await removeThread(v, thread, space);
},
},
/** Vote { value: 1 | -1 | 0 } → { score, upvotes, downvotes, my_vote } */
/** Vote { value: 1 | -1 | 0 } → { score, upvotes, downvotes, my_vote } */
@@ -907,8 +976,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -907,8 +976,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
/** Pin / lock { pinned?, locked? } — moderators. */
/** Pin / lock { pinned?, locked? } — moderators. */
async moderateThread(v, spaceSlug, threadSlug, body = {}) {
async moderateThread(v, spaceSlug, threadSlug, body = {}) {
- if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators pin or lock threads');
const { space, thread } = await threadFor(v, spaceSlug, threadSlug);
const { space, thread } = await threadFor(v, spaceSlug, threadSlug);
+ if (!await moderates(v, space)) fail(403, 'capability.denied', 'Only moderators pin or lock threads');
const flags = {};
const flags = {};
if (body.pinned !== undefined) flags.pinned = !!body.pinned;
if (body.pinned !== undefined) flags.pinned = !!body.pinned;
if (body.locked !== undefined) flags.locked = !!body.locked;
if (body.locked !== undefined) flags.locked = !!body.locked;
@@ -931,11 +1000,12 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -931,11 +1000,12 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
async setThreadMembersOnly(v, spaceSlug, threadSlug, body = {}) {
async setThreadMembersOnly(v, spaceSlug, threadSlug, body = {}) {
const { space, thread } = await threadFor(v, spaceSlug, threadSlug);
const { space, thread } = await threadFor(v, spaceSlug, threadSlug);
const mine = person(v) && thread.author_subject === v.subject;
const mine = person(v) && thread.author_subject === v.subject;
- if (!mine && !moderator(v)) fail(403, 'thread.not_yours', 'Only the author or a moderator makes a thread members-only');
+ const mod = await moderates(v, space);
+ if (!mine && !mod) fail(403, 'thread.not_yours', 'Only the author or a moderator makes a thread members-only');
await requireMembership(v, space, null);
await requireMembership(v, space, null);
const requested = body.members_only !== undefined ? body.members_only : body.owner;
const requested = body.members_only !== undefined ? body.members_only : body.owner;
- const owner = gateOwner(v, requested === undefined ? null : requested, thread.author_subject);
- if (!owner && thread.members_only_owner && !moderator(v) && thread.members_only_owner !== v.subject) fail(403, 'members_only.not_yours', 'Only the creator it is gated to, or a moderator, opens it again');
+ const owner = gateOwner(v, requested === undefined ? null : requested, thread.author_subject, mod);
+ if (!owner && thread.members_only_owner && !mod && thread.members_only_owner !== v.subject) fail(403, 'members_only.not_yours', 'Only the creator it is gated to, or a moderator, opens it again');
const next = await store.setThreadMembersOnly(db, thread.id, owner);
const next = await store.setThreadMembersOnly(db, thread.id, owner);
if (owner) await hideGated([thread.id]);
if (owner) await hideGated([thread.id]);
const projections = await authors.projectionsFor([next.author_subject, next.members_only_owner]);
const projections = await authors.projectionsFor([next.author_subject, next.members_only_owner]);
@@ -946,10 +1016,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -946,10 +1016,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
/** Members-only for a whole space { owner: 'usr_…' | null } — moderators. */
/** Members-only for a whole space { owner: 'usr_…' | null } — moderators. */
async setSpaceMembersOnly(v, spaceSlug, body = {}) {
async setSpaceMembersOnly(v, spaceSlug, body = {}) {
- if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators make a space members-only');
- const space = await spaceFor(v, spaceSlug);
+ const space = await moderatedSpace(v, spaceSlug, 'make it members-only');
const requested = body.members_only !== undefined ? body.members_only : body.owner;
const requested = body.members_only !== undefined ? body.members_only : body.owner;
- const owner = gateOwner(v, requested === undefined ? null : requested, null);
+ const owner = gateOwner(v, requested === undefined ? null : requested, null, true);
const next = await store.setSpaceMembersOnly(db, space.id, owner);
const next = await store.setSpaceMembersOnly(db, space.id, owner);
if (owner) await hideGated((await db.prepare('SELECT id FROM threads WHERE space_id = ?').all(space.id)).map((r) => r.id));
if (owner) await hideGated((await db.prepare('SELECT id FROM threads WHERE space_id = ?').all(space.id)).map((r) => r.id));
pingSpace(space);
pingSpace(space);
@@ -297,6 +297,23 @@ async function softDeletePost(db, id) {
@@ -297,6 +297,23 @@ async function softDeletePost(db, id) {
return 1;
return 1;
});
});
}
}
+// ── per-space moderators ─────────────────────────────────────
+async function listModerators(db, spaceId) {
+ return await db.prepare('SELECT subject_id, added_by, created_at FROM space_moderators WHERE space_id = ? ORDER BY created_at, subject_id').all(spaceId);
+}
+
+async function moderatesSpace(db, spaceId, subject) {
+ if (!subject) return false;
+ return !!await db.prepare('SELECT 1 FROM space_moderators WHERE space_id = ? AND subject_id = ?').get(spaceId, subject);
+}
+
+async function addModerator(db, spaceId, subject, addedBy) {
+ await db.prepare('INSERT INTO space_moderators (space_id, subject_id, added_by) VALUES (?, ?, ?) ON CONFLICT (space_id, subject_id) DO NOTHING').run(spaceId, subject, addedBy || null);
+}
+
+async function removeModerator(db, spaceId, subject) {
+ return (await db.prepare('DELETE FROM space_moderators WHERE space_id = ? AND subject_id = ?').run(spaceId, subject)).changes > 0;
+}
async function countPostsSince(db, subject, sinceSql) {
async function countPostsSince(db, subject, sinceSql) {
return (await db.prepare("SELECT COUNT(*) AS c FROM posts WHERE author_subject = ? AND created_at > datetime('now', ?)").get(subject, sinceSql)).c;
return (await db.prepare("SELECT COUNT(*) AS c FROM posts WHERE author_subject = ? AND created_at > datetime('now', ?)").get(subject, sinceSql)).c;
@@ -308,5 +325,6 @@ module.exports = {
@@ -308,5 +325,6 @@ module.exports = {
getThread, getThreadBySlug, createThread, listThreads, recentThreads, setThreadFlags, setThreadMembersOnly, setSpaceMembersOnly, softDeleteThread, countThreadsSince,
getThread, getThreadBySlug, createThread, listThreads, recentThreads, setThreadFlags, setThreadMembersOnly, setSpaceMembersOnly, softDeleteThread, countThreadsSince,
listCategories, getCategory, getCategoryById, upsertCategory, deleteCategory, setThreadCategory, setThreadStatus, getThreadByKey,
listCategories, getCategory, getCategoryById, upsertCategory, deleteCategory, setThreadCategory, setThreadStatus, getThreadByKey,
lastPosts, listGroups, updateSpace, createSpace, authorStats, bumpViews,
lastPosts, listGroups, updateSpace, createSpace, authorStats, bumpViews,
+ listModerators, moderatesSpace, addModerator, removeModerator,
getPost, addPost, listPosts, editPost, listPostVersions, softDeletePost, countPostsSince,
getPost, addPost, listPosts, editPost, listPostVersions, softDeletePost, countPostsSince,
};
};
@@ -6,7 +6,8 @@
@@ -6,7 +6,8 @@
*
*
* network.account.export_requested Community's part (POST /internal/account-exports/:id/parts with a service token):
* network.account.export_requested Community's part (POST /internal/account-exports/:id/parts with a service token):
* pastes (with their content), paste comments, comments, threads, posts,
* pastes (with their content), paste comments, comments, threads, posts,
- * attachments, likes, votes and reactions, game progress, blocks and activity.
+ * attachments, likes, votes and reactions, game progress, blocks, activity and
+ * the spaces they moderate.
* network.account.deleted what the subject (and the accounts merged into it) wrote goes. An item with
* network.account.deleted what the subject (and the accounts merged into it) wrote goes. An item with
* someone else's reply anywhere beneath it stays as an authorless tombstone
* someone else's reply anywhere beneath it stays as an authorless tombstone
* ("[deleted]"), so the replies keep their place:
* ("[deleted]"), so the replies keep their place:
@@ -14,8 +15,8 @@
@@ -14,8 +15,8 @@
* - a comment or paste comment others answered;
* - a comment or paste comment others answered;
* - a thread others posted in, and its opening post.
* - a thread others posted in, and its opening post.
* Likes, votes and reactions go and cached counts are recomputed. Game progress,
* Likes, votes and reactions go and cached counts are recomputed. Game progress,
- * blocks both ways, activity items and the cached profile go. Spaces the person
- * created stay without them. Community then confirms with counts.
+ * blocks both ways, activity items, the cached profile and their place as a space's
+ * moderator go. Spaces the person created, and moderators they added, stay without them. Community then confirms with counts.
*/
*/
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const EXPORT_RE = /^exp_[0-9A-HJKMNP-TV-Z]{26}$/;
const EXPORT_RE = /^exp_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -37,6 +38,7 @@ const EXPORTS = [
@@ -37,6 +38,7 @@ const EXPORTS = [
['paste_likes.json', 'paste_likes', 'subject_id'], ['comment_votes.json', 'comment_votes', 'subject_id'], ['thread_votes.json', 'thread_votes', 'subject_id'],
['paste_likes.json', 'paste_likes', 'subject_id'], ['comment_votes.json', 'comment_votes', 'subject_id'], ['thread_votes.json', 'thread_votes', 'subject_id'],
['post_reactions.json', 'post_reactions', 'subject_id'], ['game_progress.json', 'game_progress', 'subject_id'], ['blocks.json', 'network_blocks', 'blocker_subject'],
['post_reactions.json', 'post_reactions', 'subject_id'], ['game_progress.json', 'game_progress', 'subject_id'], ['blocks.json', 'network_blocks', 'blocker_subject'],
['activity.json', 'pulse_items', 'actor_subject'], ['submissions.json', 'submissions', 'author_subject'],
['activity.json', 'pulse_items', 'actor_subject'], ['submissions.json', 'submissions', 'author_subject'],
+ ['space_moderators.json', 'space_moderators', 'subject_id'],
];
];
async function exportPart(db, subject) {
async function exportPart(db, subject) {
@@ -138,12 +140,13 @@ async function erase(db, subjects, { now = new Date().toISOString() } = {}) {
@@ -138,12 +140,13 @@ async function erase(db, subjects, { now = new Date().toISOString() } = {}) {
}
}
for (const [table, where, key] of [['game_progress', `subject_id IN ${S}`, 'game_progress'], ['pulse_items', `actor_subject IN ${S}`, 'activity'],
for (const [table, where, key] of [['game_progress', `subject_id IN ${S}`, 'game_progress'], ['pulse_items', `actor_subject IN ${S}`, 'activity'],
['subject_projection', `subject_id IN ${S}`, 'profile'], ['profile_module_pushes', `subject_id IN ${S}`, 'profile'],
['subject_projection', `subject_id IN ${S}`, 'profile'], ['profile_module_pushes', `subject_id IN ${S}`, 'profile'],
- ['network_blocks', `blocker_subject IN ${S} OR blocked_subject IN ${S}`, 'blocks']]) {
+ ['network_blocks', `blocker_subject IN ${S} OR blocked_subject IN ${S}`, 'blocks'], ['space_moderators', `subject_id IN ${S}`, 'space_moderators']]) {
if (!await hasTable(db, table)) continue;
if (!await hasTable(db, table)) continue;
const params = where.includes(' OR ') ? [...subjects, ...subjects] : subjects;
const params = where.includes(' OR ') ? [...subjects, ...subjects] : subjects;
add(erased, key, (await db.prepare(`DELETE FROM ${table} WHERE ${where}`).run(...params)).changes);
add(erased, key, (await db.prepare(`DELETE FROM ${table} WHERE ${where}`).run(...params)).changes);
}
}
for (const table of ['spaces', 'comment_threads']) if (await hasColumn(db, table, 'created_by')) await db.prepare(`UPDATE ${table} SET created_by = NULL WHERE created_by IN ${S}`).run(...subjects);
for (const table of ['spaces', 'comment_threads']) if (await hasColumn(db, table, 'created_by')) await db.prepare(`UPDATE ${table} SET created_by = NULL WHERE created_by IN ${S}`).run(...subjects);
+ if (await hasColumn(db, 'space_moderators', 'added_by')) await db.prepare(`UPDATE space_moderators SET added_by = NULL WHERE added_by IN ${S}`).run(...subjects);
// The cached counts and scores follow the rows that stay.
// The cached counts and scores follow the rows that stay.
for (const [table, items] of recount) {
for (const [table, items] of recount) {
for (const i of items) {
for (const i of items) {
@@ -6,6 +6,7 @@
@@ -6,6 +6,7 @@
* one per person per item paste likes, comment and thread votes, post reactions: the survivor's stays where both
* one per person per item paste likes, comment and thread votes, post reactions: the survivor's stays where both
* had one (the other is dropped), then the item's cached count or score is recomputed from its rows
* had one (the other is dropped), then the item's cached count or score is recomputed from its rows
* game progress (one row per person) and the block projection: the survivor's stays; blocking oneself is dropped
* game progress (one row per person) and the block projection: the survivor's stays; blocking oneself is dropped
+ * space moderators the folded-in account's spaces become the survivor's (one row per space and person)
* Idempotent: the pulse consumer's inbox applies an event once, and a second run finds nothing under `from`.
* Idempotent: the pulse consumer's inbox applies an event once, and a second run finds nothing under `from`.
*/
*/
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -14,7 +15,7 @@ const MERGE_RE = /^mrg_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -14,7 +15,7 @@ const MERGE_RE = /^mrg_[0-9A-HJKMNP-TV-Z]{26}$/;
const AUTHORSHIP = [
const AUTHORSHIP = [
['pastes', 'owner_subject'], ['paste_comments', 'author_subject'], ['comments', 'author_subject'], ['threads', 'author_subject'],
['pastes', 'owner_subject'], ['paste_comments', 'author_subject'], ['comments', 'author_subject'], ['threads', 'author_subject'],
['posts', 'author_subject'], ['attachments', 'owner_subject'], ['pulse_items', 'actor_subject'],
['posts', 'author_subject'], ['attachments', 'owner_subject'], ['pulse_items', 'actor_subject'],
- ['submissions', 'author_subject'], ['submissions', 'reviewer_subject'],
+ ['submissions', 'author_subject'], ['submissions', 'reviewer_subject'], ['space_moderators', 'added_by'],
];
];
// Per person per item: [table, item column, recompute(db, itemId)]
// Per person per item: [table, item column, recompute(db, itemId)]
const PER_ITEM = [
const PER_ITEM = [
@@ -57,6 +58,10 @@ async function apply(db, { from, into, merge_id: mergeId }, { log = console } =
@@ -57,6 +58,10 @@ async function apply(db, { from, into, merge_id: mergeId }, { log = console } =
if (await db.prepare('SELECT 1 FROM game_progress WHERE subject_id = ?').get(into)) await db.prepare('DELETE FROM game_progress WHERE subject_id = ?').run(from);
if (await db.prepare('SELECT 1 FROM game_progress WHERE subject_id = ?').get(into)) await db.prepare('DELETE FROM game_progress WHERE subject_id = ?').run(from);
else await db.prepare('UPDATE game_progress SET subject_id = ? WHERE subject_id = ?').run(into, from);
else await db.prepare('UPDATE game_progress SET subject_id = ? WHERE subject_id = ?').run(into, from);
}
}
+ if (await hasColumn(db, 'space_moderators', 'subject_id')) {
+ await db.prepare('DELETE FROM space_moderators WHERE subject_id = ? AND space_id IN (SELECT space_id FROM space_moderators WHERE subject_id = ?)').run(from, into);
+ c.moved += (await db.prepare('UPDATE space_moderators SET subject_id = ? WHERE subject_id = ?').run(into, from)).changes;
+ }
if (await hasColumn(db, 'network_blocks', 'blocker_subject')) {
if (await hasColumn(db, 'network_blocks', 'blocker_subject')) {
for (const [col, other] of [['blocker_subject', 'blocked_subject'], ['blocked_subject', 'blocker_subject']]) {
for (const [col, other] of [['blocker_subject', 'blocked_subject'], ['blocked_subject', 'blocker_subject']]) {
for (const b of await db.prepare(`SELECT ${other} AS o FROM network_blocks WHERE ${col} = ?`).all(from)) {
for (const b of await db.prepare(`SELECT ${other} AS o FROM network_blocks WHERE ${col} = ?`).all(from)) {
@@ -3,11 +3,10 @@
@@ -3,11 +3,10 @@
* IDOR: nobody acts on someone else's things by swapping ids (roadmap WS-R task 5).
* IDOR: nobody acts on someone else's things by swapping ids (roadmap WS-R task 5).
*
*
* alex owns a public paste, a private one, a screenshot, comments, a thread with a reply, a typed comment,
* alex owns a public paste, a private one, a screenshot, comments, a thread with a reply, a typed comment,
- * an uploaded image and a space (as its creator; spaces have no per-space moderators, so a space's owner
- * is what "moderator of X" means here: cora owns another).
- *
- * TODO(T10 step 1): migrations/0005_space_moderators.sql adds per-space moderators; revisit the
- * "no per-space moderators" assumption above (and this file's space expectations) when it lands.
+ * an uploaded image and a space (as its creator: creating a space does not make you its moderator, so
+ * nobody here is listed in space_moderators and a space's owner is what "moderator of X" means here:
+ * cora owns another). Per-space moderators, and their refusals in other spaces, are in
+ * test/space-moderators.test.js.
*
*
* sam tries each of alex's ids as himself, and so do first-party services forwarding for him
* sam tries each of alex's ids as himself, and so do first-party services forwarding for him
* (svc:live, svc:tools, each with the write grants they hold)
* (svc:live, svc:tools, each with the write grants they hold)
new file mode 100644
new file mode 100644
@@ -0,0 +1,163 @@
@@ -0,0 +1,163 @@
+'use strict';
+/**
+ * Per-space moderators (T10 step 1, migrations/0005_space_moderators.sql): a person listed for a space moderates
+ * that space — thread state, deletes, settings, categories, its moderators — and nothing anywhere else; discussion
+ * staff still moderate everywhere; creating spaces and the board index stay with staff. Adding is refused for a
+ * non-moderator and across a block; account deletion erases the row and a subject merge moves it.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+
+(async () => {
+ const t = await boot({
+ appOpts: { forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } },
+ });
+ const net = t.network;
+ const mk = (id, username, role = 'user') => {
+ const u = net.addUser({ network_user_id: id, username, display_name: username });
+ return { ...u, jwt: net.sign({ id, subject_id: u.subject_id, username, display_name: username, role }) };
+ };
+ const alex = mk(31, 'alex'); // moderates general
+ const sam = mk(32, 'sam'); // nobody in particular
+ const bob = mk(33, 'bob'); // writes the threads
+ const carol = mk(34, 'carol'); // added by alex
+ const dave = mk(35, 'dave'); // blocked alex
+ const boss = mk(36, 'boss', 'global_mod');
+ const call = (path, { method = 'GET', who, json } = {}) => t.get(path, {
+ method, headers: json !== undefined ? { 'content-type': 'application/json' } : {}, body: json !== undefined ? JSON.stringify(json) : undefined, cookies: who ? [`ov_token=${who.jwt}`] : [],
+ });
+ const form = (path, who, fields) => t.get(path, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(fields).toString(), cookies: [`ov_token=${who.jwt}`] });
+ const thread = async (space) => {
+ const r = await call(`/api/v1/spaces/${space}/threads`, { method: 'POST', who: bob, json: { title: `A thread in ${space}`, body: 'hello there' } });
+ assert.strictEqual(r.status, 201, r.text);
+ return r.json().thread.slug;
+ };
+ const mods = async (space) => (await call(`/api/v1/spaces/${space}/moderators`)).json().moderators.map((m) => m.subject);
+
+ await check('(d) adding a moderator: refused for a non-moderator, works for staff and then for the space\'s moderator', async () => {
+ let r = await call(`/api/v1/spaces/general/moderators/${alex.subject_id}`, { method: 'PUT', who: sam });
+ assert.strictEqual(r.status, 403, r.text);
+ assert.strictEqual(r.json().code, 'capability.denied');
+ assert.strictEqual((await call(`/api/v1/spaces/general/moderators/${sam.subject_id}`, { method: 'PUT', who: sam })).status, 403, 'nobody adds themselves');
+ assert.strictEqual((await call(`/api/v1/spaces/general/moderators/${alex.subject_id}`, { method: 'PUT' })).status, 403, 'signed out');
+ r = await call(`/api/v1/spaces/general/moderators/${alex.subject_id}`, { method: 'PUT', who: boss });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual(r.json().moderators.map((m) => [m.subject, m.username, m.added_by]), [[alex.subject_id, 'alex', boss.subject_id]]);
+ r = await call(`/api/v1/spaces/general/moderators/${carol.subject_id}`, { method: 'PUT', who: alex });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual((await call(`/api/v1/spaces/general/moderators/${carol.subject_id}`, { method: 'PUT', who: alex })).status, 200, 'idempotent');
+ assert.deepStrictEqual(await mods('general'), [alex.subject_id, carol.subject_id]);
+ assert.strictEqual((await call('/api/v1/spaces/general/moderators/not-a-subject', { method: 'PUT', who: alex })).status, 400);
+ assert.strictEqual((await call(`/api/v1/spaces/no-such-space/moderators/${carol.subject_id}`, { method: 'PUT', who: boss })).status, 404);
+ assert.strictEqual((await call(`/api/v1/spaces/showcase/moderators/${sam.subject_id}`, { method: 'PUT', who: alex })).status, 403, 'not in another space');
+ assert.deepStrictEqual(await mods('showcase'), []);
+ });
+
+ await check('a person who blocked the one adding them (or was blocked by them) cannot be added', async () => {
+ await t.db.prepare('INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, 1, 1, ?)').run(dave.subject_id, alex.subject_id, Date.now());
+ const r = await call(`/api/v1/spaces/general/moderators/${dave.subject_id}`, { method: 'PUT', who: alex });
+ assert.strictEqual(r.status, 403, r.text);
+ assert.strictEqual(r.json().code, 'community.blocked');
+ assert.ok(!(await mods('general')).includes(dave.subject_id));
+ });
+
+ await check('(a) a moderator of general locks, sets the state of and deletes a thread there; can_moderate shows it', async () => {
+ const slug = await thread('general');
+ let r = await call(`/api/v1/spaces/general/threads/${slug}/state`, { method: 'PUT', who: alex, json: { locked: true, pinned: true } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().thread.locked, true);
+ const page = (await call(`/api/v1/spaces/general/threads/${slug}`, { who: alex })).json();
+ assert.strictEqual(page.viewer.can_moderate, true);
+ assert.strictEqual(page.viewer.can_delete, true);
+ assert.strictEqual(page.posts[0].can_edit, true);
+ assert.strictEqual((await call(`/api/v1/spaces/general/threads/${slug}/posts`, { method: 'POST', who: alex, json: { body: 'moderators reply in a locked thread' } })).status, 201);
+ assert.strictEqual((await call(`/api/v1/spaces/general/threads/${slug}/posts`, { method: 'POST', who: sam, json: { body: 'others do not' } })).status, 403);
+ assert.strictEqual((await call('/api/v1/spaces/general/threads', { who: alex })).json().viewer.can_moderate, true);
+ assert.strictEqual((await call('/api/v1/spaces/general/threads', { who: sam })).json().viewer.can_moderate, false);
+ r = await call(`/api/v1/spaces/general/threads/${slug}`, { method: 'DELETE', who: alex });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual((await call(`/api/v1/spaces/general/threads/${slug}`, { who: alex })).status, 404);
+ });
+
+ await check('(b) the same moderator gets 403 in showcase', async () => {
+ const slug = await thread('showcase');
+ assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${slug}/state`, { method: 'PUT', who: alex, json: { locked: true } })).status, 403);
+ assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${slug}`, { method: 'DELETE', who: alex })).status, 403);
+ assert.strictEqual((await call('/api/v1/spaces/showcase/settings', { method: 'PUT', who: alex, json: { name: 'Mine now' } })).status, 403);
+ assert.strictEqual((await call('/api/v1/spaces/showcase/categories/clips', { method: 'PUT', who: alex, json: { name: 'Clips' } })).status, 403);
+ assert.strictEqual((await call('/api/v1/spaces/showcase/members-only', { method: 'PUT', who: alex, json: { owner: alex.subject_id } })).status, 403);
+ assert.strictEqual((await call('/api/v1/spaces/showcase/threads', { who: alex })).json().viewer.can_moderate, false);
+ const page = (await call(`/api/v1/spaces/showcase/threads/${slug}`, { who: alex })).json();
+ assert.strictEqual(page.viewer.can_moderate, false);
+ assert.strictEqual(page.viewer.can_delete, false);
+ assert.strictEqual(page.posts[0].can_edit, false);
+ assert.strictEqual((await form(`/s/showcase/t/${slug}/state`, alex, { locked: '1' })).status, 403, 'the no-JS form too');
+ assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${slug}`, { who: sam })).json().thread.locked, false);
+ });
+
+ await check('(c) a global moderator still passes in both spaces', async () => {
+ for (const space of ['general', 'showcase']) {
+ const slug = await thread(space);
+ assert.strictEqual((await call(`/api/v1/spaces/${space}/threads/${slug}/state`, { method: 'PUT', who: boss, json: { locked: true } })).status, 200, space);
+ assert.strictEqual((await call(`/api/v1/spaces/${space}/threads/${slug}`, { method: 'DELETE', who: boss })).status, 200, space);
+ }
+ });
+
+ await check('settings and categories of their own space; never its place on the board index', async () => {
+ let r = await call('/api/v1/spaces/general/settings', { method: 'PUT', who: alex, json: { description: 'Run by its moderators' } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().space.description, 'Run by its moderators');
+ assert.strictEqual((await call('/api/v1/spaces/general/settings', { method: 'PUT', who: alex, json: { group: null } })).status, 403);
+ assert.strictEqual((await call('/api/v1/spaces/general/settings', { method: 'PUT', who: alex, json: { parent: 'showcase' } })).status, 403);
+ assert.strictEqual((await call('/api/v1/spaces/general/categories/help', { method: 'PUT', who: alex, json: { name: 'Help' } })).status, 200);
+ assert.strictEqual((await call('/api/v1/spaces/general/categories/help', { method: 'DELETE', who: alex })).status, 200);
+ const before = (await call('/api/v1/spaces/general')).json().space.group;
+ r = await form('/s/general/settings', alex, { name: 'General', description: 'From the form', style: 'forum', reactions: '1', group: '' });
+ assert.strictEqual(r.status, 303, r.text);
+ assert.deepStrictEqual((await call('/api/v1/spaces/general')).json().space.group, before, 'the form leaves the group alone');
+ });
+
+ await check('(e) a space moderator cannot create a space or change the board index', async () => {
+ assert.strictEqual((await call('/api/v1/spaces', { method: 'POST', who: alex, json: { slug: 'alex-space', name: 'Alex space' } })).status, 403);
+ assert.strictEqual((await form('/s/new-space', alex, { slug: 'alex-space', name: 'Alex space' })).status, 403);
+ assert.strictEqual((await call('/api/v1/space-groups/alex-group', { method: 'PUT', who: alex, json: { name: 'Alex group' } })).status, 403);
+ assert.strictEqual((await call('/api/v1/spaces/alex-space')).status, 404);
+ });
+
+ await check('no-JS forms add and remove; a moderator removes another (or themselves)', async () => {
+ let r = await form('/s/general/moderators', alex, { subject: sam.subject_id });
+ assert.strictEqual(r.status, 303, r.text);
+ assert.ok((await mods('general')).includes(sam.subject_id));
+ assert.strictEqual((await form('/s/showcase/moderators', alex, { subject: sam.subject_id })).status, 403);
+ r = await form('/s/general/moderators/remove', carol, { subject: sam.subject_id });
+ assert.strictEqual(r.status, 303, r.text);
+ assert.strictEqual((await call(`/api/v1/spaces/general/moderators/${sam.subject_id}`, { method: 'DELETE', who: sam })).status, 403, 'no longer one');
+ r = await call(`/api/v1/spaces/general/moderators/${carol.subject_id}`, { method: 'DELETE', who: carol });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual(await mods('general'), [alex.subject_id]);
+ });
+
+ await check('(f) account deletion erases the row; a subject merge moves it', async () => {
+ const accountData = require('../server/identity/account-data');
+ const merge = require('../server/identity/subject-merge');
+ const ids = async (subject) => (await t.db.prepare('SELECT space_id FROM space_moderators WHERE subject_id = ? ORDER BY space_id').all(subject)).map((r) => Number(r.space_id));
+ await call(`/api/v1/spaces/showcase/moderators/${carol.subject_id}`, { method: 'PUT', who: boss });
+ await call(`/api/v1/spaces/general/moderators/${carol.subject_id}`, { method: 'PUT', who: boss });
+ await call(`/api/v1/spaces/showcase/moderators/${bob.subject_id}`, { method: 'PUT', who: boss });
+ // carol folds into bob: general moves over, showcase (both had it) stays bob's alone.
+ await merge.apply(t.db, { from: carol.subject_id, into: bob.subject_id, merge_id: 'mrg_01JAB2C3D4E5F6G7H8J9K0MNP4' }, { log: { log() {} } });
+ assert.deepStrictEqual(await ids(carol.subject_id), []);
+ assert.deepStrictEqual(await ids(bob.subject_id), [1, 3]);
+ assert.strictEqual((await call('/api/v1/spaces/general/threads', { who: bob })).json().viewer.can_moderate, true);
+ // alex's account is deleted: the row goes, and the moderators alex added stay without them.
+ await call(`/api/v1/spaces/general/moderators/${sam.subject_id}`, { method: 'PUT', who: alex });
+ const out = await accountData.erase(t.db, [alex.subject_id]);
+ assert.strictEqual(out.erased.space_moderators, 1);
+ assert.deepStrictEqual(await ids(alex.subject_id), []);
+ assert.strictEqual((await t.db.prepare('SELECT added_by FROM space_moderators WHERE subject_id = ? AND space_id = 1').get(sam.subject_id)).added_by, null);
+ assert.strictEqual((await call('/api/v1/spaces/general/threads', { who: alex })).json().viewer.can_moderate, false);
+ });
+
+ await t.close();
+ done();
+})();