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).
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]
protocol schemas the client compiles against.| Area | Routes | Notes |
|---|---|---|
| Health | /healthz · /readyz · /statusz | Liveness, database readiness, build and job status |
| Auth | /v1/auth/anonymous · /v1/auth/logout | Every install starts with an anonymous session |
/v1/auth/email/start · /v1/auth/email/verify | Six-digit code by email, stored hashed, at most 5 tries | |
/v1/auth/google/start · /v1/auth/google/callback | OAuth with PKCE and state | |
/v1/auth/merge | Answers "Put them together?": merge or discard the device's anonymous progress | |
| Me | /v1/me · /v1/me/export · /v1/me/delete | Profile, a full data export, and account deletion with a 7-day undo |
| Sync | /v1/sync/push · /v1/sync/pull | See Sync Engine |
| Review | /v1/review/due | Due counts used by reminders |
| Push | /v1/push/key · /v1/push/subscribe · /v1/push/unsubscribe | Web Push (VAPID) |
/v1/push/native · /v1/push/native/unregister | APNs and FCM device tokens | |
| Sharing | /v1/share · /v1/share/:code | Challenge links for puzzles and boxes |
| Decks | /v1/decks · /v1/decks/share · /v1/decks/shared/:code | Deck snapshots for sharing |
Metrics are served on a separate internal port and scraped by the platform; they are never exposed through the edge.
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
}
| Group | Tables |
|---|---|
| Identity | users, auth_identities, sessions, email_codes, user_private, user_ref_salts, consents, audit_log |
| Learning data | keeps, review_logs, attempts, seen_boxes, puzzle_results, decks, deck_shares, user_settings |
| Sync | sync_mutations, user_changes |
| Derived | learner_state, learner_dirty, user_interests |
| Engagement | nudges, device_tokens, push_subscriptions, share_links, world_waitlist |
| Content mirror | content_boxes, content_reports |
| Analytics | events (monthly partitions), exposures, decision_logs |
| Operations | job_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).
0001_core … 0017_decks, applied in order by Fly's release command before new machines take traffic. Readiness (/readyz) checks the database connection only.${sch.nav()}