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.

Email

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