Skip to main content

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 a pending row — unless the target already has a pending request out to the caller, in which case it auto-accepts (sending back is accepting). accept transitions 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}/block overwrites any pending/accepted row (or creates one) as blocked, recording the blocker in initiated_by. A block in either direction refuses new requests; only the blocker can unblock.
  • 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. LIKE today, D1 FTS5 later; the % wildcard is stripped so a caller can't force a scan.
  • Discovery. GET /friends/games lists joinable games created by the caller's accepted friends — the lobby that makes friends-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.