Profiles & display names
Every signed-in user has a public display name and an optional bio, and a public profile page that lists the lessons they've published. None of this exposes the user's email — the hub shows the chosen display name everywhere an author or commenter is named.
Display names
The first time a signed-in user reaches the app they're asked to pick a display
name before they can use it. DisplayNameGate.jsx enforces this (wrapping the
whole app in main.jsx), and DisplayNameDialog.jsx is the picker; a user can
change their name later from the account menu.
The name is not a database column — it's stored in the Supabase user's
user_metadata.display_name. The browser can't write metadata directly; it calls
the Worker's POST /profile/display-name, which validates the name (and runs the
same profanity / name-ban checks as publishing) and writes it through the Supabase
Admin API. Because author is denormalised onto each lesson and comment row
for fast listing, changing your name also backfills it onto your existing rows.
Bios
A bio is a short free-text "about me" shown on your profile page. It's edited in
BioDialog.jsx and saved with POST /profile/bio, which caps the length, runs a
profanity check (rejecting with 422 if it fails), and — like the display name —
stores it in user_metadata.bio via the Admin API. An empty bio clears it. Bio
is profile-only (never denormalised onto rows).
Profile pages
ProfilePage.jsx renders a user's public profile at /users/:id. It reads from
the Worker's GET /profiles/:id, which returns the user's display name, bio and
follower/following counts, plus their published lessons:
The Worker resolves the profile via the Supabase Admin API and never returns the
email — only the display name (falling back to "Anonymous") and bio. The
endpoint is served under /profiles/:id on the Worker so it doesn't collide with
the SPA's own /users/:id page. The read stays public; isFollowing is only
meaningful when the request carries a session token (it reflects whether you
follow this profile, and is false for an anonymous view).
Each profile also has a feed at GET /profiles/:id/feed.xml — an Atom feed of the
user's lessons and comments (surfaced as "RSS" in the UI).
Following
Any signed-in user can follow another user from their profile page. A follow
is one row in the follows table (follower_id → following_id, defined in
apps/api/schema.sql), keyed by Supabase user id on both sides so it survives a
display-name change. The profile header shows the Follow / Following button (never
for your own profile) plus the follower and following counts.
POST /profiles/:id/follow(Bearer) — follow the user. Idempotent (ON CONFLICT DO NOTHING): re-following is a no-op, so it doesn't create a second row or re-notify. A genuinely new follow drops afollownotification into the followed user's bell. You can't follow yourself (400) or a user who doesn't exist (404).DELETE /profiles/:id/follow(Bearer) — unfollow.
Both return { following, followerCount } so the button and count update without a
refetch. The follower is always taken from the verified session, never the request
body.
The follower/following counts in the profile header are clickable: they open a
connections dialog (FollowListDialog.jsx) with Followers and Following
tabs, each row linking to that user's profile. The lists are public:
GET /profiles/:id/followers— the users who follow:id.GET /profiles/:id/following— the users:idfollows.
Both return { users: [{ id, displayName, bio }] }, newest-follow first and capped
(each id is resolved to its public profile via the Admin API, so no email leaks).
Your following feed
The signed-in home dashboard (HomePage.jsx) shows a "From people you follow"
panel: the recent lessons and comments of everyone you follow, merged newest-first.
It reads GET /following/activity (Bearer), which looks up the following_ids for
the caller and merges those users' published lessons and comments into the same
{ id, title, summary, link, updated } shape the other dashboard feeds use. It
returns an empty feed when you follow no one.
The frontend wrappers are setFollowing(), fetchFollowList() and
fetchFollowingActivity() in src/lib/users.js; the Worker handlers are in
apps/api/src/routes/follows.js.