reasonix-accounts
The account service for reasonix.io: email/password sign-up, email verification,
sessions, password reset, and public profiles. A Cloudflare Worker (Hono) backed
by D1. Runs at id.reasonix.io, separate from the internal crash dashboard.
This is the backend API only — there are no HTML pages. The web frontend (and, later, the desktop/CLI) call these JSON endpoints.
Architecture
src/
index.ts entry — exports the Hono app as the fetch handler
app.ts middleware + route wiring
env.ts types.ts config.ts
auth/ crypto.ts (PBKDF2 + token hashing) cookies.ts (session cookie)
db/ users.ts sessions.ts emailTokens.ts deviceGrants.ts index.ts (repos factory)
email/ index.ts (Mailer + templates) resend.ts types.ts
http/ errors.ts cors.ts auth.ts (cookie + Bearer session) ratelimit.ts
lib/ validation.ts (zod) handle.ts
routes/ auth.ts device.ts me.ts users.ts health.ts
Design notes:
- Sessions store
sha256(pepper:token)— the raw token only ever lives in the cookie (web) or the client's credential store (CLI/desktop), so a DB read can't resurrect a live session. Protected routes accept the session from therxidcookie or anAuthorization: Bearer <token>header, so the same table serves both surfaces (sessions.kind=web|cli). - Device sign-in (RFC 8628-style) lets the CLI/desktop authenticate without a
browser redirect:
/device/startissues adevice_code(polled) and a shortuser_code(the human approves it onAPP_ORIGIN/devicewhile signed in). Only the device code's peppered hash is stored; theclisession token is minted on the winning poll (an atomicDELETE … RETURNINGclaim), so it never lands in the DB. Polling isn't IP-limited — aslow_downhint plus the 10-minute TTL bound it. password_hashis nullable onusersso OAuth-only identities can be added later without a rebuild.- Registration is enumeration-safe: the response never reveals whether an email already exists; login/forgot return generic messages too.
- PBKDF2-HMAC-SHA256, 100k iterations — the work factor is embedded in each hash. 100k is Cloudflare Workers' hard cap for PBKDF2 (it rejects higher counts).
Endpoints
| Method | Path | Auth | Notes |
|---|---|---|---|
| POST | /auth/register |
— | { email, password, displayName? } |
| GET | /auth/verify?token= |
— | email link → 302 to APP_ORIGIN/login |
| POST | /auth/login |
— | sets rxid cookie, returns { user } |
| POST | /auth/logout |
— | clears session + cookie |
| POST | /auth/forgot |
— | { email } → reset link |
| POST | /auth/reset |
— | { token, password } |
| POST | /auth/resend-verification |
— | { email } |
| POST | /device/start |
— | CLI begins sign-in → { deviceCode, userCode, verificationUri, interval, expiresIn } |
| POST | /device/poll |
— | { deviceCode } → authorization_pending | slow_down | { sessionToken, user } |
| GET | /device/info?userCode= |
✓ | approval screen: what a user_code will authorize |
| POST | /device/approve |
✓ | { userCode } — bind the pending grant to the signed-in user |
| POST | /device/deny |
✓ | { userCode } — reject the pending grant |
| GET | /me |
✓ | the signed-in account (cookie or Bearer) |
| PATCH | /me |
✓ | { displayName?, bio?, avatarUrl?, handle? } |
| POST | /me/password |
✓ | { currentPassword, newPassword } |
| DELETE | /me |
✓ | soft-delete the account |
| GET | /u/:handle |
— | public profile |
| GET | /health |
— | liveness |
Errors are { "error": { "code": "...", "message": "..." } } with a matching HTTP status.
Configuration
wrangler.toml [vars] (non-secret): APP_ORIGIN, ALLOWED_ORIGINS,
COOKIE_DOMAIN, EMAIL_PROVIDER (stub | resend), MAIL_FROM, ADMIN_EMAILS.
Secrets (wrangler secret put NAME): SESSION_PEPPER (any long random string),
RESEND_API_KEY (only when EMAIL_PROVIDER=resend).
When EMAIL_PROVIDER isn't resend (or no key is set) the worker logs email links
to the console — enough to exercise every flow locally without a mail provider.
Local development
pnpm install
pnpm db:apply:local # create local D1 tables
pnpm dev # wrangler dev on http://localhost:8787
Put local secrets in .dev.vars (git-ignored), e.g. SESSION_PEPPER="dev-pepper".
Register a user, then read the verification link from the wrangler dev console.
Deploy
wrangler d1 create reasonix-accounts # paste database_id into wrangler.toml
wrangler d1 migrations apply reasonix-accounts --remote
wrangler secret put SESSION_PEPPER
wrangler secret put RESEND_API_KEY # if EMAIL_PROVIDER=resend
wrangler deploy
The id.reasonix.io custom domain route is declared in wrangler.toml; point the
DNS/custom-domain binding at this worker in the Cloudflare dashboard on first deploy.
The steps above are the one-time bootstrap. After that, every merge to main-v2
that touches workers/accounts/** redeploys via .github/workflows/deploy-accounts-worker.yml
(same pattern as the crash worker). CI does not run migrations — apply new ones
with pnpm db:apply:remote out of band.
RESEND_API_KEY is synced to the worker on each deploy from the RESEND_API_KEY
GitHub Actions repo secret (so the mail key has a single source of truth and needs
no local wrangler auth). SESSION_PEPPER is not in CI — set it once with
wrangler secret put SESSION_PEPPER.