Adds .planning/codebase/ with parallel-mapper outputs covering stack, integrations, architecture, structure, conventions, testing, and concerns.
29 KiB
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#2986again 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
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:101describe this as a dev convenience; nothing prevents an operator from running production with partial SMTP config.
Fix approach:
- Add a server-startup assertion that, if
NODE_ENV === "production",isSmtpEnabled()must be true. - Redact
text/htmlfrom theconsole.infocall — log onlytoandsubject. - Document the production SMTP requirement in
.env.examplealongsideAUTH_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, OAuthregister/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 = trueand provide aRATE_LIMIT_DISABLEDopt-out env var, or assertNODE_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 intouploads/{userId}/pictures/...with the user-claimed content type. - Served back from
apps/web/src/routes/uploads/$userId.$.tsx.X-Content-Type-Options: nosniffis set (:159) and the storage route forcesapplication/octet-streamonly 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_PROCESSINGon, the same applies to genuine images (no resize/strip-metadata path), so EXIF data is preserved unredacted.
Fix approach:
- 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. - Reject upload if claimed MIME does not match the sharp-detected MIME; do not fall back to client claim.
- Surface a startup warning when
FLAG_DISABLE_IMAGE_PROCESSING=trueso it is not enabled in production unknowingly.
Tech Debt: web app has near-zero test coverage
apps/web/srchas 11 test files across ~224 source files (~5%) —find apps/web/src -name "*.test.*".apps/web/src/routeshas 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, andpackages/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) andapps/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:14returnsnulluntiluseIsClient()resolves and then lazy-loads the browser bundle.apps/web/src/components/resume/preview.browser.tsx:2imports@react-pdf/renderer(pdf).apps/web/src/components/resume/pdf-canvas.tsx:1, :4, :9imports PDF.js types/runtime and sets a module-levelGlobalWorkerOptions.workerSrc.apps/web/src/routes/templates/$.tsx:1importsPDFViewerat 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.tsxuses dynamicawait import("pdfjs-dist")— a different convention frompdf-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:
- Document the boundary explicitly at the top of
apps/web/src/components/resume/preview.tsxandpdf-canvas.tsx(currently only described inAGENTS.md:35). - Consider a Vite SSR-externals config that aborts the build if
pdfjs-dist/@react-pdf/rendereris reachable from an SSR-eligible route. - Switch
apps/web/src/routes/templates/$.tsx:1to a dynamicimport("@react-pdf/renderer")inside theuseIsClient()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 200–300 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'smigrationstable 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:
} 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_*throughmigrations/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:24storespasswordas plaintext column typetext, but content is always bcrypt-hashed at write (packages/api/src/services/resume.ts:453). Column name is misleading — should bepassword_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 knipandpnpm dlx npm-check-updatesregularly (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:
-
PDF section filtering —
packages/pdf/src/templates/shared/filtering.ts:25-60decides 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 inAGENTS.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 inAGENTS.md:45. -
Resume access policy —
packages/api/src/helpers/resume-access-policy.ts. Single source of truth for owner-vs-viewer redaction (nameandmetadata.notesstripped for non-owners),NOT_FOUND-vs-FORBIDDENchoice (not-found used to avoid existence disclosure), and self-view statistics exclusion. Owner-only mutations rely on SQLWHERE userId =clauses, not this policy — drift between SQL guards and policy is silent. -
Resume password cookie —
packages/api/src/helpers/resume-access.ts. Cookie nameresume_access_{resumeId}, signed value issha256(resumeId:passwordHash), TTL 10 minutes,httpOnly,sameSite: lax,secureonly whenAPP_URLstarts withhttps. Forgetting to setAPP_URL=https://...in production drops the secure flag. -
OAuth
authorizerequest sanitization —apps/web/src/routes/api/auth.$.ts:6-51strips 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-80forcestoken_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-100manages per-resume zustand stores keyed by resume id, with debounced patch-and-resync. Failure to clean upruntimesMap 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