| 1 | # reasonix-accounts |
| 2 | |
| 3 | The account service for `reasonix.io`: email/password sign-up, email verification, |
| 4 | sessions, password reset, and public profiles. A Cloudflare Worker (Hono) backed |
| 5 | by D1. Runs at `id.reasonix.io`, separate from the internal crash dashboard. |
| 6 | |
| 7 | This is the backend API only — there are no HTML pages. The web frontend (and, |
| 8 | later, the desktop/CLI) call these JSON endpoints. |
| 9 | |
| 10 | ## Architecture |
| 11 | |
| 12 | ``` |
| 13 | src/ |
| 14 | index.ts entry — exports the Hono app as the fetch handler |
| 15 | app.ts middleware + route wiring |
| 16 | env.ts types.ts config.ts |
| 17 | auth/ crypto.ts (PBKDF2 + token hashing) cookies.ts (session cookie) |
| 18 | db/ users.ts sessions.ts emailTokens.ts deviceGrants.ts index.ts (repos factory) |
| 19 | email/ index.ts (Mailer + templates) resend.ts types.ts |
| 20 | http/ errors.ts cors.ts auth.ts (cookie + Bearer session) ratelimit.ts |
| 21 | lib/ validation.ts (zod) handle.ts |
| 22 | routes/ auth.ts device.ts me.ts users.ts health.ts |
| 23 | ``` |
| 24 | |
| 25 | Design notes: |
| 26 | |
| 27 | - **Sessions store `sha256(pepper:token)`** — the raw token only ever lives in the |
| 28 | cookie (web) or the client's credential store (CLI/desktop), so a DB read can't |
| 29 | resurrect a live session. Protected routes accept the session from the `rxid` |
| 30 | cookie or an `Authorization: Bearer <token>` header, so the same table serves |
| 31 | both surfaces (`sessions.kind` = `web` | `cli`). |
| 32 | - **Device sign-in (RFC 8628-style)** lets the CLI/desktop authenticate without a |
| 33 | browser redirect: `/device/start` issues a `device_code` (polled) and a short |
| 34 | `user_code` (the human approves it on `APP_ORIGIN/device` while signed in). Only |
| 35 | the device code's peppered hash is stored; the `cli` session token is minted on |
| 36 | the winning poll (an atomic `DELETE … RETURNING` claim), so it never lands in the |
| 37 | DB. Polling isn't IP-limited — a `slow_down` hint plus the 10-minute TTL bound it. |
| 38 | - **`password_hash` is nullable** on `users` so OAuth-only identities can be added |
| 39 | later without a rebuild. |
| 40 | - **Registration is enumeration-safe**: the response never reveals whether an email |
| 41 | already exists; login/forgot return generic messages too. |
| 42 | - **PBKDF2-HMAC-SHA256, 100k iterations** — the work factor is embedded in each |
| 43 | hash. 100k is Cloudflare Workers' hard cap for PBKDF2 (it rejects higher counts). |
| 44 | |
| 45 | ## Endpoints |
| 46 | |
| 47 | | Method | Path | Auth | Notes | |
| 48 | | ------ | -------------------------- | ---- | --------------------------------------- | |
| 49 | | POST | `/auth/register` | — | `{ email, password, displayName? }` | |
| 50 | | GET | `/auth/verify?token=` | — | email link → 302 to `APP_ORIGIN/login` | |
| 51 | | POST | `/auth/login` | — | sets `rxid` cookie, returns `{ user }` | |
| 52 | | POST | `/auth/logout` | — | clears session + cookie | |
| 53 | | POST | `/auth/forgot` | — | `{ email }` → reset link | |
| 54 | | POST | `/auth/reset` | — | `{ token, password }` | |
| 55 | | POST | `/auth/resend-verification`| — | `{ email }` | |
| 56 | | POST | `/device/start` | — | CLI begins sign-in → `{ deviceCode, userCode, verificationUri, interval, expiresIn }` | |
| 57 | | POST | `/device/poll` | — | `{ deviceCode }` → `authorization_pending` \| `slow_down` \| `{ sessionToken, user }` | |
| 58 | | GET | `/device/info?userCode=` | ✓ | approval screen: what a `user_code` will authorize | |
| 59 | | POST | `/device/approve` | ✓ | `{ userCode }` — bind the pending grant to the signed-in user | |
| 60 | | POST | `/device/deny` | ✓ | `{ userCode }` — reject the pending grant | |
| 61 | | GET | `/me` | ✓ | the signed-in account (cookie or Bearer) | |
| 62 | | PATCH | `/me` | ✓ | `{ displayName?, bio?, avatarUrl?, handle? }` | |
| 63 | | POST | `/me/password` | ✓ | `{ currentPassword, newPassword }` | |
| 64 | | DELETE | `/me` | ✓ | soft-delete the account | |
| 65 | | GET | `/u/:handle` | — | public profile | |
| 66 | | GET | `/health` | — | liveness | |
| 67 | |
| 68 | Errors are `{ "error": { "code": "...", "message": "..." } }` with a matching HTTP status. |
| 69 | |
| 70 | ## Configuration |
| 71 | |
| 72 | `wrangler.toml` `[vars]` (non-secret): `APP_ORIGIN` (web frontend), |
| 73 | `ACCOUNT_ORIGIN` (this Worker's canonical public origin), `ALLOWED_ORIGINS`, |
| 74 | `COOKIE_DOMAIN`, `EMAIL_PROVIDER` (`stub` | `resend`), `MAIL_FROM`, `ADMIN_EMAILS`. |
| 75 | |
| 76 | Secrets (`wrangler secret put NAME`): `SESSION_PEPPER` (any long random string), |
| 77 | `RESEND_API_KEY` (only when `EMAIL_PROVIDER=resend`). |
| 78 | |
| 79 | When `EMAIL_PROVIDER` isn't `resend` (or no key is set) the worker logs email links |
| 80 | to the console — enough to exercise every flow locally without a mail provider. |
| 81 | |
| 82 | ## Local development |
| 83 | |
| 84 | ```sh |
| 85 | pnpm install |
| 86 | pnpm db:apply:local # create local D1 tables |
| 87 | pnpm dev # wrangler dev on http://localhost:8787 |
| 88 | ``` |
| 89 | |
| 90 | Put local overrides and secrets in `.dev.vars` (git-ignored), e.g.: |
| 91 | |
| 92 | ```dotenv |
| 93 | ACCOUNT_ORIGIN="http://localhost:8787" |
| 94 | SESSION_PEPPER="dev-pepper" |
| 95 | ``` |
| 96 | |
| 97 | Register a user, then read the verification link from the `wrangler dev` console. |
| 98 | |
| 99 | ## Deploy |
| 100 | |
| 101 | ```sh |
| 102 | wrangler d1 create reasonix-accounts # paste database_id into wrangler.toml |
| 103 | wrangler d1 migrations apply reasonix-accounts --remote |
| 104 | wrangler secret put SESSION_PEPPER |
| 105 | wrangler secret put RESEND_API_KEY # if EMAIL_PROVIDER=resend |
| 106 | wrangler deploy |
| 107 | ``` |
| 108 | |
| 109 | The `id.reasonix.io` custom domain route is declared in `wrangler.toml`; point the |
| 110 | DNS/custom-domain binding at this worker in the Cloudflare dashboard on first deploy. |
| 111 | |
| 112 | The steps above are the one-time bootstrap. After that, every merge to `main-v2` |
| 113 | that touches `workers/accounts/**` redeploys via `.github/workflows/deploy-accounts-worker.yml` |
| 114 | (same pattern as the crash worker). CI does **not** run migrations — apply new ones |
| 115 | with `pnpm db:apply:remote` out of band. |
| 116 | |
| 117 | `RESEND_API_KEY` is synced to the worker on each deploy from the `RESEND_API_KEY` |
| 118 | GitHub Actions repo secret (so the mail key has a single source of truth and needs |
| 119 | no local wrangler auth). `SESSION_PEPPER` is not in CI — set it once with |
| 120 | `wrangler secret put SESSION_PEPPER`. |
| 121 |