Moderation
For how to use this, see Moderation.
On top of the automatic profanity filtering (comments, bios, display names, send-link messages and proposals, all via glin-profanity in apps/api/src/lib/profanity.js), the hub has a moderation layer with two privilege tiers and a moderation page at /moderation (apps/web/src/pages/ModerationPage.jsx). The page is linked from the account menu only for moderators and admins, and robots.txt (routes/seo.js) disallows it.
The browser holds no authority of its own. lib/auth.jsx asks GET /mod/whoami for the caller's role whenever the access token changes and exposes role, isModerator and isAdmin, which only decide which controls to render. The API re-derives the caller's role from user_roles on every privileged request (verifyUserAndRole in apps/api/src/lib/auth.js), so a tampered client can never grant itself power.
The API's moderation routes live at /mod, not /moderation, on purpose: the API sees every request before the static assets do, so a route at the same path as the SPA's /moderation page would swallow that page's direct loads and answer them with 401. See the registration comment in apps/api/src/app.js. The handler is apps/api/src/routes/moderation.js; the frontend wrapper is @spelling-creator/core/moderation.
Roles
Roles live in the user_roles table. A normal signed-in user is a plain author who can only touch their own content. Above that:
- Moderator: delete any comment, close any proposed change, shadowban a lesson, ban users by name, and request that a lesson be fully deleted.
- Admin: everything a moderator can do, plus add moderators, approve or deny a moderator's deletion request, fully delete a lesson, ban users by IP, and set a user's password.
isModeratorRole(role) is true for both tiers. Moderators and admins can also read drafts and shadowbanned lessons, their comments, pulls and history (canReadLesson in lib/lesson.js), and get the lesson's authorIp on those reads.
What is not on either list is editing someone else's comment. A comment can only be edited by its author (PATCH /lessons/:id/comments/:commentId checks the stored author_id); a moderator's power over a bad comment is to delete it, not to rewrite it under its author's name. For the same reason a moderator can close a proposal (closePull in routes/pulls.js accepts isModeratorRole) but never merge one: merging writes a lesson under its author's name, which is authorship, not moderation, so only the author and their trusted collaborators can do it.
There is deliberately no in-app way to create an admin. Admins are seeded by hand with the snippet at the bottom of apps/api/schema.sql, after the person has signed in once:
insert into public.user_roles (user_id, role)
select id, 'admin' from auth.users where email = 'you@example.com'
on conflict (user_id) do update set role = 'admin';Admins add moderators with POST /mod/moderators (by email, via the Admin API), and granted_by records which admin added each one. Adding a moderator never touches an existing admin (409), and removing one is filtered to role = 'moderator', so it can never remove an admin.
Shadowbanning vs. deletion
A shadowbanned lesson (lessons.shadowbanned) is dropped from every public listing (the hub, profiles, the following feed, the Atom feed and the spelling words aggregate all filter shadowbanned=eq.false) and from public reads of the lesson, its comments, pulls and history, which 404 to everyone except its author, its trusted collaborators and moderators/admins. It is reversible and any moderator can do it. The author can still load and edit the lesson; note that rowToLesson includes shadowbanned in every response and the lesson page (pages/lesson/LessonLayout.jsx) renders a Shadowbanned badge whenever it is set, so an author who opens their own lesson does see it.
A full deletion is destructive, so moderators can't do it directly. A moderator files a row in lesson_delete_requests (status pending), and an admin approves it (which deletes the lesson) or denies it. Both outcomes record resolved_by and resolved_at. The delete itself is fullyDeleteLesson in lib/lesson.js: it sweeps the packs of any proposals, deletes the lesson's comments first (so it works on a database whose comment foreign key doesn't cascade), deletes the row, and then drops the stored history. Ratings, proposals and deletion requests go with the row through their cascading foreign keys.
Deleting a comment (DELETE /mod/comments/:id) removes its replies too, through comments.parent_id ... on delete cascade.
Bans
- Name bans (
banned_names, created by moderators) block any account whose current display name matches. Names are stored normalized (lowercased, trimmed) as the primary key for an exact, case-insensitive match;display_namekeeps the original casing for the moderation page.POST /profile/display-namealso refuses a banned name (409). Because the check is against the current display name, changing one's name escapes the ban. - IP bans (
banned_ips, created by admins) block requests from an address.
Both are checked by bannedResponse in lib/bans.js, which returns 403 "Your access has been suspended.". It runs at the top of the content-creating routes: POST and PUT /lessons, PUT /git/:id/pack, POST /lessons/:id/comments, PATCH /lessons/:id/comments/:commentId, and opening, updating and merging proposals. Reads, follows, profile changes and send-link are not ban-checked.
To support banning by IP from a piece of content, the API records the creator's IP into lessons.author_ip, comments.author_ip and lesson_pull_requests.author_ip. Which header that comes from is the platform's business (clientIp in src/platform/): cf-connecting-ip on Cloudflare, and on Node the header named by CLIENT_IP_HEADER (default x-forwarded-for, read TRUSTED_PROXY_COUNT hops from the right; 0 means no IP at all, so IP bans stop matching). See Platform seam and Self-hosting.
The address is only ever returned to moderators/admins, never in public responses, and currently only on two paths: GET /lessons/:id for a draft or shadowbanned lesson, and the response to POST /mod/lessons/:id/shadowban. The published-lesson read doesn't include it, so the lesson page's Ban author by IP item is disabled ("no IP on record") until the lesson is shadowbanned and reloaded. No endpoint returns comments.author_ip or a proposal's author_ip at all yet; admins can still ban an address typed into the moderation page.
API endpoints
All require a Bearer <Supabase JWT> (401 without one) and the role shown (403 "Moderator access required." or "Admin access required.").
| Method and path | Role | Body | What it does |
|---|---|---|---|
GET /mod/whoami | any | { role }: "admin", "moderator" or null | |
DELETE /mod/comments/:id | mod | Delete any comment and its replies | |
POST /mod/lessons/:id/shadowban | mod | { shadowbanned: boolean } | Hide or unhide a lesson. Returns { lesson } with authorIp |
GET /mod/lessons/shadowbanned | mod | { lessons }, every shadowbanned lesson, newest first | |
POST /mod/lessons/:id/delete-request | mod | { reason? } | File a deletion request (reason cut to 1000 characters). 201 { request } |
DELETE /mod/lessons/:id | admin | Fully delete a lesson | |
GET /mod/delete-requests | admin | { requests }, pending only, with the lesson's title and author | |
POST /mod/delete-requests/:id/approve | admin | Delete the lesson and mark the request approved | |
POST /mod/delete-requests/:id/deny | admin | Mark the request denied without deleting | |
GET /mod/bans | mod | { names, ips }; ips is only filled in for admins | |
POST /mod/bans/name | mod | { name } | Ban a display name |
DELETE /mod/bans/name/:nameLower | mod | Lift a name ban | |
POST /mod/bans/ip | admin | { ip, reason? } | Ban an address (reason cut to 200 characters) |
DELETE /mod/bans/ip/:ip | admin | Lift an IP ban | |
GET /mod/moderators | admin | { moderators: [{ userId, email, createdAt }] } | |
POST /mod/moderators | admin | { email } | Make a signed-in-at-least-once user a moderator. 201 { moderator } |
DELETE /mod/moderators/:userId | admin | Remove a moderator | |
POST /mod/password | admin | { identifier, password } | Set a user's password (self-hosted recovery) |
Setting a password
An instance that signs people in with a username and has no mail server has nowhere to send a reset link, so a forgotten password would otherwise be unrecoverable short of the database. An admin can set one from the moderation page, identifying the person by username or by email, whichever they are known by. identifierToEmail from @spelling-creator/core/username turns a username into the synthetic address it registered under (using USERNAME_DOMAIN), and the password must be at least PASSWORD_MIN_LENGTH (8) characters, matching GOTRUE_PASSWORD_MIN_LENGTH. The section only appears when hasPasswordAuth() is true; there is nothing to set on a magic-link-only instance.
It is admin-only, never moderator. Setting somebody's password is taking their account, which is a different kind of power from hiding a lesson. An admin may reset their own and anybody below them, but not another admin's (409): admins are peers, and taking a peer's account is an escalation the tier was never meant to allow.
That last rule fails closed. If the target's role cannot be read at all (the database is unreachable, or answers with something that isn't a row list), lookupUserRole reports known: false and the reset is refused with 502 rather than allowed through. Everywhere else an unknown role means "no privileges" and blocks by itself; here an absent role is what permits the reset, so the same reading would hand over another admin's account whenever the database hiccuped.
There is no audit table, so the action leaves a line in the server log naming who reset whom (admin <id> reset the password of <id>). It records identities only, never the password.
Two limits worth knowing. The last admin locking themselves out is a database problem, not an in-app one. And the reset changes the password without necessarily ending sessions already open elsewhere, so treat it as recovery from forgetfulness rather than as containment of a compromised account. See Self-hosting.
Schema
create table public.user_roles (
user_id uuid primary key references auth.users (id) on delete cascade,
role text not null check (role in ('moderator','admin')),
granted_by uuid references auth.users (id) on delete set null, -- null for hand-seeded admins
created_at timestamptz not null default now()
);
alter table public.lessons add column if not exists shadowbanned boolean not null default false;
alter table public.lessons add column if not exists author_ip text;
alter table public.comments add column if not exists author_ip text;
create table public.banned_names (
name_lower text primary key, -- lowercased, trimmed
display_name text, -- original casing, for the moderation page
banned_by uuid references auth.users (id) on delete set null,
created_at timestamptz not null default now()
);
create table public.banned_ips (
ip text primary key,
reason text,
banned_by uuid references auth.users (id) on delete set null,
created_at timestamptz not null default now()
);
create table public.lesson_delete_requests (
id uuid primary key default gen_random_uuid(),
lesson_id uuid not null references public.lessons (id) on delete cascade,
requested_by uuid not null references auth.users (id) on delete cascade,
reason text,
status text not null default 'pending' check (status in ('pending','approved','denied')),
resolved_by uuid references auth.users (id) on delete set null,
resolved_at timestamptz,
created_at timestamptz not null default now()
);
create index lesson_delete_requests_status_idx on public.lesson_delete_requests (status, created_at desc);All four tables have RLS enabled with no policies, so only the service-role API can read or write them. (lesson_pull_requests.author_ip is part of that table's own create table; see Pull requests.)