openvibers/openvibe.community Commits

Show a space's moderators a Moderators box on the space page: remove, step down, add by @username (resolved through the Network) (#17)

OP OpenVibers committed 5 Oct 2026 · 98d08cf37d · 10 files, +141 −21

* Show a space's moderators a Moderators box on the space page: remove, step down, add by @username (resolved through the Network)

A space's moderators now see who moderates it and can manage that from the space page, without JS (plan T10 feature 3: the API and form routes from #15 had nothing rendering them).

- **Moderators box** (server/render/board.js moderatorsBox) under the settings in both styles (forum page and feed footer), shown only when viewer.can_moderate:
  - one row per moderator (the shared author chip, "since <date>");
  - a Remove button per row, or "Step down" on your own row;
  - an "Add a moderator" field that takes an @username.
- **Name a person by @username:** forum service moderatorSubject() accepts a usr_ subject as before, or an @username resolved through the Network (GET /internal/identity/resolve?username=, the identity.subject.resolve capability Community already holds; server/identity/network.js subjectForUsername):
  - an unknown or banned name is 404 moderator.unknown_user;
  - a malformed value is 400;
  - Network down is 503 identity.unavailable.
  The API PUT/DELETE /spaces/:space/moderators/:subject takes @username too.
- **Refusals stay on the page:** an unknown name, a block or a malformed value redirects back to /s/:space?mod_error=…#moderators and shows as an alert in the box, which opens; this mirrors the chat-room box.
- **Tests:** the mock Network answers the username resolve. test/space-moderators.test.js covers:
  - adding by @username in any case, and through the API;
  - the unknown-name notice;
  - in both styles, the box with its add form, rows, Step down and named remove buttons, absent for a non-moderator and for a visitor;
  - 404 vs 400 on the API.
  ov test test/space-moderators.test.js test/forum-ssr.test.js test/perf-budget.test.js test/asset-cache.test.js test/ssr-community.test.js: all pass.

Built in the main session (Opus).

* The API comment names @username for adding a moderator (review)

* Classify the username resolve in the outbound-call inventory

Changes

