# Architecture Notes

Implementation notes for decisions that aren't obvious from reading the code alone. See
`readme.md` for the product spec and `docs/uapi-contract.md` for the cPanel UAPI contract.

## Why these libraries, specifically

- **`node:sqlite` (built-in), not `better-sqlite3`/`sqlite3`.** cPanel's Node.js Selector runs
  `npm install` on the server itself; native addons routinely fail there without build tools
  present. `node:sqlite` ships with Node — zero native compilation. Start scripts pass
  `--experimental-sqlite` for cross-minor-version safety on Node 22.x.
- **`bcryptjs`, not `bcrypt`.** Same reasoning — `bcrypt` is a native addon and failed to build
  in this project's own dev environment (Windows, UNC path) with a `node-pre-gyp`/`cmd.exe`
  error. `bcryptjs` is API-compatible and pure JS.
- **AdminLTE 3's own bundled Bootstrap 4, not a separately-vendored Bootstrap 5.** The README
  lists "Bootstrap 5" and "AdminLTE 3" as separate stack items, but that combination doesn't
  actually exist as shipped: AdminLTE 3.x's compiled CSS (`adminlte.min.css`) already bundles a
  compiled Bootstrap 4 (confirmed via BS4-only selectors like `.custom-control-input` in the
  built CSS), and its JS (dropdowns, modals, sidebar toggle) uses jQuery + Bootstrap 4's
  `data-toggle`/`data-dismiss` attribute API. AdminLTE 4 (Bootstrap 5-based) exists but was still
  unstable at time of writing. `scripts/copy-assets.js` therefore vendors
  `admin-lte/node_modules/bootstrap/dist/js` (AdminLTE's own nested BS4 copy) instead of a
  top-level Bootstrap 5 package — mixing BS5's JS with BS4-authored markup would silently break
  every dropdown/modal (different attribute names, no jQuery dependency in BS5).
- **Front-end vendor packages (`admin-lte`, `chart.js`, `jquery`, `sweetalert2`,
  `@fortawesome/fontawesome-free`) are regular `dependencies`, not `devDependencies`.** They're
  only ever read by `scripts/copy-assets.js` to populate `public/vendor/`, never `import`ed by
  server code — but cPanel's "Production" application mode sets `NODE_ENV=production` for the
  `npm install` it runs, and npm skips `devDependencies` entirely under that env unless told
  otherwise. Keeping them in `devDependencies` would silently ship a UI with no CSS/JS on a real
  cPanel deploy while working fine in any local dev shell that doesn't have `NODE_ENV=production`
  set — exactly the kind of gap that doesn't show up until production.
- **`csrf-csrf`, not `csurf`** (deprecated/unmaintained). Its v3 API is `generateToken` /
  `getTokenFromRequest` (not `generateCsrfToken` / `getCsrfTokenFromRequest` — a naming change
  from earlier versions/other forks that's easy to get wrong from memory; verify against
  `node_modules/csrf-csrf/lib/esm/index.js` if upgrading).

## Session and CSRF: two non-obvious bugs already fixed

1. **`express-session`'s `saveUninitialized` must be `true`.** CSRF tokens here are
   cryptographically bound to `req.session.id` (`middleware/csrf.js`, `getSessionIdentifier`).
   With `saveUninitialized: false`, a session that nothing has written to yet is never persisted
   and never gets a `sid` cookie sent to the client — so an anonymous GET `/login` and the POST
   `/login` that follows it each get a *different* `req.session.id`, and the CSRF hash computed
   for the form silently stops matching on submit. This is invisible until you test a real
   login flow end-to-end (unit-testing the middleware in isolation won't catch it).
2. **`generateToken(req, res, overwrite, validateOnReuse)` must be called with
   `validateOnReuse: false`** in the middleware that runs on every request
   (`middleware/csrf.js`'s `exposeCsrfToken`). The library's default (`true`) *throws*
   `invalidCsrfTokenError` when an existing CSRF cookie's session-binding no longer matches the
   current `req.session.id` — which happens on every successful login, since the login
   controller deliberately calls `req.session.regenerate()` to prevent session fixation. Without
   this fix, the *first page load after logging in* throws and gets redirected by the generic
   error handler. `validateOnReuse: false` makes it silently reissue a fresh token instead.

## Route mounting: per-path guards, not router-level `.use()`

`app.js` mounts every router at `/` (e.g. `app.use(settingRoutes)`, not
`app.use('/settings', settingRoutes)`) because each router already defines its own full paths
internally. A consequence: `router.use(requireAuth, requireRole(...))` with **no path argument**
runs for *every* request that reaches that router — not just requests matching a route defined
in it — because Express dispatches unmatched requests through a router's unscoped middleware
before falling through to the next `app.use()`. Concretely, this let `routes/setting.js`'s
`requireRole('super_admin')` swallow requests meant for `routes/user.js`/`routes/log.js` (mounted
after it), so an `admin`-role user was incorrectly redirected away from `/logs` even though
`logController` explicitly allows `admin`. The fix (already applied): scope every such guard to
its own path prefix, e.g. `router.use('/settings', requireAuth, requireRole('super_admin'))`.
When adding a new router here, always scope `router.use()` calls to a path — never leave them
bare unless you mean "run for literally every request in the app."

## Single-domain scope (by design, for now)

`config/cpanel.js` resolves one `domain` for the whole app (env-seeded, editable from Settings).
Every route that touches `cpanelService` resolves the domain server-side from Settings — it never
trusts a client-supplied domain — because "Multi Domain" is explicitly listed as a *Future
Feature* in the README, not current scope. `services/cpanelRealService.js`'s functions all still
take `domain` as an explicit parameter, so adding multi-domain support later is a routing/UI
change, not a service-layer rewrite.

## Email "created" timestamps are local, not from cPanel

cPanel UAPI's `Email::list_pops` doesn't return an account creation date. Since this app is meant
to be the only way accounts get created going forward, `email_meta` (SQLite) records
`created_at` itself the moment `add_pop` succeeds. Accounts that existed before this app was ever
used show "Created: unknown" in the Detail view — deliberately not faked.

## Bulk import: in-process job + polling, not a queue

No Redis/queue in the stack, so `services/importService.js` runs each import as an in-process
async job (`Map<jobId, state>`), processed with a small concurrency limit, and the browser polls
`GET /email/import/:jobId/progress` roughly once a second. Polling (not SSE) was chosen because
LiteSpeed/Passenger shared hosting can buffer or kill long-lived streaming responses. Note the
job state is in-memory only — an app restart mid-import loses progress tracking for that job
(the rows already processed are not rolled back, only the progress UI loses track of them).

## Mock cPanel mode

`CPANEL_MOCK=true` (env, or the Settings page checkbox) swaps `services/cpanelService.js`'s
delegate from `cpanelRealService.js` to `cpanelMockService.js`, an in-memory fake seeded with a
few demo accounts. It exists purely so the app is runnable/demoable without real cPanel
credentials — state resets on every process restart. This was exercised during development in
lieu of a real cPanel account; a pass against a real cPanel UAPI endpoint is still needed before
trusting the real-service code path in production.
