Skip to content

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:

sql
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_name keeps the original casing for the moderation page. POST /profile/display-name also 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 pathRoleBodyWhat it does
GET /mod/whoamiany{ role }: "admin", "moderator" or null
DELETE /mod/comments/:idmodDelete any comment and its replies
POST /mod/lessons/:id/shadowbanmod{ shadowbanned: boolean }Hide or unhide a lesson. Returns { lesson } with authorIp
GET /mod/lessons/shadowbannedmod{ lessons }, every shadowbanned lesson, newest first
POST /mod/lessons/:id/delete-requestmod{ reason? }File a deletion request (reason cut to 1000 characters). 201 { request }
DELETE /mod/lessons/:idadminFully delete a lesson
GET /mod/delete-requestsadmin{ requests }, pending only, with the lesson's title and author
POST /mod/delete-requests/:id/approveadminDelete the lesson and mark the request approved
POST /mod/delete-requests/:id/denyadminMark the request denied without deleting
GET /mod/bansmod{ names, ips }; ips is only filled in for admins
POST /mod/bans/namemod{ name }Ban a display name
DELETE /mod/bans/name/:nameLowermodLift a name ban
POST /mod/bans/ipadmin{ ip, reason? }Ban an address (reason cut to 200 characters)
DELETE /mod/bans/ip/:ipadminLift an IP ban
GET /mod/moderatorsadmin{ moderators: [{ userId, email, createdAt }] }
POST /mod/moderatorsadmin{ email }Make a signed-in-at-least-once user a moderator. 201 { moderator }
DELETE /mod/moderators/:userIdadminRemove a moderator
POST /mod/passwordadmin{ 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 ​

sql
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.)

Copyright © 2026 Spelling Creator.