Compare commits

...
654 Commits
Author SHA1 Message Date
Amruth Pillai 712298843b chore(release): v5.3.2 (#3542) 2026-09-26 02:55:57 +02:00
Amruth Pillai 8c40313980 feat(deploy): support Vercel Hobby alongside Docker (#3541)
* feat(deploy): support Vercel Hobby alongside Docker

* fix(deploy): include PDFKit runtime font assets

* docs(deploy): document Vercel and Docker setup

* docs(deploy): record storage persistence checks

* refactor(deploy): drop scheduled staging cleanup

Staging uploads are deleted after finalization and expired ones are swept
on each new upload, so the Vercel cron job, its route, and CRON_SECRET are
no longer needed. The Deploy with Vercel wizard now asks for two secrets.

* docs(deploy): restructure Vercel guides

Split the Vercel page into a how-to with its environment reference, move the
large RPC staging protocol to an API reference page, and move CI deployment
checks to the contributing section. Point Deploy with Vercel buttons at main.

* chore: remove agent planning records and fix web app description

Delete superpowers plans/specs, ADRs, issue plans, execution briefs, domain
context maps, and Europass research. Describe apps/web as a TanStack Router
SPA served by apps/server.

* refactor(deploy): simplify Vercel support code

- Share one Redis client and key namespace through @reactive-resume/db/redis
  for API and auth instead of a second auth-only client.
- Drop the auth seeding retry; the provider already treats concurrent inserts
  as no-ops and deployment preparation seeds before runtime.
- Detect staging support from POST /api/storage/stage (404 on Docker) instead
  of a separate GET probe.
- Read staged bodies directly; the signed upload already caps their size.
- Close per-subscription Redis connections with disconnect() alone.
- Check Blob health with one list call instead of write/read/delete.
- Remove redundant tsdown onlyBundle list, dead namespace fallbacks, and the
  conditional spread in the health status.

* fix(deploy): heal stopped runs with dead owners and keep auth up without Redis

- Run owners refresh a Redis heartbeat until they release their claim. Stop
  requests reap the run immediately when the owner has stopped heartbeating,
  instead of leaving the thread blocked until the 15-minute TTL reaper.
- Auth and oRPC rate limiters fall back to per-instance memory limits when
  Redis errors, instead of rejecting every login or failing requests.

* ci: allow esbuild build for Vercel CLI and register deployment deps with knip

pnpm 12 fails dlx installs with ignored build scripts, so allow esbuild
explicitly. The server bundle keeps @vercel/blob, ioredis, and jose external,
and api/index.mjs is the Vercel Function entry.

* fix(web): send buffered RPC bodies instead of teed streams

Reading a request clone turned the original body into a stream, which
browsers send without inspectable request data and which needs duplex
mode. Send the already buffered Blob for direct requests.

* fix(web): send direct RPC bodies as bytes

Blob request bodies are sent as data pipes, so browser tooling cannot
inspect them. Buffer the original request as an ArrayBuffer and send those
bytes; this restores the e2e save assertions that match on request data.
2026-09-26 02:37:22 +02:00
Amruth Pillai 73ed3f9b03 chore(server): update version from 5.2.2 to 5.3.1 2026-09-23 00:12:42 +02:00
Lihan YANGandAmruth Pillai f0bc26cb3d fix(ci): restore Docker publishing with portable runner fallbacks (#3533)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-09-22 17:07:28 +02:00
s3kfm 0ac320b0e9 fix(web): enabled drag and drop by moving file input on top of the button (#3529) 2026-09-22 14:32:34 +02:00
PerryLinkandAmruth Pillai d3131e0977 fix(import): reject out-of-range months that render as "undefined" (#3527)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-09-21 19:47:02 +02:00
Santhi PrakashandAmruth Pillai 28d0170b05 fix(resume): detect section headings set in a side column (#3521)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-09-21 19:46:46 +02:00
Santhi PrakashandAmruth Pillai ac69dd3f1a fix(server): verify migrated schema at startup (#3513)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-09-21 19:46:36 +02:00
Amruth Pillai 3d4ae8679a Update Star History chart sources in README 2026-09-21 16:30:51 +02:00
Amruth Pillai 4fde62df6d chore: update dependencies 2026-09-19 22:05:55 +02:00
Amruth Pillai b5ff720f9d chore: update translations 2026-09-17 22:27:22 +02:00
Amruth Pillai b953435f2c fix: audit code for reduction 2026-09-17 22:16:44 +02:00
Amruth Pillai a30bf371ff docs: add IDEA.md as a symlink to AGENTS.md 2026-09-17 21:16:00 +02:00
Amruth Pillai 582a6fb429 fix(web): preserve dialogs opened during close animations
Opening another dialog during the previous dialog's 300 ms close animation could clear the new dialog and its close handler. This caused the post-merge dashboard lifecycle test to lose the Duplicate Resume dialog after renaming a resume.

- Scope delayed cleanup to the original dialog and require it to remain closed.
- Add regression coverage for both open and closing replacement dialogs; both cases failed before the fix and pass afterward.
- Include the fix in the v5.3.1 release notes.

Validation: `pnpm check`, `pnpm typecheck`, `pnpm test`, and the focused dialog-store suite (12 passing tests).
2026-09-17 12:19:06 +02:00
Amruth Pillai 24d9e5fb5c chore: release v5.3.1
Prepare v5.3.1 with dashboard search and thumbnail improvements, PDF layout fixes, cover-letter integrations, and self-hosting updates.

- Bump the root version and add release notes with contributor credits, cover-letter REST migration instructions, and the new GHCR image path.
- Align the dashboard authentication plugin with Better Auth's fetch dependency to restore auth-client type inference.
- Regenerate the OpenAPI specification so published validation limits match runtime schemas.

Validation: `pnpm lingui:extract` (no missing translations), `pnpm check`, `pnpm typecheck`, and `pnpm test`.
2026-09-17 12:02:03 +02:00
Emanuele Tonello 2a2d08a8d2 fix(web): keep resume search local (#3510) 2026-09-17 00:14:42 +02:00
Emanuele TonelloandAmruth Pillai b42eb6ec06 fix: stop application search session refetches (#3507)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-09-17 00:13:33 +02:00
Emanuele TonelloandAmruth Pillai fbf1f8fbac docs(api): describe cover letter endpoints (#3509)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-09-17 00:13:21 +02:00
Emanuele TonelloandAmruth Pillai 232f48578b fix(web): cache dashboard resume thumbnails (#3506)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-09-17 00:12:05 +02:00
Emanuele TonelloandAmruth Pillai e6a6bf0e6a feat(mcp): add independent cover-letter tools (#3508)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-09-16 22:08:23 +02:00
Amruth Pillai 96c7142fbc chore: update dependencies 2026-09-16 18:36:50 +02:00
PerryLinkandAmruth Pillai 3c5908819c docs(self-hosting): add Kubernetes self-hosting guide (#3515)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-09-16 18:03:44 +02:00
Lystran 3e129c9d9d fix(web): keep AI provider names untranslated across locales (#3516) 2026-09-16 17:40:43 +02:00
Amruth Pillai fd3494ccac docs: confirm repository migration and current-version redeployment 2026-09-12 11:29:04 +02:00
Amruth Pillai f89acb4368 chore: migrate repository links to reactive-resume/reactive-resume 2026-09-12 11:18:32 +02:00
Amruth Pillai 08e61ded7b Add powered by Blacksmith section to README
Added a powered by Blacksmith image and link to README.
2026-09-11 12:54:59 +02:00
Amruth Pillai f1d5c6bab4 ci: use reachable Ubuntu mirror for Playwright dependencies 2026-09-11 11:40:03 +02:00
Amruth Pillai e3717251cb docs: switch to verified public GHCR images 2026-09-11 11:21:49 +02:00
Amruth Pillai 30b21fa1e3 ci: verify anonymous container pulls before deployment 2026-09-11 11:17:10 +02:00
Amruth Pillai d77cb93494 fix: complete repository links and container publishing migration 2026-09-11 11:09:55 +02:00
Amruth Pillai ce996349fa Merge branch 'codex/repository-migration' 2026-09-11 10:53:08 +02:00
Amruth Pillai a62ee22f20 ci: use 32-vCPU Blacksmith runners 2026-09-11 09:51:52 +02:00
Amruth Pillai 0a4608bf9d ci: trigger 2026-09-11 03:26:42 +02:00
Amruth Pillai 9550910f17 revert: remove repository migration changes from main 2026-09-11 03:23:27 +02:00
Amruth Pillai 4076b1a523 ci: use Blacksmith runners and native multi-architecture Docker builds 2026-09-11 03:16:11 +02:00
Amruth Pillai 9699dbf2d8 ci: publish nightly images for amd64 and arm64 2026-09-11 03:02:26 +02:00
Amruth Pillai 31d6ee6251 chore: prepare repository migration and Docker Build Cloud publishing 2026-09-11 02:55:52 +02:00
Amruth Pillai d9fdf7a30a docs: announce planned repository and GHCR migration 2026-09-11 01:46:59 +02:00
Amruth Pillai 81341a107f fix(mcp): align tool annotations and descriptions with behavior 2026-09-11 00:31:58 +02:00
Amruth Pillai 3fc0896a34 fix(auth): honor client-requested token_endpoint_auth_method during DCR
Dynamic client registration unconditionally rewrote token_endpoint_auth_method
to "none" for every unauthenticated request, downgrading clients that asked for
client_secret_basic or client_secret_post to public clients. Those clients were
issued no client_secret but still authenticated at the token endpoint with the
method they registered, so the exchange failed with 401 invalid_client.
Connecting Composio to the MCP server hit this on every attempt.

Default to "none" only when the client omits the field, which keeps PKCE-only
MCP clients working while confidential clients receive a usable secret.
2026-09-10 12:47:52 +02:00
Amruth PillaiandClaude Fable 5.1 7aaed8e30b chore: pin Node.js runtime and make root TS strict mode explicit (#3501)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-10 11:15:38 +02:00
Amruth Pillai 730f795073 fix(pdf): pin @napi-rs/canvas to 1.0.8
1.0.9 rejects the Path2D objects pdfjs-dist 6.3.289 passes to fill/clip,
failing every raster test with "Value is none of these types `String`, `Path`".
2026-09-10 00:07:23 +02:00
Amruth Pillai dc8f9787a4 chore: update dependencies 2026-09-09 23:57:37 +02:00
Amruth Pillai 742526af53 chore: drop duplicated overrides and patchedDependencies from root package.json
pnpm reads audit overrides and patch mappings from pnpm-workspace.yaml, which
already carries both. The top-level package.json copies were npm-shaped fields
that pnpm never consults, and they had already drifted: the workspace file maps
'@react-pdf/textkit' unversioned while the package.json copy pinned 7.0.1.

pnpm install --frozen-lockfile still passes with pnpm-lock.yaml unchanged, and
all four patches remain applied at their recorded hashes, which is what shows
the removed block was inert.
2026-09-09 13:04:19 +02:00
Amruth Pillai 812d396120 test(pdf): compare raster baselines only on their authoring platform
Adding packages/pdf to CI turned it red on ubuntu-latest for two reasons,
neither of which is a real regression.

Rasterized pixels depend on the host font rasterizer. The chikorita, ditto
and all-template baselines differ on Linux in rasterSha256 alone: every page
count, item count and text coordinate is byte-identical to the macOS-authored
baseline. Compare the portable geometry on every host and the pixels only on
the platform the PNGs were generated on, so the characterization keeps
protecting layout without asserting another machine's antialiasing.

The picture-fit override case rasterizes twice and timed out at Vitest's 5s
default on a CI runner, with the date suite landing at 3.5-4.7s. Give the
package a 30s timeout rather than leaving every rendering test a runner
slowdown away from failing.

Verified on linux/amd64 in Docker: both files pass, 18/18.
2026-09-09 12:46:41 +02:00
Amruth Pillai 1106562169 test(pdf): refresh Lapras date-layout baseline and run all packages in CI
The Lapras section marginTop added in c0c658c0 shifted every date marker in
that template down the page, but the date-layout characterization baseline
was not regenerated, so packages/pdf has been failing on main since. The
delta is geometry-only: same page count (2), same text item count (87), same
markers, x and width unchanged; 17 markers move on y and the raster hashes
follow.

Nothing caught it because the workflow ran test:ci for only server and
tooling, leaving 17 packages uncovered. Replace that filter list with the
full workspace run so a package cannot silently lose coverage again, and
move it after the migration and storage steps that the api suite needs.

Serial execution is deliberate. Running the packages in parallel oversubscribes
the runner and starves the PDF rasterization and API rate-limit suites past
their timeouts; 19/19 pass consistently at --concurrency=1.
2026-09-09 12:33:04 +02:00
Amruth Pillai 607eafd3e8 chore(deps): bump ai-sdk, aws-sdk, react-email and tooling dependencies
Patch and minor bumps across the AI provider SDKs (@ai-sdk/*, ai),
@aws-sdk/client-s3, react-email/@react-email/ui, knip and jszip, with
pnpm-lock.yaml regenerated to match.

Also records the audit overrides and patched dependencies in the root
package.json alongside the existing pnpm-workspace.yaml entries.
2026-09-09 12:16:20 +02:00
Amruth PillaiandClaude Opus 5 ffe889b832 test: remove flaky slow-save navigation e2e test
The "stops waiting for a slow save while preserving late acknowledgements
and queued edits" test races Playwright's fake clock against real debounce
and network timing, and has failed intermittently on main and in PRs since
it landed. Six prior stabilization attempts, including bumping its timeout
to 60s, did not hold; the latest run on main still exceeded that budget.

The same behavior is covered deterministically with fake timers in
apps/web/src/features/resume/builder/draft.test.ts ("ends a stalled
navigation wait without aborting or discarding the pending save", plus
the queued-edit and pending-snapshot cases), so removing the e2e test
loses no coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018z9CKmSSEuS2UFMoqhHWtQ
2026-09-09 12:09:54 +02:00
Santhi Prakashcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>Amruth Pillai
51ac77295e fix(pdf): preserve list indentation on continuation pages (#3495) (#3497)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-09-09 11:55:48 +02:00
Diego Vega Centeno c0c658c00c fix(pdf): add marginTop to style of "section" in "Lapras" template (#3498) 2026-09-09 11:35:23 +02:00
Amruth Pillai 6416da28a4 feat(homepage): rebuild landing page and fix untranslated homepage strings in 16 locales (#3496) 2026-09-08 18:01:59 +02:00
Amruth Pillai 614a1ff9df test: wait for recovery state persistence 2026-09-07 23:06:19 +02:00
Amruth Pillai 4f60856706 refactor: remove unused UI and runtime scaffolding 2026-09-07 22:40:50 +02:00
Amruth Pillai ad91a0838c docs: remove build with ona 2026-09-07 10:00:10 +02:00
Amruth Pillai 15d6443b4f chore: update translations 2026-09-06 21:38:58 +02:00
Amruth Pillai 1f8c46b4f1 test: bump timeout for slow-save navigation e2e test
Default 30s budget was too tight for this multi-step test (resume
creation, warm-up save, fake-clock save juggling, re-navigation),
causing a CI timeout that surfaced as a generic closed-context error
rather than a real assertion failure.
2026-09-06 21:38:27 +02:00
Amruth Pillai de9a6dcfad fix: add missing twitter:url meta tag across social meta sites
Also fill in twitter:title/twitter:description on the ATS checker page,
matching its existing og: tags.
2026-09-06 21:27:40 +02:00
Amruth Pillai e19f706efd docs: expand v5.3.0 changelog
Expand the v5.3.0 changelog with shipped features, issue-linked fixes, OAuth consent details, and contributor credits.
2026-09-06 10:06:25 -07:00
Amruth Pillai 86a72bef13 chore: release v5.3.0
Release v5.3.0 with cover letter improvements, accessible resume outlines, original photo upload support, and maintenance updates.
2026-09-06 08:39:25 -07:00
Amruth Pillai d915ba3670 chore: remove Atlas Cloud sponsor placement 2026-09-06 16:59:14 +02:00
Amruth Pillai e272037bec chore: update dependencies 2026-09-06 16:42:58 +02:00
Amruth Pillai a3784558b7 feat: add cover letter builder rail shortcut 2026-09-06 15:45:58 +02:00
Amruth Pillai e0648e840a test: stabilize CI E2E suite 2026-09-06 15:24:23 +02:00
Amruth Pillai 858c8ae88a test: use pointer events for blocked navigation 2026-09-06 12:53:42 +02:00
Amruth Pillai 07ca5d7c9e test: use native click for blocked navigation 2026-09-06 12:46:27 +02:00
Amruth Pillai f573bf5998 test: dispatch blocked builder navigation clicks 2026-09-06 12:39:00 +02:00
Amruth Pillai f3622e8753 test: disable auto-wait for blocked navigation 2026-09-06 12:32:13 +02:00
Amruth Pillai d7b2a843ca test: avoid awaiting blocked builder navigation 2026-09-06 12:25:15 +02:00
Amruth Pillai d277518d28 chore: update translations 2026-09-06 12:05:09 +02:00
Amruth Pillai df2e21ef9e fix: link cover letters to templates and tidy builder UI
- Fix Grid/Compact/List tab overlap on the resumes dashboard: the fixed
  three-column grid forced cells narrower than their labels, so tab content
  spilled into neighboring cells.
- Replace the "Resume styling" resume picker with a template picker in the
  cover-letter create form and editor. The API accepts a `template` on create
  and update, and refreshing style from a resume no longer overwrites it. The
  resume control remains in the editor as "Sender details" since it is the
  only source for the letter header.
- Remove the cover-letter library button from the builder sidebar and add an
  "Import from library" option to the create-cover-letter dialog. Resume to
  library copying stays in the library with its own resume picker.
- Remove the authored-pages/PDF-overflow note from the layout sidebar.
2026-09-06 10:26:12 +02:00
autofix-ci[bot] e2cb6f111f [autofix.ci] apply automated fixes 2026-09-06 07:18:41 +00:00
Amruth Pillai 52949fcb4a fix: title-case the Cover Letters label
Align the dashboard sidebar, route header, library dialog title, docs, and
the E2E selectors that match them.
2026-09-06 09:17:45 +02:00
Amruth Pillai 55f6253603 test: trim the E2E suite to speed up CI
Remove the Semantic CSS acceptance suite (six specs, fifteen visual
baselines, and its fixtures) along with the --grep-invert that excluded it
from CI. With the serial PDF preflight gone, Playwright can run four
workers in CI instead of one.

Also drop the slowest and most redundant specs: PDF raster direction,
thumbnail resolution, import reproduction, imported tables, picture
rendering, and literal whitespace, plus the basic authored-page guidance,
settings profile, and resume lifecycle checks already covered elsewhere.
Trim the OAuth consent matrix to allow and deny on an existing session.
2026-09-06 09:17:36 +02:00
Amruth Pillai cea27a97bb fix(auth): align account schema with Better Auth 1.7.3 (#3488) 2026-09-06 00:08:48 -07:00
Amruth Pillai 7fef84078d chore: update dependencies 2026-09-06 08:46:54 +02:00
Amruth Pillai a1611c3b80 Merge pull request #3485 from amruthpillai/codex/issue-execution-ledger
docs: finalize approved issue execution ledger
2026-09-05 22:44:52 -07:00
Amruth Pillai 54366c5d29 docs: link final ledger refresh 2026-09-06 07:38:09 +02:00
Amruth Pillai 64f68a12be docs: finalize approved issue execution ledger 2026-09-06 07:37:35 +02:00
Amruth Pillai 778fd4b7d9 Merge pull request #3484 from amruthpillai/codex/fix-geometry-e2e-opt-in
test: opt in preview export geometry E2E
2026-09-05 22:35:08 -07:00
Amruth Pillai 26f2360cf0 test: opt in preview export geometry E2E 2026-09-06 07:30:49 +02:00
Amruth Pillai 42527ad83b Merge pull request #3455 from amruthpillai/codex/approved-issue-execution-plans
docs: publish approved execution plans for 63 audited issues
2026-09-05 22:25:06 -07:00
Amruth Pillai 86e200a4da docs: remove retired-plan execution authorization 2026-09-06 07:22:24 +02:00
Amruth Pillai 483b7a89b2 docs: retire legacy-link execution plan 2026-09-06 07:18:05 +02:00
Amruth Pillai 981d7581f5 Merge pull request #3456 from amruthpillai/codex/issue-execution-ledger
docs: track approved issue plan execution
2026-09-05 22:16:15 -07:00
Amruth Pillai 138f3bbd12 docs: record completion rereview 2026-09-06 07:16:01 +02:00
Amruth Pillai ea9632d1b1 docs: close completion audit gaps 2026-09-06 07:13:52 +02:00
Amruth Pillai c6746fd9a9 docs: record geometry diagnostic merge 2026-09-06 07:05:30 +02:00
Amruth Pillai 11d619d3d9 Merge pull request #3483 from amruthpillai/codex/issue-2683-preview-export-geometry
test: measure preview and export geometry
2026-09-05 22:04:17 -07:00
Amruth Pillai 25e044c86c Merge remote-tracking branch 'origin/main' into codex/issue-2683-preview-export-geometry 2026-09-06 06:59:34 +02:00
autofix-ci[bot] f447f429a9 [autofix.ci] apply automated fixes 2026-09-06 04:59:17 +00:00
Amruth Pillai 20cdb95caa docs: record ATS diagnostic merge 2026-09-06 06:59:10 +02:00
Amruth Pillai 5f5dca8445 test: harden preview export geometry diagnostics 2026-09-06 06:59:00 +02:00
Amruth Pillai 10eb3bdbc7 Merge pull request #3482 from amruthpillai/codex/issue-2845-ats-export-evaluation
test: measure ATS export extraction
2026-09-05 21:58:27 -07:00
Amruth Pillai d17e188b03 test(tooling): cover visible website labels 2026-09-06 06:57:08 +02:00
Amruth Pillai 0e5994f243 test(tooling): harden ATS export evaluation 2026-09-06 06:44:48 +02:00
Amruth Pillai 75d102718d docs: record hosted rerun evidence 2026-09-06 06:44:45 +02:00
Amruth Pillai 61526094d5 docs: record geometry diagnostic findings 2026-09-06 06:39:42 +02:00
Amruth Pillai d409b3bef4 docs: record geometry diagnostic review 2026-09-06 06:33:48 +02:00
Amruth Pillai 69d2a35cdc Merge remote-tracking branch 'origin/main' into codex/issue-2683-preview-export-geometry 2026-09-06 06:33:08 +02:00
Amruth Pillai ce372b54bb test: measure preview and PDF export geometry 2026-09-06 06:32:42 +02:00
Amruth Pillai b9a4397c93 docs: record ATS evaluation findings 2026-09-06 06:30:47 +02:00
Amruth Pillai d4fba09741 docs: record ATS evaluation review 2026-09-06 06:24:54 +02:00
Amruth Pillai b53789964f Merge remote-tracking branch 'origin/main' into codex/issue-2845-ats-export-evaluation 2026-09-06 06:24:06 +02:00
Amruth Pillai f89873f083 test: evaluate ATS PDF and DOCX extraction 2026-09-06 06:23:16 +02:00
Amruth Pillai 1653d04c3f docs: record accessibility HTML merge 2026-09-06 06:21:41 +02:00
Amruth Pillai 3e62a1d604 Merge pull request #3481 from amruthpillai/codex/issue-2844-accessibility
feat(web): improve accessible resume outline
2026-09-05 21:19:36 -07:00
Amruth Pillai acd2a9cfe9 fix(web): close accessibility outline gaps 2026-09-06 06:18:19 +02:00
Amruth Pillai 3987254061 docs: record geometry diagnostic dispatch 2026-09-06 06:09:54 +02:00
Amruth Pillai 903f9280d5 Merge remote-tracking branch 'origin/main' into codex/issue-2844-accessibility 2026-09-06 06:08:23 +02:00
Amruth Pillai bc620b2783 docs: record date layout characterization merge 2026-09-06 06:07:44 +02:00
Amruth Pillai 9f0202eace feat(web): improve accessible resume outline 2026-09-06 06:07:40 +02:00
Amruth Pillai cdb7bdd2fe Merge pull request #3480 from amruthpillai/codex/issue-3155-date-layout-characterization
test(pdf): characterize date layout geometry
2026-09-05 21:07:11 -07:00
Amruth Pillai 77a5499881 Merge remote-tracking branch 'origin/main' into codex/issue-3155-date-layout-characterization 2026-09-06 06:06:19 +02:00
Amruth Pillai 5aeefa6dff docs: record export evaluation dispatch 2026-09-06 06:05:46 +02:00
Amruth Pillai 1232d5dfb2 test(pdf): enforce date layout baselines 2026-09-06 06:05:04 +02:00
autofix-ci[bot] e71b5e6e91 [autofix.ci] apply automated fixes 2026-09-06 04:03:27 +00:00
Amruth Pillai ef36b76017 docs: record offline font diagnostic merge 2026-09-06 06:03:14 +02:00
Amruth Pillai f783908b0e Merge pull request #3479 from amruthpillai/codex/issue-3377-offline-font-diagnostic
test(e2e): add offline font diagnostic gates
2026-09-05 21:02:40 -07:00
Amruth Pillai 397d9e43ba Merge remote-tracking branch 'origin/main' into codex/issue-3377-offline-font-diagnostic 2026-09-06 06:01:51 +02:00
Amruth Pillai 313cfab631 test: close offline font diagnostic review gaps 2026-09-06 06:00:59 +02:00
Amruth Pillai 4d593922e3 docs: record successful hosted reruns 2026-09-06 06:00:51 +02:00
Amruth Pillai 6ee4ee3a4c docs: record pagination diagnostic merge 2026-09-06 06:00:24 +02:00
Amruth Pillai 5e8284e49f Merge pull request #3478 from amruthpillai/codex/issue-3350-item-pagination
test(pdf): characterize safe item pagination boundary
2026-09-05 20:58:27 -07:00
Amruth Pillai f2769dce54 test(pdf): strengthen item pagination coverage 2026-09-06 05:56:20 +02:00
Amruth Pillai 001ca16cad Merge remote-tracking branch 'origin/main' into codex/issue-3377-offline-font-diagnostic 2026-09-06 05:49:15 +02:00
Amruth Pillai 30f4edf45d test(pdf): characterize item pagination blocker 2026-09-06 05:47:56 +02:00
Amruth Pillai c8a10b3d3b test: harden offline font raster evidence 2026-09-06 05:47:56 +02:00
Amruth Pillai 58ee4eead7 Merge remote-tracking branch 'origin/main' into codex/issue-3155-date-layout-characterization 2026-09-06 05:47:24 +02:00
Amruth Pillai f97d1b736e test(pdf): characterize date layout issues 3155 2841 2026-09-06 05:46:38 +02:00
Amruth Pillai 63d6f3936d docs: record offline font raster findings 2026-09-06 05:39:38 +02:00
Amruth Pillai 9ea9318303 docs: record section heading merge 2026-09-06 05:35:36 +02:00
Amruth Pillai 368858a56f feat(resume): add per-section heading visibility (#3477) 2026-09-05 20:34:21 -07:00
Amruth Pillai f39c1d604c docs: start date-layout characterization 2026-09-06 05:32:24 +02:00
Amruth Pillai e73a5610be docs: record imported-table CI repair 2026-09-06 05:31:08 +02:00
Amruth Pillai ae8e2f76f1 test: remediate offline font diagnostic review 2026-09-06 05:30:40 +02:00
Amruth Pillai 66c25efe18 test(e2e): scope imported table border geometry (#3476) 2026-09-05 20:30:37 -07:00
Amruth Pillai cf51fb84d7 docs: record active review wave 2026-09-06 05:25:40 +02:00
Amruth Pillai 45fd3fb5e0 docs: advance font diagnostic to review 2026-09-06 05:18:16 +02:00
Amruth Pillai 61b58ae9a3 test: capture offline font network paths 2026-09-06 05:15:24 +02:00
Amruth Pillai b20ac75927 docs: record European chronology research merge 2026-09-06 05:15:06 +02:00
Amruth Pillai 578cb496aa docs(template): propose European chronology layout (#3475)
* docs: research Europass mapping and visual proposal

* docs: remediate Europass overflow artifacts
2026-09-05 20:09:13 -07:00
Amruth Pillai 4a9dced530 docs: record static-analysis follow-up 2026-09-06 05:08:01 +02:00
Amruth Pillai 97f34b7ccd fix(editor): avoid unsafe clipboard parsing pattern (#3474) 2026-09-05 20:06:23 -07:00
Amruth Pillai 2687191041 docs: record Gengar merge and next wave 2026-09-06 05:04:06 +02:00
Amruth Pillai 2a4a1583be fix(pdf): restore Gengar skill rating order (#3473) 2026-09-05 20:00:19 -07:00
Amruth Pillai 3d6fe265a0 docs: record merged import and whitespace units 2026-09-06 04:58:49 +02:00
Amruth Pillai ea97de5ec4 fix(editor): preserve literal rich-text whitespace (#3472)
* fix(web): preserve imported rich-text tables

* fix(web): harden imported table preservation

* fix(web): fail closed on lossy table markup

* chore: remove plan 16 evidence reports

* fix(web): close imported table preservation gaps

* fix(editor): preserve literal rich-text whitespace

* chore: remove plan 19 evidence report

* fix: preserve literal whitespace through layout and editor transforms

* fix: preserve whitespace in bare table cells

* chore: keep orchestration evidence untracked
2026-09-05 19:57:50 -07:00
Amruth Pillai a6057abd79 test(import): reproduce and harden resume imports (#3471) 2026-09-05 19:57:01 -07:00
Amruth Pillai 137587ebc0 docs: record import publication and active fixes 2026-09-06 04:53:57 +02:00
Amruth Pillai 6ca0f2416e docs: record accelerated execution progress 2026-09-06 04:45:50 +02:00
Amruth Pillai 744eaa902e feat(sharing): serve a configured public resume at root (#3470) 2026-09-05 19:42:44 -07:00
Amruth Pillai 870388192e feat(resume): add skill keyword list presentation (#3469)
* feat(resume): add skill keyword list presentation

* test(schema): refresh generated references
2026-09-05 19:41:14 -07:00
Amruth Pillai 8c6cb46597 docs: record merged and published plans 2026-09-06 04:33:57 +02:00
Amruth Pillai 38832014b9 fix(stylesheet): improve unsupported-gradient diagnostics (#3468)
* test(stylesheet): strengthen basics diagnostics

* fix(css): tighten gradient diagnostics
2026-09-05 19:29:21 -07:00
Amruth Pillai 0fbeeeb4c4 feat(builder): explain authored page overflow (#3467)
* feat(web): explain authored page overflow

* docs: record plan 23A verification

* chore: remove plan 23A evidence report

* test(pdf): assert authored continuation placement
2026-09-05 19:26:50 -07:00
Amruth Pillai 78e16e4195 docs: record active review lanes 2026-09-06 04:25:38 +02:00
Amruth Pillai ccd34e4278 docs: update execution ledger progress 2026-09-06 04:13:38 +02:00
Amruth Pillai b85d285b69 feat(builder): add one-shot section date sorting (#3465)
* feat(builder): add one-shot section date sorting

* docs: record plan 32 implementation evidence

* chore: remove plan 32 evidence report
2026-09-05 19:06:21 -07:00
Amruth Pillai 836ed5db48 docs: record latest execution publications 2026-09-06 03:58:31 +02:00
Amruth Pillai 19966c52fa Merge remote-tracking branch 'origin/main' into codex/issue-execution-ledger 2026-09-06 03:56:40 +02:00
Amruth Pillai 695cdb8514 test(recovery): refresh hashes for picture fit default (#3466) 2026-09-05 18:50:31 -07:00
Amruth Pillai 999cd618cb feat(web): preserve editable imported tables (#3464)
* fix(web): preserve imported rich-text tables

* fix(web): harden imported table preservation

* fix(web): fail closed on lossy table markup

* chore: remove plan 16 evidence reports

* fix(web): close imported table preservation gaps
2026-09-05 18:41:38 -07:00
Amruth Pillai b8b03c8be0 docs: record first merged execution batch 2026-09-06 03:37:58 +02:00
Amruth Pillai bc8a912ce7 Merge remote-tracking branch 'origin/main' into codex/issue-execution-ledger 2026-09-06 03:35:45 +02:00
Amruth Pillai ab67831e4b feat: add cover and contain picture fitting (#3461)
* feat: add picture fit options

* fix: harden picture fit regressions

* fix: address picture fit review findings
2026-09-05 18:33:35 -07:00
Amruth Pillai 5850230f89 feat(builder): add hidden section recovery (#3462)
* feat(builder): recover hidden sections

* fix(builder): reopen hidden section recovery
2026-09-05 18:33:11 -07:00
Amruth Pillaiandautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> 549135bb36 feat: add guarded resume recovery comparison tooling (#3460)
* feat: add synthetic resume recovery procedure

* fix: harden resume recovery comparison

* fix: validate recovery objects before serialization

* fix: require serialized recovery requests

* fix: reject ambiguous recovery requests

* [autofix.ci] apply automated fixes

* fix: reject format characters in recovery IDs

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-09-05 18:32:51 -07:00
Amruth Pillai 8c5804ed05 docs: explain current AI tailoring workflow (#3459)
* docs: explain current resume tailoring workflow

* docs: clarify AI tailoring guidance

* docs: align AI review terminology
2026-09-05 18:32:28 -07:00
Amruth Pillai 772bf14525 docs: explain local Git backup workflow (#3458)
* docs: explain local Git backup workflow

* docs: correct export and version history details

* docs: show how to select backup revisions

* docs: save recovered backup revision to file

* docs: clarify recovered backup filename
2026-09-05 18:32:07 -07:00
Amruth Pillai ee52636c10 docs: clarify separate PostgreSQL self-hosting (#3457)
* docs: clarify separate PostgreSQL self-hosting

* docs: scope app updates away from PostgreSQL

* docs: clarify safe Compose update paths

* docs: separate repository update instructions
2026-09-05 18:31:46 -07:00
Amruth Pillai 2e711fd14c fix(web): render thumbnails at displayed pixel density (#3454)
* fix(web): render thumbnails at displayed pixel density

* fix: cancel obsolete thumbnail raster work
2026-09-05 18:31:21 -07:00
Amruth Pillai a4bdc54b2c fix(builder): save pending drafts before navigation (#3453)
* fix(builder): save pending drafts before navigation

* fix(builder): bound navigation waits for slow saves
2026-09-05 18:31:10 -07:00
Amruth Pillai 8b5399aa6d docs: record plans 10 and 16 publication 2026-09-06 03:26:37 +02:00
Amruth Pillai 4a407fdd87 docs: publish plan 20A and streamline reviews 2026-09-06 02:57:59 +02:00
Amruth Pillai d25f1bb815 docs: queue plan 16 publication rereview 2026-09-06 02:49:39 +02:00
Amruth Pillai 22831058b1 docs: queue plan 20A final rereview 2026-09-06 02:48:06 +02:00
Amruth Pillai cc76138197 docs: record plan 16 preservation fix 2026-09-06 02:46:15 +02:00
Amruth Pillai 43136b7acd docs: record plan 20A navigation findings 2026-09-06 02:43:05 +02:00
Amruth Pillai 8a71a7fbaf docs: record plan 10 transaction findings 2026-09-06 02:38:54 +02:00
Amruth Pillai 3bdf14b1d2 docs: close plan 15 hosted cycle 2026-09-06 02:34:46 +02:00
Amruth Pillai 21ba966d4e docs: queue plan 15 hosted finalization 2026-09-06 02:31:28 +02:00
Amruth Pillai 6a71b91063 docs: record plan 16 validator ruling 2026-09-06 02:29:17 +02:00
Amruth Pillai 93623d8b79 docs: record plan 16 preservation gaps 2026-09-06 02:25:30 +02:00
Amruth Pillai 08f88d964a docs: queue plan 10 independent review 2026-09-06 02:24:01 +02:00
Amruth Pillai ec6747b90e docs: record plan 15 hosted fixes 2026-09-06 02:21:58 +02:00
Amruth Pillai 7d87847ead docs: queue plan 16 final rereview 2026-09-06 02:12:23 +02:00
Amruth Pillai d5c1febc58 docs: record plan 15 hosted findings 2026-09-06 02:06:56 +02:00
Amruth Pillai 18bbee8d22 docs: queue plan 20A independent review 2026-09-06 01:59:03 +02:00
Amruth Pillai 74936f2674 docs: close plan 02 hosted review cycle 2026-09-06 01:57:18 +02:00
Amruth Pillai 1051751351 docs: correct plan 20A issue scope 2026-09-06 01:55:41 +02:00
Amruth Pillai bbf9ffbc01 docs: record plan 15 hosted findings 2026-09-06 01:53:48 +02:00
Amruth Pillai 1178c1d9b3 docs: align plan 16 fix with approved scope 2026-09-06 01:52:42 +02:00
Amruth Pillai 7a9106414e docs: record plan 02 fix publication 2026-09-06 01:51:08 +02:00
Amruth Pillai cb94e6621c docs: record plan 16 review findings 2026-09-06 01:46:16 +02:00
Amruth Pillai f2893fd677 docs: queue plan 15 hosted review 2026-09-06 01:44:41 +02:00
Amruth Pillai 02b53ee3d2 docs: record plan 02 hosted fix review 2026-09-06 01:40:50 +02:00
Amruth Pillai b9da6ed587 docs: record plans 15 16 and 20 progress 2026-09-06 01:38:48 +02:00
Amruth Pillai 0384989c43 docs: record plan 07 hosted completion 2026-09-06 01:29:33 +02:00
Amruth Pillai cc36be9fd7 docs: record plan 02 hosted review disposition 2026-09-06 01:28:56 +02:00
Amruth Pillai 12046ead9e docs: record plan 07 corrected publication 2026-09-06 01:21:25 +02:00
Amruth Pillai 4a803e0c04 docs: record plan 02 hosted findings 2026-09-06 01:19:50 +02:00
Amruth Pillai 2e742da698 docs: queue plan 02 autofix review 2026-09-06 01:17:13 +02:00
Amruth Pillai 5f53956fae docs: queue plan 15a final rereview 2026-09-06 01:15:51 +02:00
Amruth Pillai 9d33aa6d44 docs: record plan 02 publication 2026-09-06 01:14:39 +02:00
Amruth Pillai 18a24bdae8 docs: record plan 09 hosted completion 2026-09-06 01:12:37 +02:00
Amruth Pillai 1a602ceafd docs: queue plan 07 rereview and start plan 16 2026-09-06 01:11:33 +02:00
Amruth Pillai f4b16a9adb docs: record plan 09 corrected publication 2026-09-06 01:05:22 +02:00
Amruth Pillai 09cc6cf37a docs: queue plan 02 final parser review 2026-09-06 01:03:36 +02:00
Amruth Pillai 4fe9ab2a0d docs: queue plan 07 late review fix 2026-09-06 01:01:30 +02:00
Amruth Pillai 036829a8c7 docs: record plan 15a review findings 2026-09-06 00:57:12 +02:00
Amruth Pillai 7cea541aef docs: queue plan 09 final rereview 2026-09-06 00:56:08 +02:00
Amruth Pillai 16a27d91b4 docs: record plan 02 final review findings 2026-09-06 00:53:32 +02:00
Amruth Pillai 451f3204d0 docs: record plan 11 hosted completion 2026-09-06 00:52:13 +02:00
Amruth Pillai 16d4dbefa6 docs: record plan 09 final review finding 2026-09-06 00:50:21 +02:00
Amruth Pillai 1e4d8ddea2 docs: queue final serialized recovery review 2026-09-06 00:44:50 +02:00
Amruth Pillai 23b71e9f99 docs: queue final plan 09 hosted rereview 2026-09-06 00:40:44 +02:00
Amruth Pillai f6fb3d7b75 docs: queue picture fit review 2026-09-06 00:38:04 +02:00
Amruth Pillai 7c4f41d6f1 docs: record recovery contract and backup workflow findings 2026-09-06 00:33:25 +02:00
Amruth Pillai 5c7d03b72d docs: track plan 11 hosted review follow-up 2026-09-06 00:27:41 +02:00
Amruth Pillai 7930d670d1 docs: record plan 07 hosted review resolution 2026-09-06 00:26:10 +02:00
Amruth Pillai 0a68d53f5b docs: record third implementation PR 2026-09-06 00:22:55 +02:00
Amruth Pillai e03dd83e5d docs: track plan 09 hosted review follow-up 2026-09-06 00:18:45 +02:00
Amruth Pillai 30bd8a8e04 docs: queue final plan 02 rereview 2026-09-06 00:17:20 +02:00
Amruth Pillai 156f24063e docs: queue clean plan 11 rereview 2026-09-06 00:15:59 +02:00
Amruth Pillai 9bdde33ddf docs: queue plan 07 hosted review follow-up 2026-09-06 00:14:36 +02:00
Amruth Pillai ad97b8a88c docs: record second published unit and review fixes 2026-09-06 00:14:02 +02:00
Amruth Pillai a5d0527090 docs: track review-ready documentation units 2026-09-06 00:04:55 +02:00
Amruth Pillai 1da0397abf docs: queue retired-link notice unit 2026-09-06 00:00:32 +02:00
Amruth Pillai bcd5cf0ce9 docs: publish first implementation PR 2026-09-05 23:56:34 +02:00
Amruth Pillai 3180672543 docs: record plan 09 review findings 2026-09-05 23:53:49 +02:00
Amruth Pillai e6e11c41b2 docs: track review fixes and re-review 2026-09-05 23:51:57 +02:00
Amruth Pillai 654f8898b6 docs: queue literal whitespace unit 2026-09-05 23:49:33 +02:00
Amruth Pillai 142555302e docs: record plan 07 review finding 2026-09-05 23:47:41 +02:00
Amruth Pillai 0a7b158ee3 docs: queue one-shot sorting unit 2026-09-05 23:46:24 +02:00
Amruth Pillai f01a590389 docs: begin plan 09 review 2026-09-05 23:45:46 +02:00
Amruth Pillai 0a14ca78f7 docs: queue section and pagination units 2026-09-05 23:44:27 +02:00
Amruth Pillai c3d98241a7 docs: begin independent reviews 2026-09-05 23:42:11 +02:00
Amruth Pillai e81de44adf docs: make audit plan discovery executable 2026-09-05 23:39:36 +02:00
Amruth Pillai c87aae562e docs: harden pinned execution briefs 2026-09-05 23:38:40 +02:00
autofix-ci[bot] 18d49376ce [autofix.ci] apply automated fixes 2026-09-05 21:35:56 +00:00
Amruth Pillai c66a15bc68 docs: queue picture and table units 2026-09-05 23:35:00 +02:00
Amruth Pillai 02de0e9fcb docs: record backend audit corrections 2026-09-05 23:32:29 +02:00
Amruth Pillai 39c564cdf1 docs: record rendering and builder audits 2026-09-05 23:30:24 +02:00
Amruth Pillai 6f09cea66d docs: record backend revalidation dispositions 2026-09-05 23:26:03 +02:00
Amruth Pillai 124f9d8a2e docs: define plan 02 recovery brief 2026-09-05 23:24:23 +02:00
Amruth Pillai 22dcb838f0 docs: queue initial documentation units 2026-09-05 23:19:46 +02:00
Amruth Pillai 01f4963762 docs: define plan 09 and 11 briefs 2026-09-05 23:19:22 +02:00
Amruth Pillai 8f7faca67d docs: make execution briefs portable 2026-09-05 23:16:47 +02:00
Amruth Pillai 699229f2c5 docs: record active revalidation wave 2026-09-05 23:14:08 +02:00
Amruth Pillai ddc60756db docs: define plan 07 implementation brief 2026-09-05 23:13:34 +02:00
Amruth Pillai 7c827a42f0 docs: link coordinator ledger PR 2026-09-05 23:11:42 +02:00
Amruth Pillai 04029ec7f5 docs: record live status of existing PRs 2026-09-05 23:11:11 +02:00
Amruth Pillai b852518335 docs: add initial revalidation briefs 2026-09-05 23:09:14 +02:00
Amruth Pillai 9ecf340b9d docs: initialize approved issue execution ledger 2026-09-05 23:07:59 +02:00
Amruth Pillai a2557b2ad4 docs: publish approved plans for 63 audited issues 2026-09-05 22:31:08 +02:00
Amruth Pillai 50f5dd7214 docs: record navigation and thumbnail audit findings 2026-09-05 21:00:39 +02:00
Amruth Pillai 7a98f6662f docs: record Unicode fixes and remaining issue actions (#3452)
* docs: record Unicode fixes and remaining issue actions

* docs: record final Unicode PR merges
2026-09-05 11:40:32 -07:00
Amruth Pillai 05e48a7cbc fix(pdf): preserve authored Unicode spaces in rich text (#3451) 2026-09-05 11:31:03 -07:00
Amruth Pillai d10eb4a55d fix(pdf): isolate cached glyph character metadata (#3450)
* fix(pdf): isolate character metadata for cached font glyphs

* test(pdf): verify glyph aliases do not grow cache

* test(pdf): assert glyph aliases are unique
2026-09-05 11:22:05 -07:00
Amruth Pillai 1536dc48d9 docs: reconcile issue audit and record remaining reproductions (#3444)
* docs: reconcile issue audit with current resolutions

* docs: record pagination PR and new Ditgar reproduction

* docs: reconcile merged fixes and preserve pending scope decisions

* docs: reconcile audit scopes and merged PR states

* docs: record Ditgar fix and verified margin closure

* docs: reconcile incremental audit totals

* docs: record RTL preview fix and latest issue resolutions

* docs: record final audit PR merges

* docs: reconcile final review evidence

* docs: record paragraph indentation and RTL canvas PRs

* docs: record marker fix and Unicode-space reproduction

* docs: record final implementation PR merges
2026-09-05 11:04:15 -07:00
Amruth Pillai 8d4cf8a2f8 fix(pdf): keep ordered list markers clear of body text (#3449)
* fix(pdf): keep ordered list markers clear of body text

* perf(pdf): cache ordered list marker sizing
2026-09-05 10:53:34 -07:00
Amruth Pillai f468651c79 feat(editor): support whole-paragraph indentation (#3448)
* feat(editor): support whole-paragraph indentation

* fix(docx): retain indentation in quotes and RTL documents

* fix(exports): bound paragraph insets in narrow PDF columns

* fix(pdf): type bounded paragraph rendering consistently

* test(editor): use explicit list conversion commands

* fix(docx): preserve quote inset on list items
2026-09-05 10:41:30 -07:00
Amruth Pillai 5c8338c175 fix(builder): preserve PDF glyph positions in RTL previews (#3447) 2026-09-05 10:31:39 -07:00
Amruth Pillai 873835a571 fix(pdf): align Ditgar item headers with body text (#3445)
* fix(pdf): align Ditgar item headers with body text

* refactor(pdf): share Ditgar header border width
2026-09-05 10:14:12 -07:00
Amruth Pillai 14c7c06516 feat: add per-resume public download button preference (#3419) 2026-09-05 10:10:00 -07:00
Amruth Pillai 9fdcec2eca fix(builder): center preview in RTL interfaces (#3446) 2026-09-05 10:09:02 -07:00
Amruth Pillai 1d4194a207 feat: manage cover letters in a shared library (#3423)
* feat: add shared cover-letter library with resume styling

* fix: retain required sanitizer dependency in CI

* fix(cover-letters): prevent concurrent AI draft requests

* chore(codacy): exclude generated migrations

* fix(applications): keep Lingui macro out of callback dependencies
2026-09-05 10:01:22 -07:00
Syed Ali Abbas ZaidiandAmruth Pillai cce6d64afa feat(import): parse a PDF resume without an AI provider (#3400)
* feat(import): parse a PDF resume without an AI provider

Importing a PDF required a connected AI provider, so anyone without a
paid API key could only import the three JSON formats. Almost nobody
arrives with one of those files; they arrive with a PDF. The first thing
a new user tries to do was blocked behind bringing their own key.

Adds a deterministic parser that reads the text out of the PDF in the
browser and prefills the builder. It pulls the contact block, segments
the body on conventional headings, and maps entries to real items,
reusing the ATS period parser for dates so a date range is not mistaken
for a phone number.

Nothing is thrown away: header parts that do not map to a field go into
the description, and unrecognized headings become custom sections. The
imported sections are placed on the page so the result renders straight
away. Output is validated against the resume schema before it is
returned.

Text extraction groups items by baseline rather than trusting hasEOL,
and turns wide column gaps into a double space, which is what lets a
row split into company, position and location.

The AI path still runs when a provider is connected. Word import is
unchanged and still requires one.

Closes #3334

* fix(import): keep every section and entry the PDF actually contains

Review found three ways the parser lost or mangled content, all of them
reproducible.

A document whose first heading was not one of the known aliases never
started a section, because unknown-heading detection was gated on a
section already being open. Everything after it was swallowed as contact
header text. The header block is now bounded by where the contact
details stop, so a heading is recognized wherever it appears.

An entry spreading company, position and dates over three lines was
imported as two malformed items. A line that introduces an entry now
merges into the open entry instead of starting a second one.

An uppercase company such as ACME CORPORATION was read as a section
heading and fragmented the entry. A heading candidate followed by a date
line is now treated as an entry header, which is what it is.

Also escape single quotes, and construct the PDF worker inside the try
so the nested worker is terminated even if construction throws.

Title-case headings are deliberately still not treated as headings:
company and school names are title case too, and splitting on them would
fragment real entries. Such a section stays in the preceding one with its
text intact rather than risking loss.

* fix(import): look past a multi-line preamble before calling a line a heading

The previous guard only inspected the next line, so an uppercase company
followed by a separate role line and then the dates was still read as a
section heading. The experience or education entry was moved into a
custom section and lost.

Heading detection now scans a two-line window for the date that marks an
entry, and stops early at a bullet so a genuine heading whose section
opens with bullet points is still recognized.

The window can suppress a real heading whose first entry puts a bare date
two lines below it. That is the deliberate direction to fail in: a missed
heading leaves the text in the preceding section, while a misread entry
fragments structured content.

* fix(import): collect an entry preamble until its dates appear

An entry that spread company, role, location and dates over four lines
was imported as two broken items: the company with no dates, and the
location carrying the period.

The cause was in entry grouping rather than heading detection. Lines
before a date were only folded into the entry header when the date sat
on the very next line; anything earlier fell through to the description.
Preamble lines are now collected into the entry header until the dates
turn up, bounded by the same lookahead and stopping at a bullet, so an
undated section cannot swallow itself.

The heading lookahead widens to four lines to match, which is the
realistic maximum for company, role, location and dates.

* fix(import): harden local PDF resume parsing

* chore(import): document audited HTML construction

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-09-05 09:53:37 -07:00
Emanuele Tonello ef47baf243 fix(applications): handle cover letter copy failures (#3394)
* fix(applications): handle cover letter copy failures

* style(applications): format clipboard error toast

* refactor(applications): memoize copy draft handler
2026-09-05 09:51:24 -07:00
Emanuele TonelloandAmruth Pillai 1f0844b39c feat(applications): add contact email and phone (#3396)
* feat(applications): add contact email and phone

* fix(applications): validate imported contact emails

* fix(applications): validate all imported contact fields

* fix(applications): preserve data when contact validation fails

* test(applications): complete contact export fixture

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-09-05 09:51:21 -07:00
Diego Vega CentenoandAmruth Pillai 8df1b25550 feat(skills): add inline layout option for skill items (#3358)
* feat(skills): add inline layout option for skill items

* fix: restore default skills layout (regressed by inline feature)

- Restore metrics rowGap style for default layout
- Only render LevelDisplay inside the row for inline layout, not default

* refactor(pdf):  Extract inline skills style logic from JSX to reusable function

* test(pdf): add test coverage for inline skills item layout

- Add test suite SkillsSectionInlineFormat to verify isInlineSkillsItem and getSkillsItemStyle behavior

* test(pdf): add comprehensive test coverage for inline skills item style logic

- Test combinations of proficiency, level, and keywords fields (0, 1, 3 fields)

* test(schema): add test coverage for column equals 1 when layout is inline

* test(web): add component-level tests for inline and columns layouts

* test(import): add v4 parser-level test for missing skills layout

* docs: regenerate skills layout references

* test(docx): include skills layout in section fixtures

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-09-05 09:51:14 -07:00
Amruth Pillai ea2beb8450 fix(pdf): keep list markers with their first text fragment (#3443)
* fix(pdf): keep list markers with their first text fragment

* fix(pdf): preserve page breaks while rewinding list companions

* fix(pdf): consume oversized list marker presence hints

* test(pdf): allow cold startup for pagination process guard

* fix(pdf): key list presence spacer
2026-09-05 09:51:09 -07:00
Santhi PrakashandAmruth Pillai 861ba8bf60 fix(ai): make provider test timeout configurable via AI_TEST_TIMEOUT_MS (#3384)
* fix(ai): make provider test timeout configurable via AI_TEST_TIMEOUT_MS

- Problem: the 30s hardcoded timeout is too short for self-hosted
  deployments with cold-start models (e.g. Ollama). Makes it impossible
  to pass the provider test (issue #3374).
- Fix: read AI_TEST_TIMEOUT_MS from the environment, defaulting to 30_000.
  Zero behaviour change when the env var is absent.
- Verification: existing test asserts "30 seconds" in the timeout
  message; default is unchanged so the test continues to pass.
  (CI needs Node 22+ — not available on this host.)

* fix(ai): add AI_TEST_TIMEOUT_MS to turbo globalEnv so it reaches the API process

- Problem: Turborepo filters env vars not listed in globalEnv, so
  AI_TEST_TIMEOUT_MS would always be undefined at runtime under
  turbo dev/start, making the override dead code.
- Fix: add AI_TEST_TIMEOUT_MS to the globalEnv array.
- Verification: turbo.json validates as valid JSON.

* fix(ai): validate AI_TEST_TIMEOUT_MS as a finite non-negative integer

* docs(ai): add JSDoc to timeout parser and test helper

* test(ai): restore AI_TEST_TIMEOUT_MS after timeout tests

- Problem: loadWithTimeout() mutates process.env.AI_TEST_TIMEOUT_MS but nothing restores it, so the last value tested ("999999999999") leaked to every test that runs after this describe block in the same file.
- Fix: save the pre-test value and restore it in an afterEach hook.
- Verification: pnpm exec vitest run src/features/ai/service.test.ts in packages/api — 18/18 passed.

* test(api): isolate AI timeout environment cases

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-09-05 09:35:14 -07:00
Santhi PrakashandAmruth Pillai e0c2f6d88a fix(pdf): preserve first character of section headings by adding left padding (#3386)
* fix(pdf): add left padding to section heading text to prevent first-character clipping

Closes #3380

* fix(pdf): apply heading padding default after style composition

Apply paddingLeft: 1 only when no composed style fragment already defines it, so an explicit paddingLeft from a template or style rule is preserved. Keep the fallback for an empty style list.

* fix(pdf): keep heading safety padding on text only

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-09-05 09:35:06 -07:00
Santhi PrakashandAmruth Pillai ea3980cba0 fix(components/form): resolve FormControl label target regressions (#3369) (#3387)
* fix(components/form): resolve FormControl label target regressions (#3369)

- Expose FormControlContext and wrap FormControl children in Base UI's
  LabelableProvider so the generated control id reaches the actual
  labelable element.
- Update InputGroup/InputGroupInput to consume the context and place
  the id on the real input instead of the fieldset.
- Update Slider to discard the wrapper id and use the context via
  LabelableProvider so the thumb input receives the id and
  aria-labelledby.
- Update ChipInput to consume the context, set id and aria-labelledby
  on the inner input, and only fall back to aria-label when not inside
  a FormItem.
- Restructure the sidebar layout so a single FormControl labels the
  numeric input and the visible FormLabel is referenced by id for the
  sibling Slider, removing the duplicate-id defect.
- Add a dev-time warning when the generated id lands on a non-labelable
  or missing element.
- Extend form.test.tsx with regression coverage.

* test(form): add regression coverage for chip-input and dual-control layout

* fix(ui): surface FormControl error state as aria-invalid on the Slider control

- Problem: FormControl injects aria-invalid={hasError} onto its rendered
  element, but Slider stripped it without re-applying it anywhere, so the
  error state never reached the DOM (flagged by Codacy/Greptile/CodeRabbit).
- Fix: bridge aria-invalid onto Base UI's native range input via the Thumb's
  public inputRef prop; Base UI v1.7 has no prop path for it (its validation
  props only apply through Base UI Field context). id stays stripped since
  LabelableProvider already delivers it to the input.
- Verification: new regression test in form.test.tsx fails on the pre-fix
  head (aria-invalid null) and passes post-fix; packages/ui 363/363 tests
  green; tsc --noEmit on packages/ui clean.

* fix(ui): let a caller-supplied data-slot override the Slider default

- Problem: the FormControl label-target fix moved data-slot="slider" after
  {...props} on SliderPrimitive.Root, so a caller's data-slot was silently
  overwritten with the default — a prop-ordering regression against both the
  prior file and the repo-wide convention (FormItem, FormLabel, InputGroup all
  place data-slot before the spread).
- Fix: restore data-slot="slider" before {...props} so caller values win.
- Verification: packages/ui — vitest src/components/slider.test.tsx
  src/components/form.test.tsx = 30/30 passing; new regression test
  ("lets a caller-supplied data-slot override the default") fails on the
  pre-fix head (data-slot="slider" wins) and passes with the fix; tsc
  --noEmit clean.

* fix(ui): preserve standalone Slider and InputGroup identity props

- Problem: the FormControl prop strip dropped a standalone caller's id on
  Slider and id/aria-describedby/aria-invalid on InputGroup, so standalone
  compositions rendered no element carrying those attributes (regression
  vs main, flagged by maintainer review on this PR).
- Fix: strip the FormControl-generated props only when a FormControl
  ancestor is present (useFormControl context); preserve explicit caller
  props for standalone usage in both components.
- Verification: new standalone + FormControl-wrapped tests fail on the
  prior head and pass after the fix; packages/ui 367/367, apps/web
  595/595, tsgo --noEmit clean.

* fix(ui): remove internal label provider dependency

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-09-05 09:35:00 -07:00
Amruth Pillai cddb01f037 fix(sharing): record public PDF download statistics (#3414)
* fix(sharing): record public PDF download statistics

* docs(api): explain download statistics access cookie
2026-09-05 09:34:52 -07:00
Santhi Prakash c4eb9d860b fix(api): translate copilot AI provider failures to BAD_GATEWAY (#3333)
* fix(api): translate copilot AI provider failures to BAD_GATEWAY

- Problem: AI provider errors (bad key, unknown model, quota, 5xx) from the
  AI SDK bubble out as opaque 500 INTERNAL_SERVER_ERROR from copilot
  endpoints (autofill, match-score, draft-message, tailor-resume).
- Fix: catch AISDKError in generatePlainText and a local generateJson
  wrapper that delegates to the shared generate-json module, translating
  both to BAD_GATEWAY (502) with the original error preserved as cause.
  Mirrors the existing pattern in features/ai/router.ts.
- Verification: vitest (CI — requires Node 22+). Test file unchanged in
  assertion logic from the original PR; the local generateJson now
  wraps the shared module instead of duplicating it.

Rebased onto main after v5.2.9 AI-layer refactor (generateJson extracted
into features/ai/generate-json.ts).

* fix(api): align generateJson prompt shape with callers and shared module

- Problem: local generateJson wrapper accepted (model, prompt: string,
  schema) but all callers pass (model, { prompt: string }, schema).
  Caught by CodeRabbit review.
- Fix: match the shared generate-json module signature — accept
  { system?, prompt } as the second argument and pass it through.
  Updated test calls to match.

* fix(test): remove stray leading dots from mock object property names

- Problem: rebase onto v5.2.9 introduced `.use`, `.output`, `.errors`
  as property names in the chain mock object, which is invalid JS
  syntax and would cause a parse error when tests run.
- Fix: remove the leading dots to restore valid property names.
- Verification: cat -A confirms tabs-only indentation, no leading dots.

* fix(docs): correct 'a actionable' to 'an actionable' in comment

- Problem: Grammar typo in inline comment.
- Fix: 'a actionable' → 'an actionable'.
- Verification: grep confirms no remaining instances.

* fix(api): narrow copilot AI BAD_GATEWAY predicate to APICallError and exhausted RetryError
2026-09-05 09:33:18 -07:00
Amruth Pillai fe9b59e111 fix: restore MCP OAuth registration and authorization (#3421)
* fix: align MCP OAuth provider schema and authorization flow

* test: isolate OpenAPI generation from OAuth initialization

* fix: accept auth routes without a callback query

* fix: require explicit OAuth consent and preserve signed requests

* test: verify OAuth audiences through real MCP initialization

* test(e2e): isolate OAuth token audience validation
2026-09-05 09:33:15 -07:00
Amruth Pillai bf71253ca4 fix: preserve margins on PDF overflow pages (#3422)
* fix: preserve page margins across PDF overflow pages

* test(pdf): preserve styled and full-width Glalie backgrounds

* test(pdf): assert exact semantic margin colors
2026-09-05 09:33:13 -07:00
github-actions[bot]andCrowdin Bot 0207e5dfcc [skip ci] chore(i18n): sync translations from crowdin (#3441)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-09-05 09:02:30 -07:00
Amruth Pillai a2d6bc0c63 fix(pdf): align dates when optional item fields are empty (#3406)
* fix(pdf): align dates when optional item fields are empty

* refactor(pdf): simplify alignment regression coverage
2026-09-05 09:02:27 -07:00
Amruth Pillai b2c3ab62b1 docs: refresh open issue audit after merged fixes (#3440)
* docs: continue open issue audit

* docs: refresh audit after merged fixes

* docs: correct merged font fix status

* docs: clarify merged audit evidence
2026-09-05 09:02:24 -07:00
Amruth Pillai fa41150723 fix: preserve photo compression during cropping and show upload limits (#3420)
* fix: preserve photo compression during cropping and show upload limits

* fix: bound cropped image size before upload
2026-09-05 08:59:13 -07:00
Amruth Pillai d53b89ba2d fix(pdf): honor semantic section heading colors (#3415) 2026-09-05 08:52:49 -07:00
Amruth Pillai 779ea5cb4a fix(resume): reject invalid submitted write values (#3413) 2026-09-05 08:51:55 -07:00
Amruth Pillai 5a6f5d4d68 fix: label remaining website and picture inputs (#3424)
* fix: connect remaining website and picture labels to inputs

* test(builder): use realistic website input events
2026-09-05 08:51:22 -07:00
Amruth Pillai 0878b256a9 fix(sharing): use neutral social preview image (#3410)
* fix(sharing): use neutral social preview image

* fix(sharing): use neutral server social preview
2026-09-05 08:51:19 -07:00
Emanuele Tonello bf27792ca0 feat(applications): attach generated cover letter PDFs (#3395)
* feat(applications): attach generated cover letter PDFs

* fix(applications): isolate generated cover letter PDFs
2026-09-05 08:51:03 -07:00
Amruth Pillai cd1c597ff0 fix(server): expose build version in health endpoint (#3404)
* fix(server): expose build version in health endpoint

* fix(server): redact public health failure details
2026-09-05 08:50:17 -07:00
Amruth Pillai 93e8d192a4 fix(pdf): apply opacity to rating icons (#3412) 2026-09-05 08:50:14 -07:00
Amruth Pillai a95e63246e fix(pdf): constrain Onyx headlines to page margins (#3408) 2026-09-05 08:47:56 -07:00
Amruth Pillai a3585a24e0 feat(applications): export filtered applications as CSV (#3426)
* feat(applications): export applications as CSV

* fix(applications): strip export CSV formula guard on import and resort catalogs

Re-importing an exported CSV kept the apostrophe that csvCell prepends to
formula-triggering cells, so a note starting with "- " came back as "'- ".
mapCsvToApplications now drops a leading apostrophe when the remainder would
have been guarded, sharing the predicate with csvCell so both sides stay in
sync.

Also runs pnpm lingui:extract: the new msgids were hand-appended to en-US.po
and missing from the other 54 catalogs.

* fix(applications): preserve CSV import values
2026-09-05 08:46:52 -07:00
Amruth Pillai 6d39074c58 fix(pdf): align skill ratings within grid rows (#3437)
* fix(pdf): align skill ratings within grid rows

* fix(pdf): align language ratings in grid rows
2026-09-05 08:46:48 -07:00
github-actions[bot]andCrowdin Bot a12e32ddac [skip ci] chore(i18n): sync translations from crowdin (#3439)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-09-05 08:46:45 -07:00
Amruth Pillai f629ea1ea3 fix(stylesheet): allow gaps between level decorations (#3434)
* fix(stylesheet): allow gaps between level decorations

* test(pdf): explain level gap raster measurements
2026-09-05 08:40:09 -07:00
Amruth Pillai b6842fb769 fix: localize default headings in server PDF exports (#3428) 2026-09-05 08:31:36 -07:00
Amruth Pillai aada380888 fix(pdf): preserve imported rich text without semantic descendants (#3438) 2026-09-05 08:30:41 -07:00
Amruth Pillai 7d809da6f8 feat(pdf): add opt-in German hyphenation (#3435) 2026-09-05 08:29:22 -07:00
Amruth Pillai 57fee67d2d fix(pdf): render picture borders and soft shadows (#3427)
* fix(pdf): render picture borders and soft shadows

* fix(pdf): preserve picture padding and bound shadow rendering
2026-09-05 08:29:17 -07:00
github-actions[bot]andCrowdin Bot 47fc16d806 [skip ci] chore(i18n): sync translations from crowdin (#3436)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-09-05 08:28:43 -07:00
Amruth Pillai 1f308af728 feat: add compact resume view with session preferences (#3425)
* feat: add compact resume view and session preferences

* test: match resume cards by literal names
2026-09-05 07:32:14 -07:00
Amruth Pillai 321f2fb43f fix(builder): validate and confirm resume passwords (#3407)
* fix(builder): validate and confirm resume passwords

* test(sharing): exercise password confirmation in browser flow
2026-09-05 07:32:10 -07:00
Amruth Pillai 18b5aa4745 fix(sharing): hide signup link when registration is disabled (#3409) 2026-09-05 07:32:07 -07:00
Amruth Pillai 8354c39c45 fix(pdf): respect requested font metrics when positioning text (#3430)
* fix(pdf): respect requested font metrics when positioning text

* fix(pdf): preserve Noto Sans HK line metrics
2026-09-05 07:32:04 -07:00
Amruth Pillai 7390c81b76 fix(build): invalidate cached tasks for workspace source changes (#3429)
* fix(build): invalidate cached tasks for workspace source changes

* test(build): launch Turbo portably through Node
2026-09-05 07:32:01 -07:00
Amruth Pillai 35cecf9c91 fix(storage): support S3 buckets with object ACLs disabled (#3432) 2026-09-05 07:29:42 -07:00
Amruth Pillai 735e700929 fix(stylesheet): keep color picker state aligned with source (#3431)
* fix(stylesheet): keep color picker state aligned with source

* fix(stylesheet): serialize picker edits as hex with alpha

* fix: preserve contextual colors in stylesheet editor
2026-09-05 07:29:39 -07:00
Amruth Pillai a9973c0054 docs: audit open issues and track resolution plan (#3418)
* docs: track open issue audit and resolution plan

* docs: update issue audit with verified fixes

* docs: record cover-letter library verification

* docs: record OAuth and cover-letter CI verification

* docs: track compact views and remaining accessibility fixes

* docs: track CSV export and picture rendering fixes

* docs: record PDF localization, cache fix and consent review

* docs: track consent and rendering fixes in repository-only audit

* docs: refresh issue audit progress and review evidence

* docs: record latest issue reproductions and published fixes
2026-09-05 07:29:35 -07:00
Amruth Pillai 97ccb4ba06 fix(builder): remove sections emptied by item moves (#3417) 2026-09-05 07:29:32 -07:00
Amruth Pillai 165841af4e fix(email): preserve optional Nodemailer property types (#3416) 2026-09-05 07:29:29 -07:00
Amruth Pillai 53288fcd3f fix(docker): load local environment overrides in Compose (#3411)
* fix(docker): load local environment overrides in Compose

* docs(docker): clarify repository Compose defaults setup
2026-09-05 07:29:26 -07:00
Amruth Pillai ddbbbde803 fix(ui): restore hover feedback for primary buttons (#3405) 2026-09-05 07:29:23 -07:00
Amruth Pillai 2cbb0f63e7 fix(builder): preserve explicit HTTP URLs (#3403) 2026-09-05 07:29:20 -07:00
Amruth Pillai 00a1357deb fix(applications): show saved notes in detail view (#3402) 2026-09-05 13:26:52 +02:00
Amruth Pillai e549d114ea test: add test to verify emoji rendering 2026-09-05 13:02:07 +02:00
Amruth Pillai 84645f122b chore: update dependencies 2026-09-04 11:05:22 +02:00
Amruth Pillai 0a092ee2a4 delete pullfrog.yml 2026-08-28 22:21:13 +02:00
Amruth Pillai f29b92e2fb chore(copy): rewrite marketing, app, and docs copy to read less AI-generated
Rewrites the landing page, in-app microcopy, and public docs, then fixes what the rewrite exposed: stale template counts, a broken quickstart anchor, out-of-sync FAQ structured data, dead error-hint branches in the MCP tools, and wrong-sense translations across all 53 locales. Adds GLOSSARY.md so translators get the right sense of the ambiguous UI terms.
2026-08-28 22:18:29 +02:00
Amruth Pillai f046f6fc51 Add pullfrog.yml workflow 2026-08-27 18:24:42 +02:00
Amruth Pillai 3fa9de140c chore: update translations 2026-08-27 07:41:45 +00:00
Amruth Pillai c288675b16 Release v5.2.9 (#3382)
* feat(ats): add ATS checker and replace resume analysis

Adds a public, browser-only ATS checker at /ats-checker and an ATS Check
section in the builder's right sidebar. PDFs are parsed locally: text
extraction, reading order, contact and date recovery, section detection,
and file-level readability are scored deterministically, with evidence
cited per finding and skipped checks reported rather than counted as
passes.

Removes the AI-scored resume analysis it supersedes: the resume_analysis
table (dropped via migration), the get_resume_analysis MCP tool, and
POST /ai/analyze-resume. The replacement, POST /ai/ats-review, reviews
extracted resume text and returns qualitative feedback with no score.

Also bumps the version to 5.2.9 and adds the changelog entry.

* chore(deps): bump workspace dependencies

* fix(ats-checker): keep negation inside each 'what this does not do' bullet

The three bullets were bare fragments whose negation came from the
section heading, which translators never see. A dozen locales rendered
them as affirmative assertions or imperatives, so the page claimed the
checker enforces a one-page rule and predicts rejection -- the opposite
of the source, and directly contradicted by the sentence beside it.

Each bullet now carries its own negation, so the polarity cannot be
lost in translation. Re-extracted and refilled across all 53 target
locales.
2026-08-27 03:37:01 +02:00
Santhi PrakashandAmruth Pillai e065a10824 fix(pdf): resolve bold text weight from the family's bold face (#3335)
* fix(pdf): resolve bold text weight from the family's bold face

Bold text (<strong>, rich-text bold, template bold styles) previously
rendered at the last stored body weight, which is ambiguous: families
are commonly stored as ["400","600"] (the typography picker's default
pairing), so bold rendered at SemiBold — nearly indistinguishable from
Regular for faces like Open Sans (#3310).

Add resolveBoldFontWeight() to the fonts package: keep a deliberate
stored bold-class choice (>= 700), else prefer the family's true Bold
face ("700"), else the heaviest >= 600 face; return null so callers
keep their existing fallback when the family has no bold-class face.

Wire it through use-register-fonts, the shared base-template-styles
builder, base-styles and the Scizor template. Default body IBM Plex
Serif ["400","500"] now renders bold at 700 (base-reset-fidelity
expectation updated accordingly).

Fixes #3310

* fix(pdf): register bold fallback faces for CJK glyph substitution

When resolveBoldFontWeight maps stored weights like ["400","600"] to the
family's 700 face, register that weight on each PDF fallback font too so
glyph-level substitution keeps bold glyphs instead of snapping to 600.

Also reorder @reactive-resume/fonts imports per Biome convention.

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-08-27 03:16:46 +02:00
Santhi Prakashgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>Amruth Pillaiautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
b47f805321 fix(pdf): render emoji via a Noto Emoji script fallback (#3351)
* fix(pdf): render emoji via a Noto Emoji script fallback

Emoji in resume content (flags, globe, pictographs) rendered as mojibake
in the preview and PDF export because the per-codepoint fallback chain
registered no emoji-capable font: every font in the stack lacked the
glyphs, so layout fell through to single-byte standard-font encoding —
each UTF-16 code unit truncated to its low byte (#3321).

Follows the #2986/#3190 script-fallback pattern: detect emoji content
(regional indicators unioned with Extended_Pictographic), map it to the
monochrome Noto Emoji web font (TrueType glyf outlines, PDF-embeddable),
and register it in the fallback stack for both serif and sans stacks.
Out-of-range weight requests alias to the nearest served weight (300-700)
so registration never falls back to the preview subset.

* fix(pdf): detect keycap emoji via the combining enclosing keycap

Greptile review on #3351: keycap sequences like 1\uFE0F\u20E3 carry no
regional indicator and no Extended_Pictographic codepoint, so they
bypassed the emoji detector and rendered garbled — the exact class of
bug #3321 fixes. Union U+20E3 into the detector; every valid keycap
sequence contains it.

* Update packages/utils/src/locale.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* [autofix.ci] apply automated fixes

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-27 03:15:34 +02:00
Santhi Prakash 2761bd6715 fix(fonts): register Vazirmatn in webfont catalog for JSON imports (#3331)
* fix(fonts): register Vazirmatn in webfont catalog for JSON imports

Imported resumes can set typography.fontFamily to Vazirmatn, but the
popularity-sorted Google Fonts slice omits it so PDF registration fell
back to IBM Plex Serif and Persian/Arabic glyphs stacked or tofu (#3098).

Add Vazirmatn as a locale-coverage manual entry (same pattern as Carlito)
and cover catalog resolution with unit tests.

* test(pdf): keep Vazirmatn as primary family for fa-IR registration

Prove JSON-imported Vazirmatn is handed to Font.register instead of
being rewritten to IBM Plex Serif (#3098).

* fix(fonts): address CodeRabbit review on Vazirmatn catalog

Assert getWebFontSource resolves files["400"] for Vazirmatn instead of
only matching the preview fallback, and split the font-generation log
line to satisfy the 120-column Biome limit.
2026-08-27 03:15:31 +02:00
Santhi Prakash a416d01112 fix(pdf): constrain bullet list content width within horizontal margin (#3367)
Change richListItemContent from flex: initial to flex: 1 with minWidth: 0.
This makes the content area fill remaining space after the marker and
columnGap, so text wraps within the user-set margin instead of overflowing.

Fixes #3336
2026-08-27 03:15:28 +02:00
github-actions[bot]andCrowdin Bot 7fac6f29c0 Sync Translations from Crowdin (#3381)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-08-26 16:25:56 +02:00
Amruth Pillai d3dddf229b Update .gitignore and AGENTS.md 2026-08-26 14:23:42 +00:00
Amruth Pillai 3c195dc3f8 Release v5.2.8 (#3375)
Upgrades to Better Auth 1.7, expands Custom Styles coverage of item headers, and adds a human-approval step to the AI agent's resume edits.

Breaking for self-hosters using a custom OAuth provider: the callback path changes from /api/auth/oauth2/callback/custom to /api/auth/callback/custom, and installs using OAUTH_DISCOVERY_URL need one additional UPDATE after upgrading. Both are documented in docs/self-hosting/sso.mdx.

- Better Auth 1.7, with the account issuer migration and the jwks alg/crv columns the 1.7 jwt plugin requires
- Agent edits gated behind an approval step, with crash-safe runs and context pruning
- item-header now covers every section header row on every template; adds the item-header-row part
- Fixes provider unlinking, auth error messages, and version conflicts on freshly created resumes
- New /auth/error page, translated across all 53 target locales
- DeepSeek Harness plugin moved into packages/dsh-plugin
- Dependency bumps across the workspace
2026-08-24 21:44:16 +02:00
github-actions[bot]andCrowdin Bot 3221afda9d Sync Translations from Crowdin (#3365)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-08-20 10:38:31 +02:00
Amruth Pillai 8ce899a04b feat(agent): omit resume documents from the copied conversation json 2026-08-20 09:40:57 +02:00
Amruth Pillai 39f36b4ac5 fix(resume): guard patch versions in the transaction, not in sql
Postgres defaultNow() stores microseconds while JS Dates are millisecond-truncated, so the SQL equality guard matched zero rows on freshly created resumes and every guarded agent patch failed with a permanent version conflict. The SELECT ... FOR UPDATE lock plus the in-transaction ms-precision check already provide the guarantee; drop the SQL predicate. Verified A/B against a live database.
2026-08-20 09:28:31 +02:00
Amruth Pillai 39590eaff6 fix(auth): allow unlinking providers after the session ages past a day (#3364)
Better Auth guards `/unlink-account` with `freshSessionMiddleware`, which
rejects any session whose `createdAt` is older than `freshAge` (one day by
default). Sessions here last a week and there is no re-authentication flow to
refresh that timestamp, so disconnecting a provider failed with
`SESSION_NOT_FRESH` for every user who signed in more than a day ago.

Disable the freshness gate, and teach `getReadableErrorMessage` to read plain
error objects: Better Auth client errors are `{ code, message, status }`
objects rather than `Error` instances, so every auth toast was collapsing to
its generic fallback instead of showing the real reason.
2026-08-20 08:20:18 +02:00
Amruth Pillai c8081ac2fe feat(agent): adopt AI SDK v7 — crash safety, context pruning, HITL approvals (#3362)
* docs(adr): propose agent AI SDK v7 adoption plan

* fix(ai): bind analyzeResume through aiService in service test

The test destructured analyzeResume as a named export that does not exist; main was red.

* test(agent): keep pure ai helpers real via spread-actual mock factory

* feat(agent): add run guards, patch version guard, run wall-clock timeout

* feat(agent): validate UI messages at the send boundary

* feat(agent): crash-safe draft-row persistence and server-side cancellation

* feat(agent): reap stale run claims at boot, on send, and on thread open

* feat(agent): fresh-document patch output and tiered context pruning

* feat(ai): shared agent tool contracts and message metadata schema

* feat(agent): add per-thread review-patches setting with update endpoint

* feat(agent): gate resume patches behind hmac-signed tool approval

* feat(agent): merge question answers and approval decisions before run claim

* feat(agent): approval ui with composed auto-send and fixture-driven tests

* feat(agent): usage metadata, tool activity cards, smoother streaming

* feat(agent): tool-call repair, input examples, structured step logging

* chore(i18n): translate new agent workspace strings across all locales

* fix(agent): gate stale-run draft cancellation on winning the claim clear

Snapshot streaming drafts before the conditional clear and skip the flip entirely when another reaper or a replacement run already cleared the claim. Also address review nits in eleven locale catalogs.

* fix(agent): flip reaped drafts only when their snapshotted state is unchanged

* fix(agent): address review findings across run lifecycle, context budget, and approval flow

- bind patches to the revision the model read via signed baseUpdatedAt
- claim the run before consuming a continuation; recorded-but-unexecuted approvals retry as pending continuations
- keep run ownership on stop() until cancellation persists; preserve the claim for the reaper when final persistence fails
- estimate tokens without serializing binary attachments (tokenx) and enforce the budget by dropping oldest whole turns
- mark crash-recovered patch results as snapshot boundaries; strip /data prefixes at execution time
- retry failed continuations without regenerate; mount a single AgentChat; disable response controls on read-only threads; freeze review toggle during runs (client+server)
- accumulate usage across continuations and match the SDK's nested usage shape; label-form token strings; reorderable source label; accessible note field; state-neutral web-search label

* chore(i18n): translate revised agent strings across all locales

* fix(agent): harden baseUpdatedAt validation and address review follow-ups

- bundle tokenx in the server runtime dependencies (e2e boot failure)
- strict ISO schema for baseUpdatedAt plus loud executor rejection of unparseable values
- it-IT source label consistency (Fonte)
- prove penultimate-turn retention in the context pruning test

* chore(deps): exempt tokenx from knip for the externalized server bundle
2026-08-20 08:06:53 +02:00
autofix-ci[bot] dbbab6fd76 [autofix.ci] apply automated fixes 2026-08-19 03:49:17 +00:00
Emanuele Tonello 8acde4c1ac fix(ai): provide current date to resume analysis (#3353) 2026-08-19 05:48:31 +02:00
Amruth Pillai 4d53a6d1de fix(stylesheet): apply item-header to every header row on every template (#3357)
`SectionItemHeader` only rendered its own box when a template opted into
`mainItemHeaderBorder` (only Ditgar did). Everywhere else it walked the
header children and attached the resolved `item-header` style to the first
descendant that happened to be a literal `View` or `InlineItemHeader`.

Sections whose header starts with anything else — certifications, awards,
projects, publications, references — matched nothing, so the style was
silently dropped; stacked headers such as experience matched only their
first row, so a second row went unstyled.

The header now always renders its own `Div`, so `item-header` covers the
whole header row of every section on every template. `Div` rather than
`View` keeps the base row gap the rows used to inherit from the item box,
and Ditgar keeps its tight header via `rowGap: 0` on its own
`sectionItemHeader` slot, so rendered output is unchanged apart from the
newly styled rows. `mainItemHeaderBorder` is now dead and removed.

Fixes #3349
2026-08-19 02:24:38 +02:00
Amruth Pillai ab811b5f10 Merge branch 'main' of github.com:amruthpillai/reactive-resume 2026-08-18 20:42:47 +02:00
Amruth Pillai 65618a82a0 feat/dsh plugin (#3356)
* docs: remove .superpowers

* feat(dsh-plugin): bring the DeepSeek Harness plugin into the monorepo

Moves dsh-plugin-reactive-resume out of its own repository and into
packages/dsh-plugin. It stays a published, public npm package — the only
one here — but now builds, typechecks, tests, and lints under the same
turbo tasks as everything else.

The move pays for itself in the drift guard. Standalone, the plugin kept a
generated snapshot of the tool names scraped from the live server card at
https://rxresu.me, plus a weekly CI job to notice when that snapshot went
stale. Sitting next to packages/mcp, it reads MCP_TOOL_NAME directly, so a
tool rename breaks the prompt guide on the same pull request instead of
days later. The snapshot, the fetch script, and the scheduled job are gone.

packages/mcp gains a ./tool-names export so that import goes through the
public export map rather than another workspace's src.

Also flips autoInstallPeers off. The DeepSeek Harness rc packages declare
peers that are host-supplied and, in one case
(@deepseek-ai/dsh-type-meta), not published at all, so auto-install 404s
the whole workspace. Turning it off drops only optional peers elsewhere;
@neodrag/core was the single hard peer that had been arriving implicitly,
and it is now declared where it is used. Full typecheck and test suites
pass, and pnpm peers check reports nothing new beyond the pre-existing
drizzle-orm range mismatch.

Tests move from test/ to colocated src/*.test.ts and the build output from
lib/ to dist/ to match repository conventions.

* fix(dsh-plugin): ship a bundle manifest and target the current Harness

`dsh plugin add` warned that the package "declares no dsh.bundle — installed
as a plain dependency, not a profile layer", and it was right. Every other
Harness plugin, in-box and third-party, ships a cordis.patch.yml and points
dsh.bundle.patch at it; that declaration is what joins a package to a
profile's bundle stack. Without it the package installed and then sat inert,
and the README's hand-written insert row was a workaround for the gap rather
than the intended way in.

The peer ranges were also a generation behind. They asked for
@deepseek-ai/dsh-mcp-client and dsh-system-prompt at ^0.0.1-rc.1, which
cannot match the 0.1.0-rc.6 a current harness ships, so the plugin could
never have resolved against the thing it targets. Both APIs are unchanged
across the bump — StreamableHttpConfig still takes the same six fields and
PromptSection still takes name/order/text — so this is a range correction,
not a migration.

That bump pays for itself elsewhere. The old generation peer-depended on
@deepseek-ai/dsh-type-meta, which was never published, and working around
that 404 is why merging this package turned autoInstallPeers off for the
whole repository and pulled @neodrag/core in by hand. The new generation
dropped that peer and publishes every other one, so both changes are
reverted and pnpm-workspace.yaml is back to what it was.

Because a bundle patch mounts the plugin the moment it is installed, a
required apiKey would fail config validation and take the profile down
before the user ever had a chance to mint a key. It now defaults to empty
and apply() warns and mounts nothing, matching how dsh-honcho-memory
handles the same problem.

Verified by packing the tarball and installing it into a clean project with
default pnpm settings: it resolves, imports, and reports its exports.
2026-08-18 20:42:42 +02:00
Amruth Pillai 6f0c727770 docs: remove .superpowers 2026-08-18 19:50:36 +02:00
Amruth Pillai ebcaa4729f fix(stylesheet): stop item header titles overlapping the date under nowrap (#3355) 2026-08-18 17:47:23 +02:00
Amruth Pillai f14e120b00 Update star history chart links in README 2026-08-18 04:26:28 +02:00
Amruth Pillai d9da31e7bc fix(ci): pass issue_number when labeling new issues
context.issue spreads to { owner, repo, number }, but Octokit v9 requires
issue_number. The request hit /repos/.../issues//labels and returned 404,
so no opened issue was ever labeled.
2026-08-18 04:04:21 +02:00
github-actions[bot]andCrowdin Bot 128916b9a0 [skip ci] chore(i18n): sync translations from crowdin (#3346)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-08-18 03:50:35 +02:00
Amruth Pillai 00be67f702 feat(stylesheet): expose the item header row to Semantic CSS (#3345)
Section item headers render the title and its trailing date inside a shared
split row styled with `flex-wrap: wrap`. When the title is long the date wraps
onto its own line and left-aligns instead of staying pinned right, which reads
as inconsistent down a list of certifications.

That row had no selector. It is a bare View, so it never reached the semantic
tree: `item-header` matches the box around the row, and the title and date are
text nodes that layout properties do not apply to. There was no stylesheet that
could reach it.

Expose it as `template-part[name="item-header-row"]`, shared by every template
because the row comes from the shared section components. Awards,
certifications, projects and publications are covered; experience, education
and volunteer stack two rows and hand their headers to the
`inline-item-header-*` parts on some templates, so they are left alone.

Readers can now write:

    @version 1;
    template-part[name="item-header-row"] { flex-wrap: nowrap; }
2026-08-18 00:29:30 +02:00
Amruth Pillai 5392728f22 chore(ui): drop the orphaned next-themes dependency
The sonner wrapper was the only consumer of next-themes; the Base UI toast
that replaced it does not use the hook. knip flagged it as unused, and CI's
`knip --fix` step removed it and then failed `pnpm check` against a lockfile
that still listed it.
2026-08-17 23:08:33 +02:00
Amruth Pillai 0b0b4ef13b chore(release): v5.2.7
Bumps the version and adds the changelog entry for the changes since v5.2.6.
2026-08-17 22:54:57 +02:00
Amruth Pillai 24c15cd8cd chore(i18n): fill missing translations
Fills the 25 strings added this cycle by the toast migration, the account page
rename, the Custom Styles status labels and the job posting auto-fill, across
all 53 target catalogs. The zu-ZA pseudo-locale is intentionally left empty.
2026-08-17 22:54:57 +02:00
Amruth Pillai 6e3853fe13 chore(i18n): extract catalogs
Picks up the strings added and removed by the toast migration, the account page
rename and the autofill change.
2026-08-17 22:32:34 +02:00
Amruth Pillai b080fcddad docs: document intent skill loading
Adds the @tanstack/intent skill lookup step to AGENTS.md so agents check for a
matching local skill before editing files.
2026-08-17 22:32:33 +02:00
Amruth Pillai 9dc2aade46 chore(deps): update dependencies
Routine version bumps across the workspace. The @react-pdf/textkit patch is
renamed to drop the pinned version so it survives the next bump.
2026-08-17 22:32:33 +02:00
Amruth Pillai e2554c9be8 chore(ui): drop the sonner dependency
Every call site now uses the Base UI toast, so the sonner wrapper and its test
go with it.
2026-08-17 22:32:33 +02:00
Amruth Pillai eedf2faf02 feat(agent): let the assistant ask clarifying questions
Adds the questionnaire and empty-state primitives and renders the
ask_user_question tool call inline in the chat, so the agent can offer choices
instead of guessing when a request is ambiguous.
2026-08-17 22:32:33 +02:00
Amruth Pillai da2f1f8244 refactor(applications): autofill from a pasted posting instead of a URL
Fetching an arbitrary job URL server side meant owning SSRF defence, redirect
and size limits, and per-site scraping quirks. The autofill tool now takes only
pasted text, so the URL input, the fetch path and its MCP annotation are gone.

The sheet gates the call behind a tested AI provider and a minimum paste length
so a stray snippet does not spend an AI call.
2026-08-17 22:32:32 +02:00
Amruth Pillai 7a14b0dfbc refactor(settings): rename the danger zone page to account
The page now holds account-level actions rather than only destructive ones, so
it is reachable at /dashboard/settings/account and presented with a neutral
icon in the sidebar and command palette.
2026-08-17 22:32:32 +02:00
Amruth Pillai 23ceee2148 refactor(web): move toast call sites to the new component
Swaps sonner's toast.success/error/loading/dismiss for the new toast.add({ type,
description }) and toast.close across dialogs, auth pages, the builder, the
dashboard and the applications views. Behaviour is unchanged.
2026-08-17 22:32:32 +02:00
Amruth Pillai 170550ed59 feat(ui): add a Base UI toast component
Adds the toast primitive that replaces sonner, along with its design-sync card
mapping. Nothing consumes it yet; the call sites move over next.
2026-08-17 22:32:31 +02:00
Amruth Pillai ac062bbcbd test: cap turbo concurrency so suites stop timing out
Turbo defaults to ten concurrent tasks and each vitest sizes its pool to the
core count, so a ten-core machine ran roughly a hundred workers and a 1.6s test
blew its 15s budget. Different suites failed on every run. At concurrency four
the whole repo passed five runs straight with no wall-clock cost.
2026-08-17 22:19:52 +02:00
Amruth Pillai bfdd29f941 test(server): generate the OpenAPI spec once per suite
Each case rebuilt the whole spec, which walks every router and resume JSON
schema. The first case already carried a raised 15s timeout and still timed out
on a loaded machine. The spec is deterministic and only read here, so build it
once: the file drops from over 15s to 1.86s.
2026-08-17 22:19:52 +02:00
Amruth Pillai e8508e6d03 test: isolate test files to stop cross-file mock leakage
Without isolation the files in a worker share one module registry, so a
vi.mock of @reactive-resume/env/server in one file leaked into another and
whichever file imported the module first won. Measured on a clean cache,
isolate: false failed four of four whole-repo runs; with isolation, none.
2026-08-17 22:19:52 +02:00
Amruth Pillai 60d0440763 test: seed the required server env vars for every suite
Units that transitively import the validated server env threw at import time
whenever no .env was present, taking out packages/auth and packages/api. Seeding
the three required variables in the shared setup fixes every current and future
caller in one place. Real values still win.
2026-08-17 22:19:52 +02:00
Amruth Pillai f4bf6887b9 test(stylesheet): complete system variables at the end of the prefix
The case passed cursor position 5 into "--resume-", which lands mid-token and
reads as a selector context, so it received the selector list. Every other case
in the file uses source.length.
2026-08-17 22:19:52 +02:00
Amruth Pillai 817d4ef971 test(stylesheet): correct the malformed declaration offset
The expected offset disagreed with its own line and column: line 2 column 17 is
offset 28, which is where `red` starts. Offset 31 pointed at `; }`. The sibling
UTF-16 case in the same file already used the correct arithmetic.

Anchors the offset to the source it must point at so it cannot drift again.
2026-08-17 22:19:52 +02:00
Amruth Pillai 7c7dbaf21d fix(agent): keep the chat composer focused while streaming
Disabling the textarea for the duration of a response made the browser blur it,
so the caret left the composer on every send and had to be clicked back. send()
already ignores calls mid-stream, so Enter stays a no-op and type-ahead works.
2026-08-17 22:19:52 +02:00
Amruth Pillai 762b999d1e fix(agent): key chat message parts by index
Every step-start part serialises to the same JSON, so the content-derived key
collided for any multi-step assistant message and React warned about duplicate
keys on each incoming chunk. Two identical text parts collided the same way.

Parts are append-only and never reordered by the AI SDK, so the index is stable.
2026-08-17 22:19:52 +02:00
Amruth Pillai 9d0dc36706 feat(seo): render social card metadata for public resumes
Public resume pages only produced their OpenGraph and Twitter tags client side,
so a shared link had no card at all. The server now injects them into the shell
and swaps in the resume's own title and description.

The lookup is scoped to public, password-free resumes and deliberately avoids
resumeService.getBySlug: that counts a view and would expose a protected
resume's summary to an unauthenticated crawler. User-authored values are escaped
before they reach the HTML, and any lookup failure falls back to the plain shell.

getResumeSocialMeta is shared with the client route head so the two cannot drift.
2026-08-17 22:19:52 +02:00
Amruth Pillai d0fa9ae8da fix(seo): shorten the meta description for mobile search results
The 131 character description overflowed the three line snippet Google renders
on mobile. The replacement is 114 characters and keeps the same claims.
2026-08-17 22:19:52 +02:00
Amruth Pillai 1e23a453a0 fix(seo): declare Twitter card tags with name attributes
X reads twitter:* meta tags from the name attribute, not property, so the card
validator reported twitter:title and twitter:description as missing. Also adds
the og:type tag the root head was never emitting.
2026-08-17 22:19:52 +02:00
Amruth Pillai 36c35c9bd5 fix(seo): serve the root request through the web app handler
The static middleware was mounted ahead of the web app fallback, and Hono's
serveStatic resolves "/" to the directory and returns dist/index.html verbatim.
handleWebApp never ran for the root route, so the OpenGraph, Twitter, canonical
and JSON-LD markup it injects was missing in production - fetching
https://rxresu.me/ as Twitterbot returned zero og: tags.

Route "/" explicitly before the static middleware so the injection runs.
2026-08-17 22:19:52 +02:00
github-actions[bot]andCrowdin Bot 0c7c3ac4c4 [skip ci] chore(i18n): sync translations from crowdin (#3330)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-08-17 10:20:08 +02:00
Amruth Pillai 9509b5bc2e refactor(stylesheet): move Semantic CSS to the browser (#3329) 2026-08-16 16:50:27 +02:00
github-actions[bot]andCrowdin Bot f848e57436 Sync Translations from Crowdin (#3328)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-08-16 12:46:45 +02:00
a4bc2693be fix(ai): bound the provider test and explain why it failed (#3319)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-08-16 12:45:00 +02:00
github-actions[bot]andCrowdin Bot 104e954b77 Sync Translations from Crowdin (#3327)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-08-16 11:57:33 +02:00
Kaushik NandClaude Opus 5 118f3679a3 fix(lefthook): run the conflict-marker check on Windows (#3320)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 11:55:30 +02:00
Amruth Pillai 6c1280dca9 chore(github): organize issue triage (#3325) 2026-08-16 10:59:02 +02:00
Amruth Pillai 8affc567e3 fix: show non-expiring API keys (#3324) 2026-08-16 10:58:55 +02:00
Amruth Pillai 409d09809a chore: release v5.2.6 2026-08-14 05:24:08 +02:00
ignaciocarreandAmruth Pillai 6d9ebccc63 feat(mcp): add cover-letter PDF downloads (#3304)
* feat(mcp): add cover-letter PDF downloads

* fix(mcp): bind signed PDF targets

* test(mcp): cover unavailable cover letters

* fix(api): accept legacy PDF download targets

* fix(server): limit legacy PDF tokens to resumes

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-08-13 23:09:56 +02:00
Syed Ali Abbas ZaidiandAmruth Pillai 45303fb465 feat(resume): add a deterministic ATS parseability check (#3314)
* feat(resume): add a deterministic ATS parseability check

Adds an offline ATS linter that reports whether a parser can read a
resume, surfaced as an always-on panel in the builder.

The existing Resume Analysis panel needs a configured AI provider, so
users who never set one up get no feedback at all. These 22 rules run
as a pure function over ResumeData with no provider, no network and no
rendered PDF, so they work for everyone on every edit.

Rules cover contact details, date parseability, sections that hold
content but never render, column and sidebar placement, and typography
thresholds. The catalog mirrors the Semantic CSS diagnostic catalog:
stable codes carrying a severity, meaning and action, with no i18n
dependency so the web layer translates by code. Each finding carries a
JSON Pointer, which is what makes jump-to-field work.

Deliberately no second score. Resume Analysis owns overallScore, so
this reports "N of M checks passed" and counts by severity instead.

* fix(resume): accept localized ongoing periods and reject bare ones

Two period-parsing bugs found in review.

The ongoing-token set was English-only, so a German resume reading
"2020 - heute" was reported as unparseable and the panel told the user
to rewrite a perfectly valid range. Rather than guess translations for
55 locales, a range ending that carries no digits and is not a month
name in the resume's locale is now read as ongoing. That keeps a
genuinely incomplete ending such as "Jan 2020 - Feb" reported, since
"Feb" resolves as a month.

A bare "Present" also parsed as a valid period, so an experience entry
with no start date passed the check. A standalone ongoing token is now
rejected; ongoing tokens remain valid as the end of a range.

* feat(web): scroll ATS findings to the item they belong to

Findings for different items in one section all landed on the section
header, so a date problem on the third role gave no more help than
naming the section.

getAtsFindingTarget now resolves the offending item from the finding's
JSON Pointer against the resume, and SectionItem carries a matching DOM
id. The panel scrolls to that item and falls back to the section header
when the item is not mounted, which is what happens while its section
is collapsed.

* fix(resume): recognize ongoing periods by token, not by shape

The previous heuristic read any short, digit-free range ending as an
ongoing marker, so "2020 - unknown", "2020 - later" and "2020 - tbd"
parsed cleanly and suppressed the finding they should have raised.

Replaced with an explicit table of ongoing words keyed by language,
covering the locales the app ships. Matching is exact, so unrecognized
endings are reported again. A locale missing from the table falls back
to the earlier behaviour of reporting its ongoing periods, which is a
visible gap someone can close by adding a word rather than a silent
hole in detection.

Tests assert every listed token parses and that the table stays
lowercase, since lookups normalize that way.

* fix(ats): parse punctuated ongoing tokens

* fix(ats): parse Unicode punctuated ongoing tokens

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-08-13 23:07:55 +02:00
Santhi PrakashandAmruth Pillai f64d02df7f fix(pdf): ignore phantom gengar skill text nodes (#3289)
* docs(agents): align Redis compose commands with development guide

- Problem: AGENTS.md omitted Redis from dev infrastructure compose commands
  while docs/contributing/development.mdx starts redis for local dev.
- Fix: document full postgres/redis/seaweedfs compose command and note that
  REDIS_URL and ENCRYPTION_SECRET are required for AI agent features.
- Verification: preflight upstream fetch; manual diff against development.mdx
  and compose.dev.yml redis service; duplicate PR gate passed.

* fix(pdf): ignore phantom gengar skill text nodes

- Problem: gengar template resumes with skills keywords fail semantic CSS activation because the legacy renderer emits a harmless empty text node that parity treats as a mismatch.

- Fix: treat the specific empty text artifact as presentation-neutral in the legacy parity comparator and add a regression test for the phantom fontSize 9 node.

- Verification: pnpm test src/semantic/legacy-parity.test.ts in packages/pdf passed (31 tests).

* docs: clarify host and container Redis URLs

- Problem: the development guide only showed the Docker Redis hostname, which fails for host-run development.\n- Fix: document localhost for host execution and redis for Docker execution.\n- Verification: pnpm test src/semantic/legacy-parity.test.ts (31 passed).

* fix(pdf): omit empty skill proficiency text

* test(pdf): cover blank skill proficiency

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-08-13 23:07:49 +02:00
bad431b2fc fix(import): auto-detect JSON format and show readable errors (#1) (#3296)
* fix(import): auto-detect JSON format and show readable errors (#1)

Readable import errors, a fail-soft v4 parser, and auto-detect of the JSON format so uploads just work. The format dropdown becomes an optional override. PDF and DOCX (AI) paths are untouched.

* fix(import): address review feedback on the v4 guard and error message

- reactive-resume-v4-json.tsx: reject arrays in isRecord so array-valued
  basics, sections, or metadata no longer pass the v4 shape guard.
- reactive-resume-v4-json.tsx: reuse the guard's error instance in the
  catch arm instead of allocating a duplicate NOT_V4_MESSAGE.
- error.ts: use a singular "Problem" label for root-level Zod issues so
  the message stays grammatical.
- add a regression test for array-valued v4 branches.

* fix(import): preserve selected JSON format

* test(import): cover selected JSON parser

---------

Co-authored-by: MrTig-afk <MrTig-afk@users.noreply.github.com>
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-08-13 23:01:06 +02:00
Syed Ali Abbas ZaidiandAmruth Pillai 9f13638eab fix(web): drop focus when panning the builder canvas (#3303)
react-zoom-pan-pinch calls preventDefault() on its window-level mousedown
listener so that dragging the canvas does not select text. That also cancels the
browser's native focus shift, so focus stays wherever it was before the pan --
typically the sidebar button that opened the last dialog, since closing a dialog
restores focus to its trigger. A focused button activates on Space keyup, so
holding space to pan and then releasing it reopened the most recent dialog.

Blur the focused element from onPanningStart, which reinstates exactly the focus
change the browser would have made on its own. onPanningStart only fires when the
mousedown target is inside the transform wrapper, so sidebar and dialog clicks are
unaffected, and keyboard-only users never trigger a pointer pan.

Closes #3300

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-08-13 22:54:23 +02:00
13e584d522 fix(icon-picker): allow selecting the empty/no-icon option (#3298)
The icon picker grid starts with an empty string entry that renders the
"no icon" (prohibit) symbol, but the onClick guard "if (icon)" treated
the empty string as falsy and ignored the click. Change the guard to
check for a defined string value so the empty/no-icon option can be
selected.

Closes #3252
Closes #3261

Co-authored-by: Devin <devin@example.com>
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-08-13 22:54:16 +02:00
Santhi PrakashandAmruth Pillai 6035402832 docs(agents): align Redis compose commands with development guide (#3288)
* docs(agents): align Redis compose commands with development guide

- Problem: AGENTS.md omitted Redis from dev infrastructure compose commands
  while docs/contributing/development.mdx starts redis for local dev.
- Fix: document full postgres/redis/seaweedfs compose command and note that
  REDIS_URL and ENCRYPTION_SECRET are required for AI agent features.
- Verification: preflight upstream fetch; manual diff against development.mdx
  and compose.dev.yml redis service; duplicate PR gate passed.

* docs(agents): clarify Redis development URLs

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-08-13 22:54:10 +02:00
Shehraan HafizandAmruth Pillai 69961210bd fix(pdf): missing spaces around bold rich text in PDFs (#3273)
* fix(pdf): missing spaces around bold rich text in PDFs

* fix(pdf): make bold tag matching quote-aware

* fix(pdf): preserve quoted bold tag attributes

* fix(pdf): handle encoded non-breaking spaces in bold boundaries

* fix(pdf): preserve top-level bold boundary spaces

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-08-13 22:54:05 +02:00
Santhi PrakashandAmruth Pillai 7eb6d3bdbf fix(auth): use loopback URL for MCP OAuth JWKS verification (#3297)
* fix(auth): use loopback URL for MCP OAuth JWKS verification

Fetch the JWKS endpoint over the internal loopback address instead of the public APP_URL, so token verification works under Docker port-mapping, reverse proxies, and other deployments where the public URL does not loop back to the Node process.

Also log the specific MCP OAuth verification error instead of swallowing it with a bare catch.

Fixes #3077

* fix(auth): normalize internal JWKS URL and throttle MCP OAuth warnings

 - Problem: default loopback JWKS URL used PORT in dev where the server
   listens on SERVER_PORT (3001), and trailing-slash overrides produced
   //api/auth/jwks; unthrottled warn logs could flood on bad bearer tokens.
 - Fix: resolveInternalBaseUrl trims/normalizes BETTER_AUTH_INTERNAL_URL,
   mirrors apps/server listen-port selection, and MCP OAuth warnings are
   throttled to once per minute.
 - Verification: pnpm exec biome check on changed files; pnpm typecheck.

* fix(auth): declare BETTER_AUTH_INTERNAL_URL in turbo globalEnv

- Problem: Turborepo strict env mode strips undeclared BETTER_AUTH_INTERNAL_URL under pnpm dev, so the JWKS override silently falls back to loopback.
- Fix: add BETTER_AUTH_INTERNAL_URL to turbo.json globalEnv (required for any new env var per CLAUDE.md).
- Verification: python3 JSON parse of turbo.json; confirmed var was absent from globalEnv before this change.

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-08-13 22:51:02 +02:00
Santhi PrakashandAmruth Pillai 5fc9c3ee04 fix(pdf): register Noto punctuation fallback for missing glyphs (#3294)
* fix(pdf): register Noto punctuation fallback for missing glyphs

- Problem: U+2022 bullet characters render as garbled glyphs when the body
  font (e.g. IBM Plex Serif) lacks the glyph and no PDF fallback is registered.
- Fix: append Noto Serif/Sans to the PDF fallback stack as a general-purpose
  punctuation source covering General Punctuation (U+2000–U+206F).
- Verification: pnpm --filter @reactive-resume/fonts test;
  pnpm --filter @reactive-resume/pdf test src/hooks/use-register-fonts.test.ts

* test(fonts): clarify zh-CN fallback test description

- Problem: getPdfFallbackFontFamilies("Times-Roman", { locale: "zh-CN" }) now
  returns ["Noto Serif SC", "Noto Serif"] (the general-purpose punctuation
  fallback is appended), so the test description "returns only the Simplified
  Chinese font for zh-CN (unchanged behavior)" is no longer accurate.
- Fix: rename the test to describe that it uses the Simplified Chinese font
  plus the punctuation fallback. The assertion is unchanged.
- Verification: pnpm --filter @reactive-resume/fonts test -> 45/45 passing.

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-08-13 22:50:58 +02:00
Santhi PrakashandAmruth Pillai a8d1f5a685 docs(contributing): align app names in guide frontmatter (#3287)
- Problem: development.mdx and architecture.mdx frontmatter still referenced
  removed client/worker/artboard apps even though the monorepo only ships
  apps/web and apps/server.
- Fix: update both descriptions to say web and server apps.
- Verification: docs-only; grep confirms only apps/web and apps/server exist.

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-08-13 22:50:53 +02:00
Santhi PrakashandAmruth Pillai dd9843172b docs(contributing): align development guide with dotenvx workflow (#3286)
* docs(contributing): align development guide with dotenvx workflow

- Problem: development.mdx told contributors to use a root `.env` file and
  export DATABASE_URL manually, while AGENTS.md and compose.dev.yml use
  `.env.local` loaded through dotenvx for dev and migration commands.
- Fix: update the setup, migration, dev-server, and database sections to
  match the dotenvx commands documented in AGENTS.md.
- Verification: preflight_ship.py (upstream bug marker present); duplicate
  PR check clean; docs-only change.

* docs(contributing): add cp command to env setup step

- Problem: setup step said to copy .env.example but the bash block only listed variable assignments.
- Fix: add explicit cp .env.example .env.local command and label the following block as edits.
- Verification: manual review of development.mdx; addresses CodeRabbit review on #3286.

* docs(contributing): align AGENTS.md env copy target with dotenvx

- Problem: AGENTS.md told contributors to copy .env.example to .env while all dev commands use .env.local.
- Fix: update the copy instruction to .env.local for consistency with the dotenvx workflow.
- Verification: manual review; folded into #3286 dotenvx alignment PR.

* docs(contributing): dotenvx-wrap remaining dev script references

- Problem: scripts table and troubleshooting still showed bare pnpm dev/db commands after the dotenvx workflow update.
- Fix: prefix dev, db, and port-override examples with dotenvx run -f .env.local --.
- Verification: manual review of development.mdx; folded into #3286.

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-08-13 22:50:48 +02:00
Amruth Pillai 28d698635f build: use official pnpm image 2026-08-13 22:43:53 +02:00
Amruth Pillai 3635b3d578 fix(stylesheet): skip parity on explicit activation (#3316) 2026-08-13 15:53:28 +02:00
Amruth Pillai 5a75eda893 ci: remove semantic CSS acceptance test 2026-08-13 09:40:54 +02:00
github-actions[bot]andCrowdin Bot e4b28e9825 Sync Translations from Crowdin (#3315)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-08-13 09:36:15 +02:00
Amruth Pillai 2d6ea9ce8d fix(auth): allow implicit social signup 2026-08-13 09:23:04 +02:00
Amruth Pillai 0e463883af docs: design implicit social signup 2026-08-13 09:16:18 +02:00
Syed Ali Abbas Zaidi 3a5b12e2a4 fix(web): confirm before the import dialog's provider link navigates away (#3308) 2026-08-11 10:26:04 +02:00
Amruth Pillai 035d94183b fix(web): show static template previews in gallery (#3302) 2026-08-10 12:18:47 +02:00
Amruth Pillai efd950bd93 fix(server): log unhandled rejections instead of crashing the process
Node 24 terminates the whole process on an unhandled promise rejection, so a
single request's stray rejection could take the server down for every user
(as the USER_STOPPED agent-abort bug did). Add a process-level unhandledRejection
handler that logs and keeps serving. Uncaught exceptions are intentionally left
on Node's default crash-and-restart, since process state is unsafe afterward.

Claude-Session: https://claude.ai/code/session_01ULhhLQ24DvnYwzP4afDuye
2026-08-09 16:01:23 +02:00
Amruth Pillai 04100aa9ef fix(agent): abort stopped runs with an AbortError, not a bare string
Stopping (or archiving) an agent run called controller.abort("USER_STOPPED")
with a plain string reason. The AI SDK only recognizes a cancellation when the
reason is an AbortError (err.name === "AbortError" / isAbortError); a bare
string is treated as a real stream error, and its rejection escaped the
background resumable-stream pump and crashed the whole server process with
ERR_UNHANDLED_REJECTION on every user Stop. Abort with a DOMException named
AbortError (label preserved as the message) so the SDK cancels the run
gracefully. Same fix for the USER_ARCHIVED path.

Claude-Session: https://claude.ai/code/session_01ULhhLQ24DvnYwzP4afDuye
2026-08-09 15:46:31 +02:00
Amruth Pillai c292968314 chore: update translations 2026-08-09 15:10:09 +02:00
Amruth Pillai ba1f469950 fix(a11y): label 2FA copy button, restore template focus ring, guard API-key double-submit
- Add an sr-only "Copy secret" label to the icon-only copy button in the 2FA
  enable dialog; it was previously announced as an unlabeled button.
- Add a focus-visible ring to template gallery cards. The only ring was gated
  on the selected state, so keyboard focus was invisible while tabbing.
- Disable the API-key create submit button while the request is in flight to
  prevent duplicate keys from a double-click.

Surfaced by a shadscan UI audit. The remaining ~95 findings were false
positives from the auditor not understanding the pnpm monorepo and the
TanStack Start root-route shell, and were waived.

Claude-Session: https://claude.ai/code/session_01JYTniVDeA56o1kGhdoCUoD
2026-08-09 14:48:46 +02:00
Amruth Pillai b4f245a38e fix(deps): restore @react-pdf/textkit patch dropped by the 4.6.0 bump
The dependency-update commit bumped @react-pdf/renderer 4.5.1->4.6.0 (textkit
6.3.0->6.4.0) and silently dropped the pnpm patch that overrides font vertical
metrics to prefer OS/2 sTypo* over inflated hhea values, matching browser line
boxes. 6.4.0 did not upstream it, so the semantic-CSS template visual snapshots
(baselined with the patch, maxDiffPixelRatio 0) no longer matched and the E2E
job failed. Re-create the patch for textkit@6.4.0 and re-register it in
patchedDependencies; remove the orphaned 6.3.0 patch.

Claude-Session: https://claude.ai/code/session_01ULhhLQ24DvnYwzP4afDuye
2026-08-09 14:41:01 +02:00
Amruth Pillai e6a31aab97 fix(stylesheet): warm and reuse the server PDF preflight worker
The server PDF preflight spawned a fresh worker per semantic-CSS edit, each
racing a 15s startup deadline to cold-load the ~721kB+5MB PDF runtime. That
load is super-linear in CPU (~3s at 1 vCPU, >15s on a throttled/shared vCPU),
so on a constrained box every edit hit the startup-timeout path and returned
STYLESHEET_PREFLIGHT_WORKER_FAILED. Since the service only advances the applied
stylesheet when preflight passes, custom styles never applied and the editor
stuck on Checking.

Warm one worker at boot and reuse it (message-based input, respawn on
crash/timeout), so the cold load is paid once instead of per edit. Raise the
render deadline 5s->30s (a rich resume renders ~5-18s on a slow box) and the
readiness ceiling to 120s so the one-time warm completes even when throttled.
Surface worker load failures instead of an unhandled-rejection crash, and log
runner-side failure paths so the previously opaque failure is diagnosable.
Verified in node:24-slim under --cpus=0.25/0.35/0.5: all reused requests pass.

Claude-Session: https://claude.ai/code/session_01ULhhLQ24DvnYwzP4afDuye
2026-08-09 14:30:55 +02:00
Amruth Pillai 88a19619da chore: update dependencies 2026-08-09 12:17:09 +02:00
autofix-ci[bot] 36232b631d [autofix.ci] apply automated fixes 2026-07-31 15:25:43 +00:00
Amruth Pillai 9eec1520a1 Merge branch 'main' of github.com:amruthpillai/reactive-resume 2026-07-31 17:24:40 +02:00
Amruth Pillai 131c1492cd chore: update dependencies 2026-07-31 17:24:32 +02:00
Amruth PillaiandCursor Agent ba8e1be2ab fix(stylesheet): harden PDF preflight and surface worker failures (#3284)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-07-31 07:57:54 +02:00
Amruth PillaiandCursor Agent 4a8f87ab8f fix(web): stop custom styles from sticking on Checking (#3283)
Concurrent compile requests from editor intelligence were rejecting
in-flight edit compiles as stale, and the store swallowed that rejection
without leaving compiling. Resolve all compile results and surface
compile failures as an error status so styles can apply again.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-07-31 01:00:45 +02:00
github-actions[bot]andCrowdin Bot 186c400ab7 Sync Translations from Crowdin (#3281)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-30 18:59:23 +02:00
Amruth Pillai d314361ad6 fix(ci): run current semantic CSS tests 2026-07-30 18:48:56 +02:00
Amruth Pillai b071a118a3 test: remove slow OpenAPI spec synchronization test 2026-07-30 16:37:03 +02:00
Amruth Pillai 3589b534f5 feat: enable semantic CSS by default 2026-07-30 16:28:44 +02:00
github-actions[bot]andCrowdin Bot 1ee24e5a9f Sync Translations from Crowdin (#3280)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-30 13:01:23 +02:00
Amruth Pillai 93bf1e882d docs: update spec.json 2026-07-30 13:00:30 +02:00
Amruth Pillai ae8d48bcee chore: update translations 2026-07-30 12:59:19 +02:00
Amruth Pillai 517199471a docs: add changelog of v5.2.5 2026-07-30 12:56:28 +02:00
Santhi Prakash 15f8bce988 docs: align pnpm version with packageManager field (#3278) 2026-07-30 12:47:00 +02:00
Amruth Pillai 164a279306 chore: update dependencies 2026-07-30 12:45:49 +02:00
github-actions[bot]andCrowdin Bot 79e4a3ddc8 Sync Translations from Crowdin (#3279)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-30 12:41:45 +02:00
Amruth PillaiandCursor Agent d2ffbf9618 feat: add semantic CSS stylesheets (#3274)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-07-30 12:39:15 +02:00
4ac19f81b3 fix: clamp page margin values to [0, 100] to prevent crash on paste (#3277)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-07-29 18:13:42 +02:00
Amruth Pillai b303b89758 fix(web): restore Tiptap Enter and list editing 2026-07-28 15:10:24 +02:00
Amruth Pillai c6ac3fd1a9 docs: remove redirects 2026-07-28 09:39:23 +02:00
Amruth Pillai fe6f84e06d docs: resolve final comparison review findings 2026-07-28 09:19:16 +02:00
Amruth Pillai 9d6426b2e0 docs: publish resume builder comparison cluster 2026-07-28 09:04:43 +02:00
Amruth Pillai d34a429dea docs: remove unsupported deployment comparisons 2026-07-28 08:59:56 +02:00
Amruth Pillai b69583c181 docs: compare career and template resume tools 2026-07-28 08:57:31 +02:00
Amruth Pillai 50f50b2672 fix(server): use public URL for homepage metadata 2026-07-28 08:56:43 +02:00
Amruth Pillai fb8c73be76 docs: narrow unsupported AI comparison claims 2026-07-28 08:53:18 +02:00
Amruth Pillai 18468a5658 docs: tighten AI comparison sourcing 2026-07-28 08:50:03 +02:00
Amruth Pillai 048eab3b49 fix(web): preserve bootstrap attribute order 2026-07-28 08:48:14 +02:00
Amruth Pillai ca774c77c8 docs: compare AI and ATS resume builders 2026-07-28 08:46:25 +02:00
Amruth Pillai a4897c20d7 docs: canonicalize getting started routes 2026-07-28 08:41:53 +02:00
Amruth Pillai bed14a72af docs: compare mainstream resume builders 2026-07-28 08:39:18 +02:00
Amruth Pillai 93c06934bd fix(web): preserve Rocket Loader exclusion in build 2026-07-28 08:38:05 +02:00
Amruth Pillai 1e665fbe7e perf(web): remove homepage video from the LCP path 2026-07-28 08:35:07 +02:00
Amruth Pillai 30812f8a8e docs: compare Reactive Resume with design editors 2026-07-28 08:33:08 +02:00
Amruth Pillai dd0531091b fix(server): limit immutable media cache headers 2026-07-28 08:31:26 +02:00
Amruth Pillai a2901bfb2e docs: plan SEO comparison content cluster 2026-07-28 08:08:29 +02:00
Amruth Pillai 418c7887ee fix(server): emit initial homepage SEO metadata 2026-07-28 08:07:53 +02:00
Amruth Pillai 12407d473d docs: plan SEO and AEO performance improvements 2026-07-28 07:36:27 +02:00
Amruth Pillai 36a46cfd66 docs: design SEO comparison content cluster 2026-07-28 07:34:36 +02:00
Amruth Pillai 0868a92e62 docs: design SEO and AEO performance improvements 2026-07-28 07:31:35 +02:00
Amruth Pillai 822d6f9431 chore: bump version to 5.2.4 2026-07-28 07:00:16 +02:00
Amruth Pillai 994093b981 chore: update translations 2026-07-27 20:57:54 +02:00
Amruth Pillai 9110e86997 refactor: ponytail audit 2026-07-27 20:26:16 +02:00
ServaTilisandClaude Opus 4.8 bb1fb3a7d6 perf(api): lazy-load PDF renderer to cut server cold-start (#3244)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 13:42:07 +02:00
Diego Vega Centeno e34e7be6e0 fix(pdf): add flex to skill name to participate in layout sizing (#3253) 2026-07-27 13:39:14 +02:00
Rakshit Kaintura 34c03b1f73 fix(auth): map oauth login to correct user id instead of account id (#3256) 2026-07-27 13:38:30 +02:00
EMRANandAmruth Pillai 0eb9ce012e fix: profile picture delete icon should reset image correctly (#3176) (#3258)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-07-27 13:31:57 +02:00
autofix-ci[bot] 966bc3ed58 [autofix.ci] apply automated fixes 2026-07-27 11:23:26 +00:00
EMRAN 08d859010c fix: allow award title unbold via custom styles (#3250) (#3257) 2026-07-27 13:22:37 +02:00
Emanuele TonelloandAmruth Pillai 47349e7ab3 feat: support editing AI provider models in the UI and auto-fill LinkedIn job postings (#3259)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-07-27 13:21:57 +02:00
Amruth Pillai 3266066826 chore: update dependencies 2026-07-27 13:19:54 +02:00
autofix-ci[bot] 6503da7e49 [autofix.ci] apply automated fixes 2026-07-27 11:14:46 +00:00
落尘 2a0782517c fix: clamp custom style numeric inputs (#3262) 2026-07-27 13:13:48 +02:00
cielhaidir d4cf260aed fix(api): support HTTPS job posting fetches (#3267) 2026-07-27 13:13:25 +02:00
Santhi Prakash e6b4733c5f docs(contributing): fix troubleshooting accordion code block formatting (#3269) 2026-07-27 13:12:44 +02:00
Diego Vega CentenoandAmruth Pillai 689e7e24d4 Filter invalid style intents to preserve valid custom styles (#3241)
* Filter invalid style intents to preserve valid custom styles

* fix: preserve valid custom style rules

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-07-09 15:50:34 +02:00
github-actions[bot]andCrowdin Bot 9085a199cf Sync Translations from Crowdin (#3243)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-09 15:35:39 +02:00
Andrea Accardo d536b1921f fix: bullet list indentation on page break (#3242)
Signed-off-by: aaccardo <hackardo@gmail.com>
2026-07-09 15:33:40 +02:00
Amruth Pillai 2b0aac820c chore(i18n): sync translations from crowdin 2026-07-09 01:24:39 +02:00
Amruth Pillai ac98139096 docs: pin v4 migration script checkout 2026-07-09 01:17:56 +02:00
Amruth Pillai d50948ddee chore(i18n): update application timeline translations 2026-07-09 01:17:56 +02:00
Amruth Pillai 42bac75ae2 Update README.md 2026-07-09 00:58:11 +02:00
Amruth Pillai c77745f34e Update README.md 2026-07-09 00:56:40 +02:00
Andrea AccardoandAmruth Pillai ed5d10c491 fix: solve list marker page break (#3177) (#3236)
Signed-off-by: aaccardo <hackardo@gmail.com>
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-07-09 00:47:50 +02:00
Amruth Pillai 18d0c14aa1 feat: add application timeline history (#3237)
* feat: add application timeline history

* fix: address application timeline review

* fix: keep application tracker e2e stable

* fix: use stable timeline e2e selector

* fix: target timeline note input in e2e
2026-07-09 00:36:45 +02:00
Amruth Pillai 1124d3dfda Fix OAuth metadata authorization server list 2026-07-08 22:15:11 +02:00
Amruth Pillai 90105cb148 chore: integrate improve-integration 2026-07-08 19:08:31 +02:00
Amruth Pillai 73daf22b2f docs: publish MCP registry metadata 2026-07-07 18:50:07 +02:00
github-actions[bot]andCrowdin Bot 25021507a0 [skip ci] chore(i18n): sync translations from crowdin (#3233)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-07 18:21:35 +02:00
Amruth Pillai 8570c1c70a fix: render cover letter exports without resume chrome 2026-07-07 17:47:52 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> 5270a2a9a0 docs: tighten SEO titles and descriptions across new docs (#3232)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-07 15:08:12 +00:00
github-actions[bot]andCrowdin Bot b87a9d8282 Sync Translations from Crowdin (#3231)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-07 17:06:14 +02:00
Amruth Pillai 46afc65cc6 Add application tracker REST and MCP parity
Add comprehensive Application Tracker REST and MCP coverage, document the MCP workflow, add Markdown/ActionLint checks, bump the release version, and fill all extracted translations.
2026-07-07 17:02:39 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> dfc5559625 docs: document expanded command palette entity search (#3225)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-06 00:32:00 +02:00
github-actions[bot]andCrowdin Bot d37ac57cc5 [skip ci] chore(i18n): sync translations from crowdin (#3224)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-06 00:30:34 +02:00
Amruth Pillai fb9c217af2 feat: add command palette entity search 2026-07-06 00:29:06 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> 0a64312bf8 docs: lengthen short SEO descriptions on two guides (#3222)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-05 21:45:25 +00:00
Amruth Pillai b404dbd42a Add application tracker (#3220)
* feat(applications): job application tracker with AI copilot

Add an Applications module at /dashboard/applications: pipeline board
(dnd-kit), table view with bulk actions, Insights (fit tiles, funnel,
sources, shareable funnel-flow SVG), campaigns, tags, CSV import, and
Add/Edit/Detail slide-overs. Each application links a live Reactive
Resume.

AI "Application Copilot" (applications.ai.*): job-posting autofill,
resume↔job match score (fit ring), resume tailoring, and cover-letter /
follow-up drafting — via the user's configured provider.

Board cards + table rows get context menus (edit / move / archive /
delete). Charts are CSS/SVG (no new chart dep); adds a UI Checkbox.

Also includes local TanStack devtools setup and toolchain bumps.

Claude-Session: https://claude.ai/code/session_01TEeRHnEayw2MFCShFRyL5f

* feat(applications): close follow-up gaps + squash migrations

Finish the deferred/open items on the applications tracker:

- Cover-letter upload re-enabled. Fix the storage blocker by deriving the
  key extension from content type (buildFileKey/EXTENSION_BY_CONTENT_TYPE)
  instead of hardcoding .jpeg, so PDFs serve correctly and non-JPEG image
  avatars keep working under FLAG_DISABLE_IMAGE_PROCESSING. Add
  coverLetterUrl/coverLetterName columns + Documents-section upload/remove.
- Contacts editor in the detail sheet (add/edit/remove, keyed per app).
- Board caps rendered cards per column (COLUMN_PAGE_SIZE=50 + "Show more").
- Extract new Lingui messages across locales.
- Guard coverLetterUrl to http(s)/relative at the API boundary.

Squash the five branch-only application-table migrations (create -> +tags
-> +cover-letter -> drop -> re-add) into a single clean CREATE TABLE via
drizzle-kit generate.

Claude-Session: https://claude.ai/code/session_01TEeRHnEayw2MFCShFRyL5f

* chore: update dependencies

* fix(web): address React Doctor findings — compiler, purity, query, component structure

prefer-module-scope-pure-function: hoist buildSubtitle, getDecimalPlaces,
handleLocaleChange, onLocaleChange, stop, listContent/groupedListContent to
module scope so they aren't rebuilt on every render.

react-compiler-todo (??=): rewrite draft.metadata.styleRules ??= [] to the
non-assignment form to unblock auto-memoization.

set-state-in-effect: derive updatedAtLabel at render time instead of syncing
it through useState + useEffect.

query-destructure-result: destructure useQuery results at call site in
resume-analysis and resume-thumbnail to follow TanStack Query v5 convention.

only-export-components: extract non-component exports to sibling .ts files so
Fast Refresh can preserve component state:
  - getNextWeights → typography/get-next-weights.ts
  - detectJsonImportType + ImportType → dialogs/resume/import.utils.ts
  - getLocaleOptions → features/locale/locale-options.tsx
  - preview helpers + DEFAULT_PDF_PAGE_SIZE → preview.shared.utils.ts
  - resolveHighlightToolbarState + defaultHighlightColor → rich-input.utils.ts
  - computeDelta + getSparklinePoints → statistics.utils.ts

no-multi-comp: split multi-component files into focused companions:
  - ResumePane + ToolbarButton → routes/agent/-components/resume-pane.tsx
  - DesktopBuilderShell → builder/$resumeId/-components/desktop-builder-shell.tsx
  - MobileBuilderShell + helpers → builder/$resumeId/-components/mobile-builder-shell.tsx
  - setBuilderLayout/getBuilderLayout moved to -store/sidebar.ts

fix(tests): add Resume type import to section-builder mocks and cast partial
mock data as unknown as Resume to satisfy stricter type checking; fix
noExplicitAny Biome errors in the same mocks.

* feat(applications): improve performance

* chore: fix knip issues

* perf(builder): halve per-keystroke render cost

Section-form fields called `form.handleSubmit()` on every keystroke, which
re-validated the whole form and toggled submit state — firing the render
cascade twice per character (~6809 renders/keystroke, FPS dropping to 9).

Persist via a form-level `listeners.onChange` instead and drop the per-field
`handleSubmit()` (basics, custom-fields, design). Narrow header/dock resume
subscriptions to metadata slices so they no longer re-render on content edits.

Cuts renders 6809 -> 3403 per keystroke (50%), 0 frame drops. Save, preview,
and design controls verified working; 449/449 web tests pass.

* perf(home): eliminate hero CLS from unreserved video box

The hero <section> is `flex items-center` (shrink-to-fit), so the video
wrapper's width depended on the video's intrinsic size, which only resolves
after the media loads. aspect-ratio couldn't reserve height without a definite
width, so the video grew from ~190px to ~563px after first paint and shoved the
centered hero text down ~373px (CLS ~0.095).

Give the wrapper a definite width (w-full + mx-auto on the CometCard) and set an
explicit aspect ratio + width/height on the video so its box is reserved before
load. CLS 0.095 -> 0; hero stays visually centered at max-w-4xl.

* docs: add application tracker guides

* chore(db): squash application migrations

* fix(email): import React in auth template for server-side rendering compatibility

* chore(release): v5.2.1

* Refactor resume rendering and builder workflows

* fix: address application tracker review findings
2026-07-05 23:44:04 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> be43b4556b Update from code changes: refreshed download and cover letter export docs (#3219)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-05 14:53:48 +02:00
github-actions[bot]andCrowdin Bot 0d1bfd4e6b Sync Translations from Crowdin (#3218)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-05 14:42:41 +02:00
Amruth Pillai 20c803e934 feat(export): separate resume/cover-letter downloads, redesign dialog, add Markdown export (#3217)
* feat(export): separate resume/cover-letter downloads, redesign dialog, add Markdown

Let people export the resume and cover letter as distinct documents, and add a
Markdown format alongside PDF / DOCX / JSON (handy for AI agents).

- Server/API: scope PDF generation and download URLs to a resume/cover-letter target.
- Export domain: getResumeExportData + resumeHasCoverLetter in @reactive-resume/resume.
- Redesign the download dialog: one global "What to export" scope toggle (Tabs) plus
  flattened per-format rows, reusing existing UI components and design language.
- Add Markdown export (@reactive-resume/resume/markdown) with a small tiptap-HTML converter.
- Fix blank section headings in DOCX and Markdown by injecting the locale-aware
  section-title resolver (titles are stored empty and resolved at render time).
- Locale catalogs updated for the new strings.

* test(e2e): open the download dialog before exporting JSON

The JSON export moved into the redesigned download dialog, so the spec now opens
the dialog from the Export sidebar section before clicking "Download JSON".
2026-07-05 14:40:49 +02:00
Amruth Pillai 6e7fc68068 fix(design-sync): address CodeRabbit review on preview files
- Add `import type * as React from "react"` to the 26 previews that reference
  `React.CSSProperties` under the automatic JSX runtime (React isn't a global
  type namespace there, so the annotation was unresolved standalone).
- Accordion preview: use `multiple` instead of `openMultiple` — @base-ui/react
  1.6 renamed the prop, so the multi-open cell wasn't actually multi-open.

Skipped CodeRabbit's BrandIcon dark-mode note: the preview renders in the
default light card (the dark <img> is hidden there); per-theme sources would
need component support it doesn't expose.

Claude-Session: https://claude.ai/code/session_01R8Aq8F1nTuvJwfut7g3DVE
2026-07-05 06:30:01 +02:00
Amruth Pillai a28e3baa61 chore(design-sync): add Reactive Resume UI sync inputs (#3216)
* chore(design-sync): add Reactive Resume UI sync inputs

Sync inputs for the claude.ai/design "Reactive Resume" project — the
@reactive-resume/ui design system (39 primary components).

- .design-sync/config.json — converter config (synth-entry, 202->39 card
  prune, overlay/grid cardMode overrides, cssEntry, tsconfig, buildCmd)
- .design-sync/build-css.mjs + tw-entry.css — compile Tailwind v4 globals.css
  to a self-contained stylesheet (inlined IBM Plex font), emit real .d.ts,
  and create the workspace self-symlink the converter needs
- .design-sync/previews/*.tsx — 39 authored preview compositions
- .design-sync/conventions.md — design-agent usage header (readmeHeader)
- .design-sync/NOTES.md — re-sync notes, gotchas, and risks
- packages/ui/tsconfig.emit.json — declaration emit for real prop contracts

Build artifacts (dist/types, .ds-compiled.css, ds-bundle, .cache) are gitignored.

Claude-Session: https://claude.ai/code/session_01R8Aq8F1nTuvJwfut7g3DVE

* chore(knip): ignore .design-sync inputs and drop stale es-toolkit ignore

- Ignore .design-sync/** (design-sync tooling: build-css.mjs + authored
  previews are standalone, not part of the app import graph — knip --fix was
  deleting them and failing the autofix job).
- Remove es-toolkit from apps/server ignoreDependencies: it's now really used
  (apps/server/src/http/health.ts imports withTimeout), so the ignore is stale.

Claude-Session: https://claude.ai/code/session_01R8Aq8F1nTuvJwfut7g3DVE
2026-07-05 06:25:51 +02:00
Amruth Pillai 9f9268f380 fix: migrate better auth 2fa schema 2026-07-05 00:05:53 +02:00
Amruth Pillai 8416a92153 fix: improve mobile responsive layouts 2026-07-04 23:54:02 +02:00
Amruth Pillai 3f6e22addb chore(locales): update PO revision dates and add attachment translations 2026-07-04 22:52:09 +02:00
Amruth Pillai 25b70c24f1 chore(deps): update pnpm to version 11.10.0 2026-07-04 22:49:43 +02:00
Amruth Pillai da40422dfa docs(changelog): add ai agent + command palette polish to v5.2.0
Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 22:40:36 +02:00
Amruth Pillai e15edafbff fix(server): restore @uiw/color-convert runtime dependency
The utils color fallback restore re-added the @uiw/color-convert import
to packages/utils but not to apps/server, whose bundle keeps the package
external. Production server startup failed with ERR_MODULE_NOT_FOUND,
breaking the E2E workflow on main. Re-add the dependency.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 22:40:36 +02:00
autofix-ci[bot] d5b177aa89 [autofix.ci] apply automated fixes 2026-07-04 20:29:16 +00:00
Amruth Pillai d32227ff43 test(web): assert donation-toast cookie security attributes
Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 22:26:25 +02:00
Amruth Pillai 7a0d1e93f3 fix(review): restore cookie attrs + color fallback, drop stale knip entry
Code-review findings:
- donation-toast: restore path/secure/sameSite cookie attributes the
  inlined useCookie dropped (security/scope regression).
- utils/color: restore @uiw fallback so percentage-notation rgb() still
  converts (custom style-rule colors are arbitrary strings); add tests
  pinning the one real difference vs the black fallback.
- knip: drop stale npm-check-updates ignoreDependencies entry.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 22:25:06 +02:00
Amruth Pillai 560956bbe6 test(api): add normalizeAgentResumePatchOperations to ./resume mock
service.ts calls it (added in 82d961241, merged from origin/main) but the
mock omitted it, breaking the patch-apply test. Identity mock matches the
test's pass-through expectation.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 22:04:28 +02:00
Amruth Pillai 7f458dc58d docs(utils): correct color fallback comment to state real ceiling
Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 22:00:58 +02:00
Amruth Pillai 361480445f chore(config): finding 5 — drop vitest scripts and config from packages/config
packages/config has no source or test files; the 4 vitest scripts and
vitest.config.ts exist purely for pipeline symmetry. Remove them.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:57:49 +02:00
Amruth Pillai 57fb23145c chore: finding 4 — remove npm-check-updates from root devDependencies
No script invokes it; pnpm dlx npm-check-updates still works on-demand.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:57:39 +02:00
Amruth Pillai 6207cbc026 chore(env): finding 3 — remove dead CROWDIN_PROJECT_ID/CROWDIN_API_TOKEN/GOOGLE_CLOUD_API_KEY
These three vars are only read by GitHub Actions workflows and tooling/fonts
scripts that access process.env directly — never by app/server runtime code.
Removes them from the env schema (server.ts) and turbo.json globalEnv.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:57:30 +02:00
Amruth Pillai a149e614a7 refactor(ui): finding 2 — delete packages/ui use-controlled-state (zero importers)
apps/web has its own copy with 3 importers; the packages/ui copy had none.
Deletes the hook and its 78-line test.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:57:20 +02:00
Amruth Pillai eab7534ea4 refactor(ui,web): finding 1 — inline use-cookie into donation-toast, drop js-cookie from ui
The 107-line useCookie hook had exactly one consumer (donation-toast) that
only read + set-with-expiry. Inline the two Cookies.* calls directly and
delete the hook + its 128-line test. Drop js-cookie and @types/js-cookie
from packages/ui/package.json (apps/web retains its own js-cookie dep).

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:57:10 +02:00
Amruth Pillai 79a69c5507 refactor(web): finding 1 — extract SectionItemDialog shell from 14 section dialogs
Each of the 14 section-item dialog files (award→volunteer) had identical
~50-line Create/Update shells (DialogContent + header + form + footer).
Added section-item-dialog.tsx with a SectionItemDialog wrapper that takes
title, icon, onSubmit, onCancel, isSubmitting, submitLabel, singleColumn?.
cover-letter and summary-item use singleColumn=true for their one-column
layout. custom.tsx is untouched (it creates section definitions, not items).

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:50:09 +02:00
Amruth Pillai 70df113ee6 refactor(web): finding 9 — public-resume reuses useResumeExport hook
Loosen useResumeExport param to ExportableResume { name, slug, data } so the
public resume page (where name may be '' for non-owner viewers) can reuse it.
getExportName() falls back to data.basics.name then slug, matching the
original inline logic.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:48:04 +02:00
Amruth Pillai 44e9a8a29f refactor(web): finding 6 — delete normalizeResumePreviewProps, inline defaults
normalizeResumePreviewProps had exactly one production caller (preview.tsx).
Defaults now live in the ResumePreview destructuring; the normalizer and its
test cases are removed.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:47:53 +02:00
Amruth Pillai e47cb37ab9 refactor(web): finding 2 — replace dialog renderer indirection with plain typed arrays
defineDialogRenderer/defineDialogRendererRegistry were identity functions.
Each registry now exports a readonly AnyDialogRendererEntry[] directly;
renderer-registry.ts retains only the types. The unused 'domain' field and
its wrapping object are gone; renderers.tsx spreads the arrays directly.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:47:37 +02:00
Amruth Pillai 02538836a9 refactor(web): finding 5 — replace match(boolean) with ternary + ActionButton wrapper
Three auth-settings components (password, two-factor, social-provider) each
duplicated an identical m.div hover/tap wrapper for both branches of
match(boolean). Extracted one ActionButton wrapper and replaced match with
a plain ternary; removed ts-pattern imports.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:47:22 +02:00
Amruth Pillai 22398a502b refactor(web): finding 4 — extract runSignIn helper in SocialAuthButtons
Three near-identical toast→auth→error→invalidate handlers collapsed into
one generic runSignIn(fn) function.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:47:11 +02:00
Amruth Pillai e00348ef84 refactor(web): finding 3 — trim CountUp to {to, duration?, separator?}
No production caller passes from/direction/delay/startWhen/onStart/onEnd.
Removed those props and their setTimeout bookkeeping; updated tests.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:47:01 +02:00
Amruth Pillai 8d17ec6583 refactor(web): finding 8 — drop localStorage migration sentinel, use null-check
Number(null) === 0 caused the old code to use a separate :initialized key as
a migration sentinel. A plain null check on localStorage.getItem() is
sufficient and removes the sentinel key entirely.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:46:50 +02:00
Amruth Pillai e93a56d753 refactor(web): finding 1 - extract TypographyGroupFields component
Extract useTypographyForm helper (captures form creation + sync) and
TypographyForm type. Extract TypographyGroupFields component with
prefix "body" | "heading" to replace 2x4 duplicated form.Field blocks.
Font Weight label ("Font Weights" vs "Font Weight") is preserved per
prefix.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:29:57 +02:00
Amruth Pillai 975cea84e3 refactor(web): finding 5 - replace ts-pattern matchers with data maps
Replace getItemTitle/getItemSubtitle exhaustive ts-pattern matchers with
TITLE_FIELD/SUBTITLE_FIELD lookup maps + special-case branches for
summary and cover-letter. Adds a shared truncateHtml helper. Removes
ts-pattern import.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:29:44 +02:00
Amruth Pillai 34398a578b refactor(web): finding 8 - inline 7 one-caller alias hooks from draft.ts
useInitializeResumeStore, useMergeResumeMetadata, useSaveStatus,
useCanUndo, useCanRedo, useUndoResume, useRedoResume each had exactly
one caller. Inline useResumeStore selectors at call sites and remove
the wrappers. Skipped usePatchResume (4 callers).

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:29:32 +02:00
Amruth Pillai 27efeab796 refactor(web): finding 7 - extract makeCustomSection, derive isStandard
Extract makeCustomSection factory to eliminate duplicate CustomSection
object literal. Replace hand-maintained SectionType array in
isStandardSectionId with membership check via `id in sections`.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:29:20 +02:00
Amruth Pillai f5ec471318 refactor(web): finding 6 - remove useQuery picture preview fetch
The uploads endpoint is public (Cache-Control: public, no auth headers),
so a plain img src suffices. Remove createPicturePreviewUrl, the useQuery
call, and the object-URL cleanup useEffect; simplify PicturePreviewControls
to use normalizedPictureUrl directly.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:29:10 +02:00
Amruth Pillai 376977a9f7 refactor(web): finding 9 - simplify useBuilderSidebar selector
Remove generic selector overload from useBuilderSidebar; callers
destructure the full return object instead of using a selector that
provides no meaningful benefit (state is rebuilt on every render).

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:28:59 +02:00
Amruth Pillai 9b9d5c833c refactor(web): findings 2/3/4 in page/design/custom-styles
Finding 2: Replace 4 number + 3 switch form.Field blocks with
pageNumberFields/pageSwitchFields array maps.
Finding 3: Extract ColorFormField helper for primary/text/background
color fields in ColorSectionForm.
Finding 4: Remove labelPrefix (was always component-specific), replace
local slugify with @reactive-resume/utils/string import; fix ariaLabel
template literals to keep test labels accurate.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:28:47 +02:00
Amruth Pillai 15448cad6a fix(web): raise lib to ES2023 for consumed api source
agent/service.ts uses findLastIndex/Array.prototype.with (ES2023); web
type-checks api source and its ES2022 lib lacked them. lib only affects
type defs, not Vite runtime output.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:04:52 +02:00
Amruth Pillai afd734dd61 refactor(import): add v4section/v4url aliases, inline clamp wrappers, replace classes with fns
Finding 5: Introduce V4Url + V4Section<T> type aliases in reactive-resume-v4-json.tsx; collapse
310-line V4ResumeData type (13x 5-field section header, 11x {label,href}) into typed aliases.
Inferred shape is structurally identical.

Finding 6: Remove 9 single-caller clamp/pxToPt wrappers; replace compile-time constants
(rotation=0, sidebarWidth=35, shadowWidth=0, gapX=4, gapY=6) with literals; inline dynamic
calls (clamp(x, 32, 512) etc.) at the two body/heading typography call sites.

Finding 7: Convert three stateless single-method importer classes to plain functions.
Extract shared rethrowAsImportError() to error.ts, replacing triplicated ZodError catch blocks.
Update web import dialog + all in-package test files to use function API.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:02:17 +02:00
Amruth Pillai 493ef12a9a refactor(utils→import): move single-consumer date/html/level helpers, inline field into fonts
Finding 4 — consumer-count verification:
  @reactive-resume/utils/date  → 1 consumer (packages/import/src/json-resume.tsx)
  @reactive-resume/utils/html  → 1 consumer (packages/import/src/json-resume.tsx)
  @reactive-resume/utils/level → 1 consumer (packages/import/src/json-resume.tsx)
  @reactive-resume/utils/url   → 4 consumers (auth, api/ai, import, server) — SKIPPED, stays in utils
  @reactive-resume/utils/field → 1 consumer (packages/fonts/src/index.ts)

Move date/html/level source + test files into packages/import and update json-resume.tsx imports.
Inline the two-line unique() helper into fonts/src/index.ts and drop the ./field subpath.
Drop the three moved subpaths from packages/utils exports.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:55:22 +02:00
Amruth Pillai a5935dee0f refactor(utils): replace MONTH_NAMES array, drop @uiw/color-convert, use z.enum for localeSchema
Finding 1: Replace hand-rolled MONTH_NAMES[12] array with Intl.DateTimeFormat("en-US", {month:"long"}).
Finding 2: Drop @uiw/color-convert fallback — parseColorString already rejects the same inputs, return "#000000" instead. Remove dep from utils and server package.json.
Finding 3: Replace 56 z.literal calls in z.union with z.enum([...]); identical parse behavior and inferred type.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:53:51 +02:00
Amruth Pillai 5226f04e86 fix(email): use automatic JSX runtime so templates render under tsx
Templates import no React (react-email convention); tsx resolves each
file's nearest tsconfig, so jsx:preserve made esbuild emit classic
React.createElement and background email rendering threw
'React is not defined'.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:45:05 +02:00
Amruth Pillai a1fb0597a3 Merge remote-tracking branch 'origin/main' into chore/ponytail-cleanup 2026-07-04 20:42:34 +02:00
Amruth Pillai a2a2c0a768 Merge branch 'main' into chore/ponytail-cleanup 2026-07-04 20:40:21 +02:00
Amruth Pillai f2ec6a499f refactor(server): replace hand-rolled withTimeout, merge web handlers, clean checks
- health.ts: swap hand-rolled withTimeout for es-toolkit's (fn-taking API); remove
  redundant inner try/catches from checkDatabase/checkStorage since runCheck catches
  all errors (findings 13, health cleanup)
- web.ts: merge handleWebApp/handleWebAppHead into one function; method is the only
  difference — isHead determines body presence (finding 14)
- app.ts: collapse two separate GET/HEAD wildcard routes into app.on(["GET","HEAD"])
- Update web.test.ts and app.test.ts to drop handleWebAppHead references

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:39:14 +02:00
Amruth Pillai 0fb81ad772 refactor(api): replace file-based statistics cache with in-memory Map
Removes fs, path, env, getLocalDataDirectory imports from statistics service.
A module-level Map<string, {value, cachedAt}> gives the same TTL semantics
without touching disk. Adds clearStatisticsCache() for test isolation and
updates the test to call it in afterEach instead of relying on unique
LOCAL_STORAGE_PATH temp dirs (finding 5).

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:36:15 +02:00
Amruth Pillai 19470c8cd2 refactor(api,server): storage — sync S3 client, dead upload types, shared inferContentType
- S3StorageService: replace async createClient/getClient/clientPromise chain with a
  synchronous constructor field; new S3Client() is synchronous (finding 7)
- uploadFile: delete speculative screenshot/pdf upload types (never called); hardcode
  picture key and simplify to 3 lines (finding 6)
- Export inferContentType from @reactive-resume/api/features/storage (finding 8)
- uploads.ts: import inferContentType from storage instead of local duplicate; inline
  buildResponseHeaders into handleUpload (findings 11, 12)

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:33:34 +02:00
Amruth Pillai 3f050e5213 refactor(api): introduce mapAgentEnvironmentError oRPC middleware (finding 1)
Replace 12 near-identical try/catch blocks in threads/actions/attachments/messages
handlers with a single AnyMiddleware that maps the AGENT_ENVIRONMENT_UNAVAILABLE
sentinel to PRECONDITION_FAILED ORPCError.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:31:25 +02:00
Amruth Pillai 91c4a2421c refactor(api): simplify agent service — findLastIndex, shared select, combined aggregate
- attachModelPartsToLatestUserMessage: findLastIndex + Array.with, drop wrapper (finding 2)
- getOrCreateForResume: extract findActiveThreadForResume, remove 16-line dup select (finding 3)
- attachments.create: combine sum+count into one query (finding 4)

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:29:19 +02:00
Amruth Pillai 82d961241e fix: polish ai agent and command palette 2026-07-04 20:24:39 +02:00
Amruth Pillai f3a60432df refactor(pdf): introduce createBaseTemplateStyles factory (~1,150 lines removed)
Move 14 identical style slots (text/heading/div/inline/link/small/bold/
richParagraph/richListItemRow/richListItemMarker/richListItemContent/
splitRow/alignEnd/picture) from all 15 template Page.tsx files into a
single createBaseTemplateStyles factory in templates/shared. Each template
now spreads …base and keeps only its real overrides. Resolved StyleSheet
values are identical to before. Update rtl-fixture and rich-text-template-
styles tests to guard the factory file rather than each template directly.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:13:42 +02:00
Amruth Pillai 0701f3b62a refactor(pdf): extract EmailContactItem/PhoneContactItem/LocationContactItem
Add three shared contact-item components to packages/pdf/src/templates/shared/
contact-item.tsx alongside the existing WebsiteContactItem and CustomFieldContactItem.
Replace ~18 lines of inline email/phone/location JSX in all 15 template headers
with the shared components (EmailContactItem accepts an optional iconName prop
for ditgar's "at" variant; rhyhorn's array-push pattern also updated).

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:04:37 +02:00
Amruth Pillai cf738b9306 refactor(pdf): replace ts-pattern match chains with satisfies Record maps
Two match chains in sections.tsx are replaced with plain lookup maps typed
via `satisfies Record<CustomSectionType, ...>` which preserves compile-time
exhaustiveness without the ts-pattern dependency. Remove ts-pattern from
packages/pdf/package.json.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 19:52:13 +02:00
Amruth Pillai fcc10c6b31 refactor(pdf): deduplicate parseFiniteNumber/parsePxValue/parseFontSize
Export parseFiniteNumber and parsePxValue from icon-size.ts (already the
canonical home of these helpers). Remove the three private copies in
rich-text-spacing.ts and import the shared ones instead.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 19:49:39 +02:00
Amruth Pillai 3e96605d4c fix(web): avoid retesting new AI providers 2026-07-04 19:42:41 +02:00
Amruth Pillai 7e35e8b657 chore(schema): delete tautology test, add helpers, collapse styleRuleSlots
- Delete templates.test.ts: re-tests z.enum semantics with a hardcoded
  fixture that duplicates the template list in templates.ts.
- default.ts: add 2-line section(icon) helper; 12 repeated 7-field blocks
  collapse to one-liners. Output is byte-identical.
- data.ts: add 2-line itemSection<T> factory; 12 baseSectionSchema.extend()
  blocks collapse to one-liners. Inferred types unchanged.
- data.ts: replace 15-field hand-listed styleRuleSlotsSchema with
  z.partialRecord(styleSlotSchema, styleIntentSchema); parse behaviour and
  TypeScript type are equivalent (unknown keys still rejected).

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 19:38:01 +02:00
Amruth Pillai 8de15822fb chore(db): delete tautological schema-mirror tests, collapse db singleton
- Delete 4 test files (auth/resume/agent schema mirrors + relations type check)
  that re-assert Drizzle table/column names copied verbatim from the schema files.
- Collapse makeDrizzleClient() + createDatabase() into a two-line module-level
  singleton; preserves globalThis.__drizzle caching behaviour.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 19:36:22 +02:00
Amruth Pillai e2099b9002 refactor(auth): replace z.enum with plain TS union; drop zod dependency
AuthProvider was the only zod usage in the package — a z.enum solely to
infer a type. Replace with a plain union type and remove zod from
packages/auth/package.json dependencies.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 19:27:24 +02:00
Amruth Pillai 5762eb6a3e refactor(ai): collapse parser prompts to template; replace makeEmptyItem with structuredClone
- Replace pdf-parser-system.md + docx-parser-system.md with a single
  parser-system.md template; prompts.ts substitutes 6 placeholders per
  source type. Produced strings are byte-identical to the former files.
- Remove makeEmptyItem recursive walker (all SECTION_ITEM_SHAPES leaves are
  already zero-valued) and call structuredClone(shape) instead.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 19:27:13 +02:00
Amruth Pillai dfe75390cd refactor(docx): getBaseRun helper, hoist mainConfig, derive sections, remove dead OL code
- Add getBaseRun() next to getHtmlStyle() and replace 8 inline baseRun spreads (~27 lines).
- Hoist one mainConfig object in buildDocument; sidebar call spreads only the two
  differing color keys (~17 lines).
- Remove BUILT_IN_SECTIONS Set; derive membership via `sectionId in data.sections` (~14 lines).
- Delete unreachable ordered-list numbering machinery in html-to-docx.ts: numberingRef is
  always undefined through all call paths, so isOrdered && numberingRef is always false (~15 lines).

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 19:27:01 +02:00
Amruth Pillai 439ae114f9 refactor(mcp): derive tool metadata from single TOOL_META record
- Extract one TOOL_META record (title/description/inputSchema/annotations per
  tool) consumed by both registerTools and buildMcpServerCard, eliminating the
  ~200-line duplication in the server card.
- Collapse TOOL_ANNOTATIONS from 14 × 4-line inline objects to 5 named
  annotation-preset consts (READ_IDEMPOTENT, WRITE_NON_IDEMPOTENT, etc.),
  saving ~55 lines.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 19:26:44 +02:00
github-actions[bot]andCrowdin Bot 9b41edb43d Sync Translations from Crowdin (#3213)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-04 19:09:57 +02:00
Amruth Pillai d87c6758ab chore: update dependencies 2026-07-04 19:06:31 +02:00
Amruth Pillai 44fa2badb4 fix(ai): pass system prompt via system option instead of system-role message
Some providers (OpenAI Responses API, others) reject system-role messages in
the messages array with AI_InvalidPromptError. Move the analyze/parse system
prompts to the generateText system option, matching the chat handler.
2026-07-04 18:31:40 +02:00
Amruth Pillai 0abb5a07e6 fix(i18n): strip merge conflict markers reintroduced by crowdin sync 2026-07-04 18:18:58 +02:00
Amruth Pillai a9a38ff5dc fix(e2e): restore template name as img alt in gallery so template-switch test passes 2026-07-04 18:17:04 +02:00
github-actions[bot]andCrowdin Bot bf70705f1f [skip ci] chore(i18n): sync translations from crowdin (#3211)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-04 18:09:01 +02:00
github-actions[bot]andCrowdin Bot 332aa210c4 [skip ci] chore(i18n): sync translations from crowdin (#3209)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-04 18:08:01 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> da6a9f2c78 fix: repair MDX parse error in community spotlight to unblock link check (#3210)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-04 16:07:50 +00:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> 4541cf1cdc Update from code changes: refresh builder guides for v5.2.0 (#3208)
* docs: refresh builder guides for v5.2.0 (undo/redo, version history, embedded AI, header downloads)

* docs: tighten SEO descriptions on v5.2.0 builder guides

---------

Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-04 18:06:45 +02:00
Amruth Pillai 27df724d2a feat(builder): show live PDF previews in template gallery, remove hover card 2026-07-04 18:05:45 +02:00
Amruth Pillai bc09430fdf chore(i18n): translate missing strings for am-ET, el-GR, km-KH, th-TH
97 strings translated for Amharic (am-ET), 97 for Greek (el-GR),
97 for Khmer (km-KH), and 98 for Thai (th-TH). All placeholders
preserved verbatim ({0}, {label}, <0>, __APP_VERSION__, etc.).

Claude-Session: https://claude.ai/code/session_012jucCw5SQBpWMoZYwVEbeC
2026-07-04 17:25:18 +02:00
Amruth Pillai e936f93e3a chore: translate missing strings for kn-IN, ml-IN, or-IN, ta-IN, te-IN
Claude-Session: https://claude.ai/code/session_012jucCw5SQBpWMoZYwVEbeC
2026-07-04 17:07:34 +02:00
Amruth Pillai 3ba566506a chore: translate missing strings for bn-BD, hi-IN, mr-IN, ne-NP
Fill 96 empty msgstr entries per locale in the Indic/Devanagari batch:
Bengali (bn-BD), Hindi (hi-IN), Marathi (mr-IN), and Nepali (ne-NP).
2026-07-04 16:41:13 +02:00
Amruth Pillai a7c599b724 chore: translate missing strings for ar-SA, fa-IR, he-IL
Claude-Session: https://claude.ai/code/session_012jucCw5SQBpWMoZYwVEbeC
2026-07-04 16:30:27 +02:00
Amruth Pillai dbb0b179c3 chore: translate missing strings for ja-JP, ko-KR, zh-CN, zh-TW
Fill in 96 empty msgstr entries per locale covering new UI strings
(AI assistant, version history, dashboard, account menu, connection
status, editor controls, and more).

Claude-Session: https://claude.ai/code/session_012jucCw5SQBpWMoZYwVEbeC
2026-07-04 16:24:57 +02:00
Amruth Pillai fc634a202d chore: translate missing strings for id-ID, ms-MY, tr-TR, vi-VN 2026-07-04 16:20:42 +02:00
Amruth Pillai 7fab23870f chore: translate missing strings for az-AZ, bg-BG, ru-RU, sr-SP, uk-UA, uz-UZ
Claude-Session: https://claude.ai/code/session_012jucCw5SQBpWMoZYwVEbeC
2026-07-04 16:08:17 +02:00
Amruth Pillai 20a8a3df9d chore: translate missing strings for pl-PL, pt-BR, pt-PT, ro-RO, sk-SK, sl-SI, sq-AL, sv-SE
Fill in all empty msgstr entries (96 per file, 92 for ro-RO) covering
new UI strings: AI assistant, version history, undo/redo, dashboard,
connection status, export data, and related builder strings.

Claude-Session: https://claude.ai/code/session_012jucCw5SQBpWMoZYwVEbeC
2026-07-04 15:54:15 +02:00
Amruth Pillai e38e37383d chore: translate missing strings for cs-CZ, da-DK, fi-FI, hu-HU, lt-LT, lv-LV, nl-NL, no-NO 2026-07-04 15:38:38 +02:00
Amruth Pillai d45116b2ba chore: translate missing strings for af-ZA, ca-ES, de-DE, en-GB, es-ES, fr-FR, it-IT 2026-07-04 15:24:14 +02:00
Amruth Pillai 6ad4f13914 docs: update changelog to not use images 2026-07-04 14:59:47 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> 2f5d321051 docs: trim changelog description to meet SEO length target (#3207)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-04 12:58:39 +00:00
Amruth Pillai 57e9c8c487 v5.2.0: undo/redo, version history, embedded AI assistant, mobile builder & more (#3205) 2026-07-04 14:57:25 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> 09bc6ec521 docs: add pg pool error handler fix to weekly changelog (#3204)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-04 07:30:15 +00:00
helder-mattosandClaude Opus 4.8 50885176e0 fix(db): attach an error handler to the pg pool (#3172)
A Postgres connection can drop at any time — e.g. a serverless Postgres such as
Neon terminating the connection (error code 57P01). node-postgres surfaces this as
an 'error' event; without a listener node re-throws it as an unhandled 'error' and
crashes the process. Idle clients emit on the pool, but a client that is connecting
or checked out emits on the client itself, so we listen on both the pool and each
client. The pool then discards the dead client and opens a fresh one on the next
query, so the server survives transient/idle disconnects.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 09:28:18 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> cbeecf6596 Draft changelog: weekly update for post-v5.1.9 changes (#3203)
* docs: add weekly changelog entry for post-v5.1.9 changes

* docs: improve changelog title and description for SEO

---------

Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-04 07:22:49 +00:00
Shanu S ee970f2961 fix: wrap list item content in flex View so bullets respect margin (#3202)
* fix(pdf): wrap list item content in flex View so bullets respect margin

* fix(pdf): add minWidth 0 to bullet content so long text wraps
2026-07-04 09:20:01 +02:00
Amruth Pillai 578a983209 feat: polish micro-interactions with consistent motion system across the app
- add strong easing tokens (--ease-out-strong, --ease-in-out-strong, --ease-drawer)
- restore menu open/close animations (dropdown, context menu, combobox) using
  interruptible transitions via Base UI starting/ending styles
- dialogs: 200ms enter / 150ms exit; command palette opts out (keyboard-initiated)
- tooltips: 400ms initial delay with instant adjacent hovers via provider grouping
- buttons: scale press feedback, specific transition properties instead of transition-all
- tabs: sliding active-tab indicator via Base UI Tabs.Indicator
- sheet: iOS drawer curve with asymmetric enter/exit timing
- animate form validation messages and auth page entrance
- remove dead radix-idiom accordion classes in builder sidebars
2026-07-03 21:48:32 +02:00
autofix-ci[bot] 617135466d [autofix.ci] apply automated fixes 2026-07-03 19:22:57 +00:00
Diego Vega Centeno fa4c8adf78 fix: add conditional flex:1 for nested list content to fix layout (#3198) 2026-07-03 21:22:03 +02:00
Amruth Pillai 5b8ab33888 fix: rethrow non-ENOENT errors when loading .env 2026-07-03 20:55:41 +02:00
Amruth Pillai 0ba44865c7 test: add e2e specs for dashboard, sections, templates, sharing and settings workflows
- dashboard-lifecycle: rename, duplicate, delete via card context menu
- section-editing: add experience item, verify persistence across reloads
- template-switch: switch template in gallery, verify persisted selection
- sharing-password: password-protect public link, unlock as anonymous visitor
- lock-resume: lock blocks update/delete, unlock restores them
- settings-profile: profile name change persists
2026-07-03 20:04:36 +02:00
Amruth Pillai a4999c04af refactor: remove dead code, unused exports and redundant dependencies
- drop dotenv (Node 24 process.loadEnvFile) and dompurify (only used by dead code)
- delete unused ui components/hooks (card, progress, checkbox, use-confirm, use-prompt)
- delete dead sanitizeHtml/sanitizeCss, url-security helpers, patch-resume tool,
  schema/page, createResumePatches, patch-proposal preview builder, fonts fallback helpers
- inline single-caller wrappers (flags service, auth getSession, pdf renderer passthrough)
- deduplicate template color helpers into shared/color-helpers
- unexport 50+ internal-only symbols, remove dead export-map entries
- replace hand-rolled unique()/useIsMobile with Set spread and usehooks-ts
2026-07-03 20:04:36 +02:00
Amruth Pillai 2a80e6a1df chore: update dependencies 2026-07-03 20:04:36 +02:00
SimoandClaude Sonnet 4.6 4c8cc5c016 fix: remove overflow hidden from safeTextStyle (#3186)
overflow: hidden on Text elements in @react-pdf/renderer v4.x clips
content at its initial computed height, hiding any text after a line
break. minWidth/maxWidth/flexShrink already handle horizontal
containment so nothing else breaks.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 18:59:00 +02:00
Amruth Pillai d3735ebe27 chore: update dependencies 2026-06-29 09:09:00 +02:00
github-actions[bot]andCrowdin Bot 8eab8fdaa0 Sync Translations from Crowdin (#3183)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-06-29 09:01:42 +02:00
autofix-ci[bot] fbb9938af6 [autofix.ci] apply automated fixes 2026-06-29 01:26:48 +00:00
Andrea Accardo 5080fddf51 bugfix: fix list break with marker (#3177) (#3178)
* bugfix: fix list break with marker (#3177)

Signed-off-by: aaccardo <hackardo@gmail.com>

* refactor: fix code smell

Signed-off-by: aaccardo <hackardo@gmail.com>

---------

Signed-off-by: aaccardo <hackardo@gmail.com>
2026-06-29 03:25:55 +02:00
Amruth Pillaicoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>Cursor Agentautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
dfd2c77bc9 Add Playwright E2E test setup (#3169)
* docs: design e2e test setup

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* docs: plan e2e test implementation

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* test: add playwright e2e scripts

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* test: configure playwright

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* test: add core e2e fixtures and specs

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* ci: run e2e tests on pull requests

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* [autofix.ci] apply automated fixes

* test: stabilize e2e suite

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* test: ignore playwright artifacts

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* Update .github/workflows/e2e.yml

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* test: address e2e review feedback

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-20 07:39:06 +02:00
Amruth PillaiandCursor Agent 56c90947e4 fix: ensure Atlas Cloud sponsor logo links to website (#3170)
Prevent the sponsor logo images from intercepting clicks so the
anchor reliably opens atlascloud.ai in a new tab instead of the SVG
asset.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-06-20 06:03:46 +02:00
github-actions[bot]andCrowdin Bot ae2a1dac12 Sync Translations from Crowdin (#3167)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-06-18 18:59:00 +02:00
Amruth Pillai dcf1b28c22 chore: release v5.1.9 2026-06-18 18:57:09 +02:00
Amruth Pillai f14d8ce693 feat: add Atlas Cloud sponsorship placements 2026-06-18 18:53:01 +02:00
robertoandAmruth Pillai 2317a82106 fix: register language-specific Noto fallback fonts for non-Latin scripts (#3158)
* fix: use language-specific Noto fonts for CJK PDF fallback

* feat: extend fallback to Arabic/Hebrew/Thai

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-06-17 13:37:09 +02:00
Cantale08andsantino cantale a523e13bfd Problem in word wrapping in the templates (#3136)
Co-authored-by: santino cantale <sopor@ARBA-TSM-WS020.tsm.local>
2026-06-17 13:28:19 +02:00
albanofazzitoandAlbano 1be75240dd fix: use non-empty placeholder for redacted resume name (#3138)
* fix: use non-empty placeholder for redacted resume name

* fix: update stale test title to match new placeholder behavior

---------

Co-authored-by: Albano <alumno26.fazzito.albano@ipm.edu.ar>
2026-06-17 13:27:39 +02:00
sdeonvacation 7275da7303 fix(ai): handle markdown-fenced JSON in analyzeResume response (#3142)
Some providers (notably Anthropic via proxies) wrap JSON output in
markdown code fences (```json ... ```), causing Output.object to
throw NoObjectGeneratedError / JSONParseError.

Replace Output.object with manual JSON boundary extraction that works
regardless of fencing. Also propagate the original AISDKError as cause
in throwAiProviderGatewayError for better diagnostics.
2026-06-17 13:27:06 +02:00
Lihan YANG bc498449d3 Fix MCP PDF download test mock (#3144) 2026-06-17 13:26:42 +02:00
Andrea Accardo 3937f7ed2b feat: add flag to disable api rate limit (#3149)
Signed-off-by: aaccardo <hackardo@gmail.com>
2026-06-17 13:26:27 +02:00
github-actions[bot]andCrowdin Bot d6de3f830f Sync Translations from Crowdin (#3162)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-06-17 13:18:31 +02:00
Amruth Pillai ef5ff30b13 chore: update linter configuration and add rimraf dependency 2026-06-17 10:51:10 +02:00
Amruth Pillai 37faf592b7 chore: update dependencies 2026-06-17 10:40:23 +02:00
github-actions[bot]andCrowdin Bot 76bd1e80f7 [skip ci] chore(i18n): sync translations from crowdin (#3148)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-06-06 09:33:09 +02:00
Amruth Pillai 042d076efa chore: update dependencies 2026-06-05 23:35:23 +02:00
github-actions[bot]andCrowdin Bot b9e4ab78ef Sync Translations from Crowdin (#3135)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-06-01 15:32:53 +02:00
Amruth Pillai 90a9bb9cf1 feat: add "Hide Link Underline" translation for multiple languages 2026-06-01 15:31:39 +02:00
Amruth Pillai 5fb4976ec9 feat: add hide link underline option to resume settings, resolves #3134 2026-06-01 15:30:49 +02:00
github-actions[bot]andCrowdin Bot d6a9bc6c4b Sync Translations from Crowdin (#3132)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-06-01 15:09:18 +02:00
Amruth Pillai 0dcdcd2960 chore(release): v5.1.8 2026-06-01 15:08:22 +02:00
JamesGoslingsandAmruth Pillai e96a51f31c feat(editor): add multicolor highlight with auto-contrast text (#3110)
* feat(editor): add multicolor highlight with auto-contrast text

Enable the Tiptap Highlight extension in multicolor mode, replacing the
single-color yellow toggle with a full color picker (16 presets + custom).
When the chosen highlight color is perceptually dark, text inside the mark
automatically renders white for readability.

Changes span the full pipeline:
- Editor: ColorPicker UI, extended renderHTML for contrast detection
- PDF: normalizeMarkElements preserves data-color as inline style
- DOCX: mergeStyle reads actual background-color from <mark>
- Utils: new isDarkColor() luminance helper

Backward-compatible: legacy <mark> without data-color still renders yellow.

Resolves #3109

* fix: handle multicolor highlight edge cases

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-06-01 14:58:15 +02:00
github-actions[bot]andCrowdin Bot 1507d869c7 Sync Translations from Crowdin (#3131)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-06-01 14:05:01 +02:00
JamesGoslingscoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>Amruth Pillai
b932711f08 feat: add section heading icons to PDF templates (#3127)
* feat: add section heading icons to PDF templates

Add customizable Phosphor icons before section titles in PDF output.
Users can toggle visibility globally via a new "Hide section heading icons"
switch (independent of item-level icons) and customize individual section
icons through the builder sidebar icon picker.

- Add `icon` field to `baseSectionSchema` and `summarySchema`
- Add `hideSectionIcons` to `pageSchema` (defaults to true for backward compat)
- Implement `SectionHeadingIcon` component with heading font-size scaling
- Support "none" sentinel for per-section icon hiding
- Fallback to sensible defaults (briefcase, graduation-cap, etc.) for legacy data
- Add icon picker to builder sidebar sections and custom section dialogs

Closes #2632

* test: add unit tests for section heading icons

- Add tests for getResumeSectionIcon() covering built-in sections,
  summary, custom sections, "none" sentinel, and default fallbacks
- Add schema tests for baseSectionSchema icon field, summarySchema icon,
  and pageSchema hideSectionIcons default behavior

* refactor: minor updates to icon display

* Update apps/web/locales/es-ES.po

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-01 14:02:42 +02:00
Amruth Pillai 1522794733 fix: typecheck 2026-06-01 10:41:37 +02:00
Lihan YANG e00ff8ceca fix(pdf): avoid toReversed in icon size resolution (#3129) 2026-06-01 10:33:04 +02:00
Amruth Pillai 8e72311bc6 Merge branch 'main' of github.com:amruthpillai/reactive-resume 2026-06-01 10:31:53 +02:00
Amruth Pillai a8c70d784c fix: typecheck 2026-06-01 10:31:25 +02:00
Amruth Pillai 0df7f21130 feat: implement download_resume_pdf mcp tool 2026-06-01 10:26:28 +02:00
Amruth PillaiandCursor Agent 6852f586ea ci: purge Cloudflare cache after release Docker image deploy (#3122)
* ci: purge Cloudflare cache after release Docker image deploy

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* ci: add timeout and retries to Cloudflare cache purge

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-05-29 01:44:30 +02:00
Amruth PillaiandCursor Agent 1414fecade fix(pdf): apply custom style fontSize to icons and level indicators (#3120) and
* fix(pdf): apply custom style fontSize to icon and level indicator sizes

Map fontSize from Icon and Level Indicator custom style slots to Phosphor
icon size and level indicator dimensions, since react-pdf icons ignore
fontSize in favor of the size prop.

* fix: separate global icon and scoped level indicator font sizes

Icon slot fontSize now drives all resume icons plus level display
decorations. Level indicator fontSize overrides only within level display.
Shared sizing logic lives in schema; design sidebar preview uses global rules.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-05-29 00:41:21 +02:00
Amruth PillaiandCursor Agent c1d11236ae fix(pdf): keep Glalie contact list border box square (#3121)
The decorative border around contact items must not inherit
picture border radius. Set contactList borderRadius to 0.

Fixes amruthpillai/reactive-resume#3119

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-05-29 00:22:51 +02:00
Lihan YANG d09ad2cdc0 Urgent fix server app version dev (#3117)
* fix(server): avoid app version global in MCP dev

* fix(server): use runtime-safe app version metadata
2026-05-29 00:13:45 +02:00
Amruth Pillai 9ce5bacd22 Show experience position with role progression (#3116) 2026-05-28 13:51:02 +02:00
Amruth Pillai 1d761be05b chore(release): v5.1.7 2026-05-27 23:59:14 +02:00
github-actions[bot]andCrowdin Bot c875541001 [skip ci] chore(i18n): sync translations from crowdin (#3113)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-05-27 23:56:10 +02:00
Amruth Pillai 16f4d2c072 docs: using custom styles 2026-05-27 23:52:19 +02:00
Amruth Pillai b491582637 chore: add missing translations 2026-05-27 23:31:58 +02:00
Amruth Pillai c6a654191c feat: improvements to custom styles 2026-05-27 22:16:14 +02:00
Amruth Pillai 8461aa65d5 chore: remove react-doctor from package scripts and update task dependencies in turbo.json 2026-05-27 11:09:33 +02:00
Amruth Pillai b04eef1479 feat: implement style rules 2026-05-27 10:57:33 +02:00
Amruth Pillai 7bff6644d8 docs: add custom styles header target design 2026-05-26 15:12:48 +02:00
Amruth Pillai 8da780c868 feat: update links for improved accessibility 2026-05-26 13:09:30 +02:00
Amruth Pillai dd1e37e579 refactor: better resume two-way sync in case of MCP/API updates 2026-05-26 12:05:38 +02:00
Amruth Pillai 19b412d84d chore(release): v5.1.6 2026-05-26 10:12:56 +02:00
Amruth Pillai 7eea6675c0 chore: update dependencies 2026-05-26 10:09:58 +02:00
Amruth Pillai 273e17c0d3 fix: issue with color format handling, resolves #3104 2026-05-26 09:59:23 +02:00
Amruth Pillai 17cddbad65 fix: reduce default list item row gap 2026-05-26 00:13:38 +02:00
Amruth PillaiandCursor 7557ab13ab fix(api): delete agent threads with sequential cleanup
Remove attachments and soft-delete the thread before storage cleanup so
partial failures do not leave inconsistent DB state. Log storage errors
without failing the request after the thread is marked deleted.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 16:33:03 +02:00
Amruth PillaiandCursor c66560ee12 refactor(web): dedupe isRTL via utils locale module
Re-export isRTL from @reactive-resume/utils/locale in the web locale
helper and consolidate RTL detection tests in the utils package.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 16:32:58 +02:00
Amruth PillaiandCursor 24c882fa9f feat(pdf): roll out shared RTL layout to all templates
Introduce createRtlStyleHelpers and a single rtl flag on RenderProvider,
migrate every template page to mirrored layout styles, and rename
alignRight to alignEnd. Fix plain rich text rendering via PdfText
paragraph renderers and map legacy Times New Roman to Times-Roman.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 16:29:50 +02:00
Yu Sun 86fff7237f fix(auth): reconcile migrated social login accounts (#3095) 2026-05-25 15:46:57 +02:00
Eyal Meschmanandautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> 266bc291eb Add RTL rendering for Rhyhorn template (#3099)
* Add RTL rendering for Rhyhorn template

* Add timeout to wait-healthy just command

* Revert prettier formatting

* Revert and ignore personal relevant files

* Revert prettier formatting from all modified files

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-25 15:46:51 +02:00
Amruth Pillai 6ec4da7914 chore: update dependencies 2026-05-25 15:44:40 +02:00
Umair Khurshid 75e9446134 docs(docker): use shallow clone in quick start (#3096) 2026-05-25 15:39:01 +02:00
Amruth Pillai 39e88dd365 chore: lint using react-doctor, update translations, dynamic imports 2026-05-21 09:56:26 +02:00
Amruth Pillai 3596102c63 chore: update dependencies 2026-05-20 23:12:39 +02:00
github-actions[bot]andCrowdin Bot c77684d317 [skip ci] chore(i18n): sync translations from crowdin (#3087)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-05-19 13:15:46 +02:00
Amruth Pillai 62f8270b3e Squashed commit of the following:
commit b2b0470a1d9267d042ec0ac66523c6635bf5b199
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 13:13:38 2026 +0200

    chore: update .gitignore to include .vite-hooks and modify pnpm-lock.yaml for dependencies

commit d28fadb5cd8706c874e616102878b4a394ec84c1
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 13:08:04 2026 +0200

    fix: remove timestamp conflict guard

commit c6998d9dbab19d09d3c8054feef1d2e4117555eb
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 12:11:51 2026 +0200

    chore(release): v5.1.5

commit f33d168711804880e1f12e88d24290aae16cc258
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 11:58:35 2026 +0200

    revert: compose.yml

commit d961e6535811a10c335525fb33a08d03e737278d
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 11:58:08 2026 +0200

    refactor(agent): replace 'revert' terminology with 'restore' for clarity, resolves #3086

commit 17f351171be218e33f01c469d95e4164d4c8dc57
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 11:10:41 2026 +0200

    refactor(pdf): simplify sidebar section filtering and update summary feature logic

commit d55179b9d76879e3204de185e8b53fadd0a107ed
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 09:53:37 2026 +0200

    chore: update pnpm-lock.yaml and turbo.json

commit 7cade6980e1a04352536bd44ef773f338c4ef599
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 09:38:30 2026 +0200

    fix(polyfill): add tested polyfill for Map Upsert methods

commit 26d175bb9c53d93225d1e907678445252c13d660
Merge: 1cf33dc6c 5b1297fa2
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 09:23:29 2026 +0200

    Merge remote-tracking branch 'origin/main' into feat/explore-hono-orpc-migration

    # Conflicts:
    #	packages/api/src/services/agent-url.ts
    #	packages/runtime-externals/package.json

commit 1cf33dc6c9d81735730ad656e16dab6501c6d6a1
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 09:22:12 2026 +0200

    chore: preserve branch changes before main sync

commit b380a4b00fdbcdd81ff4f8ef72b330fd027ccda5
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Mon May 18 07:50:28 2026 +0200

    chore: lot of fixes for monorepo migration

commit 8fcf0ec64e1c29572ebaff494338368bfcf75760
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 13:57:17 2026 +0200

    chore: update knip version and refine web app routing with new SEO endpoints

commit 234e68086ff15610a93877354c98e2c020364533
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 12:10:06 2026 +0200

    refactor(auth): update OAuth routes to include API prefix and remove unused schema endpoint

commit 91c84b9a8496b0ce21d71cae9f8b2a027638c9ac
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:54:29 2026 +0200

    chore: update dependencies and enhance PWA metadata in web app

commit 150117d4a5a9dd6cd92c64891aad8cae90f6a7af
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:12:35 2026 +0200

    docs: revise manifest-only pwa testing scope

commit 6b939a55661aec9dd8122b184e4b60a5c7325fb5
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:11:33 2026 +0200

    docs: add manifest-only pwa design

commit 1422e1fc96c400948b273210a1067251087d15d4
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:05:04 2026 +0200

    chore(dev): simplify server proxy config

commit bc2ff5a9f6fda41e6c40333c8f163aa23a6c5e48
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:04:50 2026 +0200

    docs: add unsafe oauth redirect plan

commit 445359ebe9b96c1515bf1c4c3f73ba8a8448ec12
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:04:34 2026 +0200

    feat(auth): add unsafe oauth redirect flag

commit 73fffdd24598e56b2793f7657919bc794835892e
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 10:55:02 2026 +0200

    docs: design unsafe oauth redirect flag

commit c0066aa19c15fc8a4c8e5179ed49889c117519f4
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 10:22:04 2026 +0200

    chore: update translation source paths

commit 9033da082418d252aafd6c2eed72f71f014be3d9
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 10:09:25 2026 +0200

    refactor(arch): react spa + hono migration

commit 6f27936c11bda895977dc63ee550c3346d4ce24b
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 01:10:47 2026 +0200

    docs: add docker nightly tagging design

commit ecc1fd9a88a0ee1dca2f1977dfc17f74527fe1da
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Thu May 14 20:05:44 2026 +0200

    feat: migrate to hono spa server
2026-05-19 13:14:21 +02:00
JamesGoslingsandAmruth Pillai 5b1297fa2b fix(pdf): register CJK fallback at primary font weights so bold rende… (#3080)
The CJK fallback (Noto Sans SC / Noto Serif SC) was only registered at
weight 400. When react-pdf rendered CJK characters with font-weight 700
(e.g. <strong> from a rich-text section, or templates' bold style), it
walked the font-family stack [primary, cjkFallback], failed on the
primary (no CJK glyphs), then fell back to the only registered fallback
variant (400) — and react-pdf does not synthesize bold. The bold style
was silently dropped for CJK runs in both the live preview and the
exported PDF, while still working for Latin runs.

Register the CJK fallback at the same weight range as the primary font
(lowest + highest, both styles). When body and heading share the same
fallback (the common case where both are sans or both are serif), merge
their weight ranges so each weight is registered exactly once.

webfontlist.json already ships all weights for the default CJK
fallbacks, so no font-list changes are required.

Closes #3079

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-05-19 09:09:28 +02:00
JamesGoslingsandAmruth Pillai dd7623f11e fix(pdf): align textkit line-box and font metrics to browser behaviour (#3070)
* fix(pdf): align textkit line-box and font metrics to browser behaviour

CJK characters in resumes with a tightened typography line-height
(< ~1.4) had their descenders clipped by the next line. Latin glyphs
in the same resume rendered fine. Fixes the visual regression vs the
v5.0.x Puppeteer-based renderer reported in issue #2986 and follow-ups.

The clipping is caused by two independent gaps in @react-pdf/textkit
relative to standard CSS line-box rules:

1. `height(run)` short-circuits to the user-supplied lineHeight and
   ignores the run's intrinsic ascent + descent. CSS line-boxes are
   spec'd as `max(line-height, content-area)` — when CJK glyphs are
   present the content-area is taller than a tightened lineHeight, so
   the box must grow. textkit didn't, so the baseline (computed from
   the real, larger CJK ascent) sat below the box and the descender
   bled into the next line.

2. `ascent / descent / lineGap` are read directly from fontkit's hhea
   defaults. For Source Han Sans/Serif (the CJK fallbacks registered
   in #3013) hhea is intentionally inflated for legacy Windows GDI
   compatibility (1.45 em vs 1.0 em), so even a fixed line-box would
   have been excessively tall. Browsers (and the v5.0.x Puppeteer
   renderer) read OS/2 sTypoAscender/Descender/LineGap instead, which
   are the values the type designers intend for modern shaping.

Both are upstream behaviours of `@react-pdf/textkit`, but waiting for
an upstream release would leave existing users with broken CJK output.
The fix is shipped as a pnpm patch (~30 LOC):

- `resolveTypoMetrics(font)`: prefer OS/2 typo metrics, fall back to
  hhea when an OS/2 table is absent (e.g. the StandardFont stand-ins
  for Helvetica/Courier/Times). Used by ascent/descent/lineGap so all
  height-related calculations stay consistent.
- `height(run)`: `Math.max(lineHeight || 0, intrinsic)` instead of
  the original short-circuit, matching CSS line-box rules.

The patch is self-contained: existing Latin-only resumes are
unaffected (IBM Plex Serif's typo metrics equal hhea; Roboto's typo
is slightly smaller, but only changes the rendered line-box for users
who set lineHeight below ~1.17, which already used to clip ascenders
under v5.1.x and now lays out as it would in a browser).

Tooling notes:
- `Dockerfile.dev` copies `patches/` before `pnpm install` so the
  dev image build no longer fails on `--frozen-lockfile`. The
  production `Dockerfile` already gets it for free via
  `turbo prune --docker` (the patch reference in package.json marks
  the directory as part of the pruned slice).
- The patch will become a no-op once an equivalent fix lands upstream
  in @react-pdf/textkit; the entry can then be removed from
  `pnpm.patchedDependencies` and the file deleted.

* fix(deps): regenerate lockfile and move patchedDependencies for pnpm 11

The previous commit's lockfile was authored by pnpm 8 (lockfileVersion 6.0)
and kept patchedDependencies under package.json#pnpm. The repository now
declares packageManager: pnpm@11.1.2, which:

- writes lockfileVersion 9.0 and rejects v6 with ERR_PNPM_LOCKFILE_BREAKING_CHANGE
  on --frozen-lockfile (CI failure observed in autofix.ci);
- reads pnpm settings from pnpm-workspace.yaml, silently ignoring the
  package.json#pnpm field — so the textkit patch was no longer applied.

Regenerate pnpm-lock.yaml with pnpm 11.1.2 and move patchedDependencies
to pnpm-workspace.yaml so the patch is applied and CI passes.

* chore: update dependencies

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-05-18 08:08:47 +02:00
Adian Kozlica 63e8c3ca33 fix: polyfill Map.getOrInsertComputed for Waterfox (#3067) 2026-05-15 01:50:13 +02:00
Amruth Pillai e62090cce0 fix: monkey patch a nitro build error (resolves #3065) 2026-05-14 17:21:41 +02:00
Amruth Pillai 0510c7103b chore: update dependencies 2026-05-14 16:41:24 +02:00
github-actions[bot]andCrowdin Bot 1a5c5252d1 [skip ci] chore(i18n): sync translations from crowdin (#3064)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-05-14 16:00:23 +02:00
Amruth Pillai 9df2a5287d chore(release): v5.1.4 2026-05-14 15:57:40 +02:00
Amruth Pillai 6d8d8f6e55 feat: add AI agent workspace (#3062)
* chore(ai): remove local AI store now that providers live server-side

The Zustand-based useAIStore has been replaced by the server-side
aiProviders oRPC router (encrypted credentials persisted in DB).
Delete the dead store + tests, drop the ./store export, and remove
zustand/immer deps which are no longer referenced anywhere in
packages/ai/src/.

* feat(agent): archive/delete actions and read-only state for agent threads

- Backend: mark archived threads as read-only in threads.get and reject
  messages.send with CONFLICT when the thread is archived.
- Frontend: render archived threads in the sidebar with muted styling and
  an Archived badge; add a per-thread dropdown menu in the chat header
  with Archive (non-destructive) and Delete (with confirmation); show a
  read-only banner above the message list that disambiguates archived
  vs. missing-resource causes; suppress the Retry and Stop buttons in
  read-only mode.
- Tests: new packages/api/src/services/agent.test.ts covering the
  archived-thread isReadOnly flag and the archived-thread send refusal.

* fix(agent): abort run on archive and verify ownership before deleting thread

- threads.archive: before flipping status, abort any in-flight run controller
  and clear the active-run state on the thread; cleanup failures are logged
  but do not block the status update.
- threads.delete: assert thread ownership via getThread before destructive
  work so an authenticated user cannot wipe another user's attachment rows
  by passing a foreign threadId.

Adds focused tests for both behaviors.

* feat(agent): display patch diffs and surface revert conflicts

Render apply_resume_patch tool messages with a status-aware card (applied/
reverted/conflicted), expandable operation list, and a Revert button that
correctly handles RESUME_VERSION_CONFLICT responses. Adds unit tests for
the inverse-patch builder and the agentService.actions.revert flow.

* chore(agent): remove out-of-scope attachment tests accidentally added in Task 6

The Task 6 commit (73ef1acca) accidentally re-introduced three attachment-
related tests that belong to a separate task:

- `buildAttachmentModelParts > converts text, image, supported binary, and
  unsupported attachments into model parts`
- `agentService.messages.send > persists the user message with file UI parts
  and links selected attachments to it` (was failing — the `ToolLoopAgent`
  mock is not callable as a constructor)
- `agentService.messages.send > rejects attachments that are missing, foreign,
  or already linked before persisting a message`

These were likely re-added during a stash recovery and were not requested
for Task 6, whose scope was limited to the `agentService.actions.revert`
flow. Remove them along with the helpers/fixtures (`buildAttachment`,
`buildActiveThread`, `selectWhereResult`, `selectOrderByResult`) that they
were the only consumers of. `selectLimitResult` is preserved because it is
used by the revert tests.

* chore(agent): configure runtime dependencies

* feat(db): add agent workspace schema

* feat(api): add agent backend services

* feat(web): add agent workspace UI

* chore(agent): remove legacy builder assistant

* test(agent): make agent stream mocks constructible

* chore(web): remove unused resume replacement hook

* feat(api): add unsafe AI base URL flag

* chore(dev): expose local services in compose

* fix(web): normalize resume preview gaps

* feat(api): improve agent tool handling

* feat(web): polish agent workspace UI

* chore: update dependencies

* fix(api,web): address PR review feedback for agent workspace

Security/correctness:
- Restrict AI provider URLs to http/https even in unsafe mode
- Stop exposing Redis on host network by default
- Make .env.local optional and drop app profile in compose.dev.yml
- Store agent attachments with private ACL on S3
- Reset provider test status when provider/model/baseURL changes
- Decouple non-agent AI endpoints from REDIS_URL requirement
- Fix JSON Patch add inverse for existing object members
- Wrap resume patch + agent action insert in db transaction
- Validate partialMessage at runtime and rate-limit attachment uploads
- Add unique index on agent_messages (thread_id, sequence)

UX/bugs:
- Mark agent thread route as ssr: false and guard SSE chunk parsing
- Show config-specific banner only on known configuration error
- Gate AI provider checks behind loading state in resume import
- Fix relative-time formatter blank gap between 45-59 seconds
- Clarify thread delete confirmation message

Polish:
- Raise ENCRYPTION_SECRET minimum to 32 characters
- Bucket AI rate limits by resumeId/threadId/messageId
- Trim form values before submitting AI provider config
- Use single key identifier and nullish-coalesce baseURL display

* fix: address ai agent review feedback

* fix: preserve mobile agent chat state

* docs: add ai agent workspace guides

* feat: introduce design system for Reactive Resume
2026-05-14 15:00:04 +02:00
JamesGoslings 22c60c64b6 fix(fonts): restore legacy local font names via metric-compatible ali… (#3057)
* fix(fonts): restore legacy local font names via metric-compatible aliases

Closes #2989.

In v5.0.x the Puppeteer renderer resolved fonts like 'Times New Roman'
or 'Arial' through the browser's font stack. The v5.1 migration to
@react-pdf/renderer requires every font to be Font.register()-ed; the
legacy local-font names were not carried over, so resumes upgraded
from v5.0.x had their typography silently replaced with IBM Plex Serif,
changing line breaks, page counts and overall layout.

This adds a render-time alias layer mapping the old names to
metric-compatible web fonts already shipped in the webfont list:

  Times New Roman → Tinos
  Cambria         → Tinos
  Arial           → Arimo
  Garamond        → EB Garamond
  Calibri         → Source Sans 3

- packages/fonts:
  - new `legacyFontAliases` map and `resolveLegacyFontAlias` helper.
  - `getFont` falls back to the alias map when the direct lookup misses,
    so any caller that asked 'is this a known family?' now answers
    truthfully for the legacy names.
  - `getFontDisplayName` is intentionally unchanged: the typography
    sidebar keeps showing the user's original choice ('Times New Roman'),
    while the renderer transparently swaps in the alias target.

- packages/pdf/use-register-fonts:
  - `resolvePdfFontFamily` returns the alias target when one applies,
    so `Font.register` runs against the right web font and templates
    receive a family name they can actually render.

Backwards compatible: families that were never aliased (Roboto, IBM
Plex Serif, the standard PDF fonts, ...) take exactly the same code
path as before. The CJK glyph fallback added in #2986 / PR #3013
continues to apply on top of the resolved primary family.

* fix(fonts): use Carlito (not Source Sans 3) as Calibri alias

Per maintainer review feedback: Carlito is metric-compatible with
Calibri, while Source Sans 3 only matches visually. Switching gives
upgraded resumes the same line widths, line breaks and page counts
they had under v5.0.x.

- packages/fonts/webfontlist.json: add Carlito (Google Fonts, weights
  400/700 + italics) so it's a registerable target.
- packages/scripts/fonts/generate.ts: add a getMetricCompatibleFonts
  helper and merge it into the output, mirroring how Computer Modern
  fonts are appended. This way regenerating the list (`pnpm generate`)
  re-emits Carlito automatically and dedupes if it ever enters the
  Google Fonts popularity slice.
- packages/fonts/src/index.ts: alias `Calibri → Carlito`.
- packages/fonts/src/index.test.ts: update alias test cases.
2026-05-14 11:36:15 +02:00
Amruth Pillai affa1d6646 docs: enhance documentation and guides with new features and updates 2026-05-14 03:38:39 +02:00
SirSKillzandAmruth Pillai c71f3b0b92 Feat: Add configurable AI provider base URL flag and update documentation (#3059)
* feat: add FLAG_ALLOW_UNSAFE_AI_BASE_URL for configurable AI provider base URLs

* feat: add FLAG_ALLOW_UNSAFE_AI_BASE_URL documentation

* fix: remove AI_ALLOWED_BASE_URLS from documentation and environment variable reference

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-05-14 03:01:37 +02:00
Amruth Pillai 6c4a4b2aa5 Render public resumes with PDF.js (#3061)
* fix(web): use native pdf viewer for public resumes

* fix(web): render public resumes with pdf.js

* chore: revert vite hook paths

* chore(web): address pdf viewer review
2026-05-14 02:49:43 +02:00
Amruth Pillai 1294d3354a feat(docker): enhance development setup with reactive_resume service and health checks 2026-05-13 15:35:08 +02:00
Claudeamruthpillaianthropic-code-agent[bot] <242468646+Claude@users.noreply.github.com>
42fc78dca1 [WIP] Fix dead link to Using Custom CSS in docs (#3056)
* Initial plan

* docs: remove reference to removed Custom CSS guide

Agent-Logs-Url: https://github.com/amruthpillai/reactive-resume/sessions/82961e42-251b-41da-80ee-7697968566f7

Co-authored-by: amruthpillai <1134738+amruthpillai@users.noreply.github.com>

---------

Co-authored-by: anthropic-code-agent[bot] <242468646+Claude@users.noreply.github.com>
Co-authored-by: amruthpillai <1134738+amruthpillai@users.noreply.github.com>
2026-05-13 11:22:25 +02:00
1572 changed files with 543038 additions and 96286 deletions
+3
View File
@@ -0,0 +1,3 @@
---
exclude_paths:
- "migrations/**"
+1
View File
@@ -3,6 +3,7 @@
.gitignore
.cursor
.DS_Store
.vite-hooks
# Local configuration and runtime state
.env*
+57 -11
View File
@@ -1,15 +1,33 @@
# --- Application ---
# Port used by the web server in local development and self-hosted containers.
# Public port used by the production server and the Vite web server in local development.
PORT="3000"
# Port used by the Hono server in local development. Vite proxies API requests to this port.
SERVER_PORT="3001"
# Public URL where the app is served. Used for auth callbacks, OAuth issuer URLs,
# OpenGraph metadata, and absolute upload URLs.
APP_URL="http://localhost:3000"
# Optional: serve one already-public resume at /. Use the ID from /builder/<id>.
# Unset or blank keeps the marketing home. Restart after changes.
# ROOT_RESUME_ID=
# Vercel: APP_URL can be omitted; production uses VERCEL_PROJECT_PRODUCTION_URL.
# --- Database (PostgreSQL) ---
# PostgreSQL connection URL. In Docker Compose, the hostname is usually `postgres`;
# when running directly on your machine, `localhost` is typical.
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
DATABASE_URL="postgresql://postgres:postgres@postgres:5432/postgres"
# Optional direct connection for migrations (Neon: DATABASE_URL_UNPOOLED alias).
# DATABASE_MIGRATION_URL=""
# DATABASE_POOL_MAX="10"
# When "true", the server refuses to boot if the live database schema has drifted from
# the migration ledger (e.g. a table dropped outside migrations). Default "false" logs
# the drift loudly at startup and continues.
STRICT_SCHEMA_CHECK="false"
# --- Authentication ---
# Generated using `openssl rand -hex 32`
@@ -48,22 +66,26 @@ OAUTH_USER_INFO_URL=""
# Space-separated scopes requested from the custom OAuth provider.
OAUTH_SCOPES="openid profile email"
# Comma-separated extra hosts/origins allowed for dynamic OAuth client redirect URIs.
# By default, only the APP_URL origin is allowed.
OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS=""
# --- Email (optional) ---
# If SMTP_HOST, SMTP_USER, SMTP_PASS, or SMTP_FROM is missing, the app logs the
# email to the console instead.
SMTP_HOST="localhost"
SMTP_PORT="1025"
SMTP_HOST=""
SMTP_PORT=""
SMTP_USER=""
SMTP_PASS=""
SMTP_FROM="Reactive Resume <noreply@rxresu.me>"
SMTP_SECURE="false"
# --- Storage (optional) ---
# If all S3 keys are disabled, the app uses local filesystem storage instead.
# Backend defaults to S3 when all credentials are present, otherwise local.
# Vercel defaults to private Blob. Explicit selection: local, s3, blob.
# STORAGE_BACKEND="local"
# BLOB_READ_WRITE_TOKEN=""
# BLOB_STORE_ID=""
# DEPLOYMENT_NAMESPACE="default"
# Vercel previews need isolated resources before setting ALLOW_PREVIEW_MIGRATIONS=true.
# If all S3 keys are disabled, Docker uses local filesystem storage instead.
# Make sure to mount this directory to a volume or the host filesystem to ensure data integrity.
# LOCAL_STORAGE_PATH overrides where local uploads/cache are written.
# Defaults to /app/data in the official Docker image; in dev, defaults to <workspace>/data.
@@ -73,10 +95,17 @@ SMTP_SECURE="false"
S3_ACCESS_KEY_ID="seaweedfs"
S3_SECRET_ACCESS_KEY="seaweedfs"
S3_REGION="us-east-1"
S3_ENDPOINT="http://localhost:8333"
S3_ENDPOINT="http://seaweedfs:8333"
S3_BUCKET="reactive-resume"
S3_FORCE_PATH_STYLE="true"
# --- AI Agent Workspace (optional) ---
# Required for the authenticated /agent workspace and saved AI providers.
# Redis also shares rate limits, resume events, cancellation and view deduplication.
# Vercel Upstash KV_URL is accepted as an alias for REDIS_URL.
REDIS_URL="redis://redis:6379"
ENCRYPTION_SECRET="change-me-to-a-secure-agent-secret-in-production"
# --- Feature Flags ---
# This flag disables new signups, both on the web app and the server.
FLAG_DISABLE_SIGNUPS="false"
@@ -89,6 +118,23 @@ FLAG_DISABLE_EMAIL_AUTH="false"
# This is useful if you are using a machine with limited resources, like a Raspberry Pi.
FLAG_DISABLE_IMAGE_PROCESSING="false"
# This flag disables API rate limiting for authentication endpoints.
# Rate limiting is enabled by default in production to prevent abuse.
FLAG_DISABLE_API_RATE_LIMIT="false"
# Allows dynamic OAuth client registration to use any parseable redirect URI,
# including custom schemes, private hosts, and non-loopback http:// URLs.
# WARNING: Enabling this on a public or multi-tenant deployment can enable phishing
# or token exfiltration. Only enable this on a trusted, self-hosted instance.
FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI="false"
# Allows AI providers to be configured with any base URL, including http:// and
# private/loopback addresses (e.g. http://localhost:11434 for a local Ollama instance).
# WARNING: Enabling this on a multi-tenant deployment is a Server-Side Request Forgery (SSRF)
# risk. Only enable this on a trusted, single-tenant self-hosted instance.
FLAG_ALLOW_UNSAFE_AI_BASE_URL="false"
# --- Others ---
# Google Cloud API Key (optional)
# For font-list generation tooling.
@@ -98,4 +144,4 @@ GOOGLE_CLOUD_API_KEY=""
# Crowdin (optional)
# For translation tooling.
CROWDIN_PROJECT_ID=""
CROWDIN_PERSONAL_TOKEN=""
CROWDIN_API_TOKEN=""
+3 -3
View File
@@ -15,13 +15,13 @@
{
"guid": "reactive-resume",
"name": "Reactive Resume",
"description": "A free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume.",
"description": "A free and open-source resume builder that makes it easy to create, update, and share your resume.",
"webpageUrl": {
"url": "https://rxresu.me"
},
"repositoryUrl": {
"url": "https://github.com/amruthpillai/reactive-resume",
"wellKnown": "https://github.com/amruthpillai/reactive-resume/blob/main/.github/.well-known/funding-manifest-urls"
"url": "https://github.com/reactive-resume/reactive-resume",
"wellKnown": "https://github.com/reactive-resume/reactive-resume/blob/main/.github/.well-known/funding-manifest-urls"
},
"licenses": ["spdx:MIT"],
"tags": ["data", "design", "productivity", "resume-builder"]
+82 -26
View File
@@ -1,68 +1,124 @@
name: 🐞 Bug Report
description: Create a bug report to help improve Reactive Resume
description: Report a reproducible problem with Reactive Resume
title: "[Bug] <title>"
labels: [bug, v5, needs triage]
assignees: "AmruthPillai"
labels: ["bug", "status: needs triage"]
assignees: []
body:
- type: checkboxes
attributes:
label: Is there an existing issue for this?
description: Please search to see if an issue already exists for the bug you encountered.
label: Existing issue
description: Search open and closed issues before submitting a new report.
options:
- label: Yes, I have searched the existing issues and none of them match my problem.
- label: I searched the existing issues and could not find a matching report.
required: true
- type: dropdown
id: variant
attributes:
label: Product Variant
description: What variant of Reactive Resume are you using?
label: Product variant
description: Where does the problem occur?
options:
- Cloud (https://rxresu.me)
- Self-Hosted
- Cloud
- Self-hosted
validations:
required: true
- type: input
id: version
attributes:
label: Reactive Resume version
description: Find this in Settings or provide the container image tag or commit SHA.
placeholder: 5.2.6
validations:
required: true
- type: dropdown
id: area
attributes:
label: Area
description: Choose the part of Reactive Resume most closely related to the problem.
options:
- Resume builder & data
- Templates, preview & export
- Accounts & sharing
- AI & Agent
- Language & localization
- Self-hosting
- API & integrations
- Applications & cover letters
- Other / unsure
validations:
required: true
- type: input
id: environment
attributes:
label: Environment
description: Include your operating system and browser. For self-hosted installations, also include the deployment method.
placeholder: Firefox 143 on Ubuntu 26.04, deployed with Docker Compose
validations:
required: true
- type: textarea
id: summary
attributes:
label: Describe the bug you're experiencing
description: A detailed description of what you're experiencing. Please provide as much detail as possible as it will help me diagnose and fix the issue faster.
label: Summary
description: Briefly describe the problem and its impact.
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to reproduce
description: Provide the smallest reliable sequence that demonstrates the problem.
placeholder: |
1. Open ...
2. Select ...
3. Observe ...
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
validations:
required: true
- type: dropdown
id: template
attributes:
label: What template are you using?
description: Leave blank if the issue applies to all templates, or is not template-specific.
multiple: false
label: Template
description: Leave blank when the problem is not template-specific.
options:
- Azurill
- Bronzor
- Chikorita
- Ditto
- Ditgar
- Ditto
- Gengar
- Glalie
- Kakuna
- Lapras
- Leafish
- Meowth
- Onyx
- Pikachu
- Rhyhorn
- Scizor
validations:
required: false
- type: textarea
id: logs
attributes:
label: Anything else?
description: |
Links? References? Anything that will give us more context about the issue you are encountering!
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
validations:
required: false
label: Logs and screenshots
description: Add relevant logs, screenshots, or a minimal reproduction. Remove secrets and personal resume data first.
+69 -10
View File
@@ -1,23 +1,82 @@
name: ✨ Feature Request
description: Suggest an feature or idea that you would like to see in Reactive Resume
description: Propose an actionable improvement to Reactive Resume
title: "[Feature] <title>"
labels: [enhancement, v5, needs triage]
assignees: "AmruthPillai"
labels: ["enhancement", "status: needs triage"]
assignees: []
body:
- type: checkboxes
attributes:
label: Is there an existing issue for this feature?
description: Please search to see if an issue already exists for the feature you requested.
label: Existing issue
description: Search open and closed issues before submitting a new proposal.
options:
- label: Yes, I have searched the existing issues and it doesn't exist.
- label: I searched the existing issues and could not find a matching proposal.
required: true
- type: textarea
- type: dropdown
id: variant
attributes:
label: Feature Description
description: A detailed description of the feature you would like to see in Reactive Resume. Please provide as much detail as possible as it will help me implement the feature faster.
label: Product variant
description: Choose the primary environment for this proposal.
options:
- Cloud
- Self-hosted
validations:
required: true
- type: dropdown
id: area
attributes:
label: Area
description: Choose the part of Reactive Resume most closely related to the proposal.
options:
- Resume builder & data
- Templates, preview & export
- Accounts & sharing
- AI & Agent
- Language & localization
- Self-hosting
- API & integrations
- Applications & cover letters
- Other / unsure
validations:
required: true
- type: textarea
id: problem
attributes:
label: Problem
description: What user problem or limitation should Reactive Resume solve?
validations:
required: true
- type: textarea
id: outcome
attributes:
label: Desired outcome
description: Describe the behavior you want without prescribing an implementation.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Describe current workarounds or alternatives. Write "None" if there are none.
validations:
required: true
- type: textarea
id: scope
attributes:
label: Proposed scope
description: Explain what should be included and what can remain out of scope.
validations:
required: true
- type: textarea
id: context
attributes:
label: Additional context
description: Add examples, mockups, or related issues when useful. Remove personal resume data first.
+7
View File
@@ -1 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Questions and support
url: https://github.com/reactive-resume/reactive-resume/discussions/categories/q-a
about: Get help with setup, configuration, and using Reactive Resume.
- name: Security vulnerability
url: https://github.com/reactive-resume/reactive-resume/security/advisories/new
about: Report security vulnerabilities privately.
+4
View File
@@ -0,0 +1,4 @@
self-hosted-runner:
labels:
- blacksmith-32vcpu-ubuntu-2404
- blacksmith-32vcpu-ubuntu-2404-arm
+17
View File
@@ -0,0 +1,17 @@
<!-- caveman-begin -->
Respond terse like smart caveman. All technical substance stay. Only fluff die.
Rules:
- Drop: articles (a/an/the), filler (just/really/basically), pleasantries, hedging
- Fragments OK. Short synonyms. Technical terms exact. Code unchanged.
- Pattern: [thing] [action] [reason]. [next step].
- Not: "Sure! I'd be happy to help you with that."
- Yes: "Bug in auth middleware. Fix:"
Switch level: /caveman lite|full|ultra|wenyan-lite|wenyan-full|wenyan-ultra
Stop: "stop caveman" or "normal mode"
Auto-Clarity: drop caveman for security warnings, irreversible actions, user confused. Resume after.
Boundaries: code/commits/PRs written normal.
<!-- caveman-end -->
+9 -2
View File
@@ -13,19 +13,26 @@ env:
jobs:
autofix:
runs-on: ubuntu-latest
runs-on: ${{ vars.CI_RUNNER_X64 || 'ubuntu-latest' }}
steps:
- name: Checkout Repository
uses: actions/checkout@v6
- name: Check for merge conflict markers
run: |
if git grep -nEI '<{7} |>{7} |^={7}$' -- ':(exclude)*.md' ':(exclude)*.mdx'; then
echo "::error::Merge conflict markers found in tracked files"
exit 1
fi
- name: Install pnpm
uses: pnpm/action-setup@v6
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: "lts/*"
node-version-file: ".nvmrc"
cache: "pnpm"
- name: Install Dependencies
+1 -1
View File
@@ -10,7 +10,7 @@ concurrency:
jobs:
crowdin-sync:
runs-on: ubuntu-latest
runs-on: ${{ vars.CI_RUNNER_X64 || 'ubuntu-latest' }}
permissions:
contents: write
+169 -32
View File
@@ -2,26 +2,69 @@ name: Build Docker Image
on:
workflow_dispatch:
inputs:
release:
description: Publish release aliases and redeploy production (false runs a cache-only build, then publishes a canary)
type: boolean
default: false
push:
branches:
- main
tags:
- "v*"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
IMAGE: ${{ github.repository }}
GHCR_IMAGE: ghcr.io/${{ github.repository }}
DOCKER_IMAGE: docker.io/amruthpillai/reactive-resume
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
mode:
runs-on: ${{ vars.CI_RUNNER_X64 || 'ubuntu-latest' }}
outputs:
nightly: ${{ steps.mode.outputs.nightly }}
release: ${{ steps.mode.outputs.release }}
canary: ${{ steps.mode.outputs.canary }}
steps:
- name: Determine publishing mode
id: mode
env:
EVENT_NAME: ${{ github.event_name }}
GIT_REF: ${{ github.ref }}
RELEASE: ${{ inputs.release }}
run: |
if [[ "$EVENT_NAME" == "push" && "$GIT_REF" == "refs/heads/main" ]]; then
echo "nightly=true" >> "$GITHUB_OUTPUT"
echo "release=false" >> "$GITHUB_OUTPUT"
echo "canary=false" >> "$GITHUB_OUTPUT"
elif [[ "$EVENT_NAME" == "workflow_dispatch" && "$RELEASE" != "true" ]]; then
echo "nightly=false" >> "$GITHUB_OUTPUT"
echo "release=false" >> "$GITHUB_OUTPUT"
echo "canary=true" >> "$GITHUB_OUTPUT"
else
echo "nightly=false" >> "$GITHUB_OUTPUT"
echo "release=true" >> "$GITHUB_OUTPUT"
echo "canary=false" >> "$GITHUB_OUTPUT"
fi
build:
needs: mode
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
runner: ${{ vars.CI_RUNNER_X64 || 'ubuntu-latest' }}
arch: amd64
- platform: linux/arm64
runner: ubuntu-24.04-arm
runner: ${{ vars.CI_RUNNER_ARM64 || 'ubuntu-24.04-arm' }}
arch: arm64
runs-on: ${{ matrix.runner }}
@@ -37,14 +80,38 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@v6
- name: Get version from package.json
id: version
run: echo "version=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT"
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v4
- &registries
name: Determine registries
id: registries
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
run: |
set -euo pipefail
dockerhub=false
ghcr_image="${GHCR_IMAGE,,}"
docker_image="${DOCKER_IMAGE,,}"
images="$ghcr_image"
if [[ -n "$DOCKER_USERNAME" && -n "$DOCKER_PASSWORD" ]]; then
dockerhub=true
images="${images}"$'\n'"$docker_image"
fi
{
echo "ghcr_image=$ghcr_image"
echo "docker_image=$docker_image"
echo "images<<EOF"
echo "$images"
echo "EOF"
echo "dockerhub=$dockerhub"
} >> "$GITHUB_OUTPUT"
- name: Login to Docker Hub
if: ${{ steps.registries.outputs.dockerhub == 'true' }}
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
@@ -61,12 +128,18 @@ jobs:
id: meta
uses: docker/metadata-action@v6
with:
images: |
ghcr.io/${{ env.IMAGE }}
docker.io/${{ env.IMAGE }}
images: ${{ steps.registries.outputs.images }}
tags: |
type=sha,prefix=sha-,suffix=-${{ matrix.arch }}
- name: Cache-only smoke build
if: ${{ needs.mode.outputs.canary == 'true' }}
uses: docker/build-push-action@v7
with:
context: .
platforms: ${{ matrix.platform }}
outputs: type=cacheonly
- name: Build and Push by Digest
id: build
uses: docker/build-push-action@v7
@@ -79,8 +152,6 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
annotations: ${{ steps.meta.outputs.annotations }}
cache-from: type=gha,scope=${{ env.IMAGE }}-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=${{ env.IMAGE }}-${{ matrix.arch }}
- name: Export digest
run: |
@@ -97,9 +168,15 @@ jobs:
retention-days: 1
merge:
needs: build
needs:
- mode
- build
timeout-minutes: 30
runs-on: ubuntu-latest
runs-on: ${{ vars.CI_RUNNER_X64 || 'ubuntu-latest' }}
env:
DEPLOY: ${{ secrets.SSH_KEY != '' && secrets.SSH_HOST != '' && secrets.SSH_USER != '' }}
PURGE_CLOUDFLARE: ${{ secrets.CLOUDFLARE_ZONE_ID != '' && secrets.CLOUDFLARE_API_TOKEN != '' }}
permissions:
contents: read
@@ -128,7 +205,10 @@ jobs:
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v4
- *registries
- name: Login to Docker Hub
if: ${{ steps.registries.outputs.dockerhub == 'true' }}
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
@@ -155,21 +235,31 @@ jobs:
id: meta
uses: docker/metadata-action@v6
with:
images: |
ghcr.io/${{ env.IMAGE }}
docker.io/${{ env.IMAGE }}
images: ${{ steps.registries.outputs.images }}
tags: |
type=sha,prefix=sha-
type=raw,value=latest
type=raw,value=v${{ steps.version.outputs.version }}
type=raw,value=v${{ steps.semver.outputs.major }}.${{ steps.semver.outputs.minor }}
type=raw,value=v${{ steps.semver.outputs.major }}
type=raw,value=canary-${{ github.run_id }}-${{ github.run_attempt }},enable=${{ needs.mode.outputs.canary == 'true' }}
type=raw,value=nightly,enable=${{ needs.mode.outputs.nightly == 'true' }}
type=raw,value=nightly-{{date 'YYYYMMDDHHmmss' tz='UTC'}},enable=${{ needs.mode.outputs.nightly == 'true' }}
type=raw,value=latest,enable=${{ needs.mode.outputs.release == 'true' }}
type=raw,value=v${{ steps.version.outputs.version }},enable=${{ needs.mode.outputs.release == 'true' }}
type=raw,value=v${{ steps.semver.outputs.major }}.${{ steps.semver.outputs.minor }},enable=${{ needs.mode.outputs.release == 'true' }}
type=raw,value=v${{ steps.semver.outputs.major }},enable=${{ needs.mode.outputs.release == 'true' }}
- name: Create manifest list and push
id: manifest
working-directory: /tmp/digests
run: |
set -euo pipefail
if [[ "${{ needs.mode.outputs.nightly }}" == "true" ]]; then
FINAL_TAG="nightly"
elif [[ "${{ needs.mode.outputs.canary }}" == "true" ]]; then
FINAL_TAG="canary-${{ github.run_id }}-${{ github.run_attempt }}"
else
FINAL_TAG="v${{ steps.version.outputs.version }}"
fi
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
--annotation "index:org.opencontainers.image.licenses=MIT" \
@@ -178,16 +268,19 @@ jobs:
--annotation "index:org.opencontainers.image.vendor=Amruth Pillai" \
--annotation "index:org.opencontainers.image.url=https://rxresu.me" \
--annotation "index:org.opencontainers.image.documentation=https://docs.rxresu.me" \
--annotation "index:org.opencontainers.image.source=https://github.com/amruthpillai/reactive-resume" \
--annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.version=${{ steps.version.outputs.version }}" \
$(printf 'ghcr.io/${{ env.IMAGE }}@sha256:%s ' *) \
$(printf 'docker.io/${{ env.IMAGE }}@sha256:%s ' *)
$(printf '${{ steps.registries.outputs.ghcr_image }}@sha256:%s ' *)
# Get the digest of the multi-arch manifest
GHCR_DIGEST=$(docker buildx imagetools inspect ghcr.io/${{ env.IMAGE }}:v${{ steps.version.outputs.version }} --format '{{json .Manifest.Digest}}' | tr -d '"')
DOCKER_DIGEST=$(docker buildx imagetools inspect docker.io/${{ env.IMAGE }}:v${{ steps.version.outputs.version }} --format '{{json .Manifest.Digest}}' | tr -d '"')
GHCR_DIGEST=$(docker buildx imagetools inspect ${{ steps.registries.outputs.ghcr_image }}:${FINAL_TAG} --format '{{json .Manifest.Digest}}' | tr -d '"')
echo "final_tag=$FINAL_TAG" >> "$GITHUB_OUTPUT"
echo "ghcr_digest=$GHCR_DIGEST" >> "$GITHUB_OUTPUT"
echo "docker_digest=$DOCKER_DIGEST" >> "$GITHUB_OUTPUT"
if [[ "${{ steps.registries.outputs.dockerhub }}" == "true" ]]; then
DOCKER_DIGEST=$(docker buildx imagetools inspect ${{ steps.registries.outputs.docker_image }}:${FINAL_TAG} --format '{{json .Manifest.Digest}}' | tr -d '"')
echo "docker_digest=$DOCKER_DIGEST" >> "$GITHUB_OUTPUT"
fi
- name: Install Cosign
uses: sigstore/cosign-installer@v3
@@ -195,17 +288,40 @@ jobs:
- name: Sign images with Cosign
run: |
# Sign GHCR image
cosign sign --yes ghcr.io/${{ env.IMAGE }}@${{ steps.manifest.outputs.ghcr_digest }}
cosign sign --yes ${{ steps.registries.outputs.ghcr_image }}@${{ steps.manifest.outputs.ghcr_digest }}
# Sign Docker Hub image
cosign sign --yes docker.io/${{ env.IMAGE }}@${{ steps.manifest.outputs.docker_digest }}
if [[ "${{ steps.registries.outputs.dockerhub }}" == "true" ]]; then
# Sign Docker Hub image
cosign sign --yes ${{ steps.registries.outputs.docker_image }}@${{ steps.manifest.outputs.docker_digest }}
fi
- name: Inspect image
run: |
docker buildx imagetools inspect ghcr.io/${{ env.IMAGE }}:v${{ steps.version.outputs.version }}
docker buildx imagetools inspect docker.io/${{ env.IMAGE }}:v${{ steps.version.outputs.version }}
docker buildx imagetools inspect ${{ steps.registries.outputs.ghcr_image }}:${{ steps.manifest.outputs.final_tag }}
if [[ "${{ steps.registries.outputs.dockerhub }}" == "true" ]]; then
docker buildx imagetools inspect ${{ steps.registries.outputs.docker_image }}:${{ steps.manifest.outputs.final_tag }}
fi
- name: Verify anonymous pulls on both architectures
run: |
set -euo pipefail
registry_config=$(mktemp -d)
trap 'rm -rf "$registry_config"' EXIT
# Prevent Docker from discovering a system credential helper.
printf '%s\n' '{"auths":{"ghcr.io":{},"https://index.docker.io/v1/":{}}}' > "$registry_config/config.json"
images=("${{ steps.registries.outputs.ghcr_image }}")
if [[ "${{ steps.registries.outputs.dockerhub }}" == "true" ]]; then
images+=("${{ steps.registries.outputs.docker_image }}")
fi
for image in "${images[@]}"; do
for platform in linux/amd64 linux/arm64; do
docker --config "$registry_config" pull --quiet --platform "$platform" \
"$image:${{ steps.manifest.outputs.final_tag }}"
done
done
- name: Redeploy Stack
if: ${{ needs.mode.outputs.release == 'true' && env.DEPLOY == 'true' }}
uses: appleboy/ssh-action@v1
with:
key: ${{ secrets.SSH_KEY }}
@@ -214,3 +330,24 @@ jobs:
script: |
cd docker
./manage_stack.sh up reactive_resume
- name: Purge Cloudflare cache
if: ${{ needs.mode.outputs.release == 'true' && env.PURGE_CLOUDFLARE == 'true' }}
env:
CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
set -euo pipefail
response=$(curl -fsS --max-time 10 --retry 3 --retry-delay 5 --retry-connrefused -X POST \
"https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
-H "Content-Type: application/json" \
--data '{"purge_everything":true}')
if [ "$(jq -r '.success' <<< "$response")" != "true" ]; then
echo "$response" | jq .
exit 1
fi
echo "Cloudflare cache purged successfully."
+96
View File
@@ -0,0 +1,96 @@
name: E2E Tests
on:
pull_request:
push:
branches: ["main"]
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
APP_URL: http://localhost:3000
PORT: "3000"
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
FLAG_DISABLE_SIGNUPS: "false"
FLAG_DISABLE_EMAIL_AUTH: "false"
FLAG_DISABLE_API_RATE_LIMIT: "true"
LOCAL_STORAGE_PATH: /tmp/reactive-resume-e2e-storage
jobs:
e2e:
runs-on: ${{ vars.CI_RUNNER_X64 || 'ubuntu-latest' }}
timeout-minutes: 30
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres -d postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout Repository
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@v6
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
cache: "pnpm"
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright Browser
timeout-minutes: 10
run: pnpm exec playwright install --with-deps chromium
- name: Generate Test Secrets
run: |
echo "AUTH_SECRET=$(openssl rand -hex 32)" >> "$GITHUB_ENV"
echo "ENCRYPTION_SECRET=$(openssl rand -hex 32)" >> "$GITHUB_ENV"
- name: Prepare Storage
run: mkdir -p "$LOCAL_STORAGE_PATH"
- name: Run Database Migrations
run: pnpm db:migrate
# Runs every workspace package, not a hand-maintained filter list, so a package
# cannot silently lose coverage by being left out. Serial execution: the PDF
# rasterization and API rate-limit suites time out when several packages' Vitest
# thread pools oversubscribe the runner at once.
- name: Run Unit Tests
run: pnpm exec turbo run test:ci --concurrency=1
- name: Build
run: pnpm build
- name: Run E2E Tests
run: pnpm exec playwright test
- name: Upload Playwright Report
if: always()
uses: actions/upload-artifact@v7
with:
name: playwright-report
path: |
playwright-report
test-results
if-no-files-found: ignore
retention-days: 7
+34
View File
@@ -0,0 +1,34 @@
name: Label New Issues
on:
issues:
types: [opened]
permissions:
contents: read
issues: write
jobs:
label:
runs-on: ${{ vars.CI_RUNNER_X64 || 'ubuntu-latest' }}
steps:
- name: Checkout Repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Apply Form Labels
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const { getIssueLabels } = await import(`${process.env.GITHUB_WORKSPACE}/tooling/issue-labels.mjs`);
const labels = getIssueLabels(context.payload.issue.body ?? "");
if (labels.length > 0) {
await github.rest.issues.addLabels({
...context.repo,
issue_number: context.issue.number,
labels,
});
}
+30
View File
@@ -0,0 +1,30 @@
name: Close Issues Awaiting Information
on:
schedule:
- cron: "23 4 * * *"
workflow_dispatch:
permissions:
issues: write
jobs:
stale:
runs-on: ${{ vars.CI_RUNNER_X64 || 'ubuntu-latest' }}
steps:
- name: Close Inactive Issues Awaiting Information
uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11
with:
only-issue-labels: "status: needs info"
days-before-issue-stale: 14
days-before-issue-close: 7
days-before-pr-stale: -1
days-before-pr-close: -1
stale-issue-label: stale
stale-issue-message: >-
This issue is waiting for information requested by a maintainer. It will close in 7 days if no new information is provided.
close-issue-message: >-
Closing because the requested information was not provided. Add the missing details in a comment and a maintainer can reopen the issue.
close-issue-reason: not_planned
remove-issue-stale-when-updated: true
+99
View File
@@ -0,0 +1,99 @@
name: Vercel compatibility
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
artifact:
runs-on: ${{ vars.CI_RUNNER_X64 || 'ubuntu-latest' }}
timeout-minutes: 20
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_PASSWORD: postgres
ports: [5432:5432]
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s --health-timeout 5s --health-retries 10
env:
APP_URL: http://localhost:3000
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
AUTH_SECRET: isolated-ci-auth-secret-32-characters
ENCRYPTION_SECRET: isolated-ci-encryption-secret-32-characters
REDIS_URL: redis://localhost:6379
STORAGE_BACKEND: blob
BLOB_READ_WRITE_TOKEN: vercel_blob_rw_ci_fake_build_only
VERCEL: "1"
VERCEL_ENV: production
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
# Local project settings avoid authentication and API calls. Forks receive no cloud credentials.
- name: Build Vercel artifact against isolated PostgreSQL
run: |
mkdir -p .vercel
node --input-type=module - <<'JS'
import { writeFileSync } from 'node:fs';
writeFileSync('.vercel/project.json', JSON.stringify({
projectId: 'prj_ci', orgId: 'team_ci', projectName: 'reactive-resume-ci',
settings: { framework: null, nodeVersion: '24.x', createdAt: 0 }
}));
JS
pnpm dlx --allow-build=esbuild vercel@60.0.1 build --prod --yes --global-config "$RUNNER_TEMP/vercel-offline"
- name: Check Lambda module loading and function budget
run: |
node --no-experimental-require-module --input-type=module - <<'JS'
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
const config = JSON.parse(readFileSync('.vercel/output/functions/api/index.func/.vc-config.json'));
assert.equal(config.runtime, 'nodejs24.x');
assert.equal(config.maxDuration, 300);
const tracedFiles = Object.keys(config.filePathMap ?? {});
assert.ok(tracedFiles.some((path) => path.endsWith('/pdfkit/js/standard-fonts/Helvetica.cjs')));
assert.ok(tracedFiles.some((path) => path.endsWith('/pdfkit/js/data/Helvetica.afm')));
for (const name of readdirSync('apps/server/dist')) {
if (name.endsWith('.mjs') && !['index.mjs', 'prepare-deployment.mjs'].includes(name)) {
await import(`./apps/server/dist/${name}`);
}
}
const { default: app } = await import('./apps/server/dist/vercel.mjs');
const response = await app.fetch(new Request('http://localhost:3000/api/storage/stage', { method: 'POST', body: '{}' }));
assert.equal(response.status, 401);
process.exit(0);
JS
live-smoke:
if: github.event_name == 'workflow_dispatch'
runs-on: ${{ vars.CI_RUNNER_X64 || 'ubuntu-latest' }}
environment: vercel-smoke
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- name: Smoke-test dedicated deployment
env:
SMOKE_URL: ${{ vars.VERCEL_SMOKE_URL }}
SMOKE_AI_BASE_URL: ${{ vars.VERCEL_SMOKE_AI_BASE_URL }}
SMOKE_AI_API_KEY: ${{ secrets.VERCEL_SMOKE_AI_API_KEY }}
run: node tooling/deployment/smoke.mjs
+13 -2
View File
@@ -3,7 +3,7 @@ node_modules
.pnpm-store
# Build Outputs
.output
dist
.vercel
.wrangler
@@ -36,6 +36,8 @@ logs
# Testing
coverage
reports
playwright-report
test-results
# Cache
tmp
@@ -44,11 +46,20 @@ temp
# AI
.codex
.agents
.claude
.cursor
.opencode
.codegraph
.superpowers
docs/superpowers
.worktrees
.migration
graphify-out
# Local Storage Data
/data
/apps/web/data
# Git Hooks
.vite-hooks
+31
View File
@@ -0,0 +1,31 @@
config:
default: true
MD007: false
MD009: false
MD010: false
MD012: false
MD013: false
MD001: false
MD022: false
MD024: false
MD025: false
MD028: false
MD031: false
MD032: false
MD033: false
MD034: false
MD036: false
MD040: false
MD041: false
MD046: false
MD060: false
frontMatter: "^---[\\s\\S]*?---"
gitignore: true
globs:
- "**/*.{md,mdx}"
ignores:
- ".design-sync/**"
- "node_modules/**"
- ".turbo/**"
- "dist/**"
-15
View File
@@ -1,15 +0,0 @@
// @ts-check
const betaPackages = ["drizzle-orm", "drizzle-kit", "drizzle-zod"];
/** @type {import('npm-check-updates').RunOptions} */
module.exports = {
upgrade: true,
workspaces: true,
install: "always",
packageManager: "pnpm",
target: (packageName) => {
if (betaPackages.includes(packageName)) return "@beta";
return "latest";
},
};
+1
View File
@@ -0,0 +1 @@
24
@@ -0,0 +1,39 @@
# Imported-table raster CI fix
## Root cause
Hosted runs `34007560930` (PR #3471) and `34007788443` (PR #3472) failed only in
`tests/e2e/specs/imported-table.spec.ts` with `horizontal: 18` instead of Plan 16's
`horizontal: 17`; text and vertical checks passed.
The PDF operator dump from the failed hosted artifact showed 29 table path records matching the Plan 16 contract
(17 horizontal, 12 vertical), followed by an unrelated `constructPath` `endPath` bbox:
`[0, 19.65, 358.93, 20.65]`. Its stroke color was reported as `#cc00cc` only because the helper retained the
last table stroke color. It was a later red section-divider fill/no-paint path, not an extra table border. The old
helper classified every thin bbox after the last matching color state, so it counted this false positive.
The table's explicit width is stable at 300pt, while row height legitimately changes from 30pt to 31pt after the
`Beta!` edit. The helper therefore scopes candidate paths by the fixture's 300pt horizontal grid envelope, not by a
row-height tolerance. Missing or duplicated paths inside that envelope still change the exact 17/12 contract.
## Change
- Added `tests/e2e/fixtures/pdf-borders.ts` with deterministic `countTableBorderGeometry` filtering.
- Updated browser/server PDF inspection in `tests/e2e/specs/imported-table.spec.ts` to use the helper.
- Added `tests/e2e/fixtures/pdf-borders.test.ts`; regression proves old stale-color counting returns 2 horizontal
paths while topology-scoped counting returns 1.
## Verification
- Intent skill inventory: 7 packages, 26 skills; no matching local skill for this E2E/PDF helper.
- Focused helper regression: 1 file, 1 passed.
- Dedicated imported-table E2E: 2 consecutive runs, each 1 passed; both exercise initial, unrelated-edit, and table-edit
stages plus browser and server PDF exports.
- Production build: 3/3 tasks successful.
- Web typecheck via `rtk proxy pnpm --filter web typecheck`: passed (`tsgo --noEmit`).
- Turbo boundaries: 1,443 files across 20 packages, no issues.
- Targeted Biome: 3 files, no issues.
- `git diff --check`: passed.
The root `pnpm typecheck` wrapper was also tried but invokes an incompatible `tsc` path and reports TS5096 for
`allowImportingTsExtensions`; the package's documented `tsgo --noEmit` typecheck passes.
+33
View File
@@ -0,0 +1,33 @@
# Plan 21 implementation evidence
## Revision and scope
- Worktree: `issue-3060-section-heading-visibility`
- Base: current `origin/main` at dispatch, `2a4a1583b` (`fix(pdf): restore Gengar skill rating order (#3473)`)
- Product decisions applied: Q1 explicit Show heading toggle; Q2 Move-to continuations default visible; Q3 visual omission in preview/PDF/DOCX with accessible outline labels retained.
- No `.codegraph/` directory exists in this worktree, so CodeGraph was skipped after the required presence check.
- Intent discovery ran before edits; no matching local skill was available for this schema/PDF/DOCX/web change.
## Implementation
- Added backward-compatible `showHeading` section data for summary, built-ins, and custom sections. `parseResumeData` normalizes absent legacy values to `true`; explicit `false` survives round trips.
- Added heading toggles to built-in/summary and custom section menus. Toggle mutations use `useUpdateResumeData`, preserving undo/autosave/save/reload behavior; legacy absent values are treated as visible. Existing lock fieldset remains authoritative.
- Move-to-created custom sections explicitly set `showHeading: true`, independent of source heading state or copied title.
- `SectionShell` omits complete heading/icon/decoration output when disabled in both icon and no-icon branches. Empty titles still resolve localized defaults.
- DOCX section renderers omit visible heading paragraphs for summary, built-in, and custom sections while retaining content. Screen-reader mirror continues to expose section labels regardless of visual setting.
- Added characterization for Semantic CSS `section[id="..."] section-heading { display: none; }`; body remains while heading is omitted.
- Updated default/sample fixtures, generated schema references, recovery hashes, and compatibility tests; existing Gengar renderer/order changes remain untouched.
## Verification
- `pnpm --filter @reactive-resume/schema test`: 9 files, 132 tests passed.
- `pnpm --filter @reactive-resume/pdf test`: 81 files, 1059 tests passed.
- `pnpm --filter @reactive-resume/docx test`: 9 files, 76 tests passed.
- `pnpm --filter web test`: 135 files, 942 tests passed.
- `pnpm test`: full Turborepo suite passed (19 successful tasks; 10 cache hits).
- Affected typechecks passed: schema, PDF, DOCX, web.
- Focused menu, Move-to, accessible-outline, schema, PDF semantic, and DOCX renderer tests passed.
- `pnpm exec turbo boundaries`: passed (1108 files, 20 packages).
- Read-only `pnpm exec biome check` on 22 changed source/test files: passed; no write-capable `pnpm check` run.
- `git diff --check`: passed.
- Final diff review completed; local commit follows.
@@ -0,0 +1,32 @@
# Plan 23 item-pagination execution evidence
## Scope
Plan 23 steps 1–3 were evaluated from `origin/main` at `368858a56` (Plan 21 / PR #3477 merged). Widow/orphan UI and authored-page continuation guidance remain deferred from this execution, and Semantic CSS was not changed.
## Durable diagnostic matrix
`packages/pdf/src/templates/shared/item-pagination.test.tsx` renders physical PDF pages and checks numbered tokens exactly once for:
- an item that fits remaining space;
- an item that fits a full page but not the remaining space;
- an oversized item taller than one page;
- a two-line paragraph at a boundary;
- nested bullets; and
- built-in plus custom items in an Azurill sidebar/main-column overflow fixture.
The fixture also keeps authored `metadata.layout.pages` separate from renderer-generated physical pages.
The current deterministic baseline is: fit remainder = 1 physical page; full-page-but-not-remainder = 3 pages with sampled tokens on pages 2/2/3; oversized = 5 pages with sampled tokens on pages 1/3/5; two-line boundary = 2 pages; nested bullets = 1 page; Azurill built-in/custom/sidebar = 5 pages with sampled tokens on pages 1/4/5/5/1. Page numbers here are 1-based; every token still appears exactly once.
## Concrete blocker
React PDF's only available item-level keep-together primitive is `View wrap={false}`. A durable renderer fixture with 180 paragraph-like child views shows that a non-wrapping item cannot safely fall back when its content exceeds one page: the renderer omits the oversized tail instead of splitting it. Applying the same prop to shared `SectionItem` would therefore violate the lossless token requirement; no item schema flag or menu control was added.
Do not estimate item height from HTML length, persist physical pages, alter existing Semantic CSS, or claim #3350 complete. A future implementation needs renderer-supported conditional keep-together behavior or an actual measured two-pass fallback that preserves every token.
## Verification
- `pnpm --filter @reactive-resume/pdf exec vitest run src/templates/shared/item-pagination.test.tsx`: 7 tests passed.
- No production source or schema changes made after the unsafe fallback was reproduced.
- Undo/persistence/lock UI coverage is intentionally absent because no item control was shipped; add it only when safe fallback exists.
+157
View File
@@ -0,0 +1,157 @@
# Plan 27 Phase A diagnostic evidence
Date: 2026-09-06
Issue: [#3377](https://github.com/reactive-resume/reactive-resume/issues/3377)<br>
Revision: `2a4a1583b` (`origin/main` at run start)
Scope: Phase A, steps 1–2 only. No resolver, runtime behavior, or remote-source behavior changed.
## Drift and authority
- Worktree started clean and `HEAD` matched `origin/main`; `git diff origin/main...HEAD` was empty.
- Current catalog is `packages/fonts/src/webfontlist.json`. Its web font records point at both Google Fonts static assets and jsDelivr assets; “Google blocked” is not an offline proof.
- Browser preview is `apps/web/src/components/typography/font-display.tsx` and calls `FontFace.load()` against each catalog preview URL.
- Browser PDF preview/download is `apps/web/src/features/resume/export/pdf-document.tsx` → `@reactive-resume/pdf/browser`; registration is `packages/pdf/src/hooks/use-register-fonts.ts`.
- Server PDF is `apps/server/src/http/resume-pdf.ts` → `createResumePdfDownload`; Playwright browser routing cannot observe that process’s outbound font fetches.
- The issue is open and unmodified. PR #3455 is the approved planning PR; its plan/decision log grants execution of this bounded diagnostic and manifest evidence.
## Deterministic fixture
`tests/e2e/fixtures/offline-fonts.ts` seeds one disposable resume after sample creation. It writes the same text into basics and summary, hides the picture, selects IBM Plex Serif 400/700 for body and heading, and marks the row public for the server-PDF surface.
The exact markers are versioned as `offline-font-scripts-v1`:
| Marker | Script or coverage |
| --- | --- |
| `Latin punctuation • — “quotes” €` | Latin plus General Punctuation and currency |
| `简体中文` | Han / Simplified Chinese |
| `العربية` | Arabic |
| `עברית` | Hebrew |
| `ไทย` | Thai |
| `Emoji 🚀` | Emoji |
`tests/e2e/specs/offline-fonts.spec.ts` is opt-in (`OFFLINE_FONT_DIAGNOSTIC=1`) so the normal PR E2E suite does not become network-dependent. Each surface creates a new browser context with persisted auth state, disabled service workers, and no prior browser cache. Every non-same-origin request is aborted and recorded as `{ hostname, path }`; query strings, fragments, headers, bodies, tokens, and full URLs never enter diagnostic output. Reports are attached as JSON and emitted with the same sanitized shape.
The four surfaces are separate tests:
1. Font picker preview opens Typography → Font Family and waits for lazy `FontFace` preview loads.
2. Builder PDF preview navigates to the builder, captures the active PDF canvas, and measures marker-local raster crops.
3. Browser PDF download uses the Export dialog, rasterizes the downloaded PDF, and measures marker-local crops when generation succeeds.
4. Server PDF calls the public PDF endpoint and records text-layer marker presence when generation succeeds.
Builder/browser-PDF reports keep PDF text extraction as a separate `textLayerMarkers` signal; it does not prove visible glyph outlines. Raster evidence attaches a rendered PNG and per-marker crop metrics, failing for blank or tofu-like visible crops. Blocked browser font requests classify browser surfaces as `network-error`. The server report deliberately says `server-outbound-requests-unobservable-from-playwright`; its cold-network gate remains unresolved because server outbound capture and verifiable restart identity require external host-level controls.
## Run protocol and cold-cache boundary
Build and database setup follow `tests/e2e/README.md`. Run each surface in a separately restarted production server process so module-level PDF font registration state cannot leak between controls:
```text
OFFLINE_FONT_DIAGNOSTIC=1 OFFLINE_FONT_DIAGNOSTIC_SERVER_RESTARTED=1 \
pnpm exec playwright test tests/e2e/specs/offline-fonts.spec.ts --grep "picker preview"
```
Stop and restart the production server before repeating the command with `builder PDF`, `browser PDF`, and `server PDF` grep patterns. The environment used for this change had no built `apps/server/dist` or `apps/web/dist`, no running PostgreSQL instance, and no production server to restart, so the cold E2E matrix was not run. This is an explicit infrastructure blocker, not a pass claim. The test records `serverRestartFlag` only as caller input and labels it non-proof; it does not claim a completed cold-network gate.
The current Playwright route guard cannot impose host-level egress denial on Node.js running the server. A genuinely cold server test therefore needs a separately restarted server plus host-level egress capture/deny (for example, a controlled network namespace or an approved outbound proxy). Do not infer server network behavior from an empty browser request list.
## Administrator-hosted manifest proposal
This is a proposal, not an asset download. It intentionally contains only the primary family and glyph fallbacks required by the fixture and current PDF fallback map, not the full catalog.
```json
{
"schemaVersion": "offline-fonts-v1",
"mode": "local-only",
"assetRoot": "/fonts/offline/v1",
"families": {
"IBM Plex Serif": {
"normal": { "400": "ibm-plex-serif/400.ttf", "700": "ibm-plex-serif/700.ttf" },
"italic": { "400": "ibm-plex-serif/400-italic.ttf", "700": "ibm-plex-serif/700-italic.ttf" },
"preview": "ibm-plex-serif/preview.ttf"
},
"IBM Plex Sans": {
"normal": { "400": "ibm-plex-sans/400.ttf", "700": "ibm-plex-sans/700.ttf" },
"italic": { "400": "ibm-plex-sans/400-italic.ttf", "700": "ibm-plex-sans/700-italic.ttf" },
"preview": "ibm-plex-sans/preview.ttf"
},
"Noto Serif": {
"normal": { "400": "noto-serif/400.ttf", "700": "noto-serif/700.ttf" },
"italic": { "400": "noto-serif/400-italic.ttf", "700": "noto-serif/700-italic.ttf" },
"preview": "noto-serif/preview.ttf"
},
"Noto Sans": {
"normal": { "400": "noto-sans/400.ttf", "700": "noto-sans/700.ttf" },
"italic": { "400": "noto-sans/400-italic.ttf", "700": "noto-sans/700-italic.ttf" },
"preview": "noto-sans/preview.ttf"
},
"Noto Sans SC": { "normal": { "400": "noto-sans-sc/400.ttf", "700": "noto-sans-sc/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-sc/preview.ttf" },
"Noto Serif SC": { "normal": { "400": "noto-serif-sc/400.ttf", "700": "noto-serif-sc/700.ttf" }, "italic": "reuse-normal", "preview": "noto-serif-sc/preview.ttf" },
"Noto Sans TC": { "normal": { "400": "noto-sans-tc/400.ttf", "700": "noto-sans-tc/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-tc/preview.ttf" },
"Noto Serif TC": { "normal": { "400": "noto-serif-tc/400.ttf", "700": "noto-serif-tc/700.ttf" }, "italic": "reuse-normal", "preview": "noto-serif-tc/preview.ttf" },
"Noto Sans JP": { "normal": { "400": "noto-sans-jp/400.ttf", "700": "noto-sans-jp/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-jp/preview.ttf" },
"Noto Serif JP": { "normal": { "400": "noto-serif-jp/400.ttf", "700": "noto-serif-jp/700.ttf" }, "italic": "reuse-normal", "preview": "noto-serif-jp/preview.ttf" },
"Noto Sans KR": { "normal": { "400": "noto-sans-kr/400.ttf", "700": "noto-sans-kr/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-kr/preview.ttf" },
"Noto Serif KR": { "normal": { "400": "noto-serif-kr/400.ttf", "700": "noto-serif-kr/700.ttf" }, "italic": "reuse-normal", "preview": "noto-serif-kr/preview.ttf" },
"Noto Sans Arabic": { "normal": { "400": "noto-sans-arabic/400.ttf", "700": "noto-sans-arabic/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-arabic/preview.ttf" },
"Noto Naskh Arabic": { "normal": { "400": "noto-naskh-arabic/400.ttf", "700": "noto-naskh-arabic/700.ttf" }, "italic": "reuse-normal", "preview": "noto-naskh-arabic/preview.ttf" },
"Noto Sans Hebrew": { "normal": { "400": "noto-sans-hebrew/400.ttf", "700": "noto-sans-hebrew/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-hebrew/preview.ttf" },
"Noto Sans Thai": { "normal": { "400": "noto-sans-thai/400.ttf", "700": "noto-sans-thai/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-thai/preview.ttf" },
"Noto Emoji": { "normal": { "400": "noto-emoji/400.ttf", "700": "noto-emoji/700.ttf" }, "italic": "reuse-normal", "preview": "noto-emoji/preview.ttf" }
}
}
```
### Candidate source, license, script, and size evidence
Sizes are `Content-Length` bytes from a HEAD request to the exact current catalog assets on 2026-09-06. Responses reported `Content-Encoding: gzip`; these are compressed transfer-size estimates, not a claim about the eventual on-disk representation. Preview paths are aliases to the selected 400 face and add no extra bytes when stored once. Primary sources: [IBM Plex LICENSE.txt](https://github.com/IBM/plex/blob/master/LICENSE.txt), [Noto core LICENSE](https://github.com/notofonts/noto-fonts/blob/main/LICENSE), [Noto CJK Sans LICENSE](https://github.com/notofonts/noto-cjk/blob/main/Sans/LICENSE), and [Noto Emoji font LICENSE](https://github.com/googlefonts/noto-emoji/blob/main/fonts/LICENSE).
| Family | Style/weights in proposal | Current catalog source version | License | Script/fallback role | Gzip bytes (selected files) | Build owner; runtime owners |
| --- | --- | --- | --- | --- | ---: | --- |
| IBM Plex Serif | normal 400/700; italic 400/700 | `fonts.gstatic.com/s/ibmplexserif/v20` | OFL 1.1, Reserved Font Name `Plex` | Primary serif; Latin and punctuation stack | 294,717 | `packages/fonts`; `apps/web` FontDisplay; `packages/pdf` registration |
| IBM Plex Sans | normal 400/700; italic 400/700 | `fonts.gstatic.com/s/ibmplexsans/v23` | OFL 1.1, Reserved Font Name `Plex` | Primary sans | 435,469 | `packages/fonts`; `apps/web` FontDisplay; `packages/pdf` registration |
| Noto Serif | normal 400/700; italic 400/700 | `fonts.gstatic.com/s/notoserif/v33` | OFL 1.1 | Serif punctuation fallback | 1,055,120 | `packages/fonts`; `packages/pdf` fallback registration |
| Noto Sans | normal 400/700; italic 400/700 | `fonts.gstatic.com/s/notosans/v42` | OFL 1.1 | Sans punctuation fallback | 1,236,259 | `packages/fonts`; `packages/pdf` fallback registration |
| Noto Sans SC | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosanssc/v40` | OFL 1.1 (Noto CJK) | Simplified Han; CJK fallback | 12,766,416 | `packages/fonts`; `packages/pdf` CJK fallback |
| Noto Serif SC | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notoserifsc/v35` | OFL 1.1 (Noto CJK) | Simplified Han serif fallback | 17,350,185 | `packages/fonts`; `packages/pdf` CJK fallback |
| Noto Sans TC | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosanstc/v39` | OFL 1.1 (Noto CJK) | Traditional Han fallback | 8,628,278 | `packages/fonts`; `packages/pdf` CJK fallback |
| Noto Serif TC | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notoseriftc/v36` | OFL 1.1 (Noto CJK) | Traditional Han serif fallback | 11,804,923 | `packages/fonts`; `packages/pdf` CJK fallback |
| Noto Sans JP | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosansjp/v56` | OFL 1.1 (Noto CJK) | Kana and Japanese Han fallback | 6,383,035 | `packages/fonts`; `packages/pdf` CJK fallback |
| Noto Serif JP | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notoserifjp/v33` | OFL 1.1 (Noto CJK) | Kana and Japanese Han serif fallback | 8,685,862 | `packages/fonts`; `packages/pdf` CJK fallback |
| Noto Sans KR | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosanskr/v39` | OFL 1.1 (Noto CJK) | Hangul and Korean Han fallback | 6,102,888 | `packages/fonts`; `packages/pdf` CJK fallback |
| Noto Serif KR | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notoserifkr/v31` | OFL 1.1 (Noto CJK) | Hangul and Korean Han serif fallback | 11,113,442 | `packages/fonts`; `packages/pdf` CJK fallback |
| Noto Sans Arabic | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosansarabic/v33` | OFL 1.1 | Arabic sans fallback | 178,455 | `packages/fonts`; `packages/pdf` script fallback |
| Noto Naskh Arabic | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notonaskharabic/v44` | OFL 1.1 | Arabic serif fallback | 190,924 | `packages/fonts`; `packages/pdf` script fallback |
| Noto Sans Hebrew | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosanshebrew/v50` | OFL 1.1 | Hebrew fallback for both serif/sans slots | 55,707 | `packages/fonts`; `packages/pdf` script fallback |
| Noto Sans Thai | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosansthai/v29` | OFL 1.1 | Thai fallback for both serif/sans slots | 55,173 | `packages/fonts`; `packages/pdf` script fallback |
| Noto Emoji | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notoemoji/v62` | OFL 1.1 for font files; assets/tools have separate licenses | Emoji outline fallback; verify renderer support | 1,153,847 | `packages/fonts`; `packages/pdf` script fallback |
Estimated transfer size for all rows and listed styles: **87,490,700 bytes (~83.44 MiB)**. This confirms why a full-catalog bundle is out of scope. A later implementation should subset by declared glyph requirements or make the administrator choose fallback families; it must not silently fetch another CDN.
### Source and license obligations
- Pin an upstream release/commit and retain source attribution plus the complete applicable license with hosted assets. Do not use mutable `@latest` URLs as runtime sources.
- IBM Plex’s license has Reserved Font Name `Plex`; modified/subset outputs must follow OFL naming requirements.
- Noto core, Noto CJK, and Noto Emoji font files are OFL 1.1, but Noto Emoji documents separate Apache/public-domain treatment for tools and flag image assets. Bundle only font files unless those other assets are intentionally needed and separately attributed.
- License checks are build-owner responsibility (`packages/fonts`/tooling); runtime owners (`apps/web` and `packages/pdf`) consume only the validated manifest.
### Missing-family and missing-asset behavior
Local mode must resolve only same-origin administrator-hosted manifest paths. If imported resume data names an unavailable family, show an actionable missing-family error naming the family and required local asset; apply a configured local fallback only when the administrator explicitly supplied one. If a required weight/style/fallback asset is absent, fail the affected preview/export with an actionable diagnostic containing family/style/weight and local path. Never retry Google Fonts, jsDelivr, or any other remote URL in local mode.
Standard PDF families (Helvetica, Courier, Times-Roman) remain file-free. They do not prove that a document containing punctuation, CJK, Arabic, Hebrew, Thai, or emoji is network-free; the script fallback rows remain required.
## Verification record
Completed read-only checks before handoff:
- CodeGraph exploration of font catalog, picker preview, browser PDF, server PDF, and existing fallback tests.
- `pnpm dlx @tanstack/intent@latest list`: no matching local intent skill for this work.
- `pnpm exec biome check tests/e2e/specs/offline-fonts.spec.ts tests/e2e/fixtures/offline-fonts.ts turbo.json`: passed.
- `git diff --check`: passed.
- `pnpm --filter @reactive-resume/fonts test`: passed (55 tests).
- `pnpm --filter @reactive-resume/pdf exec vitest run src/hooks/use-register-fonts.test.ts`: passed (35 tests).
- Web typography/regression suite: passed (940 tests across 135 files); web and server package typechecks passed.
- `pnpm exec playwright test tests/e2e/specs/offline-fonts.spec.ts --list`: passed (4 diagnostic tests collected).
- E2E diagnostic execution: blocked by missing build outputs and unavailable PostgreSQL/server; no success claim made.
- `pnpm exec turbo boundaries`: passed on fresh rerun (Turbo 2.10.12, 1108 files, no issues).
The implementation intentionally stops at diagnostic fixtures and manifest evidence. Shared source resolution, asset hosting, local-mode configuration, and production behavior remain Phase A step 3+ work.
@@ -0,0 +1,22 @@
# Plan 27A remediation round 2
Date: 2026-09-06
Base: `ae8e2f76f`
## Focused fixes
- Removed multilingual markers from the fixture headline. Each marker now exists only in its dedicated summary paragraph.
- Added pure marker-location helpers. Marker lookup joins PDF text items, supports markers split across items, rejects duplicate occurrences, and rejects non-whitespace neighbors that could contaminate a local crop.
- Raster measurement still scans with antialiasing padding but counts ink only inside the marker box, preventing neighboring glyphs from making blank or tofu-like evidence pass.
- Browser PDF download now separates download errors from post-download evidence errors. A received download with failed rasterization is reported as `unresolved-raster-evidence-error` and fails the opt-in test rather than passing as a generic download error.
- Added focused pure tests covering duplicate, split, neighboring, blank, and tofu-like cases.
- Removed trailing spaces from `plan-27a-remediation.md`.
## Verification
- `pnpm exec vitest run tests/e2e/fixtures/offline-font-markers.test.ts` — 5/5 passed.
- `pnpm exec biome check tests/e2e/specs/offline-fonts.spec.ts tests/e2e/fixtures/offline-fonts.ts tests/e2e/fixtures/offline-font-markers.ts tests/e2e/fixtures/offline-font-markers.test.ts` — passed.
- `pnpm exec playwright test tests/e2e/specs/offline-fonts.spec.ts --list` — 4 tests collected.
- `git diff --check` — passed after remediation-document whitespace cleanup.
Full diagnostic E2E remains opt-in and was not run in this focused round. Server outbound request capture and verifiable restart identity remain explicit external host-level blockers; no production resolver changes were made.
+24
View File
@@ -0,0 +1,24 @@
# Plan 27A remediation
Date: 2026-09-06
Base: `61b58ae9a`
Scope: concrete findings from `.orchestration/plan-27a-independent-review.md` only.
## Remediated findings
- Builder PDF preview and browser PDF download now produce raster evidence. The fixture stores each multilingual marker in its own summary paragraph, allowing the diagnostic to locate marker-local PDF text boxes and measure only those raster crops. Reports attach a rendered PNG plus per-marker `inkPixels`, trimmed dimensions, and status. Blank and tofu-like crops fail assertions; no whole-page snapshot is used.
- PDF text extraction is reported separately as `textLayerMarkers`. It is not described or asserted as proof of visible glyph outlines.
- Server PDF output remains text-extraction-only and is explicitly classified as `serverGateStatus: unresolved-external-host-level-blocker`. `serverRestartFlag` is caller input, not restart proof. Browser Playwright routing is not used to infer server egress, and no production resolver or instrumentation behavior was added.
- `.orchestration/plan-27a-diagnostic.md` now records a fresh boundaries pass and the corrected `87,490,700 bytes (~83.44 MiB)` arithmetic.
- Diagnostic remains opt-in through `OFFLINE_FONT_DIAGNOSTIC=1`; normal CI behavior remains unchanged. Request logs stay sanitized to hostname and pathname.
## Verification
- `pnpm exec biome check tests/e2e/specs/offline-fonts.spec.ts tests/e2e/fixtures/offline-fonts.ts` — passed.
- `git diff --check` — passed.
- `pnpm exec playwright test tests/e2e/specs/offline-fonts.spec.ts --list` — 4 tests collected.
- `pnpm --filter @reactive-resume/fonts test` — 55/55 passed.
- `pnpm --filter @reactive-resume/pdf exec vitest run src/hooks/use-register-fonts.test.ts` — 35/35 passed.
- `pnpm exec turbo boundaries` — passed on fresh rerun (Turbo 2.10.12, 1108 files, no issues).
Full diagnostic E2E remains unrun because this environment lacks production build output, PostgreSQL, and a production server. Server cold-network capture and verifiable restart identity remain external host-level blockers by design; this remediation does not claim that gate is complete.
@@ -0,0 +1,17 @@
# Issue 3350 remediation evidence
## Findings addressed
- `item-pagination.test.tsx` now derives complete numbered-token inventories for each generated fixture and asserts every token exactly once. Sampled token-to-physical-page placement checks remain separate.
- Pagination fixtures snapshot `metadata.layout.pages` before rendering and assert authored layout pages are unchanged afterward. Overflow fixtures also assert physical PDF page count exceeds authored page count.
- Unsafe `wrap={false}` renderer coverage remains diagnostic-only; no item controls, schema flags, or runtime behavior were added.
## Verification
- `rtk proxy pnpm --filter @reactive-resume/pdf exec vitest run src/semantic/pagination.test.tsx src/templates/shared/item-pagination.test.tsx` — 2 files, 11 tests passed.
- `rtk proxy pnpm --filter @reactive-resume/pdf typecheck` — passed.
- `rtk proxy pnpm exec biome check packages/pdf/src/templates/shared/item-pagination.test.tsx` — passed.
- `rtk proxy pnpm exec turbo boundaries` — passed; 1109 files checked.
- `rtk git diff --check origin/main...HEAD` — passed.
Only PDF test coverage and this evidence file changed; production behavior remains untouched.
-332
View File
@@ -1,332 +0,0 @@
<!-- refreshed: 2026-05-11 -->
# Architecture
**Analysis Date:** 2026-05-11
## System Overview
Reactive Resume is a single full-stack web application running as one Node.js process on port 3000. The web app is a TanStack Start application (Vite + React 19 + Nitro server) packaged in a pnpm/Turborepo monorepo. All API surface area, server-side rendering, file uploads, OAuth, OpenAPI, MCP, and PWA assets are served from the same process. Internal packages are consumed as TypeScript source through `package.json` `exports` maps that point directly at `src` files; there is no per-package `dist` output to depend on.
```text
┌────────────────────────────────────────────────────────────────────────────┐
│ Browser (React 19) │
│ TanStack Router · TanStack Query · Zustand stores · React PDF view │
│ `apps/web/src/router.tsx` · `apps/web/src/routes/__root.tsx` │
└──────────┬─────────────────────────────────────────────────────┬───────────┘
│ HTTP / SSR hydration │ /api/rpc (RPCLink)
▼ ▼
┌────────────────────────────────────────────────────────────────────────────┐
│ Nitro server entry (TanStack Start) │
│ `apps/web/src/server.ts` · Nitro plugins (`apps/web/plugins/`) │
├──────────────────────────┬─────────────────────────────────────────────────┤
│ File-based routes │ Route `server.handlers` blocks │
│ `apps/web/src/routes/*` │ `api/rpc.$.ts` · `api/auth.$.ts` · `api/health.ts`│
│ │ `api/openapi.$.ts` · `api/uploads/$userId.$.ts`│
│ │ `mcp/index.ts` · `[.]well-known/*` · `schema.json.ts`│
└──────────┬───────────────┴──────────────────────────────┬─────────────────┘
│ in-process router client │ HTTP handler
▼ ▼
┌────────────────────────────────────────────────────────────────────────────┐
│ oRPC routers │
│ `packages/api/src/routers/{ai,auth,flags,resume,statistics,storage}.ts` │
│ Procedures: `publicProcedure` / `protectedProcedure` │
│ Middleware: `packages/api/src/middleware/rate-limit/index.ts` │
└──────────┬─────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────────────────┐
│ Services & helpers │
│ `packages/api/src/services/{resume,ai,auth,storage,flags,statistics}.ts` │
│ `packages/api/src/helpers/{resume-access,resume-access-policy}.ts` │
│ `packages/api/src/services/resume-events.ts` (Postgres LISTEN/NOTIFY) │
│ `packages/auth/src/config.ts` (Better Auth) │
└──────────┬──────────────────────────────────────┬──────────────────────────┘
│ Drizzle ORM │ S3 / local FS
▼ ▼
┌──────────────────────────────────┐ ┌──────────────────────────────────┐
│ PostgreSQL (via `pg`) │ │ Storage (S3 or `<workspace>/data`)│
│ `packages/db/src/client.ts` │ │ `packages/api/src/services/storage.ts`│
│ schema: `packages/db/src/schema/*`│ │ served by `routes/uploads/$userId.$.tsx`│
│ migrations: `migrations/` │ └──────────────────────────────────┘
└──────────────────────────────────┘
```
## Component Responsibilities
| Component | Responsibility | File |
|-----------|----------------|------|
| Web app shell | TanStack Start app, route tree, SSR/CSR boundary, PWA, builder UI | `apps/web/src/router.tsx`, `apps/web/src/routes/__root.tsx` |
| Server entry | Nitro fetch handler wrapping `react-start/server-entry` | `apps/web/src/server.ts` |
| Migration plugin | Walks up to repo root, runs Drizzle migrations on boot | `apps/web/plugins/1.migrate.ts` |
| Storage plugin | Validates `<workspace>/data` writability when S3 is unused | `apps/web/plugins/2.storage.ts` |
| oRPC router root | Aggregates all sub-routers exposed at `/api/rpc` and OpenAPI | `packages/api/src/routers/index.ts` |
| oRPC context | Header-based auth resolution, `publicProcedure`/`protectedProcedure` | `packages/api/src/context.ts` |
| Resume service | CRUD, patch (RFC 6902), password, lock, statistics, analysis, events | `packages/api/src/services/resume.ts` |
| Storage service | S3 + local FS abstraction, image processing via `sharp` | `packages/api/src/services/storage.ts` |
| Resume access policy | Owner/viewer/redaction rules for `getBySlug` and statistics | `packages/api/src/helpers/resume-access-policy.ts` |
| Resume events | Postgres `LISTEN/NOTIFY` channel for live updates | `packages/api/src/services/resume-events.ts` |
| Auth | Better Auth config, OAuth provider, passkey, 2FA, API keys, JWKS | `packages/auth/src/config.ts` |
| Database | Drizzle client singleton, schema, generated migrations | `packages/db/src/client.ts`, `packages/db/src/schema/*.ts`, `migrations/` |
| Schema | Zod resume/page/template models shared across web + API + PDF + MCP | `packages/schema/src/resume/data.ts`, `packages/schema/src/templates.ts` |
| PDF rendering | React PDF `Document`, font registration, 14 template implementations | `packages/pdf/src/document.tsx`, `packages/pdf/src/templates/index.ts`, `packages/pdf/src/hooks/use-register-fonts.ts` |
| Shared UI | Base UI / shadcn-style component library and hooks | `packages/ui/src/components/*.tsx`, `packages/ui/src/hooks/*.tsx` |
| MCP server | Model Context Protocol server backed by oRPC routers | `apps/web/src/routes/mcp/index.ts`, `apps/web/src/routes/mcp/-helpers/*` |
## Pattern Overview
**Overall:** Modular monolith. One deployable web app + a constellation of source-only TypeScript packages communicating through typed `package.json` `exports`. Browser ↔ server communication uses **oRPC** (typed RPC with REST/OpenAPI generation) instead of REST/tRPC. SSR and client share the same router/queryClient via TanStack Start.
**Key Characteristics:**
- Source-consumed workspace packages (no per-package `dist` build) keep types end-to-end.
- The oRPC router is mounted twice: as a Fetch handler at `/api/rpc` and as an in-process `createRouterClient` for SSR/server functions, with identical types on both paths.
- Browser-only code (React PDF preview, PDF.js, canvas) is isolated behind explicit `.browser.tsx` files and `ssr: false` / `ssr: "data-only"` route opts to keep SSR bundles small and safe.
- Postgres is both the data store and the event bus (`pg_notify` channel `resume_updated` for live builder sync).
- Shared concerns (auth, theme, locale, feature flags, oRPC client, query client) are loaded once and passed through TanStack Router `context` rather than fetched per-route.
## Layers
**Routes (`apps/web/src/routes/`):**
- Purpose: File-based TanStack Router routes; some are pure UI, others embed `server.handlers` blocks that act as HTTP endpoints.
- Location: `apps/web/src/routes/`
- Contains: Page components, route loaders, server-only API handlers, layout wrappers.
- Depends on: `packages/api/routers` (mounted at `/api/rpc`), `packages/auth/config` (mounted at `/api/auth`), `packages/db/client`, `packages/api/services/*`.
- Used by: TanStack Router (route tree is regenerated into `apps/web/src/routeTree.gen.ts`).
**oRPC API layer (`packages/api/src/routers/`):**
- Purpose: Public typed contract for the browser, in-process callers, and OpenAPI/MCP consumers.
- Location: `packages/api/src/routers/{ai,auth,flags,resume,statistics,storage}.ts`, aggregated in `packages/api/src/routers/index.ts`.
- Contains: Procedure definitions, Zod input/output schemas, REST metadata, rate-limit middleware bindings.
- Depends on: `packages/api/src/context.ts`, `packages/api/src/dto/*`, `packages/api/src/services/*`.
- Used by: `apps/web/src/routes/api/rpc.$.ts` (RPCHandler), `apps/web/src/routes/api/openapi.$.ts` (OpenAPIHandler), `apps/web/src/libs/orpc/client.ts` (isomorphic client), MCP tools in `apps/web/src/routes/mcp/-helpers/tools.ts`.
**Services / business logic (`packages/api/src/services/`):**
- Purpose: All cross-cutting business rules — resume CRUD, statistics, AI orchestration, storage, feature flags, auth helpers, event publishing.
- Location: `packages/api/src/services/*.ts`
- Contains: Side-effecting functions with explicit `userId`-style inputs. No request/response coupling.
- Depends on: `packages/db/client`, `packages/db/schema`, `packages/auth/config`, `packages/schema/resume/*`, `packages/utils/*`, `packages/email/transport`, AI SDKs.
- Used by: Routers, MCP helpers, the `/api/health` route.
**Persistence (`packages/db/`):**
- Purpose: Drizzle ORM client + schema definitions; the only place SQL is written.
- Location: `packages/db/src/client.ts`, `packages/db/src/schema/{auth,resume,index}.ts`, `packages/db/src/relations.ts`.
- Contains: Postgres `Pool` singleton (stored on `globalThis.__pool` to survive HMR), `drizzle()` client, table definitions, relations.
- Depends on: `pg`, `drizzle-orm`, `packages/env/server`.
- Migrations: Generated by `drizzle-kit` into the repo-root `migrations/` directory (see `packages/db/drizzle.config.ts`) and applied at startup by `apps/web/plugins/1.migrate.ts`.
**Auth (`packages/auth/`):**
- Purpose: Better Auth instance with email/password, OAuth (Google/GitHub/LinkedIn + generic), passkey, 2FA, API keys, JWKS, and a JWT-issuing OAuth provider for MCP clients.
- Location: `packages/auth/src/config.ts`, `packages/auth/src/functions.ts`, `packages/auth/src/types.ts`.
- Mounted at: `apps/web/src/routes/api/auth.$.ts` (delegates `request → auth.handler(request)` after sanitizing OAuth params).
- Verifies tokens for MCP at `apps/web/src/routes/mcp/index.ts` via `verifyOAuthToken`.
**PDF rendering (`packages/pdf/`):**
- Purpose: React PDF document and 14 visual templates (named after Pokémon).
- Location: `packages/pdf/src/document.tsx` mounts `getTemplatePage(template)`; templates live under `packages/pdf/src/templates/<name>/`.
- Shared template primitives: `packages/pdf/src/templates/shared/{filtering,rich-text,sections,primitives,picture,page-size,columns}.ts(x)`.
- Fonts: `packages/pdf/src/hooks/use-register-fonts.ts` owns React PDF font registration, standard PDF font handling, CJK fallback stacks, and global hyphenation.
- Consumed by: Web builder preview (`apps/web/src/components/resume/preview*.tsx`, `pdf-canvas.tsx`), public resume page (`apps/web/src/routes/$username/$slug.tsx`), and the OpenAPI `/resumes/{id}/download` procedure (`apps/web/src/routes/api/-helpers/resume-pdf.ts`).
**Shared schemas (`packages/schema/`):**
- Purpose: Zod source of truth for resume data, templates, page settings, and AI analysis.
- Location: `packages/schema/src/resume/{data,default,sample,analysis}.ts`, `packages/schema/src/templates.ts`, `packages/schema/src/page.ts`, `packages/schema/src/icons.ts`.
- Used by: API DTOs (`packages/api/src/dto/resume.ts`), DB column typing (`packages/db/src/schema/resume.ts`), import package, PDF rendering, web forms, MCP tool descriptions, and the public JSON schema at `/schema.json`.
**Shared UI (`packages/ui/`):**
- Purpose: Headless/styled component library (Base UI + shadcn-style) used by `apps/web`.
- Location: `packages/ui/src/components/*.tsx`, `packages/ui/src/hooks/*.tsx`.
- Examples: `dialog`, `dropdown-menu`, `command`, `resizable`, `sonner`, `tooltip`, `form`, `direction`.
- Styles: `packages/ui/src/styles/globals.css` (Tailwind v4 entry).
**Support packages:**
- `packages/utils` — small focused helpers (`color`, `date`, `field`, `file`, `html`, `level`, `locale`, `network-icons`, `rate-limit`, `sanitize`, `string`, `style`, `url`, plus Node-only `monorepo.node`, `url-security.node`, and `resume/{docx,patch}`).
- `packages/env` — `@t3-oss/env-core` server schema; `dotenv` loads the repo-root `.env` (`packages/env/src/server.ts`).
- `packages/email` — `nodemailer` transport + `react-email` templates (`packages/email/src/transport.ts`, `packages/email/src/templates/*.tsx`).
- `packages/import` — converters for JSON Resume, Reactive Resume v3/v4 JSON (`packages/import/src/*.tsx`).
- `packages/ai` — Zustand store, AI prompts (`packages/ai/src/prompts/*.md`), patch-resume tool, sanitize/extraction helpers consumed by the AI router.
- `packages/fonts` — generated Google Fonts metadata (`packages/fonts/src/webfontlist.json`, `packages/fonts/src/index.ts`).
- `packages/scripts` — repo-level scripts (`packages/scripts/database/reset.ts`, `packages/scripts/fonts/generate.ts`).
- `packages/config` — shared TypeScript/Vitest base configs (`packages/config/tsconfig.base.json`, `packages/config/vitest.config.ts`).
- `packages/runtime-externals` — declares `bcrypt`, `sharp`, `@aws-sdk/client-s3` so they remain runtime-only (externalized in `apps/web/vite.config.ts`).
## Data Flow
### Primary Request Path (browser RPC call)
1. Browser route uses `orpc.resume.getById.queryOptions(...)` from `apps/web/src/libs/orpc/client.ts:84` to fetch data.
2. The isomorphic oRPC client (`apps/web/src/libs/orpc/client.ts:28-47`) creates an `RPCLink` pointing at `${window.location.origin}/api/rpc` with `credentials: "include"` and a `BatchLinkPlugin`.
3. Request hits the file route `apps/web/src/routes/api/rpc.$.ts`, where `RPCHandler` (line 9) dispatches with `BatchHandlerPlugin`, `RequestHeadersPlugin`, and `StrictGetMethodPlugin`.
4. `publicProcedure` (`packages/api/src/context.ts:79`) resolves the user from headers (`x-api-key` → bearer JWT via JWKS → Better Auth session cookie).
5. `protectedProcedure` (`packages/api/src/context.ts:90`) rejects unauthenticated callers with `ORPCError("UNAUTHORIZED")`.
6. Router handler in `packages/api/src/routers/resume.ts` calls into `packages/api/src/services/resume.ts`, which queries Drizzle (`packages/db/src/client.ts:32`).
7. Response is serialized back through oRPC and consumed by TanStack Query / the route component.
### SSR / Server-Side Path
1. During SSR, `apps/web/src/libs/orpc/client.ts:13-27` short-circuits the HTTP path via `createRouterClient(router, { context: async () => ({ locale, reqHeaders }) })`.
2. The same `publicProcedure`/`protectedProcedure` middleware runs in-process — no socket hop — but still resolves auth from the original request headers via `getRequestHeaders()` from `@tanstack/react-start/server`.
3. Route loaders (e.g. `apps/web/src/routes/builder/$resumeId/route.tsx:39-44`) populate the query cache via `context.queryClient.ensureQueryData(orpc.resume.getById.queryOptions(...))`.
### Resume Live-Update Flow
1. `subscribe` procedure in `packages/api/src/routers/resume.ts:76` returns an async generator.
2. On any mutation, `packages/api/src/services/resume-events.ts:37` calls `pg_notify('resume_updated', JSON.stringify(event))`.
3. Subscribing clients receive `resume.updated` SSE-style events via oRPC streaming (`apps/web/src/libs/orpc/client.ts:51-82`, `streamClient`).
4. The builder route consumes them through `useResumeUpdateSubscription` (`apps/web/src/components/resume/builder-resume-draft.ts`).
### PDF Download Path
1. Web client requests `GET /api/openapi/resumes/{id}/download` (oRPC OpenAPI handler at `apps/web/src/routes/api/openapi.$.ts`).
2. `downloadResumePdfProcedure` in `apps/web/src/routes/api/-helpers/resume-pdf.ts` loads the resume, renders `ResumeDocument` from `packages/pdf/src/document.tsx`, persists to storage via `getStorageService()`, and returns/streams the PDF.
### Public Resume Path
1. `apps/web/src/routes/$username/$slug.tsx` uses `ssr: "data-only"` — server fetches `resume.getBySlug` but renders the React PDF preview only on the client.
2. `packages/api/src/helpers/resume-access-policy.ts` enforces visibility, redacts non-public fields, and throws `NEED_PASSWORD` for password-protected resumes (the route then redirects to `/auth/resume-password`).
**State Management:**
- Server cache: TanStack Query (`apps/web/src/libs/query/client.ts`) wrapped by oRPC's `createTanstackQueryUtils`.
- Local UI state: Zustand stores under `apps/web/src/routes/builder/$resumeId/-store/{section,sidebar}.ts`, `apps/web/src/dialogs/store.ts`, `apps/web/src/components/command-palette/store.ts`, `apps/web/src/components/resume/builder-resume-draft.ts`.
- Router context: `theme`, `locale`, `session`, `flags`, `queryClient`, `orpc` are computed once in `apps/web/src/router.tsx` (and again in the root route `beforeLoad`) and reused by descendants.
- Cookies: builder layout (`BUILDER_LAYOUT_COOKIE_NAME`), theme, and locale are persisted via `getCookie`/`setCookie` server functions.
## Key Abstractions
**oRPC procedures (`os.$context<ORPCContext>()`):**
- Purpose: Typed RPC procedures that double as REST endpoints and MCP tool surfaces.
- Examples: `publicProcedure` and `protectedProcedure` in `packages/api/src/context.ts:79`/`:90`.
- Pattern: `procedure.route({...openapi metadata}).input(zodSchema).use(rateLimitMiddleware).output(zodSchema).handler(async ({ context, input }) => ...)`.
**Drizzle tables:**
- Purpose: Strongly-typed Postgres schema; `.jsonb()` columns are typed against Zod-derived TypeScript (`ResumeData`, `StoredResumeAnalysis`).
- Examples: `packages/db/src/schema/resume.ts` (resume, resumeStatistics, resumeAnalysis), `packages/db/src/schema/auth.ts` (Better Auth tables).
- Pattern: `pg.pgTable("name", { ... }, (t) => [pg.index().on(...), pg.unique().on(...)])`.
**Template pages (React PDF):**
- Purpose: One `TemplatePage` component per visual template, mapped by name in `packages/pdf/src/templates/index.ts`.
- Examples: `packages/pdf/src/templates/azurill/AzurillPage.tsx`, `packages/pdf/src/templates/onyx/OnyxPage.tsx`.
- Pattern: `(props: { page: LayoutPage; pageIndex: number }) => JSX`, consuming `RenderProvider` from `packages/pdf/src/context.tsx`.
**Base UI components:**
- Purpose: Headless primitives styled with Tailwind v4 and exported as composable parts.
- Examples: `packages/ui/src/components/dialog.tsx`, `packages/ui/src/components/command.tsx`, `packages/ui/src/components/resizable.tsx`.
- Imported via deep paths: `import { Dialog } from "@reactive-resume/ui/components/dialog";`.
**TanStack Router file routes:**
- Purpose: Page components, loaders, and optional `server.handlers` blocks per file.
- Examples: `apps/web/src/routes/builder/$resumeId/route.tsx`, `apps/web/src/routes/api/rpc.$.ts`.
- Pattern: `export const Route = createFileRoute("/path")({ component, loader, beforeLoad, server: { handlers: { GET, POST } }, ssr });`.
## Entry Points
**Vite + Nitro build entry:**
- Location: `apps/web/vite.config.ts`
- Triggers: `pnpm dev`, `pnpm build`. Wires TanStack Start, Tailwind v4, Lingui (i18n), Nitro plugins, and the Vite PWA plugin.
- Externals: `bcrypt`, `sharp`, `@aws-sdk/client-s3` (declared in `packages/runtime-externals`).
**Server fetch entry:**
- Location: `apps/web/src/server.ts`
- Responsibilities: Wraps `@tanstack/react-start/server-entry` and substitutes `srvx`'s `FastResponse` as the global `Response`.
**Nitro startup plugins:**
- `apps/web/plugins/1.migrate.ts` — resolves the repo-root `migrations/` folder, opens its own `pg.Pool`, runs Drizzle migrations on boot, then closes the pool.
- `apps/web/plugins/2.storage.ts` — when S3 env vars are absent, ensures the local storage directory is writable before serving requests.
**Router entry:**
- Location: `apps/web/src/router.tsx`
- Responsibilities: Builds `queryClient`, loads `theme`/`locale`/`session`/`flags` in parallel, creates the TanStack Router with router context, registers SSR query integration.
**Root route:**
- Location: `apps/web/src/routes/__root.tsx`
- Responsibilities: HTML shell, providers (`I18nProvider`, `ThemeProvider`, `HotkeysProvider`, `DirectionProvider`, `TooltipProvider`, `ConfirmDialogProvider`, `PromptDialogProvider`), PWA head/scripts, `DialogManager`, `CommandPalette`, `Toaster`.
**HTTP endpoints (route `server.handlers`):**
- `apps/web/src/routes/api/rpc.$.ts` — `/api/rpc/*` oRPC handler (browser RPC).
- `apps/web/src/routes/api/auth.$.ts` — `/api/auth/*` Better Auth handler (with OAuth payload sanitization).
- `apps/web/src/routes/api/health.ts` — `/api/health` JSON probe (db + storage with timeouts).
- `apps/web/src/routes/api/openapi.$.ts` — `/api/openapi/*` OpenAPI handler + spec.
- `apps/web/src/routes/api/uploads/$userId.$.ts` and `apps/web/src/routes/uploads/$userId.$.tsx` — signed/etagged static file serving from storage.
- `apps/web/src/routes/mcp/index.ts` — `/mcp` Model Context Protocol server (OAuth-protected).
- `apps/web/src/routes/[.]well-known/*` — `/.well-known/oauth-authorization-server`, `/.well-known/oauth-protected-resource`, `/.well-known/openid-configuration`, `/.well-known/mcp` discovery documents.
- `apps/web/src/routes/schema[.]json.ts` — `/schema.json` public JSON Schema for `ResumeData`.
**Builder + public resume entry points:**
- `apps/web/src/routes/builder/$resumeId/route.tsx` — authenticated builder shell (header + resizable left/right sidebars + artboard outlet + assistant).
- `apps/web/src/routes/builder/$resumeId/index.tsx` — `ssr: false`; lazy-loaded `PreviewPage` running React PDF/canvas only in the browser.
- `apps/web/src/routes/$username/$slug.tsx` — `ssr: "data-only"`; public/shared resume view (with password gating via `NEED_PASSWORD` ORPCError).
## Architectural Constraints
- **Single Node process:** Everything (web, oRPC, auth, MCP, OpenAPI, file serving) runs in one Node 24 process on port 3000. No separate API service.
- **Source-only workspace packages:** `package.json` `exports` point at `src/*.ts(x)`; do not assume `dist` output exists. The Vite build externalizes `bcrypt`, `sharp`, `@aws-sdk/client-s3` (`apps/web/vite.config.ts:55`).
- **Globals on `globalThis` for DB pool:** `packages/db/src/client.ts:8-11` caches the `pg.Pool` and Drizzle client on `globalThis.__pool` / `globalThis.__drizzle` to survive HMR reloads.
- **Browser-only PDF rendering:** PDF.js, `@react-pdf/renderer` canvas, and the builder preview must stay off the SSR path. Use `.browser.tsx` suffix files and `ssr: false` (builder preview) or `ssr: "data-only"` (public resume).
- **Generated route tree:** `apps/web/src/routeTree.gen.ts` is regenerated by TanStack Router tooling; never edit by hand.
- **Migrations folder location:** `drizzle-kit` writes to `../../migrations` from `packages/db/drizzle.config.ts`, so all migration directories live at the repo root, not inside the package.
- **DATABASE_URL not auto-loaded for drizzle-kit:** `pnpm db:migrate` / `pnpm db:generate` require `DATABASE_URL` exported in the shell; only the runtime Node code loads `.env` via `packages/env/src/server.ts`.
- **Rate limiting is production-only:** `packages/api/src/middleware/rate-limit/index.ts:5` and the Better Auth config gate rate limits on `process.env.NODE_ENV === "production"`.
- **OAuth audience binding:** `verifyOAuthToken` (`packages/auth/src/config.ts:40`) only accepts JWTs whose `aud` matches `${APP_URL}` (with/without trailing slash) or `${APP_URL}/mcp`.
- **Postgres LISTEN/NOTIFY coupling:** Live resume updates depend on a single shared `pg.Pool` (`packages/db/src/client.ts:13`); scaling beyond one process requires replacing `pg_notify` with a broker.
## Anti-Patterns
### Ad-hoc fetching of router context
**What happens:** Components separately calling `getSession()`, `getTheme()`, or `getLocale()` instead of reading them from TanStack Router context.
**Why it's wrong:** The root route's `beforeLoad` already loads these in parallel (`apps/web/src/routes/__root.tsx:82-93`) and exposes them via `Route.useRouteContext()`; duplicating the calls causes extra round-trips during SSR and triggers locale reloads.
**Do this instead:** Use `Route.useRouteContext()` or read from a parent route loader, as `apps/web/src/routes/__root.tsx:101` does for theme/locale.
### Direct DB or storage imports in client code
**What happens:** Importing `@reactive-resume/db/client` or `@reactive-resume/api/services/storage` from a non-route component.
**Why it's wrong:** Pulls `pg`, `sharp`, `bcrypt`, `@aws-sdk/client-s3` into the client bundle (which Vite externalizes — the build will fail or break at runtime).
**Do this instead:** Call the corresponding oRPC procedure from `packages/api/src/routers/*`. Server-only imports belong inside route `server.handlers` blocks or `.server.tsx` files like `apps/web/src/libs/resume/pdf-document.server.tsx`.
### PDF/canvas code in shared modules
**What happens:** Importing `@react-pdf/renderer` or PDF.js from a file that participates in SSR.
**Why it's wrong:** These libraries crash under Node SSR (canvas/DOM dependencies).
**Do this instead:** Keep browser code in `*.browser.tsx` / `pdf-canvas.tsx` and gate with `ssr: false` (e.g. `apps/web/src/routes/builder/$resumeId/index.tsx:6`) or `ssr: "data-only"` (e.g. `apps/web/src/routes/$username/$slug.tsx:40`).
### Bypassing the resume access policy
**What happens:** Reading resume rows directly from Drizzle in a public procedure without applying redaction.
**Why it's wrong:** Leaks owner-only fields (password hash, private flags, statistics) and breaks the password-gate flow that depends on `NEED_PASSWORD` errors.
**Do this instead:** Route through `packages/api/src/helpers/resume-access-policy.ts` (`assertCanView`, `redactResumeForViewer`, `shouldCountForStatistics`) as `packages/api/src/services/resume.ts` does.
### Hand-editing generated files
**What happens:** Modifying `apps/web/src/routeTree.gen.ts` or migration SQL after Drizzle writes it.
**Why it's wrong:** Edits are overwritten on the next `tanstack-router` regen or migration; data drift between snapshots and SQL breaks future migrations.
**Do this instead:** Add a route file under `apps/web/src/routes/`, or change the Drizzle schema in `packages/db/src/schema/*.ts` and run `pnpm db:generate`.
## Error Handling
**Strategy:** Typed errors via `ORPCError` codes; HTTP responses for low-level handlers; route-level `defaultErrorComponent` and `onError` for UI fallbacks.
**Patterns:**
- Procedures throw `ORPCError("UNAUTHORIZED"|"NOT_FOUND"|"NEED_PASSWORD"|...)` or use `.errors({ ... })` to declare typed application errors (e.g. `RESUME_SLUG_ALREADY_EXISTS`, `RESUME_VERSION_CONFLICT` in `packages/api/src/routers/resume.ts`).
- The OAuth bearer / API key / session resolvers in `packages/api/src/context.ts:14-55` swallow verification errors and log via `console.warn` so unauthenticated requests fall through to the next strategy.
- `apps/web/src/routes/api/rpc.$.ts` and `apps/web/src/routes/api/openapi.$.ts` install `onError` interceptors that log every server error with a tag (`[oRPC Server]`, `[OpenAPI]`).
- Route-level `onError` (e.g. `apps/web/src/routes/$username/$slug.tsx:24`) translates `NEED_PASSWORD` into a redirect.
- Top-level UI fallbacks: `apps/web/src/components/layout/error-screen.tsx`, `loading-screen.tsx`, `not-found-screen.tsx` (wired in `apps/web/src/router.tsx:30-32`).
- Healthcheck uses a 1.5s timeout helper (`apps/web/src/routes/api/health.ts:22-34`) so a stuck dependency cannot stall the probe.
## Cross-Cutting Concerns
**Logging:** `console.info`/`console.warn`/`console.error` with bracketed prefixes (e.g. `[oRPC Server]`, `[Healthcheck]`, `[oRPC client]`). No external log shipping is wired.
**Validation:** Zod 4 everywhere — Drizzle column types (`packages/db/src/schema/resume.ts`), oRPC input/output schemas, AI tool inputs, environment variables (`packages/env/src/server.ts`), and the public `/schema.json` route.
**Authentication:** Better Auth in `packages/auth/src/config.ts` (Drizzle adapter, email/password + OAuth + passkey + 2FA + admin + API keys + JWT + generic OAuth + dynamic client registration + custom `oauthProvider` for MCP). The unified resolver in `packages/api/src/context.ts:64` is the only place that decides which credential wins.
**Internationalization:** Lingui — `apps/web/lingui.config.ts`, `apps/web/locales/*.po`, `apps/web/src/libs/locale.ts`, RTL toggling in `__root.tsx`.
**Theming:** `apps/web/src/libs/theme.ts` (cookie-backed), `apps/web/src/components/theme/provider.tsx` (`next-themes`).
**Rate limiting:** `@orpc/experimental-ratelimit` with an in-memory ratelimiter from `packages/api/src/middleware/rate-limit/index.ts`. Trusted IP headers come from `packages/utils/src/rate-limit.ts`.
**Feature flags:** Server-resolved at boot (`packages/api/src/routers/flags.ts` and `packages/api/src/services/flags.ts`), then carried in router context via `apps/web/src/router.tsx:20`.
---
*Architecture analysis: 2026-05-11*
-393
View File
@@ -1,393 +0,0 @@
# 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 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'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*
-238
View File
@@ -1,238 +0,0 @@
# Coding Conventions
**Analysis Date:** 2026-05-11
This document is the canonical short-form reference for code style, structure, and feature-boundary rules in the Reactive Resume monorepo. The authoritative long-form reference lives in `AGENTS.md` at the repo root — when in doubt, defer to it.
## Tooling Stack
- **Formatter + linter:** Biome 2.x (`biome.json`). Pre-commit hook (`lefthook.yml`) runs `biome check --write --unsafe` on staged JS/TS/JSON files.
- **Type checker:** `tsgo --noEmit` (the `@typescript/native-preview` TS implementation) in every workspace package and `apps/web`. Use `pnpm typecheck` at the root or `pnpm --filter <pkg> typecheck` per package.
- **TypeScript base config:** `packages/config/tsconfig.base.json`, consumed by `tsconfig.json` at root and per-package `tsconfig.json` files.
- **Commit hook:** commitlint with `@commitlint/config-conventional` (`commitlint.config.cjs`). `body-max-line-length` is disabled.
- **Internal CLI:** `pnpm check` is **write-capable** (`biome check --write --unsafe .`). Use `biome check .` (no `--write`) for read-only inspection.
## Biome Configuration (`biome.json`)
**Formatter:**
- `lineWidth: 120`
- `indentStyle: "tab"`
- `javascript.formatter.quoteStyle: "double"`
- CSS parser has `tailwindDirectives: true`
**Linter rules (notable):**
- `recommended: true`
- `suspicious.noExplicitAny: "error"` — `any` is forbidden
- `suspicious.noArrayIndexKey: "off"`
- `correctness.useExhaustiveDependencies: "info"` (not an error)
- `style.useImportType: { level: "on", options: { style: "separatedType" } }` — `import type` is required when an import is type-only and must be on its own line (not inlined per-specifier)
- `style.noInferrableTypes: "error"` — drop redundant annotations like `const x: number = 1`
- `style.noUselessElse: "error"`
- `style.useSelfClosingElements: "error"`
- `style.useSingleVarDeclarator: "error"`
- `style.noParameterAssign: "error"`
- `style.useDefaultParameterLast: "error"`
- `nursery.useSortedClasses: { level: "warn", fix: "safe", functions: ["clsx", "cva", "cn"] }` — Tailwind class strings inside these wrappers are sorted automatically
**Import organization:** Biome's `assist.actions.source.organizeImports` is `on`, grouped in this order (`biome.json` lines 32-39):
1. Type-only imports (`{ "type": true }`)
2. Node built-ins (`":NODE:"`, excluding Bun)
3. Vitest + Testing Library (`vitest`, `vitest/**`, `@testing-library/**`)
4. External npm packages (`:PACKAGE:`, excluding `@reactive-resume/**`)
5. Internal workspace packages (`@reactive-resume/**`)
6. App-local aliases / relative paths (`:ALIAS:`, `:PATH:`)
A representative file that demonstrates the order: `packages/api/src/services/resume.ts` (type imports → node-free externals → `@reactive-resume/*` → relatives).
**Biome ignores:** `**/.turbo`, `**/.output`, `**/.vercel`, `**/.wrangler`, `**/coverage`, `**/reports`, `**/routeTree.gen.ts`.
## TypeScript Conventions
**Strictness flags** in `packages/config/tsconfig.base.json` are intentionally aggressive:
- `strict: true`
- `verbatimModuleSyntax: true` (pairs with Biome's `useImportType` enforcement)
- `exactOptionalPropertyTypes: true`
- `noUncheckedIndexedAccess: true`
- `noUncheckedSideEffectImports: true`
- `noUnusedLocals: true`, `noUnusedParameters: true`
- `noFallthroughCasesInSwitch: true`
- `isolatedModules: true`, `moduleResolution: "bundler"`
- `target: "ESNext"`, `module: "ESNext"`
**Type-only imports:** Always use `import type { … }` on its own line when an import is only used in type positions. See `packages/api/src/services/resume.ts:1-5` and `packages/api/src/context.ts:1-2`.
**`any`:** Banned by Biome (`noExplicitAny: "error"`). Use `unknown` and narrow, or use a discriminated union.
**Path aliases (web app only):** `apps/web/tsconfig.json` declares:
- `@/*` → `./src/*`
- `@reactive-resume/ui/*` → `../../packages/ui/src/*` (build-time alias for direct source resolution)
Internal packages do NOT use `@/*` aliases — they import siblings via relative paths and cross-package code via the `@reactive-resume/*` export maps.
## Package Export Conventions (source-consumed packages)
**Internal packages export `src` files directly via `package.json` `exports`.** There is no per-package build step; consumers pick up the TS source through bundler/vitest resolution. Do not assume any `dist/` output.
Sample (`packages/utils/package.json`):
```json
{
"name": "@reactive-resume/utils",
"type": "module",
"exports": {
"./color": "./src/color.ts",
"./date": "./src/date.ts",
"./resume/docx": "./src/resume/docx/index.ts",
"./resume/patch": "./src/resume/patch.ts",
"./url-security.node": "./src/url-security.node.ts"
}
}
```
**Conventions when adding cross-package exports:**
- Use **explicit subpath exports**, not wildcards in `@reactive-resume/utils`/`@reactive-resume/db`. Some packages (`@reactive-resume/api`) do use wildcards like `"./services/*"` — match the style of the package you're editing.
- Filenames ending in `.node.ts` (e.g. `packages/utils/src/url-security.node.ts`, `packages/utils/src/monorepo.node.ts`) are reserved for Node-only code that must not be imported from the browser bundle.
- Filename suffixes `.browser.tsx` (e.g. `apps/web/src/components/resume/preview.browser.tsx`) mark code that must stay out of SSR paths.
## React & Web App Conventions
**Routing:** TanStack Router with file-based routes under `apps/web/src/routes`.
- Each route file calls `createFileRoute("/path")({ … })` and exports `Route`. Example: `apps/web/src/routes/auth/login.tsx:18`.
- `apps/web/src/routeTree.gen.ts` is generated — never hand-edit it (also Biome-ignored).
- Server-only handlers live in `server.handlers` blocks on routes like `apps/web/src/routes/api/rpc.$.ts`, `apps/web/src/routes/api/auth.$.ts`, `apps/web/src/routes/api/health.ts`.
- Public resume route `apps/web/src/routes/$username/$slug.tsx` is `ssr: "data-only"`; nested builder preview is `ssr: false`.
**Router context** (`apps/web/src/router.tsx`) provides `queryClient`, `orpc`, `theme`, `locale`, `session`, and `flags`. Read them via `Route.useRouteContext()` rather than refetching.
**Components:**
- Functional components only. React 19.
- File naming: **kebab-case** for files and directories (e.g. `apps/web/src/components/command-palette/`, `packages/ui/src/components/alert-dialog.tsx`, `apps/web/src/dialogs/api-key/create.tsx`).
- Test file: `<name>.test.ts(x)` colocated with the implementation (e.g. `packages/ui/src/components/button.tsx` + `packages/ui/src/components/button.test.tsx`).
- shadcn/Base UI primitive components in `packages/ui/src/components/*.tsx` are exported via the `./components/*` subpath. Hooks via `./hooks/*`.
**Tailwind class strings:** Always wrap in `clsx`, `cva`, or `cn` so Biome's `useSortedClasses` rule can sort them safely.
## oRPC Conventions (`packages/api`)
- **Procedures:** Build new procedures with `publicProcedure` or `protectedProcedure` from `packages/api/src/context.ts:79-99`. `protectedProcedure` adds the authenticated `User` to context and throws `ORPCError("UNAUTHORIZED")` otherwise; prefer it for anything authenticated.
- **Routers** live in `packages/api/src/routers/*.ts` and are composed in `packages/api/src/routers/index.ts` (`ai`, `auth`, `flags`, `resume`, `statistics`, `storage`). Each router file may export sub-routers internally (see `tagsRouter`, `statisticsRouter`, `analysisRouter`, `updatesRouter` in `packages/api/src/routers/resume.ts`).
- **Business logic** belongs in `packages/api/src/services/*.ts`. Handlers must stay thin: validate input, call a service, return its output. Example pattern: `packages/api/src/routers/resume.ts:24-26` calls `resumeService.tags.list(...)`.
- **DTOs / IO schemas** live in `packages/api/src/dto/*.ts` and are imported as `resumeDto.<op>.input` / `resumeDto.<op>.output`.
- **Errors:** Declare typed errors with `.errors({ CODE: { message, status } })` on the procedure (see `packages/api/src/routers/resume.ts:282-291`). Throw `new ORPCError("CODE")` inside services. The web side translates codes to user-facing strings in `apps/web/src/libs/error-message.ts`.
- **Route metadata:** Every procedure declares `.route({ method, path, tags, operationId, summary, description, successDescription })` so the OpenAPI/MCP endpoints stay accurate.
- **Rate limiting:** Apply via `.use(resumeMutationRateLimit)` / `.use(resumePasswordRateLimit)` from `packages/api/src/middleware/rate-limit`.
- **Web exposure:** Routers are mounted at `/api/rpc` by `apps/web/src/routes/api/rpc.$.ts`. The isomorphic client lives at `apps/web/src/libs/orpc/client.ts` — server-side calls use the in-process router client and browser calls hit `/api/rpc` with credentials.
## Drizzle Conventions (`packages/db`)
- **Schema location:** `packages/db/src/schema/*.ts`. Tables exported from `packages/db/src/schema/index.ts`.
- **Client:** `packages/db/src/client.ts`, exported as `@reactive-resume/db/client`.
- **Migrations:** Generated to **`migrations/` at the repo root** by `drizzle-kit generate`. Use `pnpm db:generate` after schema changes.
- **`DATABASE_URL` handling:** `drizzle-kit` does **not** auto-load `.env`. Always export `DATABASE_URL` in the shell (or prefix the command) before running `pnpm db:generate` / `pnpm db:migrate`.
- **Runtime migration:** `apps/web/plugins/1.migrate.ts` runs migrations on Nitro startup, so `pnpm db:migrate` is mostly used for first-time setup or debugging.
- **Patterns observed in `packages/db/src/schema/resume.ts`:**
- Primary keys use `pg.text("id").$defaultFn(() => generateId())` (UUIDv7 from `@reactive-resume/utils/string`).
- `createdAt` / `updatedAt` use `withTimezone: true`, `.defaultNow()`, and `$onUpdate(() => new Date())`.
- JSONB columns get `.$type<T>()` for end-to-end typing.
- Composite uniques/indexes are declared in the table's tuple callback.
- Foreign keys use `onDelete: "cascade"`.
## Schema-First Change Workflow
When changing resume data shape, propagate in this order (per `AGENTS.md`):
1. **`packages/schema/src/resume/*.ts`** — Zod schemas and types (entry point).
2. **`packages/api/src/dto/*.ts`** — API DTOs that re-use those schemas.
3. **`packages/import/src/*.tsx`** — importers (`json-resume`, `reactive-resume-json`, `reactive-resume-v4-json`).
4. **`packages/pdf/src/templates/**`** — PDF rendering for every template (`azurill`, `bronzor`, `chikorita`, `ditgar`, `ditto`, `gengar`, `glalie`, `kakuna`, `lapras`, `leafish`, `meowth`, `onyx`, `pikachu`, `rhyhorn`, `scizor`). Shared filtering: `packages/pdf/src/templates/shared/filtering.ts`.
5. **`apps/web/src/`** — builder forms and any consumer hooks.
Adding/renaming a template requires changes in `packages/schema/src/templates.ts`, `packages/pdf/src/templates/index.ts`, the template directory `packages/pdf/src/templates/<name>/`, and static previews under `apps/web/public/templates/{jpg,pdf}/`.
## Error Handling Patterns
- **Services:** Throw `new ORPCError("CODE")` (e.g. `NOT_FOUND`, `UNAUTHORIZED`, custom `RESUME_LOCKED`). Example: `packages/api/src/services/resume.ts:54`.
- **Routers:** Declare expected codes via `.errors({ … })` so callers get typed error narrowing.
- **Auth helpers** in `packages/api/src/context.ts:14-55` catch verification errors and `console.warn(...)` rather than throwing, returning `null` so the caller can fall through to the next auth method.
- **Web side:** `apps/web/src/libs/error-message.ts` exposes `getReadableErrorMessage`, `getOrpcErrorMessage`, and `getResumeErrorMessage` for translating raw errors into UI-safe strings. Pair with `sonner` toasts (see `apps/web/src/routes/auth/login.tsx:45-64`).
## Logging
- No dedicated logging framework. Use `console.warn` / `console.error` for diagnostic output, scoped tightly (see `packages/api/src/context.ts:25`).
- Server logs are also where dev-mode email verification links surface when SMTP is unconfigured.
## i18n & Translations (Lingui)
- **Library:** `@lingui/core`, `@lingui/react` with the babel macro plugin enabled in `apps/web/vitest.config.ts` and Vite config.
- **Config:** `apps/web/lingui.config.ts` — source locale `en-US`, pseudo locale `zu-ZA`, 50+ supported locales. Catalogs live in `apps/web/locales/{locale}.po`.
- **Usage:**
- Import macros: `import { t } from "@lingui/core/macro"` and `import { Trans } from "@lingui/react/macro"` (`apps/web/src/routes/auth/login.tsx:1-2`).
- Wrap displayed text in `<Trans>...</Trans>` for JSX or `` t`...` `` / `t({ message, comment })` for strings.
- Provide `comment:` for ambiguous fallback strings (see `login.tsx:57-60`).
- **Extraction:** `pnpm lingui:extract` (turbo task in `apps/web`). Crowdin sync runs via `.github/workflows/crowdin-sync.yml`.
## Comments Policy
Comments stay short and explain **why**, not what. Patterns observed:
- One-line `//` comments before a non-obvious decision (`vitest.setup.ts:5-7` explains why `cleanup()` is registered manually; `packages/utils/src/monorepo.node.test.ts:11` explains the `realpathSync` call).
- JSDoc/TSDoc only on cross-package public functions where intent matters (e.g. `packages/api/src/context.ts:57-63` documents `resolveUserFromRequestHeaders`).
- Inline `/* @__PURE__ */` annotations on `$onUpdate(() => new Date())` in Drizzle schemas (`packages/db/src/schema/resume.ts:36`).
- No commented-out code in commits.
## Git Hooks & Commit Style
**Lefthook (`lefthook.yml`):**
```yaml
pre-commit:
parallel: true
jobs:
- name: lint and format
glob: "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}"
run: pnpm biome check --write --unsafe --no-errors-on-unmatched --files-ignore-unknown=true {staged_files}
stage_fixed: true
commit-msg:
jobs:
- name: commitlint
run: pnpm commitlint --edit {1}
```
The pre-commit hook **rewrites staged files** with Biome fixes (`stage_fixed: true`). Run `pnpm check` before staging to avoid surprises.
**Commit messages:** Conventional Commits (`commitlint.config.cjs` extends `@commitlint/config-conventional`). Examples from `git log`:
- `feat: implement an AI chat window for agentic resume building`
- `fix(pdf): register CJK fallback font so Chinese/Japanese/Korean text renders correctly`
- `fix(lapras): adjust lapras border color to fixed gray`
- `chore: migrate from jsdom to happy-dom for testing environment`
- `docs: update AGENTS.md with detailed codebase structure`
- `test: add unit and component tests across the monorepo`
- `chore(release): v5.1.2`
Allowed types: `feat`, `fix`, `chore`, `docs`, `test`, `refactor`, `style`, `perf`, `build`, `ci`, `revert`. Scope is optional and lowercase. `body-max-line-length` is disabled, so long PR bodies are fine.
## CI Workflows
- **`.github/workflows/autofix.yml`** runs on every PR and push to `main`: `pnpm install --frozen-lockfile` → `pnpm knip --fix` (prune unused deps) → `pnpm check` (Biome) → `autofix-ci/action` opens fix commits.
- **`.github/workflows/docker-build.yml`** is `workflow_dispatch` only, builds multi-arch Docker images.
- **`.github/workflows/crowdin-sync.yml`** syncs translation catalogs.
- There is no CI workflow that runs `pnpm test` today. Tests run locally via `pnpm test` / `pnpm test:ci` and through turbo's `test:agent` reporter for agent-driven runs.
## Function & Module Design
- **Function size:** Most service functions stay under ~40 lines. Bigger flows (e.g. `resumeService.patch`) are decomposed into helpers in `packages/api/src/helpers/*` and `packages/api/src/services/resume-events.ts`.
- **Parameters:** Service helpers consistently take a single object argument (`async ({ id, userId })`) rather than positional args. See `packages/api/src/services/resume.ts:27,41,65`.
- **Return values:** Services return plain typed objects; routers shape the response via `.output(schema)` so Zod validates at the boundary.
- **Module boundaries:**
- Cross-package imports must go through declared `exports` subpaths. Reaching into `packages/<x>/src/internal-file` directly is not allowed.
- `packages/utils` exports are narrowly scoped — if you need a new helper for another package, add a new explicit subpath in `packages/utils/package.json`.
- `packages/runtime-externals` and `packages/scripts` are support packages; avoid importing them into runtime code.
---
*Convention analysis: 2026-05-11*
-239
View File
@@ -1,239 +0,0 @@
# External Integrations
**Analysis Date:** 2026-05-11
## APIs & External Services
**AI Providers (user-supplied API keys, called per-request from `packages/api/src/services/ai.ts`):**
- OpenAI — Default base URL `https://api.openai.com/v1` (`packages/ai/src/types.ts`)
- SDK: `@ai-sdk/openai ^3.0.63` via `createOpenAI(...).chat(model)`
- API key supplied per-call from the resume's `ai` config; not read from server env
- Anthropic — Default base URL `https://api.anthropic.com/v1`
- SDK: `@ai-sdk/anthropic ^3.0.76` via `createAnthropic(...).languageModel(model)`
- Google Gemini — Default base URL `https://generativelanguage.googleapis.com/v1beta`
- SDK: `@ai-sdk/google ^3.0.71` via `createGoogleGenerativeAI(...).languageModel(model)`
- Vercel AI Gateway — Default base URL `https://ai-gateway.vercel.sh/v3/ai`
- SDK: `ai ^6.0.177` via `createGateway(...).languageModel(model)`
- OpenRouter — Default base URL `https://openrouter.ai/api/v1`
- SDK: `@ai-sdk/openai-compatible ^2.0.47` via `createOpenAICompatible({ name: "openrouter", ... })`
- Ollama — Default base URL `https://ollama.com/api`
- SDK: `ollama-ai-provider-v2 ^3.5.0` via `createOllama(...)`
- Security: `packages/api/src/services/ai.ts` `resolveBaseUrl` rejects non-HTTPS URLs, credentialed URLs, and private/loopback hosts (via `packages/utils/src/url-security.node.ts`).
- File-input AI calls are capped at 10MB (`MAX_AI_FILE_BYTES`).
**OAuth Identity Providers (server-side env-configured, optional):**
- Google — `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` (config in `packages/auth/src/config.ts`).
- GitHub — `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET`.
- LinkedIn — `LINKEDIN_CLIENT_ID` / `LINKEDIN_CLIENT_SECRET`.
- Custom Generic OAuth — `OAUTH_PROVIDER_NAME`, `OAUTH_CLIENT_ID`, `OAUTH_CLIENT_SECRET`, plus either `OAUTH_DISCOVERY_URL` or all three of `OAUTH_AUTHORIZATION_URL`/`OAUTH_TOKEN_URL`/`OAUTH_USER_INFO_URL`. Scopes from `OAUTH_SCOPES` (defaults `openid profile email`). Wired via Better Auth's `genericOAuth` plugin.
- Trusted providers for account linking (`packages/auth/src/config.ts`): `google`, `github`, `linkedin`.
**Translation / Localization:**
- Crowdin — `CROWDIN_PROJECT_ID`, `CROWDIN_PERSONAL_TOKEN`. Config in `crowdin.yml`; pull request automation labelled `l10n`. Source catalog `apps/web/locales/en-US.po`.
**Font Catalog Tooling:**
- Google Fonts Developer API — `GOOGLE_CLOUD_API_KEY` consumed by `packages/scripts/fonts/generate.ts` hitting `https://www.googleapis.com/webfonts/v1/webfonts`. Output committed at `packages/fonts/src/webfontlist.json`.
## Data Storage
**Databases:**
- PostgreSQL — Required relational database.
- Connection env: `DATABASE_URL` (validated as `postgres(ql)://...` in `packages/env/src/server.ts`)
- Client: `drizzle-orm 1.0.0-beta.22` with `pg ^8.20.0` Pool, instantiated in `packages/db/src/client.ts` (singleton via `globalThis.__pool` / `globalThis.__drizzle`).
- Schema location: `packages/db/src/schema/index.ts` (auth + resume tables) with `packages/db/src/relations.ts`.
- Migrations: generated by `drizzle-kit` into repo-root `migrations/` (e.g. `20260507144406_fast_nova/`). Generator config at `packages/db/drizzle.config.ts`.
- Auto-migration on app start: `apps/web/plugins/1.migrate.ts` runs `drizzle-orm/node-postgres/migrator` against the resolved migrations folder.
- `drizzle-kit` does NOT auto-load `.env` — `DATABASE_URL` must be exported before running `pnpm db:generate` / `pnpm db:migrate` (see `AGENTS.md` "Important" callout).
- Health probe: `packages/api/src/services/storage.ts` healthcheck + `apps/web/src/routes/api/health.ts` runs `SELECT 1` through Drizzle (1.5s timeout).
**File Storage (selected at runtime in `packages/api/src/services/storage.ts`):**
- S3-compatible object storage when all three of `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_BUCKET` are set.
- Client: `@aws-sdk/client-s3 ^3.1045.0` (`S3Client` constructed in `S3StorageService`).
- Options: `S3_REGION` (default `us-east-1`), `S3_ENDPOINT` (for non-AWS), `S3_FORCE_PATH_STYLE`.
- Tested compatible backends: AWS S3 and SeaweedFS (see `compose.dev.yml` / `compose.yml`).
- All writes use `ACL: "public-read"` (consumed publicly from `/uploads/$userId/$` route, with path validation).
- Local filesystem fallback (`LocalStorageService`) used when any of the three S3 vars is missing.
- Path: `LOCAL_STORAGE_PATH` if set (must be absolute); otherwise `<workspace>/data` in dev or `/app/data` in the Docker image.
- Validated at boot by `apps/web/plugins/2.storage.ts` (mkdir + access check).
- Key layout (built in `packages/api/src/services/storage.ts`):
- `uploads/{userId}/pictures/{timestamp}.jpeg`
- `uploads/{userId}/screenshots/{resumeId}/{timestamp}.jpeg`
- `uploads/{userId}/pdfs/{resumeId}/{timestamp}.pdf`
- Public read route: `apps/web/src/routes/uploads/$userId.$.tsx` (ETag, content-type sniffing, path traversal protection).
- Image preprocessing: `sharp ^0.34.5` resizes to 800x800 and re-encodes JPEG at quality 80, unless `FLAG_DISABLE_IMAGE_PROCESSING=true`.
**Caching:**
- No external cache (Redis/Memcached) configured.
- In-process rate limiter: `@orpc/experimental-ratelimit` `MemoryRatelimiter` (`packages/api/src/middleware/rate-limit/index.ts`).
- Workbox precache in the service worker (PWA) — `apps/web/vite.config.ts` `VitePWA` setup (skipWaiting, clientsClaim, cleanupOutdatedCaches).
## Authentication & Identity
**Auth Provider:** Better Auth `1.6.10`, configured in `packages/auth/src/config.ts`.
- Mounted as a TanStack Start server handler at `/api/auth/*` via `apps/web/src/routes/api/auth.$.ts`.
- Drizzle adapter: `@better-auth/drizzle-adapter` bound to `db` + schema (provider `pg`).
- Trusted origins: `http://localhost:3000`, `http://127.0.0.1:3000`, and the normalized origin of `APP_URL`.
- Secure cookies enabled automatically when `APP_URL` is `https://`.
- Trusted IP headers (used by both Better Auth `advanced.ipAddress.ipAddressHeaders` and the oRPC rate limiter): `CF-Connecting-IP`, `CF-Connecting-IPv6`, `True-Client-IP`, `X-Forwarded-For`, `X-Real-IP` (`packages/utils/src/rate-limit.ts`).
- Telemetry: explicitly disabled.
**Email/Password:**
- Enabled unless `FLAG_DISABLE_EMAIL_AUTH=true`.
- Password hash: `bcrypt` (cost 10) via `hash` / `compare` from `bcrypt ^6.0.0`.
- Min 8, max 64 char passwords.
- Email verification: sent on signup using `VerifyEmail` template from `@reactive-resume/email/templates/auth`.
- Reset password: `sendResetPassword` -> `ResetPasswordEmail` template.
- Email change: `sendChangeEmailConfirmation` -> `VerifyEmailChange` template.
**Better Auth Plugins (in `packages/auth/src/config.ts`):**
- `jwt()` — JWKS published at `/api/auth/jwks` (also referenced by `verifyOAuthToken`).
- `admin()` — Admin operations.
- `passkey()` (`@better-auth/passkey ^1.6.10`) — WebAuthn passkeys.
- `genericOAuth({ config })` — Driven by `OAUTH_*` env vars when configured.
- `twoFactor({ issuer: "Reactive Resume" })` — TOTP + backup codes; UI routes `/auth/verify-2fa` and `/auth/verify-2fa-backup`.
- `apiKey()` (`@better-auth/api-key ^1.6.10`) — Header `x-api-key`; `enableSessionForAPIKeys: true`; per-key rate limit 1000 req/hour.
- `oauthProvider()` (`@better-auth/oauth-provider ^1.6.10`) — Makes this app act as an OAuth 2.1 authorization server for MCP clients. Allows dynamic and unauthenticated client registration (RFC 7591) but redirect URIs are gated by an allowlist in the auth `hooks.before` middleware and `OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS`. Audiences whitelist: `${APP_URL}`, `${APP_URL}/`, `${APP_URL}/mcp`, `${APP_URL}/mcp/`.
- `username()` — Normalized lowercase usernames (`^[a-z0-9._-]+$`, length 3–64).
- `dash({ apiKey: BETTER_AUTH_API_KEY })` — Better Auth Dashboard (only when `BETTER_AUTH_API_KEY` is set).
**Social Providers:**
- Google, GitHub, LinkedIn — Each activates only when both `*_CLIENT_ID` and `*_CLIENT_SECRET` env vars are set (`packages/auth/src/config.ts`). `disableImplicitSignUp: true`; account linking enabled.
**Session / Access in API context:**
- `packages/api/src/context.ts` exposes `protectedProcedure` (referenced in `AGENTS.md`) for authenticated procedures.
- oRPC middleware reads request headers via `RequestHeadersPlugin` (`apps/web/src/routes/api/rpc.$.ts`, `apps/web/src/routes/api/openapi.$.ts`).
**API Key Auth for HTTP / MCP:**
- `x-api-key` header recognised by Better Auth API Key plugin.
- OpenAPI spec at `/api/openapi/spec.json` declares `apiKey` security scheme (`apps/web/src/routes/api/openapi.$.ts`).
- MCP server first tries OAuth Bearer (`Authorization: Bearer ...`), then falls back to `x-api-key` (`apps/web/src/routes/mcp/index.ts`).
## Monitoring & Observability
**Error Tracking:**
- None — no Sentry/Datadog/Rollbar SDK present.
- Errors are logged via `console.error` (oRPC interceptors `onError` in `apps/web/src/routes/api/rpc.$.ts` and `apps/web/src/routes/api/openapi.$.ts`).
**Health Checks:**
- `GET /api/health` — `apps/web/src/routes/api/health.ts` returns JSON with DB and storage status; 503 if unhealthy. Used by Docker `HEALTHCHECK`.
**Logs:**
- `console.info`/`console.warn`/`console.error` only. SMTP failures, missing config, sanitization diagnostics, and migration progress are all written to stdout/stderr.
## CI/CD & Deployment
**Hosting / Distribution:**
- Self-hosted Docker image — `Dockerfile` builds final image around `node:24-slim`, expose port 3000, default `LOCAL_STORAGE_PATH=/app/data`. Multi-stage uses `turbo prune` for both the web app and the `runtime-externals` package (which carries `bcrypt`, `sharp`, `@aws-sdk/client-s3` as native deps).
- Container labels point to `https://rxresu.me` and `https://docs.rxresu.me`.
**CI Pipeline:**
- Not present in this worktree (no `.github/workflows/` enumerated here). Scripts produce CI-friendly Vitest reports (`reports/vitest-junit.xml`, `reports/vitest-results.json`) via the `test:ci` task.
**Local Orchestration:**
- `compose.dev.yml` — `postgres:latest`, `seaweedfs:latest`, `seaweedfs_create_bucket` (uses `quay.io/minio/mc:latest`).
- `compose.yml` — Same services plus `reactive_resume` app container, networks `data_network` / `storage_network`.
**Git Hooks:**
- Lefthook (`lefthook.yml`) — `pre-commit` runs `biome check --write --unsafe` on staged JS/TS/JSON files; `commit-msg` runs `commitlint --edit`.
## Environment Configuration
**Required env vars (validated by Zod in `packages/env/src/server.ts`):**
- `APP_URL` — http(s) URL, used for auth base URL, OAuth audiences, OG metadata, and public upload URLs.
- `DATABASE_URL` — `postgres(ql)://...`.
- `AUTH_SECRET` — Better Auth signing secret (`openssl rand -hex 32`).
**Optional auth env vars:**
- `BETTER_AUTH_API_KEY` — Enables Better Auth Dashboard plugin.
- `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`.
- `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`.
- `LINKEDIN_CLIENT_ID`, `LINKEDIN_CLIENT_SECRET`.
- `OAUTH_PROVIDER_NAME`, `OAUTH_CLIENT_ID`, `OAUTH_CLIENT_SECRET`, `OAUTH_DISCOVERY_URL`, `OAUTH_AUTHORIZATION_URL`, `OAUTH_TOKEN_URL`, `OAUTH_USER_INFO_URL`, `OAUTH_SCOPES`, `OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS`.
**Optional SMTP env vars (all required together to enable real sends):**
- `SMTP_HOST`, `SMTP_PORT` (default 587), `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_SECURE` (default false). Fallback in `packages/email/src/transport.ts`: log to console with subject/body when SMTP is not fully configured.
**Optional storage env vars:**
- `LOCAL_STORAGE_PATH` — Must be absolute when set.
- `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_REGION` (default `us-east-1`), `S3_ENDPOINT`, `S3_BUCKET`, `S3_FORCE_PATH_STYLE` (default false).
**Optional feature flags:**
- `FLAG_DISABLE_SIGNUPS` — Disables signups across all providers.
- `FLAG_DISABLE_EMAIL_AUTH` — Disables email/password auth and verification flows.
- `FLAG_DISABLE_IMAGE_PROCESSING` — Skips Sharp resize/encode (useful on resource-constrained hardware).
**Optional tooling env vars:**
- `CROWDIN_PROJECT_ID` and `CROWDIN_PERSONAL_TOKEN` for Crowdin translation sync.
- `GOOGLE_CLOUD_API_KEY` — For `packages/scripts/fonts/generate.ts`.
**Secrets location:**
- `.env` at the repo root (gitignored). `.env.example` provides documented defaults. Existence noted: `.env.local`, `.env.production` files present locally (contents not read; may contain secrets).
- Turbo cache invalidation tied to env vars via `turbo.json` `globalEnv` whitelist.
## Webhooks & Callbacks
**Incoming:**
- OAuth provider callbacks served by Better Auth: `/api/auth/oauth2/callback/{providerId}` (e.g. `/api/auth/oauth2/callback/custom` configured in `packages/auth/src/config.ts`).
- OAuth Authorization Server endpoints (when this app acts as an OAuth provider for MCP clients): handled by Better Auth `oauthProvider` plugin under `/api/auth/oauth2/*`. Login/consent pages at `/auth/oauth` (`apps/web/src/routes/auth/oauth.ts`).
- `.well-known` discovery routes:
- `apps/web/src/routes/[.]well-known/oauth-authorization-server.ts` and `.../oauth-authorization-server.$.ts`
- `apps/web/src/routes/[.]well-known/oauth-protected-resource.ts` and `.../oauth-protected-resource.$.ts`
- `apps/web/src/routes/[.]well-known/openid-configuration.ts`
- `apps/web/src/routes/[.]well-known/mcp/server-card[.]json.ts`
- Generic catch-all: `apps/web/src/routes/[.]well-known/$.ts`
- MCP Streamable HTTP transport: `apps/web/src/routes/mcp/index.ts` (uses `WebStandardStreamableHTTPServerTransport`). Authenticates via OAuth Bearer or `x-api-key`.
**Outgoing:**
- AI provider HTTPS requests (see "AI Providers" above) — initiated per user request from `packages/api/src/services/ai.ts`.
- SMTP outbound (`packages/email/src/transport.ts`) for verification, password reset, and email-change confirmations.
- S3 PUT/GET/DELETE/LIST through `@aws-sdk/client-s3` when S3 storage is configured.
- Crowdin and Google Fonts requests only from CI / scripts, not from the runtime web app.
## API Endpoints Exposed by the App
| Endpoint | Handler | Purpose |
|----------|---------|---------|
| `/api/health` | `apps/web/src/routes/api/health.ts` | Liveness/readiness JSON (DB + storage). |
| `/api/auth/*` | `apps/web/src/routes/api/auth.$.ts` | Better Auth handler (sessions, OAuth, passkey, JWKS, etc.). |
| `/api/rpc/*` | `apps/web/src/routes/api/rpc.$.ts` | oRPC server (`packages/api/src/routers/index.ts`: `ai`, `auth`, `flags`, `resume`, `statistics`, `storage`). |
| `/api/openapi/*` | `apps/web/src/routes/api/openapi.$.ts` | OpenAPI-style REST wrapper of the oRPC router; spec at `/api/openapi/spec.json`. Auth via `x-api-key`. |
| `/mcp` | `apps/web/src/routes/mcp/index.ts` | MCP Streamable HTTP server (`@modelcontextprotocol/sdk`). |
| `/uploads/{userId}/...` | `apps/web/src/routes/uploads/$userId.$.tsx` | Public read of stored images/PDFs with ETag + path validation. |
| `/schema.json` | `apps/web/src/routes/schema[.]json.ts` | Resume JSON schema. |
| `/.well-known/...` | `apps/web/src/routes/[.]well-known/*` | OAuth/OIDC/MCP discovery documents. |
## Rate Limiting
**Better Auth (production only, see `packages/auth/src/config.ts` `isRateLimitEnabled`):**
- Global default: 60 req / 60s.
- `/sign-in/email`: 5/60s; `/sign-up/email`: 3/60s.
- `/request-password-reset`, `/send-verification-email`: 3/600s.
- `/two-factor/verify-otp`, `/verify-totp`, `/verify-backup-code`: 5/600s.
- `/is-username-available`: 20/60s.
- OAuth provider endpoints: `register` 5/60s, `authorize` 30/60s, `token` 20/60s, `introspect` 60/60s, `revoke` 30/60s, `userinfo` 60/60s.
- API keys: 1000 req / hour per key.
- Source: `packages/utils/src/rate-limit.ts`.
**oRPC procedure-level (in-memory, production only, see `packages/api/src/middleware/rate-limit/index.ts`):**
- `resumePassword`: 5 / 10min.
- `pdfExport`: 5 / 60s.
- `aiRequest`: 20 / 60s.
- `jobsSearch`: 30 / 60s.
- `jobsTestConnection`: 10 / 60s.
- `storageUpload`: 20 / 60s.
- `storageDelete`: 30 / 60s.
- `resumeMutations`: 60 / 60s.
- Keys are derived from authenticated user id when present, otherwise from the first trusted-IP header or a UA+language fingerprint.
## Optional Services Declared in Compose Files
| Service | Image | Purpose | File |
|---------|-------|---------|------|
| `postgres` | `postgres:latest` | Required PostgreSQL DB | `compose.dev.yml`, `compose.yml` |
| `seaweedfs` | `chrislusf/seaweedfs:latest` | Optional S3-compatible storage for dev/prod | `compose.dev.yml`, `compose.yml` |
| `seaweedfs_create_bucket` | `quay.io/minio/mc:latest` | One-shot init that creates `reactive-resume` bucket | `compose.dev.yml`, `compose.yml` |
| `reactive_resume` | Built from `Dockerfile` | The app itself in prod compose | `compose.yml` |
---
*Integration audit: 2026-05-11*
-210
View File
@@ -1,210 +0,0 @@
# Technology Stack
**Analysis Date:** 2026-05-11
## Languages
**Primary:**
- TypeScript `^6.0.3` — All workspace code under `apps/web/src` and `packages/*/src`. Typechecked with the experimental TS native compiler `@typescript/native-preview` (`7.0.0-dev.20260510.1`) via `tsgo --noEmit`.
- TSX (React 19) — UI components in `apps/web/src`, `packages/ui/src/components`, `packages/pdf/src/templates`, and email templates in `packages/email/src/templates`.
**Secondary:**
- JavaScript (ESM, `"type": "module"`) — A handful of config files such as `commitlint.config.cjs` and `apps/web/postcss.config` style snippets.
- JSON / JSONC — `package.json`, `tsconfig.json`, `biome.json`, `turbo.json`, `knip.json`, `apps/web/components.json`, `packages/schema/schema.json`, locale and webfont metadata.
- SQL — Drizzle-generated migrations under `migrations/` (e.g. `migrations/20260507144406_fast_nova/`).
- YAML — `compose.yml`, `compose.dev.yml`, `lefthook.yml`, `crowdin.yml`, `pnpm-workspace.yaml`.
- Markdown — `AGENTS.md`, `README.md`, `SECURITY.md`, `LICENSE`, docs under `docs/`.
## Runtime
**Environment:**
- Node.js `24` — Pinned in `Dockerfile` via `ARG NODE_VERSION=24`. Single Node process exposes the web app on port `3000`.
- Browser runtime — React 19 SSR + hydration via TanStack Start. PWA service worker registered from `apps/web/vite.config.ts`.
**Package Manager:**
- pnpm `11.0.9` — Pinned in `package.json` `packageManager` field (with integrity hash). Managed via Corepack (`corepack enable`) per `AGENTS.md`.
- Workspace topology: `pnpm-workspace.yaml` includes `apps/*` and `packages/*`.
- Lockfile: `pnpm-lock.yaml` is present and committed at repo root.
- `allowBuilds` in `pnpm-workspace.yaml`: `bcrypt`, `esbuild`, `lefthook`, `msw`, `sharp`.
- `postcss` is pinned by an override to `^8.5.14` in `pnpm-workspace.yaml`.
**Build Orchestrator:**
- Turborepo `^2.9.12` — `turbo.json` defines tasks (`build`, `dev`, `typecheck`, `test`, `db:generate`, `db:migrate`, `db:studio`, `lingui:extract`) and the `globalEnv` whitelist for cache invalidation.
- The Docker build also pins `turbo@2.9.9` via `pnpm dlx` for the pruner stages.
## Frameworks
**Core (apps/web):**
- React `^19.2.6` with `react-dom ^19.2.6` (from `apps/web/package.json`).
- TanStack Start `^1.167.65` — Full-stack framework wiring Vite, Nitro, and React Router. Server handlers live in route files under `apps/web/src/routes`.
- TanStack Router `^1.169.2` — File-based router. `apps/web/src/routeTree.gen.ts` is generated.
- TanStack React Query `^5.100.9` with SSR bridge `@tanstack/react-router-ssr-query ^1.166.12`.
- TanStack React Form `^1.32.0` and React Hotkeys `^0.10.0`.
- Vite `^8.0.11` (rolldown-based) — `apps/web/vite.config.ts` orchestrates plugins.
- Nitro `3.0.260429-beta` — Server framework used through `nitro/vite`. Plugins at `apps/web/plugins/1.migrate.ts` and `apps/web/plugins/2.storage.ts` run on Nitro startup.
- `srvx ^0.11.15` — HTTP server runtime used by Nitro/TanStack Start.
**RPC / API:**
- oRPC `^1.14.2` family — `@orpc/server`, `@orpc/client`, `@orpc/openapi`, `@orpc/json-schema`, `@orpc/zod`, `@orpc/tanstack-query`, `@orpc/experimental-ratelimit`.
- `@modelcontextprotocol/sdk ^1.29.0` — MCP server exposed at `/mcp` via `apps/web/src/routes/mcp/index.ts`.
**Auth:**
- Better Auth `1.6.10` — Core auth framework configured in `packages/auth/src/config.ts`.
- Better Auth plugins: `@better-auth/api-key ^1.6.10`, `@better-auth/drizzle-adapter ^1.6.10`, `@better-auth/infra ^0.2.6` (dashboard), `@better-auth/oauth-provider ^1.6.10`, `@better-auth/passkey ^1.6.10`, plus built-ins (`admin`, `jwt`, `twoFactor`, `username`, `genericOAuth`).
- `jose ^6.2.3` — JWT verification for OAuth tokens (MCP authentication).
- `bcrypt ^6.0.0` — Password hashing (10 rounds in `packages/auth/src/config.ts`).
**Database & ORM:**
- Drizzle ORM `1.0.0-beta.22` with PostgreSQL driver `pg ^8.20.0` (node-postgres).
- `drizzle-kit 1.0.0-beta.22` — Migration tooling. Config at `packages/db/drizzle.config.ts` (dialect `postgresql`, schema `./src/schema/index.ts`, out `../../migrations`).
- `drizzle-zod 1.0.0-beta.14-a36c63d` — Schema-derived Zod validators in `packages/api`.
**PDF / Rendering:**
- `@react-pdf/renderer ^4.5.1` with types `@react-pdf/types ^2.11.1` — Used by `packages/pdf/src/document.tsx` and all templates under `packages/pdf/src/templates/<name>/`.
- `pdfjs-dist 5.7.284` — Client-side PDF rendering and parsing (browser-only paths).
- `react-pdf-html ^2.1.5`, `node-html-parser ^7.1.0`, `phosphor-icons-react-pdf ^0.1.3`, `cjk-regex ^3.4.0` — PDF helpers and CJK fallback.
- Font registration owned by `packages/pdf/src/hooks/use-register-fonts.ts`; webfont catalog at `packages/fonts/src/webfontlist.json`.
**UI / Styling:**
- Base UI / shadcn-style components — `@base-ui/react ^1.4.1`, `shadcn ^4.7.0` (CLI), components live in `packages/ui/src/components/*.tsx`. Config at `apps/web/components.json` (style `base-nova`, base color `zinc`, icon library `phosphor`).
- Tailwind CSS `^4.3.0` via `@tailwindcss/vite ^4.3.0` and `@tailwindcss/postcss ^4.3.0`. Plugin `@tailwindcss/typography ^0.5.19`. Global stylesheet at `packages/ui/src/styles/globals.css`.
- PostCSS `^8.5.14` and `tw-animate-css ^1.4.0`.
- `class-variance-authority ^0.7.1`, `clsx ^2.1.1`, `tailwind-merge ^3.6.0`.
- Icons: `@phosphor-icons/react ^2.1.10`, `@phosphor-icons/web ^2.1.2`.
- Fonts: `@fontsource-variable/ibm-plex-sans ^5.2.8` (shipped with `packages/ui`).
- Theming: `next-themes ^0.4.6`.
- Animation: `motion ^12.38.0`.
- Toasts: `sonner ^2.0.7`.
- Command palette: `cmdk ^1.1.1`.
- Misc UI: `react-resizable-panels ^4.11.0`, `react-window ^2.2.7`, `react-zoom-pan-pinch ^4.0.3`, `qrcode.react ^4.2.0`, `@uiw/color-convert ^2.10.1`, `@uiw/react-color-colorful ^2.10.1`.
**Drag & Drop and Editing:**
- `@dnd-kit/core ^6.3.1`, `@dnd-kit/sortable ^10.0.0`, `@dnd-kit/utilities ^3.2.2`.
- TipTap `^3.23.1` editor with `starter-kit`, `pm`, `react`, plus extensions `color`, `highlight`, `table`, `text-align`, `text-style`.
**State / Validation / Patterns:**
- Zustand `^5.0.13` — Client and AI state stores (`packages/ai/src/store.ts`).
- Immer `^11.1.8`.
- Zod `^4.4.3` — Validators across schema, api, env, ai, import, utils packages.
- `ts-pattern ^5.9.0` — Pattern matching in API services.
- `es-toolkit ^1.46.1` and `fuse.js ^7.3.0`.
**Internationalization (i18n):**
- Lingui `^6.0.1` family — `@lingui/core`, `@lingui/react`, `@lingui/cli`, `@lingui/format-po`, `@lingui/vite-plugin`, `@lingui/babel-plugin-lingui-macro`.
- Babel transformer chain via `@rolldown/plugin-babel ^0.2.3` and `babel-plugin-macros ^3.1.0`.
- Locale config at `apps/web/lingui.config.ts` (source locale `en-US`, ~55 target locales, pseudo-locale `zu-ZA`).
- Translation catalogs at `apps/web/locales/{locale}.po`. Crowdin sync configured in `crowdin.yml`.
**Email:**
- React Email `^6.1.1` with `@react-email/ui ^6.1.1` — Templates in `packages/email/src/templates/auth.tsx`.
- Nodemailer `^8.0.7` SMTP transport — `packages/email/src/transport.ts`.
**AI:**
- Vercel AI SDK `ai ^6.0.177` and `@ai-sdk/react ^3.0.179`.
- Provider SDKs: `@ai-sdk/openai ^3.0.63`, `@ai-sdk/anthropic ^3.0.76`, `@ai-sdk/google ^3.0.71`, `@ai-sdk/openai-compatible ^2.0.47`, `ollama-ai-provider-v2 ^3.5.0`.
- Supported providers enumerated at `packages/ai/src/types.ts`: `openai`, `anthropic`, `gemini`, `vercel-ai-gateway`, `openrouter`, `ollama`.
- JSON patch / repair: `fast-json-patch ^3.1.1`, `jsonrepair ^3.14.0`, `deepmerge-ts ^7.1.5`.
**Storage:**
- AWS SDK v3 — `@aws-sdk/client-s3 ^3.1045.0` used in `packages/api/src/services/storage.ts`. Marked external in the rolldown build (see `apps/web/vite.config.ts`) and isolated into `packages/runtime-externals` for Docker.
**Image Processing:**
- Sharp `^0.34.5` — Used in `packages/api/src/services/storage.ts` `processImageForUpload`. Disabled when `FLAG_DISABLE_IMAGE_PROCESSING` is true.
**Document Generation / Import:**
- `docx ^9.6.1` — DOCX export utilities in `packages/utils/src/resume/docx/`.
- JSON Resume importers in `packages/import` (`json-resume`, `reactive-resume-json`, `reactive-resume-v4-json`).
**HTML Sanitization:**
- `dompurify ^3.4.2` — Used in `packages/utils/src/sanitize.ts`.
- `@sindresorhus/slugify ^3.0.0`, `unique-names-generator ^4.7.1`, `uuid ^14.0.0`.
**PWA:**
- `vite-plugin-pwa ^1.3.0` — Workbox-based service worker, manifest defined in `apps/web/src/libs/pwa`.
**Testing:**
- Vitest `^4.1.5` with `@vitest/coverage-v8 ^4.1.5` (provider `v8`).
- Shared config in `vitest.shared.ts`; per-package configs at `<pkg>/vitest.config.ts`.
- DOM: `happy-dom ^20.9.0` (`disableJavaScriptFileLoading`, `disableCSSFileLoading`, navigation disabled in `vitest.shared.ts`).
- Testing Library: `@testing-library/react ^16.3.2`, `@testing-library/dom ^10.4.1`, `@testing-library/jest-dom ^6.9.1`, `@testing-library/user-event ^14.6.1`.
- Tests are co-located in each package's `src/**/*.{test,spec}.{ts,tsx}` and discovered automatically.
**Lint / Format / Tooling:**
- Biome `^2.4.15` — Sole linter + formatter (`biome.json`: tabs, double quotes, line width 120, organized import groups, `useSortedClasses` for `clsx`/`cva`/`cn`). Pre-commit runs through Lefthook.
- Lefthook `^2.1.6` — Git hooks defined in `lefthook.yml` (`biome check --write --unsafe` on staged JS/TS/JSON files; `commitlint --edit` on commit messages).
- Commitlint `^21.0.0` with `@commitlint/config-conventional` (see `commitlint.config.cjs`).
- Knip `^6.12.2` — Dead-code/unused-deps detection (`knip.json`).
- `npm-check-updates ^22.1.1`.
- `tsx ^4.21.0` — Used by `packages/scripts` for ad hoc TS scripts.
## Key Dependencies
**Critical:**
- `@tanstack/react-start ^1.167.65` — App framework boundary; route server handlers depend on it.
- `@orpc/server ^1.14.2` — Type-safe RPC; backbone of `/api/rpc` and `/api/openapi`.
- `better-auth 1.6.10` — Identity, sessions, OAuth provider, MCP auth.
- `drizzle-orm 1.0.0-beta.22` + `pg ^8.20.0` — All DB access.
- `@react-pdf/renderer ^4.5.1` — Resume PDF generation.
- `@aws-sdk/client-s3 ^3.1045.0` — S3-compatible object storage.
- `@modelcontextprotocol/sdk ^1.29.0` — MCP server endpoint.
- `ai ^6.0.177` + `@ai-sdk/*` — Resume AI features (analysis, parsing, chat).
**Infrastructure:**
- `vite ^8.0.11` + `nitro 3.0.260429-beta` — Build and server runtime.
- `turbo ^2.9.12` — Workspace task graph and caching.
- `dotenv ^17.4.2` — Loaded by `packages/env/src/server.ts` for app/server code (drizzle-kit does NOT auto-load).
- `@t3-oss/env-core ^0.13.11` — Server env validation in `packages/env/src/server.ts`.
## Configuration
**TypeScript:**
- Root `tsconfig.json` extends `@reactive-resume/config/tsconfig.base.json` (`packages/config/tsconfig.base.json`).
- Each package has its own `tsconfig.json` and runs `tsgo --noEmit`.
- Apps/packages export source from `src/*.ts` via package.json `exports` — no `dist/` artifacts unless explicitly built (e.g. `apps/web/.output`).
**Build:**
- `apps/web/vite.config.ts` orchestrates `tailwindcss`, `tanstackStart`, `viteReact`, `lingui`, `babel` (Lingui macro preset), `nitro` (with `1.migrate.ts` and `2.storage.ts` plugins), and `VitePWA`.
- Rolldown externals: `bcrypt`, `sharp`, `@aws-sdk/client-s3` (kept out of the client/server bundle and provided by `packages/runtime-externals` in Docker).
- Output: `apps/web/.output/server/index.mjs` (Nitro), public assets in `apps/web/.output/public`.
**Database:**
- Drizzle config: `packages/db/drizzle.config.ts` — `dialect: "postgresql"`, schema in `packages/db/src/schema/index.ts`, migrations written to repo-root `migrations/`.
- Client: `packages/db/src/client.ts` exposes singleton `db` plus `Pool` via `pg`, using `env.DATABASE_URL`.
- Startup auto-migration: `apps/web/plugins/1.migrate.ts` runs `drizzle-orm/node-postgres/migrator` against the resolved `migrations/` folder.
**Environment:**
- Server env contract validated by Zod in `packages/env/src/server.ts` (via `@t3-oss/env-core`).
- `dotenv` auto-loads root `.env` from `packages/env/src/server.ts` using `findWorkspaceRoot()`.
- Required: `APP_URL`, `DATABASE_URL`, `AUTH_SECRET`.
- Cache invalidation env vars listed in `turbo.json` `globalEnv`.
- `.env.example` present at repo root; `.env.local` and `.env.production` exist locally (contents not read — may contain secrets).
**Linting / Formatting:**
- `biome.json` — Tabs, 120-col, double quotes, sorted Tailwind classes for `clsx|cva|cn`, organized import groups (`type` imports first, then Node built-ins, test packages, third-party, `@reactive-resume/**`, then aliases/relative).
- `lefthook.yml` — Pre-commit Biome on staged files; commit-msg Commitlint.
- `commitlint.config.cjs` — Conventional commits.
- `knip.json` — Workspace-level ignores for `runtime-externals` external deps.
**Container / Compose:**
- `Dockerfile` (multi-stage): `base` (Node 24 slim + corepack), `pruner` (turbo prune), `builder` (frozen lockfile install + `pnpm turbo run build --filter=web --force`), `runtime-pruner` + `runtime-deps` (deploy `@reactive-resume/runtime-externals` with native deps), final `runtime` image running `node .output/server/index.mjs`, HEALTHCHECK on `/api/health`.
- `compose.dev.yml` — Dev `postgres:latest` + `seaweedfs:latest` (S3 emulator) + `seaweedfs_create_bucket` init container using `quay.io/minio/mc:latest`.
- `compose.yml` — Same services plus the app container `reactive_resume` built from `Dockerfile`, with `data_network`/`storage_network` and S3 env defaults pointing at SeaweedFS.
## Platform Requirements
**Development:**
- Node.js 24, pnpm 11.0.9 (via Corepack), Docker (for Postgres / SeaweedFS).
- Repo-local `data/` directory for local-storage mode (auto-created by `apps/web/plugins/2.storage.ts`).
- `LOCAL_STORAGE_PATH` must be absolute when set.
- SMTP optional; without it `packages/email/src/transport.ts` logs the email to console.
**Production:**
- Single Node 24 process on port 3000 (`apps/web/.output/server/index.mjs`).
- Official Docker image listed in `Dockerfile` labels (`org.opencontainers.image.url=https://rxresu.me`).
- Production uses S3-compatible storage when all three of `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_BUCKET` are set; otherwise falls back to local filesystem under `/app/data` (per Dockerfile `ENV LOCAL_STORAGE_PATH=/app/data`).
- HEALTHCHECK polls `GET /api/health` (handler at `apps/web/src/routes/api/health.ts` checks DB and storage with a 1.5s timeout).
- Rate limiting only active when `NODE_ENV=production` (see `packages/auth/src/config.ts` and `packages/api/src/middleware/rate-limit/index.ts`).
---
*Stack analysis: 2026-05-11*
-394
View File
@@ -1,394 +0,0 @@
# Codebase Structure
**Analysis Date:** 2026-05-11
## Directory Layout
```text
reactive-resume/
├── apps/
│ └── web/ # Sole deployable application (TanStack Start / Vite / Nitro)
│ ├── plugins/ # Nitro startup plugins (run on `pnpm dev` / `pnpm start`)
│ ├── public/ # Static assets (PWA, opengraph, templates, screenshots)
│ ├── locales/ # Lingui `.po` translation catalogs (~40 locales)
│ ├── src/
│ │ ├── components/ # Reusable web components grouped by feature
│ │ ├── dialogs/ # Global dialog system + dialog implementations
│ │ ├── hooks/ # App-level React hooks
│ │ ├── libs/ # Browser/server glue (auth, orpc, query, resume, theme, locale, pwa)
│ │ ├── routes/ # File-based TanStack Router routes (UI + `server.handlers`)
│ │ ├── router.tsx # Router factory (builds query client, context, SSR query integration)
│ │ ├── routeTree.gen.ts # GENERATED — do not hand-edit
│ │ ├── server.ts # Nitro fetch entry (wraps `react-start/server-entry`)
│ │ └── index.css # Tailwind v4 entry
│ ├── components.json # shadcn config
│ ├── lingui.config.ts # i18n extract config
│ ├── vite.config.ts # Vite + TanStack Start + Nitro + PWA + Lingui
│ └── vitest.config.ts
├── packages/
│ ├── ai/ # AI prompts, Zustand store, patch-resume tool
│ ├── api/ # oRPC routers, services, helpers, DTOs, rate-limit middleware
│ ├── auth/ # Better Auth config and helpers
│ ├── config/ # Shared tsconfig + vitest base configs
│ ├── db/ # Drizzle client + schema (Postgres)
│ ├── email/ # Nodemailer transport + react-email templates
│ ├── env/ # `@t3-oss/env-core` server env (auto-loads root `.env`)
│ ├── fonts/ # Google Fonts metadata
│ ├── import/ # JSON Resume / Reactive Resume v3/v4 importers
│ ├── pdf/ # React PDF document, fonts, 14 templates, shared primitives
│ ├── runtime-externals/ # Declares bcrypt, sharp, @aws-sdk/client-s3 as runtime-only
│ ├── schema/ # Zod schemas (resume data, templates, page, analysis, icons)
│ ├── scripts/ # Standalone tsx scripts (db reset, font generation)
│ ├── ui/ # Shared Base UI + shadcn-style components
│ └── utils/ # Small focused helpers (color, date, html, sanitize, etc.)
├── migrations/ # Drizzle-generated SQL + snapshots (one folder per migration)
├── docs/ # Project documentation
├── skills/ # Internal agent skill definitions
├── data/ # Local-FS storage root (when S3 is unset) — gitignored runtime data
├── .vite-hooks/ # Lefthook-managed git hooks (commit-msg, pre-commit)
├── .github/ # GitHub Actions / issue templates
├── .claude/ # Project-local Claude/Codex agent assets
├── .codex/
├── .planning/ # GSD planning + codebase maps (this directory)
├── .vscode/
├── compose.dev.yml # Postgres + SeaweedFS for local dev
├── compose.yml # Production-shaped compose
├── Dockerfile # Node 24 multi-stage build
├── AGENTS.md # Canonical agent guide (symlinked from CLAUDE.md)
├── README.md
├── SECURITY.md
├── LICENSE
├── package.json # Root scripts (turbo run …) + workspace devDeps
├── pnpm-workspace.yaml # `apps/*` + `packages/*`
├── pnpm-lock.yaml
├── turbo.json # Pipeline + globalEnv allowlist
├── biome.json # Biome (lint + format) config
├── knip.json # Dead-code/dep analysis config
├── lefthook.yml # Git hooks
├── commitlint.config.cjs
├── crowdin.yml # Translation sync config
├── tsconfig.json # Root TS solution config
├── vitest.shared.ts / vitest.setup.ts # Shared vitest config and global setup
├── .ncurc.cjs # npm-check-updates ignore list
├── .env.example # Documented env vars
└── .gitignore / .dockerignore
```
## Directory Purposes
**`apps/web/`:**
- Purpose: The only deployed app — TanStack Start / React 19 / Vite / Nitro.
- Contains: Routes, components, dialogs, hooks, libs, server entry, build config.
- Key files: `apps/web/vite.config.ts`, `apps/web/src/router.tsx`, `apps/web/src/server.ts`, `apps/web/src/routes/__root.tsx`, `apps/web/plugins/1.migrate.ts`, `apps/web/plugins/2.storage.ts`.
**`apps/web/src/routes/`:**
- Purpose: TanStack Router file-based route tree. Each file is either a UI route, a server-only endpoint (`server.handlers`), or both.
- Notable subtrees:
- `__root.tsx` — global HTML shell, providers, head/meta, PWA scripts.
- `_home/` — public marketing layout (`route.tsx`, `index.tsx`, `-sections/{hero,features,faq,testimonials,...}.tsx`).
- `auth/` — login/register/2FA/password flows and OAuth callback (`oauth.ts`).
- `dashboard/` — authenticated dashboard (`route.tsx`, `index.tsx`, `resumes/`, `settings/{profile,preferences,api-keys,authentication,integrations,job-search,danger-zone}.tsx`, `-components/{header,sidebar,functions}.{ts,tsx}`).
- `builder/$resumeId/` — resume builder (`route.tsx` shell, `index.tsx` browser-only preview, `-components/`, `-sidebar/{left,right}/`, `-store/{section,sidebar}.ts`).
- `$username/$slug.tsx` — public/shared resume route (`ssr: "data-only"`).
- `api/` — server-only endpoints (`rpc.$.ts`, `auth.$.ts`, `health.ts`, `openapi.$.ts`, `uploads/$userId.$.ts`, `-helpers/resume-pdf.ts`).
- `mcp/` — Model Context Protocol server (`index.ts`, `-helpers/{tools,resources,prompts,mcp-server-card,mcp-tool-names,tool-annotations}.ts`).
- `[.]well-known/` — OAuth/OIDC/MCP discovery documents.
- `uploads/$userId.$.tsx` — etag/security-validated file serving.
- `templates/$.tsx` — template preview/download.
- `schema[.]json.ts` — `/schema.json` endpoint.
**`apps/web/src/components/`:**
- Purpose: Reusable, app-scoped components organized by domain.
- Subdirectories:
- `animation/` — Motion-driven UI (`comet-card`, `count-up`, `spotlight`, `text-mask`).
- `command-palette/` — Cmd-K UI (`index.tsx`, `store.ts`, `pages/`).
- `input/` — Custom inputs (`chip-input`, `color-picker`, `github-stars-button`, `icon-picker`, `rich-input`, `url-input`).
- `layout/` — Error/loading/not-found screens, breakpoint indicator.
- `level/`, `locale/`, `theme/`, `typography/` — combobox/toggle utilities for those concerns.
- `resume/` — Builder preview surface (`preview.tsx`, `preview.browser.tsx`, `preview.shared.tsx`, `pdf-canvas.tsx`, `builder-resume-draft.ts`).
- `ui/` — App-level UI extensions (`combobox.tsx`, `copyright.tsx`).
- `user/` — User dropdown menu.
**`apps/web/src/libs/`:**
- Purpose: Glue between UI and external systems.
- Contents:
- `auth/{client.ts,session.ts}` — Better Auth browser client + SSR session getter.
- `orpc/client.ts` — Isomorphic oRPC client (in-process server + RPCLink browser).
- `query/client.ts` — TanStack Query client factory.
- `resume/` — Section, PDF, and ordering helpers (`make-section-item.ts`, `move-item.ts`, `section-actions.ts`, `section-title.ts`, `section-title-locale.ts`, `pdf-document.tsx`, `pdf-document.server.tsx`, `section.tsx`).
- `theme.ts`, `locale.ts`, `pwa.ts`, `error-message.ts`, `tanstack-form.tsx` — direct app utilities.
**`apps/web/src/dialogs/`:**
- Purpose: Global imperative dialog system.
- Contents: `manager.tsx` (renders open dialogs), `store.ts` (Zustand store), then domain dialogs under `auth/`, `resume/{sections,template,index.tsx,import.tsx}`, `api-key/`.
**`apps/web/src/hooks/`:**
- Purpose: Web-app hooks. (Shared cross-package hooks live in `packages/ui/src/hooks/`.)
- Contents: `use-confirm.tsx`, `use-controlled-state.tsx`, `use-form-blocker.tsx`, `use-mobile.tsx`, `use-prompt.tsx`, `use-sync-form-values.ts`.
**`apps/web/plugins/`:**
- Purpose: Nitro startup plugins.
- Contents: `1.migrate.ts` (runs Drizzle migrations on boot), `2.storage.ts` (validates the local data dir when S3 isn't configured).
**`apps/web/public/`:**
- Purpose: Static assets served at the site root.
- Notable subdirs: `templates/{jpg,pdf}/` (per-template previews), `screenshots/` (PWA + marketing), `opengraph/`, `icon/`, `logo/`, `fonts/`, `photos/`, `sounds/`, `videos/`. Top-level files include `favicon.{ico,svg}`, `apple-touch-icon-180x180.png`, `manifest.webmanifest`, `pwa-{64,192,512}x.png`, `maskable-icon-512x512.png`, `robots.txt`, `sitemap.xml`, `funding.json`.
**`apps/web/locales/`:**
- Purpose: Lingui `.po` translation catalogs (~40 languages, `en-US` is source).
- Synced via `crowdin.yml` and `pnpm lingui:extract`.
**`packages/ai/`:**
- Purpose: AI prompt assets, patch-resume tool, sanitize/extraction helpers.
- Key paths: `packages/ai/src/prompts/{chat,analyze-resume,docx-parser,pdf-parser}-*.md`, `packages/ai/src/store.ts`, `packages/ai/src/tools/{patch-resume,patch-proposal}.ts`, `packages/ai/src/resume/{extraction-template,sanitize}.ts`.
**`packages/api/`:**
- Purpose: Server-side business logic exposed over oRPC. The only place oRPC procedures live.
- Key paths: `packages/api/src/context.ts` (auth context + `publicProcedure`/`protectedProcedure`), `packages/api/src/routers/{ai,auth,flags,resume,statistics,storage,index}.ts`, `packages/api/src/services/{resume,resume-events,storage,ai,auth,flags,statistics}.ts`, `packages/api/src/dto/resume.ts`, `packages/api/src/helpers/{resume-access,resume-access-policy}.ts`, `packages/api/src/middleware/rate-limit/index.ts`.
**`packages/auth/`:**
- Purpose: Better Auth instance and types reused by web routes and API context.
- Key paths: `packages/auth/src/config.ts` (Drizzle adapter, OAuth/passkey/2FA/api-key/JWT/MCP OAuth provider), `packages/auth/src/functions.ts`, `packages/auth/src/types.ts`.
**`packages/config/`:**
- Purpose: Shared tsconfig and vitest base configs consumed via workspace devDep.
- Key paths: `packages/config/tsconfig.base.json`, `packages/config/vitest.config.ts`.
**`packages/db/`:**
- Purpose: Drizzle client and schema. Migration tooling but **migrations live in repo-root `migrations/`** (`packages/db/drizzle.config.ts` → `out: "../../migrations"`).
- Key paths: `packages/db/src/client.ts` (singleton `pg.Pool` + `drizzle()` on `globalThis`), `packages/db/src/schema/{auth,resume,index}.ts`, `packages/db/src/relations.ts`.
**`packages/email/`:**
- Purpose: SMTP transport + react-email templates.
- Key paths: `packages/email/src/transport.ts`, `packages/email/src/templates/{auth,reset-password,verify-email,verify-email-change}.tsx`.
**`packages/env/`:**
- Purpose: Type-safe server environment variables. Auto-loads the repo-root `.env` for app/server callers (drizzle-kit must export `DATABASE_URL` manually).
- Key paths: `packages/env/src/server.ts`.
**`packages/fonts/`:**
- Purpose: Generated Google Fonts metadata used by the typography combobox and React PDF font registration.
- Key paths: `packages/fonts/src/index.ts`, `packages/fonts/src/webfontlist.json` (regenerated by `packages/scripts/fonts/generate.ts`).
**`packages/import/`:**
- Purpose: Importers that convert external resume formats into the shared `ResumeData` schema.
- Key paths: `packages/import/src/{json-resume,reactive-resume-json,reactive-resume-v4-json}.tsx`.
**`packages/pdf/`:**
- Purpose: React PDF rendering — the same code paths used by the browser preview and the server-side PDF download.
- Key paths: `packages/pdf/src/document.tsx`, `packages/pdf/src/context.tsx`, `packages/pdf/src/section-title.ts`, `packages/pdf/src/hooks/use-register-fonts.ts`, `packages/pdf/src/templates/index.ts`, `packages/pdf/src/templates/<name>/<Name>Page.tsx` (14 templates: `azurill`, `bronzor`, `chikorita`, `ditgar`, `ditto`, `gengar`, `glalie`, `kakuna`, `lapras`, `leafish`, `meowth`, `onyx`, `pikachu`, `rhyhorn`, `scizor`), shared primitives under `packages/pdf/src/templates/shared/` (`filtering.ts`, `rich-text.tsx`, `sections.tsx`, `primitives.tsx`, `picture.ts`, `page-size.ts`, `columns.ts`, `metrics.ts`, `meta-line.tsx`, `contact.ts`, `contact-item.tsx`, `level-display.tsx`, `section-links.ts`, `rich-text-html.ts`, `rich-text-spacing.ts`, `styles.ts`, `types.ts`, `context.tsx`).
**`packages/runtime-externals/`:**
- Purpose: Vendors `bcrypt`, `sharp`, `@aws-sdk/client-s3` so they stay runtime-only (Vite externalizes them in `apps/web/vite.config.ts:55`).
- No `src/` — `package.json` is the entire surface.
**`packages/schema/`:**
- Purpose: Source-of-truth Zod schemas for resume data, templates, page settings, AI analysis, and icon catalog.
- Key paths: `packages/schema/src/resume/{data,default,sample,analysis}.ts`, `packages/schema/src/templates.ts`, `packages/schema/src/page.ts`, `packages/schema/src/icons.ts`.
**`packages/scripts/`:**
- Purpose: Standalone tsx scripts; no exports.
- Key paths: `packages/scripts/database/reset.ts` (`pnpm --filter @reactive-resume/scripts db:reset`), `packages/scripts/fonts/generate.ts` (`pnpm --filter @reactive-resume/scripts fonts:generate`).
**`packages/ui/`:**
- Purpose: Shared component library (Base UI primitives + shadcn-style wrappers, Tailwind v4 styles).
- Key paths: `packages/ui/src/components/{dialog,dropdown-menu,command,resizable,form,tooltip,sonner,…}.tsx`, `packages/ui/src/hooks/{use-confirm,use-controlled-state,use-mobile,use-prompt}.tsx`, `packages/ui/src/styles/globals.css`.
**`packages/utils/`:**
- Purpose: Pure utility functions used everywhere. Each helper has its own export path; do not import internal files.
- Key paths: `packages/utils/src/{color,date,field,file,html,level,locale,network-icons,rate-limit,sanitize,string,style,url}.ts`, Node-only `packages/utils/src/{monorepo.node,url-security.node}.ts`, and `packages/utils/src/resume/{docx/index.ts,patch.ts}`.
**`migrations/`:**
- Purpose: Drizzle-generated migration directories (one per migration), kept at the repo root.
- Layout: each `YYYYMMDDhhmmss_<adjective>_<noun>/` contains `migration.sql` (the SQL Drizzle will apply) and `snapshot.json` (Drizzle's internal state).
- Applied by `apps/web/plugins/1.migrate.ts` on every server boot, and manually by `pnpm db:migrate`. Generated by `pnpm db:generate` (which writes here because of `out: "../../migrations"` in `packages/db/drizzle.config.ts`). Do not hand-edit `migration.sql`/`snapshot.json`.
**`data/` (and `apps/web/data/`):**
- Purpose: Default local-filesystem storage root when S3 vars are unset. `<workspace>/data` is validated/created at boot by `apps/web/plugins/2.storage.ts`. Override with `LOCAL_STORAGE_PATH` (must be absolute).
- `data/statistics/` and `apps/web/data/statistics/` exist as runtime byproducts; treat as gitignored runtime state.
**`.vite-hooks/`:**
- Purpose: Lefthook-managed git hooks (`pre-commit` runs `biome check`, `commit-msg` runs commitlint). Configured by `lefthook.yml` and `commitlint.config.cjs`.
**`.planning/`:**
- Purpose: GSD planning artifacts.
- Subdirectories: `.planning/codebase/` (this directory — analysis docs).
## Key File Locations
**Entry Points:**
- `apps/web/src/server.ts` — Nitro fetch entry.
- `apps/web/src/router.tsx` — Router factory.
- `apps/web/src/routes/__root.tsx` — Root route + global providers.
- `apps/web/plugins/1.migrate.ts` — Migration on boot.
- `apps/web/plugins/2.storage.ts` — Local storage validation on boot.
**Configuration:**
- `apps/web/vite.config.ts` — Vite + TanStack Start + Nitro + PWA + Lingui.
- `turbo.json` — Pipeline + `globalEnv` allowlist.
- `pnpm-workspace.yaml` — Workspaces (`apps/*` + `packages/*`).
- `biome.json` — Lint + format rules.
- `knip.json` — Dead-code analysis config.
- `lefthook.yml` — Git hooks.
- `packages/db/drizzle.config.ts` — Drizzle migration config (writes to `../../migrations`).
- `packages/env/src/server.ts` — Server env schema + `.env` loader.
- `apps/web/lingui.config.ts` — i18n extract config.
- `compose.dev.yml` / `compose.yml` — Postgres + SeaweedFS (dev) and prod-shaped compose.
**Core API:**
- `packages/api/src/routers/index.ts` — Router root.
- `packages/api/src/context.ts` — Auth resolver + procedure factories.
- `packages/api/src/services/resume.ts` — Resume CRUD/patch/lock/password/duplication.
- `packages/api/src/services/storage.ts` — S3 + local FS storage abstraction.
- `packages/api/src/helpers/resume-access-policy.ts` — Visibility/redaction policy.
**Core data:**
- `packages/db/src/schema/resume.ts` — Resume, statistics, analysis tables.
- `packages/db/src/schema/auth.ts` — Better Auth tables.
- `packages/db/src/client.ts` — Singleton `pg.Pool` + `drizzle()` client.
- `packages/schema/src/resume/data.ts` — Canonical Zod schema for `ResumeData`.
- `packages/schema/src/templates.ts` — Enum of template names.
**Core PDF:**
- `packages/pdf/src/document.tsx` — `ResumeDocument` root component.
- `packages/pdf/src/templates/index.ts` — Template registry.
- `packages/pdf/src/hooks/use-register-fonts.ts` — Font registration + CJK fallbacks.
- `packages/pdf/src/templates/shared/filtering.ts` — Shared section filtering.
**Auth:**
- `packages/auth/src/config.ts` — Better Auth instance.
- `apps/web/src/routes/api/auth.$.ts` — `/api/auth/*` handler with OAuth sanitization.
- `apps/web/src/libs/auth/{client.ts,session.ts}` — Browser client + SSR session helper.
**Testing:**
- `vitest.shared.ts`, `vitest.setup.ts` — Repo-wide setup (Testing Library, jest-dom, happy-dom env).
- `packages/config/vitest.config.ts` — Reusable Vitest base.
- `apps/web/vitest.config.ts` — Web app Vitest config.
## Naming Conventions
**Files:**
- Source files: `kebab-case.ts` / `kebab-case.tsx` (e.g. `resume-access-policy.ts`, `command-palette.tsx`).
- Component PascalCase is reserved for component names *inside* files; filenames stay kebab-case (e.g. `Button` exported from `packages/ui/src/components/button.tsx`).
- PDF template components are the one PascalCase exception: `packages/pdf/src/templates/azurill/AzurillPage.tsx` (kept that way because the directory name doubles as the template enum value).
- Tests sit next to their subject: `foo.ts` ↔ `foo.test.ts`, `foo.tsx` ↔ `foo.test.tsx`.
- Browser-only modules use a `.browser.tsx` suffix (e.g. `apps/web/src/components/resume/preview.browser.tsx`).
- Server-only modules use a `.server.tsx` suffix when they live next to browser counterparts (e.g. `apps/web/src/libs/resume/pdf-document.server.tsx`).
- Generated files use `.gen.ts` (e.g. `apps/web/src/routeTree.gen.ts`).
- Node-only utilities use a `.node.ts` suffix (e.g. `packages/utils/src/monorepo.node.ts`, `packages/utils/src/url-security.node.ts`).
**Routes (TanStack Router file conventions):**
- `$param.tsx` — dynamic path segment (e.g. `apps/web/src/routes/$username/$slug.tsx`).
- `$.tsx` — splat (matches the rest of the path; used for `api/rpc/$`, `api/auth/$`, `[.]well-known/$`).
- `_layout/` (underscore prefix) — pathless layout group (e.g. `apps/web/src/routes/_home/`).
- `-folder/` (dash prefix) — colocated, non-route helpers (`-components/`, `-sidebar/`, `-store/`, `-sections/`, `-helpers/`). The router ignores these.
- `[.]well-known` — escaped folder name for paths starting with a dot.
- `name[.]json.ts` — escaped dot in a route filename (used for `/schema.json`).
- `route.tsx` — layout/wrapper route at a directory level; `index.tsx` — index route inside it.
**Directories:**
- `apps/<app>` and `packages/<name>` — kebab-case workspace members.
- `packages/<name>/src/<subdomain>/<file>.ts` — every public path in `package.json` exports points into `src/`.
**Package names:** All workspace packages are scoped under `@reactive-resume/*` (`api`, `auth`, `db`, `ui`, etc.). The web app is just `web`.
## Where to Add New Code
**New oRPC procedure:**
- Define in `packages/api/src/routers/<domain>.ts`, register it on the exported router map, and add corresponding business logic to `packages/api/src/services/<domain>.ts`.
- If authenticated, prefer `protectedProcedure` from `packages/api/src/context.ts`.
- If it mutates resumes, attach `resumeMutationRateLimit` from `packages/api/src/middleware/rate-limit/index.ts`.
- Define input/output Zod schemas in `packages/api/src/dto/<domain>.ts` when reused.
**New database table or column:**
- Add the table/column to `packages/db/src/schema/<file>.ts`, update `packages/db/src/relations.ts` if needed, re-export from `packages/db/src/schema/index.ts`.
- Run `DATABASE_URL=... pnpm db:generate` to write a migration directory under `migrations/`.
- Apply with `DATABASE_URL=... pnpm db:migrate` (or just start the app — `apps/web/plugins/1.migrate.ts` runs them on boot).
**New resume field or section:**
- Update Zod first in `packages/schema/src/resume/data.ts` (and `default.ts` / `sample.ts` if applicable).
- Adjust API DTOs (`packages/api/src/dto/resume.ts`), importers (`packages/import/src/*.tsx`), PDF templates and shared primitives (`packages/pdf/src/templates/...`), and the builder forms under `apps/web/src/routes/builder/$resumeId/-sidebar/`.
**New resume template:**
- Add to the template enum in `packages/schema/src/templates.ts`.
- Implement the page component at `packages/pdf/src/templates/<name>/<Name>Page.tsx` and register it in `packages/pdf/src/templates/index.ts`.
- Drop static previews into `apps/web/public/templates/jpg/<name>.jpg` and `apps/web/public/templates/pdf/<name>.pdf`.
**New web route:**
- Add the file under `apps/web/src/routes/...`. Use `$param.tsx` for dynamic segments, `_layout/` for pathless groups, and `-folder/` for colocated helpers.
- The route tree regenerates into `apps/web/src/routeTree.gen.ts` — do not hand-edit.
- For browser-only sub-routes, set `ssr: false` (see `apps/web/src/routes/builder/$resumeId/index.tsx`) or `ssr: "data-only"` (see `apps/web/src/routes/$username/$slug.tsx`).
**New server-only HTTP endpoint:**
- Add a route file (typically under `apps/web/src/routes/api/`) and export `Route = createFileRoute(...)({ server: { handlers: { GET/POST/ANY: handler } } })`.
- Import server-only deps (`@reactive-resume/db/client`, `@reactive-resume/api/services/*`) only inside the handler module so they stay out of the client bundle.
**New shared component:**
- App-only: `apps/web/src/components/<group>/<name>.tsx` (with colocated test if behaviour is non-trivial).
- Reused across packages: `packages/ui/src/components/<name>.tsx` (export via deep path `@reactive-resume/ui/components/<name>`).
**New shared utility:**
- Add to `packages/utils/src/<topic>.ts` and an explicit `"./<topic>": "./src/<topic>.ts"` entry in `packages/utils/package.json` `exports`. Never import private files across packages.
**New dialog:**
- Implement under `apps/web/src/dialogs/<group>/<name>.tsx`.
- Register with the dialog store and ensure `apps/web/src/dialogs/manager.tsx` renders it.
**New AI prompt or tool:**
- Prompts go in `packages/ai/src/prompts/<name>.md` (re-exported via `packages/ai/src/prompts.ts`).
- Tools go in `packages/ai/src/tools/<name>.ts` and are exposed through the AI router in `packages/api/src/routers/ai.ts`.
**New MCP tool/resource/prompt:**
- Register in `apps/web/src/routes/mcp/-helpers/{tools,resources,prompts}.ts`; reuse existing oRPC services for actual logic.
## Special Directories
**`apps/web/src/routes/`:**
- Purpose: Source for the file-based route tree.
- Generated artifact: `apps/web/src/routeTree.gen.ts`.
- Committed: Yes (the tree file is committed; regenerated by TanStack Router tooling on dev/build).
**`migrations/`:**
- Purpose: Drizzle-generated SQL + snapshots.
- Generated: Yes (by `pnpm db:generate`).
- Committed: Yes — do not hand-edit, but always commit new migration folders.
**`apps/web/.output/`:**
- Purpose: Nitro/Vite production build output (server + client + PWA assets).
- Generated: Yes (by `pnpm build`).
- Committed: No (gitignored).
**`apps/web/locales/`:**
- Purpose: Lingui `.po` catalogs.
- Generated: `en-US.po` is the source; other locales are synced via Crowdin (`crowdin.yml`).
- Committed: Yes.
**`data/` and `apps/web/data/`:**
- Purpose: Default local storage root when S3 is unset (`<workspace>/data`).
- Generated: Yes (at runtime by `apps/web/plugins/2.storage.ts` or service calls).
- Committed: No (gitignored runtime state).
**`.turbo/`, `.pnpm-store/`, `node_modules/`:**
- Purpose: Tool caches and dependency stores.
- Committed: No.
**`.vite-hooks/`:**
- Purpose: Lefthook-installed git hooks (`commit-msg`, `pre-commit`).
- Committed: Yes (so contributors get hooks automatically), but the underlying behavior is defined by `lefthook.yml`.
**`packages/runtime-externals/`:**
- Purpose: Marks `bcrypt`, `sharp`, `@aws-sdk/client-s3` as runtime dependencies that Vite externalizes.
- No source files — package.json is the entire contract.
## Files / Locations to Avoid Hand-Editing
- `apps/web/src/routeTree.gen.ts` — regenerated by TanStack Router tooling.
- `migrations/<timestamp>_<name>/migration.sql` and `snapshot.json` — generated by `drizzle-kit`. Add a new migration via `pnpm db:generate` instead of editing past ones.
- `pnpm-lock.yaml` — managed by pnpm. Update via `pnpm install`.
- `apps/web/.output/`, `apps/web/coverage/`, `packages/*/coverage/`, `packages/*/reports/` — build/test artifacts.
- `apps/web/locales/*.po` (except `en-US.po`) — synced from Crowdin per `crowdin.yml`.
- `apps/web/public/screenshots/`, `apps/web/public/opengraph/`, `apps/web/public/templates/{jpg,pdf}/` — regenerated assets; replace files wholesale rather than diff-editing.
---
*Structure analysis: 2026-05-11*
-253
View File
@@ -1,253 +0,0 @@
# Testing Patterns
**Analysis Date:** 2026-05-11
## Test Framework
**Runner:** Vitest 4.x.
- Root devDependency in `package.json`: `"vitest": "^4.1.5"`, `"@vitest/coverage-v8": "^4.1.5"`.
- Every workspace package and `apps/web` has its own `vitest.config.ts` that delegates to the shared factory at `vitest.shared.ts`.
**DOM environment:** `happy-dom` 20.x (migrated from jsdom in commit `7a60a42a0`). Default test environment per `vitest.shared.ts:19` is `"node"`; per-package configs opt into browser-like envs.
**Assertion / testing libraries** (root `package.json` devDependencies):
- `@testing-library/react` ^16.3.2
- `@testing-library/dom` ^10.4.1
- `@testing-library/jest-dom` ^6.9.1 — registered globally in `vitest.setup.ts:1`
- `@testing-library/user-event` ^14.6.1
**Run commands** (root `package.json`):
```bash
pnpm test # turbo run test → vitest run --passWithNoTests in every package
pnpm test:coverage # turbo run test:coverage → adds --coverage flag
pnpm test:ci # adds GitHub Actions, JSON, and JUnit reporters
pnpm test:agent # agent-friendly reporter + JSON output for agentic runs
```
Per-package commands (uniform across all packages, see `packages/api/package.json:13-19`):
```bash
pnpm --filter @reactive-resume/utils test
pnpm --filter @reactive-resume/api test
pnpm --filter web test
```
Vitest test paths are package-relative when filtering: `pnpm --filter @reactive-resume/utils test -- src/string.test.ts`.
## Shared Vitest Configuration
`vitest.shared.ts` exports `createVitestProjectConfig({ name, dirname, environment, plugins })`. Highlights:
- `root: dirname` — each package runs in isolation.
- `envDir: workspaceRoot` — `.env` at repo root is loaded for every package.
- `resolve: { tsconfigPaths: true }` — TS path aliases resolve from the package's own `tsconfig.json`.
- `setupFiles: [./vitest.setup.ts]` — global hooks applied to all projects.
- `include: ["src/**/*.{test,spec}.?(c|m)[jt]s?(x)"]` — both `.test.*` and `.spec.*` are picked up.
- `exclude: ["node_modules", "dist", ".output", "coverage", "reports"]`.
- `pool: "threads"`, `isolate: false` — fast threaded execution with shared module state inside a worker.
- `passWithNoTests: true` — packages without tests don't fail CI.
- `environmentOptions.happyDOM` disables JS/CSS file loading and navigation for safety.
Coverage is configured directly in `vitest.shared.ts:49-56`:
- Provider: `v8`
- Output: `./coverage` per package
- Reporters: `text`, `text-summary`, `json-summary`, `json`, `lcov`, `html`
- Include: `src/**/*.{ts,tsx}`
- Exclude: `src/**/*.{test,spec}.*`, `src/**/*.d.ts`, `src/routeTree.gen.ts`
- `reportOnFailure: true`
No global coverage thresholds are enforced (no `thresholds: {...}` block). Per-package coverage HTML lives under each package's `coverage/` directory after `pnpm test:coverage`.
## Global Setup (`vitest.setup.ts`)
Applied to every project:
1. `import "@testing-library/jest-dom/vitest"` — registers `toBeInTheDocument`, `toHaveAttribute`, etc.
2. `afterEach(() => cleanup())` — explicit RTL cleanup (Vitest doesn't expose `afterEach` globally without `test.globals: true`).
3. Polyfills for jsdom/happy-dom gaps: `ResizeObserver`, `IntersectionObserver`, `Element.prototype.scrollIntoView`, `window.matchMedia` (used by `cmdk`, Base UI, `next-themes`).
## Per-Package Vitest Configs
All `vitest.config.ts` files reuse the shared factory. Notable variants:
- **Node default** (`packages/utils/vitest.config.ts`, `packages/api/vitest.config.ts`, `packages/db/vitest.config.ts`, `packages/schema/vitest.config.ts`, `packages/ai/vitest.config.ts`, `packages/email/vitest.config.ts`, `packages/fonts/vitest.config.ts`, `packages/env/vitest.config.ts`, `packages/auth/vitest.config.ts`, `packages/import/vitest.config.ts`, `packages/pdf/vitest.config.ts`, `packages/config/vitest.config.ts`) — `environment: "node"`.
- **DOM** (`packages/ui/vitest.config.ts:7`) — `environment: "happy-dom"` for component tests.
- **Web app** (`apps/web/vitest.config.ts`) — `environment: "node"` plus Vite plugins to mirror dev: `@tailwindcss/vite`, `@lingui/vite-plugin` (with `linguiTransformerBabelPreset`), and `@rolldown/plugin-babel`. Individual web tests opt into the DOM via the `@vitest-environment happy-dom` file-level comment.
Per-test environment overrides (declared at the top of the file as `// @vitest-environment happy-dom` or in a `/** @vitest-environment happy-dom */` block):
- `packages/utils/src/sanitize.test.ts`
- `packages/utils/src/file.test.ts`
- `apps/web/src/components/resume/preview.browser.test.tsx`
- `apps/web/src/components/resume/preview.shared.test.tsx`
- `apps/web/src/components/typography/combobox.test.tsx`
## Test File Organization
**Location:** Co-located with implementation. `foo.ts` lives next to `foo.test.ts` (or `foo.test.tsx` for React).
**Naming:**
- `<name>.test.ts` — Node/pure logic (e.g. `packages/utils/src/string.test.ts`).
- `<name>.test.tsx` — JSX/component tests (e.g. `packages/ui/src/components/button.test.tsx`).
- `<name>.node.test.ts` — Node-only modules whose implementation is also `.node.ts` (e.g. `packages/utils/src/url-security.node.test.ts`, `packages/utils/src/monorepo.node.test.ts`).
- No `.spec.*` files in the repo today, but the include pattern supports them.
**Test counts (current):** 127 `*.test.ts` files + 98 `*.test.tsx` files across `packages/` and `apps/web/src/`.
**Hot spots (where coverage is densest):**
- `packages/utils/src/*.test.ts` — string, color, html, date, level, locale, sanitize, rate-limit, field, file, network-icons, style, url, url-security.node, monorepo.node, plus `resume/patch.test.ts`.
- `packages/ui/src/components/*.test.tsx` — ~30+ component tests (button, dialog, alert, badge, card, combobox, command, popover, scroll-area, sidebar, switch, tabs, textarea, toggle, tooltip, etc.).
- `packages/pdf/src/templates/shared/*.test.ts` — columns, section-links, rich-text, metrics, picture, filtering.
- `packages/pdf/src/section-title.test.ts` and `packages/pdf/src/hooks/use-register-fonts.test.ts`.
- `packages/api/src/{dto,helpers,services}/*.test.ts` — `dto/resume`, `helpers/resume-access-policy`, `services/ai`.
- `packages/schema/src/{templates,page}.test.ts` and `packages/schema/src/resume/{data,default}.test.ts`.
- `packages/ai/src/{tools,resume}/*.test.ts` — patch-proposal, sanitize, extraction-template.
- `packages/import/src/reactive-resume-v4-json.test.ts`, `packages/fonts/src/index.test.ts`.
- `apps/web/src/libs/{pwa,locale,theme,error-message}.test.ts`, `apps/web/src/dialogs/store.test.ts`, `apps/web/src/components/resume/preview.{browser,shared}.test.tsx`, `apps/web/src/components/typography/combobox.test.tsx`.
## Test Structure Patterns
**Idiomatic skeleton** (from `packages/utils/src/string.test.ts:1-21` and `packages/api/src/helpers/resume-access-policy.test.ts:1-17`):
```typescript
import { describe, expect, it } from "vitest";
import { thingUnderTest } from "./thing";
describe("thingUnderTest", () => {
it("returns X for Y", () => {
expect(thingUnderTest(input)).toBe(expected);
});
it("returns Z for empty input", () => {
expect(thingUnderTest("")).toBe("");
});
});
```
**Patterns observed:**
- Top-level `describe` per exported function; nested `describe` blocks group behaviors.
- One assertion focus per `it` — short, declarative names ("returns X", "throws Y when Z", "does not mutate the input").
- Negative cases are first-class — every helper has tests for `null`, empty string, unknown shapes, etc.
- `it.each([...] as const)("variant=%s renders without throwing", (variant) => {...})` for matrix tests over discriminated union variants (see `packages/ui/src/components/button.test.tsx:55-79`).
- Setup with `beforeEach` / `afterEach` is used only when needed (timers, temp dirs, store reset). Example: `apps/web/src/dialogs/store.test.ts:4-13` resets a Zustand store between tests with `useDialogStore.setState(...)`.
- Temp directories use `fs.mkdtempSync` + `realpathSync` and are torn down in `afterEach` (`packages/utils/src/monorepo.node.test.ts:7-17`).
- Fake timers via `vi.useFakeTimers()` / `vi.advanceTimersByTime(300)` for animation/transition assertions (`apps/web/src/dialogs/store.test.ts:54-65`).
## Mocking
**Library:** Vitest's built-in `vi` (no Jest). Used sparingly — most tests cover pure functions.
**Patterns:**
- `vi.fn()` for callback assertions: `expect(onClick).toHaveBeenCalledOnce()` (`packages/ui/src/components/button.test.tsx:32-37`).
- `vi.fn().mockResolvedValue(true)` for async handlers (`apps/web/src/dialogs/store.test.ts:86-93`).
- `vi.mock("module-path", () => ({ ... }))` for replacing modules. Heaviest example: `apps/web/src/components/resume/preview.browser.test.tsx:25-81` mocks `@react-pdf/renderer`, `@/libs/resume/pdf-document`, `./builder-resume-draft`, and `./pdf-canvas` so the preview component can be exercised without React PDF.
- `vi.hoisted(() => ({ ... }))` to share mutable state with hoisted `vi.mock` factories (`apps/web/src/components/resume/preview.browser.test.tsx:8-12`). This is required because `vi.mock` calls are hoisted above imports.
- Spies via `vi.spyOn` are rare; mock modules are preferred so the real implementation stays out of scope.
**Test data / fixtures:**
- No central `fixtures/` directory. Tests build minimal objects inline or extend canonical defaults from the schema package:
- `import { defaultResumeData } from "@reactive-resume/schema/resume/default"` (used by `packages/api/src/helpers/resume-access-policy.test.ts:2`).
- `import { sampleResumeData } from "@reactive-resume/schema/resume/sample"` (used by `apps/web/src/components/resume/preview.browser.test.tsx:5`).
- Local helper builders are inlined per test file (e.g. the `resumeDataWithPageCount` helper at `apps/web/src/components/resume/preview.browser.test.tsx:14-23`).
## React Component Testing
**Render + query** via Testing Library (`packages/ui/src/components/button.test.tsx`):
```typescript
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
render(<Button onClick={onClick}>Click</Button>);
await userEvent.click(screen.getByRole("button"));
```
**Accessibility-first queries:** `getByRole("button", { name: "..." })` is the default; `aria-label` is asserted explicitly (`packages/ui/src/components/button.test.tsx:81-84`).
**Slot / data-attr conventions:** Components expose `data-slot` for shadcn/Base UI slotting and tests assert it (`button.test.tsx:22-25`).
**Async UI:** Use `waitFor` from `@testing-library/react` and `await userEvent.*`. The `cleanup()` afterEach in `vitest.setup.ts` ensures DOM doesn't leak between tests despite `isolate: false`.
## Reporters
Per-package `package.json` scripts (uniform pattern, e.g. `packages/api/package.json:15-18`):
- `test` → `vitest run --passWithNoTests`
- `test:coverage` → `vitest run --coverage --passWithNoTests`
- `test:ci` → `vitest run --coverage --reporter=default --reporter=github-actions --reporter=json --reporter=junit --outputFile.json=reports/vitest-results.json --outputFile.junit=reports/vitest-junit.xml --passWithNoTests`
- `test:agent` → `vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests`
The `agent` reporter is a Vitest 4 feature optimized for LLM-driven runs; `pnpm test:agent` is the canonical script to use when an agent needs structured pass/fail data.
JUnit + JSON outputs land in `reports/` inside each package (Biome-ignored).
## CI
- **`.github/workflows/autofix.yml`** — runs on every PR and push to `main`. It runs `pnpm knip --fix` and `pnpm check`. **It does NOT run `pnpm test`.** Tests are not currently gating CI.
- **`.github/workflows/docker-build.yml`** — `workflow_dispatch` only; builds multi-arch images. No test step.
- **`.github/workflows/crowdin-sync.yml`** — translation sync only.
Tests are run locally (`pnpm test`) or by agents via `pnpm test:agent` / `pnpm test:ci`. No PR is currently blocked by a failing test on GitHub Actions.
## Test Types
- **Unit tests** — Dominant. Pure functions in `packages/utils`, `packages/schema`, `packages/pdf/src/templates/shared`, `packages/api/src/{helpers,dto}` are exercised in isolation.
- **Component tests** — `packages/ui/src/components/*.test.tsx` and a handful in `apps/web/src/components/`. Driven by `@testing-library/react` + `happy-dom` (file-level override) or `packages/ui`'s package-level `environment: "happy-dom"`.
- **Integration tests** — None against the live Drizzle client or the running TanStack Start server. `apps/web/src/components/resume/preview.browser.test.tsx` is the closest, mocking React PDF and exercising the preview pipeline end-to-end in happy-dom.
- **E2E / browser tests** — Not present. No Playwright, Cypress, or `vitest --browser` config.
## Common Patterns
**Async error testing:**
```typescript
expect(() => assertCanView({ userId: "u1", isPublic: false }, null)).toThrow();
try {
assertCanView({ userId: "u1", isPublic: false }, null);
expect.unreachable();
} catch (error: unknown) {
expect((error as { code?: string }).code).toBe("NOT_FOUND");
}
```
(See `packages/api/src/helpers/resume-access-policy.test.ts:29-44`.)
**Immutability assertions:**
```typescript
const before = JSON.stringify(resume);
redactResumeForViewer(resume, false);
expect(JSON.stringify(resume)).toBe(before);
```
(See `packages/api/src/helpers/resume-access-policy.test.ts:86-93`.)
**Time-sensitive logic:** UUIDv7 ordering is verified with a `setTimeout` and a string compare instead of mocking time (`packages/utils/src/string.test.ts:15-20`).
## Coverage Gaps Worth Flagging
These areas have implementation but no `*.test.*` files alongside them today — agents adding features here should consider adding tests.
- **`packages/email/src/transport.ts` and templates** — no tests. SMTP transport is untested.
- **`packages/env/src/server.ts`** — no tests for env-var schema validation.
- **`packages/auth/`** — no tests; Better Auth config and helpers are uncovered.
- **`packages/api/src/services/{resume,storage,statistics,auth,flags,resume-events}.ts`** — only `ai.ts` has a service-level test (`ai.test.ts`). Most resume mutation flow logic is exercised only indirectly through `helpers/resume-access-policy.test.ts` and `dto/resume.test.ts`.
- **`packages/api/src/routers/*`** — no router-level tests. End-to-end oRPC procedure behavior (auth + rate limit + service composition) is not asserted.
- **`packages/api/src/middleware/rate-limit/*`** — no tests.
- **`packages/db/src/schema/*`** — no tests (schema correctness is implicit, but no migration roundtrip tests).
- **`packages/scripts/`** — no tests.
- **`packages/runtime-externals/`** — no tests.
- **`apps/web/src/routes/**`** — route handlers (`api/rpc.$.ts`, `api/auth.$.ts`, `api/health.ts`, `uploads/...`, `mcp/...`, `auth/oauth.ts`) and most builder UI under `routes/builder/$resumeId` are uncovered.
- **`apps/web/src/dialogs/**`** — only `store.test.ts` covers the dialog store. Individual dialog components (resume create/update/import, two-factor, api-key) are uncovered.
- **`packages/pdf/src/templates/{azurill,bronzor,...}`** — individual template renderers have no tests; only the shared primitives in `templates/shared/` are covered.
When adding tests in any of these areas, follow the colocation rule (`foo.ts` ↔ `foo.test.ts`) and reuse `defaultResumeData` / `sampleResumeData` from `@reactive-resume/schema/resume/*` instead of inventing new fixtures.
---
*Testing analysis: 2026-05-11*
+27
View File
@@ -0,0 +1,27 @@
.env*
!.env.example
.git
.codegraph
.superpowers
.agents
.codex
.claude
.turbo
**/node_modules
**/dist
**/coverage
**/reports
data
apps/web/data
screenshots
.vercel
.wrangler
.tanstack
.worktrees
.migration
.supermemory
.cache
tmp
temp
**/test-results
**/playwright-report
-71
View File
@@ -1,71 +0,0 @@
#!/bin/sh
if [ "$LEFTHOOK_VERBOSE" = "1" -o "$LEFTHOOK_VERBOSE" = "true" ]; then
set -x
fi
if [ "$LEFTHOOK" = "0" ]; then
exit 0
fi
call_lefthook()
{
if test -n "$LEFTHOOK_BIN"
then
"$LEFTHOOK_BIN" "$@"
elif lefthook -h >/dev/null 2>&1
then
lefthook "$@"
elif /Users/amruth/Projects/reactive-resume/node_modules/.pnpm/lefthook-darwin-arm64@2.1.6/node_modules/lefthook-darwin-arm64/bin/lefthook -h >/dev/null 2>&1
then
/Users/amruth/Projects/reactive-resume/node_modules/.pnpm/lefthook-darwin-arm64@2.1.6/node_modules/lefthook-darwin-arm64/bin/lefthook "$@"
else
dir="$(git rev-parse --show-toplevel)"
osArch=$(uname | tr '[:upper:]' '[:lower:]')
cpuArch=$(uname -m | sed 's/aarch64/arm64/;s/x86_64/x64/')
if test -f "$dir/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook"
then
"$dir/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook" "$@"
elif test -f "$dir/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook"
then
"$dir/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook" "$@"
elif test -f "$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook"
then
"$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook" "$@"
elif test -f "$dir/node_modules/lefthook/bin/index.js"
then
"$dir/node_modules/lefthook/bin/index.js" "$@"
elif go tool lefthook -h >/dev/null 2>&1
then
go tool lefthook "$@"
elif bundle exec lefthook -h >/dev/null 2>&1
then
bundle exec lefthook "$@"
elif yarn lefthook -h >/dev/null 2>&1
then
yarn lefthook "$@"
elif pnpm lefthook -h >/dev/null 2>&1
then
pnpm lefthook "$@"
elif swift package lefthook >/dev/null 2>&1
then
swift package --build-path .build/lefthook --disable-sandbox lefthook "$@"
elif command -v mint >/dev/null 2>&1
then
mint run csjones/lefthook-plugin "$@"
elif uv run lefthook -h >/dev/null 2>&1
then
uv run lefthook "$@"
elif mise exec -- lefthook -h >/dev/null 2>&1
then
mise exec -- lefthook "$@"
elif devbox run lefthook -h >/dev/null 2>&1
then
devbox run lefthook "$@"
else
echo "Can't find lefthook in PATH"
fi
fi
}
call_lefthook run "commit-msg" "$@"
-71
View File
@@ -1,71 +0,0 @@
#!/bin/sh
if [ "$LEFTHOOK_VERBOSE" = "1" -o "$LEFTHOOK_VERBOSE" = "true" ]; then
set -x
fi
if [ "$LEFTHOOK" = "0" ]; then
exit 0
fi
call_lefthook()
{
if test -n "$LEFTHOOK_BIN"
then
"$LEFTHOOK_BIN" "$@"
elif lefthook -h >/dev/null 2>&1
then
lefthook "$@"
elif /Users/amruth/Projects/reactive-resume/node_modules/.pnpm/lefthook-darwin-arm64@2.1.6/node_modules/lefthook-darwin-arm64/bin/lefthook -h >/dev/null 2>&1
then
/Users/amruth/Projects/reactive-resume/node_modules/.pnpm/lefthook-darwin-arm64@2.1.6/node_modules/lefthook-darwin-arm64/bin/lefthook "$@"
else
dir="$(git rev-parse --show-toplevel)"
osArch=$(uname | tr '[:upper:]' '[:lower:]')
cpuArch=$(uname -m | sed 's/aarch64/arm64/;s/x86_64/x64/')
if test -f "$dir/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook"
then
"$dir/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook" "$@"
elif test -f "$dir/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook"
then
"$dir/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook" "$@"
elif test -f "$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook"
then
"$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook" "$@"
elif test -f "$dir/node_modules/lefthook/bin/index.js"
then
"$dir/node_modules/lefthook/bin/index.js" "$@"
elif go tool lefthook -h >/dev/null 2>&1
then
go tool lefthook "$@"
elif bundle exec lefthook -h >/dev/null 2>&1
then
bundle exec lefthook "$@"
elif yarn lefthook -h >/dev/null 2>&1
then
yarn lefthook "$@"
elif pnpm lefthook -h >/dev/null 2>&1
then
pnpm lefthook "$@"
elif swift package lefthook >/dev/null 2>&1
then
swift package --build-path .build/lefthook --disable-sandbox lefthook "$@"
elif command -v mint >/dev/null 2>&1
then
mint run csjones/lefthook-plugin "$@"
elif uv run lefthook -h >/dev/null 2>&1
then
uv run lefthook "$@"
elif mise exec -- lefthook -h >/dev/null 2>&1
then
mise exec -- lefthook "$@"
elif devbox run lefthook -h >/dev/null 2>&1
then
devbox run lefthook "$@"
else
echo "Can't find lefthook in PATH"
fi
fi
}
call_lefthook run "pre-commit" "$@"
+4 -1
View File
@@ -27,5 +27,8 @@
["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
],
"tailwindCSS.experimental.configFile": "src/styles/globals.css",
"typescript.experimental.useTsgo": true
"typescript.experimental.useTsgo": true,
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
}
}
+104 -86
View File
@@ -1,106 +1,124 @@
# AGENTS.md
<!-- intent-skills:start -->
## Skill Loading
## Cursor Cloud specific instructions
Before editing files for a substantial task:
- Run `pnpm dlx @tanstack/intent@latest list` from the workspace root to see available local skills.
- If a listed skill matches the task, run `pnpm dlx @tanstack/intent@latest load <package>#<skill>` before changing files.
- Use the loaded `SKILL.md` guidance while making the change.
- Monorepos: when working across packages, run the skill check from the workspace root and prefer the local skill for the package being changed.
- Multiple matches: prefer the most specific local skill for the package or concern you are changing; load additional skills only when the task spans multiple packages or concerns.
<!-- intent-skills:end -->
### Overview
<!-- caveman-begin -->
Respond terse like smart caveman. All technical substance stay. Only fluff die.
Reactive Resume is a pnpm monorepo (Turborepo) with a single full-stack web app at `apps/web` (TanStack Start / React 19 / Vite) and ~15 internal packages under `packages/`. It runs as a single Node.js process on port 3000.
Rules:
- Drop: articles (a/an/the), filler (just/really/basically), pleasantries, hedging
- Fragments OK. Short synonyms. Technical terms exact. Code unchanged.
- Pattern: [thing] [action] [reason]. [next step].
- Not: "Sure! I'd be happy to help you with that."
- Yes: "Bug in auth middleware. Fix:"
Internal packages are source-consumed through `package.json` export maps that point at `src` files. Do not assume package-local `dist` output exists unless a package explicitly adds it.
Switch level: /caveman lite|full|ultra|wenyan-lite|wenyan-full|wenyan-ultra
Stop: "stop caveman" or "normal mode"
### Prerequisites
Auto-Clarity: drop caveman for security warnings, irreversible actions, user confused. Resume after.
- **Node.js 24** (matches Dockerfile `ARG NODE_VERSION=24`). Use `nvm install 24 && nvm use 24` if needed.
- **Docker** is required to run PostgreSQL. Start it with `sudo dockerd &` if the daemon isn't running.
- **pnpm 11.0.9** is managed via corepack (`corepack enable`).
Boundaries: code/commits/PRs written normal.
<!-- caveman-end -->
### Codebase map
## Agent skills
- `apps/web` is the only app. It owns TanStack Start routes, Vite/Nitro config, PWA setup, oRPC client wiring, route-level server handlers, and the resume builder UI.
- `packages/api` contains oRPC routers, services, DTOs, storage, resume access policy, statistics, AI services, and rate limiting. The web route `apps/web/src/routes/api/rpc.$.ts` exposes these routers at `/api/rpc`.
- `packages/auth` contains Better Auth config, auth helper functions, and exported auth types. The web route `apps/web/src/routes/api/auth.$.ts` delegates to `auth.handler`.
- `packages/db` contains the Drizzle client and schema. Migration files live at the repo root in `migrations/`.
- `packages/env` defines server environment validation and auto-loads the root `.env` for app/server code.
- `packages/schema` contains Zod schemas and typed resume/page/template models.
- `packages/pdf` contains the React PDF document, font registration, shared template primitives, and template implementations.
- `packages/ui` contains shared Base UI/shadcn-style components and hooks.
- `packages/fonts`, `packages/email`, `packages/import`, `packages/ai`, `packages/utils`, `packages/scripts`, `packages/config`, and `packages/runtime-externals` provide focused support surfaces. Prefer their existing exports over adding cross-package shortcuts.
- Issues and specs: GitHub Issues for `reactive-resume/reactive-resume`. See `docs/agents/issue-tracker.md`.
### Web app conventions
## Overview
- Routes are file-based under `apps/web/src/routes`. Do not hand-edit `apps/web/src/routeTree.gen.ts`; it is generated by TanStack Router tooling.
- Server-only route handlers use route `server.handlers` blocks, for example `api/rpc.$.ts`, `api/auth.$.ts`, `api/health.ts`, uploads, schema, OpenAPI, MCP, and `.well-known` routes.
- `apps/web/src/router.tsx` initializes router context with `queryClient`, `orpc`, `theme`, `locale`, `session`, and `flags`. Reuse route context where possible instead of refetching these concerns ad hoc.
- The builder shell lives under `apps/web/src/routes/builder/$resumeId`. The nested preview route is client-only (`ssr: false`), while the public resume route `apps/web/src/routes/$username/$slug.tsx` uses `ssr: "data-only"`.
- Browser-only resume preview code is split across `apps/web/src/components/resume/preview.tsx`, `preview.browser.tsx`, `pdf-canvas.tsx`, and shared helpers. Keep PDF.js/canvas/browser APIs out of SSR paths.
- The isomorphic oRPC client is in `apps/web/src/libs/orpc/client.ts`; server calls use an in-process router client and browser calls use `/api/rpc` with credentials included.
Reactive Resume is a pnpm monorepo (Turborepo) with two deployable apps: `apps/web` (React 19 SPA with TanStack Router and Vite) and `apps/server` (Hono / Node.js). The production Docker image runs a single Node.js process on port 3000; `apps/server` mounts the API/auth/MCP/static routes and serves the built web app.
### Package and feature boundaries
Internal packages are source-consumed through `package.json` export maps pointing at `src` files. Do not assume package-local `dist` output exists unless a package explicitly adds it.
- Add new API procedures in `packages/api/src/routers/*` and keep business logic in `packages/api/src/services/*` or helpers. Prefer `protectedProcedure` from `packages/api/src/context.ts` for authenticated procedures.
- Add database columns/tables in `packages/db/src/schema/*`, then generate root-level migrations with `pnpm db:generate`.
- Add or change resume data shape in `packages/schema/src/resume/*` first, then update API DTOs, importers, PDF rendering, and web forms that consume that shape.
- Add or rename templates in all relevant places: `packages/schema/src/templates.ts`, `packages/pdf/src/templates/index.ts`, template source under `packages/pdf/src/templates/<name>/`, and static previews under `apps/web/public/templates/{jpg,pdf}`.
- Shared PDF section filtering lives in `packages/pdf/src/templates/shared/filtering.ts`. Keep template-specific visual exceptions in the owning template directory unless multiple templates need the same behavior.
- `packages/pdf/src/hooks/use-register-fonts.ts` owns React PDF font registration, standard PDF font handling, CJK fallback stacks, and global hyphenation behavior.
- `packages/utils` has narrowly exported helpers. If another package needs a utility, add an explicit export path instead of importing private files.
Prerequisites: **Node.js 24** (pinned in `.nvmrc`; matches Dockerfile `ARG NODE_VERSION=24`), **pnpm 12.3.4** (pinned by `packageManager` in the root `package.json`; pnpm self-manages to it, so any recent pnpm can bootstrap — the Dockerfile's `ARG PNPM_VERSION` only picks the base image) ([install guide](https://pnpm.io/installation)), and **Docker** for PostgreSQL (`sudo dockerd &` if the daemon isn't running).
### Database
## Ownership map
PostgreSQL runs via Docker Compose:
Where each concern lives, and where new code for it goes:
| Area | Owner |
|------|-------|
| Web routes, loaders, user-facing workflows | `apps/web/src/routes`, `apps/web/src/features` (file-based; never hand-edit `routeTree.gen.ts`) |
| Server HTTP routes/adapters, startup checks, static handlers, MCP transport, OpenAPI/well-known | `apps/server/src/{http,rpc,mcp,openapi,static,startup}` |
| Authenticated API contracts + business logic | `packages/api/src/features/*` (oRPC routers, DTOs, rate limiting; aggregated at `@reactive-resume/api/routers` for `/api/rpc`) |
| Auth | `packages/auth` (Better Auth config/helpers/types; `apps/server/src/http/auth.ts` delegates to `auth.handler`) |
| DB client + schema | `packages/db` (Drizzle; migrations at repo root `migrations/`) |
| Server env validation | `packages/env` (auto-loads root `.env`) |
| Resume/page/template Zod schemas | `packages/schema` |
| Pure resume-domain behavior (no DB/HTTP/DOM/renderer deps) | `packages/resume` (JSON Patch helpers, social-network icons) |
| Resume PDF rendering | `packages/pdf` (React PDF document, font registration, template primitives, browser/server adapters) |
| PDF.js viewer/canvas UI | `apps/web/src/features/resume` — never in `packages/pdf` |
| DOCX export | `packages/docx` |
| MCP tools/prompts/resources/server-card | `packages/mcp` |
| Generic UI primitives + hooks | `packages/ui` (Base UI/shadcn-style); workflow-specific UI stays in the owning web feature |
| Focused support surfaces | `packages/fonts`, `packages/email`, `packages/import`, `packages/ai`, `packages/utils`, `packages/config` — prefer existing exports over cross-package shortcuts |
| Dev-only scripts | `tooling/`, not `packages/`, so packages only hold runtime-bundled code |
Narrow cross-cutting helpers go in `packages/utils` only after checking no domain package is a better owner. Specifically: resume JSON Patch behavior belongs in `@reactive-resume/resume/patch` and DOCX builders in `@reactive-resume/docx` — not in `@reactive-resume/utils`.
## Web app conventions
- `apps/web/src/router.tsx` initializes router context with `queryClient`, `orpc`, `theme`, `locale`, `session`, and `flags`. Reuse route context instead of refetching these ad hoc.
- The web app is a client-rendered SPA. `apps/server` serves `index.html` and injects page metadata (OpenGraph, canonical, JSON-LD) in `apps/server/src/static/web.ts`; there is no React SSR.
- Builder shell: `apps/web/src/routes/builder/$resumeId`. Public resume route: `apps/web/src/routes/$username/$slug.tsx`.
- Browser-only preview code: `apps/web/src/features/resume/preview`. Public PDF viewer: `apps/web/src/features/resume/public`. Keep PDF.js/canvas code in these features, not in `packages/pdf`.
- oRPC client: `apps/web/src/libs/orpc/client.ts` calls `/api/rpc` with credentials included. `apps/web/src/libs/orpc/fetch.ts` stages large request bodies through Blob on Vercel.
- For React components with explicit props, use a named props type (e.g. `type FooProps = {...}` with `function Foo(props: FooProps)`) rather than inline object annotations, especially with more than one field or with generics.
## Package boundaries
`pnpm exec turbo boundaries` is the executable check. Rules:
- Workspace deps go through package names and export maps. Never import another workspace's `src` tree via repo paths, `@reactive-resume/*/src/*`, or TS path aliases.
- Workspace `turbo.json` files declare coarse tags: `app:web`, `app:server`, `runtime:server` (server-only packages: API/auth/db/env/email/MCP), `runtime:browser` (browser-only shared UI), `runtime:universal` (environment-neutral domain packages), plus `role:domain|infra|adapter|api|rendering|tooling` for intent.
- Runtime-specific code lives behind explicit export subpaths (`@reactive-resume/pdf/browser`, `@reactive-resume/pdf/server`, `@reactive-resume/env/server`). Keep root exports environment-neutral unless the package is intentionally server-only.
- Wildcard exports are allowed only for leaf libraries with an intentionally file-like surface — currently `@reactive-resume/ui/components/*`, `@reactive-resume/ui/hooks/*`, and schema resume model files. Prefer explicit exports for packages owning runtime behavior.
- Prefer `protectedProcedure` from `packages/api/src/context.ts` for authenticated procedures. Expose only intentional public surfaces through `packages/api/package.json`.
- Shared PDF section filtering: `packages/pdf/src/templates/shared/filtering.ts`. Template-specific visual exceptions stay in the owning template directory unless multiple templates need the behavior. `packages/pdf/src/hooks/use-register-fonts.ts` owns font registration, standard PDF fonts, CJK fallback stacks, and global hyphenation.
Multi-place changes:
- **Resume data shape**: `packages/schema/src/resume/*` first, then API DTOs, importers, PDF rendering, and web forms consuming it.
- **New template**: `packages/schema/src/templates.ts`, `packages/pdf/src/templates/index.ts`, source under `packages/pdf/src/templates/<name>/`, and previews under `apps/web/public/templates/{jpg,pdf}`.
- **New DB column/table**: `packages/db/src/schema/*`, then `dotenvx run -f .env.local -- pnpm db:generate`.
- **New env var**: `packages/env/src/server.ts` **and** the `globalEnv` array in `turbo.json`. Turborepo 2.x strict env mode filters out unlisted vars, so the variable will be `undefined` in child processes at runtime even when correctly set in the OS/container environment.
## Environment and database
Copy `.env.example` to `.env.local`. Three required vars: `APP_URL` (default `http://localhost:3000`), `DATABASE_URL` (default `postgresql://postgres:postgres@localhost:5432/postgres`), `AUTH_SECRET` (any non-empty string).
- **S3/SeaweedFS optional.** If `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, and `S3_BUCKET` are all set, the app uses S3-compatible storage. `.env.example` ships SeaweedFS defaults, so either start the `seaweedfs` compose service or comment those vars out to use local filesystem storage under `<workspace>/data`. `LOCAL_STORAGE_PATH` must be absolute when set.
- **`REDIS_URL` and `ENCRYPTION_SECRET`** are optional for core resume flows but both required for saved AI providers and the authenticated `/agent` workspace. Host-run dev uses `REDIS_URL=redis://localhost:6379`; the container-run app uses `redis://redis:6379`.
- **`drizzle-kit` (used by `pnpm db:migrate`) reads `DATABASE_URL` from `process.env` directly** — it does not auto-load `.env`. Run migration commands through `dotenvx`.
- The production server auto-runs migrations at startup before serving traffic, so manual `pnpm db:migrate` is mainly for first setup, migration debugging, or applying migrations without starting the app.
## Commands
Prefix dev servers and migration commands with `dotenvx run -f .env.local --`. Tests, typechecks, linters, boundary checks, and `pnpm build` do not need it; if one fails on a missing env var, rerun it with the prefix.
```
sudo docker compose -f compose.dev.yml up -d postgres
sudo docker compose -f compose.dev.yml up -d postgres # DB only
sudo docker compose -f compose.dev.yml up -d postgres redis seaweedfs seaweedfs_create_bucket # full infra
dotenvx run -f .env.local -- pnpm dev # port 3000 (dev:web for web only)
dotenvx run -f .env.local -- pnpm db:generate # db:migrate to apply
pnpm check # Biome — WRITE-CAPABLE (--write --unsafe)
pnpm test | pnpm typecheck | pnpm build | pnpm exec turbo boundaries
```
The dev default connection string is `postgresql://postgres:postgres@localhost:5432/postgres`.
Prefer package filters over repo-wide runs, e.g. `pnpm --filter web typecheck`, `pnpm --filter @reactive-resume/pdf test`. Vitest paths are package-relative under `pnpm --filter <package> test -- <path>`.
**Important**: `drizzle-kit` (used by `pnpm db:migrate`) reads `DATABASE_URL` from `process.env` directly — it does **not** auto-load the `.env` file. You must `export DATABASE_URL=...` before running migration commands, or set it in your shell profile.
## Gotchas
The web app also runs migrations during Nitro startup via `apps/web/plugins/1.migrate.ts`. Manual `pnpm db:migrate` is mainly for first setup, migration debugging, or applying migrations without starting the app.
### Environment
Copy `.env.example` to `.env`. The three required variables are:
- `APP_URL` (default `http://localhost:3000`)
- `DATABASE_URL` (default `postgresql://postgres:postgres@localhost:5432/postgres`)
- `AUTH_SECRET` (any non-empty string)
S3/SeaweedFS is optional. If `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, and `S3_BUCKET` are all set, the app uses S3-compatible storage. The checked-in `.env.example` sets SeaweedFS defaults, so either start the `seaweedfs` compose service too or comment out those S3 vars to use local filesystem storage under `<workspace>/data`. `LOCAL_STORAGE_PATH` must be absolute when set.
### Common commands
| Task | Command |
|------|---------|
| Install deps | `pnpm install` |
| Start Postgres only | `sudo docker compose -f compose.dev.yml up -d postgres` |
| Start Postgres + SeaweedFS | `sudo docker compose -f compose.dev.yml up -d postgres seaweedfs seaweedfs_create_bucket` |
| Generate migrations | `DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres" pnpm db:generate` |
| Run migrations | `DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres" pnpm db:migrate` |
| Dev server | `pnpm dev` (starts on port 3000) |
| Web dev server only | `pnpm dev:web` |
| Lint/format | `pnpm check` (Biome) |
| Tests | `pnpm test` (Vitest) |
| Build | `pnpm build` |
| Typecheck | `pnpm typecheck` |
For focused validation, prefer package filters before repo-wide commands, for example:
```
pnpm --filter web typecheck
pnpm --filter @reactive-resume/pdf test
pnpm --filter @reactive-resume/api test
```
Vitest test paths are package-relative when running through `pnpm --filter <package> test -- <path>`.
### Gotchas
- The dev server (`pnpm dev`) auto-runs migrations on startup via Nitro, so `pnpm db:migrate` is only strictly needed for first-time setup or after pulling new migration files.
- Email sending requires SMTP config; without it, emails are logged to console. This is fine for dev — the app still functions, but email verification links appear in server logs.
- The `lefthook.yml` pre-commit hook runs `biome check` on staged files. Run `pnpm check` before committing to avoid hook failures.
- `pnpm check` is write-capable (`biome check --write --unsafe .`). Call that out when using it, and use narrower Biome commands if you need a non-mutating inspection.
- Biome uses tabs, double quotes, line width 120, organized import groups, and sorted Tailwind classes for `clsx`, `cva`, and `cn`.
- Most packages use `tsgo --noEmit` for typechecking and `vitest run --passWithNoTests` for tests.
- There may be unrelated local edits in the worktree. Inspect `git status --short` first and avoid reverting files you did not touch.
- Email sending needs SMTP config; without it emails are logged to console. Dev still works — verification links appear in server logs.
- `lefthook.yml` pre-commit runs `biome check` on staged files. Run `pnpm check` before committing.
- `pnpm check` is write-capable. Call that out when using it, and use narrower Biome commands for a non-mutating inspection.
- Biome: tabs, double quotes, line width 120, organized import groups, sorted Tailwind classes for `clsx`, `cva`, `cn`.
- Most packages typecheck with `tsgo --noEmit` and test with `vitest run --passWithNoTests`.
- There may be unrelated local edits in the worktree. Check `git status --short` first; do not revert files you did not touch.
+347
View File
@@ -0,0 +1,347 @@
---
version: alpha
name: Reactive Resume
description: A monochrome, content-first design system for a free and open-source resume builder. Dark-by-default with light mode support.
colors:
primary: "#343434"
primary-foreground: "#FBFBFB"
secondary: "#F7F7F7"
secondary-foreground: "#343434"
background: "#FFFFFF"
foreground: "#252525"
muted: "#F7F7F7"
muted-foreground: "#8E8E8E"
card: "#FFFFFF"
card-foreground: "#252525"
border: "#EBEBEB"
input: "#EBEBEB"
ring: "#B5B5B5"
destructive: "#DC2626"
on-destructive: "#FFFFFF"
typography:
heading:
fontFamily: IBM Plex Sans Variable
fontSize: 1rem
fontWeight: 500
body:
fontFamily: IBM Plex Sans Variable
fontSize: 0.875rem
fontWeight: 400
body-sm:
fontFamily: IBM Plex Sans Variable
fontSize: 0.75rem
fontWeight: 400
label:
fontFamily: IBM Plex Sans Variable
fontSize: 0.8rem
fontWeight: 500
hero-heading:
fontFamily: IBM Plex Sans Variable
fontSize: 3.75rem
fontWeight: 700
letterSpacing: -0.025em
rounded:
sm: 0.18rem
md: 0.24rem
lg: 0.3rem
xl: 0.42rem
2xl: 0.54rem
3xl: 0.66rem
4xl: 0.78rem
spacing:
xs: 4px
sm: 8px
md: 16px
lg: 24px
xl: 32px
2xl: 48px
components:
button-default:
backgroundColor: "{colors.primary}"
textColor: "{colors.primary-foreground}"
rounded: "{rounded.lg}"
padding: 10px
height: 36px
button-outline:
backgroundColor: "{colors.background}"
textColor: "{colors.foreground}"
rounded: "{rounded.lg}"
padding: 10px
height: 36px
button-secondary:
backgroundColor: "{colors.secondary}"
textColor: "{colors.secondary-foreground}"
rounded: "{rounded.lg}"
padding: 10px
height: 36px
button-ghost:
backgroundColor: "{colors.background}"
textColor: "{colors.foreground}"
rounded: "{rounded.lg}"
padding: 10px
height: 36px
button-destructive:
backgroundColor: "{colors.destructive}"
textColor: "{colors.on-destructive}"
rounded: "{rounded.lg}"
padding: 10px
height: 36px
card:
backgroundColor: "{colors.card}"
textColor: "{colors.card-foreground}"
rounded: "{rounded.lg}"
padding: 16px
input:
backgroundColor: "{colors.background}"
textColor: "{colors.foreground}"
rounded: "{rounded.lg}"
height: 36px
padding: 10px
input-focus:
backgroundColor: "{colors.background}"
textColor: "{colors.foreground}"
rounded: "{rounded.lg}"
height: 36px
padding: 10px
badge:
backgroundColor: "{colors.primary}"
textColor: "{colors.primary-foreground}"
rounded: "{rounded.md}"
padding: 4px
popover:
backgroundColor: "{colors.card}"
textColor: "{colors.card-foreground}"
rounded: "{rounded.xl}"
padding: 4px
sidebar:
backgroundColor: "{colors.muted}"
textColor: "{colors.foreground}"
padding: 8px
sidebar-item:
backgroundColor: "{colors.muted}"
textColor: "{colors.muted-foreground}"
rounded: "{rounded.lg}"
padding: 8px
sidebar-item-active:
backgroundColor: "{colors.primary}"
textColor: "{colors.primary-foreground}"
rounded: "{rounded.lg}"
padding: 8px
tooltip:
backgroundColor: "{colors.primary}"
textColor: "{colors.primary-foreground}"
rounded: "{rounded.md}"
padding: 6px
separator:
backgroundColor: "{colors.border}"
height: 1px
dialog:
backgroundColor: "{colors.card}"
textColor: "{colors.card-foreground}"
rounded: "{rounded.xl}"
padding: 24px
input-invalid:
backgroundColor: "{colors.background}"
textColor: "{colors.destructive}"
rounded: "{rounded.lg}"
height: 36px
padding: 10px
---
## Overview
Reactive Resume is a monochrome, content-first design system built for a resume builder used by tens of thousands of people worldwide. The visual identity prioritizes readability and unobtrusiveness — the user's resume content is always the hero, never the chrome around it.
The system defaults to dark mode with a warm near-black backdrop that makes the resume preview "float" as the visual anchor. Light mode is supported as a full alternative. The authenticated app shell (dashboard, builder, settings) uses an entirely achromatic grayscale palette — the sole chromatic exception is destructive red for dangerous actions. The landing page introduces subtle chromatic accents: blue-tinted spotlight gradients on the hero, a multicolor text-mask animation on hover, and social auth provider brand colors (Google blue, LinkedIn blue) on the login page.
The overall aesthetic is a professional tool UI: clean grid lines, subtle borders, generous whitespace, and typography that steps back to let the content shine. Think "VS Code meets Figma" — a productivity workspace, not a marketing site.
One deliberate counterpoint to the serious UI: all resume templates are named after Pokemon (Azurill, Bronzor, Chikorita, Ditgar, Gengar, Pikachu, etc.). This is an intentional brand choice — playful naming for templates injects personality into an otherwise utilitarian interface, making templates feel collectible and memorable rather than generic ("Template 1", "Modern", "Classic").
## Colors
The palette is rooted in achromatic OKLch values (chroma = 0), producing a pure grayscale scale without warm or cool casts. Colors are defined as CSS custom properties using `oklch()` and consumed through Tailwind CSS 4 theme tokens. Always prefer CSS variables (e.g., `var(--primary)`) or Tailwind tokens (e.g., `bg-primary`) over raw color values. The hex values in this document's YAML front matter are agent-friendly approximations of the canonical OKLch definitions in `packages/ui/src/styles/globals.css` — use hex only where OKLch is unavailable.
- **Primary (#343434 light / #EBEBEB dark):** Used for high-emphasis interactive surfaces — default buttons, selected states, and text selection. In dark mode this inverts to near-white so buttons remain prominent.
- **Foreground (#252525 light / #FBFBFB dark):** Body text and headings. High contrast against the background in both themes.
- **Background (#FFFFFF light / #252525 dark):** The canvas. Pure white in light mode, warm near-black in dark mode.
- **Card (#FFFFFF light / #343434 dark):** Elevated surface for cards, panels, and the builder sidebar. In dark mode, one step lighter than the background to create subtle depth.
- **Muted (#F7F7F7 light / #454545 dark):** De-emphasized backgrounds for secondary UI regions, hover states, and inactive tabs.
- **Muted Foreground (#8E8E8E light / #B5B5B5 dark):** Captions, helper text, timestamps, and metadata. Deliberately low-contrast against the background to recede visually.
- **Border (#EBEBEB light / white at 10% opacity dark):** Thin separator lines. In dark mode, uses transparent white rather than a solid gray to blend naturally with any underlying surface color.
- **Input (#EBEBEB light / white at 15% opacity dark):** Form field borders, slightly more prominent than general borders to make input areas discoverable.
- **Destructive (#DC2626 light / #EF4444 dark):** The only chromatic color in the palette. Reserved exclusively for delete actions, error states, and danger-zone operations. Used at 10% opacity as a background tint with full saturation for text, creating a soft but unmistakable warning.
- **Ring (#B5B5B5 light / #8E8E8E dark):** Focus ring indicator at 50% opacity, surrounding focused interactive elements.
- **Sidebar Primary (dark only, #6366F1):** An indigo value inherited from the shadcn/ui defaults. Not actively used in the current UI — sidebar active states use the standard grayscale primary token instead. Retained in the CSS custom properties for potential future customization.
Resume templates have their own independent color system — users pick primary, text, and background colors per resume through a color picker in the builder's Design panel. These template colors are completely separate from the app shell palette.
## Typography
The entire application uses a single typeface: **IBM Plex Sans Variable**. This is a humanist sans-serif with an extensive weight range (100–900) and excellent readability at small sizes, both on screen and in PDFs.
- **Hero heading (responsive: 2.25rem mobile / 3rem tablet / 3.75rem desktop, weight 700, tracking-tight):** Landing page headline only. Large, bold, and commanding. Scales across three breakpoints.
- **Section heading (1rem / 16px, weight 500):** Used for section titles in the builder sidebar, settings panels, and dashboard cards. Medium weight provides hierarchy without shouting.
- **Body (0.875rem / 14px, weight 400):** The workhorse. All form labels, descriptions, card content, and general UI text.
- **Small body (0.75rem / 12px, weight 400):** Captions, helper text, timestamps, and metadata.
- **Label (0.8rem / ~13px, weight 500):** Button text, badge labels, and form field labels. Slightly heavier than body to denote interactivity.
The resume content itself uses a separate font system — users choose from 1,000+ Google Fonts for their resume headings and body text, with category-aware fallback stacks including CJK support (Noto Sans SC, PingFang SC, Hiragino Sans GB for sans-serif; Noto Serif SC, Songti SC for serif). Standard PDF fonts (Helvetica, Courier, Times-Roman) are available as offline fallbacks.
Font rendering uses `antialiased` (grayscale AA) and `proportional-nums` across the board for clean rendering and properly spaced numerals in dates and phone numbers.
## Layout
### Builder (Three-Panel Workspace)
The core builder uses a resizable three-panel layout powered by `react-resizable-panels`:
- **Left sidebar (default 22%):** Resume section forms — personal info, experience, education, skills, and custom sections. Scrollable with collapsible section groups.
- **Center artboard (default 56%):** Live resume preview rendered via PDF.js canvas. Supports zoom, pan, and pinch gestures via `react-zoom-pan-pinch`. The preview maintains A4 aspect ratio (210:297) with a subtle shadow to simulate a physical page.
- **Right sidebar (default 22%):** Design controls — template picker, font selection, color picker, layout manager (page assignments, section ordering via drag-and-drop).
Panel sizes persist in cookies. On mobile (< 768px), sidebars collapse to 0% width and become toggleable overlays (max 95% width when open). The desktop minimum collapsed width is 48px (icon rail).
### Dashboard
Standard sidebar navigation layout using the `Sidebar` component system. The sidebar contains: logo, resume list link, agent link, settings subnavigation (profile, preferences, authentication, API keys, integrations, danger zone), and a footer with user avatar. Content area shows a responsive grid of resume cards.
### Landing Page
Full-width single-column marketing layout:
1. **Floating builder preview** — A non-interactive screenshot of the builder as a hero visual, creating an immediate "this is what you get" impression.
2. **Hero** — Centered headline, subheadline, and two CTAs (primary "Get Started" with arrow, ghost "Learn More" with icon).
3. **Features grid** — 4-column responsive grid with icon + title + description cards, separated by thin border lines.
4. **Template carousel** — Horizontally scrolling row of template preview thumbnails with Pokemon-themed names.
5. **Testimonials** — Tiled user quotes in a masonry-style grid.
6. **Support / FAQ / Footer** — Accordion FAQ, community section, and a 4-column footer with logo, resource links, community links, and license info.
### Responsive Breakpoints
Mobile detection uses a 768px threshold via `MediaQueryList`. The layout is optimized for workspace productivity on larger screens, with responsive mobile support that adapts the multi-panel builder into a streamlined single-panel experience. Both desktop and mobile are supported experiences — the builder's three-panel layout leverages desktop space, while mobile surfaces the same editing capabilities through collapsible overlays.
### Page Aspect Ratio
A custom Tailwind token `--aspect-page: 210 / 297` enforces A4 paper proportions wherever resume pages are rendered (builder preview, public view, PDF export).
## Animation
Animations use the Motion library (formerly Framer Motion) and follow a consistent choreography pattern:
**Entrance animations** use a fade-up reveal: elements start at `opacity: 0, y: 20-100` and animate to `opacity: 1, y: 0`. The hero section uses a larger y-offset (100px) for dramatic effect; subsequent sections use 20px for subtlety.
**Timing principles:**
- **Base duration:** 0.35s–0.6s for standard section reveals, 0.45s for hero elements, up to 1.1s for the hero video entrance.
- **Stagger pattern:** Sequential delays within a group, typically 0.1s–0.15s apart (hero: 0.55s, 0.7s, 0.82s, 0.95s). For grids, use `index * 0.03`–`0.1` for per-item stagger.
- **Easing:** `easeOut` for entrances (elements decelerate into position). `easeInOut` for looping/ambient animations.
- **Performance:** Apply `will-change-[transform,opacity]` on animated elements and `will-change-transform` on continuously animated elements.
**Hover/interaction animations** are quick (0.2s) and subtle — small scale bumps (`scale: 1.01`), slight y-offsets (`y: -2`), and `active:translate-y-px` for button press.
**Ambient animations** loop infinitely with `easeInOut` — the scroll indicator bounces gently (`y: [0, 5, 0]` over 1.5s).
**Reduced motion:** All CSS transitions and animations collapse to `0.01ms` duration and single iteration when `prefers-reduced-motion: reduce` is active. Motion library animations should also respect this preference.
## Elevation & Depth
Elevation is handled through background color layering rather than drop shadows:
- **Level 0 — Background:** The base canvas (`--background`).
- **Level 1 — Card:** One step lighter in dark mode (`--card`), used for sidebars, panels, and cards.
- **Level 2 — Popover:** Same as card, but appears above the content layer in popovers, dropdowns, and command palette.
- **Level 3 — Overlay:** Backdrop blur (`backdrop-blur-xs` at 0.5px or `backdrop-blur-2xl` at 40px) with `backdrop-saturate-150` for modal overlays, creating a frosted-glass effect over the workspace.
The resume preview page uses a subtle drop shadow to simulate a physical sheet of paper floating above the dark artboard — one of the few places actual shadows appear.
## Shapes
Border radius follows a multiplicative scale from a single `--radius` base of `0.3rem`:
| Token | Value | Usage |
|:------|:------|:------|
| `sm` | 0.18rem (≈3px) | Small badges, inline chips |
| `md` | 0.24rem (≈4px) | XS/SM buttons, compact elements |
| `lg` | 0.3rem (≈5px) | Default buttons, cards, inputs |
| `xl` | 0.42rem (≈7px) | Larger cards, modal corners |
| `2xl` | 0.54rem (≈9px) | Dialog containers |
| `3xl` | 0.66rem (≈11px) | Large panels |
| `4xl` | 0.78rem (≈12px) | Full-page modals |
The radius scale is deliberately tight — the largest value (0.78rem) is still quite subtle. This avoids the "rounded everything" aesthetic and keeps the UI feeling precise and tool-like. Interactive elements consistently use `rounded-lg` as the default.
## Components
### Buttons
Six variants, all sharing `rounded-lg` corners, `font-medium`, `text-sm`, and a 1px `translate-y` on active press (except when the button opens a popup):
- **Default:** Solid primary background. The highest-emphasis action on any screen.
- **Outline:** Transparent with a border. For secondary actions that need clear boundaries.
- **Secondary:** Muted background. For paired actions alongside a primary button.
- **Ghost:** No background or border. For toolbar actions and inline controls where chrome would be noise.
- **Destructive:** Red at 10% opacity background with red text. Visually alarming without being garish.
- **Link:** Underline-on-hover text. For inline navigation within prose.
Size scale: `xs` (28px), `sm` (32px), `default` (36px), `lg` (40px), plus `icon` variants at each size for square icon-only buttons.
### Cards
White/dark surface with foreground text. Composed of `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter`, and `CardAction` slots. Default vertical padding is `py-4` (compact: `py-3`).
### Forms
Built on TanStack Form with Zod validation. Composed of `FormItem`, `FormLabel`, `FormControl`, `FormMessage`, and `FormDescription`. Validation errors only appear after field touch. Invalid fields get a red destructive border with a ring.
### Dialogs
Centralized dialog manager with 40+ dialog types, all rendered via pattern matching (`ts-pattern`). Dialogs support before-close validation, form blocking for unsaved changes, and confirmation prompts. Used for all CRUD operations on resume sections, settings changes, and import/export flows.
### Command Palette
Triggered by `Cmd+K` / `Ctrl+K`. Built on `cmdk` with fuzzy search via `Fuse.js`. Multi-page navigation (resumes, settings, preferences) with back navigation via Backspace. Screen-reader accessible with `sr-only` headings.
### Toast Notifications
Powered by Sonner, positioned bottom-right with rich colors. Used for auto-save feedback, form submission status, error reporting, and donation prompts. Loading toasts are used during async operations (PDF generation, resume creation) with dismiss-on-complete.
### Drag and Drop
Powered by `@dnd-kit` with `PointerSensor` and `KeyboardSensor`. Used in chip inputs (skill tags, URL lists) and page layout management (section ordering across resume pages). Smooth animations via Motion library.
## Internationalization
The app supports 40+ locales including RTL languages (Arabic, Hebrew, Persian, Urdu, Uyghur, Yiddish). i18n is not an afterthought — it shapes layout decisions:
**Direction:** The `<html>` element receives `dir="rtl"` or `dir="ltr"` based on the active locale, detected via `isRTL()` which checks the language prefix against a known RTL set. All layout mirroring flows from this single attribute.
**Logical properties:** Use CSS logical properties (`ps-`, `pe-`, `ms-`, `me-`, `inline-start`, `inline-end`, `inset-s-`, `inset-e-`) instead of physical (`pl-`, `pr-`, `ml-`, `mr-`, `left`, `right`). Button components already use `has-data-[icon=inline-start]:ps-2` and `has-data-[icon=inline-end]:pe-2` patterns. This ensures correct spacing in both LTR and RTL layouts without separate stylesheets.
**Variable-length text:** Translations can be 30–50% longer than English (German, Finnish) or significantly shorter (CJK). UI elements should accommodate variable text length — avoid fixed widths on buttons and labels. Use `whitespace-nowrap` only where truncation is acceptable, and prefer `min-w-0` with `truncate` over fixed-width containers.
**Icons:** Directional icons (arrows, chevrons, progress indicators) should mirror in RTL contexts. Phosphor Icons provides mirrored variants for directional icons. Non-directional icons (settings gear, checkmark, delete) do not mirror.
**Strings:** All user-facing strings use Lingui macros (`t`, `msg`, `<Trans>`) — never hardcode English text in components. Translation files are `.po` format under `/locale/`.
## Do's and Don'ts
### Do
- **Use the grayscale palette for all app chrome.** The absence of color is the brand. The resume content is the only thing that should be colorful.
- **Default to dark mode.** The dark workspace makes resume previews pop and reduces eye strain during extended editing sessions.
- **Use `text-sm` (14px) as the base text size.** The UI is information-dense — form fields, section labels, metadata — and needs to be scannable without feeling cramped.
- **Keep border radius tight.** Use `rounded-lg` (0.3rem) as the default. The tool should feel precise, not playful.
- **Respect reduced motion preferences.** All animations collapse to 0.01ms when `prefers-reduced-motion: reduce` is active.
- **Use Phosphor Icons consistently.** Regular weight, `size-4` (16px) default. Icons should be functional labels, not decorative.
- **Maintain the three-panel builder proportions.** The center artboard should always dominate. Sidebars are support panels, not equal peers.
- **Use transparent-white borders in dark mode.** `oklch(1 0 0 / 10%)` blends naturally with any surface rather than introducing a distinct gray band.
### Don't
- **Don't introduce accent colors into the app shell.** No blues, greens, or purples for primary actions. The only chromatic color is destructive red. The inherited indigo sidebar-primary token exists in CSS custom properties but is not actively used.
- **Don't use drop shadows for elevation.** Rely on background color layering and border separation. The one exception is the resume page preview shadow.
- **Don't make the UI compete with the resume content.** If a new feature draws more visual attention than the resume preview, it needs to be toned down.
- **Don't use large border radii.** Nothing above `rounded-xl` on standard components. Large pills and full-round shapes conflict with the precision-tool aesthetic.
- **Don't hardcode colors outside the token system.** All colors flow through CSS custom properties so that dark/light mode switching works automatically.
- **Don't use multiple typefaces in the app shell.** IBM Plex Sans Variable is the only UI font. Resume templates have their own font system, but the chrome stays single-family.
- **Don't skip the `data-slot` attribute on components.** It's used for styling hooks and accessibility selectors throughout the component library.
- **Don't forget RTL.** The app supports 40+ locales including Arabic, Hebrew, Persian, and Urdu. Use logical properties (`ps`, `pe`, `ms`, `me`) instead of physical (`pl`, `pr`, `ml`, `mr`).
+23 -18
View File
@@ -1,22 +1,23 @@
# syntax=docker/dockerfile:1.7
# Base image only; pnpm self-manages to the `packageManager` version in package.json.
ARG PNPM_VERSION=11.21.0
ARG NODE_VERSION=24
FROM node:${NODE_VERSION}-slim AS base
FROM ghcr.io/pnpm/pnpm:${PNPM_VERSION} AS base
ARG NODE_VERSION
RUN pnpm runtime set node ${NODE_VERSION} -g --config.store-dir=/pnpm/runtime-store
WORKDIR /app
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
PNPM_HOME="/pnpm" \
PATH="/pnpm:$PATH" \
TURBO_TELEMETRY_DISABLED=1
RUN corepack enable
ENV TURBO_TELEMETRY_DISABLED=1
FROM base AS pruner
COPY . .
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store,sharing=locked \
pnpm dlx turbo@2.9.9 prune web --docker
pnpm dlx turbo@2.9.12 prune web server --docker
FROM base AS builder
COPY --from=pruner /app/out/json/ ./
@@ -25,18 +26,18 @@ RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store,sharing=locked \
pnpm install --frozen-lockfile
COPY --from=pruner /app/out/full/ ./
RUN rm -rf apps/web/.output && pnpm turbo run build --filter=web --force
RUN rm -rf apps/web/dist apps/server/dist && pnpm turbo run build --filter=web --filter=server --force
FROM base AS runtime-pruner
COPY . .
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store,sharing=locked \
pnpm dlx turbo@2.9.9 prune @reactive-resume/runtime-externals --docker
pnpm dlx turbo@2.9.12 prune server --docker
FROM base AS runtime-deps
COPY --from=runtime-pruner /app/out/json/ ./
COPY --from=runtime-pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store,sharing=locked \
pnpm --filter=@reactive-resume/runtime-externals deploy --prod --legacy /runtime-deps
pnpm install --prod --frozen-lockfile
FROM node:${NODE_VERSION}-slim AS runtime
@@ -47,7 +48,7 @@ LABEL org.opencontainers.image.description="A free and open-source resume builde
LABEL org.opencontainers.image.vendor="Amruth Pillai"
LABEL org.opencontainers.image.url="https://rxresu.me"
LABEL org.opencontainers.image.documentation="https://docs.rxresu.me"
LABEL org.opencontainers.image.source="https://github.com/amruthpillai/reactive-resume"
LABEL org.opencontainers.image.source="https://github.com/reactive-resume/reactive-resume"
ENV NODE_ENV="production" \
PORT=3000 \
@@ -55,18 +56,22 @@ ENV NODE_ENV="production" \
WORKDIR /app
RUN mkdir -p /app/apps/web /app/data && chown node:node /app/data
RUN mkdir -p /app/apps/server /app/apps/web /app/data && chown node:node /app/data
COPY --from=runtime-deps --chown=node:node /runtime-deps/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/apps/web/.output ./apps/web/.output
COPY --from=runtime-deps --chown=node:node /app/node_modules ./node_modules
COPY --from=pruner --chown=node:node /app/package.json /app/pnpm-lock.yaml /app/pnpm-workspace.yaml ./
COPY --from=runtime-deps --chown=node:node /app/apps/server/package.json ./apps/server/package.json
COPY --from=runtime-deps --chown=node:node /app/apps/server/node_modules ./apps/server/node_modules
COPY --from=builder --chown=node:node /app/apps/web/dist ./apps/web/dist
COPY --from=builder --chown=node:node /app/apps/server/dist ./apps/server/dist
COPY --from=pruner --chown=node:node /app/migrations ./migrations
WORKDIR /app/apps/web
WORKDIR /app
USER node
EXPOSE 3000/tcp
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:3000/api/health').then((r) => { if (!r.ok) process.exit(1); }).catch(() => process.exit(1));"]
CMD ["node", "-e", "fetch(`http://127.0.0.1:${process.env.PORT ?? 3000}/api/health`).then((r) => { if (!r.ok) process.exit(1); }).catch(() => process.exit(1));"]
CMD ["node", ".output/server/index.mjs"]
CMD ["node", "apps/server/dist/index.mjs"]
+43
View File
@@ -0,0 +1,43 @@
# syntax=docker/dockerfile:1.7
ARG PNPM_VERSION=11.21.0
ARG NODE_VERSION=24
FROM ghcr.io/pnpm/pnpm:${PNPM_VERSION} AS dev
ARG NODE_VERSION
RUN pnpm runtime set node ${NODE_VERSION} -g --config.store-dir=/pnpm/runtime-store
WORKDIR /app
ENV NODE_ENV=development \
TURBO_TELEMETRY_DISABLED=1
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
COPY patches ./patches
COPY apps/server/package.json ./apps/server/package.json
COPY apps/web/package.json ./apps/web/package.json
COPY packages/ai/package.json ./packages/ai/package.json
COPY packages/api/package.json ./packages/api/package.json
COPY packages/auth/package.json ./packages/auth/package.json
COPY packages/config/package.json ./packages/config/package.json
COPY packages/db/package.json ./packages/db/package.json
COPY packages/email/package.json ./packages/email/package.json
COPY packages/env/package.json ./packages/env/package.json
COPY packages/fonts/package.json ./packages/fonts/package.json
COPY packages/import/package.json ./packages/import/package.json
COPY packages/pdf/package.json ./packages/pdf/package.json
COPY packages/schema/package.json ./packages/schema/package.json
COPY packages/ui/package.json ./packages/ui/package.json
COPY packages/utils/package.json ./packages/utils/package.json
COPY tooling/package.json ./tooling/package.json
RUN --mount=type=cache,id=reactive-resume-dev-pnpm-store,target=/pnpm/store,sharing=locked \
pnpm install --frozen-lockfile
COPY . .
EXPOSE 3000/tcp 3001/tcp
CMD ["pnpm", "run", "dev"]
+260
View File
@@ -0,0 +1,260 @@
# Glossary
What the recurring terms in Reactive Resume's interface actually mean.
This file exists because most of the interface is translated from short, standalone strings.
A translator, human or machine, sees `Board` or `Resume` with no surrounding sentence, picks the
most common English sense, and gets it wrong. Every entry below has been mistranslated that way
in at least one shipped locale.
**If you are translating, read the term here before translating it.** When the English word has
a common sense that is *not* the one used here, that wrong sense is listed explicitly.
Terms are grouped by the part of the product they belong to. Source references point at where the
string is defined, so you can read the surrounding code when this file is not enough.
## Always left untranslated
Product and technology names stay in English (or in the locale's established transliteration, if
the catalog already uses one consistently):
Reactive Resume, GitHub, Crowdin, Docker, PostgreSQL, Better Auth, TanStack, Microsoft Word,
PDF, DOCX, JSON, CSV, API, MCP, oRPC, SSO, CSS, URL, JSON Resume.
AI provider names are brand names and stay in English: OpenAI, Anthropic Claude, Google
Gemini, Vercel AI Gateway, OpenRouter, Mistral AI, Cohere, xAI Grok, Groq, DeepSeek, Together.ai,
Fireworks, Cerebras, Perplexity, Ollama Cloud.
Template names are proper nouns and are never translated: Azurill, Bronzor, Chikorita, Ditgar,
Ditto, Gengar, Glalie, Kakuna, Lapras, Leafish, Meowth, Onyx, Pikachu, Rhyhorn, Scizor.
## The document
**Resume** — the job-application document the app builds. Always a noun.
Not the verb "to resume", "to continue", or "to restart". This is the single most common
mistranslation in the catalogs: many locales render the standalone `Resume` label as the verb.
In `application-form-sheet.tsx` the label marks the resume attached to a job application.
Where a locale's normal word for this document is CV, use CV.
**Resumes** — plural of the above. A list of the user's documents.
**Cover letter** — the letter accompanying a resume. Stored as a resume section, not a separate
document.
**Builder** — the editor where a resume is composed. A tool, not a construction worker or a
person who builds.
**Template** — a visual design for a resume. Not a "model" in the machine-learning sense, and
not a "sample" or "example" document. Beware in languages where the natural word for template
is also the word for model: the app uses "model" separately, for AI models.
**Section** — one block of a resume, such as Experience or Education. Not a legal section or a
document chapter.
**Item** — one entry inside a section, for example a single job or a single degree. Generic on
purpose. Not "product", "article", or "column".
**Page** — one physical page of the rendered resume. Not a web page.
**Columns** — the column count of a resume layout. Not database or spreadsheet columns.
**Slug** — the URL-safe identifier in a resume's public address. Usually kept in English or
transliterated; never translated as "snail".
### Resume section names
These are the built-in section presets, defined in `apps/web/src/libs/resume/section.tsx` and
`apps/web/src/dialogs/resume/sections/custom.tsx`. Translate them the way a resume in the target
language would label them:
**Basics** — name, contact details, and headline. Not "fundamentals" or "basic settings".
**Summary** — the short personal statement at the top of a resume. Not a summary of the app, and
not an AI-generated abstract.
**Profiles** — links to the user's social and professional accounts (LinkedIn, GitHub). Plural.
Distinct from **Profile**, below, which is the user's own account page. These two are different
things and several catalogs have collapsed them into one word.
**Volunteer** — volunteering experience. A noun naming a section, not the verb "to volunteer".
Also: Experience, Education, Skills, Languages, Awards, Certifications, Interests, Projects,
Publications, References, Custom.
## The application tracker
**Applications** — job applications the user has submitted. Not software applications, apps, or
programs. Frequently mistranslated as the software sense.
**Board** — the kanban board view of applications, arranged in columns by stage. Not a board of
directors, a committee, a plank, or a noticeboard.
**Stage** — where an application sits in the pipeline (applied, interviewing, offer, rejected).
Not a theatre stage or a phase of construction.
**Source** — where the user found the job listing (a job board, a referral, a company site).
Singular, and specific to one application. Not a source code file and not a data source.
**Pipeline** — the sequence of stages an application moves through. A recruiting funnel, not a
physical pipe, duct, conduit, or oil pipeline. Seven locales translated it as plumbing.
**Table** — the table view of applications, one of the view options next to Board and List. Not a
piece of furniture.
**Archive** — a verb in this context: to move an application out of the active list. Not the
noun "an archive". It is a menu action and pairs with **Unarchive**; almost every locale had the
noun here.
**Applied on** — the date the user submitted the application. "Applied" is the job-application
verb, not "applied a substance onto a surface" and not "applied a patch".
**Mark rejected / Mark as…** — "Mark" is the verb, to set a status. It is not the given name Mark.
**Match score** — how well a resume matches a job description. A degree of correspondence, not a
sporting fixture.
**Fit**, as in "Score my fit" or "Strong fit" — how well the user suits the role. Not physical
fitness, and not how clothing fits.
**A stretch** — a role the user is unlikely to get, an ambitious application. Not a stretching
exercise.
**Notes** — the user's free-text notes on an application. Compare **Note** in the ATS checker,
which is not the same thing.
**Timeline** — the dated history of one application.
## The AI agent
**Threads** — conversations with the AI agent. The chat sense, as in a message thread. Not
sewing thread, not string, not yarn, and not a CPU thread. Several locales use the textile word.
**Provider** — a third-party AI service the user configures, such as OpenAI or Anthropic. A
service supplier. Not a healthcare provider, and not a person who provides for a family.
**Model** — the specific AI model chosen from a provider, such as Claude Sonnet or GPT. Not a
**Template** (several locales used the same word for both), not a device model or product
variant, and not a "style" or "pattern".
**Working resume** — the resume a thread is currently editing. "Working" describes the draft
being worked on, not the user's employment. It is not their work history, not a "job resume",
and not a *functional résumé*, which is a real and different résumé format.
**Tailor** — a verb: to adapt a resume to a specific job description. Nothing to do with
dressmaking or sewing.
**Sources** — the citations the agent attaches to an answer. Plural, and distinct from **Source**
in the application tracker above.
**Draft** — a working copy of a resume the agent edits. A noun.
**Patch** — a set of JSON Patch operations the agent proposes. Kept in English in most catalogs.
Not a cloth patch, a scrap of fabric, an adhesive bandage, or a connector.
## The ATS checker
**ATS** — applicant tracking system: recruiting software that parses resumes. Spell it out on
first use in languages where the acronym is unfamiliar. It is not a drug test, a transmission,
or any other expansion of the letters; at least one catalog translated `ATS Check` as a test for
amphetamines.
**Readability, Layout, Sections, Contact details, Dates, Writing** — the six check categories, in
`apps/web/src/features/ats-checker/messages.ts`. "Layout" here means page geometry and reading
order, not the builder's layout settings.
**Blocker, Warning, Tip** — the three severity levels of a finding.
**Note** — the label for an informational finding, in
`apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/ats-check.tsx`. A severity label,
not a written note. Unrelated to **Notes** in the application tracker.
**Parse / parsing** — software reading text out of the PDF.
## Account and security
**Passkey / Passkeys** — a WebAuthn credential that replaces a password, stored on the user's
device or security key. **It is not a password.** Many catalogs translate it with their word for
"password", which is actively confusing: both appear together on the security settings page, so
the user cannot tell which credential a message refers to. If the target language has no
established term, keep "passkey" in English rather than reusing the word for password.
**Password** — the ordinary secret. Distinct from the above, always.
**Two-factor authentication (2FA)** — a second verification step at sign-in.
**Backup codes** — single-use codes for signing in when the second factor is unavailable.
**API key** — a token for programmatic access. **Key** on its own, in `ai-section.tsx`, means the
AI provider's API key. Not a physical door key, not a keyboard key, and not the adjective "key"
in the sense of crucial or main.
**Session** — an active sign-in on one device.
**Sign in / Sign out** — the app's chosen verbs. Prefer the locale's equivalent of "sign in"
over "log in" where both exist, and keep whichever the catalog already uses consistently.
## Navigation and app shell
**Dashboard** — the main page after signing in, listing resumes and applications. Not a vehicle
dashboard, an instrument panel, or a control panel in the machinery sense.
**Profile** — the user's own account settings page. Distinct from **Profiles**, the resume
section, above.
**Lock / Unlock** — verbs: to make a resume read-only, and to release it.
**Tags** — user-defined labels for organizing resumes and applications.
**Custom** — in `color-picker.tsx`, a user-chosen color as opposed to a preset. An adjective.
**Public URL** — the shareable address of a published resume. Use one term consistently; the
English strings say "public URL" rather than "public link".
## Verbs that read as adjectives or nouns
Button labels and `aria-label` strings are usually **imperative verbs**: they say what the
control does. Read as a noun or an adjective, they turn into nonsense. This is the most common
error in the catalogs after the ambiguous nouns above.
**Open** — the verb. `Open AI agent` means *open the AI agent panel*; it does not describe an
agent that is "open", and it is **not a reference to OpenAI, the company**. Around forty-five of
the fifty-three catalogs got this wrong, split between "an open AI agent" and a transliteration
of *OpenAI*. The same applies to `Open in builder`.
**Close** — likewise the verb, as in `Close AI assistant`. Not "an assistant for closing things",
and not the adjective "close/nearby".
The app names two different surfaces here, and both strings are real: **AI agent** is the
full workspace at `/agent`, opened from the builder dock (`Open AI agent`), while **AI assistant**
is the panel that slides out inside the builder (`Open AI assistant`, `Close AI assistant`).
Translate them as two distinct names, the way the English does.
**Clear** — the verb, to empty a field or remove filters. Not the adjective "transparent",
"obvious", or "clear-cut".
**Lock / Unlock** — verbs. `Unlock` is specifically the opposite of `Lock`, not a synonym for
`Open`; several catalogs collapsed the two and produced two identical menu items.
**Archive / Unarchive**, **Mark**, **Tailor**, **Duplicate**, **Import**, **Export**, **Share**,
**Star** — all verbs when they appear as a control label. Check the `#:` source reference if you
are unsure whether a given string is a button or a heading.
## Message syntax
These are not words to translate, and breaking them breaks the interface:
- `{name}`, `{count}`, `{email}`, `{MAX_IMPORT}`, `{overflow}` — value placeholders. Keep the
spelling exactly, keep every one that appears in the source, and add none.
- `{count, plural, one {# item} other {# items}}` — ICU plurals. Translate only the text inside
the inner braces, keep the `#`, and use the plural categories your language actually needs
(Arabic and the Slavic languages legitimately have more than English).
- `<0>…</0>`, `<1>…</1>`, `<0/>` — indexes pointing at interface elements such as links and bold
spans. Keep every index and keep the pairs matched. You may move a tag inside the sentence for
word order, as long as it still wraps the corresponding words.
A missing or renamed placeholder is a runtime error, not a style problem.
## Adding to this file
When a translator asks what a term means, the answer belongs here. When you add a term, say what
it means in this app and, if the English word is ambiguous, say plainly which sense is wrong.
+50 -31
View File
@@ -1,3 +1,9 @@
> [!IMPORTANT]
> **Repository moved:** Reactive Resume now lives at **[`reactive-resume/reactive-resume`](https://github.com/reactive-resume/reactive-resume)** on GitHub.
> **Docker Hub stays at `amruthpillai/reactive-resume`.** GHCR builds now publish to `ghcr.io/reactive-resume/reactive-resume`.
> Verified image tags: `latest`, `v5`, `v5.3`, and `v5.3.0` (AMD64 and ARM64). The current version was rebuilt and production redeployed for this rename; no new GitHub release or version bump was made. See [migration details](https://github.com/reactive-resume/reactive-resume/issues/3503).
> GitHub Sponsors and Open Collective funding links remain unchanged.
<div align="center">
<a href="https://rxresu.me">
<img src="apps/web/public/opengraph/banner.jpg" alt="Reactive Resume" />
@@ -5,7 +11,7 @@
<h1>Reactive Resume</h1>
<p>Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume.</p>
<p>Reactive Resume is a free and open-source resume builder that makes it easy to create, update, and share your resume.</p>
<p>
<a href="https://rxresu.me"><strong>Get Started</strong></a>
@@ -14,9 +20,9 @@
</p>
<p>
<img src="https://img.shields.io/github/package-json/v/amruthpillai/reactive-resume?style=flat-square" alt="Reactive Resume Version">
<img src="https://img.shields.io/github/stars/amruthpillai/Reactive-Resume?style=flat-square" alt="GitHub Stars">
<img src="https://img.shields.io/github/license/amruthpillai/Reactive-Resume?style=flat-square" alt="License" />
<img src="https://img.shields.io/github/package-json/v/reactive-resume/reactive-resume?style=flat-square" alt="Reactive Resume Version">
<img src="https://img.shields.io/github/stars/reactive-resume/reactive-resume?style=flat-square" alt="GitHub Stars">
<img src="https://img.shields.io/github/license/reactive-resume/reactive-resume?style=flat-square" alt="License" />
<img src="https://img.shields.io/docker/pulls/amruthpillai/reactive-resume?style=flat-square" alt="Docker Pulls" />
<a href="https://discord.gg/aSyA5ZSxpb"><img src="https://img.shields.io/discord/1173518977851473940?style=flat-square&label=discord" alt="Discord" /></a>
<a href="https://crowdin.com/project/reactive-resume"><img src="https://badges.crowdin.net/reactive-resume/localized.svg?style=flat-square" alt="Crowdin" /></a>
@@ -27,26 +33,26 @@
---
Reactive Resume makes building resumes straightforward. Pick a template, fill in your details, and export to PDF—no account required for basic use. For those who want more control, the entire application can be self-hosted on your own infrastructure.
Pick a template, fill in your details, and export to PDF. Basic use needs no account. If you want more control, you can run the whole application on your own infrastructure.
Built with privacy as a core principle, Reactive Resume gives you complete ownership of your data. The codebase is fully open-source under the MIT license, with no tracking, no ads, and no hidden costs.
You own your data. The codebase is open source under the MIT license, with no tracking, no ads, and no hidden costs.
## Features
**Resume Building**
- Real-time preview as you type
- Live preview as you type
- Multiple export formats (PDF, JSON, DOCX)
- Drag-and-drop section ordering
- Custom sections for any content type
- Rich text editor with formatting support
- Rich text editor
**Templates**
- Professionally designed templates
- A4 and Letter size support
- 15 templates to choose from
- A4 and Letter page sizes
- Customizable colors, fonts, and spacing
- Custom CSS for advanced styling
- Structured Style Rules for section and text styling
**Privacy & Control**
@@ -61,7 +67,7 @@ Built with privacy as a core principle, Reactive Resume gives you complete owner
- Multi-language support
- Share resumes via unique links
- Import from JSON Resume format
- Dark mode support
- Dark mode
- Passkey and two-factor authentication
## Templates
@@ -143,7 +149,7 @@ The quickest way to run Reactive Resume locally:
```bash
# Clone the repository
git clone https://github.com/amruthpillai/reactive-resume.git
git clone --depth=1 https://github.com/reactive-resume/reactive-resume.git reactive-resume
cd reactive-resume
# Start all services
@@ -153,44 +159,48 @@ docker compose up -d
open http://localhost:3000
```
[![Build with Ona](https://ona.com/build-with-ona.svg)](https://app.ona.com/#https://github.com/amruthpillai/reactive-resume)
For detailed setup instructions, environment configuration, and self-hosting guides, see the [documentation](https://docs.rxresu.me).
## Tech Stack
| Category | Technology |
| ---------------- | ------------------------------- |
| Framework | TanStack Start (React 19, Vite) |
| Framework | TanStack Router (React 19, Vite) |
| Runtime | Node.js |
| Language | TypeScript |
| Database | PostgreSQL with Drizzle ORM |
| API | ORPC (Type-safe RPC) |
| Auth | Better Auth |
| Styling | Tailwind CSS |
| UI Components | Radix UI |
| UI Components | Base UI + shadcn-style package |
| State Management | Zustand + TanStack Query |
## Documentation
Comprehensive guides are available at [docs.rxresu.me](https://docs.rxresu.me):
The full documentation lives at [docs.rxresu.me](https://docs.rxresu.me):
| Guide | Description |
| ---------------------------------------------------------------------------- | -------------------------------- |
| [Getting Started](https://docs.rxresu.me/getting-started) | First-time setup and basic usage |
| [Self-Hosting](https://docs.rxresu.me/self-hosting/docker) | Deploy on your own server |
| [Development Setup](https://docs.rxresu.me/contributing/development) | Local development environment |
| [Project Architecture](https://docs.rxresu.me/contributing/architecture) | Codebase structure and patterns |
| [Development setup](https://docs.rxresu.me/contributing/development) | Local development environment |
| [Project architecture](https://docs.rxresu.me/contributing/architecture) | Codebase structure and patterns |
| [Exporting Your Resume](https://docs.rxresu.me/guides/exporting-your-resume) | PDF and JSON export options |
## Self-Hosting
Reactive Resume can be self-hosted using Docker. The stack includes:
Reactive Resume supports Docker and Vercel Hobby.
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Freactive-resume%2Freactive-resume&project-name=reactive-resume&repository-name=reactive-resume&env=AUTH_SECRET%2CENCRYPTION_SECRET&envDescription=Generate+two+independent+secrets+with+openssl+rand+-hex+32.+Keep+these+values+across+deployments.&envLink=https%3A%2F%2Fdocs.rxresu.me%2Fself-hosting%2Fvercel&stores=%5B%7B%22type%22%3A%22integration%22%2C%22protocol%22%3A%22storage%22%2C%22integrationSlug%22%3A%22neon%22%2C%22productSlug%22%3A%22neon%22%7D%2C%7B%22type%22%3A%22integration%22%2C%22protocol%22%3A%22storage%22%2C%22integrationSlug%22%3A%22upstash%22%2C%22productSlug%22%3A%22upstash-kv%22%7D%2C%7B%22type%22%3A%22blob%22%2C%22access%22%3A%22private%22%7D%5D)
Vercel provisions Neon PostgreSQL, private Blob storage, and Upstash Redis through its deployment wizard. Supply two persistent secrets, then deploy. See the [Vercel guide](docs/self-hosting/vercel.mdx) for setup, limits, and optional SMTP/OAuth configuration.
For Docker, the stack includes:
- **PostgreSQL** — Database for storing user data and resumes
- **SeaweedFS** (optional) — S3-compatible storage for file uploads
> **From v5.1.0 onwards** — PDF generation now runs entirely client-side via `@react-pdf/renderer`. New deployments no longer require Browserless, Chromium, or any external print service as a dependency. The `PRINTER_*` and `BROWSERLESS_*` environment variables are no longer read and can be removed from your `.env`.
> **From v5.1.0 onwards** — PDF generation runs entirely client-side via `@react-pdf/renderer`. New deployments no longer need Browserless, Chromium, or any external print service. The `PRINTER_*` and `BROWSERLESS_*` environment variables are no longer read and can be removed from your `.env`.
Pull the latest image from Docker Hub or GitHub Container Registry:
@@ -199,14 +209,14 @@ Pull the latest image from Docker Hub or GitHub Container Registry:
docker pull amruthpillai/reactive-resume:latest
# GitHub Container Registry
docker pull ghcr.io/amruthpillai/reactive-resume:latest
docker pull ghcr.io/reactive-resume/reactive-resume:latest
```
See the [self-hosting guide](https://docs.rxresu.me/self-hosting/docker) for complete instructions.
## Support
Reactive Resume is and always will be free and open-source. If it has helped you land a job or saved you time, please consider supporting continued development:
Reactive Resume is and always will be free and open source. If it has helped you land a job or saved you time, please consider supporting continued development:
<p>
<a href="https://github.com/sponsors/AmruthPillai">
@@ -220,23 +230,28 @@ Reactive Resume is and always will be free and open-source. If it has helped you
Other ways to support:
- Star this repository
- Report bugs and suggest features
- Report reproducible bugs and suggest actionable features
- Help other users in [GitHub Discussions](https://github.com/reactive-resume/reactive-resume/discussions/categories/q-a)
- Improve documentation
- Help with translations
<a href="https://blacksmith.sh/">
<img width="368" height="126" alt="powered-by-blacksmith" src="https://github.com/user-attachments/assets/3e95d11b-4579-4082-8d0c-6b574f925625" />
</a>
## Star History
<a href="https://www.star-history.com/#amruthpillai/reactive-resume&type=date&legend=top-left">
<a href="https://www.star-history.com/?repos=reactive-resume%2Freactive-resume&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=amruthpillai/reactive-resume&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=amruthpillai/reactive-resume&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=amruthpillai/reactive-resume&type=date&legend=top-left" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=reactive-resume/reactive-resume&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=reactive-resume/reactive-resume&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=reactive-resume/reactive-resume&type=date&legend=top-left" />
</picture>
</a>
## Contributing
Contributions make open-source thrive. Whether fixing a typo or adding a feature, all contributions are welcome.
Every contribution helps, whether it is a typo fix or a new feature.
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
@@ -244,7 +259,11 @@ Contributions make open-source thrive. Whether fixing a typo or adding a feature
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
See the [development setup guide](https://docs.rxresu.me/contributing/development) for detailed instructions on how to set up the project locally.
See the [development setup guide](https://docs.rxresu.me/contributing/development) for how to run the project locally.
Maintainers review the [`status: needs triage` queue](https://github.com/reactive-resume/reactive-resume/issues?q=is%3Aissue+is%3Aopen+label%3A%22status%3A+needs+triage%22)
weekly. Triaged bugs become `status: confirmed`; feature proposals become `status: accepted`; reports that need details become
`status: needs info`.
## License
+1
View File
@@ -0,0 +1 @@
export { default } from "../apps/server/dist/vercel.mjs";
+106
View File
@@ -0,0 +1,106 @@
{
"name": "server",
"version": "0.0.0",
"type": "module",
"private": true,
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsdown",
"start": "node dist/index.mjs",
"docs:gen": "tsx src/openapi/generate-spec.ts",
"typecheck": "tsgo --noEmit",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage --passWithNoTests",
"test:ci": "vitest run --coverage --reporter=default --reporter=github-actions --reporter=json --reporter=junit --outputFile.json=reports/vitest-results.json --outputFile.junit=reports/vitest-junit.xml --passWithNoTests",
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
},
"imports": {
"#react-pdf-renderer": "@react-pdf/renderer"
},
"dependencies": {
"@ai-sdk/anthropic": "^4.0.58",
"@ai-sdk/cerebras": "^3.0.53",
"@ai-sdk/cohere": "^4.0.46",
"@ai-sdk/deepseek": "^3.0.49",
"@ai-sdk/fireworks": "^3.0.56",
"@ai-sdk/google": "^4.0.76",
"@ai-sdk/groq": "^4.0.46",
"@ai-sdk/mistral": "^4.0.48",
"@ai-sdk/openai": "^4.0.71",
"@ai-sdk/openai-compatible": "^3.0.53",
"@ai-sdk/perplexity": "^4.0.48",
"@ai-sdk/togetherai": "^3.0.54",
"@ai-sdk/xai": "^5.0.4",
"@aws-sdk/client-s3": "^3.1136.0",
"@better-auth/api-key": "^1.7.5",
"@better-auth/drizzle-adapter": "^1.7.5",
"@better-auth/infra": "^0.4.9",
"@better-auth/oauth-provider": "^1.7.5",
"@better-auth/passkey": "^1.7.5",
"@bramus/specificity": "^2.4.2",
"@hono/node-server": "^2.1.1",
"@modelcontextprotocol/sdk": "^1.30.0",
"@orpc/client": "^1.15.2",
"@orpc/experimental-ratelimit": "^1.15.2",
"@orpc/json-schema": "^1.15.2",
"@orpc/openapi": "^1.15.2",
"@orpc/server": "^1.15.2",
"@orpc/zod": "^1.15.2",
"@react-pdf/hyphenate": "0.1.0",
"@react-pdf/renderer": "^4.9.0",
"@reactive-resume/api": "workspace:*",
"@reactive-resume/auth": "workspace:*",
"@reactive-resume/db": "workspace:*",
"@reactive-resume/env": "workspace:*",
"@reactive-resume/mcp": "workspace:*",
"@reactive-resume/schema": "workspace:*",
"@reactive-resume/utils": "workspace:*",
"@sindresorhus/slugify": "^3.0.1",
"@t3-oss/env-core": "^0.13.11",
"@uiw/color-convert": "^2.10.3",
"@vercel/blob": "^2.8.0",
"@vercel/functions": "^3.9.9",
"ai": "^7.0.107",
"bcrypt": "^6.0.0",
"better-auth": "1.7.5",
"cjk-regex": "^3.5.0",
"css-tree": "^3.2.1",
"deepmerge-ts": "^8.0.2",
"drizzle-orm": "1.0.0-rc.4",
"drizzle-zod": "1.0.0-beta.14-a36c63d",
"es-toolkit": "^1.52.0",
"fast-json-patch": "^3.1.1",
"fast-png": "^8.0.0",
"hono": "^4.13.8",
"ioredis": "^6.0.0",
"jose": "^6.2.12",
"jsonrepair": "^3.15.0",
"node-html-parser": "^9.0.4",
"nodemailer": "^10.0.10",
"ollama-ai-provider-v2": "^4.0.1",
"pg": "^8.23.0",
"phosphor-icons-react-pdf": "^0.1.3",
"react": "^19.3.0",
"react-email": "^6.9.5",
"react-pdf-html": "^2.1.5",
"resumable-stream": "^2.2.13",
"sanitize-html": "^2.17.7",
"sharp": "^0.35.4",
"tokenx": "^2.1.0",
"ts-pattern": "^5.9.0",
"unique-names-generator": "^4.7.1",
"uuid": "^14.0.2",
"zod": "^4.6.5"
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/node": "^26.6.2",
"@types/pg": "^8.23.1",
"@types/react": "^19.3.0",
"@typescript/native-preview": "7.0.0-dev.20260707.2",
"tsdown": "^0.23.0",
"tsx": "^4.23.13",
"typescript": "^7.0.2",
"vitest": "^5.0.1"
}
}
+1
View File
@@ -0,0 +1 @@
export const appVersion = typeof __APP_VERSION__ === "undefined" ? "0.0.0" : __APP_VERSION__;
+221
View File
@@ -0,0 +1,221 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
handleAuth: vi.fn(),
handleOAuth: vi.fn(),
handleRpc: vi.fn(),
handleOpenApi: vi.fn(),
handleHealth: vi.fn(),
handleUpload: vi.fn(),
handleMcp: vi.fn(),
handleResumePdfDownload: vi.fn(),
handlePublicResumePdf: vi.fn(),
handleMcpServerCard: vi.fn(),
handleOAuthAuthorizationServer: vi.fn(),
handleOAuthProtectedResource: vi.fn(),
handleOpenIdConfiguration: vi.fn(),
handleWellKnownFallback: vi.fn(),
handleRobots: vi.fn(),
handleSitemap: vi.fn(),
handleLlms: vi.fn(),
serveWebDistStatic: vi.fn(),
handleWebApp: vi.fn(),
}));
vi.mock("./auth", () => ({
handleAuth: mocks.handleAuth,
handleOAuth: mocks.handleOAuth,
}));
vi.mock("./health", () => ({
handleHealth: mocks.handleHealth,
}));
vi.mock("../rpc/handler", () => ({
handleRpc: mocks.handleRpc,
}));
vi.mock("../openapi/handler", () => ({
handleOpenApi: mocks.handleOpenApi,
}));
vi.mock("../openapi/metadata", () => ({
handleMcpServerCard: mocks.handleMcpServerCard,
handleOAuthAuthorizationServer: mocks.handleOAuthAuthorizationServer,
handleOAuthProtectedResource: mocks.handleOAuthProtectedResource,
handleOpenIdConfiguration: mocks.handleOpenIdConfiguration,
handleWellKnownFallback: mocks.handleWellKnownFallback,
}));
vi.mock("../static/uploads", () => ({
handleUpload: mocks.handleUpload,
}));
vi.mock("../static/seo", () => ({
handleRobots: mocks.handleRobots,
handleSitemap: mocks.handleSitemap,
handleLlms: mocks.handleLlms,
}));
vi.mock("../static/web", () => ({
serveWebDistStatic: mocks.serveWebDistStatic,
handleWebApp: mocks.handleWebApp,
}));
vi.mock("../mcp/handler", () => ({
handleMcp: mocks.handleMcp,
}));
vi.mock("./resume-pdf", () => ({
handleResumePdfDownload: mocks.handleResumePdfDownload,
}));
vi.mock("./public-resume-pdf", () => ({
handlePublicResumePdf: mocks.handlePublicResumePdf,
}));
const transportEnv = (remoteAddress: string) =>
({
incoming: { socket: { remoteAddress } },
}) as never;
beforeEach(() => {
vi.clearAllMocks();
mocks.handleAuth.mockResolvedValue(new Response("auth"));
mocks.handleOAuth.mockResolvedValue(new Response("oauth"));
mocks.handleRpc.mockResolvedValue(new Response("rpc"));
mocks.handleOpenApi.mockResolvedValue(new Response("openapi"));
mocks.handleHealth.mockReturnValue(new Response("health"));
mocks.handleUpload.mockResolvedValue(new Response("upload"));
mocks.handleMcp.mockResolvedValue(new Response("mcp"));
mocks.handleResumePdfDownload.mockResolvedValue(new Response("pdf"));
mocks.handlePublicResumePdf.mockResolvedValue(new Response("public-pdf"));
mocks.handleMcpServerCard.mockReturnValue(new Response("server-card"));
mocks.handleOAuthAuthorizationServer.mockReturnValue(new Response("oauth-authorization-server"));
mocks.handleOAuthProtectedResource.mockReturnValue(new Response("oauth-protected-resource"));
mocks.handleOpenIdConfiguration.mockReturnValue(new Response("openid-configuration"));
mocks.handleWellKnownFallback.mockReturnValue(new Response("well-known"));
mocks.handleRobots.mockReturnValue(new Response("robots"));
mocks.handleSitemap.mockReturnValue(new Response("sitemap"));
mocks.handleLlms.mockReturnValue(new Response("llms"));
mocks.serveWebDistStatic.mockResolvedValue(undefined);
mocks.handleWebApp.mockResolvedValue(new Response("web"));
});
describe("createApp", () => {
it("routes /api/auth/oauth to the OAuth bridge before the Better Auth wildcard", async () => {
const { createApp } = await import("./app");
const app = createApp();
const request = new Request("http://localhost:3001/api/auth/oauth?client_id=test-client");
const response = await app.fetch(request);
await expect(response.text()).resolves.toBe("oauth");
expect(mocks.handleOAuth).toHaveBeenCalledWith(request);
expect(mocks.handleAuth).not.toHaveBeenCalled();
});
it("routes signed resume PDF downloads before the web fallback", async () => {
const { createApp } = await import("./app");
const app = createApp();
const request = new Request("http://localhost:3001/api/resumes/resume-1/pdf?token=signed");
const response = await app.fetch(request);
await expect(response.text()).resolves.toBe("pdf");
expect(mocks.handleResumePdfDownload).toHaveBeenCalledWith(request, "resume-1");
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
expect(mocks.handleWebApp).not.toHaveBeenCalled();
});
it("uses the transport address for public PDF fallback despite rotated forwarding headers", async () => {
const { createApp } = await import("./app");
const app = createApp();
const first = new Request("http://localhost:3001/api/resumes/jane/resume/pdf", {
headers: { "x-forwarded-for": "198.51.100.1" },
});
const rotated = new Request("http://localhost:3001/api/resumes/jane/resume/pdf", {
headers: { "x-forwarded-for": "198.51.100.2" },
});
const env = transportEnv("203.0.113.9");
const response = await app.fetch(first, env);
await app.fetch(rotated, env);
await expect(response.text()).resolves.toBe("public-pdf");
expect(mocks.handlePublicResumePdf).toHaveBeenNthCalledWith(1, first, "jane", "resume", "203.0.113.9");
expect(mocks.handlePublicResumePdf).toHaveBeenNthCalledWith(2, rotated, "jane", "resume", "203.0.113.9");
expect(mocks.handleResumePdfDownload).not.toHaveBeenCalled();
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
expect(mocks.handleWebApp).not.toHaveBeenCalled();
});
it("passes the transport address to RPC and OpenAPI and fails closed when it is unavailable", async () => {
const { createApp } = await import("./app");
const app = createApp();
const trustedRpcRequest = new Request("http://localhost:3001/api/rpc", {
headers: { "cf-connecting-ip": "198.51.100.1" },
});
const unknownRpcRequest = new Request("http://localhost:3001/api/rpc", {
headers: { "cf-connecting-ip": "198.51.100.2" },
});
const trustedOpenApiRequest = new Request("http://localhost:3001/api/openapi/resumes/jane/resume");
const unknownOpenApiRequest = new Request("http://localhost:3001/api/openapi/resumes/jane/resume");
await app.fetch(trustedRpcRequest, transportEnv("203.0.113.9"));
await app.fetch(unknownRpcRequest);
await app.fetch(trustedOpenApiRequest, transportEnv("203.0.113.9"));
await app.fetch(unknownOpenApiRequest);
expect(mocks.handleRpc).toHaveBeenNthCalledWith(1, trustedRpcRequest, "203.0.113.9");
expect(mocks.handleRpc).toHaveBeenNthCalledWith(2, unknownRpcRequest, "unknown");
expect(mocks.handleOpenApi).toHaveBeenNthCalledWith(1, trustedOpenApiRequest, "203.0.113.9");
expect(mocks.handleOpenApi).toHaveBeenNthCalledWith(2, unknownOpenApiRequest, "unknown");
});
it.each([
["GET", "/robots.txt", "robots", mocks.handleRobots],
["HEAD", "/robots.txt", "", mocks.handleRobots],
["GET", "/sitemap.xml", "sitemap", mocks.handleSitemap],
["HEAD", "/sitemap.xml", "", mocks.handleSitemap],
["GET", "/llms.txt", "llms", mocks.handleLlms],
["HEAD", "/llms.txt", "", mocks.handleLlms],
])("routes %s %s before the static fallback", async (method, pathname, expectedBody, handler) => {
const { createApp } = await import("./app");
const app = createApp();
const request = new Request(`http://localhost:3001${pathname}`, { method });
const response = await app.fetch(request);
await expect(response.text()).resolves.toBe(expectedBody);
expect(handler).toHaveBeenCalledWith({ head: method === "HEAD" });
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
expect(mocks.handleWebApp).not.toHaveBeenCalled();
});
it.each(["GET", "HEAD"])("routes %s / to the web app handler so SEO markup is injected", async (method) => {
const { createApp } = await import("./app");
const app = createApp();
const request = new Request("http://localhost:3001/", { method });
const response = await app.fetch(request);
expect(response.status).toBe(200);
expect(mocks.handleWebApp).toHaveBeenCalledWith(request);
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
});
});
it.each(["/auth/consent", "/auth/consent/", "/auth/login"])("prevents framing or caching %s", async (path) => {
const { createApp } = await import("./app");
mocks.serveWebDistStatic.mockImplementationOnce(async (_context: unknown, next: () => Promise<void>) => {
await next();
});
const response = await createApp().request(`http://localhost:3000${path}?sig=signed`);
expect(response.status).toBe(200);
expect(await response.text()).toBe("web");
expect(response.headers.get("content-security-policy")).toBe("frame-ancestors 'none'");
expect(response.headers.get("x-frame-options")).toBe("DENY");
expect(response.headers.get("referrer-policy")).toBe("no-referrer");
expect(response.headers.get("cache-control")).toBe("no-store");
});
+91
View File
@@ -0,0 +1,91 @@
import type { Http2Bindings, HttpBindings } from "@hono/node-server";
import type { Context } from "hono";
import { isIP } from "node:net";
import { getConnInfo } from "@hono/node-server/conninfo";
import { Hono } from "hono";
import { prepareStagedBody, withStagedBody } from "@reactive-resume/api/features/storage/transport";
import { handleMcp } from "../mcp/handler";
import { handleOpenApi } from "../openapi/handler";
import {
handleMcpServerCard,
handleOAuthAuthorizationServer,
handleOAuthProtectedResource,
handleOpenIdConfiguration,
handleWellKnownFallback,
} from "../openapi/metadata";
import { handleRpc } from "../rpc/handler";
import { handleSchemaJson } from "../static/schema";
import { handleLlms, handleRobots, handleSitemap } from "../static/seo";
import { handleUpload } from "../static/uploads";
import { handleWebApp, serveWebDistStatic } from "../static/web";
import { handleAuth, handleOAuth } from "./auth";
import { handleHealth } from "./health";
import { handlePublicResumePdf } from "./public-resume-pdf";
import { handleResumePdfDownload } from "./resume-pdf";
type ServerEnvironment = { Bindings: HttpBindings | Http2Bindings };
const getTrustedClient = (context: Context<ServerEnvironment>): string => {
try {
const address = getConnInfo(context).remote.address?.trim();
return address && isIP(address) ? address : "unknown";
} catch {
return "unknown";
}
};
type AppOptions = {
serveStatic?: boolean;
trustedClient?: (request: Request) => string;
};
export function createApp(options: AppOptions = {}) {
const app = new Hono<ServerEnvironment>();
const client = (c: Context<ServerEnvironment>) => options.trustedClient?.(c.req.raw) ?? getTrustedClient(c);
app.use("/auth/*", async (c, next) => {
await next();
c.header("Content-Security-Policy", "frame-ancestors 'none'");
c.header("X-Frame-Options", "DENY");
c.header("Referrer-Policy", "no-referrer");
c.header("Cache-Control", "no-store");
});
app.post("/api/storage/stage", (c) => prepareStagedBody(c.req.raw));
app.all("/api/rpc", (c) => withStagedBody(c.req.raw, (request) => handleRpc(request, client(c))));
app.all("/api/rpc/*", (c) => withStagedBody(c.req.raw, (request) => handleRpc(request, client(c))));
app.all("/api/openapi", (c) => handleOpenApi(c.req.raw, client(c)));
app.all("/api/openapi/*", (c) => handleOpenApi(c.req.raw, client(c)));
app.get("/api/auth/oauth", (c) => handleOAuth(c.req.raw));
app.all("/api/auth/*", (c) => handleAuth(c.req.raw));
app.get("/api/health", () => handleHealth());
app.get("/api/resumes/:username/:slug/pdf", (c) =>
handlePublicResumePdf(c.req.raw, c.req.param("username"), c.req.param("slug"), client(c)),
);
app.get("/api/resumes/:id/pdf", (c) => handleResumePdfDownload(c.req.raw, c.req.param("id")));
app.get("/api/uploads/*", (c) => handleUpload(c.req.raw));
app.get("/uploads/*", (c) => handleUpload(c.req.raw));
app.get("/schema.json", () => handleSchemaJson());
app.all("/mcp", (c) => handleMcp(c.req.raw));
app.all("/mcp/*", (c) => handleMcp(c.req.raw));
app.get("/.well-known/mcp/server-card.json", () => handleMcpServerCard());
app.get("/.well-known/oauth-authorization-server", (c) => handleOAuthAuthorizationServer(c.req.raw));
app.get("/.well-known/oauth-authorization-server/*", (c) => handleOAuthAuthorizationServer(c.req.raw));
app.get("/.well-known/openid-configuration", (c) => handleOpenIdConfiguration(c.req.raw));
app.get("/.well-known/oauth-protected-resource", () => handleOAuthProtectedResource());
app.get("/.well-known/oauth-protected-resource/*", () => handleOAuthProtectedResource());
app.all("/.well-known/*", () => handleWellKnownFallback());
app.on(["GET", "HEAD"], "/robots.txt", (c) => handleRobots({ head: c.req.method === "HEAD" }));
app.on(["GET", "HEAD"], "/sitemap.xml", (c) => handleSitemap({ head: c.req.method === "HEAD" }));
app.on(["GET", "HEAD"], "/llms.txt", (c) => handleLlms({ head: c.req.method === "HEAD" }));
// Must precede the static middleware: serveStatic resolves "/" to dist/index.html and would
// return it verbatim, skipping the OpenGraph/Twitter/canonical/JSON-LD injection in handleWebApp.
app.on(["GET", "HEAD"], "/", (c) => handleWebApp(c.req.raw));
if (options.serveStatic !== false) app.use("/*", serveWebDistStatic);
app.on(["GET", "HEAD"], "/*", (c) => handleWebApp(c.req.raw));
return app;
}
+260
View File
@@ -0,0 +1,260 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
getSession: vi.fn(),
consent: vi.fn(),
continueOAuth: vi.fn(),
handler: vi.fn(),
env: {
SERVER_PORT: 3001,
APP_URL: "http://localhost:3000",
FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI: false,
},
}));
vi.mock("@reactive-resume/auth/config", () => ({
auth: {
api: {
getSession: mocks.getSession,
oauth2Consent: mocks.consent,
oauth2Continue: mocks.continueOAuth,
},
handler: mocks.handler,
},
}));
vi.mock("@reactive-resume/db/client", () => ({ db: {} }));
vi.mock("@reactive-resume/db/schema", () => ({ oauthClient: {}, verification: {} }));
vi.mock("@reactive-resume/env/server", () => ({
env: mocks.env,
}));
beforeEach(() => {
vi.clearAllMocks();
mocks.env.FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI = false;
mocks.handler.mockResolvedValue(new Response("ok"));
});
describe("handleAuth", () => {
it.for([null, false, 42, "client", [], [{ redirect_uris: [] }]])(
"rejects non-object registration payload %j",
async (body) => {
const { handleAuth } = await import("./auth");
const response = await handleAuth(
new Request("http://localhost:3000/api/auth/oauth2/register", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({ message: "Invalid registration payload" });
expect(mocks.handler).not.toHaveBeenCalled();
},
);
it("registers third-party https callbacks so remote MCP clients can complete DCR", async () => {
const { handleAuth } = await import("./auth");
const response = await handleAuth(
new Request("http://localhost:3001/api/auth/oauth2/register", {
method: "POST",
body: JSON.stringify({ redirect_uris: ["https://claude.ai/api/mcp/auth_callback"] }),
headers: { "content-type": "application/json" },
}),
);
expect(response.status).toBe(200);
expect(mocks.handler).toHaveBeenCalledOnce();
});
it("rejects unsafe dynamic OAuth redirect URIs in safe mode", async () => {
const { handleAuth } = await import("./auth");
const response = await handleAuth(
new Request("http://localhost:3001/api/auth/oauth2/register", {
method: "POST",
body: JSON.stringify({ redirect_uris: ["https://192.168.1.10/callback"] }),
headers: { "content-type": "application/json" },
}),
);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({
error: "invalid_redirect_uri",
error_description: "redirect_uri is not allowed",
});
expect(mocks.handler).not.toHaveBeenCalled();
});
it("forwards custom-scheme dynamic OAuth redirect URIs when unsafe mode is enabled", async () => {
const { handleAuth } = await import("./auth");
mocks.env.FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI = true;
const response = await handleAuth(
new Request("http://localhost:3001/api/auth/oauth2/register", {
method: "POST",
body: JSON.stringify({ redirect_uris: ["myapp://callback"] }),
headers: { "content-type": "application/json" },
}),
);
expect(response.status).toBe(200);
expect(mocks.handler).toHaveBeenCalledOnce();
});
it.each(["localhost", "127.0.0.1", "[::1]"])(
"infers native application type for exact %s loopback callbacks",
async (host) => {
const { handleAuth } = await import("./auth");
await handleAuth(
new Request("http://localhost:3000/api/auth/oauth2/register", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ redirect_uris: [`http://${host}:3210/callback`] }),
}),
);
const forwarded = mocks.handler.mock.calls[0]?.[0] as Request;
await expect(forwarded.json()).resolves.toMatchObject({
application_type: "native",
token_endpoint_auth_method: "none",
});
},
);
it.each(["client_secret_basic", "client_secret_post"])(
"keeps an explicitly registered %s so the client receives a client secret",
async (method) => {
const { handleAuth } = await import("./auth");
await handleAuth(
new Request("http://localhost:3000/api/auth/oauth2/register", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
redirect_uris: ["https://example.com/callback"],
token_endpoint_auth_method: method,
}),
}),
);
const forwarded = mocks.handler.mock.calls[0]?.[0] as Request;
await expect(forwarded.json()).resolves.toMatchObject({ token_endpoint_auth_method: method });
},
);
it.each([
{ redirect_uris: ["https://example.com/callback"] },
{ redirect_uris: ["http://localhost.evil.example/callback"] },
{ redirect_uris: ["http://localhost:3210/callback"], application_type: "web" },
{ redirect_uris: ["http://localhost:3210/callback", "https://example.com/callback"] },
])("does not infer native for explicit web or non-loopback clients: %j", async (body) => {
const { handleAuth } = await import("./auth");
mocks.env.FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI = true;
await handleAuth(
new Request("http://localhost:3000/api/auth/oauth2/register", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
);
const forwarded = mocks.handler.mock.calls[0]?.[0] as Request;
expect((await forwarded.json()).application_type).not.toBe("native");
});
it("preserves repeated resource indicators during authorization sanitization", async () => {
const { handleAuth } = await import("./auth");
await handleAuth(
new Request(
"http://localhost:3000/api/auth/oauth2/authorize?resource=http%3A%2F%2Flocalhost%3A3000&resource=http%3A%2F%2Flocalhost%3A3000%2Fmcp",
),
);
const forwarded = mocks.handler.mock.calls[0]?.[0] as Request;
expect(new URL(forwarded.url).searchParams.getAll("resource")).toEqual([
"http://localhost:3000",
"http://localhost:3000/mcp",
]);
});
});
describe("handleOAuth", () => {
it("redirects unauthenticated users to the same-origin login route", async () => {
const { handleOAuth } = await import("./auth");
mocks.getSession.mockResolvedValueOnce(null);
const response = await handleOAuth(
new Request(
"http://localhost:3001/api/auth/oauth?client_id=test-client&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&state=abc&exp=123&sig=456",
),
);
expect(response.status).toBe(302);
const location = response.headers.get("Location");
expect(location).toMatch(/^\/auth\/login\?/);
const loginUrl = new URL(location ?? "", "http://localhost:3000");
const callbackUrl = new URL(loginUrl.searchParams.get("callbackURL") ?? "", "http://localhost:3000");
expect(loginUrl.origin).toBe("http://localhost:3000");
expect(callbackUrl.pathname).toBe("/api/auth/oauth");
expect(callbackUrl.searchParams.get("client_id")).toBe("test-client");
expect(callbackUrl.searchParams.get("redirect_uri")).toBe("https://example.com/callback");
expect(callbackUrl.searchParams.get("state")).toBe("abc");
expect(callbackUrl.searchParams.get("exp")).toBe("123");
expect(callbackUrl.searchParams.get("sig")).toBe("456");
});
it("continues signed authorization without approving consent on GET", async () => {
const { handleOAuth } = await import("./auth");
mocks.getSession.mockResolvedValueOnce({ user: { id: "owner" } });
mocks.continueOAuth.mockResolvedValueOnce(
Response.json({ redirect: true, url: "/auth/consent?client_id=client&sig=signed" }),
);
const query = "client_id=client&resource=one&resource=two&exp=123&sig=456";
const response = await handleOAuth(new Request(`http://localhost:3000/api/auth/oauth?${query}`));
expect(mocks.continueOAuth).toHaveBeenCalledWith(
expect.objectContaining({ body: { postLogin: true, oauth_query: query } }),
);
expect(mocks.consent).not.toHaveBeenCalled();
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/auth/consent?client_id=client&sig=signed");
});
it("preserves provider failures instead of issuing an authorization code", async () => {
const { handleOAuth } = await import("./auth");
mocks.getSession.mockResolvedValueOnce({ user: { id: "owner" } });
mocks.continueOAuth.mockResolvedValueOnce(Response.json({ error: "invalid_signature" }, { status: 400 }));
const response = await handleOAuth(new Request("http://localhost:3000/api/auth/oauth?sig=invalid"));
expect(response.status).toBe(400);
expect(response.headers.get("location")).toBeNull();
await expect(response.json()).resolves.toEqual({ error: "invalid_signature" });
});
it("preserves provider cookies and cache headers on forced reauthentication", async () => {
const { handleOAuth } = await import("./auth");
mocks.getSession.mockResolvedValueOnce({ user: { id: "owner" } });
const headers = new Headers({ "cache-control": "no-store", "content-length": "123" });
headers.append("set-cookie", "oauth_state=state; Path=/; HttpOnly");
headers.append("set-cookie", "session=refreshed; Path=/; HttpOnly");
mocks.continueOAuth.mockResolvedValueOnce(
Response.json({ redirect: true, url: "/api/auth/oauth?prompt=login&sig=signed" }, { headers }),
);
const response = await handleOAuth(new Request("http://localhost:3000/api/auth/oauth?sig=original"));
expect(response.status).toBe(302);
expect(response.headers.get("location")).toMatch(/^\/auth\/login\?reauthenticate=true&/);
expect(response.headers.getSetCookie()).toEqual(headers.getSetCookie());
expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.headers.get("content-type")).toBeNull();
expect(response.headers.get("content-length")).toBeNull();
});
});
describe("OAuth provider response validation", () => {
it.for([{}, { url: null }, { url: 7 }, { url: "" }, { url: "undefined" }, { url: "javascript:alert(1)" }])(
"fails closed for malformed provider response %j",
async (body) => {
const { handleOAuth } = await import("./auth");
mocks.getSession.mockResolvedValueOnce({ user: { id: "owner" } });
mocks.continueOAuth.mockResolvedValueOnce(Response.json(body));
const response = await handleOAuth(new Request("http://localhost:3000/api/auth/oauth?sig=signed"));
expect(response.status).toBe(502);
expect(response.headers.get("location")).toBeNull();
expect(mocks.consent).not.toHaveBeenCalled();
},
);
});
+211
View File
@@ -0,0 +1,211 @@
import { APIError } from "better-auth/api";
import { auth } from "@reactive-resume/auth/config";
import { env } from "@reactive-resume/env/server";
import { isAllowedOAuthRedirectUri } from "@reactive-resume/utils/url-security.node";
const oauthAuthorizeSanitizedParams = [
"prompt",
"redirect_uri",
"client_id",
"code_challenge",
"code_challenge_method",
"response_type",
"scope",
"state",
"resource",
] as const;
function sanitizeOAuthAuthorizeRequest(request: Request): Request {
if (request.method !== "GET") return request;
const url = new URL(request.url);
if (!url.pathname.endsWith("/oauth2/authorize")) return request;
const sanitizeValue = (value: string) =>
value
.replace(/[\r\n\t]+/g, " ")
.replace(/\s+/g, " ")
.trim();
const sanitizeParam = (key: string) => {
const values = url.searchParams.getAll(key);
if (!values.length) return;
url.searchParams.delete(key);
for (const value of values) url.searchParams.append(key, sanitizeValue(value));
};
for (const key of oauthAuthorizeSanitizedParams) sanitizeParam(key);
const redirectUri = url.searchParams.get("redirect_uri");
if (redirectUri && !URL.canParse(redirectUri)) {
try {
const decodedRedirectUri = decodeURIComponent(redirectUri);
if (URL.canParse(decodedRedirectUri)) {
url.searchParams.set("redirect_uri", decodedRedirectUri);
}
} catch {
// Ignore malformed encoded values and let Better Auth validation handle them.
}
}
if (url.toString() === request.url) return request;
return new Request(url.toString(), request);
}
function isRegistrationPayload(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function defaultPublicClientRegistration(request: Request): Promise<Request> {
if (request.method !== "POST") return request;
const url = new URL(request.url);
if (!url.pathname.endsWith("/oauth2/register")) return request;
const cloned = request.clone();
let body: Record<string, unknown>;
try {
const payload: unknown = await cloned.json();
if (!isRegistrationPayload(payload)) return request;
body = payload;
} catch {
return request;
}
// MCP native clients often omit OIDC application_type. Infer it only for
// exact HTTP loopback callbacks; the provider still validates every URI.
if (body.application_type === undefined && Array.isArray(body.redirect_uris) && body.redirect_uris.length > 0) {
const allLoopback = body.redirect_uris.every(
(uri: unknown) =>
typeof uri === "string" && /^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]+)?(?:[/?]|$)/i.test(uri),
);
if (allLoopback) body.application_type = "native";
}
// MCP clients that authenticate with PKCE alone omit the method, and Better Auth
// would otherwise register them as `client_secret_basic`. Honor an explicit choice:
// forcing it to "none" issues no client secret, so the client's own Basic/post
// credentials are rejected at the token endpoint with 401 invalid_client.
if (!request.headers.get("authorization")) {
body.token_endpoint_auth_method ??= "none";
}
return new Request(url.toString(), {
method: request.method,
headers: request.headers,
body: JSON.stringify(body),
});
}
async function validateDynamicClientRegistrationRequest(request: Request): Promise<Response | undefined> {
if (request.method !== "POST") return;
const url = new URL(request.url);
if (!url.pathname.endsWith("/oauth2/register")) return;
const cloned = request.clone();
let body: Record<string, unknown>;
try {
const payload: unknown = await cloned.json();
if (!isRegistrationPayload(payload)) {
return Response.json({ message: "Invalid registration payload" }, { status: 400 });
}
body = payload;
} catch {
return Response.json({ message: "Invalid registration payload" }, { status: 400 });
}
const oauthTrustedOrigins = [new URL(env.APP_URL).origin.toLowerCase()];
const redirectUris = Array.isArray(body.redirect_uris) ? body.redirect_uris : [];
for (const redirectUri of redirectUris) {
if (
typeof redirectUri !== "string" ||
!isAllowedOAuthRedirectUri(redirectUri, oauthTrustedOrigins, {
allowUnsafe: env.FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI,
})
) {
return Response.json(
{ error: "invalid_redirect_uri", error_description: "redirect_uri is not allowed" },
{ status: 400 },
);
}
}
}
export async function handleAuth(request: Request) {
const registrationValidationError = await validateDynamicClientRegistrationRequest(request);
if (registrationValidationError) return registrationValidationError;
const sanitizedRequest = sanitizeOAuthAuthorizeRequest(request);
const finalRequest = await defaultPublicClientRegistration(sanitizedRequest);
return auth.handler(finalRequest);
}
export async function handleOAuth(request: Request) {
try {
return await resumeOAuth(request);
} catch (error) {
// Before-hooks can throw even when the provider is called with asResponse.
if (error instanceof APIError) return Response.json(error.body, { status: error.statusCode });
throw error;
}
}
async function resumeOAuth(request: Request) {
const session = await auth.api.getSession({ headers: request.headers });
const url = new URL(request.url);
if (session?.user) {
// Resume authorization without granting consent. The provider decides whether
// the user must sign in, explicitly approve a client, or reuse an existing grant.
// Its signed query must survive the login round trip byte-for-byte.
const response = await auth.api.oauth2Continue({
asResponse: true,
request,
headers: request.headers,
body: { postLogin: true, oauth_query: url.search.slice(1) },
});
if (!(response instanceof Response)) throw new Error("OAuth provider did not return a response");
if (!response.ok) return response;
const result: unknown = await response.json().catch(() => null);
if (
!result ||
typeof result !== "object" ||
!("url" in result) ||
typeof result.url !== "string" ||
!result.url ||
!(result.url.startsWith("/") || URL.canParse(result.url)) ||
!URL.canParse(result.url, env.APP_URL)
)
return Response.json({ error: "invalid_provider_response" }, { status: 502 });
const headers = new Headers(response.headers);
headers.delete("content-type");
headers.delete("content-length");
const target = new URL(result.url, env.APP_URL);
if (["javascript:", "data:", "vbscript:", "file:", "blob:"].includes(target.protocol)) {
return Response.json({ error: "invalid_provider_response" }, { status: 502 });
}
if (target.origin === new URL(env.APP_URL).origin && target.pathname === "/api/auth/oauth") {
return redirectToOAuthLogin(target, true, headers);
}
headers.set("Location", result.url);
return new Response(null, { status: 302, headers });
}
return redirectToOAuthLogin(url);
}
function redirectToOAuthLogin(url: URL, reauthenticate = false, headers = new Headers()) {
const prompt = new Set(url.searchParams.get("prompt")?.split(" ") ?? []);
const loginUrl = new URL(prompt.has("create") ? "/auth/register" : "/auth/login", env.APP_URL);
if (reauthenticate) loginUrl.searchParams.set("reauthenticate", "true");
loginUrl.searchParams.set("callbackURL", `/api/auth/oauth${url.search}`);
headers.set("Location", `${loginUrl.pathname}${loginUrl.search}`);
return new Response(null, {
status: 302,
headers,
});
}
+22
View File
@@ -0,0 +1,22 @@
export function getCookie(request: Request, name: string): string | undefined {
const cookieHeader = request.headers.get("cookie");
if (!cookieHeader) return;
for (const part of cookieHeader.split(";")) {
const [rawName, ...rawValue] = part.trim().split("=");
if (rawName === name && rawValue.length > 0) return rawValue.join("=");
}
}
export function mergeResponseHeaders(response: Response, headers: Headers): Response {
if ([...headers].length === 0) return response;
const nextHeaders = new Headers(response.headers);
for (const [key, value] of headers) nextHeaders.append(key, value);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: nextHeaders,
});
}
+96
View File
@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { execute, healthcheck } = vi.hoisted(() => ({ execute: vi.fn(), healthcheck: vi.fn() }));
vi.mock("@reactive-resume/db/client", () => ({ db: { execute } }));
vi.mock("@reactive-resume/api/features/storage", () => ({ getStorageService: () => ({ healthcheck }) }));
vi.mock("../app-version", () => ({ appVersion: "9.8.7" }));
import { handleHealth } from "./health";
describe("health version reporting", () => {
beforeEach(() => {
execute.mockResolvedValue([]);
healthcheck.mockResolvedValue({ status: "healthy" });
});
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it("reports the built application version when launched directly by Node", async () => {
vi.stubEnv("npm_package_version", undefined);
const response = await handleHealth();
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ service: "reactive-resume", version: "9.8.7", status: "healthy" });
});
it("ignores a package manager's workspace package version", async () => {
vi.stubEnv("npm_package_version", "0.0.0");
expect(await (await handleHealth()).json()).toMatchObject({ version: "9.8.7" });
});
it("keeps the version available when a dependency is unhealthy", async () => {
vi.stubEnv("npm_package_version", undefined);
execute.mockRejectedValueOnce(new Error("Database unavailable"));
vi.spyOn(console, "warn").mockImplementation(() => {});
const response = await handleHealth();
expect(response.status).toBe(503);
expect(await response.json()).toMatchObject({ version: "9.8.7", status: "unhealthy" });
});
it.each(["database", "storage"])("keeps thrown %s error details in server logs only", async (dependency) => {
const detail = "Connection failed for private-user at internal.example:5432";
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
(dependency === "database" ? execute : healthcheck).mockRejectedValueOnce(new Error(detail));
const response = await handleHealth();
const body = await response.json();
expect(response.status).toBe(503);
expect(JSON.stringify(body)).not.toContain(detail);
expect(body[dependency]).toMatchObject({
status: "unhealthy",
error: expect.stringContaining("health check failed"),
});
expect(warn).toHaveBeenCalledWith(
"[Healthcheck]",
expect.objectContaining({
[dependency]: expect.objectContaining({ error: detail }),
}),
);
});
it("redacts returned storage failures while preserving diagnostics in server logs", async () => {
const detail = "Access denied to bucket private-bucket on internal.example";
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
healthcheck.mockResolvedValueOnce({
status: "unhealthy",
type: "s3",
message: detail,
error: detail,
internalDetail: detail,
});
const response = await handleHealth();
const body = await response.json();
expect(response.status).toBe(503);
expect(body.storage).toEqual({
status: "unhealthy",
type: "s3",
latencyMs: expect.any(Number),
error: "Storage health check failed.",
});
expect(JSON.stringify(body)).not.toContain(detail);
expect(warn).toHaveBeenCalledWith(
"[Healthcheck]",
expect.objectContaining({ storage: expect.objectContaining({ error: detail, message: detail }) }),
);
});
});
+92
View File
@@ -0,0 +1,92 @@
import { sql } from "drizzle-orm";
import { withTimeout } from "es-toolkit";
import { getStorageService } from "@reactive-resume/api/features/storage";
import { db } from "@reactive-resume/db/client";
import { getRedis } from "@reactive-resume/db/redis";
import { appVersion } from "../app-version";
const HEALTHCHECK_TIMEOUT_MS = 1_500;
type CheckResult = {
status: "healthy" | "unhealthy";
latencyMs: number;
error?: string;
[key: string]: unknown;
};
// ponytail: es-toolkit withTimeout takes a fn, not a promise — call site passes check (not check())
async function runCheck(check: () => Promise<object>): Promise<CheckResult> {
const startedAt = performance.now();
try {
const data = await withTimeout(check, HEALTHCHECK_TIMEOUT_MS);
const latencyMs = Math.round(performance.now() - startedAt);
const result = data as { status?: string };
if (result.status === "unhealthy") return { ...(data as object), status: "unhealthy", latencyMs };
return { ...(data as object), status: "healthy", latencyMs };
} catch (error) {
return {
status: "unhealthy",
error: error instanceof Error ? error.message : "Unknown error",
latencyMs: Math.round(performance.now() - startedAt),
};
}
}
function publicCheck(check: CheckResult, name: "Database" | "Storage" | "Redis"): CheckResult {
if (check.status === "healthy") return check;
return {
status: check.status,
latencyMs: check.latencyMs,
error: `${name} health check failed.`,
...(check.type === "local" || check.type === "s3" || check.type === "blob" ? { type: check.type } : {}),
};
}
// ponytail: inner try/catches removed; runCheck's outer catch handles all errors
async function checkDatabase() {
await db.execute(sql`SELECT 1`);
return { status: "healthy" };
}
const checkStorage = () => getStorageService().healthcheck();
export async function handleHealth() {
const redisClient = getRedis();
const [database, storage, redis] = await Promise.all([
runCheck(checkDatabase),
runCheck(checkStorage),
redisClient
? runCheck(async () => {
await redisClient.ping();
return { status: "healthy" };
})
: undefined,
]);
const status = [database, storage, redis].some((check) => check?.status === "unhealthy") ? "unhealthy" : "healthy";
const checks = {
service: "reactive-resume",
version: appVersion,
status,
timestamp: new Date().toISOString(),
uptime: `${process.uptime().toFixed(2)}s`,
database: publicCheck(database, "Database"),
storage: publicCheck(storage, "Storage"),
...(redis ? { redis: publicCheck(redis, "Redis") } : {}),
};
if (status === "unhealthy") {
console.warn("[Healthcheck]", { route: "/api/health", database, storage });
}
const headers = new Headers();
const body = JSON.stringify(checks);
headers.set("Content-Type", "application/json; charset=UTF-8");
headers.set("Content-Length", Buffer.byteLength(body, "utf-8").toString());
return new Response(body, {
headers,
status: checks.status === "unhealthy" ? 503 : 200,
});
}
@@ -0,0 +1,337 @@
import { createHash, randomBytes } from "node:crypto";
import { describe, expect, it, vi } from "vitest";
vi.mock("@reactive-resume/email/transport", () => ({ sendEmail: vi.fn() }));
// Run only against an explicitly supplied disposable database, after applying migrations.
const databaseURL = process.env.OAUTH_TEST_DATABASE_URL;
describe.skipIf(!databaseURL)("MCP OAuth flow with PostgreSQL", () => {
it("registers public clients, resumes login, and exchanges a resource-bound PKCE code", async () => {
if (!databaseURL) return;
process.env.DATABASE_URL = databaseURL;
process.env.APP_URL = "http://localhost:33920";
process.env.AUTH_SECRET = "oauth-integration-test-secret-only";
const { handleAuth, handleOAuth } = await import("./auth");
// Better Auth disables origin checks by default in test mode; exercise production behavior.
const { auth } = await import("@reactive-resume/auth/config");
(await auth.$context).skipOriginCheck = false;
const origin = process.env.APP_URL;
const redirectURI = "http://127.0.0.1:33921/callback";
const request = (path: string, body: object, cookie = "") =>
new Request(`${origin}/api/auth/${path}`, {
method: "POST",
headers: { "content-type": "application/json", origin, cookie },
body: JSON.stringify(body),
});
const registration = await handleAuth(
request("oauth2/register", { client_name: "OAuth integration", redirect_uris: [redirectURI] }),
);
expect(registration.status, await registration.clone().text()).toBe(201);
const client = await registration.json();
expect(client.token_endpoint_auth_method).toBe("none");
const deniedRegistration = await handleAuth(
request("oauth2/register", {
client_name: "Denied resource",
redirect_uris: [redirectURI],
resources: ["https://untrusted.example/mcp"],
}),
);
expect(deniedRegistration.status).toBe(400);
await expect(deniedRegistration.json()).resolves.toMatchObject({ error: "invalid_target" });
const verifier = randomBytes(32).toString("base64url");
const query = new URLSearchParams({
client_id: client.client_id,
redirect_uri: redirectURI,
response_type: "code",
scope: "openid profile offline_access",
code_challenge: createHash("sha256").update(verifier).digest("base64url"),
code_challenge_method: "S256",
resource: `${origin}/mcp`,
state: "opaque-state",
});
const authorize = await handleAuth(new Request(`${origin}/api/auth/oauth2/authorize?${query}`));
expect(authorize.status, await authorize.clone().text()).toBe(302);
const bridgeURL = authorize.headers.get("location");
expect(bridgeURL).toBeTruthy();
const login = await handleOAuth(new Request(new URL(bridgeURL ?? "", origin)));
const loginURL = new URL(login.headers.get("location") ?? "", origin);
const callbackURL = loginURL.searchParams.get("callbackURL");
expect(callbackURL).toContain("sig=");
expect(callbackURL).toContain("resource=");
const unique = randomBytes(6).toString("hex");
const signup = await handleAuth(
request("sign-up/email", {
name: "OAuth Test",
email: `oauth-${unique}@example.com`,
username: `oauth-${unique}`,
password: "password123",
}),
);
expect(signup.status, await signup.clone().text()).toBe(200);
const cookie = signup.headers
.getSetCookie()
.map((value) => value.split(";", 1)[0])
.join("; ");
const tamperedURL = new URL(`${origin}${callbackURL}`);
tamperedURL.searchParams.set("state", "tampered");
const tampered = await handleOAuth(new Request(tamperedURL, { headers: { cookie } }));
expect(tampered.status).toBe(400);
await expect(tampered.json()).resolves.toMatchObject({ error: "invalid_signature" });
const callback = await handleOAuth(new Request(`${origin}${callbackURL}`, { headers: { cookie } }));
expect(callback.status, await callback.clone().text()).toBe(302);
const consentURL = new URL(callback.headers.get("location") ?? "", origin);
expect(consentURL.pathname).toBe("/auth/consent");
expect(consentURL.searchParams.has("code")).toBe(false);
const oauth_query = consentURL.search.slice(1);
const consents = async () => {
const response = await handleAuth(new Request(`${origin}/api/auth/oauth2/get-consents`, { headers: { cookie } }));
expect(response.status).toBe(200);
return response.json();
};
expect(await consents()).toEqual([]);
const silent = await handleAuth(
new Request(`${origin}/api/auth/oauth2/authorize?${query}&prompt=none`, { headers: { cookie } }),
);
expect(new URL(silent.headers.get("location") ?? "").searchParams.get("error")).toBe("consent_required");
const tamperedConsentQuery = new URLSearchParams(oauth_query);
tamperedConsentQuery.set("scope", "openid profile email offline_access");
const tamperedConsent = await handleAuth(
request(
"oauth2/consent",
{
accept: true,
oauth_query: tamperedConsentQuery.toString(),
},
cookie,
),
);
expect(tamperedConsent.status).toBe(400);
expect(await consents()).toEqual([]);
const csrf = await handleAuth(
new Request(`${origin}/api/auth/oauth2/consent`, {
method: "POST",
headers: { cookie, origin: "https://untrusted.example", "content-type": "application/json" },
body: JSON.stringify({ accept: true, oauth_query }),
}),
);
expect(csrf.status).toBe(403);
const denied = await handleAuth(request("oauth2/consent", { accept: false, oauth_query }, cookie));
expect(denied.status, await denied.clone().text()).toBe(200);
const deniedURL = new URL((await denied.json()).url);
expect(deniedURL.searchParams.get("error")).toBe("access_denied");
expect(deniedURL.searchParams.get("state")).toBe("opaque-state");
expect(deniedURL.searchParams.has("code")).toBe(false);
expect(await consents()).toEqual([]);
const accepted = await handleAuth(request("oauth2/consent", { accept: true, oauth_query }, cookie));
expect(accepted.status, await accepted.clone().text()).toBe(200);
expect(await consents()).toHaveLength(1);
const codeURL = new URL((await accepted.json()).url);
expect(codeURL.origin).toBe(new URL(redirectURI).origin);
expect(codeURL.searchParams.get("state")).toBe("opaque-state");
const code = codeURL.searchParams.get("code");
expect(code).toBeTruthy();
const tokenRequest = () =>
new Request(`${origin}/api/auth/oauth2/token`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: client.client_id,
code: code ?? "",
redirect_uri: redirectURI,
code_verifier: verifier,
resource: `${origin}/mcp`,
}),
});
const tokenResponse = await handleAuth(tokenRequest());
expect(tokenResponse.status, await tokenResponse.clone().text()).toBe(200);
const token = await tokenResponse.json();
expect(token.access_token).toBeTruthy();
expect(token.refresh_token).toBeTruthy();
const claims = JSON.parse(Buffer.from(token.access_token.split(".")[1], "base64url").toString());
expect([claims.aud].flat()).toContain(`${origin}/mcp`);
expect((await handleAuth(tokenRequest())).status).toBe(400);
}, 30_000);
it("exchanges a code for a confidential client that registered client_secret_basic", async () => {
if (!databaseURL) return;
process.env.DATABASE_URL = databaseURL;
process.env.APP_URL = "http://localhost:33920";
process.env.AUTH_SECRET = "oauth-integration-test-secret-only";
const { handleAuth, handleOAuth } = await import("./auth");
const origin = process.env.APP_URL;
const redirectURI = "http://127.0.0.1:33921/callback";
const request = (path: string, body: object, cookie = "") =>
new Request(`${origin}/api/auth/${path}`, {
method: "POST",
headers: { "content-type": "application/json", origin, cookie },
body: JSON.stringify(body),
});
const registration = await handleAuth(
request("oauth2/register", {
client_name: "Confidential MCP client",
redirect_uris: [redirectURI],
token_endpoint_auth_method: "client_secret_basic",
}),
);
expect(registration.status, await registration.clone().text()).toBe(201);
const client = await registration.json();
// Downgrading this to a public client leaves the client without a secret, and its
// Basic-authenticated token exchange then fails with 401 invalid_client.
expect(client.token_endpoint_auth_method).toBe("client_secret_basic");
expect(client.client_secret).toBeTruthy();
const verifier = randomBytes(32).toString("base64url");
const query = new URLSearchParams({
client_id: client.client_id,
redirect_uri: redirectURI,
response_type: "code",
scope: "openid profile offline_access",
code_challenge: createHash("sha256").update(verifier).digest("base64url"),
code_challenge_method: "S256",
resource: `${origin}/mcp`,
state: "opaque-state",
});
const authorize = await handleAuth(new Request(`${origin}/api/auth/oauth2/authorize?${query}`));
const login = await handleOAuth(new Request(new URL(authorize.headers.get("location") ?? "", origin)));
const callbackURL = new URL(login.headers.get("location") ?? "", origin).searchParams.get("callbackURL");
const unique = randomBytes(6).toString("hex");
const signup = await handleAuth(
request("sign-up/email", {
name: "Confidential Test",
email: `confidential-${unique}@example.com`,
username: `confidential-${unique}`,
password: "password123",
}),
);
expect(signup.status, await signup.clone().text()).toBe(200);
const cookie = signup.headers
.getSetCookie()
.map((value) => value.split(";", 1)[0])
.join("; ");
const callback = await handleOAuth(new Request(`${origin}${callbackURL}`, { headers: { cookie } }));
const oauth_query = new URL(callback.headers.get("location") ?? "", origin).search.slice(1);
const accepted = await handleAuth(request("oauth2/consent", { accept: true, oauth_query }, cookie));
expect(accepted.status, await accepted.clone().text()).toBe(200);
const code = new URL((await accepted.json()).url).searchParams.get("code");
const tokenResponse = await handleAuth(
new Request(`${origin}/api/auth/oauth2/token`, {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
authorization: `Basic ${Buffer.from(`${client.client_id}:${client.client_secret}`).toString("base64")}`,
},
body: new URLSearchParams({
grant_type: "authorization_code",
code: code ?? "",
redirect_uri: redirectURI,
code_verifier: verifier,
resource: `${origin}/mcp`,
}),
}),
);
expect(tokenResponse.status, await tokenResponse.clone().text()).toBe(200);
await expect(tokenResponse.json()).resolves.toMatchObject({ token_type: "Bearer" });
}, 30_000);
it.each(["login", "max-age", "create"])(
"requires fresh authentication for %s without looping",
async (mode) => {
if (!databaseURL) return;
process.env.DATABASE_URL = databaseURL;
process.env.APP_URL = "http://localhost:33920";
process.env.AUTH_SECRET = "oauth-integration-test-secret-only";
const { handleAuth, handleOAuth } = await import("./auth");
const origin = process.env.APP_URL;
const cookieOf = (response: Response) =>
response.headers
.getSetCookie()
.map((value) => value.split(";", 1)[0])
.join("; ");
const post = (path: string, body: object, cookie = "") =>
handleAuth(
new Request(`${origin}/api/auth/${path}`, {
method: "POST",
headers: { "content-type": "application/json", origin, cookie },
body: JSON.stringify(body),
}),
);
const unique = randomBytes(6).toString("hex");
const credentials = {
name: "Reauth Test",
email: `reauth-${unique}@example.com`,
username: `reauth-${unique}`,
password: "password123",
};
const existingSignup = await post("sign-up/email", credentials);
expect(existingSignup.status).toBe(200);
const oldCookie = cookieOf(existingSignup);
const registration = await post("oauth2/register", {
client_name: "Reauth integration",
redirect_uris: ["http://127.0.0.1:33921/callback"],
});
expect(registration.status).toBe(201);
const client = await registration.json();
const query = new URLSearchParams({
client_id: client.client_id,
redirect_uri: "http://127.0.0.1:33921/callback",
response_type: "code",
scope: "openid profile",
resource: `${origin}/mcp`,
code_challenge: createHash("sha256").update(randomBytes(32)).digest("base64url"),
code_challenge_method: "S256",
...(mode === "max-age" ? { max_age: "0" } : { prompt: mode }),
});
const authorization = await handleAuth(
new Request(`${origin}/api/auth/oauth2/authorize?${query}`, { headers: { cookie: oldCookie } }),
);
expect(authorization.status).toBe(302);
const bridge = await handleOAuth(
new Request(new URL(authorization.headers.get("location") ?? "", origin), { headers: { cookie: oldCookie } }),
);
expect(bridge.status).toBe(302);
const loginURL = new URL(bridge.headers.get("location") ?? "", origin);
expect(loginURL.pathname).toBe(mode === "create" ? "/auth/register" : "/auth/login");
expect(loginURL.searchParams.get("reauthenticate")).toBe("true");
const callbackURL = new URL(loginURL.searchParams.get("callbackURL") ?? "", origin);
const oauth_query = callbackURL.search.slice(1);
const authenticated =
mode === "create"
? await post(
"sign-up/email",
{ ...credentials, email: `new-${unique}@example.com`, username: `new-${unique}` },
oldCookie,
)
: await post(
"sign-in/email",
{ email: credentials.email, password: credentials.password, oauth_query },
oldCookie,
);
expect(authenticated.status, await authenticated.clone().text()).toBe(200);
const newCookie = cookieOf(authenticated);
expect(newCookie).not.toBe(oldCookie);
const continuation =
mode === "create" ? await post("oauth2/continue", { created: true, oauth_query }, newCookie) : authenticated;
expect(continuation.status, await continuation.clone().text()).toBe(200);
const result = await continuation.json();
let target = new URL(result.url, origin);
if (target.pathname === "/api/auth/oauth") {
const response = await handleOAuth(new Request(target, { headers: { cookie: newCookie } }));
expect(response.status, await response.clone().text()).toBe(302);
target = new URL(response.headers.get("location") ?? "", origin);
}
expect(target.pathname).toBe("/auth/consent");
const accepted = await post("oauth2/consent", { accept: true, oauth_query: target.search.slice(1) }, newCookie);
expect(accepted.status, await accepted.clone().text()).toBe(200);
target = new URL((await accepted.json()).url, origin);
expect(target.origin).toBe("http://127.0.0.1:33921");
expect(target.searchParams.get("code")).toBeTruthy();
},
30_000,
);
});
@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
createPublicResumePdf: vi.fn(),
}));
vi.mock("@reactive-resume/api/features/resume/public-pdf", () => ({
createPublicResumePdf: mocks.createPublicResumePdf,
}));
const { handlePublicResumePdf } = await import("./public-resume-pdf");
const trustedClient = "203.0.113.9";
describe("handlePublicResumePdf", () => {
beforeEach(() => vi.clearAllMocks());
it("returns the authorized on-demand PDF without forwarding compatibility metadata", async () => {
const body = new File(["%PDF"], "Ada_Lovelace.pdf", { type: "text/plain" });
mocks.createPublicResumePdf.mockResolvedValueOnce({
body,
filename: "Ada_Lovelace.pdf",
});
const request = new Request("https://example.com/api/resumes/jane/resume/pdf?ignored=true", {
headers: { "x-forwarded-for": "203.0.113.7" },
});
const response = await handlePublicResumePdf(request, "jane", "resume", trustedClient);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("application/pdf");
expect(response.headers.get("Content-Disposition")).toBe('inline; filename="Ada_Lovelace.pdf"');
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
expect(await response.text()).toBe("%PDF");
expect(mocks.createPublicResumePdf).toHaveBeenCalledWith({
username: "jane",
slug: "resume",
requestHeaders: request.headers,
trustedClient,
});
});
it("keeps password and private responses uncacheable", async () => {
mocks.createPublicResumePdf.mockResolvedValueOnce({
body: new File(["%PDF"], "resume.pdf", { type: "application/pdf" }),
filename: "resume.pdf",
});
const request = new Request("https://example.com/api/resumes/jane/resume/pdf");
const response = await handlePublicResumePdf(request, "jane", "resume", trustedClient);
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
expect(mocks.createPublicResumePdf).toHaveBeenCalledWith({
username: "jane",
slug: "resume",
requestHeaders: request.headers,
trustedClient,
});
});
it.each([
[{ code: "NEED_PASSWORD" }, 401],
[{ code: "NOT_FOUND" }, 404],
[{ code: "RATE_LIMIT_EXCEEDED" }, 429],
[{ code: "INTERNAL_SERVER_ERROR" }, 500],
])("maps controlled API errors without caching the response", async (error, status) => {
mocks.createPublicResumePdf.mockRejectedValueOnce(error);
const response = await handlePublicResumePdf(
new Request("https://example.com/api/resumes/jane/resume/pdf"),
"jane",
"resume",
trustedClient,
);
expect(response.status).toBe(status);
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
});
});
+44
View File
@@ -0,0 +1,44 @@
import { createPublicResumePdf } from "@reactive-resume/api/features/resume/public-pdf";
const noStoreResponse = (body: string, status: number) =>
new Response(body, { status, headers: { "Cache-Control": "private, no-store" } });
const errorStatus = (error: unknown): number => {
const code = typeof error === "object" && error && "code" in error ? (error as { code?: unknown }).code : undefined;
if (code === "NEED_PASSWORD") return 401;
if (code === "NOT_FOUND") return 404;
if (code === "RATE_LIMIT_EXCEEDED") return 429;
return 500;
};
export async function handlePublicResumePdf(
request: Request,
username: string,
slug: string,
trustedClient = "unknown",
): Promise<Response> {
try {
const result = await createPublicResumePdf({
username,
slug,
requestHeaders: request.headers,
trustedClient,
});
return new Response(result.body, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `inline; filename="${result.filename.replaceAll('"', "")}"`,
"Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff",
},
});
} catch (error) {
const status = errorStatus(error);
if (status === 500) console.error("Public resume PDF generation failed", error);
return noStoreResponse(
status === 500 ? "Failed to generate public resume PDF" : "Public resume PDF unavailable",
status,
);
}
}
+153
View File
@@ -0,0 +1,153 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
createResumePdfDownload: vi.fn(),
verifyResumePdfDownloadToken: vi.fn(),
}));
vi.mock("@reactive-resume/api/features/resume/export", () => ({
createResumePdfDownload: mocks.createResumePdfDownload,
verifyResumePdfDownloadToken: mocks.verifyResumePdfDownloadToken,
}));
const { handleResumePdfDownload } = await import("./resume-pdf");
describe("handleResumePdfDownload", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders the PDF when the signed URL token is valid", async () => {
const pdf = new File([new Uint8Array([37, 80, 68, 70])], "Scizor.pdf", { type: "application/pdf" });
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({
ok: true,
resumeId: "resume-1",
userId: "user-1",
target: "resume",
expiresAt: "2026-06-01T10:10:00.000Z",
});
mocks.createResumePdfDownload.mockResolvedValueOnce({
headers: { "content-disposition": 'attachment; filename="Scizor.pdf"' },
body: pdf,
});
const response = await handleResumePdfDownload(
new Request("https://example.com/api/resumes/resume-1/pdf?token=signed"),
"resume-1",
);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("application/pdf");
expect(response.headers.get("Content-Disposition")).toBe('attachment; filename="Scizor.pdf"');
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
expect(await response.text()).toBe("%PDF");
expect(mocks.createResumePdfDownload).toHaveBeenCalledWith({ id: "resume-1", userId: "user-1", target: "resume" });
});
it("passes the cover letter target through to PDF rendering", async () => {
const pdf = new File([new Uint8Array([37, 80, 68, 70])], "Cover Letter.pdf", { type: "application/pdf" });
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({
ok: true,
resumeId: "resume-1",
userId: "user-1",
target: "cover-letter",
expiresAt: "2026-06-01T10:10:00.000Z",
});
mocks.createResumePdfDownload.mockResolvedValueOnce({
headers: { "content-disposition": 'attachment; filename="Cover Letter.pdf"' },
body: pdf,
});
await handleResumePdfDownload(
new Request("https://example.com/api/resumes/resume-1/pdf?token=signed&target=cover-letter"),
"resume-1",
);
expect(mocks.createResumePdfDownload).toHaveBeenCalledWith({
id: "resume-1",
userId: "user-1",
target: "cover-letter",
});
});
it("defaults a legacy token without a target to resume", async () => {
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({
ok: true,
resumeId: "resume-1",
userId: "user-1",
expiresAt: "2026-06-01T10:10:00.000Z",
});
mocks.createResumePdfDownload.mockResolvedValueOnce({
headers: { "content-disposition": 'attachment; filename="Cover Letter.pdf"' },
body: new File([], "Cover Letter.pdf", { type: "application/pdf" }),
});
await handleResumePdfDownload(new Request("https://example.com/api/resumes/resume-1/pdf?token=legacy"), "resume-1");
expect(mocks.createResumePdfDownload).toHaveBeenCalledWith({
id: "resume-1",
userId: "user-1",
target: "resume",
});
});
it("rejects a cover-letter target for a legacy token without one", async () => {
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({
ok: true,
resumeId: "resume-1",
userId: "user-1",
expiresAt: "2026-06-01T10:10:00.000Z",
});
const response = await handleResumePdfDownload(
new Request("https://example.com/api/resumes/resume-1/pdf?token=legacy&target=cover-letter"),
"resume-1",
);
expect(response.status).toBe(401);
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
});
it("rejects a target that differs from the signed token", async () => {
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({
ok: true,
resumeId: "resume-1",
userId: "user-1",
target: "resume",
expiresAt: "2026-06-01T10:10:00.000Z",
});
const response = await handleResumePdfDownload(
new Request("https://example.com/api/resumes/resume-1/pdf?token=signed&target=cover-letter"),
"resume-1",
);
expect(response.status).toBe(401);
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
});
it("rejects missing, invalid, and expired tokens before rendering", async () => {
let response = await handleResumePdfDownload(
new Request("https://example.com/api/resumes/resume-1/pdf"),
"resume-1",
);
expect(response.status).toBe(401);
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({ ok: false, reason: "invalid_signature" });
response = await handleResumePdfDownload(
new Request("https://example.com/api/resumes/resume-1/pdf?token=bad"),
"resume-1",
);
expect(response.status).toBe(401);
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({ ok: false, reason: "expired" });
response = await handleResumePdfDownload(
new Request("https://example.com/api/resumes/resume-1/pdf?token=expired"),
"resume-1",
);
expect(response.status).toBe(410);
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
});
});
+62
View File
@@ -0,0 +1,62 @@
import { createResumePdfDownload, verifyResumePdfDownloadToken } from "@reactive-resume/api/features/resume/export";
function unauthorizedResponse() {
return new Response("Unauthorized", {
status: 401,
headers: {
"Cache-Control": "private, no-store",
},
});
}
function expiredResponse() {
return new Response("Download link expired", {
status: 410,
headers: {
"Cache-Control": "private, no-store",
},
});
}
function errorStatus(error: unknown) {
const code = typeof error === "object" && error && "code" in error ? (error as { code?: unknown }).code : undefined;
return code === "NOT_FOUND" ? 404 : 500;
}
export async function handleResumePdfDownload(request: Request, id: string) {
const searchParams = new URL(request.url).searchParams;
const token = searchParams.get("token");
if (!token) return unauthorizedResponse();
const verification = verifyResumePdfDownloadToken({ resumeId: id, token });
if (!verification.ok) return verification.reason === "expired" ? expiredResponse() : unauthorizedResponse();
const queryTarget = searchParams.get("target");
if (
verification.target
? queryTarget !== null && queryTarget !== verification.target
: queryTarget && queryTarget !== "resume"
)
return unauthorizedResponse();
try {
const target = verification.target ?? "resume";
const download = await createResumePdfDownload({ id, userId: verification.userId, target });
return new Response(download.body, {
headers: {
"Content-Type": download.body.type || "application/pdf",
"Content-Disposition": download.headers["content-disposition"],
"Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff",
},
});
} catch (error) {
console.error("[PDF Download]", error);
return new Response("Failed to generate resume PDF", {
status: errorStatus(error),
headers: {
"Cache-Control": "private, no-store",
},
});
}
}
+41
View File
@@ -0,0 +1,41 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const events = vi.hoisted(() => [] as string[]);
vi.mock("./startup/checks", () => ({
runStartupChecks: async () => {
await Promise.resolve();
events.push("migrations complete");
},
}));
vi.mock("./http/app", () => {
events.push("auth imported");
return {
createApp: () => {
events.push("app created");
return { fetch: vi.fn() };
},
};
});
vi.mock("@reactive-resume/auth/config", () => ({
initializeAuth: async () => {
await Promise.resolve();
events.push("auth ready");
},
}));
vi.mock("@hono/node-server", () => ({
serve: () => {
events.push("server listening");
},
}));
vi.mock("@reactive-resume/env/server", () => ({ env: { SERVER_PORT: 3001 } }));
afterEach(() => vi.restoreAllMocks());
describe("server startup", () => {
it("finishes migrations before importing auth and seeding OAuth resources", async () => {
vi.spyOn(process, "on").mockReturnValue(process);
const entry = await import("./index");
expect(events).toEqual([]);
await entry.main();
expect(events).toEqual(["migrations complete", "auth imported", "auth ready", "app created", "server listening"]);
});
});
+43
View File
@@ -0,0 +1,43 @@
import { pathToFileURL } from "node:url";
import { serve } from "@hono/node-server";
import { env } from "@reactive-resume/env/server";
import { runStartupChecks } from "./startup/checks";
export async function main() {
await runStartupChecks();
// Load and initialize auth only after migrations have created the provider tables.
const { createApp } = await import("./http/app");
const { initializeAuth } = await import("@reactive-resume/auth/config");
await initializeAuth();
// Safety net: Node 24 crashes the whole process on an unhandled rejection. One request's
// stray promise must not take the server down for everyone, so log and keep serving.
// Registered after startup checks so a broken startup still fails loudly. (Left uncaught
// exceptions on Node's default crash-and-restart, since process state is unsafe after one.)
process.on("unhandledRejection", (reason) => {
console.error("[unhandledRejection]", reason);
});
const port =
process.env.NODE_ENV === "production" ? Number.parseInt(process.env.PORT ?? "3000", 10) : env.SERVER_PORT;
const app = createApp();
serve(
{
fetch: app.fetch,
port,
},
(info) => {
console.info(`🚀 Up and running on http://localhost:${info.port}`);
},
);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
console.error(error);
process.exit(1);
});
}
+50
View File
@@ -0,0 +1,50 @@
import { auth, verifyOAuthToken } from "@reactive-resume/auth/config";
const OAUTH_WARN_THROTTLE_MS = 60_000;
let lastOAuthWarnAt = 0;
function warnOAuthThrottled(message: string, detail?: unknown): void {
const now = Date.now();
if (now - lastOAuthWarnAt < OAUTH_WARN_THROTTLE_MS) return;
lastOAuthWarnAt = now;
if (detail !== undefined) {
console.warn(message, detail);
return;
}
console.warn(message);
}
export class AuthError extends Error {
constructor() {
super("Unauthorized");
}
}
export async function authenticateRequest(request: Request): Promise<void> {
const authHeader = request.headers.get("authorization");
if (authHeader?.startsWith("Bearer ")) {
try {
const payload = await verifyOAuthToken(authHeader.slice(7));
if (payload?.sub) return;
warnOAuthThrottled("[MCP] OAuth token verified but missing `sub` claim");
} catch (error) {
warnOAuthThrottled("[MCP] OAuth token verification failed:", error);
}
}
const apiKey = request.headers.get("x-api-key");
if (apiKey) {
try {
const result = await auth.api.verifyApiKey({ body: { key: apiKey } });
if (result.valid) return;
} catch {
// Invalid or malformed key; fall through to AuthError.
}
}
throw new AuthError();
}
+42
View File
@@ -0,0 +1,42 @@
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
import { env } from "@reactive-resume/env/server";
import { AuthError, authenticateRequest } from "./auth";
import { createMcpServer } from "./server";
export async function handleMcp(request: Request) {
try {
await authenticateRequest(request);
const server = createMcpServer(request);
const transport = new WebStandardStreamableHTTPServerTransport({
enableJsonResponse: true,
});
await server.connect(transport);
return await transport.handleRequest(request);
} catch (error) {
if (error instanceof AuthError) {
return Response.json(
{ id: null, jsonrpc: "2.0", error: { code: -32603, message: "Unauthorized" } },
{
status: 401,
headers: {
"WWW-Authenticate": `Bearer resource_metadata="${env.APP_URL}/.well-known/oauth-protected-resource"`,
},
},
);
}
console.error("[MCP]", error);
return Response.json({
id: null,
jsonrpc: "2.0",
error: {
code: -32603,
message: `Error handling request: ${error instanceof Error ? error.message : String(error)}`,
},
});
}
}
+52
View File
@@ -0,0 +1,52 @@
import type { RouterClient } from "@orpc/server";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { onError } from "@orpc/client";
import { createRouterClient } from "@orpc/server";
import router from "@reactive-resume/api/routers";
import {
buildMcpServerInfo,
MCP_TOOL_NAME,
registerPrompts,
registerResources,
registerTools,
} from "@reactive-resume/mcp";
import { appVersion } from "../app-version";
import { getRequestLocale } from "../rpc/locale";
function createRequestClient(request: Request): RouterClient<typeof router> {
return createRouterClient(router, {
interceptors: [
onError((error) => {
console.error("[MCP oRPC]", error);
}),
],
context: () => ({
locale: getRequestLocale(request),
reqHeaders: request.headers,
resHeaders: new Headers(),
}),
});
}
export function createMcpServer(request: Request) {
const server = new McpServer(buildMcpServerInfo(appVersion), {
instructions: [
"You are connected to Reactive Resume over MCP.",
"Authenticate with OAuth (recommended) or an API key (`x-api-key`).",
`Discover resume IDs with \`${MCP_TOOL_NAME.listResumes}\` (not \`resources/list\`).`,
`List distinct tags with \`${MCP_TOOL_NAME.listResumeTags}\`.`,
`Read schema at \`resume://_meta/schema\`; read resume JSON via \`resume://{id}\` or \`${MCP_TOOL_NAME.getResume}\`.`,
`Apply body edits with JSON Patch through \`${MCP_TOOL_NAME.patchResume}\`.`,
`Change name, slug, tags, or public visibility with \`${MCP_TOOL_NAME.updateResume}\` (returns canonical share URL; anonymous access only when \`isPublic\` is true; passwords are managed in the web app only).`,
`Create short-lived authenticated PDF download URLs with \`${MCP_TOOL_NAME.downloadResumePdf}\`; set target to \`cover-letter\` to export a visible cover letter separately.`,
`Import full ResumeData JSON with \`${MCP_TOOL_NAME.importResume}\`.`,
].join(" "),
});
const client = createRequestClient(request);
registerResources(server, client);
registerTools(server, client, request.headers);
registerPrompts(server);
return server;
}
+20
View File
@@ -0,0 +1,20 @@
import { readFile, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
async function generateOpenApiDocumentation(
target = fileURLToPath(new URL("../../../../docs/spec.json", import.meta.url)),
) {
const packageJson = JSON.parse(await readFile(new URL("../../../../package.json", import.meta.url), "utf8")) as {
version: string;
};
process.env.APP_URL ??= "https://rxresu.me";
process.env.DATABASE_URL ??= "postgresql://localhost/reactive_resume_docs";
process.env.AUTH_SECRET ??= "documentation-generation-isolated-process-only";
const { generateOpenApiSpec } = await import("./generator");
const spec = await generateOpenApiSpec({ appUrl: "https://rxresu.me", version: packageJson.version });
await writeFile(target, `${JSON.stringify(spec, null, "\t")}\n`);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
await generateOpenApiDocumentation(process.argv[2]);
}
+256
View File
@@ -0,0 +1,256 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it, vi } from "vitest";
import z from "zod";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { createResumeDataJsonSchema } from "@reactive-resume/schema/resume/json-schema";
import { writableResumeDataSchema } from "@reactive-resume/schema/resume/write";
// Spec generation reads procedure contracts without executing authentication. Keep the
// provider's resource seeding out of this unit test; real OAuth initialization is covered
// by the opt-in PostgreSQL integration suite after migrations run.
vi.mock("@reactive-resume/auth/config", () => ({ auth: {}, verifyOAuthToken: vi.fn() }));
type GeneratedSpecView = {
components?: { schemas?: Record<string, unknown> };
paths?: Record<
string,
Record<
string,
{
tags?: string[];
operationId?: string;
summary?: string;
description?: string;
responses?: Record<string, { description?: string }>;
requestBody?: {
content?: Record<string, { schema?: unknown }>;
};
}
>
>;
};
// Building the spec walks every router and resume JSON schema, which costs seconds. It is
// deterministic and every test here only reads it, so generate it once for the whole file —
// regenerating per test made the first case time out under a loaded machine.
let specPromise: ReturnType<typeof generateOnce> | undefined;
async function generateOnce() {
const { generateOpenApiSpec } = await import("./generator");
return generateOpenApiSpec({
appUrl: "https://rxresu.me",
version: "9.8.7",
});
}
function generateSpec() {
specPromise ??= generateOnce();
return specPromise;
}
function getRequestSchema(spec: GeneratedSpecView, path: string, method: string) {
return spec.paths?.[path]?.[method]?.requestBody?.content?.["application/json"]?.schema;
}
function containsImpossibleSchema(value: unknown): boolean {
if (Array.isArray(value)) return value.some(containsImpossibleSchema);
if (typeof value !== "object" || value === null) return false;
const object = value as Record<string, unknown>;
const negated = object.not;
if (typeof negated === "object" && negated !== null && Object.keys(negated).length === 0) {
return true;
}
return Object.values(object).some(containsImpossibleSchema);
}
function findImpossibleRequestSchemas(spec: GeneratedSpecView) {
const impossibleRequests: string[] = [];
for (const [path, operations] of Object.entries(spec.paths ?? {})) {
for (const [method, operation] of Object.entries(operations)) {
for (const [mediaType, content] of Object.entries(operation.requestBody?.content ?? {})) {
if (containsImpossibleSchema(content.schema)) {
impossibleRequests.push(`${method.toUpperCase()} ${path} (${mediaType})`);
}
}
}
}
return impossibleRequests;
}
describe("generateOpenApiSpec", () => {
it("documents all cover-letter procedures with REST metadata", async () => {
const spec = (await generateSpec()) as GeneratedSpecView;
const expected = [
["get", "/cover-letters", "listCoverLetters", "List cover letters", "200"],
["get", "/cover-letters/{id}", "getCoverLetter", "Get cover letter by ID", "200"],
["post", "/cover-letters", "createCoverLetter", "Create a cover letter", "200"],
["put", "/cover-letters/{id}", "updateCoverLetter", "Update a cover letter", "200"],
["post", "/cover-letters/{id}/refresh-style", "refreshCoverLetterStyle", "Refresh cover letter style", "200"],
["post", "/cover-letters/{id}/duplicate", "duplicateCoverLetter", "Duplicate a cover letter", "200"],
["delete", "/cover-letters/{id}", "deleteCoverLetter", "Delete a cover letter", "200"],
["post", "/cover-letters/from-resume", "copyEmbeddedCoverLetter", "Copy an embedded cover letter", "200"],
["get", "/cover-letters/{id}/export", "exportCoverLetter", "Export a cover letter", "200"],
["post", "/cover-letters/import", "importCoverLetter", "Import a cover letter", "200"],
] as const;
for (const [method, path, operationId, summary, successStatus] of expected) {
const operation = spec.paths?.[path]?.[method];
expect(operation).toMatchObject({
tags: ["Cover Letters"],
operationId,
summary,
description: expect.any(String),
responses: { [successStatus]: { description: expect.any(String) } },
});
}
});
it("keeps published cover-letter operations in sync with the runtime spec", async () => {
const published = JSON.parse(
await readFile(new URL("../../../../docs/spec.json", import.meta.url), "utf8"),
) as GeneratedSpecView;
const runtime = await generateSpec();
const coverLetterPaths = (spec: GeneratedSpecView) =>
Object.fromEntries(
Object.entries(spec.paths ?? {}).filter(
([path]) => path.startsWith("/cover-letters") || path.startsWith("/coverLetters/"),
),
);
const publishedPaths = coverLetterPaths(published);
const runtimePaths = coverLetterPaths(runtime as GeneratedSpecView);
expect(Object.keys(publishedPaths).sort()).toEqual(Object.keys(runtimePaths).sort());
expect(publishedPaths).toEqual(runtimePaths);
});
it("uses caller-provided application URL and version", async () => {
const spec = await generateSpec();
expect(spec.info).toMatchObject({
title: "Reactive Resume",
version: "9.8.7",
});
expect(spec.servers).toEqual([{ url: "https://rxresu.me/api/openapi" }]);
expect(spec.externalDocs).toEqual({
url: "https://docs.rxresu.me",
description: "Reactive Resume Documentation",
});
}, 15_000);
it("documents the public health endpoint at its actual URL", async () => {
const spec = await generateSpec();
const health = spec.paths?.["/api/health"]?.get;
expect(health).toMatchObject({
operationId: "getHealth",
security: [],
servers: [{ url: "https://rxresu.me" }],
});
for (const status of ["200", "503"]) {
expect(health?.responses?.[status]).toMatchObject({
content: {
"application/json": {
schema: {
required: expect.arrayContaining(["service", "version", "status"]),
properties: { version: { type: "string" } },
},
},
},
});
}
});
it("uses the canonical input-side ResumeData schema in update requests", async () => {
const spec = (await generateSpec()) as GeneratedSpecView;
const { $schema: _dialect, ...canonicalInputSchema } = createResumeDataJsonSchema();
expect(spec.components?.schemas?.ResumeData).toEqual(canonicalInputSchema);
expect(getRequestSchema(spec, "/resumes/{id}", "put")).toMatchObject({
properties: {
data: { $ref: "#/components/schemas/ResumeData" },
},
});
});
it("accepts legacy input with omitted picture fit in the published request schema", async () => {
const spec = (await generateSpec()) as GeneratedSpecView;
expect(spec.components?.schemas?.ResumeData).toMatchObject({
properties: {
picture: { required: expect.not.arrayContaining(["fit"]) },
},
});
});
it("publishes the custom-section type and item correlation", async () => {
const spec = (await generateSpec()) as GeneratedSpecView;
const schema = z.fromJSONSchema(spec.components?.schemas?.ResumeData as Parameters<typeof z.fromJSONSchema>[0]);
const mismatched = {
...defaultResumeData,
customSections: [
{
id: "custom-experience",
type: "experience",
title: "Experience",
icon: "",
columns: 1,
hidden: false,
keepTogether: false,
startOnNewPage: false,
items: [{ id: "summary-item", hidden: false, content: "<p>Not an experience item</p>" }],
},
],
};
expect(schema.safeParse(mismatched).success).toBe(false);
});
it("enforces the same submitted bounds as the published request schema", async () => {
const spec = (await generateSpec()) as GeneratedSpecView;
const published = z.fromJSONSchema(spec.components?.schemas?.ResumeData as Parameters<typeof z.fromJSONSchema>[0]);
for (const marginX of [0, 100, -1, 500]) {
const data = structuredClone(defaultResumeData);
data.metadata.page.marginX = marginX;
const expected = marginX === 0 || marginX === 100;
expect(published.safeParse(data).success).toBe(expected);
expect(writableResumeDataSchema.safeParse(data).success).toBe(expected);
}
});
it("does not publish impossible request schemas", async () => {
const spec = (await generateSpec()) as GeneratedSpecView;
expect(findImpossibleRequestSchemas(spec)).toEqual([]);
});
it("checks every request body media type for impossible schemas", () => {
const spec: GeneratedSpecView = {
paths: {
"/documents": {
post: {
requestBody: {
content: {
"application/json": { schema: { type: "object" } },
"multipart/form-data": { schema: { not: {} } },
},
},
},
},
},
};
expect(findImpossibleRequestSchemas(spec)).toEqual(["POST /documents (multipart/form-data)"]);
});
it("documents imported data as an accepted ResumeData input", async () => {
const spec = (await generateSpec()) as GeneratedSpecView;
expect(getRequestSchema(spec, "/resumes/import", "post")).toEqual({
type: "object",
properties: {
data: { $ref: "#/components/schemas/ResumeData" },
},
required: ["data"],
});
});
});
+130
View File
@@ -0,0 +1,130 @@
import type { OpenAPI } from "@orpc/openapi";
import { OpenAPIGenerator } from "@orpc/openapi";
import { JSON_SCHEMA_INPUT_REGISTRY, ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
import { downloadResumePdfProcedure } from "@reactive-resume/api/features/resume/export";
import router from "@reactive-resume/api/routers";
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
import { createResumeDataJsonSchema } from "@reactive-resume/schema/resume/json-schema";
import { writableResumeDataSchema } from "@reactive-resume/schema/resume/write";
export const openAPIRouter = {
...router,
resume: {
...router.resume,
downloadPdf: downloadResumePdfProcedure,
},
};
const { $schema: _dialect, ...resumeDataInputSchema } = createResumeDataJsonSchema();
type ResumeDataInputJsonSchema = Parameters<typeof JSON_SCHEMA_INPUT_REGISTRY.add<typeof resumeDataSchema>>[1];
JSON_SCHEMA_INPUT_REGISTRY.add(resumeDataSchema, resumeDataInputSchema as unknown as ResumeDataInputJsonSchema);
JSON_SCHEMA_INPUT_REGISTRY.add(writableResumeDataSchema, resumeDataInputSchema as unknown as ResumeDataInputJsonSchema);
const importResumeInputSchema = openAPIRouter.resume.import["~orpc"].inputSchema;
if (importResumeInputSchema) {
JSON_SCHEMA_INPUT_REGISTRY.add(importResumeInputSchema, {
type: "object",
properties: {
data: { $ref: "#/components/schemas/ResumeData" },
},
required: ["data"],
});
}
const openAPIGenerator = new OpenAPIGenerator({
schemaConverters: [
new ZodToJsonSchemaConverter({
interceptors: [
({ options, next }) => {
const [required, schema] = next();
const impossible =
Object.keys(schema).length === 1 &&
typeof schema.not === "object" &&
schema.not !== null &&
Object.keys(schema.not).length === 0;
return options.strategy === "input" && impossible ? [required, {}] : [required, schema];
},
],
}),
],
});
type GenerateOpenApiSpecOptions = {
appUrl: string;
version: string;
};
const healthDependencySchema = {
type: "object",
properties: {
status: { type: "string", enum: ["healthy", "unhealthy"] },
latencyMs: { type: "number" },
error: { type: "string", description: "Generic failure message. Detailed diagnostics are logged on the server." },
},
required: ["status", "latencyMs"],
additionalProperties: true,
} satisfies OpenAPI.SchemaObject;
const healthResponseSchema = {
type: "object",
properties: {
service: { type: "string", enum: ["reactive-resume"] },
version: { type: "string", description: "The running application's build version." },
status: { type: "string", enum: ["healthy", "unhealthy"] },
timestamp: { type: "string", format: "date-time" },
uptime: { type: "string" },
database: healthDependencySchema,
storage: healthDependencySchema,
},
required: ["service", "version", "status", "timestamp", "uptime", "database", "storage"],
} satisfies OpenAPI.SchemaObject;
export async function generateOpenApiSpec({ appUrl, version }: GenerateOpenApiSpecOptions) {
return await openAPIGenerator.generate(openAPIRouter, {
info: {
title: "Reactive Resume",
version,
description: "Reactive Resume API",
license: { name: "MIT", url: "https://github.com/reactive-resume/reactive-resume/blob/main/LICENSE" },
contact: { name: "Amruth Pillai", email: "hello@amruthpillai.com", url: "https://amruthpillai.com" },
},
servers: [{ url: `${appUrl}/api/openapi` }],
paths: {
"/api/health": {
get: {
operationId: "getHealth",
tags: ["System"],
summary: "Get application health and version",
description: "Checks database and storage availability. Does not require authentication.",
servers: [{ url: appUrl }],
security: [],
responses: {
"200": {
description: "The application and its dependencies are healthy.",
content: { "application/json": { schema: healthResponseSchema } },
},
"503": {
description: "One or more application dependencies are unhealthy.",
content: { "application/json": { schema: healthResponseSchema } },
},
},
},
},
},
externalDocs: { url: "https://docs.rxresu.me", description: "Reactive Resume Documentation" },
commonSchemas: {
ResumeData: { schema: writableResumeDataSchema, strategy: "input" },
},
components: {
securitySchemes: {
apiKey: {
type: "apiKey",
name: "x-api-key",
in: "header",
description: "The API key to authenticate requests.",
},
},
},
security: [{ apiKey: [] }],
filter: ({ contract }) => !contract["~orpc"].route.tags?.includes("Internal"),
});
}
+41
View File
@@ -0,0 +1,41 @@
import { SmartCoercionPlugin } from "@orpc/json-schema";
import { OpenAPIHandler } from "@orpc/openapi/fetch";
import { onError } from "@orpc/server";
import { BatchHandlerPlugin, RequestHeadersPlugin, StrictGetMethodPlugin } from "@orpc/server/plugins";
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
import { env } from "@reactive-resume/env/server";
import { appVersion } from "../app-version";
import { mergeResponseHeaders } from "../http/headers";
import { getRequestLocale } from "../rpc/locale";
import { generateOpenApiSpec, openAPIRouter } from "./generator";
const openAPIHandler = new OpenAPIHandler(openAPIRouter, {
plugins: [
new BatchHandlerPlugin(),
new RequestHeadersPlugin(),
new StrictGetMethodPlugin(),
new SmartCoercionPlugin({
schemaConverters: [new ZodToJsonSchemaConverter()],
}),
],
interceptors: [
onError((error) => {
console.error("[OpenAPI]", error);
}),
],
});
export async function handleOpenApi(request: Request, trustedClient = "unknown") {
if (request.method === "GET" && (request.url.endsWith("/spec.json") || request.url.endsWith("/spec"))) {
return Response.json(await generateOpenApiSpec({ appUrl: env.APP_URL, version: appVersion }));
}
const resHeaders = new Headers();
const { response } = await openAPIHandler.handle(request, {
prefix: "/api/openapi",
context: { locale: getRequestLocale(request), reqHeaders: request.headers, resHeaders, trustedClient },
});
if (!response) return new Response("NOT_FOUND", { status: 404 });
return mergeResponseHeaders(response, resHeaders);
}
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
auth: {},
env: {
APP_URL: "https://rxresu.me",
},
}));
vi.mock("@better-auth/oauth-provider", () => ({
oauthProviderAuthServerMetadata: vi.fn(() => vi.fn(() => Response.json({}))),
oauthProviderOpenIdConfigMetadata: vi.fn(() => vi.fn(() => Response.json({}))),
}));
vi.mock("@reactive-resume/auth/config", () => ({
auth: mocks.auth,
}));
vi.mock("@reactive-resume/env/server", () => ({
env: mocks.env,
}));
vi.mock("@reactive-resume/mcp/server-card", () => ({
buildMcpServerCard: vi.fn(() => ({})),
}));
vi.mock("../app-version", () => ({
appVersion: "test",
}));
describe("handleOAuthProtectedResource", () => {
it("advertises the mounted auth issuer as the authorization server", async () => {
const { handleOAuthProtectedResource } = await import("./metadata");
const response = await handleOAuthProtectedResource();
await expect(response.json()).resolves.toMatchObject({
resource: "https://rxresu.me",
authorization_servers: ["https://rxresu.me/api/auth"],
});
});
});
+36
View File
@@ -0,0 +1,36 @@
import { oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata } from "@better-auth/oauth-provider";
import { auth } from "@reactive-resume/auth/config";
import { env } from "@reactive-resume/env/server";
import { buildMcpServerCard } from "@reactive-resume/mcp/server-card";
import { appVersion } from "../app-version";
export const handleOAuthAuthorizationServer = oauthProviderAuthServerMetadata(auth);
export const handleOpenIdConfiguration = oauthProviderOpenIdConfigMetadata(auth);
export function handleWellKnownFallback() {
return new Response("OK", { status: 200 });
}
export function handleMcpServerCard() {
return Response.json(buildMcpServerCard(appVersion), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=60, stale-while-revalidate=120",
},
});
}
export function handleOAuthProtectedResource() {
const metadata = {
resource: env.APP_URL,
bearer_methods_supported: ["header"],
authorization_servers: [`${env.APP_URL}/api/auth`],
};
return Response.json(metadata, {
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=15, stale-while-revalidate=15, stale-if-error=86400",
},
});
}
+20
View File
@@ -0,0 +1,20 @@
import { initializeAuth } from "@reactive-resume/auth/config";
import { getPool } from "@reactive-resume/db/client";
import { env } from "@reactive-resume/env/server";
import { runDatabaseMigrations } from "./startup/checks";
if (process.env.VERCEL_ENV === "preview" && process.env.ALLOW_PREVIEW_MIGRATIONS !== "true") {
throw new Error(
"Preview deployment needs an isolated database. Set ALLOW_PREVIEW_MIGRATIONS=true only after connecting one.",
);
}
if (process.env.VERCEL === "1") {
if (env.STORAGE_BACKEND !== "blob")
throw new Error("Vercel requires private Blob storage for direct uploads. Docker supports local, S3, and Blob.");
if (!env.REDIS_URL || !env.ENCRYPTION_SECRET) throw new Error("Vercel requires Redis and ENCRYPTION_SECRET.");
}
await runDatabaseMigrations();
await initializeAuth();
await getPool().end();
+31
View File
@@ -0,0 +1,31 @@
import { onError } from "@orpc/server";
import { RPCHandler } from "@orpc/server/fetch";
import { BatchHandlerPlugin, RequestHeadersPlugin, StrictGetMethodPlugin } from "@orpc/server/plugins";
import router from "@reactive-resume/api/routers";
import { mergeResponseHeaders } from "../http/headers";
import { getRequestLocale } from "./locale";
const rpcHandler = new RPCHandler(router, {
plugins: [new BatchHandlerPlugin(), new RequestHeadersPlugin(), new StrictGetMethodPlugin()],
interceptors: [
onError((error) => {
console.error("[oRPC Server]", error);
}),
],
});
export async function handleRpc(request: Request, trustedClient = "unknown") {
const resHeaders = new Headers();
const { response } = await rpcHandler.handle(request, {
prefix: "/api/rpc",
context: {
locale: getRequestLocale(request),
reqHeaders: request.headers,
resHeaders,
trustedClient,
},
});
if (!response) return new Response("NOT_FOUND", { status: 404 });
return mergeResponseHeaders(response, resHeaders);
}
+8
View File
@@ -0,0 +1,8 @@
import type { Locale } from "@reactive-resume/utils/locale";
import { defaultLocale, isLocale } from "@reactive-resume/utils/locale";
import { getCookie } from "../http/headers";
export function getRequestLocale(request: Request): Locale {
const locale = getCookie(request, "locale");
return isLocale(locale) ? locale : defaultLocale;
}
+108
View File
@@ -0,0 +1,108 @@
import { constants, existsSync } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { drizzle } from "drizzle-orm/node-postgres";
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { Pool } from "pg";
import { env } from "@reactive-resume/env/server";
import { getLocalDataDirectory } from "@reactive-resume/utils/monorepo.node";
import { verifyMigratedSchema } from "./schema-check";
function resolveFromCurrentModule(relativePath: string) {
return fileURLToPath(new URL(relativePath, import.meta.url));
}
function resolveWorkspaceFolder(folderName: string): string {
let dir = resolveFromCurrentModule(".");
while (dir !== path.dirname(dir)) {
const candidate = path.join(dir, folderName);
if (existsSync(candidate)) return candidate;
dir = path.dirname(dir);
}
throw new Error(`Could not locate ${folderName} folder relative to ${resolveFromCurrentModule(".")}`);
}
export async function runDatabaseMigrations() {
console.info("Running database migrations...");
const pool = new Pool({
connectionString: env.DATABASE_MIGRATION_URL ?? env.DATABASE_URL,
max: 1,
connectionTimeoutMillis: 10_000,
});
try {
const client = await pool.connect();
try {
await client.query("SELECT pg_advisory_lock(721830451)");
const db = drizzle({ client });
try {
await migrate(db, { migrationsFolder: resolveWorkspaceFolder("migrations") });
console.info("Database migrations completed");
} catch (error) {
console.error("Database migrations failed", { error });
throw error;
}
// Post-migration verification is not a migration failure, so it gets its own log
// message. A drifted schema still lets the server boot; STRICT_SCHEMA_CHECK=true
// makes the drift fatal instead.
try {
await verifyMigratedSchema(client);
} catch (error) {
console.error("Database schema verification failed", { error });
if (env.STRICT_SCHEMA_CHECK) throw error;
console.error(
"Continuing with a drifted database schema; set STRICT_SCHEMA_CHECK=true to refuse startup instead.",
);
}
} finally {
try {
await client.query("SELECT pg_advisory_unlock(721830451)");
} finally {
client.release();
}
}
} finally {
await pool.end();
}
}
async function validateLocalStoragePath() {
if (env.STORAGE_BACKEND !== "local") return;
const dataDirectory = getLocalDataDirectory(env.LOCAL_STORAGE_PATH);
console.info(`Validating local storage path: ${dataDirectory}`);
try {
await fs.mkdir(dataDirectory, { recursive: true });
await fs.access(dataDirectory, constants.R_OK | constants.W_OK);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
console.error(
`Local storage path is not writable: ${dataDirectory}\n` +
` ${message}\n` +
"Set LOCAL_STORAGE_PATH to a writable directory or fix permissions on the existing path.",
);
throw error;
}
}
async function reapStaleAgentRuns() {
try {
const { reapStaleAgentRunsAtBoot } = await import("@reactive-resume/api/features/agent/runs");
await reapStaleAgentRunsAtBoot();
} catch (error) {
// A reap failure must not block serving traffic; stuck runs also heal lazily on access.
console.error("Failed to reap stale agent runs at boot", { error });
}
}
export async function runStartupChecks() {
await runDatabaseMigrations();
await validateLocalStoragePath();
await reapStaleAgentRuns();
}
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { collectExpectedColumns, verifyMigratedSchema } from "./schema-check";
describe("collectExpectedColumns", () => {
it("collects every column of every schema table", () => {
const expected = collectExpectedColumns();
expect(expected.length).toBeGreaterThan(0);
expect(expected).toContainEqual({ tableName: "ai_providers", columnName: "user_id" });
expect(expected).toContainEqual({ tableName: "user", columnName: "id" });
});
});
describe("verifyMigratedSchema", () => {
it("passes when the catalog reports nothing missing", async () => {
const queryable = { query: async () => ({ rows: [] }) };
await expect(verifyMigratedSchema(queryable)).resolves.toBeUndefined();
});
it("fails with the table name when every column of a table is missing", async () => {
const rows = collectExpectedColumns()
.filter((e) => e.tableName === "ai_providers")
.map((e) => ({ table_name: e.tableName, column_name: e.columnName }));
const queryable = { query: async () => ({ rows }) };
await expect(verifyMigratedSchema(queryable)).rejects.toThrow('table "ai_providers"');
});
it("fails with the qualified column name when only some columns are missing", async () => {
const queryable = { query: async () => ({ rows: [{ table_name: "user", column_name: "role" }] }) };
await expect(verifyMigratedSchema(queryable)).rejects.toThrow('"user"."role"');
});
it("passes the expected table and column lists to the catalog query", async () => {
let captured: unknown[] | undefined;
const queryable = {
query: (_text: string, values?: unknown[]) => {
captured = values;
return Promise.resolve({ rows: [] });
},
};
await verifyMigratedSchema(queryable);
const [tables, columns] = captured as [string[], string[]];
// The query relies on $1/$2 being index-aligned, so each table name must pair
// with its own column name at the same index.
const pairs = tables.map((table, index) => `${table}.${columns[index]}`);
expect(pairs).toContain("ai_providers.user_id");
});
});
+71
View File
@@ -0,0 +1,71 @@
import { is } from "drizzle-orm";
import { getTableConfig, PgTable } from "drizzle-orm/pg-core";
import * as schema from "@reactive-resume/db/schema";
interface SchemaQueryable {
query(text: string, values?: unknown[]): Promise<{ rows: { table_name: string; column_name: string }[] }>;
}
export function collectExpectedColumns() {
const expected: { tableName: string; columnName: string }[] = [];
for (const value of Object.values(schema)) {
if (!is(value, PgTable)) continue;
const config = getTableConfig(value);
for (const column of config.columns) expected.push({ tableName: config.name, columnName: column.name });
}
return expected;
}
// The migration ledger (drizzle.__drizzle_migrations) only records that a migration ran; it
// cannot detect objects that were dropped or lost outside the migrator (a partial restore,
// a manual DROP TABLE, or a recreated "public" schema while the "drizzle" schema survives).
// Comparing the live catalog with the declared schema turns that silent drift into a startup
// failure instead of runtime "relation does not exist" (42P01) errors. The comparison covers
// tables and columns only — indexes, constraints, and enums are intentionally out of scope.
export async function verifyMigratedSchema(queryable: SchemaQueryable): Promise<void> {
const expected = collectExpectedColumns();
if (expected.length === 0) return;
// $1 and $2 are index-aligned: $1[i] is the name of the table expected to contain $2[i].
// Names are qualified as "public.<table>" so the lookup does not follow the connection's
// search_path — migrations always create these tables in the public schema.
const result = await queryable.query(
`select e.table_name, e.column_name
from unnest($1::text[], $2::text[]) as e(table_name, column_name)
where to_regclass('public.' || e.table_name) is null
or not exists (
select 1 from pg_catalog.pg_attribute a
where a.attrelid = to_regclass('public.' || e.table_name)
and a.attname = e.column_name
and a.attnum > 0 and not a.attisdropped
)
order by e.table_name, e.column_name`,
[expected.map((e) => e.tableName), expected.map((e) => e.columnName)],
);
if (result.rows.length === 0) return;
const expectedPerTable = new Map<string, number>();
for (const e of expected) expectedPerTable.set(e.tableName, (expectedPerTable.get(e.tableName) ?? 0) + 1);
const missingByTable = new Map<string, Set<string>>();
for (const row of result.rows) {
const columns = missingByTable.get(row.table_name) ?? new Set<string>();
columns.add(row.column_name);
missingByTable.set(row.table_name, columns);
}
const missing = [...missingByTable.entries()].map(([table, columns]) =>
columns.size === expectedPerTable.get(table)
? `table "${table}"`
: `column(s) ${[...columns].map((column) => `"${table}"."${column}"`).join(", ")}`,
);
throw new Error(
`Database schema does not match the migration ledger: ${missing.join(", ")} ` +
"missing even though all migrations are marked as applied. This usually means the database was " +
"restored from a backup that did not include these objects, or they were dropped outside of " +
"migrations. Restore a consistent backup or recreate the missing objects, then restart the server.",
);
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import z from "zod";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { handleSchemaJson } from "./schema";
describe("handleSchemaJson", () => {
it("publishes the custom-section type and item correlation", async () => {
const response = handleSchemaJson();
const schema = z.fromJSONSchema((await response.json()) as Parameters<typeof z.fromJSONSchema>[0]);
const mismatched = {
...defaultResumeData,
customSections: [
{
id: "custom-experience",
type: "experience",
title: "Experience",
icon: "",
columns: 1,
hidden: false,
keepTogether: false,
startOnNewPage: false,
items: [{ id: "summary-item", hidden: false, content: "<p>Not an experience item</p>" }],
},
],
};
expect(schema.safeParse(mismatched).success).toBe(false);
});
});
+17
View File
@@ -0,0 +1,17 @@
import { createResumeDataJsonSchema } from "@reactive-resume/schema/resume/json-schema";
import { appVersion } from "../app-version";
export function handleSchemaJson() {
return Response.json(createResumeDataJsonSchema(), {
status: 200,
headers: {
"Content-Type": "application/schema+json; charset=utf-8",
"Cache-Control": "public, max-age=86400, immutable",
"Surrogate-Control": "max-age=86400",
"X-Content-Type-Options": "nosniff",
"X-Robots-Tag": "index, follow",
ETag: appVersion,
Vary: "Accept",
},
});
}
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@reactive-resume/env/server", () => ({
env: {
APP_URL: "https://app.example.com/",
},
}));
const { handleLlms, handleRobots, handleSitemap } = await import("./seo");
describe("SEO static endpoints", () => {
it("generates robots.txt from the normalized app URL", async () => {
const response = handleRobots();
const text = await response.text();
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
expect(text).toContain("User-agent: *");
expect(text).toContain("Allow: /");
expect(text).toContain("Disallow: /api/rpc");
expect(text).toContain("Disallow: /api/auth");
expect(text).toContain("Disallow: /mcp");
expect(text).toContain("Disallow: /.well-known");
expect(text).toContain("Sitemap: https://app.example.com/sitemap.xml");
expect(text).toContain("Sitemap: https://docs.rxresu.me/sitemap.xml");
expect(text).not.toMatch(/GPTBot|ClaudeBot|PerplexityBot|CCBot|ChatGPT-User/);
});
it("generates an app-domain-only sitemap", async () => {
const response = handleSitemap();
const text = await response.text();
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("application/xml; charset=UTF-8");
expect(text).toContain("<loc>https://app.example.com/</loc>");
expect(text).toContain("<loc>https://app.example.com/ats-checker</loc>");
expect(text).not.toContain("docs.rxresu.me");
expect(text).not.toContain("/auth");
expect(text).not.toContain("/dashboard");
expect(text).not.toContain("/builder");
expect(text).not.toContain("/templates");
expect(text).not.toContain("/schema.json");
});
it("generates a lightweight llms.txt product index", async () => {
const response = handleLlms();
const text = await response.text();
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
expect(text).toContain("# Reactive Resume");
expect(text).toContain("- Product: https://app.example.com");
expect(text).toContain("- Documentation: https://docs.rxresu.me");
expect(text).toContain("- Documentation sitemap: https://docs.rxresu.me/sitemap.xml");
expect(text).toContain("- Documentation llms.txt: https://docs.rxresu.me/llms.txt");
expect(text).toContain("- API documentation: https://docs.rxresu.me/api-reference");
expect(text).toContain("- Resume schema: https://app.example.com/schema.json");
expect(text).toContain("- MCP documentation: https://docs.rxresu.me/guides/using-the-mcp-server");
expect(text).toContain("- OpenAPI specification: https://app.example.com/api/openapi/spec.json");
});
it("returns headers without a body for HEAD responses", async () => {
const response = handleLlms({ head: true });
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
expect(await response.text()).toBe("");
});
});
+78
View File
@@ -0,0 +1,78 @@
import { env } from "@reactive-resume/env/server";
const DOCS_URL = "https://docs.rxresu.me";
type StaticSeoOptions = {
head?: boolean;
};
function appUrl() {
return env.APP_URL.replace(/\/+$/, "");
}
function textResponse(body: string, options: StaticSeoOptions = {}) {
return new Response(options.head ? null : body, {
headers: { "Content-Type": "text/plain; charset=UTF-8" },
});
}
export function handleRobots(options?: StaticSeoOptions) {
const baseUrl = appUrl();
const body = [
"User-agent: *",
"Allow: /",
"Disallow: /api/rpc",
"Disallow: /api/auth",
"Disallow: /mcp",
"Disallow: /.well-known",
"",
`Sitemap: ${baseUrl}/sitemap.xml`,
`Sitemap: ${DOCS_URL}/sitemap.xml`,
"",
].join("\n");
return textResponse(body, options);
}
export function handleSitemap(options?: StaticSeoOptions) {
const baseUrl = appUrl();
const body = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
" <url>",
` <loc>${baseUrl}/</loc>`,
" </url>",
" <url>",
` <loc>${baseUrl}/ats-checker</loc>`,
" </url>",
"</urlset>",
"",
].join("\n");
return new Response(options?.head ? null : body, {
headers: { "Content-Type": "application/xml; charset=UTF-8" },
});
}
export function handleLlms(options?: StaticSeoOptions) {
const baseUrl = appUrl();
const body = [
"# Reactive Resume",
"",
"Reactive Resume is an open-source resume builder for creating, managing, and exporting resumes.",
"",
"## Links",
"",
`- Product: ${baseUrl}`,
`- Documentation: ${DOCS_URL}`,
`- Documentation sitemap: ${DOCS_URL}/sitemap.xml`,
`- Documentation llms.txt: ${DOCS_URL}/llms.txt`,
`- API documentation: ${DOCS_URL}/api-reference`,
`- Resume schema: ${baseUrl}/schema.json`,
`- MCP documentation: ${DOCS_URL}/guides/using-the-mcp-server`,
`- OpenAPI specification: ${baseUrl}/api/openapi/spec.json`,
"",
].join("\n");
return textResponse(body, options);
}
+110
View File
@@ -0,0 +1,110 @@
import type { IncomingHttpHeaders } from "node:http";
import { createServer } from "node:http";
import { afterAll, beforeAll, beforeEach, expect, it, vi } from "vitest";
const envMock = vi.hoisted(() => ({
APP_URL: "https://resume.example.com",
S3_ACCESS_KEY_ID: "test-access-key",
S3_SECRET_ACCESS_KEY: "test-secret-key",
S3_REGION: "us-east-1",
S3_ENDPOINT: "",
S3_BUCKET: "test-bucket",
S3_FORCE_PATH_STYLE: true,
}));
vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
type StoredObject = { data: Buffer; contentType: string };
type StorageRequest = { method: string; path: string; headers: IncomingHttpHeaders };
const objects = new Map<string, StoredObject>();
const requests: StorageRequest[] = [];
// Wire-contract stub, not an AWS emulator. It applies the documented BucketOwnerEnforced
// PUT rule to real SDK requests: no ACL or bucket-owner-full-control is accepted.
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-ownership-error-responses.html
const server = createServer(async (request, response) => {
const path = new URL(request.url ?? "/", "http://localhost").pathname;
requests.push({ method: request.method ?? "", path, headers: request.headers });
const fail = (status: number, code: string) => {
response.writeHead(status, { "Content-Type": "application/xml" });
response.end(`<Error><Code>${code}</Code><Message>${code}</Message></Error>`);
};
// Only checks that the SDK authenticates its requests; this stub does not verify signatures.
if (!request.headers.authorization?.startsWith("AWS4-HMAC-SHA256 ")) return fail(403, "AccessDenied");
if (request.method === "PUT") {
const chunks: Buffer[] = [];
for await (const chunk of request) chunks.push(Buffer.from(chunk));
const acl = request.headers["x-amz-acl"];
if (acl && acl !== "bucket-owner-full-control") return fail(400, "AccessControlListNotSupported");
objects.set(path, {
data: Buffer.concat(chunks),
contentType: request.headers["content-type"] ?? "application/octet-stream",
});
response.writeHead(200, { ETag: '"test-etag"' });
return response.end();
}
if (request.method === "DELETE") {
objects.delete(path);
response.writeHead(204);
return response.end();
}
const object = objects.get(path);
if (!object) return fail(404, "NoSuchKey");
response.writeHead(200, { "Content-Type": object.contentType, "Content-Length": object.data.length });
response.end(object.data);
});
let storage: ReturnType<typeof import("@reactive-resume/api/features/storage").getStorageService>;
let handleUpload: typeof import("./uploads").handleUpload;
beforeAll(async () => {
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing stub TCP address");
envMock.S3_ENDPOINT = `http://127.0.0.1:${address.port}`;
storage = (await import("@reactive-resume/api/features/storage")).getStorageService();
({ handleUpload } = await import("./uploads"));
});
beforeEach(() => {
objects.clear();
requests.length = 0;
});
afterAll(async () => {
server.closeAllConnections();
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
});
it("keeps the ACL-disabled storage health check healthy", async () => {
expect(await storage.healthcheck()).toMatchObject({ status: "healthy", type: "s3" });
expect(requests.map(({ method }) => method)).toEqual(["PUT", "DELETE"]);
expect(objects.size).toBe(0);
});
it("stores images without ACLs and serves them through the signed application proxy", async () => {
const key = "uploads/user-1/pictures/photo.png";
const data = new Uint8Array([137, 80, 78, 71]);
await storage.write({ key, data, contentType: "image/png" });
expect(requests[0]?.headers["x-amz-acl"]).toBeUndefined();
const direct = await fetch(`${envMock.S3_ENDPOINT}/${envMock.S3_BUCKET}/${key}`);
expect(direct.status).toBe(403);
const response = await handleUpload(new Request(`${envMock.APP_URL}/api/${key}`));
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("image/png");
expect(new Uint8Array(await response.arrayBuffer())).toEqual(data);
expect(requests.at(-1)?.headers.authorization).toMatch(/^AWS4-HMAC-SHA256 /);
});
it("stores private attachments without ACLs while keeping them outside the public proxy", async () => {
const key = "uploads/user-1/agent/thread-1/private.txt";
const data = new TextEncoder().encode("private attachment");
await storage.write({ key, data, contentType: "text/plain", private: true });
expect(requests[0]?.headers["x-amz-acl"]).toBeUndefined();
const requestCount = requests.length;
const response = await handleUpload(new Request(`${envMock.APP_URL}/api/${key}`));
expect(response.status).toBe(404);
expect(requests).toHaveLength(requestCount);
const direct = await fetch(`${envMock.S3_ENDPOINT}/${envMock.S3_BUCKET}/${key}`);
expect(direct.status).toBe(403);
expect((await storage.read(key))?.data).toEqual(data);
});
+54
View File
@@ -0,0 +1,54 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const readMock = vi.fn();
vi.mock("@reactive-resume/api/features/storage", () => ({
getStorageService: () => ({
read: readMock,
}),
}));
vi.mock("@reactive-resume/env/server", () => ({
env: {
APP_URL: "https://example.com",
},
}));
const { handleUpload } = await import("./uploads");
describe("handleUpload", () => {
beforeEach(() => {
readMock.mockReset();
});
it("serves public upload keys", async () => {
readMock.mockResolvedValueOnce({
data: new TextEncoder().encode("image"),
size: 5,
contentType: "image/jpeg",
});
const response = await handleUpload(new Request("https://example.com/api/uploads/user-1/pictures/photo.jpeg"));
expect(response.status).toBe(200);
expect(readMock).toHaveBeenCalledWith("uploads/user-1/pictures/photo.jpeg");
expect(response.headers.get("Content-Type")).toBe("image/jpeg");
expect(response.headers.get("Cross-Origin-Resource-Policy")).toBe("same-site");
expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull();
});
it("does not serve private agent attachment keys through the public uploads route", async () => {
readMock.mockResolvedValueOnce({
data: new TextEncoder().encode("secret"),
size: 6,
contentType: "text/plain",
});
const response = await handleUpload(
new Request("https://example.com/api/uploads/user-1/agent/thread-1/attachment.txt"),
);
expect(response.status).toBe(404);
expect(readMock).not.toHaveBeenCalled();
});
});
@@ -1,76 +1,49 @@
import { createHash } from "node:crypto";
import { basename, extname, normalize } from "node:path";
import { createFileRoute } from "@tanstack/react-router";
import { getStorageService } from "@reactive-resume/api/services/storage";
import { env } from "@reactive-resume/env/server";
import { getStorageService, inferContentType } from "@reactive-resume/api/features/storage";
export const Route = createFileRoute("/uploads/$userId/$")({
server: { handlers: { GET: handler } },
});
/**
* Handler for GET requests to serve uploaded files, supporting ETags, content security, and path validation.
* Handles nested paths like:
* - /uploads/{userId}/pictures/{timestamp}.jpeg
* - /uploads/{userId}/screenshots/{resumeId}/{timestamp}.jpeg
* - /uploads/{userId}/pdfs/{resumeId}/{timestamp}.pdf
*/
export async function handler({ request }: { request: Request }) {
export async function handleUpload(request: Request) {
const { userId, filePath } = parseRouteParams(request.url);
if (!userId || !filePath) return new Response("Bad Request", { status: 400 });
if (!isValidPath(userId) || !isValidPathSegments(filePath)) return new Response("Forbidden", { status: 403 });
if (isPrivateUploadPath(filePath)) return new Response("Not Found", { status: 404 });
const storageService = getStorageService();
// Build the full storage key: uploads/{userId}/{filePath}
const key = `uploads/${userId}/${filePath}`;
const storedFile = await storageService.read(key);
if (!storedFile) return new Response("Not Found", { status: 404 });
const filename = filePath.split("/").pop() ?? filePath;
const ext = extname(filename).toLowerCase();
const contentType = storedFile.contentType ?? inferContentTypeFromExtension(ext);
const contentType = storedFile.contentType ?? inferContentType(filename);
const etag = createEtag(storedFile);
if (isNotModified(request.headers, etag)) return makeNotModifiedResponse(etag);
const shouldForceDownload = [".pdf"].includes(ext);
const headers = await buildResponseHeaders({
filename,
storedFile,
contentType,
etag,
shouldForceDownload,
});
const buffer = toArrayBuffer(storedFile.data);
const headers = new Headers();
headers.set("Content-Type", shouldForceDownload ? "application/octet-stream" : contentType);
headers.set("Content-Length", storedFile.size.toString());
return new Response(buffer, { headers });
}
function inferContentTypeFromExtension(ext: string): string {
switch (ext) {
case ".webp":
return "image/webp";
case ".png":
return "image/png";
case ".jpg":
case ".jpeg":
return "image/jpeg";
case ".gif":
return "image/gif";
case ".pdf":
return "application/pdf";
default:
return "application/octet-stream";
if (shouldForceDownload) {
headers.set("Content-Disposition", `attachment; filename="${encodeURIComponent(basename(filename))}"`);
}
headers.set("Cache-Control", "public, max-age=31536000, immutable");
headers.set("ETag", etag);
headers.set("X-Content-Type-Options", "nosniff");
headers.set("X-Robots-Tag", "noindex, nofollow");
headers.set("Cross-Origin-Resource-Policy", "same-site");
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
headers.set("X-Frame-Options", "DENY");
headers.set("X-Download-Options", "noopen");
return new Response(toArrayBuffer(storedFile.data), { headers });
}
/**
* Extracts userId and the remaining file path from the request URL.
*/
function parseRouteParams(url: string): { userId: string | undefined; filePath: string | undefined } {
const pathname = new URL(url).pathname;
const [, pathAfterUploads] = pathname.split("/uploads/");
@@ -87,27 +60,22 @@ function parseRouteParams(url: string): { userId: string | undefined; filePath:
return { userId, filePath: filePath || undefined };
}
/**
* Validates that a path segment does not contain directory traversal attempts.
*/
function isValidPath(segment: string): boolean {
const normalized = normalize(segment).replace(/^(\.\.(\/|\\|$))+/, "");
return normalized === segment;
}
/**
* Validates all segments in a path for directory traversal attempts.
*/
function isValidPathSegments(path: string): boolean {
const segments = path.split("/");
return segments.every((segment) => isValidPath(segment));
}
/**
* Checks for ETag match for conditional GET requests.
*/
function isPrivateUploadPath(path: string): boolean {
return path.split("/")[0] === "agent";
}
function isNotModified(headers: Headers, etag: string): boolean {
const ifNoneMatch = headers.get("If-None-Match");
const candidates = ifNoneMatch?.split(",").map((s) => s.trim()) ?? [];
@@ -115,9 +83,6 @@ function isNotModified(headers: Headers, etag: string): boolean {
return candidates.includes(etag);
}
/**
* Returns a 304 Not Modified response with caching headers.
*/
function makeNotModifiedResponse(etag: string): Response {
return new Response(null, {
status: 304,
@@ -125,60 +90,12 @@ function makeNotModifiedResponse(etag: string): Response {
});
}
type BuildResponseHeaderArgs = {
filename: string;
storedFile: { size: number };
contentType: string;
etag: string;
shouldForceDownload: boolean;
};
/**
* Builds all headers for serving the file, including caching, security, and download headers.
*/
async function buildResponseHeaders({
filename,
storedFile,
contentType,
etag,
shouldForceDownload,
}: BuildResponseHeaderArgs): Promise<Headers> {
const headers = new Headers();
headers.set("Content-Type", shouldForceDownload ? "application/octet-stream" : contentType);
headers.set("Content-Length", storedFile.size.toString());
if (shouldForceDownload) {
headers.set("Content-Disposition", `attachment; filename="${encodeURIComponent(basename(filename))}"`);
}
headers.set("Cache-Control", "public, max-age=31536000, immutable");
headers.set("ETag", etag);
// Security Headers
headers.set("X-Content-Type-Options", "nosniff");
headers.set("X-Robots-Tag", "noindex, nofollow");
headers.set("Cross-Origin-Resource-Policy", "same-site");
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
headers.set("X-Frame-Options", "DENY");
headers.set("X-Download-Options", "noopen");
headers.set("Access-Control-Allow-Origin", env.APP_URL);
return headers;
}
/**
* Converts a Uint8Array to ArrayBuffer efficiently.
*/
function toArrayBuffer(data: Uint8Array): ArrayBuffer {
return data.byteOffset === 0 && data.byteLength === data.buffer.byteLength
? (data.buffer as ArrayBuffer)
: (data.slice().buffer as ArrayBuffer);
}
/**
* Generates or returns the ETag for a stored file.
*/
function createEtag(storedFile: { data: Uint8Array; size: number; etag?: string }): string {
if (storedFile.etag) {
const tag = storedFile.etag.trim();
+317
View File
@@ -0,0 +1,317 @@
import fs from "node:fs/promises";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
env: { APP_URL: "https://rxresu.me", ROOT_RESUME_ID: undefined as string | undefined },
serveStatic: vi.fn((_options?: unknown) => vi.fn()),
getPublicResumeSocialMeta: vi.fn(),
}));
vi.mock("@reactive-resume/api/features/resume/social-meta", () => ({
getPublicResumeSocialMeta: mocks.getPublicResumeSocialMeta,
}));
vi.mock("node:fs", () => ({
existsSync: vi.fn(() => true),
}));
vi.mock("node:fs/promises", () => ({
default: {
readFile: vi.fn(),
},
}));
vi.mock("@hono/node-server/serve-static", () => ({
serveStatic: mocks.serveStatic,
}));
vi.mock("@reactive-resume/env/server", () => ({
env: mocks.env,
}));
type StaticOptions = {
onFound?: (
path: string,
context: {
req: { path: string };
header: (name: string, value: string) => void;
},
) => void | Promise<void>;
};
const { handleWebApp } = await import("./web");
const staticOptions = mocks.serveStatic.mock.calls[0]?.[0] as StaticOptions | undefined;
describe("web app fallback classification", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.env.ROOT_RESUME_ID = undefined;
vi.mocked(fs.readFile).mockResolvedValue("<html>app</html>");
mocks.getPublicResumeSocialMeta.mockResolvedValue(null);
});
it("serves the shell for the root app route without noindex", async () => {
const response = await handleWebApp(new Request("https://example.com/"));
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("text/html; charset=UTF-8");
expect(response.headers.get("X-Robots-Tag")).toBeNull();
expect(await response.text()).toBe("<html>app</html>");
});
it("injects canonical metadata and structured data into tracking-parameter root requests only", async () => {
vi.mocked(fs.readFile).mockResolvedValue(`
<!doctype html>
<html>
<head>
<title>Reactive Resume — A free and open-source resume builder</title>
<meta
name="description"
content="Reactive Resume is a free and open-source resume builder that makes it easy to create, update, and share your resume."
>
</head>
<body><div id="app"></div></body>
</html>
`);
const response = await handleWebApp(new Request("http://server.internal/?utm_source=search"));
const html = await response.text();
expect(html).toContain('<link rel="canonical" href="https://rxresu.me/">');
expect(html).toContain('<link rel="preload" href="/videos/timelapse-v1.webp" as="image" fetchpriority="high">');
expect(html).toContain('<meta property="og:url" content="https://rxresu.me/">');
expect(html).toContain('<meta property="og:image" content="https://rxresu.me/opengraph/banner.jpg">');
expect(html).toContain('id="reactive-resume-structured-data"');
expect(html).toContain('"@type":["SoftwareApplication","WebApplication"]');
expect(html).toContain('"url":"https://rxresu.me/"');
expect(html).not.toContain("utm_source");
const dashboardResponse = await handleWebApp(new Request("https://example.com/dashboard"));
expect(await dashboardResponse.text()).not.toContain('rel="canonical"');
});
describe("the ATS checker page", () => {
const shell = `<html><head><title>Reactive Resume — A free and open-source resume builder</title><meta name="description" content="Marketing copy."></head><body></body></html>`;
it("serves an indexable shell rather than a 404", async () => {
vi.mocked(fs.readFile).mockResolvedValue(shell);
const response = await handleWebApp(new Request("https://example.com/ats-checker"));
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("text/html; charset=UTF-8");
expect(response.headers.get("X-Robots-Tag")).toBeNull();
});
it("replaces the shell metadata with the checker's own", async () => {
vi.mocked(fs.readFile).mockResolvedValue(shell);
const html = await (await handleWebApp(new Request("https://example.com/ats-checker"))).text();
expect(html).toContain("<title>ATS Checker - Reactive Resume</title>");
expect(html).toContain('<link rel="canonical" href="https://rxresu.me/ats-checker">');
expect(html).toContain('<meta property="og:url" content="https://rxresu.me/ats-checker">');
expect(html).toContain('<meta property="og:image" content="https://rxresu.me/opengraph/ats-checker.png">');
expect(html).toContain('id="ats-checker-structured-data"');
expect(html).not.toContain("Marketing copy.");
});
it("answers HEAD without a body", async () => {
const response = await handleWebApp(new Request("https://example.com/ats-checker", { method: "HEAD" }));
expect(response.status).toBe(200);
expect(await response.text()).toBe("");
});
it("does not treat the checker path as a public resume owner", async () => {
const response = await handleWebApp(new Request("https://example.com/ats-checker/anything"));
expect(response.status).toBe(404);
expect(mocks.getPublicResumeSocialMeta).not.toHaveBeenCalled();
});
});
describe("public resume social cards", () => {
const shell = `<html><head><title>Reactive Resume — A free and open-source resume builder</title><meta name="description" content="Marketing copy."></head><body></body></html>`;
it("injects resume-specific social metadata and replaces the shell title", async () => {
vi.mocked(fs.readFile).mockResolvedValue(shell);
mocks.getPublicResumeSocialMeta.mockResolvedValue({
name: "Jane Doe",
title: "Jane Doe — Staff Engineer",
description: "Builds resilient distributed systems.",
template: "azurill",
});
const html = await (await handleWebApp(new Request("https://example.com/jane/resume"))).text();
expect(mocks.getPublicResumeSocialMeta).toHaveBeenCalledWith({ username: "jane", slug: "resume" });
expect(html).toContain("<title>Jane Doe - Reactive Resume</title>");
expect(html).toContain('<meta name="description" content="Builds resilient distributed systems.">');
expect(html).not.toContain("Marketing copy.");
expect(html).toContain('<link rel="canonical" href="https://rxresu.me/jane/resume">');
expect(html).toContain('<meta property="og:type" content="profile">');
expect(html).toContain('<meta property="og:title" content="Jane Doe — Staff Engineer">');
expect(html).toContain('<meta property="og:image" content="https://rxresu.me/opengraph/banner.jpg">');
expect(html).toContain('<meta name="twitter:card" content="summary_large_image">');
expect(html).toContain('<meta name="twitter:image" content="https://rxresu.me/opengraph/banner.jpg">');
});
it("escapes user-authored values so resume content cannot break out of the attribute", async () => {
vi.mocked(fs.readFile).mockResolvedValue(shell);
mocks.getPublicResumeSocialMeta.mockResolvedValue({
name: 'Jane" onload="alert(1)',
title: "<script>alert(1)</script>",
description: 'Ends with " and & ampersand',
template: "azurill",
});
const html = await (await handleWebApp(new Request("https://example.com/jane/resume"))).text();
expect(html).not.toContain("<script>alert(1)</script>");
expect(html).not.toContain('onload="alert(1)');
expect(html).toContain('<meta property="og:title" content="&lt;script&gt;alert(1)&lt;/script&gt;">');
expect(html).toContain('content="Ends with &quot; and &amp; ampersand"');
});
it("serves the plain shell when the resume is not publicly shareable", async () => {
vi.mocked(fs.readFile).mockResolvedValue(shell);
const html = await (await handleWebApp(new Request("https://example.com/jane/private"))).text();
expect(html).toBe(shell);
});
it("serves the plain shell when the lookup fails", async () => {
vi.mocked(fs.readFile).mockResolvedValue(shell);
mocks.getPublicResumeSocialMeta.mockRejectedValue(new Error("database unavailable"));
const response = await handleWebApp(new Request("https://example.com/jane/resume"));
expect(response.status).toBe(200);
await expect(response.text()).resolves.toBe(shell);
});
});
it("caches versioned homepage media immutably", async () => {
const headers = new Headers();
await staticOptions?.onFound?.("", {
req: { path: "/videos/timelapse-v1.mp4" },
header: (name, value) => headers.set(name, value),
});
expect(headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable");
const unversionedHeaders = new Headers();
await staticOptions?.onFound?.("", {
req: { path: "/videos/timelapse.mp4" },
header: (name, value) => unversionedHeaders.set(name, value),
});
expect(unversionedHeaders.get("Cache-Control")).toBeNull();
});
it.each(["/", "/alice/resume"])("sets framing and report-only CSP security headers on %s", async (pathname) => {
const response = await handleWebApp(new Request(`https://example.com${pathname}`));
expect(response.status).toBe(200);
expect(response.headers.get("X-Frame-Options")).toBe("DENY");
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
expect(response.headers.get("Content-Security-Policy-Report-Only")).toContain("frame-ancestors 'none'");
});
it.each(["/auth/login", "/dashboard", "/builder/resume-1", "/agent", "/templates", "/templates/azurill.pdf"])(
"serves noindex shell for known app prefix %s",
async (pathname) => {
const response = await handleWebApp(new Request(`https://example.com${pathname}`));
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("text/html; charset=UTF-8");
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, follow");
expect(await response.text()).toBe("<html>app</html>");
},
);
it("serves noindex shell for public resume shaped routes", async () => {
const response = await handleWebApp(new Request("https://example.com/alice/resume"));
expect(response.status).toBe(200);
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, follow");
expect(await response.text()).toBe("<html>app</html>");
});
it("returns noindex 404 for unknown non-asset routes", async () => {
const response = await handleWebApp(new Request("https://example.com/unknown/extra/path"));
expect(response.status).toBe(404);
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
expect(await response.text()).toBe("Not Found");
expect(fs.readFile).not.toHaveBeenCalled();
});
it.each(["/api/foo", "/mcp/foo", "/uploads/foo"])(
"does not treat reserved two-segment path %s as a public resume",
async (pathname) => {
const response = await handleWebApp(new Request(`https://example.com${pathname}`));
expect(response.status).toBe(404);
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
expect(await response.text()).toBe("Not Found");
expect(fs.readFile).not.toHaveBeenCalled();
},
);
it("returns plain 404 for missing asset-looking paths", async () => {
const response = await handleWebApp(new Request("https://example.com/assets/missing.css"));
expect(response.status).toBe(404);
expect(response.headers.get("X-Robots-Tag")).toBeNull();
expect(await response.text()).toBe("Not Found");
expect(fs.readFile).not.toHaveBeenCalled();
});
it("mirrors fallback status and headers for HEAD without a body", async () => {
const knownResponse = await handleWebApp(new Request("https://example.com/dashboard", { method: "HEAD" }));
const unknownResponse = await handleWebApp(
new Request("https://example.com/unknown/extra/path", { method: "HEAD" }),
);
expect(knownResponse.status).toBe(200);
expect(knownResponse.headers.get("Content-Type")).toBe("text/html; charset=UTF-8");
expect(knownResponse.headers.get("X-Robots-Tag")).toBe("noindex, follow");
expect(await knownResponse.text()).toBe("");
expect(unknownResponse.status).toBe(404);
expect(unknownResponse.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
expect(unknownResponse.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
expect(await unknownResponse.text()).toBe("");
});
});
describe("configured root shell", () => {
it.each(["GET", "HEAD"])("serves no-store noindex headers for %s", async (method) => {
mocks.env.ROOT_RESUME_ID = "private-or-missing-id";
const response = await handleWebApp(new Request("https://attacker.example/", { method }));
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, follow");
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
});
it("uses configured canonical root without leaking ID or marketing metadata", async () => {
mocks.env.ROOT_RESUME_ID = "private-or-missing-id";
vi.mocked(fs.readFile).mockResolvedValue(
'<html><head><title>Marketing title</title><meta name="description" content="Marketing copy."></head><body></body></html>',
);
const html = await (
await handleWebApp(
new Request("https://attacker.example/?id=other", {
headers: { host: "attacker.example", "x-forwarded-host": "evil.example" },
}),
)
).text();
expect(html).toContain('<link rel="canonical" href="https://rxresu.me/" data-root-resume-shell>');
expect(html).toContain('<meta name="robots" content="noindex, follow" data-root-resume-shell>');
expect(html).not.toMatch(/private-or-missing-id|attacker|evil|Marketing|application\/ld\+json|timelapse/);
});
});
+347
View File
@@ -0,0 +1,347 @@
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { serveStatic } from "@hono/node-server/serve-static";
import { env } from "@reactive-resume/env/server";
function resolveWebDistPath() {
const candidates = [
// Source layout: apps/server/src/static/web.ts -> apps/web/dist
fileURLToPath(new URL("../../../web/dist", import.meta.url)),
// Bundled layout: apps/server/dist/index.mjs -> apps/web/dist
fileURLToPath(new URL("../../web/dist", import.meta.url)),
];
const [fallback] = candidates;
if (!fallback) throw new Error("Could not resolve web dist path");
return candidates.find((candidate) => existsSync(candidate)) ?? fallback;
}
const staticRoot = resolveWebDistPath();
const indexHtmlPath = `${staticRoot}/index.html`;
const noindexShellPrefixes = ["/auth", "/dashboard", "/builder", "/agent", "/templates"];
/**
* Marketing pages the SPA owns that search engines should index.
*
* Without an entry here the fallback below returns 404 for the path in production — the dev Vite
* server serves the shell for anything, so this failure only ever shows up once deployed.
*/
const indexableAppPaths = new Set(["/ats-checker"]);
const reservedPublicResumeSegments = new Set([
"api",
"mcp",
".well-known",
"uploads",
"auth",
"dashboard",
"builder",
"agent",
"templates",
"ats-checker",
]);
function isAssetPath(pathname: string): boolean {
return pathname.split("/").pop()?.includes(".") ?? false;
}
function getPathSegments(pathname: string) {
return pathname.split("/").filter(Boolean);
}
function isNoindexShellPath(pathname: string): boolean {
return noindexShellPrefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
}
function isPublicResumePath(pathname: string): boolean {
const segments = getPathSegments(pathname);
const [firstSegment] = segments;
return segments.length === 2 && firstSegment !== undefined && !reservedPublicResumeSegments.has(firstSegment);
}
const BASE_SECURITY_HEADERS = {
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Content-Security-Policy-Report-Only":
"default-src 'self'; img-src 'self' data: blob:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'",
};
const ROOT_TITLE = "Reactive Resume — A free and open-source resume builder";
// Keep under ~120 characters so Google's mobile SERP snippet is not truncated at 3 lines.
const ROOT_DESCRIPTION =
"Free, open-source resume builder. Create, update, and share your resume, with no ads and no paywall.";
const ROOT_POSTER_PATH = "/videos/timelapse-v1.webp";
const ROOT_FAQ_ITEMS = [
{
question: "Is Reactive Resume really free?",
answer:
"Yes. Reactive Resume is free to use, with no hidden costs, premium tiers, or subscription fees. It's open source, and it will stay free.",
},
{
question: "How is my data protected?",
answer:
"Your data is stored securely and never shared with third parties. If you want full control over it, you can self-host Reactive Resume on your own servers.",
},
{
question: "Can I export my resume to PDF?",
answer: "Yes. One click exports your resume to PDF, with your formatting and styling intact.",
},
{
question: "Is Reactive Resume available in multiple languages?",
answer:
"Yes. Pick your language on the settings page, or with the language switcher in the top right corner. If your language is missing, or the existing translation could be better, you can contribute to the translations on Crowdin.",
},
{
question: "What makes Reactive Resume different from other resume builders?",
answer:
"Reactive Resume is open source, private, and free. It shows no ads, doesn't track what you do, and doesn't lock features behind a paywall.",
},
{
question: "How do I share my resume?",
answer: "Share it with a public URL, put a password on that URL, or download the PDF and send it yourself.",
},
] as const;
function createRootSeoMarkup(canonicalUrl: string) {
const origin = new URL(canonicalUrl).origin;
const imageUrl = `${origin}/opengraph/banner.jpg`;
const structuredData = {
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
name: "Reactive Resume",
url: canonicalUrl,
},
{
"@type": ["SoftwareApplication", "WebApplication"],
name: "Reactive Resume",
url: canonicalUrl,
description: ROOT_DESCRIPTION,
applicationCategory: "BusinessApplication",
operatingSystem: "Web",
isAccessibleForFree: true,
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
},
codeRepository: "https://github.com/reactive-resume/reactive-resume",
},
{
"@type": "Project",
name: "Reactive Resume",
url: canonicalUrl,
sameAs: ["https://github.com/reactive-resume/reactive-resume"],
},
{
"@type": "FAQPage",
mainEntity: ROOT_FAQ_ITEMS.map((item) => ({
"@type": "Question",
name: item.question,
acceptedAnswer: {
"@type": "Answer",
text: item.answer,
},
})),
},
],
};
return `
<link rel="canonical" href="${canonicalUrl}">
<link rel="preload" href="${ROOT_POSTER_PATH}" as="image" fetchpriority="high">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Reactive Resume">
<meta property="og:title" content="${ROOT_TITLE}">
<meta property="og:description" content="${ROOT_DESCRIPTION}">
<meta property="og:url" content="${canonicalUrl}">
<meta property="og:image" content="${imageUrl}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${ROOT_TITLE}">
<meta name="twitter:description" content="${ROOT_DESCRIPTION}">
<meta name="twitter:image" content="${imageUrl}">
<script id="reactive-resume-structured-data" type="application/ld+json">${JSON.stringify(structuredData)}</script>
`;
}
const ATS_CHECKER_TITLE = "ATS Checker - Reactive Resume";
// Keep under ~120 characters so Google's mobile SERP snippet is not truncated at 3 lines.
const ATS_CHECKER_DESCRIPTION =
"Check whether software can read your resume PDF. Runs entirely in your browser, so your file is never uploaded.";
function createAtsCheckerSeoMarkup(origin: string) {
const canonicalUrl = `${origin}/ats-checker`;
const imageUrl = `${origin}/opengraph/ats-checker.png`;
const structuredData = {
"@context": "https://schema.org",
"@type": "WebApplication",
name: "ATS Checker",
url: canonicalUrl,
description: ATS_CHECKER_DESCRIPTION,
applicationCategory: "BusinessApplication",
operatingSystem: "Web",
isAccessibleForFree: true,
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
isPartOf: { "@type": "WebSite", name: "Reactive Resume", url: `${origin}/` },
};
return `
<link rel="canonical" href="${canonicalUrl}">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Reactive Resume">
<meta property="og:title" content="${ATS_CHECKER_TITLE}">
<meta property="og:description" content="${ATS_CHECKER_DESCRIPTION}">
<meta property="og:url" content="${canonicalUrl}">
<meta property="og:image" content="${imageUrl}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${ATS_CHECKER_TITLE}">
<meta name="twitter:description" content="${ATS_CHECKER_DESCRIPTION}">
<meta name="twitter:image" content="${imageUrl}">
<script id="ats-checker-structured-data" type="application/ld+json">${JSON.stringify(structuredData)}</script>
`;
}
// Resume names, headlines, and summaries are user-authored, so they must never reach the served
// HTML unescaped.
const escapeAttribute = (value: string) =>
value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
async function createPublicResumeSeoMarkup(pathname: string, origin: string) {
const [username, slug] = getPathSegments(pathname);
if (!username || !slug) return null;
// A card render must never take down the page: any lookup failure falls back to the plain shell.
const meta = await import("@reactive-resume/api/features/resume/social-meta")
.then((module) => module.getPublicResumeSocialMeta({ username, slug }))
.catch(() => null);
if (!meta) return null;
const canonicalUrl = `${origin}/${username}/${slug}`;
const imageUrl = `${origin}/opengraph/banner.jpg`;
const pageTitle = escapeAttribute(`${meta.name} - Reactive Resume`);
const title = escapeAttribute(meta.title);
const description = escapeAttribute(meta.description);
return {
pageTitle,
description,
markup: `
<link rel="canonical" href="${canonicalUrl}">
<meta property="og:type" content="profile">
<meta property="og:site_name" content="Reactive Resume">
<meta property="og:title" content="${title}">
<meta property="og:description" content="${description}">
<meta property="og:url" content="${canonicalUrl}">
<meta property="og:image" content="${imageUrl}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${title}">
<meta name="twitter:description" content="${description}">
<meta name="twitter:image" content="${imageUrl}">
`,
};
}
export const serveWebDistStatic = serveStatic({
root: staticRoot,
onFound: (_path, context) => {
if (/^\/videos\/.*-v\d+\.(?:mp4|webp)$/.test(context.req.path)) {
context.header("Cache-Control", "public, max-age=31536000, immutable");
}
},
});
function getFallbackResponseHeaders(pathname: string) {
if (pathname === "/" && env.ROOT_RESUME_ID?.trim()) {
return {
"Content-Type": "text/html; charset=UTF-8",
"X-Robots-Tag": "noindex, follow",
"Cache-Control": "private, no-store",
...BASE_SECURITY_HEADERS,
};
}
if (pathname === "/" || indexableAppPaths.has(pathname)) {
return { "Content-Type": "text/html; charset=UTF-8", ...BASE_SECURITY_HEADERS };
}
if (isNoindexShellPath(pathname) || isPublicResumePath(pathname)) {
return {
"Content-Type": "text/html; charset=UTF-8",
"X-Robots-Tag": "noindex, follow",
...BASE_SECURITY_HEADERS,
};
}
return null;
}
function notFoundResponse(options: { head?: boolean; noindex?: boolean } = {}) {
const headers = new Headers({ "Content-Type": "text/plain; charset=UTF-8" });
if (options.noindex) headers.set("X-Robots-Tag", "noindex, nofollow");
return new Response(options.head ? null : "Not Found", {
status: 404,
headers,
});
}
// ponytail: GET and HEAD share the same routing logic; method determines body presence
export async function handleWebApp(request: Request) {
const isHead = request.method === "HEAD";
const pathname = new URL(request.url).pathname;
if (!isNoindexShellPath(pathname) && isAssetPath(pathname)) {
return new Response(isHead ? null : "Not Found", { status: 404 });
}
const headers = getFallbackResponseHeaders(pathname);
if (!headers) return notFoundResponse({ head: isHead, noindex: true });
if (isHead) return new Response(null, { status: 200, headers });
const html = await fs.readFile(indexHtmlPath, "utf-8");
const canonicalUrl = new URL("/", env.APP_URL).toString();
if (pathname === "/" && env.ROOT_RESUME_ID?.trim()) {
// Root configuration never discloses a target in the HTML shell. The public API
// gates data and browser metadata; shell requests must not count extra views.
const shell = html
.replace(/<title>[^<]*<\/title>/, "<title>Reactive Resume</title>")
.replace(/<meta\s+name="description"[^>]*>/, '<meta name="description" content="">');
const markup = `<link rel="canonical" href="${escapeAttribute(canonicalUrl)}" data-root-resume-shell><meta name="robots" content="noindex, follow" data-root-resume-shell>`;
return new Response(shell.replace("</head>", `${markup}</head>`), { headers });
}
if (pathname === "/") {
return new Response(html.replace("</head>", `${createRootSeoMarkup(canonicalUrl)}</head>`), { headers });
}
if (pathname === "/ats-checker") {
const origin = new URL(env.APP_URL).origin;
const withTitle = html
.replace(/<title>[^<]*<\/title>/, `<title>${ATS_CHECKER_TITLE}</title>`)
.replace(/<meta\s+name="description"[^>]*>/, `<meta name="description" content="${ATS_CHECKER_DESCRIPTION}">`);
return new Response(withTitle.replace("</head>", `${createAtsCheckerSeoMarkup(origin)}</head>`), { headers });
}
if (isPublicResumePath(pathname)) {
const resumeSeo = await createPublicResumeSeoMarkup(pathname, new URL(env.APP_URL).origin);
if (resumeSeo) {
// The shell's generic title/description are replaced so shares and previews show the resume,
// not the marketing copy baked into index.html.
const withTitle = html
.replace(/<title>[^<]*<\/title>/, `<title>${resumeSeo.pageTitle}</title>`)
.replace(/<meta\s+name="description"[^>]*>/, `<meta name="description" content="${resumeSeo.description}">`);
return new Response(withTitle.replace("</head>", `${resumeSeo.markup}</head>`), { headers });
}
}
return new Response(html, { headers });
}
+99
View File
@@ -0,0 +1,99 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { TRUSTED_IP_HEADERS } from "@reactive-resume/utils/rate-limit";
const mocks = vi.hoisted(() => ({
ipAddress: vi.fn<(request: Request) => string | undefined>(),
waitUntil: vi.fn(),
initializeAuth: vi.fn(),
attachDatabasePool: vi.fn(),
configureAgentStreamLifetime: vi.fn(),
pool: {},
getPool: vi.fn(),
createApp:
vi.fn<
(options: { serveStatic: boolean; trustedClient: (request: Request) => string }) => {
fetch: (request: Request) => Promise<Response>;
}
>(),
handle: vi.fn<(request: Request) => Promise<Response>>(),
}));
vi.mock("@vercel/functions", () => ({
ipAddress: mocks.ipAddress,
waitUntil: mocks.waitUntil,
attachDatabasePool: mocks.attachDatabasePool,
}));
vi.mock("@reactive-resume/api/features/agent/streams", () => ({
configureAgentStreamLifetime: mocks.configureAgentStreamLifetime,
}));
vi.mock("@reactive-resume/auth/config", () => ({ initializeAuth: mocks.initializeAuth }));
vi.mock("@reactive-resume/db/client", () => ({ getPool: mocks.getPool }));
vi.mock("./http/app", () => ({ createApp: mocks.createApp }));
function spoofedRequest() {
return new Request("https://resume.test/api/rpc?batch=1", {
method: "POST",
body: "original RPC body",
headers: {
...Object.fromEntries(TRUSTED_IP_HEADERS.map((header) => [header, "192.0.2.66"])),
"x-forwarded-for": "192.0.2.66, 192.0.2.77",
cookie: "session=original",
authorization: "Bearer original",
"content-type": "application/json",
},
});
}
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
mocks.getPool.mockReturnValue(mocks.pool);
mocks.handle.mockResolvedValue(new Response("handled"));
mocks.createApp.mockReturnValue({ fetch: mocks.handle });
});
describe("Vercel adapter", () => {
it("registers platform lifetime hooks and disables filesystem static serving", async () => {
await import("./vercel");
expect(mocks.configureAgentStreamLifetime).toHaveBeenCalledExactlyOnceWith(mocks.waitUntil);
expect(mocks.attachDatabasePool).toHaveBeenCalledExactlyOnceWith(mocks.pool);
expect(mocks.createApp).toHaveBeenCalledExactlyOnceWith({
serveStatic: false,
trustedClient: expect.any(Function),
});
});
it.each(["203.0.113.9", "2001:db8::9"])("replaces all spoofed IP headers with platform IP %s", async (ip) => {
mocks.ipAddress.mockReturnValue(ip);
const { default: adapter } = await import("./vercel");
const request = spoofedRequest();
expect(await (await adapter.fetch(request)).text()).toBe("handled");
expect(mocks.ipAddress).toHaveBeenCalledExactlyOnceWith(request);
const forwarded = mocks.handle.mock.calls[0]?.[0];
if (!forwarded) throw new Error("Expected forwarded request");
for (const header of TRUSTED_IP_HEADERS) {
const expected = ["x-real-ip", "x-forwarded-for"].includes(header.toLowerCase()) ? ip : null;
expect(forwarded.headers.get(header)).toBe(expected);
}
expect(mocks.createApp.mock.calls[0]?.[0].trustedClient(forwarded)).toBe(ip);
expect(forwarded.url).toBe(request.url);
expect(forwarded.method).toBe("POST");
expect(await forwarded.text()).toBe("original RPC body");
expect(forwarded.headers.get("cookie")).toBe("session=original");
expect(forwarded.headers.get("authorization")).toBe("Bearer original");
expect(forwarded.headers.get("content-type")).toBe("application/json");
});
it.each([undefined, "", "invalid-ip", "203.0.113.9, 192.0.2.66"])(
"clears attacker headers when platform IP is missing or invalid: %s",
async (ip) => {
mocks.ipAddress.mockReturnValue(ip);
const { default: adapter } = await import("./vercel");
await adapter.fetch(spoofedRequest());
const forwarded = mocks.handle.mock.calls[0]?.[0];
if (!forwarded) throw new Error("Expected forwarded request");
for (const header of TRUSTED_IP_HEADERS) expect(forwarded.headers.has(header)).toBe(false);
expect(mocks.createApp.mock.calls[0]?.[0].trustedClient(forwarded)).toBe("unknown");
},
);
});
+29
View File
@@ -0,0 +1,29 @@
import { isIP } from "node:net";
import { attachDatabasePool, ipAddress, waitUntil } from "@vercel/functions";
import { configureAgentStreamLifetime } from "@reactive-resume/api/features/agent/streams";
import { initializeAuth } from "@reactive-resume/auth/config";
import { getPool } from "@reactive-resume/db/client";
import { TRUSTED_IP_HEADERS } from "@reactive-resume/utils/rate-limit";
import { createApp } from "./http/app";
configureAgentStreamLifetime(waitUntil);
attachDatabasePool(getPool());
const app = createApp({
serveStatic: false,
trustedClient: (request) => request.headers.get("x-real-ip") ?? "unknown",
});
export default {
async fetch(request: Request) {
await initializeAuth();
const ip = ipAddress(request);
const headers = new Headers(request.headers);
for (const name of TRUSTED_IP_HEADERS) headers.delete(name);
headers.delete("x-real-ip");
if (ip && isIP(ip)) {
headers.set("x-real-ip", ip);
headers.set("x-forwarded-for", ip);
}
return app.fetch(new Request(request, { headers }));
},
};
+1
View File
@@ -0,0 +1 @@
declare const __APP_VERSION__: string;
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "@reactive-resume/config/tsconfig.base.json",
"include": ["src/**/*.ts", "src/**/*.tsx", "tsdown.config.ts", "vitest.config.ts"],
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ESNext", "DOM"],
"types": ["node"],
"paths": {
"@/*": ["./src/*"]
}
}
}
+83
View File
@@ -0,0 +1,83 @@
import type { TsdownPlugin } from "tsdown";
import { readdirSync, readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "tsdown";
const rootPackageJson = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf-8")) as {
version?: string;
};
// Lambda disables require(ESM) and uses stricter CJS export detection than standalone Node.
const bundledInteropPackages = new Set([
"@uiw/color-convert",
"@babel/runtime",
"sanitize-html",
"htmlparser2",
"domhandler",
"domutils",
"domelementtype",
"dom-serializer",
"entities",
"deepmerge",
"escape-string-regexp",
"is-plain-object",
"parse-srcset",
"postcss",
"nanoid",
"picocolors",
"source-map-js",
"launder",
"dayjs",
]);
const shouldExternalizeThirdParty = (id: string) => {
const packageName = id
.split("/")
.slice(0, id.startsWith("@") ? 2 : 1)
.join("/");
if (id.startsWith("@reactive-resume/") || bundledInteropPackages.has(packageName)) return false;
if (id.startsWith("@/") || id.startsWith(".") || id.startsWith("/") || id.startsWith("\0")) return false;
return true;
};
const aiPromptsDir = resolve(dirname(fileURLToPath(import.meta.url)), "../../packages/ai/src/prompts");
const promptAssetsPlugin: TsdownPlugin = {
name: "prompt-assets",
buildStart() {
for (const filename of readdirSync(aiPromptsDir)) {
if (!filename.endsWith(".md")) continue;
this.emitFile({
type: "asset",
fileName: `prompts/${filename}`,
source: readFileSync(resolve(aiPromptsDir, filename), "utf-8"),
});
}
},
};
export default defineConfig({
entry: { index: "src/index.ts", vercel: "src/vercel.ts", "prepare-deployment": "src/prepare-deployment.ts" },
// Keep import.meta.url-based asset lookup adjacent to the entrypoints.
outputOptions: { chunkFileNames: "[name]-[hash].mjs" },
format: "esm",
platform: "node",
target: "node24",
outDir: "dist",
clean: true,
shims: true,
dts: false,
define: { __APP_VERSION__: JSON.stringify(rootPackageJson.version ?? "0.0.0") },
// The flagged dynamic imports are deliberate: they defer evaluation of env-dependent
// modules so tests can run without env vars, not to split chunks.
suppressWarnings: [/dynamic import will not move module into another chunk/],
outExtensions: () => ({ js: ".mjs" }),
deps: {
alwaysBundle: [/^@reactive-resume\//, ...bundledInteropPackages],
neverBundle: shouldExternalizeThirdParty,
},
plugins: [promptAssetsPlugin],
});
+13
View File
@@ -0,0 +1,13 @@
{
"extends": ["//"],
"tags": ["app:server", "runtime:server", "role:adapter"],
"tasks": {
"test": { "env": ["OAUTH_TEST_DATABASE_URL"] },
"test:coverage": { "env": ["OAUTH_TEST_DATABASE_URL"] },
"test:agent": { "env": ["OAUTH_TEST_DATABASE_URL"] },
"test:ci": {
"cache": false,
"env": ["OAUTH_TEST_DATABASE_URL"]
}
}
}
@@ -1,7 +1,8 @@
import { fileURLToPath } from "node:url";
import { createVitestProjectConfig } from "../../vitest.shared";
// @boundaries-ignore root shared Vitest config
import { createVitestProjectConfig } from "../../vitest.shared.mts";
export default createVitestProjectConfig({
name: "@reactive-resume/config",
name: "server",
dirname: fileURLToPath(new URL(".", import.meta.url)),
});
+62
View File
@@ -0,0 +1,62 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#09090B" />
<meta name="application-name" content="Reactive Resume" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Reactive Resume" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<!-- Keep under ~120 characters so Google's mobile SERP snippet is not truncated at 3 lines. -->
<meta name="description" content="Free, open-source resume builder. Create, update, and share a professional resume in minutes — no ads, no paywall.">
<link rel="icon" href="/favicon.ico" type="image/x-icon" sizes="128x128" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" sizes="256x256 any" />
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" type="image/png" sizes="180x180 any" />
<link rel="manifest" href="/manifest.webmanifest" crossorigin="use-credentials" />
<title>Reactive Resume — A free and open-source resume builder</title>
</head>
<body>
<!-- Keep #app empty: main.tsx only mounts React when rootElement has no children. -->
<div id="app"></div>
<!-- Branded first paint; hidden once React populates #app (higher-specificity rule below). -->
<div id="initial-loader">
<img src="/icon/dark.svg" width="48" height="48" alt="Reactive Resume" />
<div class="initial-loader__spinner"></div>
<span class="initial-loader__sr-only">Loading</span>
</div>
<style>
@keyframes app-spin { to { transform: rotate(360deg) } }
#initial-loader {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
background: #09090b;
}
#app:not(:empty) ~ #initial-loader { display: none; }
.initial-loader__spinner {
width: 24px;
height: 24px;
border: 2px solid rgba(250, 250, 250, 0.2);
border-top-color: #fafafa;
border-radius: 9999px;
animation: app-spin 0.7s linear infinite;
}
.initial-loader__sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
</style>
<script type="module" data-cfasync="false" src="/src/main.tsx"></script>
</body>
</html>
+4757 -1045
View File
File diff suppressed because it is too large Load Diff
+4688 -976
View File
File diff suppressed because it is too large Load Diff
+4682 -970
View File
File diff suppressed because it is too large Load Diff
+4686 -974
View File
File diff suppressed because it is too large Load Diff
+4685 -973
View File
File diff suppressed because it is too large Load Diff
+4688 -976
View File
File diff suppressed because it is too large Load Diff
+4683 -971
View File
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More