openvibers/openvibe.community Commits

Let each space have its own moderators who moderate only that space

OP OpenVibers committed 4 Oct 2026 · 4c08128108 · 12 files, +434 −64

Asked: T10 step 1 — OpenVibe.Community: per-space moderators (feature 3 of the Community hub, the only missing code piece). Authorization change: security-critical. ONE pull request from current origin/main (git fetch first; read with `git show origin/main:<path>`, the local checkout may be stale). The gr…
Harness job community-t10-step-1-openvibe-community-175624 (plan T10).

Changes

ddiff --git a/README.md b/README.md
iindex 4d2603f..2c4e2d9 100644
---- a/README.md
++++ b/README.md
@@@ -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
thread). 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)
ddiff --git a/STATUS.json b/STATUS.json
iindex 78cd8e4..a5a2bed 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
}
ddiff --git a/docs/cutover-community-0005-space-moderators.md b/docs/cutover-community-0005-space-moderators.md
nnew file mode 100644
iindex 0000000..d44f1e3
---- /dev/null
++++ b/docs/cutover-community-0005-space-moderators.md
@@@ -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.
ddiff --git a/migrations/0005_space_moderators.sql b/migrations/0005_space_moderators.sql
nnew file mode 100644
iindex 0000000..2bd9bb7
---- /dev/null
++++ b/migrations/0005_space_moderators.sql
@@@ -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);
ddiff --git a/server/forum/api.js b/server/forum/api.js
iindex 78f299e..75ce80c 100644
---- a/server/forum/api.js
++++ b/server/forum/api.js
@@@ -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;
ddiff --git a/server/forum/routes.js b/server/forum/routes.js
iindex 4ed7549..481cf00 100644
---- a/server/forum/routes.js
++++ b/server/forum/routes.js
@@@ -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) => {
ddiff --git a/server/forum/service.js b/server/forum/service.js
iindex 2937f6e..a60e0ee 100644
---- a/server/forum/service.js
++++ b/server/forum/service.js
@@@ -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, vi
const 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, vi
attached_at: isoTime(r.attached_at),
} : null);
/** The space's owner (its creator, or the creator whose VIP members' space it is) or discussion staff. */
-- const canManageChatRoom = (v, space) => moderator(v) || (person(v) && (space.created_by === v.subject || space.members_only_owner === v.subject));
++ 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, vi
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 mine = person(v) && p.author_subject === v.subject;
return {
@@@ -321,7 +328,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
body_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, vi
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;
-- 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, vi
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). */
async function spaceFields(body, space) {
const f = {};
@@@ -429,6 +453,48 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
POSTS_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, vi
async 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, vi
async 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, 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 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, vi
const { 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, vi
const 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, vi
page, 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, vi
const 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, vi
const 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, vi
const { 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, vi
async 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);
ddiff --git a/server/forum/store.js b/server/forum/store.js
iindex ab698ad..2b7c3c9 100644
---- a/server/forum/store.js
++++ b/server/forum/store.js
@@@ -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,
};
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 6b0afee..1376687 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -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) {
ddiff --git a/server/identity/subject-merge.js b/server/identity/subject-merge.js
iindex 9cda34c..0488585 100644
---- a/server/identity/subject-merge.js
++++ b/server/identity/subject-merge.js
@@@ -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)) {
ddiff --git a/test/security-idor.test.js b/test/security-idor.test.js
iindex ec53151..3458288 100644
---- a/test/security-idor.test.js
++++ b/test/security-idor.test.js
@@@ -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)
ddiff --git a/test/space-moderators.test.js b/test/space-moderators.test.js
nnew file mode 100644
iindex 0000000..af3fa19
---- /dev/null
++++ b/test/space-moderators.test.js
@@@ -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();
++})();