# epic-stack — Onboarding Guide

> Generated by [Repo Onboarding](https://repo-onboarding-tau.vercel.app) · analyzed Sep 10, 2026 · onboard/0.3.0 · commit `8473afd`

The Epic Stack is Kent C. Dodds's opinionated full-stack starter and reference application, and this repository is the template itself. It ships as a working app called Epic Notes: server-rendered React Router 7 on an Express server, SQLite through Prisma replicated across Fly.io machines with LiteFS, and notes whose images live in S3-compatible Tigris storage. Around that deliberately small domain it implements the parts most products need and few get right on the first try: email signup with emailed one-time codes, password reset, GitHub OAuth, passkeys, TOTP two-factor, role-based permissions, a two-tier cache, tiered rate limiting, and a GitHub Actions workflow that tests every change and deploys `main` to production and `dev` to staging. `npx epicli` generates a new project from it and runs remix.init to rename the app and optionally provision Fly.

**Who this is for:** Developers who generated an app from the Epic Stack (or are evaluating it) and need to know where authentication, data access, styling and deployment live before they change them, and contributors to the template itself, whose design decisions are recorded one per file in docs/decisions.

## Contents

- [Overview](#overview)
- [Architecture](#architecture)
- [Dependency Graph](#dependency-graph)
- [Codebase Map](#codebase-map)
- [API Surface](#api-surface)
- [Design System](#design-system)
- [Contributor Guide](#contributor-guide)
- [Guided Tour](#guided-tour)
- [Hotspots](#hotspots)
- [Setup](#setup)
- [Delivery](#delivery)
- [Learn](#learn)
- [First Tasks](#first-tasks)

## Overview

**367 files · 44,194 lines · primary language TypeScript**

### Tech stack

| Name | Category | Role |
| --- | --- | --- |
| TypeScript | language | Every module in app/, server/ and tests/ is TypeScript (ADR 001 makes the stack TypeScript-only), and imports resolve through the package.json `#app/*` and `#tests/*` subpath map rather than tsconfig paths. |
| React | library | Renders every route module on the server with renderToPipeableStream in app/entry.server.tsx and hydrates in the browser; UI state is kept in forms, fetchers and the URL rather than client-side stores. |
| React Router | framework | Framework mode v7: route modules export loader, action, meta, handle and ErrorBoundary; `react-router build` produces build/server and build/client; react-router-auto-routes turns the app/routes folder tree into the route manifest. |
| Express | framework | The HTTP server in server/index.ts: HTTPS and trailing-slash redirects, compression, security headers, three rate-limit tiers and static assets, before handing every request to @react-router/express. |
| Prisma | library | ORM for the SQLite application database: schema in prisma/schema.prisma, migrations in prisma/migrations (the initial one also inserts the admin and user roles), and TypedSQL for the raw user-search query in prisma/sql. |
| SQLite | database | Both datastores: Prisma's application database and a separate cache database that app/utils/cache.server.ts opens directly with node:sqlite; other/litefs.yml puts both in WAL mode when a deployed primary boots. |
| LiteFS | infra | Replicates the SQLite files across Fly machines: other/litefs.yml elects a primary through Consul, runs migrations only on the primary candidate, and fronts the app with a proxy that forwards write requests to the primary. |
| Fly.io | platform | Deploy target: fly.toml defines the volume, service ports and health checks, and the GitHub workflow builds the image with flyctl and deploys `main` to the app and `dev` to its `-staging` twin. |
| Tailwind CSS | library | Version 4, configured entirely in app/styles/tailwind.css: semantic colour custom properties with light and `.dark` values, a named type scale and animations declared with @theme, and a class-based dark variant. |
| shadcn/ui | tooling | The source registry behind app/components/ui: components.json points its CLI at the Tailwind file and `#app/components/ui`, and downloaded components are edited in place rather than updated as a dependency. |
| Radix UI | library | Headless behaviour and accessibility under the checkbox, dropdown menu, label and tooltip primitives, plus the Slot that gives Button its `asChild` prop; only files in app/components/ui import it. |
| Conform | library | Progressively enhanced forms: every action validates FormData with parseWithZod, and useForm wires field props and the action's `submission.reply()` errors back into the Field components. |
| Vite | tooling | Builds the app through the React Router plugin, runs as Express middleware in development, generates the SVG icon sprite, and loads the Sentry plugin only for production builds that have an auth token. |
| Vitest | tooling | Unit and component tests colocated as app/**/*.test.ts(x), each worker copying a pre-migrated SQLite file and failing any test that logs console.error or console.warn. |
| Playwright | tooling | End-to-end tests in tests/e2e, run in CI against the built app started with `npm run start:mocks`, using fixtures that insert a user and a session cookie instead of clicking through login. |
| MSW | tooling | Intercepts Resend, GitHub, Tigris and Have I Been Pwned calls both in tests and in `npm run dev` (MOCKS=true), so the app runs offline with the fake values in .env.example. |

### Languages

| Language | Files | LOC | Share |
| --- | --- | --- | --- |
| JSON | 7 | 19,910 | 45.1% |
| TypeScript | 154 | 13,064 | 29.6% |
| Markdown | 101 | 10,072 | 22.8% |
| JavaScript | 3 | 337 | 0.8% |
| SQL | 2 | 257 | 0.6% |
| YAML | 2 | 224 | 0.5% |
| CSS | 1 | 185 | 0.4% |
| Dockerfile | 1 | 68 | 0.2% |
| TOML | 2 | 54 | 0.1% |
| Config | 2 | 23 | 0.1% |

## Architecture

### Request path: Express first, then React Router

`index.ts` is the process entry: it installs source-map support, imports the MSW server when `MOCKS=true`, then imports `server/index.ts`. That file builds one Express app and layers, in order: an HTTPS redirect driven by Fly's `X-Forwarded-Proto`, a trailing-slash redirect, compression, `@nichtsam/helmet` security headers, a 404 handler for `/img/*` and `/favicons/*`, `morgan` logging, and three `express-rate-limit` tiers — 10 requests a minute for non-GET requests whose path contains login, signup, verify, onboarding, reset-password, settings/profile or admin (and for GETs to `/verify`), 100 for other non-GET requests, 1000 for everything else. Outside production every limit is multiplied by 10,000.

In development it mounts Vite in middleware mode and loads `server/app.ts` through `ssrLoadModule` on every request, so route edits need no restart. In production it serves `build/client` (fingerprinted `/assets` with a one-year immutable cache) and imports `build/server/index.js`, which Vite built from `server/app.ts`. Either way `server/app.ts` is only `createRequestHandler` from `@react-router/express`.

React Router runs the root loader and the matched route loaders, or the matched action, and `app/entry.server.tsx` streams the document: it generates a CSP nonce, sets a report-only Content-Security-Policy, waits for `onAllReady` for bots and `onShellReady` for people, and stamps Fly region and LiteFS primary headers on document and data responses. `app/root.tsx`'s loader is the one place that loads the signed-in user (with roles and permissions), the theme cookie, the flash toast, honeypot props and the public `ENV`; components read the user from it with `useOptionalUser()`.

Deployed, two proxies sit in front of Express: Fly's edge sends traffic to internal port 8080, where the LiteFS proxy listens and forwards to the app on 8081, sending write requests to the primary machine.

**From browser to route module**

```mermaid
flowchart TB
  browser["Browser"] --> fly["Fly edge, ports 80 and 443"]
  fly --> litefs["LiteFS proxy :8080<br/>other/litefs.yml, deployed only"]
  litefs --> express["Express :8081<br/>server/index.ts"]
  express --> redirects["HTTPS and trailing-slash redirects"]
  redirects --> headers["compression, helmet headers, morgan"]
  headers --> limits["express-rate-limit tiers"]
  limits -->|development| vite["Vite middleware<br/>ssrLoadModule server/app.ts"]
  limits -->|production| statics["express.static build/client"]
  statics --> bundle["build/server/index.js"]
  vite --> handler["createRequestHandler<br/>server/app.ts"]
  bundle --> handler
  handler --> rootLoader["app/root.tsx loader<br/>user, theme, toast, ENV"]
  handler --> routeModule["app/routes loader or action"]
  rootLoader --> render["app/entry.server.tsx<br/>nonce, report-only CSP, streamed HTML"]
  routeModule --> render
```

### Route modules and the server-only utilities they call

Routes are files under `app/routes/`, turned into a manifest by `react-router-auto-routes` (`app/routes.ts`). Folders prefixed with `_` (`_auth`, `_marketing`, `_seo`) group files without adding a URL segment, `$` marks a parameter, `_layout.tsx` wraps its sibling routes, a trailing `_` escapes nesting (`$noteId_.edit.tsx` is `/users/:username/notes/:noteId/edit` but does not render inside the note view), `[.]` escapes a dot (`robots[.]txt.ts`), and `+` folders hold colocated modules that never become routes (`notes/+shared/note-editor.tsx`). Anything named `*.server.*` is ignored as a route, which is how route-specific server logic sits beside its route: `_auth/verify.server.ts`, `_auth/login.server.ts`, `settings/profile/change-email.server.tsx`.

Shared server code is one module per concern in `app/utils/*.server.ts`: `auth.server.ts` (sessions, login, signup, password hashing, the common-password check), `permissions.server.ts`, `db.server.ts` (the Prisma singleton), `cache.server.ts`, `storage.server.ts` (hand-signed S3 requests to Tigris), `email.server.ts` (Resend over fetch), `toast.server.ts` and `honeypot.server.ts`. There is no auth middleware: each loader and action calls a guard itself, and a layout loader's guard does not protect a child route's action, which is why every action under `settings/profile` calls `requireUserId` again. Resource routes under `app/routes/resources/` are loaders and actions with no UI: image optimisation, the health check, the theme cookie and the personal data export.

**Which route groups call which utilities**

```mermaid
flowchart LR
  subgraph routes["app/routes"]
    authRoutes["_auth: login, signup, verify, onboarding, OAuth, passkeys"]
    notesRoutes["users/$username/notes"]
    settingsRoutes["settings/profile"]
    resourceRoutes["resources: images, healthcheck, theme-switch"]
    adminRoutes["admin/cache"]
  end
  verifyServer["_auth/verify.server.ts"]
  authServer["utils/auth.server.ts"]
  permServer["utils/permissions.server.ts"]
  dbServer["utils/db.server.ts"]
  cacheServer["utils/cache.server.ts"]
  storageServer["utils/storage.server.ts"]
  emailServer["utils/email.server.ts"]
  authRoutes --> authServer
  authRoutes --> verifyServer
  authRoutes --> emailServer
  settingsRoutes --> authServer
  settingsRoutes --> verifyServer
  settingsRoutes --> storageServer
  notesRoutes --> authServer
  notesRoutes --> permServer
  notesRoutes --> storageServer
  resourceRoutes --> storageServer
  adminRoutes --> permServer
  adminRoutes --> cacheServer
  cacheServer -->|replica writes| adminRoutes
  permServer --> authServer
  authServer --> dbServer
  authServer --> storageServer
  verifyServer --> dbServer
  dbServer --> appDb[("SQLite app database")]
  cacheServer --> cacheDb[("SQLite cache database")]
  storageServer --> tigris(["Tigris S3"])
  emailServer --> resend(["Resend API"])
```

### Identity: session rows, one-time codes and second factors

Sign-in state is a database row, not a token. `login()`, `signup()`, the passkey flow and the GitHub callback all create a `Session` row and put only its id in the `en_session` cookie, signed with `SESSION_SECRET`. `getUserId()` looks that row up on every request and treats a missing or expired row as a forced logout. `requireUserId`, `requireAnonymous`, `requireUserWithRole` and `requireUserWithPermission` are the whole guard vocabulary.

Everything that must prove control of an email address or an authenticator goes through one mechanism. `prepareVerification()` in `_auth/verify.server.ts` upserts a `Verification` row holding a TOTP secret, keyed by `target` and `type`, and returns a six-character code plus a `/verify` URL to email. The `/verify` action checks the code against that row and dispatches on `type` — `onboarding`, `reset-password`, `change-email` or `2fa` — to a handler colocated with the feature. What a handler proves is carried to the next step in a separate `en_verification` cookie with a ten-minute max age: the email being onboarded, the username being reset, or a session still waiting for its second factor.

Two-factor reuses the same table. Enabling it stores a `2fa-verify` row that becomes `2fa` once the user confirms a code from their app. At login, `handleNewSession()` in `_auth/login.server.ts` looks for that row; if it exists, the new session id is parked in `en_verification` and the user is sent to `/verify?type=2fa` instead of receiving `en_session`. Sensitive settings pages call `requireRecentVerification()`, which sends 2FA users back through `/verify` when their last verification is older than the window `shouldRequestTwoFA()` computes.

**Email signup through /verify and /onboarding**

```mermaid
sequenceDiagram
  actor V as Visitor
  participant S as /signup action
  participant P as prepareVerification
  participant DB as SQLite
  participant R as Resend
  participant Vf as /verify action
  participant O as /onboarding action
  V->>S: POST email, honeypot checked
  S->>P: type onboarding, target email, 10 minutes
  P->>DB: upsert Verification with TOTP secret
  S->>R: send email with code and verify link
  S-->>V: redirect to /verify
  V->>Vf: POST six-character code
  Vf->>DB: check TOTP, delete Verification
  Vf-->>V: en_verification cookie holds email, redirect /onboarding
  V->>O: POST username, name, password
  O->>DB: create User, Password and Session with role user
  O-->>V: en_session cookie set, en_verification destroyed, toast
```

### Data and replication: one writable primary, and a cache that knows it

`prisma/schema.prisma` models a small domain — a `User` owns `Note`s, each with `NoteImage`s, plus one optional `UserImage` — and a larger identity model: `Password` (a bcrypt hash), `Session`, `Connection` (OAuth accounts), `Passkey`, `Verification`, and many-to-many `Role` and `Permission`. Images are not stored in the database: rows hold an `objectKey` into Tigris, and `/resources/images` fetches the object with a signed GET and resizes it with openimg, caching results on disk. The `admin` and `user` roles and their sixteen permissions (create, read, update, delete × user, note × own, any) are inserted by the initial migration SQL, not by `prisma/seed.ts`: `user` holds the eight `own` variants and `admin` the eight `any` variants, so every environment has them after `prisma migrate deploy`.

In production both SQLite files sit on a LiteFS FUSE mount and replicate to every machine, but only the primary — elected through Consul among machines whose region equals `PRIMARY_REGION` — can write. `other/litefs.yml` runs `prisma migrate deploy` and the WAL pragmas only on the primary candidate, then `npm start` everywhere. The LiteFS proxy forwards write requests (POST and friends) to the primary, but a GET loader that writes must call `ensurePrimary()` itself, as the OAuth callback does. The cache follows the same rule by hand: `app/utils/cache.server.ts` offers an in-memory LRU and a SQLite-backed `cache` for `cachified`; on a replica, `cache.set` and `cache.delete` POST the change to the primary's `/admin/cache/sqlite` with a Bearer `INTERNAL_COMMAND_TOKEN`, a value the Dockerfile generates for every image.

**Primary, replicas and the cache write path**

```mermaid
flowchart LR
  consul(["Fly Consul lease"])
  subgraph primaryRegion["Machines in PRIMARY_REGION"]
    primary["Primary instance"]
  end
  replica["Replica instance"]
  primary -.->|holds lease| consul
  replica -.->|watches lease| consul
  primary -->|"at boot: prisma migrate deploy, WAL pragmas"| files[("sqlite.db and cache.db on the LiteFS mount")]
  files ==>|LiteFS replication| replicaFiles[("read-only copies")]
  replica --> replicaFiles
  replica -->|"POST, PUT, DELETE forwarded by the LiteFS proxy"| primary
  replica -->|"cache.set or delete: POST /admin/cache/sqlite with Bearer INTERNAL_COMMAND_TOKEN"| primary
  callback["GET /auth/:provider/callback"] -->|ensurePrimary| primary
```

## Dependency Graph

```mermaid
flowchart LR
  n0["index.ts"]
  n1["server/index.ts"]
  n2["server/app.ts"]
  n3["server/utils/monitoring.ts"]
  n4["app/entry.server.tsx"]
  n5["app/root.tsx"]
  n6["app/routes/_auth"]
  n7["app/routes/users"]
  n8["app/routes/settings/profile"]
  n9["app/routes/resources"]
  n10["app/routes/admin/cache"]
  n11["_auth/verify.server.ts"]
  n12["utils/auth.server.ts"]
  n13["utils/session.server.ts"]
  n14["utils/permissions.server.ts"]
  n15["utils/db.server.ts"]
  n16["utils/cache.server.ts"]
  n17["utils/storage.server.ts"]
  n18["utils/email.server.ts"]
  n19["utils/litefs.server.ts"]
  n20["utils/env.server.ts"]
  n21["utils/providers/github.server.ts"]
  n22["app/components/ui"]
  n23["app/components/forms.tsx"]
  n24["app/styles/tailwind.css"]
  n25["tests/mocks"]
  n26["prisma/schema.prisma"]
  n27["express"]
  n28["@react-router/express"]
  n29["react-router"]
  n30["@prisma/client"]
  n31["remix-auth"]
  n32["litefs-js"]
  n33["@epic-web/cachified"]
  n34["@conform-to/zod"]
  n35["@radix-ui/react-*"]
  n36["tailwindcss"]
  n37["openimg"]
  n38["msw"]
  n39["SQLite app database (LiteFS)"]
  n40["SQLite cache database"]
  n41["Tigris object storage"]
  n42["Resend email API"]
  n43["GitHub OAuth and API"]
  n44["Have I Been Pwned range API"]
  n45["Sentry"]
  n0 -->|imports| n1
  n0 -->|imports when MOCKS=true| n25
  n1 -->|builds app with| n27
  n1 -->|loads via Vite in dev, build/server in production| n2
  n1 -->|initialises in production when SENTRY_DSN is set| n3
  n3 -->|reports to| n45
  n2 -->|createRequestHandler| n28
  n28 -->|runs root loader and layout| n5
  n28 -->|renders documents with| n4
  n4 -->|validates env at startup| n20
  n4 -->|instance headers| n19
  n4 -->|captures loader and action errors| n45
  n5 -->|getUserId| n12
  n5 -->|loads user with roles| n15
  n5 -->|renders shell with| n22
  n5 -->|Outlet, Scripts, useLoaderData| n29
  n6 -->|login, signup, requireAnonymous| n12
  n6 -->|prepareVerification, validateRequest| n11
  n6 -->|sends codes| n18
  n6 -->|ensurePrimary in OAuth callback| n19
  n6 -->|form fields| n23
  n8 -->|requireUserId| n12
  n8 -->|requireRecentVerification| n11
  n8 -->|profile photo upload| n17
  n7 -->|delete:note checks| n14
  n7 -->|requireUserId| n12
  n7 -->|note image upload| n17
  n7 -->|notes and TypedSQL search| n15
  n7 -->|parseWithZod| n34
  n9 -->|signed GET for images| n17
  n9 -->|resizes images| n37
  n10 -->|requireUserWithRole admin| n14
  n10 -->|reads and deletes keys| n16
  n11 -->|Verification rows| n15
  n14 -->|requireUserId| n12
  n14 -->|role and permission query| n15
  n12 -->|en_session cookie| n13
  n12 -->|Session, User, Password| n15
  n12 -->|Authenticator| n31
  n12 -->|registers strategy| n21
  n12 -->|uploads OAuth avatar| n17
  n12 -->|checks common passwords| n44
  n21 -->|OAuth and profile calls| n43
  n21 -->|caches connection data| n16
  n15 -->|wraps| n30
  n26 -->|generates| n30
  n30 -->|reads and writes| n39
  n16 -->|backs| n33
  n16 -->|node:sqlite| n40
  n16 -->|checks primary| n19
  n16 -->|forwards replica writes to /admin/cache/sqlite| n10
  n19 -->|re-exports| n32
  n17 -->|signed PUT and GET| n41
  n18 -->|POST /emails| n42
  n23 -->|composes| n22
  n23 -->|useInputControl| n34
  n22 -->|wraps| n35
  n22 -->|semantic utility classes| n24
  n24 -->|configures| n36
  n25 -->|setupServer| n38
  n25 -->|intercepts| n42
  n25 -->|intercepts| n43
  n25 -->|intercepts| n41
  n25 -->|intercepts| n44
```

| Node | Kind | Path | Description |
| --- | --- | --- | --- |
| index.ts | entrypoint | `index.ts` | Process entry: source maps, optional MSW mocks, then the Express server. |
| server/index.ts | internal-module | `server/index.ts` | Express app: redirects, headers, rate limits, static files, dev Vite or production bundle. |
| server/app.ts | internal-module | `server/app.ts` | React Router request handler; the SSR build input in vite.config.ts. |
| server/utils/monitoring.ts | internal-module | `server/utils/monitoring.ts` | Sentry initialisation with health-check and bot-noise filters. |
| app/entry.server.tsx | internal-module | `app/entry.server.tsx` | Streams HTML with a nonce and report-only CSP, adds Fly and LiteFS headers, reports errors. |
| app/root.tsx | internal-module | `app/root.tsx` | Document shell and root loader: user, theme, toast, honeypot, ENV. |
| app/routes/_auth | internal-module | `app/routes/_auth` | Login, signup, verify, onboarding, password reset, GitHub OAuth and passkey routes. |
| app/routes/users | internal-module | `app/routes/users` | User search, profiles and the notes feature. |
| app/routes/settings/profile | internal-module | `app/routes/settings/profile` | Profile, photo, password, email, connections, passkeys and 2FA settings. |
| app/routes/resources | internal-module | `app/routes/resources` | UI-less routes: images, healthcheck, theme switch, data export. |
| app/routes/admin/cache | internal-module | `app/routes/admin/cache` | Admin cache browser and the replica-to-primary cache write endpoint. |
| _auth/verify.server.ts | internal-module | `app/routes/_auth/verify.server.ts` | prepareVerification, code validation and dispatch to per-flow handlers. |
| utils/auth.server.ts | internal-module | `app/utils/auth.server.ts` | Session lookup, guards, login, signup, password hashing, common-password check. |
| utils/session.server.ts | internal-module | `app/utils/session.server.ts` | The en_session cookie storage. |
| utils/permissions.server.ts | internal-module | `app/utils/permissions.server.ts` | requireUserWithPermission and requireUserWithRole. |
| utils/db.server.ts | internal-module | `app/utils/db.server.ts` | Prisma client singleton with slow-query logging. |
| utils/cache.server.ts | internal-module | `app/utils/cache.server.ts` | LRU and SQLite caches for cachified, forwarding replica writes to the primary. |
| utils/storage.server.ts | internal-module | `app/utils/storage.server.ts` | SigV4-signed uploads and fetches against Tigris. |
| utils/email.server.ts | internal-module | `app/utils/email.server.ts` | Renders React Email templates and posts them to Resend. |
| utils/litefs.server.ts | internal-module | `app/utils/litefs.server.ts` | Server-only re-exports of litefs-js helpers such as ensurePrimary. |
| utils/env.server.ts | internal-module | `app/utils/env.server.ts` | Zod validation of process.env at startup and the public ENV subset. |
| utils/providers/github.server.ts | internal-module | `app/utils/providers/github.server.ts` | GitHub OAuth strategy, cached profile lookups and the mock redirect. |
| app/components/ui | internal-module | `app/components/ui` | shadcn/ui-derived primitives: Button, Icon, Input, menus, tooltips, OTP input. |
| app/components/forms.tsx | internal-module | `app/components/forms.tsx` | Field, TextareaField, CheckboxField, OTPField and ErrorList for Conform forms. |
| app/styles/tailwind.css | internal-module | `app/styles/tailwind.css` | Colour tokens, type scale, animations and the dark variant. |
| tests/mocks | internal-module | `tests/mocks` | MSW handlers for every third-party API the app calls. |
| prisma/schema.prisma | internal-module | `prisma/schema.prisma` | Data model; migrations and seed live beside it. |
| express | external-package | — | — |
| @react-router/express | external-package | — | — |
| react-router | external-package | — | — |
| @prisma/client | external-package | — | — |
| remix-auth | external-package | — | — |
| litefs-js | external-package | — | — |
| @epic-web/cachified | external-package | — | — |
| @conform-to/zod | external-package | — | — |
| @radix-ui/react-* | external-package | — | — |
| tailwindcss | external-package | — | — |
| openimg | external-package | — | — |
| msw | external-package | — | — |
| SQLite app database (LiteFS) | datastore | — | — |
| SQLite cache database | datastore | — | — |
| Tigris object storage | external-service | — | — |
| Resend email API | external-service | — | — |
| GitHub OAuth and API | external-service | — | — |
| Have I Been Pwned range API | external-service | — | — |
| Sentry | external-service | — | — |

## Codebase Map

### `app/routes` — core domain

Every URL the app serves, as React Router route modules whose file and folder names generate the route manifest; loaders, actions, UI and error boundaries live together per route.

Key files:

- `app/routes.ts` — autoRoutes config: ignores tests, dotfiles and *.server.* / *.client.* files.
- `app/routes/$.tsx` — Splat route whose loader and action throw 404 to render the not-found boundary.
- `app/routes/me.tsx` — Redirects the signed-in user to their own profile.

### `app/routes/_auth` — core domain

All identity flows without a URL prefix: password login, email signup and onboarding, password reset, the shared /verify code step, GitHub OAuth start and callback, and passkey sign-in and registration.

Key files:

- `app/routes/_auth/verify.server.ts` — prepareVerification, isCodeValid and the dispatch by verification type.
- `app/routes/_auth/login.server.ts` — handleNewSession: sets en_session or detours through 2FA.
- `app/routes/_auth/auth.$provider/callback.ts` — OAuth callback that links, signs in or starts onboarding.
- `app/routes/_auth/onboarding/index.tsx` — Account creation gated on a verified email in the verification cookie.
- `app/routes/_auth/webauthn/authentication.ts` — Passkey challenge and verification returning JSON.

### `app/routes/users` — core domain

The Epic Notes domain: user search, public profiles, and each user's notes with image attachments, including the shared editor used for creating and editing.

Key files:

- `app/routes/users/index.tsx` — Search page running the TypedSQL searchUsers query.
- `app/routes/users/$username/notes/_layout.tsx` — Owner lookup and the notes sidebar.
- `app/routes/users/$username/notes/$noteId.tsx` — Note view and permission-checked delete action.
- `app/routes/users/$username/notes/+shared/note-editor.server.tsx` — Owner-checked upsert with Tigris image uploads.
- `app/routes/users/$username/notes/+shared/note-editor.tsx` — Conform editor UI and the NoteEditorSchema shared with the server.

### `app/routes/settings/profile` — core domain

Account settings under one layout: profile details, photo, password create and change, email change, OAuth connections, passkeys, and enabling or disabling two-factor authentication.

Key files:

- `app/routes/settings/profile/index.tsx` — One action with update-profile, sign-out-of-sessions and delete-data intents.
- `app/routes/settings/profile/two-factor/verify.tsx` — QR code and confirmation that turns a 2fa-verify row into 2fa.
- `app/routes/settings/profile/change-email.server.tsx` — Completes an email change once /verify accepts the code.
- `app/routes/settings/profile/photo.tsx` — Multipart upload with a 3MB limit, stored in Tigris.

### `app/routes/resources` — adapter

Routes with no page of their own that other pages and infrastructure call: the image optimiser, the Fly health check, the theme preference cookie and the JSON export of a user's data.

Key files:

- `app/routes/resources/images.tsx` — openimg optimiser over Tigris objects and local files.
- `app/routes/resources/healthcheck.tsx` — SELECT 1 plus a HEAD request to itself.
- `app/routes/resources/theme-switch.tsx` — Theme action plus the ThemeSwitch component and useTheme hooks.

### `app/routes/admin/cache` — supporting

An admin-only browser for cache keys across LiteFS instances, and the token-protected endpoint replicas use to write cache entries on the primary.

Key files:

- `app/routes/admin/cache/index.tsx` — Search, inspect and delete keys per instance.
- `app/routes/admin/cache/sqlite.server.ts` — updatePrimaryCacheValue and the INTERNAL_COMMAND_TOKEN-guarded action.

### `app/utils` — core infrastructure

Shared modules routes call instead of reaching for libraries directly; *.server.ts files are server-only concerns (auth, database, cache, storage, email, env), the rest are isomorphic helpers and hooks.

Key files:

- `app/utils/auth.server.ts` — Session model, guards, login and signup.
- `app/utils/permissions.server.ts` — Role and permission guards throwing 403.
- `app/utils/cache.server.ts` — LRU and SQLite caches, primary-aware writes.
- `app/utils/env.server.ts` — Startup env schema and the public getEnv subset.
- `app/utils/misc.tsx` — cn(), header helpers, useIsPending, useDoubleCheck, image URLs.
- `app/utils/user.ts` — useUser, useOptionalUser and client-side userHasPermission.

### `app/components` — design system

The UI building blocks: shadcn/ui-derived primitives in ui/, form field compositions for Conform, and a few app-shell components mounted by root.tsx.

Key files:

- `app/components/ui/button.tsx` — cva variants and sizes, asChild via Radix Slot.
- `app/components/ui/icon.tsx` — Typed sprite icons with size scale.
- `app/components/forms.tsx` — Field, TextareaField, CheckboxField, OTPField, ErrorList.
- `app/components/error-boundary.tsx` — GeneralErrorBoundary used by route ErrorBoundaries.

### `app/styles/tailwind.css` — config

The whole Tailwind v4 configuration: light and dark colour custom properties, their @theme inline mapping, the named type scale, radii, keyframe animations and the container utility.

Key files:

- `components.json` — shadcn CLI config pointing at this stylesheet.

### `server` — entrypoint

The Node process around the React Router app: the Express server with its middleware stack, the request handler Vite builds for production, and Sentry setup.

Key files:

- `index.ts` — Loads mocks when MOCKS=true, then server/index.ts.
- `server/index.ts` — Middleware order, rate limits, dev versus production serving.
- `server/app.ts` — createRequestHandler from @react-router/express.
- `app/entry.server.tsx` — Streaming render, CSP, Fly headers, error reporting.

### `prisma` — data

Database schema, the single initial migration (which also inserts roles and permissions), the development seed with the kody admin user, and raw SQL compiled by TypedSQL.

Key files:

- `prisma/schema.prisma` — SQLite datasource with the typedSql preview feature.
- `prisma/migrations/20250221233640_init/migration.sql` — Tables plus INSERTs for 16 permissions and 2 roles.
- `prisma/seed.ts` — Five fake users with notes, and kody / kodylovesyou.
- `prisma/sql/searchUsers.sql` — User search ordered by most recent note.

### `tests` — tests

Everything tests need except the unit tests themselves (which sit next to code in app/): Playwright specs and fixtures, MSW handlers, Vitest global and per-worker setup, and image fixtures.

Key files:

- `tests/playwright-utils.ts` — login, insertNewUser, navigate and prepareGitHubUser fixtures.
- `tests/e2e/onboarding.test.ts` — Largest spec: signup, OAuth and reset journeys.
- `tests/mocks/index.ts` — MSW server shared by dev and tests.
- `tests/setup/setup-test-env.ts` — Fails tests on console.error or console.warn.
- `tests/setup/global-setup.ts` — Builds tests/prisma/base.db when the schema changes.

### `other` — config

Deployment and tooling files kept out of the root: the Dockerfile Fly builds, the LiteFS config, raw SVG icons for the sprite, and the Sly icon CLI config.

Key files:

- `other/Dockerfile` — Multi-stage image ending in `litefs mount`.
- `other/litefs.yml` — Mount, proxy, Consul lease and boot commands.
- `other/svg-icons` — Source SVGs compiled into app/components/ui/icons/sprite.svg.

### `docs` — docs

Adopter documentation per topic, 47 numbered decision records explaining why the stack is shaped this way, and SKILL.md guides for AI coding agents working in an Epic Stack app.

Key files:

- `docs/decisions` — ADRs from 001-typescript-only to 047-mock-cache-server-in-tests.
- `docs/routing.md` — Generated route listing for the current file tree.
- `docs/database.md` — LiteFS primary rules and widen-then-narrow migrations.
- `docs/skills` — Agent skills for auth, caching, database, forms, routing, testing and UI.

### `.github/workflows` — delivery

The single CI and CD workflow that lints, type-checks, unit-tests and end-to-end-tests every change and deploys pushes to main and dev on Fly.

Key files:

- `.github/workflows/deploy.yml` — lint, typecheck, vitest, playwright, container and deploy jobs.

### `remix.init` — supporting

Post-generation script run when a new project is created from the template: renames the app, writes .env with a random session secret, removes template-only files and can provision Fly apps, volumes, Consul and Tigris.

Key files:

- `remix.init/index.mjs` — Setup prompts and flyctl commands.

## API Surface

| Method | Path | Actor | File |
| --- | --- | --- | --- |
| GET | `/` | public | `app/routes/_marketing/index.tsx` |
| GET | `/about` | public | `app/routes/_marketing/about.tsx` |
| GET | `/privacy` | public | `app/routes/_marketing/privacy.tsx` |
| GET | `/support` | public | `app/routes/_marketing/support.tsx` |
| GET | `/tos` | public | `app/routes/_marketing/tos.tsx` |
| GET | `/robots.txt` | public | `app/routes/_seo/robots[.]txt.ts` |
| GET | `/sitemap.xml` | public | `app/routes/_seo/sitemap[.]xml.ts` |
| GET | `/login` | anonymous visitor | `app/routes/_auth/login.tsx` |
| POST | `/login` | anonymous visitor | `app/routes/_auth/login.tsx` |
| GET | `/logout` | public | `app/routes/_auth/logout.tsx` |
| POST | `/logout` | public | `app/routes/_auth/logout.tsx` |
| GET | `/signup` | anonymous visitor | `app/routes/_auth/signup.tsx` |
| POST | `/signup` | public | `app/routes/_auth/signup.tsx` |
| GET | `/forgot-password` | public | `app/routes/_auth/forgot-password.tsx` |
| POST | `/forgot-password` | public | `app/routes/_auth/forgot-password.tsx` |
| GET | `/reset-password` | anonymous visitor with verification cookie | `app/routes/_auth/reset-password.tsx` |
| POST | `/reset-password` | anonymous visitor with verification cookie | `app/routes/_auth/reset-password.tsx` |
| GET | `/verify` | public | `app/routes/_auth/verify.tsx` |
| POST | `/verify` | public | `app/routes/_auth/verify.tsx` |
| GET | `/onboarding` | anonymous visitor with verification cookie | `app/routes/_auth/onboarding/index.tsx` |
| POST | `/onboarding` | anonymous visitor with verification cookie | `app/routes/_auth/onboarding/index.tsx` |
| GET | `/onboarding/:provider` | anonymous visitor with verification cookie | `app/routes/_auth/onboarding/$provider.tsx` |
| POST | `/onboarding/:provider` | anonymous visitor with verification cookie | `app/routes/_auth/onboarding/$provider.tsx` |
| GET | `/auth/:provider` | public | `app/routes/_auth/auth.$provider/index.ts` |
| POST | `/auth/:provider` | public | `app/routes/_auth/auth.$provider/index.ts` |
| GET | `/auth/:provider/callback` | public | `app/routes/_auth/auth.$provider/callback.ts` |
| GET | `/webauthn/authentication` | public | `app/routes/_auth/webauthn/authentication.ts` |
| POST | `/webauthn/authentication` | public | `app/routes/_auth/webauthn/authentication.ts` |
| GET | `/webauthn/registration` | signed-in user | `app/routes/_auth/webauthn/registration.ts` |
| POST | `/webauthn/registration` | signed-in user | `app/routes/_auth/webauthn/registration.ts` |
| GET | `/me` | signed-in user | `app/routes/me.tsx` |
| GET | `/users` | public | `app/routes/users/index.tsx` |
| GET | `/users/:username` | public | `app/routes/users/$username/index.tsx` |
| GET | `/users/:username/notes` | public | `app/routes/users/$username/notes/_layout.tsx` |
| GET | `/users/:username/notes/new` | signed-in user | `app/routes/users/$username/notes/new.tsx` |
| POST | `/users/:username/notes/new` | signed-in user | `app/routes/users/$username/notes/+shared/note-editor.server.tsx` |
| GET | `/users/:username/notes/:noteId` | public | `app/routes/users/$username/notes/$noteId.tsx` |
| POST | `/users/:username/notes/:noteId` | note owner or admin | `app/routes/users/$username/notes/$noteId.tsx` |
| GET | `/users/:username/notes/:noteId/edit` | note owner | `app/routes/users/$username/notes/$noteId_.edit.tsx` |
| POST | `/users/:username/notes/:noteId/edit` | note owner | `app/routes/users/$username/notes/+shared/note-editor.server.tsx` |
| GET | `/settings/profile` | signed-in user | `app/routes/settings/profile/index.tsx` |
| POST | `/settings/profile` | signed-in user | `app/routes/settings/profile/index.tsx` |
| GET | `/settings/profile/change-email` | recently re-verified user | `app/routes/settings/profile/change-email.tsx` |
| POST | `/settings/profile/change-email` | signed-in user | `app/routes/settings/profile/change-email.tsx` |
| GET | `/settings/profile/connections` | signed-in user | `app/routes/settings/profile/connections.tsx` |
| POST | `/settings/profile/connections` | signed-in user | `app/routes/settings/profile/connections.tsx` |
| GET | `/settings/profile/passkeys` | signed-in user | `app/routes/settings/profile/passkeys.tsx` |
| POST | `/settings/profile/passkeys` | signed-in user | `app/routes/settings/profile/passkeys.tsx` |
| GET | `/settings/profile/password` | signed-in user | `app/routes/settings/profile/password.tsx` |
| POST | `/settings/profile/password` | signed-in user | `app/routes/settings/profile/password.tsx` |
| GET | `/settings/profile/password/create` | signed-in user | `app/routes/settings/profile/password_.create.tsx` |
| POST | `/settings/profile/password/create` | signed-in user | `app/routes/settings/profile/password_.create.tsx` |
| GET | `/settings/profile/photo` | signed-in user | `app/routes/settings/profile/photo.tsx` |
| POST | `/settings/profile/photo` | signed-in user | `app/routes/settings/profile/photo.tsx` |
| GET | `/settings/profile/two-factor` | signed-in user | `app/routes/settings/profile/two-factor/index.tsx` |
| POST | `/settings/profile/two-factor` | signed-in user | `app/routes/settings/profile/two-factor/index.tsx` |
| GET | `/settings/profile/two-factor/verify` | signed-in user | `app/routes/settings/profile/two-factor/verify.tsx` |
| POST | `/settings/profile/two-factor/verify` | signed-in user | `app/routes/settings/profile/two-factor/verify.tsx` |
| GET | `/settings/profile/two-factor/disable` | recently re-verified user | `app/routes/settings/profile/two-factor/disable.tsx` |
| POST | `/settings/profile/two-factor/disable` | recently re-verified user | `app/routes/settings/profile/two-factor/disable.tsx` |
| GET | `/resources/images` | public | `app/routes/resources/images.tsx` |
| GET | `/resources/healthcheck` | public | `app/routes/resources/healthcheck.tsx` |
| POST | `/resources/theme-switch` | public | `app/routes/resources/theme-switch.tsx` |
| GET | `/resources/download-user-data` | signed-in user | `app/routes/resources/download-user-data.tsx` |
| GET | `/admin/cache` | admin | `app/routes/admin/cache/index.tsx` |
| POST | `/admin/cache` | admin | `app/routes/admin/cache/index.tsx` |
| GET | `/admin/cache/lru/:cacheKey` | admin | `app/routes/admin/cache/lru.$cacheKey.ts` |
| GET | `/admin/cache/sqlite/:cacheKey` | admin | `app/routes/admin/cache/sqlite.$cacheKey.ts` |
| POST | `/admin/cache/sqlite` | replica instance | `app/routes/admin/cache/sqlite.server.ts` |
| GET | `/assets/*` | public | `server/index.ts` |
| GET | `/img/*` | public | `server/index.ts` |
| GET | `/favicons/*` | public | `server/index.ts` |
| ANY | `/*` | public | `app/routes/$.tsx` |

### Actors

- **public** — Anyone, signed in or not: marketing and SEO pages, profiles, notes and user search, the image optimiser, theme preference, signup and password-reset starts, /verify, passkey sign-in and the health check.
- **anonymous visitor** — A visitor without a session; signed-in users who reach these routes are redirected to /. Covers the login page and form and the signup page.
- **anonymous visitor with verification cookie** — An anonymous visitor holding the ten-minute en_verification cookie that /verify or the GitHub callback sets; without it onboarding redirects to /signup and password reset to /login.
- **signed-in user** — Any valid session: their own profile settings, photo, password, connections, passkeys and 2FA setup, creating notes, and downloading all of their own data as JSON.
- **recently re-verified user** — A signed-in user who, if 2FA is enabled, entered a code within the shouldRequestTwoFA window; otherwise they are sent through /verify?type=2fa before changing email or disabling 2FA.
- **note owner** — The signed-in user whose id is the note's ownerId: only they can load the edit page or update the note, and anyone else gets a 404 rather than a 403.
- **note owner or admin** — Deleting a note requires `delete:note:own` for its owner (granted to the user role) or `delete:note:any` for anyone else (granted to the admin role); others get a 403.
- **admin** — Users with the admin role, like the seeded kody: browse, inspect and delete LRU and SQLite cache entries on any LiteFS instance.
- **replica instance** — Another instance of this app rather than a person: a LiteFS replica forwarding a cache set or delete to the primary with the per-image INTERNAL_COMMAND_TOKEN as a Bearer token.

### Routes worth explaining

**`GET /sitemap.xml`** — Generated from the route manifest by @nasa-gcn/remix-seo; routes opt out with `handle.getSitemapEntries: () => null`, which is why auth and settings routes export that handle.

**`POST /login`** — Creates the Session row first, then handleNewSession decides: users with 2FA get the session id parked in en_verification and a redirect to /verify?type=2fa, everyone else gets en_session.

**`POST /signup`** — Unlike the page, the action does not call requireAnonymous; it rejects an email that already has an account, emails a ten-minute onboarding code and redirects to /verify.

**`POST /forgot-password`** — Answers 'No user exists with this username or email' for unknown accounts, unlike the reset handler that deliberately hides it, and sits outside the strongest rate-limit tier although every valid request sends an email.

**`POST /verify`** — The single endpoint behind onboarding, password reset, email change and 2FA login: it checks a TOTP code against the Verification row for the submitted type and target and dispatches to that flow's handler in verify.server.ts.

**`POST /auth/:provider`** — Starts the OAuth redirect (github is the only provider); signed-in users post here from settings to link an account. With MOCK_ GitHub credentials it skips GitHub and redirects straight to the callback with a fake code.

**`GET /auth/:provider/callback`** — A GET that writes: it links a connection, signs in, or starts onboarding depending on whether the visitor is signed in and the GitHub account or email is already known, so it calls ensurePrimary() first.

**`POST /webauthn/authentication`** — Passkey sign-in as JSON: the GET issues options and stores the challenge in a cookie, the POST verifies the signed response against the stored public key and passes the new session through handleNewSession, so 2FA still applies.

**`GET /users`** — User search runs prisma/sql/searchUsers.sql through TypedSQL: a LIKE match on username or name, ordered by each user's most recently updated note and capped at 50.

**`POST /users/:username/notes/new`** — Shares its action with the edit route; a new note is always owned by the signed-in user, whatever `:username` is in the URL.

**`GET /users/:username/notes/:noteId/edit`** — Loads the note filtered by `ownerId`, so the admin role's seeded `update:note:any` permission is never consulted — even though admins see the Edit button, because the toolbar renders whenever they can delete.

**`POST /settings/profile`** — One action with three intents chosen by the `intent` field: update-profile, sign-out-of-sessions and delete-data.

**`POST /settings/profile/change-email`** — Only the page's loader demands recent 2FA verification; the action checks for a session, rejects an email already in use and starts a change-email verification that completes through /verify.

**`POST /settings/profile/two-factor/verify`** — Confirms the code from the authenticator app and renames the pending `2fa-verify` Verification row to `2fa`, which is what turns two-factor on for every later login.

**`GET /resources/images`** — The openimg optimiser: `objectKey` fetches from Tigris with a signed request, `src` fetches an allowlisted URL or a local file, and resized results are cached on disk and served with a one-year immutable Cache-Control.

**`GET /resources/healthcheck`** — Polled by the http_check in fly.toml every 10 seconds: it runs SELECT 1 and a HEAD request to the app's own host, returning 500 if either fails.

**`POST /admin/cache/sqlite`** — Not for people: the action runs only on the primary and only for `Authorization: Bearer INTERNAL_COMMAND_TOKEN`, a value the Dockerfile generates per image; any other caller is redirected to an external video.

**`GET /assets/*`** — Fingerprinted build output served by express.static with a one-year immutable cache and no fallthrough, so a missing asset is a 404 rather than a rendered page.

**`GET /favicons/*`** — Registered before express.static even though its comment assumes the opposite order, so it answers 404 for the Android icons that public/site.webmanifest references.

## Design System

Tailwind v4 utility classes written against semantic tokens: app/styles/tailwind.css declares colour, radius, type-scale and animation custom properties (light values on :root, dark on .dark) and maps them into utilities through @theme inline, so markup says bg-primary, text-muted-foreground, text-h1 and rounded-md rather than a value. Components merge classes with cn() from app/utils/misc.tsx (clsx plus tailwind-merge) and express variants with class-variance-authority, the way buttonVariants does. The second way not to introduce is colour literals or Tailwind palette utilities: the violet logo tiles on the marketing index and DropdownMenuCheckboxItem's stroke="black" check icon are the existing exceptions, not the pattern to copy.

### Reuse rule

**Reuse when:** Before writing markup, look in app/components/ui (shadcn-derived primitives) and app/components/forms.tsx (Conform field wrappers). Route files reach for Button or StatusButton for actions — variant, size, asChild for links, status for submits — and Field, TextareaField, CheckboxField or OTPField for form inputs. Raw <button> elements are the exception rather than a second system: the theme switch toggle, the note editor's remove-image control and hidden default submit, and the logout submit that DropdownMenuItem asChild turns into a menu row. When a primitive is almost right, add a cva variant to it, the way Button gained its wide and pill sizes, rather than overriding className at each call site.

**Create when:** Create a primitive when the same interface pattern is needed on a second screen and nothing in app/components/ui or forms.tsx covers it. Pull the shadcn registry version with the CLI first (components.json points its ui alias at #app/components/ui) and adapt it to the tokens, and put new Conform compositions in forms.tsx. A piece used by one screen stays in its route folder, the way ImageChooser lives in app/routes/users/$username/notes/+shared/note-editor.tsx.

A new primitive belongs in `app/components/ui`.

### Token groups

Sampled, not exhaustive — each group's file is the full list.

**Colour** — `app/styles/tailwind.css`

Tailwind utilities generated from the @theme inline mapping: bg-primary text-primary-foreground for actions, text-muted-foreground for secondary copy, border-input aria-[invalid]:border-input-invalid on fields, with opacity modifiers such as hover:bg-primary/80.

For example: `background`, `foreground`, `primary`, `muted-foreground`, `accent`, `destructive`, `foreground-destructive`, `input-invalid`.

**Typography** — `app/styles/tailwind.css`

Named scale utilities from the --text-* tokens: page headings use text-h1 (login.tsx, users/index.tsx) and text-h2 (the note page title), secondary copy text-body-sm, while the ui primitives still size themselves with Tailwind's default text-sm.

For example: `text-mega`, `text-h1`, `text-h2`, `text-h6`, `text-body-lg`, `text-body-sm`, `text-body-2xs`, `text-button`.

**Radius** — `app/styles/tailwind.css`

One --radius base with --radius-sm/md/lg/xl derived from it by calc(), consumed as rounded-sm, rounded-md and rounded-lg — inputs, buttons, tooltips and menus all use rounded-md — so changing --radius reshapes every corner at once.

For example: `radius`, `radius-sm`, `radius-md`, `radius-lg`, `radius-xl`.

**Motion** — `app/styles/tailwind.css`

Named animations exposed as animate-* utilities: animate-slide-top, animate-slide-left and animate-roll-reveal on the marketing index, animate-caret-blink for the fake caret in InputOTPSlot. Tooltip and menu enter/exit use tw-animate-css classes (animate-in fade-in-0 zoom-in-95) instead.

For example: `animate-roll-reveal`, `animate-slide-left`, `animate-slide-top`, `animate-caret-blink`.

**Vertical spacing** — `app/components/spacer.tsx`

Gaps between page sections come from the Spacer component's named sizes — <Spacer size="xs" /> renders an empty div whose height class comes from the size map in spacer.tsx.

For example: `4xs`, `3xs`, `2xs`, `xs`, `sm`, `md`, `lg`, `4xl`.

**Icon size** — `app/components/ui/icon.tsx`

Icons are sized by name through the Icon component — <Icon name="pencil-1" size="md" /> — where each size also sets the gap to label children, and size="font" scales with the surrounding text.

For example: `font`, `xs`, `sm`, `md`, `lg`, `xl`.

### Primitives

Exhaustive — a primitive that is not here does not exist.

| Primitive | Reach for it when | File |
| --- | --- | --- |
| Button | Every clickable action and every link styled as one: pick a variant (default, destructive, outline, secondary, ghost, link) and a size (default, wide, sm, lg, pill, icon), and pass asChild to render a Link with button styling, as the note page's Edit link does. buttonVariants is exported beside it but nothing else imports it. | `app/components/ui/button.tsx` |
| StatusButton | Submit buttons whose request state matters: pass status ('pending' while the form submits, 'success' or 'error' from the result, otherwise 'idle') and it appends a delayed spinner, check or cross, with an optional message shown in a tooltip. | `app/components/ui/status-button.tsx` |
| Icon | Every icon in the app: renders a named symbol from the SVG sprite (IconName lists the valid names) at a named size, and when given children lays the label out beside the icon with a matching gap. | `app/components/ui/icon.tsx` |
| Input | The styled single-line text input, whose border switches to input-invalid when aria-invalid is set. Field in forms.tsx wraps it with a Label and an ErrorList for Conform forms. | `app/components/ui/input.tsx` |
| Label | The styled form label that the field wrappers in forms.tsx pair with their controls; use it directly only for a control those wrappers do not cover. | `app/components/ui/label.tsx` |
| Checkbox | The Radix checkbox styled with border-primary and a bg-primary checked state; CheckboxField wraps it for Conform, which is how the remember-me and terms checkboxes are built. | `app/components/ui/checkbox.tsx` |
| Textarea | The multi-line text input with the same border, focus ring and invalid styling as Input; TextareaField wraps it for Conform forms such as the note editor's content field. | `app/components/ui/textarea.tsx` |
| Tooltip | The Radix tooltip root: compose Tooltip around a TooltipTrigger and a TooltipContent inside a TooltipProvider, as StatusButton does to show its message. | `app/components/ui/tooltip.tsx` |
| TooltipTrigger | The element that opens the tooltip on hover and focus; StatusButton puts its status icon here so the message appears over it. | `app/components/ui/tooltip.tsx` |
| TooltipContent | The floating tooltip panel with popover colours, a rounded-md border and tw-animate-css enter/exit animation; the explanatory text goes here. | `app/components/ui/tooltip.tsx` |
| TooltipProvider | Supplies the shared open-delay behaviour a Tooltip needs; StatusButton renders one around its own tooltip, so wrap any new Tooltip in it too. | `app/components/ui/tooltip.tsx` |
| InputOTP | The one-time-code input (input-otp) behind every verification code entry; OTPField in forms.tsx wraps it with a label and errors for the code-entry screens. | `app/components/ui/input-otp.tsx` |
| InputOTPGroup | Groups adjacent InputOTPSlot cells into one visually joined, bordered row; OTPField uses it to lay out the code cells. | `app/components/ui/input-otp.tsx` |
| InputOTPSlot | One character cell of the code input, showing the typed character, the active ring and the animate-caret-blink fake caret on the focused slot. | `app/components/ui/input-otp.tsx` |
| InputOTPSeparator | The divider rendered between InputOTPGroup rows, so a long code reads in chunks instead of one unbroken row of cells. | `app/components/ui/input-otp.tsx` |
| DropdownMenu | The Radix dropdown root for action menus; the header's UserDropdown is built from it, and a new menu composes the DropdownMenu* parts rather than a hand-rolled popover. | `app/components/ui/dropdown-menu.tsx` |
| DropdownMenuTrigger | The element that opens the menu; pass asChild so an existing Button or Link becomes the trigger instead of an extra wrapper element. | `app/components/ui/dropdown-menu.tsx` |
| DropdownMenuContent | The menu panel: popover colours, a rounded-md border, a shadow and tw-animate-css enter/exit animation keyed to the side it opens on. | `app/components/ui/dropdown-menu.tsx` |
| DropdownMenuItem | One selectable action row with the accent focus highlight; pass asChild to turn a Link or a submit button into a menu row. | `app/components/ui/dropdown-menu.tsx` |
| DropdownMenuCheckboxItem | A menu row that toggles a boolean with a check indicator; its check icon is drawn with a literal stroke="black", so it stays black on the dark theme. | `app/components/ui/dropdown-menu.tsx` |
| DropdownMenuRadioItem | A menu row representing one mutually exclusive choice inside a DropdownMenuRadioGroup, with an indicator on the selected option. | `app/components/ui/dropdown-menu.tsx` |
| DropdownMenuLabel | A non-interactive heading row inside a menu, for naming a group of items without making the heading selectable. | `app/components/ui/dropdown-menu.tsx` |
| DropdownMenuSeparator | The thin horizontal divider between groups of menu items; use it instead of a bordered div so spacing matches the menu. | `app/components/ui/dropdown-menu.tsx` |
| DropdownMenuShortcut | A right-aligned span for the keyboard-shortcut hint at the end of a menu row, styled so it recedes behind the label. | `app/components/ui/dropdown-menu.tsx` |
| DropdownMenuGroup | Wraps related menu items so assistive technology announces them as a group; pair it with DropdownMenuLabel for a visible heading. | `app/components/ui/dropdown-menu.tsx` |
| DropdownMenuPortal | Renders the menu content into document.body so it escapes the overflow and stacking context of the trigger's container. | `app/components/ui/dropdown-menu.tsx` |
| DropdownMenuSub | The root of a nested submenu inside a menu, pairing a DropdownMenuSubTrigger with its DropdownMenuSubContent. | `app/components/ui/dropdown-menu.tsx` |
| DropdownMenuSubTrigger | The menu row that opens a nested submenu on hover or arrow key, styled like a regular item with an open-state highlight. | `app/components/ui/dropdown-menu.tsx` |
| DropdownMenuSubContent | The panel of a nested submenu, with the same popover colours, border, shadow and animations as DropdownMenuContent. | `app/components/ui/dropdown-menu.tsx` |
| DropdownMenuRadioGroup | Holds the value for a set of DropdownMenuRadioItem rows so exactly one option in the menu is selected at a time. | `app/components/ui/dropdown-menu.tsx` |
| EpicToaster | The app-wide toast outlet built on sonner and styled with bg-background, text-foreground and border-border; rendered once at the root: an action raises a toast with redirectWithToast from app/utils/toast.server.ts, and useToast(data.toast) in app/root.tsx shows it here. | `app/components/ui/sonner.tsx` |
| ErrorList | Renders Conform's error array for a field or a whole form in the destructive text colour, under the id the control's aria-describedby points at; every field wrapper uses it, and forms render it directly for form-level errors. | `app/components/forms.tsx` |
| Field | The default Conform text field — Label, Input wired to aria-invalid and aria-describedby, and ErrorList — taking labelProps, inputProps and errors; use it for every single-line input in a route form. | `app/components/forms.tsx` |
| TextareaField | The multi-line counterpart of Field (Label, Textarea and ErrorList with the same props-and-errors shape), used for long text such as a note's content. | `app/components/forms.tsx` |
| CheckboxField | A Checkbox with its label and errors for Conform booleans, keeping the Radix checkbox's state in sync with the value the form submits; remember-me on login and the terms checkbox in onboarding use it. | `app/components/forms.tsx` |
| OTPField | The Conform wrapper around InputOTP with a label and errors; the /verify page (email codes and the 2FA login check) and /settings/profile/two-factor/verify (confirming a new authenticator app) use it. | `app/components/forms.tsx` |
| Spacer | Adds a named amount of vertical space between page sections (<Spacer size="4xs" /> through "4xl") as an empty block, so pages share one vertical rhythm. | `app/components/spacer.tsx` |
| GeneralErrorBoundary | The shared body of every route ErrorBoundary: routes render it with statusHandlers per HTTP status (the note edit route maps 404 to a not-found message) and it falls back to a generic message for anything else. | `app/components/error-boundary.tsx` |
| floatingToolbarClassName | A class string rather than a component: the pinned bottom action bar shared by the note page and the note editor, so a new screen with Edit, Delete or Save actions applies it instead of recreating the bar. | `app/components/floating-toolbar.tsx` |

## Contributor Guide

### Known risks and sharp edges

#### Anything returned by getEnv() is shipped to every browser (high)

app/root.tsx serialises getEnv() into window.ENV on every page, so adding a secret there to reach it from a component publishes it to every visitor, and source maps make hard-coded secrets public too.

**Mitigation:** Read secrets through process.env in *.server.ts modules only, add new required variables to the zod schema in env.server.ts, and add to getEnv only values that are safe to be public.

Files: `app/utils/env.server.ts`, `app/root.tsx`, `docs/secrets.md`

#### Writes from GET loaders fail on replicas unless they call ensurePrimary (high)

The LiteFS proxy forwards POST-style requests to the primary, but a GET loader that creates or updates rows — like the OAuth callback that links connections and creates sessions — would run on a read-only replica for users routed to another region, a failure that never appears locally.

**Mitigation:** Keep writes in actions, and where a loader must write, call ensurePrimary() from app/utils/litefs.server.ts first, as auth.$provider/callback.ts does.

Files: `app/routes/_auth/auth.$provider/callback.ts`, `app/utils/litefs.server.ts`, `other/litefs.yml`

#### Migrations run while the previous version is still serving (high)

litefs.yml applies `prisma migrate deploy` when the new primary boots and deploys are zero-downtime, so a migration that drops or renames a column breaks instances still running the old code until they are replaced.

**Mitigation:** Follow the widen-then-narrow sequence in docs/database.md: ship code that tolerates both shapes, then the migration, then the cleanup, each as its own deploy.

Files: `prisma/schema.prisma`, `prisma/migrations`, `other/litefs.yml`, `docs/database.md`

#### The Content-Security-Policy is report-only (medium)

app/entry.server.tsx sends the CSP with `reportOnly: true` (ADR 022), so the browser logs violations instead of blocking them; an adopter who assumes the nonce setup protects against injected scripts is running without an enforced policy.

**Mitigation:** Once third-party sources are added to the directives, remove `reportOnly: true` and check pages with the browser console open for violations.

Files: `app/entry.server.tsx`, `docs/decisions/022-report-only-csp.md`

#### Rate-limit tiers are matched by path substring (medium)

Only non-GET paths containing an entry in `strongPaths` get the 10-per-minute limit. /forgot-password, which sends an email per valid request, and the /webauthn endpoints fall into the 100-per-minute tier, and limits use in-memory stores per instance.

**Mitigation:** Update `strongPaths` in server/index.ts in the same change that adds or renames an auth or email-sending route, and verify with `npm run start:mocks`, since limits are multiplied by 10,000 in development.

Files: `server/index.ts`, `docs/decisions/025-rate-limiting.md`

#### The two-factor re-verification window is two minutes, not two hours (low)

`shouldRequestTwoFA` computes `const twoHours = 1000 * 60 * 2` under a comment saying two hours, so users with 2FA are asked for a fresh code on change-email and two-factor/disable far more often than the comment suggests.

**Mitigation:** Decide which window is intended, fix the constant or the comment, and pin it with a unit test beside login.server.ts.

Files: `app/routes/_auth/login.server.ts`, `app/routes/_auth/verify.server.ts`

#### Manifest icons under /favicons are answered with 404 (low)

server/index.ts registers its `/img` and `/favicons` 404 handler before `express.static`, although its comment assumes the opposite order, so the Android icons public/site.webmanifest references are never served.

**Mitigation:** Move that handler below the static middleware in the production branch and request /favicons/android-chrome-192x192.png against `npm run start:mocks`.

Files: `server/index.ts`, `public/site.webmanifest`, `public/favicons/README.md`

### Where should this kind of change go?

#### Add a page or resource route

Start in `app/routes`.

Related: `app/routes.ts`, `docs/routing.md`, `app/root.tsx`

The route manifest is generated from file and folder names, so a file's location is its URL; the route module owns its data and guards, and UI inherits the shell and user from root.tsx.

Verify:

- npx react-router routes
- npm run typecheck
- npm run test:e2e:dev

#### Change the database schema

Start in `prisma/schema.prisma`.

Related: `prisma/migrations`, `prisma/seed.ts`, `tests/db-utils.ts`, `docs/database.md`

Prisma generates both the client and the migration from the schema, the seed and test factories construct rows directly, and production applies migrations at boot on the LiteFS primary.

Verify:

- npx prisma migrate dev --name <change>
- npm run typecheck
- npm run test -- --run
- npm run test:e2e:run

#### Add or restyle a UI primitive

Start in `app/components/ui`.

Related: `app/styles/tailwind.css`, `components.json`, `app/components/forms.tsx`

Primitives are shadcn/ui sources owned by the repository and styled with semantic Tailwind tokens, so a new one arrives through the CLI into this folder and takes its colours from tailwind.css.

Verify:

- npx shadcn@latest add <component>
- npm run lint
- npm run typecheck
- Cycle system, light and dark with the footer theme switch

#### Call a new third-party API

Start in `app/utils`.

Related: `tests/mocks/index.ts`, `.env.example`, `app/utils/env.server.ts`, `docs/secrets.md`

Offline development is a guiding principle: every outbound service has an MSW handler, a fake value in .env.example and, if required at boot, an entry in the env.server.ts schema.

Verify:

- npm run dev and confirm MSW prints no unhandled-request warning
- npm run test -- --run

#### Add an icon

Start in `other/svg-icons`.

Related: `other/sly/sly.json`, `app/components/ui/icon.tsx`, `vite.config.ts`

The Vite spritesheet plugin compiles this folder into app/components/ui/icons/sprite.svg with generated types, so Icon only accepts names of files that exist here.

Verify:

- npx sly add @radix-ui/icons <icon-name>
- npm run typecheck

#### Protect a route by role or permission

Start in `app/utils/permissions.server.ts`.

Related: `app/utils/user.ts`, `prisma/migrations/20250221233640_init/migration.sql`

Server checks and client affordances use the same permission strings, and a new permission only exists in every environment if a migration inserts it and assigns it to roles.

Verify:

- npm run test:e2e:run

## Guided Tour

### 1. Boot order and the Express layer

Files: [`index.ts` (L1–L23)](https://github.com/epicweb-dev/epic-stack/blob/8473afd804b66dba6a23f317908dc35d1535e90d/index.ts#L1-L23), [`server/index.ts` (L28–L195)](https://github.com/epicweb-dev/epic-stack/blob/8473afd804b66dba6a23f317908dc35d1535e90d/server/index.ts#L28-L195)

**Why:** Everything later in the tour assumes this boot order: index.ts decides whether MSW intercepts outbound calls before any app module loads, and server/index.ts decides what happens to a request before React Router ever sees it. A 429, a surprise redirect or a missing header is explained here, not in a route.

**Notice:** MOCKS=true comes from the dev script, not NODE_ENV. The strongest rate limit is chosen with `req.path.includes(...)` against a hard-coded list that omits /forgot-password and /webauthn but still names /resources/login and /resources/verify, which no longer exist, and every limit is multiplied by 10,000 outside production.

### 2. How files become URLs

Files: [`app/routes.ts` (L1–L18)](https://github.com/epicweb-dev/epic-stack/blob/8473afd804b66dba6a23f317908dc35d1535e90d/app/routes.ts#L1-L18), [`docs/routing.md` (L89–L202)](https://github.com/epicweb-dev/epic-stack/blob/8473afd804b66dba6a23f317908dc35d1535e90d/docs/routing.md#L89-L202)

**Why:** The route tree is generated rather than declared, so the naming rules are the API for adding a page. The generated listing in docs/routing.md is the quickest way to map a URL in the browser back to the file that serves it, which every later step relies on.

**Notice:** `_auth` adds no URL segment, `$noteId_.edit.tsx` escapes nesting so the editor does not render inside the note view, `+shared` holds colocated modules that are not routes, and *.server.* files are ignored. `npx react-router routes` prints the manifest for the current tree.

### 3. The root loader: what every page can rely on

Files: [`app/root.tsx` (L71–L133)](https://github.com/epicweb-dev/epic-stack/blob/8473afd804b66dba6a23f317908dc35d1535e90d/app/root.tsx#L71-L133), [`app/utils/user.ts` (L10–L26)](https://github.com/epicweb-dev/epic-stack/blob/8473afd804b66dba6a23f317908dc35d1535e90d/app/utils/user.ts#L10-L26)

**Why:** Every route renders under root.tsx, and its loader is the only place the signed-in user, theme preference, flash toast and public ENV are loaded. Knowing this stops you from re-querying the user in each route just to show a name or hide a button.

**Notice:** The user is selected together with roles and permissions, which is what lets `userHasPermission` decide UI affordances in the browser without another request, while the server re-checks with requireUserWithPermission. A session whose user no longer exists is logged out right here.

### 4. The guard vocabulary

Files: [`app/utils/auth.server.ts` (L29–L74)](https://github.com/epicweb-dev/epic-stack/blob/8473afd804b66dba6a23f317908dc35d1535e90d/app/utils/auth.server.ts#L29-L74), [`app/utils/permissions.server.ts` (L6–L60)](https://github.com/epicweb-dev/epic-stack/blob/8473afd804b66dba6a23f317908dc35d1535e90d/app/utils/permissions.server.ts#L6-L60), [`app/utils/session.server.ts` (L1–L12)](https://github.com/epicweb-dev/epic-stack/blob/8473afd804b66dba6a23f317908dc35d1535e90d/app/utils/session.server.ts#L1-L12)

**Why:** Authorisation is a function call at the top of each loader and action rather than middleware, so these few functions are what you copy into any new route. Their redirects and 403 responses explain the behaviour you will see in the browser when a guard fails.

**Notice:** `getUserId` validates the Session row on every request and clears a stale cookie, `requireUserId` preserves the current URL as redirectTo, and a permission string such as `delete:note:own` is split into action, entity and access before a single Prisma query checks it against the user's roles.

### 5. One complete feature: notes

Files: [`app/routes/users/$username/notes/_layout.tsx` (L11–L26)](https://github.com/epicweb-dev/epic-stack/blob/8473afd804b66dba6a23f317908dc35d1535e90d/app/routes/users/$username/notes/_layout.tsx#L11-L26), [`app/routes/users/$username/notes/$noteId.tsx` (L56–L90)](https://github.com/epicweb-dev/epic-stack/blob/8473afd804b66dba6a23f317908dc35d1535e90d/app/routes/users/$username/notes/$noteId.tsx#L56-L90), [`app/routes/users/$username/notes/+shared/note-editor.server.tsx` (L27–L131)](https://github.com/epicweb-dev/epic-stack/blob/8473afd804b66dba6a23f317908dc35d1535e90d/app/routes/users/$username/notes/+shared/note-editor.server.tsx#L27-L131), [`app/routes/users/$username/notes/+shared/note-editor.tsx` (L46–L75)](https://github.com/epicweb-dev/epic-stack/blob/8473afd804b66dba6a23f317908dc35d1535e90d/app/routes/users/$username/notes/+shared/note-editor.tsx#L46-L75)

**Why:** With the boot path and guards in hand, the notes routes show every pattern together — public loaders, owner-scoped writes, permission checks, file uploads and Conform forms — in the smallest real feature. It is the template most new features in an Epic Stack app should copy.

**Notice:** One editor action serves both /new and /:noteId/edit through `export { action }`. Ownership is enforced inside the schema's superRefine, deletion checks `delete:note:own` for owners and `delete:note:any` for everyone else, and images are uploaded to Tigris inside the Zod transform before the upsert runs.

### 6. Proving an email: prepareVerification and /verify

Files: [`app/routes/_auth/signup.tsx` (L37–L91)](https://github.com/epicweb-dev/epic-stack/blob/8473afd804b66dba6a23f317908dc35d1535e90d/app/routes/_auth/signup.tsx#L37-L91), [`app/routes/_auth/verify.server.ts` (L76–L200)](https://github.com/epicweb-dev/epic-stack/blob/8473afd804b66dba6a23f317908dc35d1535e90d/app/routes/_auth/verify.server.ts#L76-L200), [`app/routes/_auth/onboarding/index.tsx` (L44–L59)](https://github.com/epicweb-dev/epic-stack/blob/8473afd804b66dba6a23f317908dc35d1535e90d/app/routes/_auth/onboarding/index.tsx#L44-L59)

**Why:** Signup, password reset, email change and two-factor login all reuse this one mechanism. Understanding it once makes four flows readable, and a change to any of them usually has to start in verify.server.ts rather than in the page you are looking at.

**Notice:** The code is a TOTP derived from a Verification row keyed by target and type, validateRequest dispatches on `type` to a handler colocated with each feature, and the proof survives between requests only in the ten-minute en_verification cookie that onboarding/index.tsx requires before anyone can create an account.

### 7. Where the data lives once deployed

Files: [`prisma/schema.prisma` (L14–L49)](https://github.com/epicweb-dev/epic-stack/blob/8473afd804b66dba6a23f317908dc35d1535e90d/prisma/schema.prisma#L14-L49), [`other/litefs.yml` (L21–L46)](https://github.com/epicweb-dev/epic-stack/blob/8473afd804b66dba6a23f317908dc35d1535e90d/other/litefs.yml#L21-L46), [`app/utils/cache.server.ts` (L158–L198)](https://github.com/epicweb-dev/epic-stack/blob/8473afd804b66dba6a23f317908dc35d1535e90d/app/utils/cache.server.ts#L158-L198), [`app/routes/admin/cache/sqlite.server.ts` (L10–L59)](https://github.com/epicweb-dev/epic-stack/blob/8473afd804b66dba6a23f317908dc35d1535e90d/app/routes/admin/cache/sqlite.server.ts#L10-L59)

**Why:** Local development uses a single SQLite file, so the replica behaviour only appears after deploy. Reading the LiteFS config beside the cache's write path is the only way to see the rule that writes must reach the primary before that rule causes a production-only bug.

**Notice:** Migrations run from litefs.yml on the primary candidate when a container starts, not in CI. A replica forwards cache writes to the primary over HTTP with INTERNAL_COMMAND_TOKEN, and the roles a new database needs come from the migration SQL rather than seed.ts.

### 8. How the tests stand the app up

Files: [`tests/setup/global-setup.ts` (L12–L39)](https://github.com/epicweb-dev/epic-stack/blob/8473afd804b66dba6a23f317908dc35d1535e90d/tests/setup/global-setup.ts#L12-L39), [`tests/setup/db-setup.ts` (L1–L31)](https://github.com/epicweb-dev/epic-stack/blob/8473afd804b66dba6a23f317908dc35d1535e90d/tests/setup/db-setup.ts#L1-L31), [`tests/playwright-utils.ts` (L91–L119)](https://github.com/epicweb-dev/epic-stack/blob/8473afd804b66dba6a23f317908dc35d1535e90d/tests/playwright-utils.ts#L91-L119), [`tests/e2e/notes.test.ts` (L5–L45)](https://github.com/epicweb-dev/epic-stack/blob/8473afd804b66dba6a23f317908dc35d1535e90d/tests/e2e/notes.test.ts#L5-L45)

**Why:** The harness reuses everything the tour has covered — the Prisma client, the session storage and the mocks — so it comes last. Knowing its shortcuts is what lets you write a test for your first change instead of verifying it by clicking through the app.

**Notice:** Vitest workers each copy a pre-migrated base.db that is rebuilt only when schema.prisma is newer, and Playwright's `login` fixture writes a Session row and cookie directly, navigates with React Router's typed `href()`, and deletes the user when the test ends.

## Hotspots

| File | Commits | Churn | Activity | Insight |
| --- | --- | --- | --- | --- |
| `package.json` | 144 | — | moderate | Touched by nearly half of the 300 analysed commits, mostly dependency upgrades such as the June 2026 React Router CVE bump and the January and February 2026 dependency sweeps. Keeping an adopted app current with the template is mostly replaying these bumps. |
| `server/index.ts` | 21 | — | dormant | Became the single server entry in December 2025 (#1066) and was fixed in January 2026 for middleware ordering and loading mocks in dev. Quiet now, but it is where ordering bugs between Express middleware, Vite and the MSW import have appeared before. |
| `vite.config.ts` | 17 | — | active | The one active hotspot: the August 2026 change (#1095) stopped Sentry from injecting duplicate debug IDs that broke client source maps, after earlier additions of React Router devtools, the icon spritesheet plugin and the Vitest cache-server stub from ADR 047. Build tooling, not app code, is where the template still moves. |
| `.github/workflows/deploy.yml` | 15 | — | dormant | Settled since May 2025, after changes that build the container in parallel with lint and tests (#996) and upload source maps during the build (#1012). A stable workflow means the gates a contributor must pass rarely change under them. |
| `server/utils/monitoring.ts` | 13 | — | moderate | Its July 2026 change (#1093) filters Fly health checks and bot traffic out of Sentry, together with app/utils/sentry-event-filters.ts and entry.server.tsx. Check those three files before adding Sentry filtering of your own. |
| `app/utils/cache.server.ts` | 10 | — | dormant | History includes a September 2025 commit titled 'restore multi-region-safe cache': the replica-to-primary write forwarding was lost and put back. It is the clearest evidence that the LiteFS write rule is easy to break when editing this file. |
| `app/utils/auth.server.ts` | 10 | — | dormant | Its changes landed in early 2025 — the remix-auth GitHub upgrade, moving images to Tigris and the Have I Been Pwned password check — and nothing since March 2025, so it is a dependable reference for the session model rather than code in flux. |
| `app/entry.server.tsx` | 9 | — | moderate | Last changed in July 2026 to stop reporting React Router's expected missing-action and missing-loader errors from bots. It owns the nonce, the streaming strategy and the CSP, which still ships in report-only mode. |

Churn says the application code is settled and the edges are what move. The busiest file by far is package.json (dependency upgrades); the files changed most recently are build and observability plumbing — vite.config.ts, server/utils/monitoring.ts and app/entry.server.tsx — plus docs/examples.md. Core modules such as auth.server.ts and the route files have been quiet for a year or more. For a newcomer that means the domain code is a dependable pattern library to copy, while dependency versions and Sentry configuration are where an adopted app is most likely to conflict when pulling template updates.

## Setup

### Prerequisites

- Node.js ^22.18.0 (package.json engines; CI uses Node 22)
- npm, since the repository commits package-lock.json
- git
- Chromium for Playwright, installed by `npm run setup` or `npm run test:e2e:install`

### Setup

**Create the environment file**

Every value in .env.example is fake. GitHub, Tigris, Resend and the password-breach API are intercepted by MSW in development and tests, so no third-party account is needed to start.

```sh
cp .env.example .env
```

**Install dependencies**

```sh
npm install
```

**Build, migrate and generate the typed SQL client**

Runs `npm run build`, `prisma migrate deploy` (creating prisma/data.db and inserting the admin and user roles), `prisma generate --sql` for the searchUsers query, and `playwright install`.

```sh
npm run setup
```

**Seed demo data**

Creates five fake users with notes and the admin user `kody` (password `kodylovesyou`). `npx prisma migrate reset --force` drops the database and reseeds from scratch.

```sh
npx prisma@6 db seed
```

### Run

**Start the dev server with mocks**

Serves http://localhost:3000, or the next free port, with Vite and MSW intercepting outbound calls. Log in as kody after seeding.

```sh
npm run dev
```

**Run without request mocking**

Put real credentials in .env first. GitHub login keeps using the built-in mock flow for as long as GITHUB_CLIENT_ID starts with MOCK_.

```sh
npm run dev:no-mocks
```

**Run the production build locally**

The same server CI's Playwright job starts. Production mode applies the real rate limits; `npm start` runs without mocks and needs real service credentials.

```sh
npm run build
npm run start:mocks
```

### Test

**Unit and component tests (Vitest)**

Tests live next to the code as app/**/*.test.ts(x). The first run builds tests/prisma/base.db, and any console.error or console.warn fails a test unless it is explicitly mocked.

```sh
npm test
npm run test -- --run
npm run coverage
```

**End-to-end tests (Playwright)**

`test:e2e:dev` opens the Playwright UI against `npm run dev`; `test:e2e:run` builds first and runs headless with CI=true, as the pipeline does.

```sh
npm run test:e2e:install
npm run test:e2e:dev
npm run test:e2e:run
```

**Everything the pipeline checks**

`typecheck` runs `react-router typegen` before tsc so route `+types` imports resolve; `validate` runs unit tests, lint, typecheck and the e2e suite in parallel.

```sh
npm run lint
npm run typecheck
npm run validate
```

## Delivery

Everything runs from .github/workflows/deploy.yml (🚀 Deploy), triggered by every pull request and every push to main or dev, with an in-progress run for the same ref cancelled. Four check jobs start in parallel — ⬣ ESLint, ʦ TypeScript, ⚡ Vitest and 🎭 Playwright — each copying .env.example to .env and running prisma migrate deploy plus prisma generate --sql against a throwaway SQLite file first; npm run validate runs local equivalents of all four (build first, because the Playwright run serves the built app). On a pull request the run ends there. On a push, 📦 Prepare Container runs alongside the checks and builds other/Dockerfile remotely with flyctl deploy --build-only --push, labelling the image with the commit SHA and pushing it to Fly's registry for the <app>-staging app when the branch is dev, or for the app named in fly.toml when it is main (the production build also mounts SENTRY_AUTH_TOKEN as a build secret). The 🚀 Deploy job needs all five jobs, then runs flyctl deploy --image with that exact SHA-tagged image, so what ships is the image built from the commit that passed the checks. On each Fly machine the container starts litefs mount: machines in PRIMARY_REGION, the lease candidates, run prisma migrate deploy and switch both SQLite databases to WAL, every machine regenerates TypedSQL, and then npm start boots Express on 8081 behind the LiteFS proxy on 8080, while fly.toml's service checks poll /resources/healthcheck and /litefs/health.

_Everything here is read from a committed file. Production dashboards, log access, rollback procedure and on-call are not in this repository, so they are not here — a boundary rather than a gap._

### Build

A Docker image built from other/Dockerfile and tagged with the commit SHA in registry.fly.io: Node 22 with production node_modules, the generated Prisma client, the React Router build/ output, prisma/ with its migrations, a per-image INTERNAL_COMMAND_TOKEN written to .env, and the LiteFS binary with other/litefs.yml, started by litefs mount.

Defined in `other/Dockerfile`, produced by `flyctl deploy --build-only --push --image-label <commit-sha> --build-arg COMMIT_SHA=<commit-sha>`.

### Gates

Exhaustive — a check that is not here does not run.

| Gate | Checks | Run locally | File |
| --- | --- | --- | --- |
| ⬣ ESLint | Runs eslint . over the whole repository, after copying .env.example and generating the Prisma client with TypedSQL, and fails on any lint error. | `npm run lint` | `.github/workflows/deploy.yml` |
| ʦ TypeScript — 🏗 Build | Runs react-router build as the job's first real step, so a change that type-checks but cannot bundle — a broken import, a Vite plugin error, a server-only module pulled into the client — fails here. | `npm run build` | `.github/workflows/deploy.yml` |
| ʦ TypeScript — 🔎 Type check | Runs npm run typecheck — react-router typegen to regenerate the +types route modules, then tsc — against the TypedSQL-generated client, failing on any type error, including loader or action data drifting from what a route component reads. | `npm run typecheck` | `.github/workflows/deploy.yml` |
| ⚡ Vitest | Runs npm run test -- --coverage over app/**/*.test.{ts,tsx} with tests/setup/setup-test-env.ts loaded, where any console.error or console.warn during a test throws, so a noisy test fails as surely as a broken assertion. | `npm run test -- --run` | `.github/workflows/deploy.yml` |
| 🎭 Playwright | Installs Chromium, restores prisma/data.db from a cache keyed on schema.prisma and the migration files (seeding with prisma migrate reset --force on a miss), builds, then runs npx playwright test against npm run start:mocks with MSW mocks, 2 retries and forbidOnly so a committed test.only fails; the HTML report is uploaded as the playwright-report artifact. | `npm run build && npm run test:e2e:run` | `.github/workflows/deploy.yml` |

### Environments

- **production** — deployed from push to main (the app named in fly.toml), per `.github/workflows/deploy.yml`
- **staging** — deployed from push to dev (the <app>-staging Fly app), per `.github/workflows/deploy.yml`

### Migrations

Migrations live in `prisma/migrations`.

LiteFS applies them at container start, before the app boots: the exec list in other/litefs.yml runs npx prisma migrate deploy with if-candidate: true, so only machines in PRIMARY_REGION that can hold the Consul lease migrate, and npm start runs only after the exec steps before it finish. The prisma migrate deploy steps in CI only build throwaway test databases and never touch a deployed one.

Applied with `npx prisma migrate deploy`.

### Deploy variables

Exhaustive — names and purposes only, never values.

| Variable | Needed for | Declared in |
| --- | --- | --- |
| `FLY_API_TOKEN` | GitHub Actions secret that lets flyctl build, push and deploy the image; without it the 📦 Prepare Container and 🚀 Deploy jobs cannot authenticate with Fly. | `.github/workflows/deploy.yml` |
| `SESSION_SECRET` | Signs the app's session cookies (a comma-separated list allows rotation); init() in env.server.ts throws at boot without it, and docs/deployment.md sets it as a Fly secret for each app. | `app/utils/env.server.ts` |
| `HONEYPOT_SECRET` | Seeds the encryption of the honeypot timestamp field that rejects bot form submissions; required by the env schema, so the server refuses to boot without it. | `app/utils/env.server.ts` |
| `AWS_ACCESS_KEY_ID` | Access key for the hand-signed S3 requests that store and serve note and profile images in Tigris; created by fly storage create and required at boot. | `app/utils/env.server.ts` |
| `AWS_SECRET_ACCESS_KEY` | Secret key that signs every SigV4 request to Tigris; created by fly storage create and required by the env schema at boot. | `app/utils/env.server.ts` |
| `AWS_REGION` | Region component of the SigV4 signature on Tigris requests; created by fly storage create and required at boot. | `app/utils/env.server.ts` |
| `AWS_ENDPOINT_URL_S3` | The Tigris S3 endpoint image uploads and reads are sent to; it must parse as a URL or the env schema fails at boot. | `app/utils/env.server.ts` |
| `BUCKET_NAME` | The Tigris bucket holding uploaded note and user images; created by fly storage create and required at boot. | `app/utils/env.server.ts` |
| `INTERNAL_COMMAND_TOKEN` | Shared secret a replica sends when it forwards a cache write to the primary (app/routes/admin/cache/sqlite.server.ts); generated with openssl into .env at image build, so every machine running one image agrees on it and nobody sets it by hand. | `other/Dockerfile` |
| `DATABASE_URL` | The Prisma connection string, pointing at sqlite.db inside the LiteFS mount so every write goes through LiteFS replication; required by the env schema. | `other/Dockerfile` |
| `DATABASE_PATH` | Filesystem path of the main SQLite database inside the LiteFS mount; litefs.yml switches it to WAL on lease candidates and env.server.ts requires it. | `other/Dockerfile` |
| `DATABASE_FILENAME` | Name of the main database file that the LiteFS proxy in other/litefs.yml tracks for write forwarding and that litefs-js reads transaction numbers for. | `other/Dockerfile` |
| `CACHE_DATABASE_PATH` | Path of the SQLite cache database opened by app/utils/cache.server.ts, kept inside the LiteFS mount so cache entries replicate; litefs.yml switches it to WAL. | `other/Dockerfile` |
| `CACHE_DATABASE_FILENAME` | File name the Dockerfile composes CACHE_DATABASE_PATH from, so the cache database sits beside sqlite.db under LITEFS_DIR. | `other/Dockerfile` |
| `LITEFS_DIR` | The LiteFS FUSE mount directory (fuse.dir in litefs.yml) that both database paths live under, and where litefs-js reads the .primary file to tell whether this machine is the primary. | `other/Dockerfile` |
| `INTERNAL_PORT` | Port the LiteFS proxy listens on (proxy.addr in litefs.yml), which must match internal_port 8080 in fly.toml, and the port litefs-js addresses sibling instances on. | `other/Dockerfile` |
| `PORT` | Port server/index.ts listens on (8081 in the image, 3000 when unset) and the target the LiteFS proxy forwards requests to. | `other/Dockerfile` |
| `NODE_ENV` | Must be production in the image: env.server.ts validates it, and server/index.ts uses it to serve the built app instead of Vite and to enforce the real rate limits. | `other/Dockerfile` |
| `PRISMA_SCHEMA_DISABLE_ADVISORY_LOCK` | Stops prisma migrate deploy from taking an advisory lock, which the Dockerfile's comment ties to running migrations against the SQLite databases in WAL mode. | `other/Dockerfile` |
| `FLY_CONSUL_URL` | The Consul endpoint LiteFS uses for its primary lease; it exists once fly consul attach (docs/deployment.md step 7) has run, and without it no machine can become the primary that accepts writes. | `other/litefs.yml` |
| `PRIMARY_REGION` | Region whose machines are lease candidates (candidate: FLY_REGION == PRIMARY_REGION), set from primary_region in fly.toml; it decides which machines run migrations and accept writes. | `other/litefs.yml` |
| `FLY_REGION` | Region of the running machine, provided by the Fly runtime; compared with PRIMARY_REGION for lease candidacy and echoed in the fly-region response header by app/entry.server.tsx. | `other/litefs.yml` |
| `FLY_APP_NAME` | App name provided by the Fly runtime; it builds the LiteFS advertise URL and Consul key, and litefs-js uses it to discover and address sibling instances over .internal DNS. | `other/litefs.yml` |
| `HOSTNAME` | Machine hostname provided by the runtime, used in the advertise URL other LiteFS nodes replicate from. | `other/litefs.yml` |

## Learn

### TypeScript

Every module in app/, server/ and tests/ is TypeScript (ADR 001 makes the stack TypeScript-only), and imports resolve through the package.json `#app/*` and `#tests/*` subpath map rather than tsconfig paths.

**Start here:** <https://www.typescriptlang.org/docs/>

- [Modules - Reference](https://www.typescriptlang.org/docs/handbook/modules/reference.html) (reference) — Imports here go through the package.json `imports` map (`#app/*`, `#tests/*`) since ADR 046 removed tsconfig paths; this page explains how TypeScript resolves those `#` specifiers.

**In this repo:** Route modules import generated `./+types/<route>.ts` types for loader and action arguments, which only exist after `react-router typegen` — the reason `npm run typecheck` runs typegen before tsc and a fresh clone shows type errors until it does.

Files: `package.json`, `app/routes/users/$username/notes/$noteId.tsx`, `docs/decisions/046-remove-path-aliases.md`

### React

Renders every route module on the server with renderToPipeableStream in app/entry.server.tsx and hydrates in the browser; UI state is kept in forms, fetchers and the URL rather than client-side stores.

**Start here:** <https://react.dev/>

- [You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect) (guide) — The repo ships a Cursor rule, .cursor/rules/avoid-use-effect.mdc, that links this page and asks for event handlers, ref callbacks and derived data instead of useEffect in new components.

- [renderToPipeableStream](https://react.dev/reference/react-dom/server/renderToPipeableStream) (reference) — app/entry.server.tsx renders every document with it and switches between onShellReady and onAllReady depending on isbot; this reference explains those callbacks and abort.

**In this repo:** Server data reaches components through loader data and route props, and pending or optimistic UI is read from navigation and fetchers — the theme toggle is an optimistic fetcher, not client state.

Files: `app/routes/resources/theme-switch.tsx`, `app/entry.server.tsx`, `.cursor/rules/avoid-use-effect.mdc`

### React Router

Framework mode v7: route modules export loader, action, meta, handle and ErrorBoundary; `react-router build` produces build/server and build/client; react-router-auto-routes turns the app/routes folder tree into the route manifest.

**Start here:** <https://reactrouter.com/home>

- [Route Module](https://reactrouter.com/start/framework/route-module) (reference) — Every file in app/routes exports some of loader, action, meta, handle, headers and ErrorBoundary; this is the list of those exports and when each one runs.

- [Actions](https://reactrouter.com/start/framework/actions) (guide) — All mutations here are actions reached by Form or fetcher submissions, several switching on an `intent` field; this page explains how an action's return value and revalidation work.

**In this repo:** Routes come from folder names through react-router-auto-routes rather than a hand-written config, guards throw `redirect()` or `data(..., { status })`, and `export { action } from './+shared/note-editor.server.tsx'` shares one action between two URLs.

Files: `app/routes.ts`, `app/routes/users/$username/notes/new.tsx`, `react-router.config.ts`

### Express

The HTTP server in server/index.ts: HTTPS and trailing-slash redirects, compression, security headers, three rate-limit tiers and static assets, before handing every request to @react-router/express.

**Start here:** <https://expressjs.com/>

- [Express behind proxies](https://expressjs.com/en/guide/behind-proxies.html) (guide) — server/index.ts sets `trust proxy` because Fly terminates TLS in front of it, and keys rate limits on Fly-Client-Ip instead of req.ip; this page explains what trusting the proxy changes.

- [Production Best Practices: Security](https://expressjs.com/en/advanced/best-practice-security.html) (guide) — server/index.ts links this page when it disables x-powered-by, and its helmet headers, HTTPS redirect and rate limiting follow the same checklist.

**In this repo:** Express is only the outer shell: middleware order in server/index.ts decides redirects, headers and rate limits, and the final handler passes every remaining request to React Router's createRequestHandler in server/app.ts.

Files: `server/index.ts`, `server/app.ts`

### Prisma

ORM for the SQLite application database: schema in prisma/schema.prisma, migrations in prisma/migrations (the initial one also inserts the admin and user roles), and TypedSQL for the raw user-search query in prisma/sql.

**Start here:** <https://www.prisma.io/docs>

- [TypedSQL](https://www.prisma.io/docs/orm/prisma-client/using-raw-sql/typedsql) (guide) — User search is raw SQL in prisma/sql/searchUsers.sql, compiled into a typed function by `prisma generate --sql` — the step setup, CI and litefs.yml all run.

- [Applying a migration](https://www.prisma.io/docs/orm/prisma-migrate/workflows/development-and-production) (guide) — Schema changes are authored with `prisma migrate dev`, while CI and the LiteFS boot sequence run `prisma migrate deploy`; this explains why deploy never generates or resets and is safe on the primary.

- [Prisma Client API](https://www.prisma.io/docs/orm/reference/prisma-client-reference) (reference) — auth.server.ts relies on PrismaPromise laziness — the `.catch()` is what triggers the session delete on logout — and links this reference for that behaviour.

**In this repo:** A single PrismaClient survives dev reloads through `remember('prisma', ...)` and logs queries slower than 20 ms, and queries select explicit fields by convention, with download-user-data.tsx as the commented exception that uses `include`.

Files: `app/utils/db.server.ts`, `prisma/schema.prisma`, `app/routes/resources/download-user-data.tsx`

### SQLite

Both datastores: Prisma's application database and a separate cache database that app/utils/cache.server.ts opens directly with node:sqlite; other/litefs.yml puts both in WAL mode when a deployed primary boots.

**Start here:** <https://www.sqlite.org/docs.html>

- [Write-Ahead Logging](https://www.sqlite.org/wal.html) (guide) — other/litefs.yml switches both the application and cache databases to WAL when the primary boots, to reduce locking between concurrent readers and the writer; this explains what WAL changes.

**In this repo:** There are two SQLite files: Prisma's application database and a cache database that cache.server.ts opens directly with Node's built-in node:sqlite, creating its single `cache` table only on the primary.

Files: `app/utils/cache.server.ts`, `other/litefs.yml`

### LiteFS

Replicates the SQLite files across Fly machines: other/litefs.yml elects a primary through Consul, runs migrations only on the primary candidate, and fronts the app with a proxy that forwards write requests to the primary.

**Start here:** <https://fly.io/docs/litefs/>

- [LiteFS Config Reference](https://fly.io/docs/litefs/config/) (reference) — other/litefs.yml uses the fuse, data, proxy, Consul lease and exec sections; this defines each key, including `if-candidate` on the migration command.

- [Built-in HTTP Proxy](https://fly.io/docs/litefs/proxy/) (reference) — The proxy on port 8080 is why POST requests on a replica still write successfully: it forwards write requests to the primary with fly-replay, which GET loaders do not get.

- [Determining the LiteFS primary](https://fly.io/docs/litefs/primary/) (guide) — Explains how an instance knows whether it is the primary — the same question litefs-js answers for cache.server.ts and ensurePrimary before a write.

**In this repo:** The app talks to LiteFS through litefs-js: entry.server.tsx reports primary and current instance in response headers, the OAuth callback calls ensurePrimary() before writing, and replica cache writes are forwarded to the primary over HTTP.

Files: `other/litefs.yml`, `app/utils/litefs.server.ts`, `app/routes/admin/cache/sqlite.server.ts`

### Fly.io

Deploy target: fly.toml defines the volume, service ports and health checks, and the GitHub workflow builds the image with flyctl and deploys `main` to the app and `dev` to its `-staging` twin.

**Start here:** <https://fly.io/docs/>

- [App configuration (fly.toml)](https://fly.io/docs/reference/configuration/) (reference) — fly.toml defines the volume mount, service ports, concurrency limits and the http_checks against /resources/healthcheck and /litefs/health; healthcheck.tsx links this page.

**In this repo:** Each project has two Fly apps — the name in fly.toml and the same name with -staging — which the workflow targets by branch, and remix.init/index.mjs can create both along with volumes, Consul and Tigris storage.

Files: `fly.toml`, `.github/workflows/deploy.yml`, `remix.init/index.mjs`

### Tailwind CSS

Version 4, configured entirely in app/styles/tailwind.css: semantic colour custom properties with light and `.dark` values, a named type scale and animations declared with @theme, and a class-based dark variant.

**Start here:** <https://tailwindcss.com/docs>

- [Theme variables](https://tailwindcss.com/docs/theme) (reference) — There is no tailwind.config file: the type scale, radii and animations are @theme variables in app/styles/tailwind.css and colours are mapped with @theme inline, all explained here.

- [Dark mode](https://tailwindcss.com/docs/dark-mode) (guide) — Themes switch through a `.dark` class on <html>, chosen from the en_theme cookie or client hints and declared with `@custom-variant dark`; this page covers that class-based setup.

**In this repo:** Colours are semantic pairs such as `bg-muted text-muted-foreground` whose values flip under `.dark`; the only Tailwind palette colours in app/ are the violet logo tiles on the marketing page, and class lists are merged with `cn()` so a className prop can override a primitive.

Files: `app/styles/tailwind.css`, `app/utils/misc.tsx`, `app/components/ui/button.tsx`

### shadcn/ui

The source registry behind app/components/ui: components.json points its CLI at the Tailwind file and `#app/components/ui`, and downloaded components are edited in place rather than updated as a dependency.

**Start here:** <https://ui.shadcn.com/docs>

- [Theming](https://ui.shadcn.com/docs/theming) (guide) — The background and foreground token pairs in tailwind.css follow shadcn's CSS-variable convention, so this page tells you which variables a newly added component will expect.

- [shadcn](https://ui.shadcn.com/docs/cli) (reference) — app/components/ui/README.md points to the CLI, and components.json already maps its output to `#app/components/ui` and `cn` to `#app/utils/misc`.

**In this repo:** Downloaded components are edited in place and updated by hand (ADR 019), so they drift from upstream; StatusButton and the Conform-bound fields in forms.tsx are the Epic Stack's own additions on top of them.

Files: `components.json`, `app/components/ui/README.md`, `docs/decisions/019-components.md`

### Radix UI

Headless behaviour and accessibility under the checkbox, dropdown menu, label and tooltip primitives, plus the Slot that gives Button its `asChild` prop; only files in app/components/ui import it.

**Start here:** <https://www.radix-ui.com/primitives/docs/overview/introduction>

- [Composition](https://www.radix-ui.com/primitives/docs/guides/composition) (guide) — Button, DropdownMenuTrigger and DropdownMenuItem are used with `asChild` throughout — a Link rendered as a button, a submit button rendered as a menu item — which is the pattern this guide covers.

**In this repo:** Radix supplies behaviour only: each part is re-exported from app/components/ui with Tailwind classes and a `data-slot` attribute, and only files in that folder import @radix-ui packages.

Files: `app/components/ui/dropdown-menu.tsx`, `app/components/user-dropdown.tsx`, `app/components/ui/button.tsx`

### Conform

Progressively enhanced forms: every action validates FormData with parseWithZod, and useForm wires field props and the action's `submission.reply()` errors back into the Field components.

**Start here:** <https://conform.guide/>

- [parseWithZod](https://conform.guide/api/zod/parseWithZod) (reference) — Every action parses FormData with parseWithZod, usually async with superRefine for database checks and transform for uploads or login; this covers the async and intent options they rely on.

- [useForm](https://conform.guide/api/react/useForm) (reference) — Forms pass the action's `submission.reply()` back as `lastResult` and spread `getInputProps` into Field; useForm's options explain constraint, onValidate and shouldRevalidate.

**In this repo:** Schemas are shared by client and server — note-editor.tsx exports NoteEditorSchema for useForm's onValidate and the action's parseWithZod — and errors render without JavaScript because actions return `submission.reply()`.

Files: `app/routes/users/$username/notes/+shared/note-editor.tsx`, `app/routes/users/$username/notes/+shared/note-editor.server.tsx`, `app/components/forms.tsx`

### Vite

Builds the app through the React Router plugin, runs as Express middleware in development, generates the SVG icon sprite, and loads the Sentry plugin only for production builds that have an auth token.

**Start here:** <https://vite.dev/guide/>

- [Server-Side Rendering (SSR)](https://vite.dev/guide/ssr) (guide) — server/index.ts runs Vite in middlewareMode with appType 'custom' and loads server/app.ts through ssrLoadModule on each request, the setup this guide describes.

- [Configuring Vite](https://vite.dev/config/) (reference) — vite.config.ts sets an es2022 target, hidden source maps, an asset inlining exception for favicons, the SSR input and the Vitest block; this reference explains each option.

**In this repo:** vite.config.ts does more than bundle: a first plugin swaps cache.server.ts for a stub under Vitest (ADR 047), the icons plugin writes the sprite into app/components/ui/icons, and the Sentry plugin loads only for production builds with SENTRY_AUTH_TOKEN.

Files: `vite.config.ts`, `server/index.ts`

### Vitest

Unit and component tests colocated as app/**/*.test.ts(x), each worker copying a pre-migrated SQLite file and failing any test that logs console.error or console.warn.

**Start here:** <https://vitest.dev/>

- [Configuring Vitest](https://vitest.dev/config/) (reference) — The test block in vite.config.ts sets include, setupFiles, globalSetup and restoreMocks; this defines why global setup builds base.db once while setup files run in every worker.

- [Mocking](https://vitest.dev/guide/mocking) (guide) — setup-test-env.ts spies on console.error and console.warn to fail tests, and a test that expects a logged error must replace that spy's implementation, as the thrown message instructs.

**In this repo:** Unit tests sit next to the code they cover, such as auth.server.test.ts and auth.$provider/callback.test.ts, and run against a real SQLite copy per worker plus MSW handlers rather than mocking Prisma.

Files: `tests/setup/setup-test-env.ts`, `tests/setup/db-setup.ts`, `app/routes/_auth/auth.$provider/callback.test.ts`

### Playwright

End-to-end tests in tests/e2e, run in CI against the built app started with `npm run start:mocks`, using fixtures that insert a user and a session cookie instead of clicking through login.

**Start here:** <https://playwright.dev/docs/intro>

- [Fixtures](https://playwright.dev/docs/test-fixtures) (guide) — tests/playwright-utils.ts extends `test` with login, insertNewUser, navigate and prepareGitHubUser fixtures that create database rows before a test and delete them after.

- [Web server](https://playwright.dev/docs/test-webserver) (reference) — playwright.config.ts starts `npm run start:mocks` in CI and `npm run dev` locally, reusing a server that is already running; this page covers those webServer options.

**In this repo:** Specs authenticate by writing a Session row and cookie directly instead of using the login form, navigate with React Router's typed `href()` so a renamed route breaks the test at compile time, and clean up their users afterwards.

Files: `tests/playwright-utils.ts`, `playwright.config.ts`, `tests/e2e/notes.test.ts`

### MSW

Intercepts Resend, GitHub, Tigris and Have I Been Pwned calls both in tests and in `npm run dev` (MOCKS=true), so the app runs offline with the fake values in .env.example.

**Start here:** <https://mswjs.io/docs>

- [Node.js integration](https://mswjs.io/docs/integrations/node) (guide) — tests/mocks/index.ts calls setupServer and listen inside the Node process for both Vitest and `npm run dev`, the integration this page describes.

**In this repo:** Mocks are part of everyday development, not only tests: index.ts imports tests/mocks before the server when MOCKS=true, and unhandled requests print MSW's warning except for Sentry and React Router devtools traffic.

Files: `tests/mocks/index.ts`, `index.ts`, `.env.example`

## First Tasks

- [ ] **Fix the base border colour that never applies** — easy
  app/styles/tailwind.css (line 218) sets the global border-color to hsl(var(--border)), but --border holds an oklch() value, so the declaration is invalid and every element with a bare border class falls back to currentColor. TooltipContent and both dropdown menu panels use border with no colour utility, so their outlines take the text colour. Change it to var(--border), then compare a StatusButton tooltip and the user menu before and after, in light and dark mode.
  Files: `app/styles/tailwind.css`, `app/components/ui/tooltip.tsx`, `app/components/ui/dropdown-menu.tsx` · _A one-line change with a visible before and after that teaches the token pipeline — :root variables, the @theme inline mapping and how utilities consume them — without touching server code._
- [ ] **Make the 2FA re-verification window match its comment** — medium
  shouldRequestTwoFA in app/routes/_auth/login.server.ts says users are asked for 2FA again after two hours, but const twoHours = 1000 * 60 * 2 is two minutes. requireRecentVerification in verify.server.ts relies on it to guard disabling 2FA and changing email. Decide which is intended (a short window is defensible for sensitive settings), make the constant and the comment agree under a clearly named value, and add a colocated Vitest test covering a session verified inside and outside the window.
  Files: `app/routes/_auth/login.server.ts`, `app/routes/_auth/verify.server.ts`, `app/routes/settings/profile/two-factor/disable.tsx`, `app/routes/settings/profile/change-email.server.tsx` · _It walks a newcomer through the session and verification cookies behind every sensitive settings page, and the colocated test pattern in app/utils/auth.server.test.ts is already there to copy._
- [ ] **Close the gaps in the strict rate-limit path list** — medium
  server/index.ts applies its strongest rate limit only to paths listed in strongPaths, matched exactly with includes. /forgot-password and the /webauthn passkey endpoints are missing, while /resources/login and /resources/verify no longer exist as routes. Rebuild the list from the routes that accept credentials or send email, consider prefix matching so nested paths are covered, record the change in docs/decisions/025-rate-limiting.md, and check it with npm run build and npm run start:mocks.
  Files: `server/index.ts`, `docs/decisions/025-rate-limiting.md` · _Small in code, but it forces reading the Express middleware order and the full route list, which is the map a newcomer needs before touching the server._
- [ ] **Let admins edit any note, as their permission says** — hard
  The init migration grants admins update:note:any, and the note page shows the Edit link to anyone allowed to delete the note, but the loader in $noteId_.edit.tsx and the action in +shared/note-editor.server.tsx both filter by ownerId: userId, so an admin who follows Edit gets a 404. Replace the ownership filters with requireUserWithPermission using update:note:own or update:note:any, make sure saving keeps the note's original owner, show the Edit link through userHasPermission the way canDelete already works, and add a Playwright test that signs in as an admin and edits another user's note.
  Files: `app/routes/users/$username/notes/$noteId_.edit.tsx`, `app/routes/users/$username/notes/+shared/note-editor.server.tsx`, `app/routes/users/$username/notes/$noteId.tsx`, `app/utils/permissions.server.ts`, `app/utils/user.ts`, `tests/e2e/notes.test.ts`, `prisma/migrations/20250221233640_init/migration.sql` · _It crosses every layer a real feature does — seeded RBAC data, server-side permission guards, the UI permission check and an end-to-end test — on a small, well-contained flow._

---

_Generated by [repo-onboarding](https://repo-onboarding-tau.vercel.app) vonboard/0.3.0. Regenerate with `npx repo-onboarding export`._
