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)
Changes
@@@ -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)@@@ -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; }@@@ -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@@@ -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.@@@ -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 } });});@@@ -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 };@@@ -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">${esreturn 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 };@@@ -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' });});});@@@ -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'],@@@ -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');
@@ -391,7 +391,7 @@ API (`/api/v1/spaces`, `/api/v1/posts`, problem+json errors):
@@ -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) |
| `DELETE /spaces/:space/chat-room` | The space's owner or staff (moderator services too): detach it (idempotent) |
| `PUT /posts/:id` `{ body }` · `DELETE /posts/:id` · `GET /posts/:id/versions` | Author or moderator |
| `PUT /posts/:id` `{ body }` · `DELETE /posts/:id` · `GET /posts/:id/versions` | Author or moderator |
| `GET /spaces/:space/moderators` | The space's own moderators `{ space, moderators: [{ subject, username, display_name, added_by, added_at }] }` (whoever can read the space) |
| `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)
### A space's chat room (OpenVibe.Chat)
@@ -259,6 +259,9 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-
@@ -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 { 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; }
.chat-room h2 { margin: 0 0 6px; }
.space-settings summary, .crosspost-form summary { cursor: pointer; color: var(--text-secondary); font-weight: 600; }
.space-settings summary, .crosspost-form summary { cursor: pointer; color: var(--text-secondary); font-weight: 600; }
+.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; }
.discuss-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
.postbits { display: grid; gap: 12px; margin: 12px 0; }
.postbits { display: grid; gap: 12px; margin: 12px 0; }
.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; }
.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; }
@@ -31,7 +31,8 @@
@@ -31,7 +31,8 @@
* themselves (Chat checks they manage the room) → { chat_room, created }
* themselves (Chat checks they manage the room) → { chat_room, created }
* DELETE /spaces/:space/chat-room the space's owner or staff (idempotent) → { detached, chat }
* DELETE /spaces/:space/chat-room the space's owner or staff (idempotent) → { detached, chat }
* GET /spaces/:space/moderators the space's own moderators → { space, moderators: [{ subject, username, display_name, added_by, added_at }] }
* 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)
* DELETE /spaces/:space/moderators/:subject remove one — the space's moderators or staff (idempotent)
* POST /posts/:id/reactions { reaction: agree|winner|funny|informative|friendly|sympathy|dumb|disgusting|bad_reading|late|null }
* POST /posts/:id/reactions { reaction: agree|winner|funny|informative|friendly|sympathy|dumb|disgusting|bad_reading|late|null }
* PUT /posts/:id { body } DELETE /posts/:id GET /posts/:id/versions
* PUT /posts/:id { body } DELETE /posts/:id GET /posts/:id/versions
@@ -129,8 +129,10 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -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 });
const out = await forum.listThreads(req.viewer, req.params.space, { sort: req.query.sort, page: req.query.page, category: req.query.category, status: req.query.status });
if (out.page > out.pages) throw new ApiError(404, 'page.not_found', 'There is no page with that number.');
if (out.page > out.pages) throw new ApiError(404, 'page.not_found', 'There is no page with that number.');
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 });
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); }
} catch (err) { failPage(req, res, err, next); }
}));
}));
@@ -259,18 +261,27 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -259,18 +261,27 @@ function createForumRoutes({ forum, viewers, config }) {
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
}));
}));
- // The space's own moderators (no JS): add one by Network subject, remove one; back to the space.
+ // 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) => {
router.post('/s/:space/moderators', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
try {
try {
await forum.addModerator(req.viewer, req.params.space, String((req.body || {}).subject || '').trim().slice(0, 64));
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) => {
router.post('/s/:space/moderators/remove', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
try {
try {
await forum.removeModerator(req.viewer, req.params.space, String((req.body || {}).subject || '').trim().slice(0, 64));
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.
// The space's chat room (no JS): attach by address or link, detach. A refusal comes back as a notice on the space.
@@ -415,6 +415,23 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -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. */
/** The space, when the viewer moderates it: 404 (no such space), else 403. */
async function moderatedSpace(v, slug, what) {
async function moderatedSpace(v, slug, what) {
const space = await spaceFor(v, slug);
const space = await spaceFor(v, slug);
@@ -471,9 +488,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -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
* 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.
* 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');
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))) {
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');
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
@@ -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. */
/** 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');
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 () => {
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 } });
if (await store.removeModerator(db, space.id, subject)) await events.moderationAction(v, 'space.moderator_removed', { type: 'space', id: space.slug }, { details: { subject } });
});
});
@@ -111,6 +111,30 @@ function createNetworkIdentity({ config, db, fetchImpl = globalThis.fetch } = {}
@@ -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;
let inflight = null;
const pending = new Set();
const pending = new Set();
/** Background refresh of stale entries, batched and deduplicated. */
/** Background refresh of stale entries, batched and deduplicated. */
@@ -149,7 +173,7 @@ function createNetworkIdentity({ config, db, fetchImpl = globalThis.fetch } = {}
@@ -149,7 +173,7 @@ function createNetworkIdentity({ config, db, fetchImpl = globalThis.fetch } = {}
return map;
return map;
}
}
- return { resolveSubjects, resolveLegacy, subjectForNetworkUser, rememberClaims, projections, tokens };
+ return { resolveSubjects, resolveLegacy, subjectForNetworkUser, subjectForUsername, rememberClaims, projections, tokens };
}
}
module.exports = { createNetworkIdentity, PROJECTION_TTL_MS };
module.exports = { createNetworkIdentity, PROJECTION_TTL_MS };
@@ -12,7 +12,7 @@ const ld = require('./jsonld');
@@ -12,7 +12,7 @@ const ld = require('./jsonld');
const { renderPage } = require('./layout');
const { renderPage } = require('./layout');
const { escapeHtml: esc } = require('./highlight');
const { escapeHtml: esc } = require('./highlight');
const { markdownToText } = require('./markdown');
const { markdownToText } = require('./markdown');
-const { timeTag, fmtDate, num, avatarUrl } = require('./pages');
+const { timeTag, fmtDate, num, avatarUrl, authorHtml } = require('./pages');
const f = require('./forum');
const f = require('./forum');
const STYLE_LABELS = { forum: 'Forum', feed: 'Feed' };
const STYLE_LABELS = { forum: 'Forum', feed: 'Feed' };
@@ -108,8 +108,29 @@ function settingsForm(space, groups = []) {
@@ -108,8 +108,29 @@ function settingsForm(space, groups = []) {
</form></details>`;
</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) ──────────────────────────────────
// ── /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 filtered = !!(category || status);
const indexable = space.visibility === 'public' && !space.members_only && !filtered;
const indexable = space.visibility === 'public' && !space.members_only && !filtered;
const href = (o = {}) => f.spaceHref(space.slug, { sort: 'active', category, status, ...o }).replace(/[?&]sort=active/, '').replace(/\?&/, '?').replace(/\?$/, '');
const href = (o = {}) => f.spaceHref(space.slug, { sort: 'active', category, status, ...o }).replace(/[?&]sort=active/, '').replace(/\?&/, '?').replace(/\?$/, '');
@@ -136,7 +157,7 @@ ${threads.length ? `<table class="board-table topic-table"><caption class="sr-on
@@ -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>`}
<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}
${pagerHtml}
</section>
</section>
-${viewer.can_moderate ? settingsForm(space, groups) : ''}`;
+${viewer.can_moderate ? settingsForm(space, groups) + moderatorsBox(space, moderators, { me, error: modError }) : ''}`;
return renderPage({
return renderPage({
title: `${space.name}${page > 1 ? ` (page ${page})` : ''}`,
title: `${space.name}${page > 1 ? ` (page ${page})` : ''}`,
description: `${space.description || `Topics in ${space.name}`} ${plural(total, 'topic', 'topics')} on OpenVibe.Community.`,
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
@@ -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 });
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 };
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
* Also the Wave-1 service-principal surface Community uses in 'community' authority mode:
* 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)
* - client_credentials grants (form-encoded, as openvibe-contracts' token client sends them)
* mint service tokens with the requested audience and scope as capabilities;
* 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.
* token for openvibe.network holding identity.subject.resolve.
* signService() mints tokens as another service (e.g. Live) would present them to Community.
* signService() mints tokens as another service (e.g. Live) would present them to Community.
*/
*/
@@ -86,6 +86,18 @@ function start({ clientSecret = 'shh' } = {}) {
@@ -86,6 +86,18 @@ function start({ clientSecret = 'shh' } = {}) {
}
}
return json(200, { results });
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' });
json(404, { error: 'not found' });
});
});
});
});
@@ -26,7 +26,7 @@ const INVENTORY = {
@@ -26,7 +26,7 @@ const INVENTORY = {
'server/auth/routes.js': [4, 'createAuthClient, JWKS, /oauth/token, /oauth/revoke: OV_NETWORK_INTERNAL_URL, fixed paths'],
'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/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/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/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/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'],
'server/identity/profile-module.js': [3, 'Network modules API (community.profile) with a service token'],
@@ -47,7 +47,8 @@ const { boot, check, done } = require('./helpers/app');
@@ -47,7 +47,8 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(r.status, 200, r.text);
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.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.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/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.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'), []);
assert.deepStrictEqual(await mods('showcase'), []);
@@ -137,6 +138,36 @@ const { boot, check, done } = require('./helpers/app');
@@ -137,6 +138,36 @@ const { boot, check, done } = require('./helpers/app');
assert.deepStrictEqual(await mods('general'), [alex.subject_id]);
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 () => {
await check('(f) account deletion erases the row; a subject merge moves it', async () => {
const accountData = require('../server/identity/account-data');
const accountData = require('../server/identity/account-data');
const merge = require('../server/identity/subject-merge');
const merge = require('../server/identity/subject-merge');