Lesson hub & accounts
The Lesson hub (/hub) is a public gallery of lessons users have shared.
Anyone can browse and preview; saving to the cloud and commenting require a
signed-in account. The editor's Save to cloud button is a dropdown with two
choices: Publish to hub (shared publicly) or Save as draft (backed up to
the database but kept private — only the author sees it, in a "Your drafts"
section on the hub). A draft can be published later, or a published lesson pulled
back to a draft; both go through the same POST/PUT with a published flag.
Signed-in users see an Edit action on lessons they published — it
opens the lesson back in the editor (warning first before it replaces any
in-progress work), and saving sends a PUT that the Worker accepts only from the
lesson's author. Clicking a lesson opens its own page at /hub/:id, where
comments (including threaded replies) appear beneath it. Comments are
moderated server-side: a comment containing profanity (detected with
glin-profanity) is blocked
entirely by the Worker — it is never stored, and the user is shown why. Posting a
reply sends a notification to the parent commenter and the
lesson author.
Where the data lives. Lessons are stored in Supabase Postgres, but — like
the AI and Pixabay features — the browser never talks to the database directly.
All lesson reads/writes go through the companion Worker (apps/api in this
monorepo), which holds the privileged Supabase credentials server-side. The only
thing the browser does directly with Supabase is authentication.
How sign-in works. The login page (/login) uses
Supabase Auth magic links: enter an
email, receive a one-time link, and the Supabase JS client (in src/lib/supabase.js)
exchanges the callback for a session. We use the PKCE flow so the callback
returns a ?code= in the query string rather than access tokens in the URL
fragment (hash). The session JWT is what authorises a
publish: the app sends it to the Worker as a Bearer token, and the Worker
verifies it (and derives the author) before inserting the row.
Worker endpoints (contract)
These live in the Worker (apps/api). The frontend
(src/lib/lessons.js) expects them at paths under VITE_API_URL. (The Worker
also exposes the profile, notification, and moderation endpoints documented on
their own pages.)
docis the editor document shape used throughout the app:{ title, sections: [{ id, name, blocks: [...] }] }. Store it asjsonb.POST /lessonsbody is{ title, doc, published }. The Worker should verify the JWT (e.g. validate the HS256 signature with the Supabase JWT secret, or callGET {SUPABASE_URL}/auth/v1/userwith the token), reject if invalid, take the author from the verified user (never trust a client-supplied author), and insert with the service-role key.published(defaulttruewhen omitted) decides whether the lesson is shared on the hub or saved as a private draft. The response includesauthorId(the publisher's Supabase user id) so the hub can tell which lessons belong to the signed-in user and offer Edit.GET /lessons/mineverifies the JWT and returns the caller's own lessons (drafts and published), scoped toauthor_id = <verified user>. It backs the hub's "Your drafts" section, since drafts are filtered out ofGET /lessons.PUT /lessons/:idbody is{ title, doc, published? }. The Worker verifies the JWT the same way, then updates the row only if the verified user is its author (the update is filtered on bothidandauthor_id, so a non-author's request matches no rows and is rejected with403). Whenpublishedis present it is updated too, so a draft can be published or a published lesson pulled back to a draft; omitting it leaves the current state alone.authorandcreated_atare left unchanged. This backs the editor's Save to cloud actions when editing a lesson loaded from the hub.POST /lessons/:id/commentsbody is{ body }. The Worker verifies the JWT the same way, derives the author from the verified user, then runs the text throughglin-profanity; if any profanity is found it rejects the whole comment with422(nothing is stored). Otherwise it inserts the row with the service-role key. The check runs on the Worker so it can't be bypassed by a crafted client request.POST /ai-text/dislikebody is{ subject, documentName }. The Worker verifies the JWT the same way (sign-in required), then rebuilds the cache key for that text suggestion and deletes it, so the next request for the same subject is regenerated instead of served from cache.- On 4xx/5xx, return a short plain-text reason — the frontend surfaces it directly (matching the existing AI/Pixabay error convention).
Supabase schema
The canonical, ready-to-run schema lives in the monorepo at
apps/api/schema.sql.
Run it once in the Supabase SQL editor. Besides the lessons and comments
tables shown below, that file also defines the notifications table (see
Notifications) and the moderation tables — user_roles,
banned_names, banned_ips, and lesson_delete_requests — plus the
shadowbanned / author_ip columns (see Moderation). The two
core tables, for reference: