Skip to content

Moderation

On top of the automatic profanity filtering applied to comments, bios, and display names, the hub has a moderation layer with two privilege tiers and a dedicated moderation queue at /moderation (ModerationPage.jsx). The page is gated to privileged users and is disallowed for robots.

The browser holds no authority of its own: it asks the Worker GET /mod/whoami what the caller is allowed to do and renders accordingly, and the Worker re-derives the caller's role from the database on every privileged request, so a tampered client can never grant itself power. (The Worker's moderation API lives at /mod, not /moderation — it's named differently on purpose so it can't collide with this page's own /moderation route; see the registration comment in apps/api/src/index.js.)

Roles

Roles live in the user_roles table (apps/api/schema.sql). 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 a moderator's lesson-deletion request, fully delete a lesson, and ban users by IP.

Note what is not on either list: editing someone else's comment. A comment can only be edited by the person who wrote it (see Rich text) — 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 proposed change but never merge one. Merging writes a lesson under its author's name, which is authorship, not moderation; only that author and the collaborators they trust can do it.

There is deliberately no in-app way to create an admin — admins are seeded by hand in the Supabase SQL editor (see the snippet at the bottom of schema.sql). Admins can add moderators (POST /mod/moderators); granted_by records which admin added each one.

Shadowbanning vs. deletion

A shadowbanned lesson (lessons.shadowbanned) is dropped from the public hub listing and from public single-lesson reads (it 404s to everyone except its author and mods/admins), so the author still sees it as normal and doesn't realise it's hidden. This is reversible and any moderator can do 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.

Bans

  • Name bans (banned_names, created by moderators) block any account whose display name matches from commenting or publishing/editing. Names are stored normalised (lower-cased, trimmed) for an exact, case-insensitive match.
  • IP bans (banned_ips, created by admins) block any request from an address.

Both are checked at the top of the content-creating Worker routes. To support "ban this user by IP" from a piece of their content, the Worker captures the creator's IP (cf-connecting-ip) into lessons.author_ip / comments.author_ip. That column is only ever surfaced to mods/admins, never in public responses.

Worker endpoints

All require a Bearer <Supabase JWT> and the appropriate role; the frontend wrapper is @spelling-creator/core/moderation.

Method & pathRoleWhat it does
GET /mod/whoamianyThe caller's capabilities (mod/admin flags).
DELETE /mod/comments/:idmodDelete any comment.
POST /mod/lessons/:id/shadowbanmodHide/unhide a lesson from the public hub.
GET /mod/lessons/shadowbannedmodList shadowbanned lessons.
POST /mod/lessons/:id/delete-requestmodFile a request to fully delete a lesson.
GET /mod/delete-requestsadminList pending deletion requests.
POST /mod/delete-requests/:id/approveadminApprove (and delete) a request.
DELETE /mod/lessons/:idadminFully delete a lesson.
GET / POST / DELETE /mod/bans/name…modList / add / remove name bans.
GET / POST / DELETE /mod/bans/ip…adminList / add / remove IP bans.
GET / POST / DELETE /mod/moderators…adminList / add / remove moderators.

Copyright © 2026 Spelling Creator.