Files
Reactive-Resume/.planning/codebase/CONCERNS.md
T
Amruth Pillai adfc9b527b docs: map existing codebase
Adds .planning/codebase/ with parallel-mapper outputs covering stack,
integrations, architecture, structure, conventions, testing, and concerns.
2026-05-11 11:16:33 +02:00

394 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Codebase Concerns
**Analysis Date:** 2026-05-11
## TODO / FIXME / HACK / XXX Comments
A repo-wide grep for `TODO`, `FIXME`, `HACK`, and `XXX` markers across `apps/web/src/**` and `packages/**` returned **zero hits** in source code. The team appears to track follow-ups in PRs/issues rather than inline. Two referenced issues remain anchored in inline comments:
- `packages/pdf/src/hooks/use-register-fonts.ts:22` — references issue `#2986` (CJK glyph-level font fallback).
- `packages/pdf/src/hooks/use-register-fonts.ts:103` — references issue `#2986` again for CJK textkit substitution.
These are stable design references, not unresolved debt — included for traceability only.
The remaining inline-comment "concerns" found during exploration sit in the **Anti-debt narrative** below, derived from code shape rather than comment markers.
---
## High Severity
### Security: SMTP-disabled fallback logs full email bodies (incl. verification/reset links)
When `SMTP_HOST` / `SMTP_USER` / `SMTP_PASS` / `SMTP_FROM` are not all set, the email transport logs the entire payload — including `text` and `html` bodies — to `stdout`.
- File: `packages/email/src/transport.ts:59-66`
```ts
console.info("SMTP not configured; skipping email send.", {
to: payload.to,
subject: payload.subject,
text: payload.text,
html: payload.html,
});
```
**Impact:**
- Password reset, email verification, and email-change confirmation URLs (which are credential-equivalent bearer tokens) are written to server logs whenever SMTP is not fully configured.
- In any shared-log / log-shipping environment (Docker, Kubernetes, journald, cloud logging) this is a credential leak.
- The README and `AGENTS.md:101` describe this as a dev convenience; nothing prevents an operator from running production with partial SMTP config.
**Fix approach:**
1. Add a server-startup assertion that, if `NODE_ENV === "production"`, `isSmtpEnabled()` must be true.
2. Redact `text` / `html` from the `console.info` call — log only `to` and `subject`.
3. Document the production SMTP requirement in `.env.example` alongside `AUTH_SECRET`.
### Security: rate limiting is silently disabled in non-production
Both the oRPC rate-limit middleware and Better Auth's rate-limit config gate on `process.env.NODE_ENV === "production"`. Anything that does not set `NODE_ENV=production` at runtime (default `node` invocation, custom Docker entrypoints that forget to set it, self-hosters running `pnpm start` without `NODE_ENV`) runs with **all rate limiting disabled**, including:
- `/sign-in/email`, `/sign-up/email`, password-reset, OAuth `register`/`authorize`/`token` (`packages/auth/src/config.ts:30, :69-78, :249-251, :388-390`)
- Resume password verification, AI calls, PDF export, storage uploads/deletes, resume mutations (`packages/api/src/middleware/rate-limit/index.ts:5, :75`)
**Impact:** brute-force-friendly. A self-hosted deployment that omits `NODE_ENV=production` is wide open on auth and resume-password endpoints.
**Fix approach:**
- Either default `isRateLimitEnabled = true` and provide a `RATE_LIMIT_DISABLED` opt-out env var, or assert `NODE_ENV === "production"` at boot when not running tests.
### Security: rate limiter is in-process memory only — broken under horizontal scaling
`MemoryRatelimiter` is used for every oRPC rate limit (`packages/api/src/middleware/rate-limit/index.ts:59-66`). Better Auth's `rateLimit` and `apiKey.rateLimit` blocks (`packages/auth/src/config.ts:248-251, :387-391`) similarly do not configure a distributed store.
**Impact:** Running >1 Node instance behind a load balancer multiplies the effective rate limit by the instance count. Brute-force attacks bypass the limit by retrying until they hit a different replica.
**Fix approach:** Swap `MemoryRatelimiter` for a Redis-backed ratelimiter (the package supports it via `@orpc/experimental-ratelimit`) once a redis dependency is acceptable. Until then, document the single-instance constraint in the deployment docs.
### Security: file upload accepts arbitrary MIME with image processing disabled
`uploadFile` (`packages/api/src/routers/storage.ts:42-71`) only runs the sharp image-processing pipeline for files where `isImageFile(file.type)` returns true based on the **client-supplied `file.type`**, and even that pipeline is skipped if `FLAG_DISABLE_IMAGE_PROCESSING=true` (`packages/api/src/services/storage.ts:96-101`).
**Impact:**
- A client can claim `content-type: text/html` (or any non-image MIME) on a 10 MB upload, and the file is stored verbatim into `uploads/{userId}/pictures/...` with the user-claimed content type.
- Served back from `apps/web/src/routes/uploads/$userId.$.tsx`. `X-Content-Type-Options: nosniff` is set (`:159`) and the storage route forces `application/octet-stream` only for `.pdf` (`:39, :148-153`), so a stored HTML body could be served as HTML if the inferred extension matches. The picture key always ends in `.jpeg` (`storage.ts:60`), which mitigates this in the picture path, but there is no MIME allow-list enforced at upload time.
- With `FLAG_DISABLE_IMAGE_PROCESSING` on, the same applies to genuine images (no resize/strip-metadata path), so EXIF data is preserved unredacted.
**Fix approach:**
1. Add a strict allow-list at the router boundary (`packages/api/src/routers/storage.ts:9`) — `z.file().mime(["image/png", "image/jpeg", "image/webp", "image/gif"])` plus magic-byte validation.
2. Reject upload if claimed MIME does not match the sharp-detected MIME; do not fall back to client claim.
3. Surface a startup warning when `FLAG_DISABLE_IMAGE_PROCESSING=true` so it is not enabled in production unknowingly.
### Tech Debt: web app has near-zero test coverage
- `apps/web/src` has **11 test files** across **~224 source files** (~5%) — `find apps/web/src -name "*.test.*"`.
- `apps/web/src/routes` has **1 test file** across **125 route files** (`apps/web/src/routes/builder/$resumeId/-components/donation-toast.test.tsx`).
- Total repo: ~93 test files / ~385 source files (~24%). Most coverage lives in `packages/ui`, `packages/utils`, `packages/pdf/src/templates/shared`, and `packages/api` (5 tests).
**Impact:**
- Refactors to routes, builder shell, sidebar forms, resume preview wiring, MCP tools, and uploads handler land without a regression net.
- Particularly thin: `apps/web/src/routes/api/**` (rpc, auth, openapi, mcp, uploads) and `apps/web/src/routes/builder/$resumeId/**`.
**Fix approach:** Phase-by-phase, add server-handler integration tests for `api/health.ts`, `api/auth.$.ts` (registration-validation path), `uploads/$userId.$.tsx` (path traversal), and at least smoke tests around the builder store in `apps/web/src/components/resume/builder-resume-draft.ts`.
---
## Medium Severity
### Fragile: PDF.js / canvas SSR boundary is enforced by convention only
The SSR/CSR split for the resume preview is hand-maintained:
- `apps/web/src/components/resume/preview.tsx:14` returns `null` until `useIsClient()` resolves and then lazy-loads the browser bundle.
- `apps/web/src/components/resume/preview.browser.tsx:2` imports `@react-pdf/renderer` (`pdf`).
- `apps/web/src/components/resume/pdf-canvas.tsx:1, :4, :9` imports PDF.js types/runtime and sets a module-level `GlobalWorkerOptions.workerSrc`.
- `apps/web/src/routes/templates/$.tsx:1` imports `PDFViewer` at the **top level** but the route component itself bails on `!isClient`. Top-level import means the bundle reaches the SSR chunk; correctness relies on the import being tree-shaken away when SSR runs.
- `apps/web/src/routes/dashboard/resumes/-components/cards/resume-thumbnail.tsx` uses dynamic `await import("pdfjs-dist")` — a different convention from `pdf-canvas.tsx`'s static import.
- SSR mode hints: `apps/web/src/routes/builder/$resumeId/index.tsx:4` (`ssr: false`), `apps/web/src/routes/$username/$slug.tsx` (`ssr: "data-only"`).
**Impact:** A future contributor adding a static PDF.js import inside a SSR-rendered route component will break SSR with a `window is not defined` error at build/run time. There is no lint rule or build-time guard enforcing this.
**Fix approach:**
1. Document the boundary explicitly at the top of `apps/web/src/components/resume/preview.tsx` and `pdf-canvas.tsx` (currently only described in `AGENTS.md:35`).
2. Consider a Vite SSR-externals config that aborts the build if `pdfjs-dist` / `@react-pdf/renderer` is reachable from an SSR-eligible route.
3. Switch `apps/web/src/routes/templates/$.tsx:1` to a dynamic `import("@react-pdf/renderer")` inside the `useIsClient()` branch for consistency with the rest of the preview pipeline.
### Fragile: `getStorageService()` is captured at module load in router
`packages/api/src/routers/storage.ts:7` calls `getStorageService()` at module top level. Because `apps/web/src/routes/api/rpc.$.ts` constructs a new `RPCHandler` on every request, the router's storage reference is fixed for the lifetime of the Node process.
**Impact:** Switching backends (e.g. flipping S3 vars on at runtime) requires a process restart, which is normally fine — but the singleton is also held inside Nitro's HMR boundary in dev, so config changes during `pnpm dev` need a full restart (not just a save).
**Fix approach:** Call `getStorageService()` inside each handler instead, or invalidate the cached service when env values change.
### Fragile: `RPCHandler` instantiated per-request
`apps/web/src/routes/api/rpc.$.ts:8-16` creates a new `RPCHandler` (with plugins) for every incoming request. Each instance re-walks the router tree and re-constructs plugin pipelines.
**Impact:** Measurable per-request cost on high-RPS dashboards; not catastrophic but unnecessary.
**Fix approach:** Move `new RPCHandler(...)` out of the handler and reuse a module-level instance; only `getLocale()` should run per-request.
### Performance: PDF preview regenerates entire PDF on every change
`apps/web/src/components/resume/preview.browser.tsx:103-131` debounces PDF generation by 100ms and calls `pdf(resumeDocument).toBlob()` on every resume change. For multi-page resumes with images, this re-renders the full document and re-loads it into PDF.js.
**Impact:** Builder feels sluggish on slower hardware when typing into fields; CPU spikes on each keystroke after debounce.
**Mitigation in place:** `UPDATE_DEBOUNCE_MS = 100`, crossfade between staged/active layers (`:20-80`).
**Fix approach:** Long-term, switch to incremental rendering or page-level memoization keyed by section hash. Short-term, raise debounce to 200300 ms while typing.
### Performance: font registration cost
`packages/pdf/src/hooks/use-register-fonts.ts:18, :86` keeps a module-level `registeredFontVariants` Set keyed by `family:weight:style`. Per-resume registration calls `Font.register` once per (family × weight × italic × CJK-fallback) combination, with web-font fetches resolved through `getWebFontSource`. This Set never expires — if many resumes with different typography are previewed in one session, the registered-font count grows for the page lifetime.
**Impact:** Memory grows in the builder for sessions that switch typography frequently. Not a leak in the GC sense, but bounded only by the size of the registered-font universe.
**Fix approach:** Acceptable for current usage. Document the cap and reconsider if typography switching becomes more common.
### Performance: large source files hint at oversized modules
Top offenders by line count (excluding generated/test fixtures):
| Lines | File |
|------:|------|
| 1535 | `packages/schema/src/icons.ts` (static data) |
| 1088 | `apps/web/src/routes/builder/$resumeId/-components/assistant.tsx` |
| 900 | `packages/pdf/src/templates/shared/sections.tsx` |
| 789 | `apps/web/src/components/input/rich-input.tsx` |
| 785 | `packages/import/src/reactive-resume-v4-json.tsx` |
| 685 | `packages/ui/src/components/sidebar.tsx` |
| 556 | `apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx` |
| 543 | `packages/api/src/services/resume.ts` |
`assistant.tsx` (1088 lines) and `sections.tsx` (900 lines) are particularly likely to accumulate further complexity without splitting. `rich-input.tsx` (789 lines) is the rich-text editor — likely justifies its size but has zero direct tests.
**Fix approach:** Split `assistant.tsx` along tool boundaries; extract per-section renderers from `sections.tsx` if any individual section grows further.
### Security: `verifyPassword` rate limit keyed by `username:slug:ip`
`packages/api/src/middleware/rate-limit/index.ts:77-83` keys the resume-password limiter on `resume-password:{username}:{slug}:{clientKey}` where `clientKey` is the client IP. The window/max is 5 attempts per 10 minutes (`packages/utils/src/rate-limit.ts:43`). This is reasonable, but the global Better Auth global rule for `/two-factor/verify-otp` is also 5 per 600s. An attacker on a botnet (different IPs) is not blocked at the resource level — each IP gets its own 5/10min budget against the same resume.
**Impact:** Limited but present brute-force surface on password-protected public resumes.
**Fix approach:** Add a per-resume global cap on top of the per-IP limit (e.g. 50/hour per `username:slug` regardless of IP).
### Security: `as string` cast on password hash
`packages/api/src/services/resume.ts:487` casts `resume.password` to `string` after a `isNotNull(schema.resume.password)` WHERE clause. The cast is correct in context, but if a future refactor drops the `isNotNull` guard the cast silently allows `null` through `bcrypt.compare`.
**Fix approach:** Replace `as string` with a runtime `if (!resume.password) throw new ORPCError(...)` check.
### Security: trust of `TRUSTED_IP_HEADERS` is unconditional
`packages/utils/src/rate-limit.ts:1-7` defines a list of trusted IP headers (`CF-Connecting-IP`, `True-Client-IP`, `X-Forwarded-For`, etc.) that the rate limiter and Better Auth (`packages/auth/src/config.ts:276`) honour from any caller.
**Impact:** If the app is deployed without a proxy that strips client-supplied versions of these headers, any client can spoof their rate-limit identity by setting `X-Forwarded-For: 1.2.3.4`.
**Fix approach:** Document that operators **must** terminate at a trusted proxy (Cloudflare, nginx, Caddy) that strips inbound `X-Forwarded-For` / `X-Real-IP`. Optionally, add a `TRUST_PROXY` env flag and only honour those headers when set.
### Fragile: `cachedTransport` in `email/transport.ts` ignores env mutation
`packages/email/src/transport.ts:21-37` caches the nodemailer transport on first use. If SMTP creds change at runtime (e.g. credential rotation in a deployed instance), the cached transport keeps using the stale credentials until the process restarts.
**Fix approach:** Detect cred changes and rebuild, or document the restart-on-rotation behaviour.
### Storage gotcha: statistics cache is filesystem-bound even with S3 configured
`packages/api/src/services/statistics.ts:21-52` caches user/resume/star counts as files in `getLocalDataDirectory(env.LOCAL_STORAGE_PATH)` regardless of whether S3 is enabled.
**Impact:** When S3 is configured and `LOCAL_STORAGE_PATH` is on ephemeral storage (e.g. container scratch), the cache is recreated on every redeploy — meaning a cold start always queries the DB / GitHub API rather than re-using the cache. Also breaks horizontal scaling — each replica has its own cache file.
**Fix approach:** Cache via the configured storage service (`getStorageService`) instead of raw `fs`, or move to an in-memory + TTL cache.
---
## Low Severity
### Tech Debt: generated file you must not edit
`apps/web/src/routeTree.gen.ts` (983 lines, `eslint-disable`, `@ts-nocheck` at top) is auto-generated by TanStack Router. It is correctly excluded from Biome (`biome.json:14`) and noted in `AGENTS.md:31`.
**Action required of contributors:** Never hand-edit. Regenerate by running `pnpm dev` (TanStack tooling watches `apps/web/src/routes/**`).
### Tech Debt: dev workflow — `pnpm check` is write-capable
`package.json:20` defines `"check": "biome check --write --unsafe ."`. Running `pnpm check` will modify files. The lefthook pre-commit (`lefthook.yml:7-9`) runs the same command on staged files only, and `stage_fixed: true` re-stages them.
**Impact:** Surprise file modifications when a contributor runs `pnpm check` expecting a non-mutating audit.
**Fix approach:** Add a parallel `pnpm check:ci` (no `--write`) for inspection, and document the distinction (already covered in `AGENTS.md:103`).
### Tech Debt: dev gotcha — `drizzle-kit` does not auto-load `.env`
`packages/db/drizzle.config.ts:8` reads `process.env.DATABASE_URL || ""`. The `@reactive-resume/env` package auto-loads `.env` via dotenv (`packages/env/src/server.ts:9-11`), but `drizzle-kit` runs as a separate process that does not import `@reactive-resume/env`.
**Impact:** Fresh `pnpm db:migrate` silently fails with an empty connection string unless `DATABASE_URL` is exported in the shell. Documented in `AGENTS.md:58, :79-80`.
**Fix approach:** Either import the env package from `drizzle.config.ts` to inherit the `.env` load, or wrap the script with a one-liner that exports `DATABASE_URL`.
### Tech Debt: Nitro plugin auto-runs migrations on dev/prod boot
`apps/web/plugins/1.migrate.ts:23-40` runs migrations on every Nitro startup. This is convenient but couples app-boot health to migration health.
**Impact:**
- A bad migration takes down the whole web service on boot, not just future migration runs.
- Two app instances starting concurrently both call `migrate()`; Drizzle's `migrations` table uses transactions to avoid duplicate apply, but the race adds startup latency.
- For prod self-hosters, there is no "boot-without-migrate" knob.
**Fix approach:** Gate on an env flag (e.g. `RUN_MIGRATIONS_ON_BOOT=true`, default true) and document running `pnpm db:migrate` separately in zero-downtime deploys.
### Tech Debt: S3 toggle is all-or-nothing
`packages/api/src/services/storage.ts:336` and `apps/web/plugins/2.storage.ts:7`: storage backend selection requires all three of `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_BUCKET` to be set. Setting two of three silently falls back to local storage.
**Impact:** Operator misconfigures S3 (e.g. forgets `S3_BUCKET`), app silently writes to local FS — uploads disappear on next container restart on ephemeral disks.
**Fix approach:** Treat "any S3 var set" as "S3 intended" — throw on partial config rather than silently downgrading.
### Security: no global CSP / X-Frame-Options on HTML responses
A repo-wide grep for `Content-Security-Policy`, `X-Frame-Options`, `Strict-Transport-Security` finds them only in the uploads route (`apps/web/src/routes/uploads/$userId.$.tsx:159-165`) and the schema JSON route (`apps/web/src/routes/schema[.]json.ts`). The HTML shell (`apps/web/src/routes/__root.tsx`) sets no CSP, no HSTS, no `Permissions-Policy`, no `Referrer-Policy`.
**Impact:**
- No clickjacking protection on the resume builder or public resume pages — they can be framed by any origin.
- No CSP means an XSS through rich-text rendering (`packages/utils/src/sanitize.*`, `packages/pdf/src/templates/shared/rich-text-html.ts`) has no defence-in-depth.
**Fix approach:** Add a Nitro response hook that sets `Content-Security-Policy`, `Strict-Transport-Security`, `Referrer-Policy: strict-origin-when-cross-origin`, `X-Content-Type-Options: nosniff`, and `X-Frame-Options: SAMEORIGIN` on all HTML responses. Validate that the CSP allows `pdf.worker.min.mjs`, `@react-pdf/renderer` font fetches, and the configured S3/SeaweedFS origin.
### Security: OAuth dynamic-client registration allows unauthenticated callers
`packages/auth/src/config.ts:395-401` enables `allowDynamicClientRegistration: true` and `allowUnauthenticatedClientRegistration: true` on the oauthProvider plugin. The comment (`:397-399`) explicitly states this is required for MCP onboarding (RFC 7591) and that the phishing vector is closed by the redirect-URI allowlist in `hooks.before` (`:253-271`) and `apps/web/src/routes/api/auth.$.ts:97-111`.
**Impact:** Anyone can register an OAuth client. The protection depends entirely on the redirect-URI allowlist correctness in `parseAllowedHostList(env.OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS)` and `isAllowedOAuthRedirectUri`.
**Fix approach:** Already mitigated in code; the residual risk is operator misconfiguration of `OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS`. Add a startup warning when `OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS` is empty (i.e. allowlist is APP_URL-only).
### Tech Debt: log noise on auth fallthrough
`packages/api/src/context.ts:25, :37, :53` calls `console.warn` for every failed Bearer / session / API-key validation. In an unauthenticated user flow that hits the same route via Bearer-not-present → session-cookie path, no warning fires, but in an actual token-mismatch path the warning fires on every request.
**Impact:** Log volume in production when API keys expire or rotate. Logs may leak token shape information indirectly via repeated warnings.
**Fix approach:** Drop to `console.debug` (which is no-op in default Node), or rate-limit the warning per token-hash.
### Tech Debt: `getStorageService()` cached service singleton in module-load order
`packages/api/src/services/storage.ts:343-350` caches the service at first call. Combined with the `packages/api/src/routers/storage.ts:7` top-level call, if storage env vars are not present at module-load time (e.g. `.env` not loaded yet) the local backend is wired in permanently for the process.
**Fix approach:** Lazy-validate env on first use, not at module load.
### Anti-pattern: empty catch swallows preview generation errors
`apps/web/src/components/resume/preview.browser.tsx:120`:
```ts
} catch {}
```
A failed PDF generation in the builder preview is silently swallowed. The crossfade machinery keeps showing the previous preview, masking template bugs or runtime errors from contributors during development.
**Fix approach:** At minimum, `console.error` the failure (matching the pattern used in `pdf-canvas.tsx:68`). Better: surface a toast or banner so failed-preview state is visible.
### Anti-pattern: `console.warn`/`error` lacks structure
Every error log in `packages/api/src` uses `console.warn`/`console.error` with positional args (e.g. `services/resume.ts:158, :293, :363`, `context.ts:25, :37, :53`). There is no centralised logger, no structured fields, no request correlation ID.
**Fix approach:** Introduce a minimal logger (pino-light or a thin wrapper) with `level`, `event`, `userId`, `requestId` fields. Already partly done in the healthcheck (`apps/web/src/routes/api/health.ts:65, "[Healthcheck]"`).
### Fragile: in-process pub/sub for resume update events
`packages/api/src/services/resume-events.ts` (the subscribe path consumed by `packages/api/src/routers/resume.ts:99-103`'s `subscribeResumeUpdates`) is in-process. Two web replicas will not see each other's resume update events.
**Impact:** Multi-tab / multi-device builder sync across replicas does not work behind a load balancer.
**Fix approach:** Redis pub/sub or Postgres `LISTEN/NOTIFY` once distributed deployment becomes a target.
---
## Migration & Schema Concerns
### Migration count is small but each is unversioned in app
- 13 migration files in `migrations/` (`migrations/20260114102228_*` through `migrations/20260507144406_*`).
- The Nitro plugin runs every pending migration on every boot (`apps/web/plugins/1.migrate.ts:23-40`).
- There is no "down" / rollback story. Drizzle-kit generates forward-only migrations.
**Action:** Standard for drizzle workflows. Document that downgrade requires manual SQL.
### Schema notes
- `packages/db/src/schema/resume.ts:24` stores `password` as plaintext column type `text`, but content is always bcrypt-hashed at write (`packages/api/src/services/resume.ts:453`). Column name is misleading — should be `password_hash`. Renaming requires a non-trivial migration.
---
## Dependency Risk
### Pre-1.0 / preview dependencies in production critical path
| Package | Version | Risk |
|---|---|---|
| `drizzle-orm` | `1.0.0-beta.22` | Beta. Breaking changes possible until 1.0.0 stable. (`packages/api/package.json`, `packages/auth/package.json`, `packages/db/package.json`, `apps/web/package.json`) |
| `drizzle-kit` | `1.0.0-beta.22` | Beta migrator. Snapshot format may change. (`packages/db/package.json`) |
| `drizzle-zod` | `1.0.0-beta.14-a36c63d` | Beta + specific commit hash — version drift risk. (`packages/api/package.json`) |
| `nitro` | `3.0.260429-beta` | Beta server runtime in the critical path. (`apps/web/package.json`) |
| `@typescript/native-preview` | `7.0.0-dev.20260510.1` | Dev build of `tsgo`. All packages use it for `typecheck`. |
| `typescript` | `^6.0.3` | TS 6 — recent major. |
| `vite` | `^8.0.11` | Vite 8 — recent major. |
| `react` / `react-dom` | `^19.2.6` | React 19. |
| `@orpc/experimental-ratelimit` | `^1.14.2` | Explicitly experimental in the package name. (`packages/api/package.json`) |
| `@tanstack/react-start` | `^1.167.65` | TanStack Start is pre-1.x semver but not labelled beta. |
| `better-auth` | `1.6.10` (exact) | Pinned exact, not `^`. Manual upgrade required for security patches. |
**Impact:** Library upgrades in this stack are high-risk; the team must follow each upstream's release notes closely. The `BETA` / `DEV` versions also affect lockfile churn.
**Fix approach:**
- Set up Renovate / Dependabot for the beta packages specifically, so security patches are caught.
- Run `pnpm knip` and `pnpm dlx npm-check-updates` regularly (both are devDependencies, `package.json:34, :40`).
### Several heavy native dependencies are externalised at build time
`apps/web/vite.config.ts:55-57` externals `bcrypt`, `sharp`, `@aws-sdk/client-s3`. `packages/runtime-externals` is the workspace package that wraps these. Knip is configured to ignore them (`knip.json:14`).
**Risk:** Operators must ensure these are installed at runtime (covered in `Dockerfile`). Self-hosters using a Node base image without build-essentials may hit `bcrypt` build failures.
**Fix approach:** Mostly documented; consider switching `bcrypt``bcryptjs` (pure JS) to remove a build dependency.
---
## Cross-Cutting Hard-to-Find Logic
These are non-obvious surfaces that consumers of this map should know about before changing related code:
1. **PDF section filtering**`packages/pdf/src/templates/shared/filtering.ts:25-60` decides which resume sections render in PDFs based on hidden flags and required title fields. Per-template visual exceptions live in each template directory; cross-template visual changes go here. Noted in `AGENTS.md:44`.
2. **React-PDF font registration & CJK fallback stack**`packages/pdf/src/hooks/use-register-fonts.ts:68-133`. Owns standard-PDF-font handling (`isStandardPdfFontFamily`), CJK glyph-level fallback (#2986), and global hyphenation callback. Module-level registration cache (`:18`). Noted in `AGENTS.md:45`.
3. **Resume access policy**`packages/api/src/helpers/resume-access-policy.ts`. Single source of truth for owner-vs-viewer redaction (`name` and `metadata.notes` stripped for non-owners), `NOT_FOUND`-vs-`FORBIDDEN` choice (not-found used to avoid existence disclosure), and self-view statistics exclusion. Owner-only mutations rely on SQL `WHERE userId =` clauses, not this policy — drift between SQL guards and policy is silent.
4. **Resume password cookie**`packages/api/src/helpers/resume-access.ts`. Cookie name `resume_access_{resumeId}`, signed value is `sha256(resumeId:passwordHash)`, TTL 10 minutes, `httpOnly`, `sameSite: lax`, `secure` only when `APP_URL` starts with `https`. Forgetting to set `APP_URL=https://...` in production drops the secure flag.
5. **OAuth `authorize` request sanitization**`apps/web/src/routes/api/auth.$.ts:6-51` strips control chars from OAuth parameters and decodes broken-but-decodable redirect URIs before passing to Better Auth. Easy to bypass if a new OAuth flow is added that does not route through this handler.
6. **Dynamic client registration coercion to public client**`apps/web/src/routes/api/auth.$.ts:53-80` forces `token_endpoint_auth_method = "none"` for unauthenticated registrations (specifically for Claude.ai's MCP onboarding quirk). This is non-standard behaviour buried in a request preprocessor.
7. **Builder draft sync**`apps/web/src/components/resume/builder-resume-draft.ts:46-100` manages per-resume zustand stores keyed by resume id, with debounced patch-and-resync. Failure to clean up `runtimes` Map on resume close = subscription/timer leaks.
---
## Test Coverage Gaps (Priority Map)
| Area | Source files | Test files | Priority |
|------|-------------:|-----------:|----------|
| `apps/web/src/routes/**` | 125 | 1 | **High** — covers all server handlers and the builder shell |
| `apps/web/src/routes/api/**` (rpc/auth/uploads/openapi/mcp) | ~10 | 0 | **High** — security-sensitive route handlers |
| `apps/web/src/components/resume/**` | 5 | 2 | Medium |
| `apps/web/src/dialogs/**` | ~30 | 1 (`store.test.ts`) | Medium |
| `packages/api/src/routers/**` | 7 | 0 (DTO test only) | **High** — auth-protected procedures |
| `packages/api/src/services/**` | 8 | 1 (`ai.test.ts`) | High |
| `packages/auth/src/**` | 3 | 0 | **High** — central auth config never directly tested |
| `packages/email/src/**` | ~5 | 0 | Medium |
| `packages/import/src/**` | several importers | 1 (v4) | Medium |
| `packages/pdf/src/templates/shared/**` | ~20 | 11 | Low (well covered) |
---
*Concerns audit: 2026-05-11*