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 /moderation/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.

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, 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.

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 /moderation/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 src/lib/moderation.js.

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