API and Data Model

The API is a modular monolith: one Node 22 process built with Hono and bundled with esbuild, with each concern in its own module and its own tables. The same codebase also runs the jobs process (see Background Jobs).

Request pipeline

flowchart LR
  REQ[request from the Worker] --> SEC{proxy secret<br/>from the edge?}
  SEC -- yes --> IP[trust forwarded client IP]
  SEC -- no --> IP2[use the connecting IP]
  IP & IP2 --> ID[request id · timing · metrics]
  ID --> AUTH[session lookup<br/>httpOnly cookie → hash → user]
  AUTH --> V[validate body<br/>protocol schemas]
  V --> H[module handler]
  H --> DB[(Postgres<br/>pooled)]
  H --> RES[JSON response]
  H -. errors .-> SEN[Sentry]
  • The Worker adds a proxy secret on every proxied call. Only when it matches does the API trust the forwarded client IP for its own per-IP limits (such as sign-in codes), so nobody can spoof an address by calling the API directly.
  • Sessions are 32 random bytes in an httpOnly, SameSite=Lax cookie; only their SHA-256 is stored, so a database leak reveals no usable session. There are no JWTs to revoke: logout marks the row revoked.
  • Request bodies are validated with the same protocol schemas the client compiles against.

Routes

AreaRoutesNotes
Health/healthz · /readyz · /statuszLiveness, database readiness, build and job status
Auth/v1/auth/anonymous · /v1/auth/logoutEvery install starts with an anonymous session
/v1/auth/email/start · /v1/auth/email/verifySix-digit code by email, stored hashed, at most 5 tries
/v1/auth/google/start · /v1/auth/google/callbackOAuth with PKCE and state
/v1/auth/mergeAnswers "Put them together?": merge or discard the device's anonymous progress
Me/v1/me · /v1/me/export · /v1/me/deleteProfile, a full data export, and account deletion with a 7-day undo
Sync/v1/sync/push · /v1/sync/pullSee Sync Engine
Review/v1/review/dueDue counts used by reminders
Push/v1/push/key · /v1/push/subscribe · /v1/push/unsubscribeWeb Push (VAPID)
/v1/push/native · /v1/push/native/unregisterAPNs and FCM device tokens
Sharing/v1/share · /v1/share/:codeChallenge links for puzzles and boxes
Decks/v1/decks · /v1/decks/share · /v1/decks/shared/:codeDeck snapshots for sharing

Metrics are served on a separate internal port and scraped by the platform; they are never exposed through the edge.

Data model

erDiagram
  users ||--o{ auth_identities : "signs in with"
  users ||--o{ sessions : has
  users ||--|| user_settings : has
  users ||--|| user_private : "PII kept apart"
  users ||--o{ consents : gives
  users ||--o{ keeps : keeps
  users ||--o{ review_logs : reviews
  users ||--o{ attempts : answers
  users ||--o{ decks : owns
  decks ||--o{ deck_shares : "shared as"
  users ||--o{ sync_mutations : pushed
  users ||--o{ user_changes : "change feed"
  users ||--o{ device_tokens : "native push"
  users ||--o{ push_subscriptions : "web push"
  users ||--o{ nudges : receives
  users ||--|| learner_state : "derived model"
  users ||--o{ user_interests : "derived"
  users ||--o{ share_links : creates
  users {
    uuid id PK
    timestamptz created_at
    bool anonymous
  }
  sync_mutations {
    text id PK "client ULID"
    uuid user_id FK
    text type
    timestamptz applied_at
  }
  user_changes {
    uuid user_id FK
    bigint seq
    text entity
    jsonb data
  }
  learner_state {
    uuid user_id PK
    jsonb topics
    jsonb cards_summary
    timestamptz updated_at
  }

Table groups

GroupTables
Identityusers, auth_identities, sessions, email_codes, user_private, user_ref_salts, consents, audit_log
Learning datakeeps, review_logs, attempts, seen_boxes, puzzle_results, decks, deck_shares, user_settings
Syncsync_mutations, user_changes
Derivedlearner_state, learner_dirty, user_interests
Engagementnudges, device_tokens, push_subscriptions, share_links, world_waitlist
Content mirrorcontent_boxes, content_reports
Analyticsevents (monthly partitions), exposures, decision_logs
Operationsjob_runs, lake_exports

decision Personal data lives in one table. Email addresses and names sit in user_private, joined only where needed (sign-in, export, deletion). Every other table carries just a user id, and analytics carry a salted pseudonym instead (see Security and Privacy).

Migrations

  • Plain SQL files, numbered 0001_core … 0017_decks, applied in order by Fly's release command before new machines take traffic. Readiness (/readyz) checks the database connection only.
  • Rules enforced in review: additive first (add a column, backfill, then switch readers), never rename in place, and every migration must be safe for the previous build that is still serving.
  • Migrations refuse to run through a transaction pooler, because advisory locks and DDL need a direct session.
  • Locally, the API runs on embedded Postgres (PGlite) with the same migrations, so tests exercise the real schema.

${sch.nav()}