Identity & authentication
Firebase ID tokens, verified in-worker
Every /api/engine/* request carries a Firebase ID token — as Authorization: Bearer <token>, or as ?token= on WebSocket upgrades (browsers can't set
headers on upgrades). The Worker verifies it with jose against Google's
securetoken JWKS: RS256 pinned (no algorithm confusion), issuer and audience
checked against the configured FIREBASE_PROJECT_ID, expiry enforced. A failure
is a deliberately unspecific 401 — signature, expiry, issuer, and audience
failures all read the same to a client ("re-authenticate").
The verified claims carry the uid, isAnonymous (the anonymous
sign-in-provider claim, which drives every guest gate), and the profile fields
(Google supplies name + picture, Apple usually only email, guests none).
Provisioning & guests
A users row appears on first sight of a valid token, so there is no signup
call to make. Username is derived from the email local part (sanitized to a
[a-z0-9_.]{3,20} charset) or a generated player_NNNNN handle for guests, with
a collision-retry loop.
Guests are first-class: anonymous sign-in gives a real uid and a real (ephemeral)
account. Because linkWithCredential preserves the uid, guest→permanent
conversion is an in-place backfill on the same row — the provider's display name
and avatar overwrite the guest's, while the stable username handle survives.
Guest capability is deliberately narrowed: guests may play (including vs bots,
unrated) but cannot create friends-access games or join rated games. Inactive
guests are swept by the cron; see Account lifecycle.
The username is the stable, editable handle (distinct from the provider
display name, which the engine never lets a user edit). PUT /me/username
validates the same [a-z0-9_.]{3,20} charset and returns a clean 409 on a
collision (the column is UNIQUE). The display name and avatar come from
the auth provider (or an uploaded avatar); PUT /me/avatar is the only way a
user changes their picture.
The social graph
Friendships, search, and blocking are cross-game and D1-only — they never
touch a Durable Object. The relationships table stores one row per unordered
pair in canonical order (user_id_1 < user_id_2) with a status
(pending / accepted / blocked) and an initiated_by actor, so a single
shared row encodes the relationship and the direction of a request or block is
recovered from initiated_by.
- Requests.
POST /friends/requests {targetUserId}inserts apendingrow — unless the target already has a pending request out to the caller, in which case it auto-accepts (sending back is accepting).accepttransitions the request the other party initiated.DELETE /friends/{id}is the single idempotent unfriend / withdraw / decline. All writes require a registered caller, and a friend target must be registered too (a guest is a throwaway identity). - Blocking.
POST /friends/{id}/blockoverwrites any pending/accepted row (or creates one) asblocked, recording the blocker ininitiated_by. A block in either direction refuses new requests; only the blocker canunblock. - Search.
GET /users/search?q=is a case-insensitive substring match on username or display name, excluding the caller, guests, and anyone in a blocked relationship with the caller, ranked exact → prefix → substring.LIKEtoday, D1 FTS5 later; the%wildcard is stripped so a caller can't force a scan. - Discovery.
GET /friends/gameslists joinable games created by the caller's accepted friends — the lobby that makesfriends-access games reachable.
Friend-event pushes (friend_request, friend_accepted) fire from the route
through the shared, required FCM path. Because these
run in a stateless Worker (not the always-alive DO), they ride
executionCtx.waitUntil so a slow FCM call never delays the response — the one
place the engine uses waitUntil deliberately.