Skip to content

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 site

Request 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 from public/.
  • ASSETS binding: the Worker's fetch handler (src/index.ts) also forwards any path that does not start with /api/ to env.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 nearest 404.html, which here is public/404.html.
  • Unknown /api/ paths are answered by the Worker with 404 not_found as 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 }:

PathMethodsAuth
/api/auth/signup, /api/auth/login, /api/auth/logoutPOSTnone
/api/plansGETnone
/api/stripe/webhookPOSTStripe signature
/api/me, /api/usageGETsession
/api/inmateGET, PUT, DELETEsession
/api/billing/checkout, /api/billing/portalPOSTsession
/api/hermes/*handled by handleHermes in src/hermes.tsbearer token

routeApi looks the request path up in the table:

  1. Not in the table. If the path starts with /api/hermes/ the request goes to handleHermes; any other path gives 404 not_found.
  2. In the table. The method is checked first; a wrong method returns 405 method_not_allowed with an Allow header listing the route's methods. Then the route's handler runs.
  3. Session routes call requireUser inside their handler, which throws 401 unauthorized without a valid session cookie. Because the method check comes first, POST /api/me without a cookie returns 405, not 401.
  4. Hermes routes. handleHermes checks 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 gives 404 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/ ​

FileResponsibility
index.tsWorker fetch entry, API routing, and the signup, login, logout, and /api/me handlers
env.tsEnv interface (bindings, vars, optional secrets) and row types User, Inmate, SubscriptionRow
http.tsjson, errorResponse, apiError, readJson, cookieValue, sessionCookie, methodNotAllowed
auth.tsPBKDF2 password hashing and verification, session create/lookup/clear, requireUser
plans.tsThe PLANS constant, the Plan type, and findPlan
inmate.tsLoad, validate-and-save, and remove the account's inmate; the public inmate shape
billing.tsCreate Stripe Checkout sessions (expiring the user's previously open one first) and Billing Portal sessions through the Stripe REST API
stripe_webhook.tsVerify Stripe-Signature, deduplicate events, archive to R2, re-fetch the subscription, map events to subscription and connection state, record and cancel duplicate subscriptions
hermes.tsBearer-authenticated endpoints, split into one handler per endpoint: pending connections, acknowledgements, usage ingestion (via recordUsage), allowances (monthly window per subscription)
period.tsmonthlyWindow(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.tsgetUsage 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_hash and users.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_at is set 30 days after creation.
  • getSessionUser joins sessions to users where the hash matches and expires_at is 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 204 even 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.

The cookie is built by sessionCookie in src/http.ts:

text
ra_session=<token>; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=2592000
  • HttpOnly and SameSite=Lax are always set; the path is /.
  • Secure is added when the request URL scheme is https: (the Worker looks at request.url, not at APP_URL), so production over HTTPS always gets Secure and http://localhost development does not.
  • Clearing sets Max-Age=0 and Expires=Thu, 01 Jan 1970 00:00:00 GMT instead of the 30-day Max-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:

  • POST /api/stripe/webhook authenticates the request by HMAC signature (see Webhooks and Security).
  • /api/hermes/* authenticates with Authorization: Bearer <HERMES_LINK_TOKEN>, compared in constant time. If the secret is not configured every Hermes request is rejected with 401.