@AGENTS.md

# Project notes

- Use Node 20.20.2 (`.nvmrc`); Prisma 7 refuses Node 23, the machine default.
- The parent folder `Web:Backend` has a colon, which breaks `node_modules/.bin` on PATH. Never rely on `npx` or bare binary names — use the npm scripts, which call `node node_modules/...` directly.
- `prisma migrate dev` does not regenerate the client in Prisma 7; run `npm run db:generate` after every migration.
- Backend logic belongs in `src/server/**` (framework-free where possible); pages and actions stay thin. Every protected page/action calls `requireUser()`.
- Slate palette, small `PageHeader` titles — no large headings or animations (spec §30). Light and dark themes (default: system) — the choice is the `bts_theme` cookie (`src/lib/theme.ts`), rendered as `<html data-theme>` by the root layout. Dark mode works by remapping the palette variables in `globals.css`, so keep writing plain light-theme classes (`bg-white`, `text-slate-900`, `bg-red-50`…); don't hard-code hex colours (use `fill-slate-500`, `var(--color-…)`), use `slate-950` for modal backdrops, and wrap always-dark areas in `.light-palette`.
- Statuses are WORKING / SLOW / NOT_WORKING (+ derived DISABLED / PENDING). SLOW is "up" — use `isUp()` from `src/lib/status.ts`, never `=== "WORKING"`, when deciding whether an app answered. Anything that filters or orders by status must be updated in three places together: `displayStatus()`, `STATUS_FILTERS` (dashboard service) and `STATUS_WHERE` (projects service).
- Import from a sheet: parsers in `src/server/import/spreadsheet.ts` are dependency-free by design (the npm `xlsx` package has unfixed advisories) — don't add a spreadsheet library. Pure classification lives in `src/lib/import/rows.ts`.
- Domain expiry (`src/lib/domains/`, `src/server/domains/`): which apps share a domain is derived from their URLs at read time — never store a project↔domain link. Watched = app domains + `added_manually`, minus `hidden_at`; `domainsInUse()` is the one place that decides it. Lookups are RDAP only (IANA bootstrap + `BUILT_IN_RDAP_SERVERS`); e2e tests must never reach a real registry (`RDAP_BOOTSTRAP_URL` points at the fake server).
- Roles: `ADMIN` (Super Admin) and `VIEWER` (view only). Every mutating server action must call `canEdit(user)` right after `requireUser()` and return `VIEW_ONLY_ERROR`; admin-only pages use `requireAdmin()` (404 for viewers); shared pages hide controls with `editable`. Add a check to `tests/e2e/suites/roles.mjs` for every new action.
- Never construct `PrismaClient` directly — use `createPrismaClient()` (`src/server/db-client.ts`), which pins the DB session to UTC. Without it timestamps are stored shifted by the local UTC offset (this bit us once; data was repaired by +5:30 on 2026-09-17).
- Format dates only via `src/lib/format.ts`; don't use `toLocaleString` (runtime-dependent output such as "Sept").
- Tests: `npm test` (unit, vitest) and `npm run test:e2e` (real server + dedicated `*_test` database; never point it at the dev DB). Add an e2e check for every user-visible behaviour change; run `npm run check` before finishing.
- Next 16 conventions that differ from older versions: `src/proxy.ts` (not middleware), `error.tsx` receives `retry` (not `reset`), `params`/`searchParams` are Promises.
