Self-hosted single-user dashboard for monitoring private tracker stats. Next.js 16 App Router, PostgreSQL, Drizzle ORM, Tailwind v4, Vitest.
pnpm only. Never use npm or yarn. Node.js >= 22 required.
- First line of every JS/TS file:
// relative/path/to/file.tsx - If a file has 5+ functions, add a comment block listing them after the filepath comment
- Client components need explicit
"use client"directive - Server-only modules use
import "server-only"at the top
- Do not import the entire React package. Destructure:
import { useState, useEffect } from "react"notimport React from "react" - Use
import type { ... }for type-only imports. - Path aliases:
@/maps tosrc/,@typographymaps to Typography components. - Import order is enforced by Biome's
organizeImportsrule. Components before lib before types.
- ALWAYS use Drizzle's schema-first approach. NEVER use raw SQL migrations.
- Schema lives in
src/lib/db/schema.ts. Usedrizzle-kit pushto sync schema to DB. DISTINCT ONis supported natively viadb.selectDistinctOn(). Do not usedb.execute(sql\...`)` for this.- Use explicit column projections in
.select({ ... })instead of.select()(which fetches all columns). - BigInt columns use
{ mode: "bigint" }and return nativebigintvalues from queries. - Upserts use
.onConflictDoUpdate()or.onConflictDoNothing().
- Tracker upload/download/buffer values are stored as
bigintin the database. - Always wrap BigInt conversions in try/catch when processing external data.
Number.MAX_SAFE_INTEGERis ~9 PiB. Daily deltas will never approach this. Do not over-engineer BigInt→Number conversions for percentage calculations on daily deltas.- Serialize bigints as
.toString()in API responses (JSON cannot represent BigInt).
- All date strings for checkpoint tables and daily comparisons use
localDateStr()fromsrc/lib/formatters.ts, which respects theTZenvironment variable. - Do NOT use
.toISOString().slice(0, 10)for checkpoint dates — that returns UTC, not the user's configured timezone. - When
TZis not set,localDateStr()falls back to UTC (same behavior astoISOString).
- All catch blocks must either log the error (
log.warn,log.errorfrom@/lib/logger) or re-throw. Empty catch blocks trigger the security audit. - Use
logfrom@/lib/logger(pino), notconsole.log/console.error. - Decryption errors (stale session keys) should return HTTP 401, not 422 or 500. Use
isDecryptionError()from@/lib/error-utils. - Scheduler and poll operations wrap checkpoint writes in try/catch so failures don't break the poll cycle.
- Every route handler must call
authenticate()from@/lib/api-helpersand check the result before proceeding. - Use
NextResponse.json()for responses. - Sensitive data (encrypted tokens, password hashes, encryption salts) must never appear in API responses. Column projections exclude them structurally.
Single-user app with master password auth. No third-party providers.
- Three-layer defense in depth:
proxy.ts(cookie presence check) →getSession()(JWE decrypt) → route handler (authenticate()+decodeKey()) - Session: JWE (encrypted JWT via
EncryptJWT, notSignJWT), stored intt_sessionhttpOnly/secure/sameSite=strict cookie. Contains{ ek: encryptionKeyHex }. - Key derivation: master password →
scrypt(password, salt, 32)→ AES-256-GCM key. This key decrypts all API tokens in the DB. - JWE key: derived from
SESSION_SECRETenv via HKDF-SHA256 with domain-separated info labels. - Sliding session refresh:
proxy.tsre-sets cookie expiry on every request without decrypting the JWE. - Lockout: atomic SQL counter (
failedLoginAttempts + 1), configurable threshold, returns 429 with Retry-After. Both login and TOTP verify paths increment. - TOTP 2FA: optional, uses
otpauthlibrary. Backup codes are hexXXXX-XXXX, compared withtimingSafeEqual. - Stale sessions: when the encryption key doesn't match (i.e., password changed), AES-GCM auth tag fails.
isDecryptionError()catches this → return 401, not 422/500. - Key zeroing:
stopScheduler()zero-fills the key buffer on logout. - Public routes: defined in
proxy.tsasPUBLIC_EXACTandPUBLIC_PREFIXarrays. All other routes require the cookie.
Route handler pattern:
const auth = await authenticate()
if (auth instanceof NextResponse) return auth // 401
const key = decodeKey(auth) // Buffer
// use key to decrypt DB fields- A static security audit runs in CI (
scripts/security-audit.ts). It checks for: missing auth on routes, hardcoded secrets, bare catch blocks, console.log in routes, raw SQL in routes, unvalidated JSON parse, unsafe redirects, missing fetch timeouts, and more. - Suppress findings with
// security-audit-ignore: <reason>on the line above. A bare suppression without a reason is itself a critical failure. - URL inputs are validated against SSRF via
isUnsafeNetworkHost()insrc/lib/network.ts.
- Component anatomy: internal sub-components are unexported functions within the file. Single public export at the bottom. Types exported with
export type { ... }. - Use React 19's ref-as-prop pattern (direct
refprop), notforwardRef. - SVG path calculations with floating-point geometry should round to 2 decimal places for SSR hydration safety.
All module-level singletons (cron tasks, DB client, qBT SID cache) must be stored on globalThis to survive webpack HMR reloads in development. Without this, hot-reloads orphan the old instance while creating a new one.
- Framework: Vitest.
- Test mocks must match real API response shapes. Do not add fields (i.e.,
isPrivate: true) that the real API does not return — this masks bugs. - Do not add test-only methods to production classes. Put test utilities in test files.
- Page files (
page.tsx) should be thin wrappers — mostly orchestration and render logic. Extract business logic, UI sections, and data fetching into components and hooks. - Repeated code should be extracted to helper functions or reusable components. Do not duplicate logic across files.
- Centralized constants belong in
src/lib/constants.ts. Avoid magic strings and magic numbers scattered throughout the codebase.
- NEVER read
localStorageinuseStateinitializers — causes hydration mismatch. Initialize with a server-safe default, then hydrate inuseEffect.
- Tracker platforms use an adapter pattern:
TrackerAdapterinterface withgetAdapter()factory. UNIT3D, Gazelle, and GGn are implemented. Adding a new platform means adding a new adapter file insrc/lib/adapters/, not modifying existing ones.
- Daily upload/download deltas are computed as BigInt subtraction. Converting daily deltas to Number for percentage calculations is safe —
Number.MAX_SAFE_INTEGER(~9 PiB) vastly exceeds any realistic daily transfer. Do not over-engineer BigInt arithmetic for percentage computations on daily deltas.
- All
fetch()calls must include a timeout:fetch(url, { signal: AbortSignal.timeout(15_000) }). This is a CI-blocking security audit check. - URLs from user input must be validated with
validateHttpUrl()before use infetch(). - Error messages must not contain full URLs (which may include API tokens). Use
sanitizeNetworkError().
- Use centralized helpers from
src/lib/api-helpers.ts:authenticate(),parseJsonBody(),parseRouteId(),validateHttpUrl(),validateHexColor(),validatePort(),validateJoinedAt(). - All validators return
NextResponse | null— check withif (err) return err. - String inputs: check type, trim, enforce length limits (i.e., name <= 100 chars, URL <= 500 chars).
- AES-256-GCM with random 12-byte IV. Format:
base64(iv + authTag + ciphertext). - Key derivation:
scrypt(password, salt, 32, { N: 16384, r: 8, p: 1 }). - Use
encrypt()/decrypt()fromsrc/lib/crypto.ts. Keys are alwaysBuffer. - Encryption keys are zeroed in memory on logout via
stopScheduler().
useId()for stable label/input ID association in form controls.aria-invalidandaria-describedbyon inputs with errors.aria-hidden="true"on decorative icons/SVGs.aria-labelon icon-only buttons.
- Do not use ESLint. It has been deprecated for this project.
- Do not use
npmoryarn—pnpmonly. - Do not create raw SQL migration files. Use
drizzle-kit push. - Do not use
.toISOString().slice(0, 10)for checkpoint/daily dates — uselocalDateStr(). - Do not pass unknown props through to DOM elements via
{...rest}spreads without filtering component-only props first. - Do not use dynamic code execution or inject raw HTML into the DOM. See
scripts/security-audit.tsfor the full list of banned functions. - Do not use
===for secret comparisons — usetimingSafeEqual. - Do not use
Number()orparseInt()directly on BigInt database columns. - Do not commit
.envfiles. - Do not use
JSON.parse()without a try/catch wrapper. - Do not use
console.login API routes — use theloginstance from@/lib/logger. - Do not use
db.execute()in API routes (raw SQL is banned except for the health checkSELECT 1).