Architecture
How the system works once it is running. File layout is on the codebase page.
Request model
The application is React Router 8 in Framework Mode, on Vite, running on Node 24. A route module is the page: a loader reads, an action mutates, a component renders. Data mode and Declarative mode are for embedding a router in an existing app. This repository is the app.
Vite is the bundler because Framework Mode is a Vite plugin. Node is the runtime because the default adapters and the CLIs expect it. Docker images are node:24-alpine. Bun, Deno, and a Node 22 image are refused.
Persistence
Data lives in one SQLite file, prisma/data.db, through Prisma 7 and @prisma/adapter-better-sqlite3. The client is a singleton in app/lib/db.server.ts so a hot reload does not open another handle and trip SQLITE_BUSY.
New model ids are CUID2 strings. Auto-increment is reserved for numbers a person enumerates, such as invoices. Production schema changes follow expand-and-contract. Relative file: URLs are resolved next to the Prisma schema. A connection_limit query on that URL is stripped, because the adapter would otherwise treat the query as part of the filename.
The database decision names LiteFS as the way a later deployment would replicate the file. This copy does not ship a LiteFS config. A client-server database, Prisma 8, and a second ORM are refused.
Authentication
Sign-in is a magic link or GitHub OAuth. There is no password. The first successful login creates the user. Email is the unique identifier.
A Session row is the login. The cookie stores the session id, httpOnly, SameSite=Lax, and secure in production. Mutations are POST. GET never creates or destroys a session. Account management is under /account: profile, photo, email, connections, and two-factor. Sign-in is /login.
One Verification table holds every short-lived code and the long-lived two-factor secret. Emailed codes omit 0 and O. Authenticator codes are digits, SHA-1, 30 seconds. A profile image is one optional SQLite blob.
- Passwords, usernames as login, and passkeys are refused.
- Auth-as-a-service and auth frameworks are refused. The tables and routes stay in this repo.
- A signed cookie with no session table, and a JWT used as the session, are refused. Both are hard to revoke.
- Roles are not part of auth. Who may sign in is not who may delete a user.
The rest of the app calls sendEmail in app/lib/email.server.ts. The first vendor is Resend, reached with fetch. There is no Resend SDK and no React Email package. A route does not call the Resend URL itself.
EMAIL_LOGIN turns the product on. EMAIL_LOGIN_MOCK keeps the call on the machine: MSW intercepts it, logs the code, and writes the last message per recipient under mocks/fixtures/email/. A key sitting in the environment is not permission to send.
Payments
Subscriptions bill through Stripe Checkout in subscription mode, and the Customer Portal changes them. The webhook is the fulfillment. A return to the billing page is not a payment, because some methods finish after the customer has left Checkout.
The feature ships off. SUBSCRIPTIONS must be "true" before /plans, billing, and the webhook exist. The live credential is a restricted key (rk_). A secret key (sk_) fails the env parse. Tax collection stays off until a registration exists. Local and test runs use SUBSCRIPTIONS_MOCK and MSW, not the live API.
Deployment
npm run build writes build/client and build/server. The container listens on port 8080. The Fly config in the repo sets the primary region to Madrid, forces HTTPS, and mounts a volume at /data for the database file. Any host that runs Docker can run the same image.
Start locally with:
cp .env.example .env npm ci npm run db:deploy npm run dev