.planning/codebase/CONCERNS.md
Analysis Date: 2026-05-11
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.
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.
packages/email/src/transport.ts:59-66console.info("SMTP not configured; skipping email send.", {
to: payload.to,
subject: payload.subject,
text: payload.text,
html: payload.html,
});
Impact:
AGENTS.md:101 describe this as a dev convenience; nothing prevents an operator from running production with partial SMTP config.Fix approach:
NODE_ENV === "production", isSmtpEnabled() must be true.text / html from the console.info call — log only to and subject..env.example alongside AUTH_SECRET.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)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:
isRateLimitEnabled = true and provide a RATE_LIMIT_DISABLED opt-out env var, or assert NODE_ENV === "production" at boot when not running tests.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.
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:
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.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.FLAG_DISABLE_IMAGE_PROCESSING on, the same applies to genuine images (no resize/strip-metadata path), so EXIF data is preserved unredacted.Fix approach:
packages/api/src/routers/storage.ts:9) — z.file().mime(["image/png", "image/jpeg", "image/webp", "image/gif"]) plus magic-byte validation.FLAG_DISABLE_IMAGE_PROCESSING=true so it is not enabled in production unknowingly.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).packages/ui, packages/utils, packages/pdf/src/templates/shared, and packages/api (5 tests).Impact:
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.
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.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:
apps/web/src/components/resume/preview.tsx and pdf-canvas.tsx (currently only described in AGENTS.md:35).pdfjs-dist / @react-pdf/renderer is reachable from an SSR-eligible route.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.getStorageService() is captured at module load in routerpackages/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.
RPCHandler instantiated per-requestapps/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.
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 200–300 ms while typing.
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.
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.
verifyPassword rate limit keyed by username:slug:ippackages/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).
as string cast on password hashpackages/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.
TRUSTED_IP_HEADERS is unconditionalpackages/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.
cachedTransport in email/transport.ts ignores env mutationpackages/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.
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.
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/**).
pnpm check is write-capablepackage.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).
drizzle-kit does not auto-load .envpackages/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.
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:
migrate(); Drizzle's migrations table uses transactions to avoid duplicate apply, but the race adds startup latency.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.
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.
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:
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.
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).
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.
getStorageService() cached service singleton in module-load orderpackages/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.
apps/web/src/components/resume/preview.browser.tsx:120:
} 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.
console.warn/error lacks structureEvery 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]").
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.
migrations/ (migrations/20260114102228_* through migrations/20260507144406_*).apps/web/plugins/1.migrate.ts:23-40).Action: Standard for drizzle workflows. Document that downgrade requires manual SQL.
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.| 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:
pnpm knip and pnpm dlx npm-check-updates regularly (both are devDependencies, package.json:34, :40).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.
These are non-obvious surfaces that consumers of this map should know about before changing related code:
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.
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.
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.
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.
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.
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.
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.
| 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