ddiff --git a/README.md b/README.md
iindex 2c4e2d9..8ef666f 100644
---- a/README.md
++++ b/README.md
@@@ -391,7 +391,7 @@ API (`/api/v1/spaces`, `/api/v1/posts`, problem+json errors):
| `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 }` |
++| `PUT`/`DELETE /spaces/:space/moderators/:subject` | The space's moderators or staff: add (blocks refuse, 403 `community.blocked`) or remove one (idempotent). `:subject` is a Network user subject (`usr_…`) or an `@username`, resolved through the Network (404 `moderator.unknown_user` when nobody holds it). The space page shows its moderators a Moderators box (list, remove or step down, add by @username); its no-JS forms are `POST /s/:space/moderators` and `POST /s/:space/moderators/remove` `{ subject }`, and a refusal comes back as a notice in the box |
### A space's chat room (OpenVibe.Chat)
ddiff --git a/public/css/community.css b/public/css/community.css
iindex f20fd6e..7792ab5 100644
---- a/public/css/community.css
++++ b/public/css/community.css
@@@ -259,6 +259,9 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-
.chat-room { margin: 12px 0 16px; padding: 10px 14px; border: 1px solid var(--border-light); border-radius: 10px; background: var(--bg-card); }
.chat-room h2 { margin: 0 0 6px; }
.space-settings summary, .crosspost-form summary { cursor: pointer; color: var(--text-secondary); font-weight: 600; }
++.mod-list { list-style: none; padding: 0; margin: 10px 0; display: grid; gap: 6px; }
++.mod-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; padding: 6px 10px; border: 1px solid var(--border-light); border-radius: 8px; background: var(--bg-card); }
++.mod-row .inline-form { margin-left: auto; }
.discuss-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
.postbits { display: grid; gap: 12px; margin: 12px 0; }
.postbit { display: grid; grid-template-columns: 170px 1fr; background: var(--bg-card); border: 1px solid var(--border-light); border-radius: 12px; scroll-margin-top: 80px; }
ddiff --git a/server/forum/api.js b/server/forum/api.js
iindex 75ce80c..49f9ac5 100644
---- a/server/forum/api.js
++++ b/server/forum/api.js
@@@ -31,7 +31,8 @@
* 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)
++ * PUT /spaces/:space/moderators/:subject add a person (usr_… or @username, resolved through the Network; 404 moderator.unknown_user)
++ * — 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
ddiff --git a/server/forum/routes.js b/server/forum/routes.js
iindex 481cf00..43e3a47 100644
---- a/server/forum/routes.js
++++ b/server/forum/routes.js
@@@ -129,8 +129,10 @@ function createForumRoutes({ forum, viewers, config }) {
const out = await forum.listThreads(req.viewer, req.params.space, { sort: req.query.sort, page: req.query.page, category: req.query.category, status: req.query.status });
if (out.page > out.pages) throw new ApiError(404, 'page.not_found', 'There is no page with that number.');
const chatRoom = forumPages.chatRoomBox(out.space, { canManage: out.viewer.can_manage_chat_room, error: typeof req.query.chat_error === 'string' ? req.query.chat_error.slice(0, 200) : null });
-- if (out.space.style === 'forum') return html(res, boardPages.forumSpacePage({ ...out, user: req.user, chatRoom }));
-- html(res, forumPages.spacePage({ ...out, user: req.user, chatRoom, footer: out.viewer.can_moderate ? boardPages.settingsForm(out.space, out.groups) : '' }));
++ const me = req.viewer && req.viewer.subject;
++ const modError = typeof req.query.mod_error === 'string' ? req.query.mod_error.slice(0, 200) : null;
++ if (out.space.style === 'forum') return html(res, boardPages.forumSpacePage({ ...out, user: req.user, chatRoom, me, modError }));
++ html(res, forumPages.spacePage({ ...out, user: req.user, chatRoom, footer: out.viewer.can_moderate ? boardPages.settingsForm(out.space, out.groups) + boardPages.moderatorsBox(out.space, out.moderators || [], { me, error: modError }) : '' }));
} catch (err) { failPage(req, res, err, next); }
}));
@@@ -259,18 +261,27 @@ function createForumRoutes({ forum, viewers, config }) {
} 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.
++ // The space's own moderators (no JS): add one by @username (or Network subject), remove one; back to the space's
++ // moderators box. A refusal about the person named (unknown, blocked, malformed) comes back as a notice there.
++ const modsBack = (req, error) => `/s/${encodeURIComponent(req.params.space)}${error ? `?mod_error=${encodeURIComponent(error)}` : ''}#moderators`;
++ const modRefusal = (err) => err instanceof ApiError && (String(err.code).startsWith('moderator.') || err.code === 'community.blocked' || err.code === 'identity.unavailable');
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); }
++ res.redirect(303, modsBack(req));
++ } catch (err) {
++ if (modRefusal(err)) return res.redirect(303, modsBack(req, err.message));
++ 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); }
++ res.redirect(303, modsBack(req));
++ } catch (err) {
++ if (modRefusal(err)) return res.redirect(303, modsBack(req, err.message));
++ 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.
ddiff --git a/server/forum/service.js b/server/forum/service.js
iindex a60e0ee..e558689 100644
---- a/server/forum/service.js
++++ b/server/forum/service.js
@@@ -415,6 +415,23 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
});
}
++ /**
++ * The person a moderator form or API call names: a Network user subject (usr_…) as it is, or an @username
++ * resolved through the Network (identity.subject.resolve). 400 when it is neither, 404 when nobody holds the name.
++ */
++ async function moderatorSubject(who) {
++ const s = String(who || '').trim();
++ if (isUserSubject(s)) return s;
++ if (!/^@?[A-Za-z0-9_.-]{1,40}$/.test(s)) fail(400, 'moderator.invalid_subject', 'Name the person by their @username');
++ let subject = null;
++ if (network && network.subjectForUsername) {
++ try { subject = await network.subjectForUsername(s); }
++ catch { fail(503, 'identity.unavailable', 'Accounts cannot be looked up right now; try again in a minute'); }
++ }
++ if (!subject) fail(404, 'moderator.unknown_user', `No OpenVibe account is called @${s.replace(/^@/, '')}`);
++ return subject;
++ }
++
/** 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);
@@@ -471,9 +488,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
* 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) {
++ async addModerator(v, spaceSlug, who) {
const space = await moderatedSpace(v, spaceSlug, 'add moderators');
-- if (!isUserSubject(subject)) fail(400, 'moderator.invalid_subject', 'Name the person by their Network subject (usr_…)');
++ const subject = await moderatorSubject(who);
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');
}
@@@ -487,9 +504,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
},
/** Remove one of the space's moderators (themselves included) — its moderators or discussion staff. Idempotent. */
-- async removeModerator(v, spaceSlug, subject) {
++ async removeModerator(v, spaceSlug, who) {
const space = await moderatedSpace(v, spaceSlug, 'remove moderators');
-- if (!isUserSubject(subject)) fail(400, 'moderator.invalid_subject', 'Name the person by their Network subject (usr_…)');
++ const subject = await moderatorSubject(who);
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 } });
});
ddiff --git a/server/identity/network.js b/server/identity/network.js
iindex 41c3d68..5fe4530 100644
---- a/server/identity/network.js
++++ b/server/identity/network.js
@@@ -111,6 +111,30 @@ function createNetworkIdentity({ config, db, fetchImpl = globalThis.fetch } = {}
});
}
++ /**
++ * The user subject an @username names right now (a leading @ and case are ignored), or null when nobody holds it
++ * or the account is banned or deleted (Network answers 404). Remembered in the projection cache like any lookup.
++ */
++ async function subjectForUsername(username, retried = false) {
++ const name = String(username || '').trim().replace(/^@/, '');
++ if (!/^[A-Za-z0-9_.-]{1,40}$/.test(name)) return null;
++ const res = await fetchImpl(`${base}/internal/identity/resolve?username=${encodeURIComponent(name)}`, {
++ headers: { Accept: 'application/json', ...(await tokens.authHeaders()) },
++ signal: AbortSignal.timeout(8000),
++ });
++ if (res.status === 401 && !retried) { tokens.invalidate(); return await subjectForUsername(name, true); }
++ if (res.status === 404) return null;
++ const p = await res.json().catch(() => null);
++ if (!res.ok || !p || !p.subject) {
++ const err = new Error(`resolve ${res.status}: ${(p && (p.detail || p.error)) || 'bad response'}`);
++ err.status = res.status;
++ throw err;
++ }
++ if (p.subject.type !== 'user') return null;
++ await remember(p);
++ return p.subject.id;
++ }
++
let inflight = null;
const pending = new Set();
/** Background refresh of stale entries, batched and deduplicated. */
@@@ -149,7 +173,7 @@ function createNetworkIdentity({ config, db, fetchImpl = globalThis.fetch } = {}
return map;
}
-- return { resolveSubjects, resolveLegacy, subjectForNetworkUser, rememberClaims, projections, tokens };
++ return { resolveSubjects, resolveLegacy, subjectForNetworkUser, subjectForUsername, rememberClaims, projections, tokens };
}
module.exports = { createNetworkIdentity, PROJECTION_TTL_MS };
ddiff --git a/server/render/board.js b/server/render/board.js
iindex 8618210..6c9df29 100644
---- a/server/render/board.js
++++ b/server/render/board.js
@@@ -12,7 +12,7 @@ const ld = require('./jsonld');
const { renderPage } = require('./layout');
const { escapeHtml: esc } = require('./highlight');
const { markdownToText } = require('./markdown');
--const { timeTag, fmtDate, num, avatarUrl } = require('./pages');
++const { timeTag, fmtDate, num, avatarUrl, authorHtml } = require('./pages');
const f = require('./forum');
const STYLE_LABELS = { forum: 'Forum', feed: 'Feed' };
@@@ -108,8 +108,29 @@ function settingsForm(space, groups = []) {
</form></details>`;
}
++/**
++ * Moderators: who moderates the space, a remove (or "Step down") button per person, and an add-by-@username form, no
++ * JS. `me` is the viewer's subject; `error` is a refused add or remove, shown in the box (which then opens).
++ */
++function moderatorsBox(space, moderators = [], { me = null, error = null } = {}) {
++ const slug = esc(space.slug);
++ const rows = moderators.map((m) => {
++ const name = m.username ? `@${m.username}` : (m.display_name || 'this person');
++ const self = me && m.subject === me;
++ return `<li class="mod-row">${authorHtml(m)}${m.added_at ? ` <span class="muted small">since ${timeTag(m.added_at)}</span>` : ''}
++ <form class="inline-form" method="post" action="/s/${slug}/moderators/remove"><input type="hidden" name="subject" value="${esc(m.subject)}"><button class="btn btn-sm btn-ghost" type="submit"${self ? '' : ` aria-label="Remove ${esc(name)} as a moderator"`}>${self ? 'Step down' : 'Remove'}</button></form></li>`;
++ }).join('');
++ return `<details class="space-settings" id="moderators"${error ? ' open' : ''}><summary><i class="fa-solid fa-user-shield" aria-hidden="true"></i> Moderators (${moderators.length})</summary>
++${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
++${rows ? `<ul class="mod-list">${rows}</ul>` : '<p class="muted small">No one moderates this space yet; discussion staff do.</p>'}
++<form class="paste-form" method="post" action="/s/${slug}/moderators">
++ <label class="field"><span>Add a moderator</span><input name="subject" required maxlength="41" placeholder="@username" autocomplete="off" autocapitalize="none" spellcheck="false"></label>
++ <div class="form-actions"><button class="btn btn-primary" type="submit">Add moderator</button></div>
++</form></details>`;
++}
++
// ── /s/:space (forum style) ──────────────────────────────────
--function forumSpacePage({ space, threads, page, pages, total, user, categories = [], category = null, status = null, viewer = {}, children = [], groups = [], chatRoom = '' }) {
++function forumSpacePage({ space, threads, page, pages, total, user, categories = [], category = null, status = null, viewer = {}, children = [], groups = [], chatRoom = '', moderators = [], me = null, modError = null }) {
const filtered = !!(category || status);
const indexable = space.visibility === 'public' && !space.members_only && !filtered;
const href = (o = {}) => f.spaceHref(space.slug, { sort: 'active', category, status, ...o }).replace(/[?&]sort=active/, '').replace(/\?&/, '?').replace(/\?$/, '');
@@@ -136,7 +157,7 @@ ${threads.length ? `<table class="board-table topic-table"><caption class="sr-on
<tbody>${threads.map((t) => topicRow(t, space)).join('')}</tbody></table>` : `<p class="empty">${filtered ? `Nothing here yet. <a href="${esc(href({ category: null, status: null }))}">See every topic</a>.` : viewer.can_start !== false ? `No topics yet — <a href="/s/${esc(space.slug)}/new">start the first one</a>.` : 'Nothing here yet.'}</p>`}
${pagerHtml}
</section>
--${viewer.can_moderate ? settingsForm(space, groups) : ''}`;
++${viewer.can_moderate ? settingsForm(space, groups) + moderatorsBox(space, moderators, { me, error: modError }) : ''}`;
return renderPage({
title: `${space.name}${page > 1 ? ` (page ${page})` : ''}`,
description: `${space.description || `Topics in ${space.name}`} ${plural(total, 'topic', 'topics')} on OpenVibe.Community.`,
@@@ -277,4 +298,4 @@ ${groups.map((g) => `<section class="board-group"><h2 class="board-group-h">${es
return renderPage({ title: 'Discuss in a space', canonicalPath: '/s/discuss', robots: 'noindex,follow', active: 'spaces', footerVariant: 'compact', body });
}
--module.exports = { boardIndexPage, forumSpacePage, forumTopicPage, newSpacePage, settingsForm, discussPage };
++module.exports = { boardIndexPage, forumSpacePage, forumTopicPage, newSpacePage, settingsForm, moderatorsBox, discussPage };
ddiff --git a/test/helpers/mock-network.js b/test/helpers/mock-network.js
iindex 9831bed..f547d07 100644
---- a/test/helpers/mock-network.js
++++ b/test/helpers/mock-network.js
@@@ -5,7 +5,7 @@
* Also the Wave-1 service-principal surface Community uses in 'community' authority mode:
* - client_credentials grants (form-encoded, as openvibe-contracts' token client sends them)
* mint service tokens with the requested audience and scope as capabilities;
-- * - POST /internal/identity/resolve-batch answers from `directory` and insists on a service
++ * - POST /internal/identity/resolve-batch (and GET /internal/identity/resolve?username=) answer from `directory` and insist on a service
* token for openvibe.network holding identity.subject.resolve.
* signService() mints tokens as another service (e.g. Live) would present them to Community.
*/
@@@ -86,6 +86,18 @@ function start({ clientSecret = 'shh' } = {}) {
}
return json(200, { results });
}
++ if (req.url.startsWith('/internal/identity/resolve?') && req.method === 'GET') {
++ // GET /internal/identity/resolve?username= (Network #56): the current holder of a name, any case, a
++ // leading @ ignored; 404 for nobody (or a banned account).
++ const auth = String(req.headers.authorization || '');
++ const v = serviceAuth.verifyServiceToken(auth.slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });
++ if (!v.ok) return json(401, { code: v.code, error: v.reason });
++ if (!(v.claims.cap || []).includes('identity.subject.resolve')) return json(403, { code: 'capability.denied', error: 'not granted' });
++ if (directory.down) return json(503, { error: 'down' });
++ const name = String(new URL(req.url, 'http://x').searchParams.get('username') || '').replace(/^@/, '').toLowerCase();
++ const u = directory.users.find((x) => String(x.username || '').toLowerCase() === name && !x.banned);
++ return u ? json(200, projection(u)) : json(404, { code: 'identity.subject_not_found' });
++ }
json(404, { error: 'not found' });
});
});
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 33dbb85..84c475d 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -26,7 +26,7 @@ const INVENTORY = {
'server/auth/routes.js': [4, 'createAuthClient, JWKS, /oauth/token, /oauth/revoke: OV_NETWORK_INTERNAL_URL, fixed paths'],
'server/chat-rooms.js': [1, 'OV_CHAT_INTERNAL_URL /api/chat/rooms/<slug>/attachments; the slug must match ROOM_SLUG and is encoded'],
'server/events.js': [3, 'service token (Network) and the outbox publisher: EVENTS_URL'],
-- 'server/identity/network.js': [2, 'Network /internal/identity/resolve-batch with a service token'],
++ 'server/identity/network.js': [3, 'Network /internal/identity/resolve-batch and /internal/identity/resolve?username= with a service token (configured base, never a user URL)'],
'server/render/pages.js': [1, 'not a call: the home page shows a fetch() sample as text (never executed)'],
'server/identity/account-data.js': [2, 'Network /oauth/token and /internal/account-exports and /internal/account-deletions (fixed paths) with a service token'],
'server/identity/profile-module.js': [3, 'Network modules API (community.profile) with a service token'],
ddiff --git a/test/space-moderators.test.js b/test/space-moderators.test.js
iindex af3fa19..f472875 100644
---- a/test/space-moderators.test.js
++++ b/test/space-moderators.test.js
@@@ -47,7 +47,8 @@ const { boot, check, done } = require('./helpers/app');
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/general/moderators/not-a-subject', { method: 'PUT', who: alex })).status, 404, 'a name nobody holds');
++ assert.strictEqual((await call(`/api/v1/spaces/general/moderators/${encodeURIComponent('not a name!')}`, { method: 'PUT', who: alex })).status, 400, 'neither a subject nor a name');
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'), []);
@@@ -137,6 +138,36 @@ const { boot, check, done } = require('./helpers/app');
assert.deepStrictEqual(await mods('general'), [alex.subject_id]);
});
++ await check('the space page gives its moderators a Moderators box (add by @username, remove, step down) in both styles; nobody else sees it', async () => {
++ let r = await form('/s/general/moderators', alex, { subject: '@Sam' });
++ assert.strictEqual(r.status, 303, r.text);
++ assert.match(String(r.headers.get('location')), /^\/s\/general#moderators$/);
++ assert.ok((await mods('general')).includes(sam.subject_id), 'added by @username, in any case');
++ r = await form('/s/general/moderators', alex, { subject: '@nobody-here' });
++ assert.strictEqual(r.status, 303, r.text);
++ assert.match(String(r.headers.get('location')), /^\/s\/general\?mod_error=.+#moderators$/, 'an unknown name comes back as a notice');
++ assert.match(String((await form('/s/general/moderators', alex, { subject: 'not a name!' })).headers.get('location')), /mod_error=/);
++ assert.strictEqual((await call('/api/v1/spaces/general/moderators/%40sam', { method: 'PUT', who: alex })).status, 200, 'the API takes @username too');
++ for (const style of ['forum', 'feed']) {
++ assert.strictEqual((await form('/s/general/settings', alex, { name: 'General', description: 'From the form', style, reactions: '1', group: '' })).status, 303);
++ const page = await call(`/s/general?mod_error=${encodeURIComponent('No OpenVibe account is called @nobody-here')}`, { who: alex });
++ assert.strictEqual(page.status, 200, page.text);
++ assert.ok(page.text.includes('<details class="space-settings" id="moderators" open>'), `${style}: the box, open on a refusal`);
++ assert.ok(page.text.includes('No OpenVibe account is called @nobody-here'), `${style}: the refusal shows`);
++ assert.ok(page.text.includes('action="/s/general/moderators"'), `${style}: the add form`);
++ assert.ok(page.text.includes(`name="subject" value="${sam.subject_id}"`), `${style}: sam is listed with a remove form`);
++ assert.ok(page.text.includes('>Step down</button>'), `${style}: alex can step down`);
++ assert.ok(page.text.includes('aria-label="Remove @sam as a moderator"'), `${style}: the remove button names the person`);
++ for (const who of [bob, undefined]) {
++ const other = await call('/s/general', { who });
++ assert.ok(!other.text.includes('id="moderators"') && !other.text.includes('/moderators"'), `${style}: not for ${who ? 'a non-moderator' : 'a visitor'}`);
++ }
++ }
++ r = await form('/s/general/moderators/remove', alex, { subject: sam.subject_id });
++ assert.strictEqual(r.status, 303, 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');