Appearance
Architecture
Folder layout
text
cloud/reform-assistant/
├── package.json scripts and devDependencies (own lockfile, not a root workspace)
├── package-lock.json
├── wrangler.jsonc Worker name, assets, D1, R2, APP_URL
├── tsconfig.json strict TypeScript for src/, test/, vitest.config.ts
├── vitest.config.ts Vitest with the Cloudflare Workers pool
├── .dev.vars.example template for local secrets (copy to .dev.vars)
├── migrations/
│ ├── 0001_init.sql D1 schema
│ └── 0002_checkout_sessions.sql adds users.open_checkout_session_id
├── src/ Worker code
│ ├── index.ts entry point, API route table, signup/login/logout/me handlers
│ ├── env.ts Env bindings and row types
│ ├── http.ts JSON helpers, error mapping, cookie helpers
│ ├── auth.ts password hashing, sessions
│ ├── plans.ts the three plans
│ ├── inmate.ts inmate load/save/remove
│ ├── billing.ts Stripe Checkout and Billing Portal calls
│ ├── stripe_webhook.ts webhook verification and event handling
│ ├── hermes.ts bearer-token endpoints for Hermes (one handler per endpoint)
│ ├── period.ts monthly allowance window calculation
│ └── usage.ts usage reporting and recording
├── public/ static assets (no build step)
│ ├── index.html landing page
│ ├── 404.html not-found page
│ ├── favicon.svg
│ ├── assets/ site.css, site.js, hero.svg
│ ├── signup/index.html
│ ├── login/index.html
│ ├── console/ index.html, app.js, auth.js, console.css
│ ├── terms/index.html
│ └── privacy/index.html
├── hermes_bridge/
│ ├── portal_sync.py cron script run on the Hermes host
│ └── README.md
├── test/ Vitest suites and migration setup
└── docs/ this VitePress siteRequest routing
wrangler.jsonc configures the static assets and the Worker together:
jsonc
"assets": {
"directory": "./public",
"binding": "ASSETS",
"not_found_handling": "404-page",
"run_worker_first": ["/api/*"]
}run_worker_first: ["/api/*"]: only requests whose path matches/api/*are sent to the Worker before the asset lookup. Every other path (/,/signup/,/console/,/assets/site.css, ...) is answered directly frompublic/.ASSETSbinding: the Worker'sfetchhandler (src/index.ts) also forwards any path that does not start with/api/toenv.ASSETS.fetch(request), so the Worker serves assets correctly even if it is invoked for them.not_found_handling: "404-page": unknown asset paths get the nearest404.html, which here ispublic/404.html.- Unknown
/api/paths are answered by the Worker with404 not_foundas JSON, not with the HTML page.
API router
src/index.ts declares one route table, API_ROUTES, a map from exact path to { methods, handler }:
| Path | Methods | Auth |
|---|---|---|
/api/auth/signup, /api/auth/login, /api/auth/logout | POST | none |
/api/plans | GET | none |
/api/stripe/webhook | POST | Stripe signature |
/api/me, /api/usage | GET | session |
/api/inmate | GET, PUT, DELETE | session |
/api/billing/checkout, /api/billing/portal | POST | session |
/api/hermes/* | handled by handleHermes in src/hermes.ts | bearer token |
routeApi looks the request path up in the table:
- Not in the table. If the path starts with
/api/hermes/the request goes tohandleHermes; any other path gives404 not_found. - In the table. The method is checked first; a wrong method returns
405 method_not_allowedwith anAllowheader listing the route's methods. Then the route's handler runs. - Session routes call
requireUserinside their handler, which throws401 unauthorizedwithout a valid session cookie. Because the method check comes first,POST /api/mewithout a cookie returns405, not401. - Hermes routes.
handleHermeschecks the bearer token first (401 unauthorized), then dispatches by path to one function per endpoint (listConnections,acknowledgeConnection,postUsage,listAllowances); each checks its own method (405), and an unknown/api/hermes/...path gives404 not_found.
All errors thrown as apiError(status, code, message) become {"error":{"code":"...","message":"..."}} with that status; anything else becomes 500 internal_error. See the HTTP API reference.
Modules in src/
| File | Responsibility |
|---|---|
index.ts | Worker fetch entry, API routing, and the signup, login, logout, and /api/me handlers |
env.ts | Env interface (bindings, vars, optional secrets) and row types User, Inmate, SubscriptionRow |
http.ts | json, errorResponse, apiError, readJson, cookieValue, sessionCookie, methodNotAllowed |
auth.ts | PBKDF2 password hashing and verification, session create/lookup/clear, requireUser |
plans.ts | The PLANS constant, the Plan type, and findPlan |
inmate.ts | Load, validate-and-save, and remove the account's inmate; the public inmate shape |
billing.ts | Create Stripe Checkout sessions (expiring the user's previously open one first) and Billing Portal sessions through the Stripe REST API |
stripe_webhook.ts | Verify Stripe-Signature, deduplicate events, archive to R2, re-fetch the subscription, map events to subscription and connection state, record and cancel duplicate subscriptions |
hermes.ts | Bearer-authenticated endpoints, split into one handler per endpoint: pending connections, acknowledgements, usage ingestion (via recordUsage), allowances (monthly window per subscription) |
period.ts | monthlyWindow(start, end, months, now): one of exactly months monthly slices of a Stripe billing period (the last ends at the billing end), used by getUsage and the allowances endpoint |
usage.ts | getUsage for the console and recordUsage for Hermes (attributes usage to the inmate with a live subscription) |
Data model
Seven D1 tables (users, sessions, inmates, subscriptions, connections, usage_events, stripe_events) are created by two migrations, migrations/0001_init.sql and migrations/0002_checkout_sessions.sql, and raw Stripe payloads are archived in R2 under stripe/events/ and stripe/duplicates/. Every table, column, constraint and key is described once, in the Data model reference; the rules built on them are in Product primitives.
Authentication and sessions
Family accounts use email and password with a server-side session. Code lives in src/auth.ts, src/http.ts, and the signup and login handlers in src/index.ts.
Passwords
- Signup requires an email matching
^[^\s@]+@[^\s@]+\.[^\s@]+$(after trimming and lowercasing), a non-empty trimmed name, and a password of at least 10 characters. - Hashing uses Web Crypto PBKDF2 with hash SHA-256, 100,000 iterations, a 16-byte random salt from
crypto.getRandomValues, and a 256-bit output. - The hash and the salt are stored base64-encoded in
users.password_hashandusers.password_salt. - Verification re-derives the key and compares with a constant-time comparison.
- Login for an unknown email still runs one PBKDF2 derivation against a dummy salt and hash before returning
401 invalid_credentials, so unknown emails and wrong passwords behave alike.
Sessions
- A session token is 32 random bytes, base64url-encoded without padding.
- Only the SHA-256 digest of the token (base64) is stored, as
sessions.token_hash. The token itself exists only in the cookie. - A session lasts 30 days:
expires_atis set 30 days after creation. getSessionUserjoinssessionstouserswhere the hash matches andexpires_atis in the future. If no row matches, it deletes any row with that hash and treats the request as unauthenticated.- Logout deletes the session row for the presented cookie and clears the cookie. It returns
204even if there was no cookie. - Signup and login each create a new session row; existing sessions of the same user are not revoked.
- Expired sessions are purged when a session is created (signup or login): all expired rows of that user are deleted, plus up to 100 expired rows of any user, so the table does not grow without bound and no scheduled job is needed.
Cookie
The cookie is built by sessionCookie in src/http.ts:
text
ra_session=<token>; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=2592000HttpOnlyandSameSite=Laxare always set; the path is/.Secureis added when the request URL scheme ishttps:(the Worker looks atrequest.url, not atAPP_URL), so production over HTTPS always getsSecureandhttp://localhostdevelopment does not.- Clearing sets
Max-Age=0andExpires=Thu, 01 Jan 1970 00:00:00 GMTinstead of the 30-dayMax-Age. - The token value is URL-encoded when written and URL-decoded when read.
The code has no CSRF token and no rate limiting; state-changing endpoints rely on SameSite=Lax and JSON bodies.
Machine authentication
Two endpoints do not use sessions: