chore: update dependencies

This commit is contained in:
Amruth Pillai
2026-07-08 18:54:00 +02:00
parent f9eafdf5cf
commit af5c7dde82
114 changed files with 7209 additions and 2056 deletions
@@ -0,0 +1,259 @@
# Plan 001: Characterization tests for the resume service
> **Executor instructions**: Follow this plan step by step. Run every
> verification command and confirm the expected result before moving to the
> next step. If anything in the "STOP conditions" section occurs, stop and
> report — do not improvise. When done, update the status row for this plan
> in `plans/README.md`.
>
> **Drift check (run first)**: `git diff --stat 73daf22b2..HEAD -- packages/api/src/features/resume/service.ts`
> If `service.ts` changed since this plan was written, compare the "Current
> state" excerpts against the live code before proceeding; on a mismatch,
> treat it as a STOP condition.
## Status
- **Priority**: P1
- **Effort**: M
- **Risk**: LOW
- **Depends on**: none
- **Category**: tests
- **Planned at**: commit `73daf22b2`, 2026-07-08
## Why this matters
`packages/api/src/features/resume/service.ts` is 772 lines and owns every
resume mutation — `create`, `update`, `patch` (JSON Patch application),
`delete`, `duplicate`, `setLocked`, `setPassword`, `removePassword`,
`verifyPassword`, `getBySlug`, plus version-history snapshotting. It has **no
`service.test.ts`**. The only coverage is Playwright e2e, which exercises the
happy path through the UI and cannot assert error codes, lock enforcement, or
snapshot throttling in isolation. This service also has high churn (v5.2.0
added undo/redo + version history). Characterization tests here (a) catch
regressions in CRUD/patch/lock/password behavior before users do, and (b) are
a prerequisite for Plan 003 (which changes not-found behavior in this file)
and Plan 007 (template refactor) — you cannot safely refactor code that has no
behavioral net under it.
The goal is **characterization tests**: capture what the code does today,
locking in current behavior so later changes are deliberate, not accidental.
## Current state
- `packages/api/src/features/resume/service.ts` — the service under test.
Exports a `resumeService` object literal whose methods each take an `input`
object and call the mocked `db`. Key behaviors to pin down:
- `update` (lines ~555635): reads `isLocked`; throws `ORPCError("RESUME_LOCKED")`
if locked; on a successful `UPDATE ... RETURNING`, if `!resume` throws
`ORPCError("NOT_FOUND")`; maps a unique-constraint violation on
`resume_slug_user_id_unique` to `ORPCError("RESUME_SLUG_ALREADY_EXISTS")`.
- `setLocked` (lines ~663679), `setPassword` (~681699), `removePassword`
(~726742): each runs an `UPDATE ... RETURNING`, then `if (!resume) return;`
(silent no-op when no row matches — **this is today's behavior; capture it
as-is. Plan 003 will change it.**), else calls `notifyResumeUpdated`.
- `delete` (lines ~744769): transaction that throws `NOT_FOUND` when the row
is missing and `RESUME_LOCKED` when locked, then deletes and cleans storage.
- `statistics.increment` (lines ~199237): two `INSERT ... ON CONFLICT DO
UPDATE` writes inside a transaction.
- **Test convention to follow** — model the new test after the existing
sibling `packages/api/src/features/applications/service.test.ts`. It mocks
the DB layer with `vi.hoisted` + `vi.mock`, then dynamically imports the
service. The exact shape to copy (from that file, lines 168):
```ts
import { beforeEach, describe, expect, it, vi } from "vitest";
const dbMock = vi.hoisted(() => ({
select: vi.fn(), insert: vi.fn(), update: vi.fn(),
delete: vi.fn(), transaction: vi.fn(),
}));
vi.mock("@reactive-resume/db/client", () => ({ db: dbMock }));
vi.mock("@reactive-resume/db/schema", () => ({ /* stub the tables used */ }));
vi.mock("drizzle-orm", () => ({
and: (...a: unknown[]) => a, eq: (...a: unknown[]) => a,
isNotNull: (...a: unknown[]) => a, sql: Object.assign(
(s: TemplateStringsArray, ...v: unknown[]) => ({ s, v }),
{ join: (v: unknown[]) => v },
),
}));
const { resumeService } = await import("./service");
```
Note the `applications/service.test.ts` helpers `createSelectChain(rows)` and
`setSelectResults(...)` — reuse that pattern to script what each `db.select`
/ `db.update().returning()` call resolves to.
- **Mocks this service needs beyond the applications example** (grep the
imports at the top of `service.ts` and mock each):
- `bcrypt` (`hash`, `compare`) — used by `setPassword`/`verifyPassword`.
(The real import specifier in `service.ts` is `bcrypt`, not `bcryptjs` —
mock that exact module path.) Mock `hash` to return a fixed string and
`compare` to return a boolean you control.
- The snapshot/patch helpers imported into `service.ts` (e.g.
`applyResumePatchTx`, `maybeSnapshotOnSave`, `writeResumeVersion`,
`notifyResumeUpdated`, `getStorageService`) — mock them so the service's
own branching is what's under test, not their internals. Read the top of
`service.ts` to get the exact import specifiers and mock each module path
the same way the applications test mocks `../storage/service`.
- **ORPCError assertions** — errors are `ORPCError` instances from
`@orpc/server` with a `.code` (e.g. `"NOT_FOUND"`, `"RESUME_LOCKED"`). Assert
with `await expect(fn()).rejects.toThrow()` and, where you can, check the
code: `await fn().catch((e) => expect(e.code).toBe("RESUME_LOCKED"))`, or
assert on `.message`. Confirm the real shape by reading how
`access-policy.test.ts` or `access.test.ts` in the same folder assert on
`ORPCError` and copy that style.
## Commands you will need
| Purpose | Command | Expected on success |
|-----------|---------------------------------------------------------------------|---------------------|
| Typecheck | `pnpm --filter @reactive-resume/api typecheck` | exit 0, no errors |
| Run test | `pnpm --filter @reactive-resume/api test -- resume/service.test.ts` | all pass |
| All api tests | `pnpm --filter @reactive-resume/api test` | all pass |
(These are the repo's real commands — package-scoped Vitest via Turborepo.
Do NOT run `pnpm check`; it rewrites files.)
## Scope
**In scope** (the only files you should create/modify):
- `packages/api/src/features/resume/service.test.ts` (create)
- `plans/README.md` (status row only)
**Out of scope** (do NOT touch):
- `packages/api/src/features/resume/service.ts` — this plan adds tests that
characterize its *current* behavior. Do not "fix" anything you find here,
even the silent `if (!resume) return;` no-ops in `setLocked`/`setPassword`/
`removePassword` — those are Plan 003's job, and this test must assert the
current silent-return behavior so Plan 003's change is visible as a test diff.
- Any router file (`crud.ts`, `sharing.ts`) — router tests are a separate
future effort.
- The real database or migrations — this is a pure unit test with a mocked db.
## Git workflow
- Branch: `advisor/001-resume-service-tests`
- Commit style: conventional commits (repo uses them — e.g.
`test(api): add characterization tests for resume service`).
- Do NOT push or open a PR unless the operator instructed it.
## Steps
### Step 1: Scaffold the test file and mocks
Create `packages/api/src/features/resume/service.test.ts`. Copy the mock
scaffolding pattern from `applications/service.test.ts` (lines 168). Read the
top-of-file imports in `service.ts` and add a `vi.mock(...)` for every module
it imports that touches I/O (db, schema, drizzle-orm, bcryptjs, storage
service, and the patch/snapshot/notify helpers). Stub `@reactive-resume/db/schema`
with the table/column objects the service references (`resume`,
`resumeStatistics`, `resumeStatisticsDaily`, `user`).
**Verify**: `pnpm --filter @reactive-resume/api test -- resume/service.test.ts`
→ the file is picked up (even with zero real tests it should not error on
import; add one `it("imports", () => expect(resumeService).toBeDefined())` to
confirm wiring). Expected: 1 passing test.
### Step 2: Characterize `update`
Add a `describe("update")` block with these cases, asserting **current**
behavior:
- Throws `RESUME_LOCKED` when the pre-read returns `{ isLocked: true }`.
- Returns the updated row on success (script `db.update().set().where().returning()`
to resolve `[{ id, name, slug, ... }]`).
- Throws `NOT_FOUND` when the `RETURNING` resolves to `[]` (no row matched).
- Maps a thrown error whose `cause.constraint === "resume_slug_user_id_unique"`
to `RESUME_SLUG_ALREADY_EXISTS`.
**Verify**: `pnpm --filter @reactive-resume/api test -- resume/service.test.ts`
→ all cases pass.
### Step 3: Characterize `setLocked`, `setPassword`, `removePassword`
Add a `describe` for each. For every method assert **both** paths:
- Success path: `RETURNING` resolves to `[{ id, updatedAt }]` → the method
resolves (returns `undefined`) and `notifyResumeUpdated` was called once with
the expected `mutation` value (`"lock"` / `"password"`).
- **Not-found path: `RETURNING` resolves to `[]` → the method resolves
`undefined` and `notifyResumeUpdated` is NOT called** (this pins the current
silent no-op; Plan 003 will flip this to throwing `NOT_FOUND`).
- For `setPassword`, assert `hash` (mocked bcrypt) was called with the input
password before the update.
**Verify**: `pnpm --filter @reactive-resume/api test -- resume/service.test.ts`
→ all pass.
### Step 4: Characterize `verifyPassword` and `delete`
- `verifyPassword`: throws `INVALID_PASSWORD` when no matching row; throws
`INVALID_PASSWORD` when `compare` (mocked) returns `false`; returns `true`
and calls `grantResumeAccess` when `compare` returns `true`.
- `delete`: script the transaction mock (see how `applications/service.test.ts`
handles `db.transaction` — if it doesn't, make `dbMock.transaction` invoke
its callback with a `tx` object exposing the same `select`/`delete` chain).
Assert `NOT_FOUND` when the row is missing, `RESUME_LOCKED` when locked, and
storage `delete` called for both screenshot and pdf keys on success.
**Verify**: `pnpm --filter @reactive-resume/api test -- resume/service.test.ts`
→ all pass.
### Step 5: Characterize `statistics.increment`
Assert that a `views: true` call runs `db.transaction`, and inside it inserts
into both `resumeStatistics` and `resumeStatisticsDaily` with an
`onConflictDoUpdate`. You do not need to assert SQL text — assert that both
`tx.insert(...)` calls happen (spy on the tx insert). This case is the safety
net for Plan 005, which changes when `increment` is *called* (not its body).
**Verify**: `pnpm --filter @reactive-resume/api test` → the whole api package
test suite passes (no regressions in sibling tests from your new mocks).
## Test plan
- New file: `packages/api/src/features/resume/service.test.ts`, structured as
one `describe` per method, following `applications/service.test.ts` as the
structural pattern.
- Cases per method are listed in Steps 25 (happy path + each error/edge branch
the code contains today).
- Verification: `pnpm --filter @reactive-resume/api test` → all pass, including
the new tests. Count the new tests in the output; expect ≥ 14 new cases.
## Done criteria
Machine-checkable. ALL must hold:
- [ ] `packages/api/src/features/resume/service.test.ts` exists
- [ ] `pnpm --filter @reactive-resume/api test -- resume/service.test.ts` exits 0 with ≥ 14 passing cases
- [ ] `pnpm --filter @reactive-resume/api test` exits 0 (no sibling regressions)
- [ ] `pnpm --filter @reactive-resume/api typecheck` exits 0
- [ ] `git status --porcelain` shows only `service.test.ts` (new) and `plans/README.md` modified
- [ ] `plans/README.md` status row for 001 updated to DONE
## STOP conditions
Stop and report back (do not improvise) if:
- `service.ts` has drifted from the excerpts above (method line ranges or error
codes differ materially) — the codebase changed since this plan was written.
- The mocking approach fights the service: e.g. the service imports something
that runs real I/O at module load and can't be cleanly mocked. Report what
and where; do not weaken the test into a no-op.
- You find a genuine bug while characterizing (behavior that looks wrong). Do
NOT fix it here — write the test to capture current behavior, add a
`// NOTE: characterizes current behavior; see finding` comment, and report it.
## Maintenance notes
- Plan 003 changes `setLocked`/`setPassword`/`removePassword` to throw
`NOT_FOUND` instead of silently returning. When that lands, the "not-found
path" assertions from Step 3 must be updated in the same PR — that test diff
is the intended signal that behavior changed on purpose.
- Plan 005 changes the *caller* of `statistics.increment` (dedup), not its body,
so Step 5's test should keep passing; if it breaks, 005 changed more than
intended.
- A reviewer should check the mocks assert real branching, not tautologies
(e.g. that `NOT_FOUND` comes from an empty `RETURNING`, not from a mock that
always throws).
+233
View File
@@ -0,0 +1,233 @@
# Plan 002: Add CSP + framing headers to web pages; gate the uploads CORS header
> **Executor instructions**: Follow this plan step by step. Run every
> verification command and confirm the expected result before moving on. If
> anything in "STOP conditions" occurs, stop and report. When done, update the
> status row for this plan in `plans/README.md`.
>
> **Drift check (run first)**:
> `git diff --stat 73daf22b2..HEAD -- apps/server/src/static/web.ts apps/server/src/static/uploads.ts`
> If either file changed since this plan was written, compare the "Current
> state" excerpts against the live code before proceeding; on mismatch, STOP.
## Status
- **Priority**: P1
- **Effort**: S
- **Risk**: LOW
- **Depends on**: none
- **Category**: security
- **Planned at**: commit `73daf22b2`, 2026-07-08
## Why this matters
The Hono server serves every HTML page (public resumes, the auth/dashboard/
builder shells) through `handleWebApp` in `apps/server/src/static/web.ts`, and
that response sets only `Content-Type` and (sometimes) `X-Robots-Tag`. There is
**no `Content-Security-Policy` and no `X-Frame-Options`**. Consequences:
- Any page can be framed by an attacker's site → clickjacking against
authenticated actions and public resume views.
- No script-source restriction → a single injected-content bug becomes a much
larger XSS blast radius than it needs to be.
Separately, the file-serving endpoint in `apps/server/src/static/uploads.ts`
sets `Access-Control-Allow-Origin: env.APP_URL` **unconditionally** on a GET
endpoint whose only real consumers are same-origin. `Cross-Origin-Resource-Policy:
same-site` is already set, so the ACAO header adds cross-origin exposure for no
functional benefit.
This plan adds the missing headers to web responses and removes the
unnecessary ACAO header. It is deliberately conservative: **CSP ships in
report-only mode first** so it cannot break the app on rollout.
## Current state
- `apps/server/src/static/web.ts:55-65``getFallbackResponseHeaders` returns
a plain object of headers per path (or `null` for a 404):
```ts
function getFallbackResponseHeaders(pathname: string) {
if (pathname === "/") return { "Content-Type": "text/html; charset=UTF-8" };
if (isNoindexShellPath(pathname) || isPublicResumePath(pathname)) {
return {
"Content-Type": "text/html; charset=UTF-8",
"X-Robots-Tag": "noindex, follow",
};
}
return null;
}
```
These objects are spread into the `Response` at `web.ts:86-92` (both the HEAD
`new Response(null, { status: 200, headers })` path and the GET
`new Response(html, { headers })` path).
- `apps/server/src/static/uploads.ts:28-46` — the file response already sets a
strong header set as a model to follow, and ends with the ACAO line to remove:
```ts
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");
headers.set("Access-Control-Allow-Origin", env.APP_URL); // <-- remove this line
```
- **Existing test files that assert headers** (follow their style, they are
your regression net):
- `apps/server/src/static/web.test.ts`
- `apps/server/src/static/uploads.test.ts`
- **Design constraint** — the app has no known legitimate need to be iframed,
and the builder preview is a same-origin pdf.js canvas (not a cross-origin
frame). So `X-Frame-Options: DENY` is safe. The app does load web fonts and
images from same origin and inline styles/scripts from the Vite bundle, which
is why CSP starts **report-only**: do not enforce a policy you have not
observed the app satisfy.
## Commands you will need
| Purpose | Command | Expected on success |
|-----------|----------------------------------------|---------------------|
| Typecheck | `pnpm --filter server typecheck` | exit 0 |
| Test | `pnpm --filter server test -- static` | all pass |
(The server package is named `server` in `apps/server/package.json`, not
`@reactive-resume/server`.)
(Do NOT run `pnpm check`.)
## Scope
**In scope**:
- `apps/server/src/static/web.ts`
- `apps/server/src/static/web.test.ts`
- `apps/server/src/static/uploads.ts`
- `apps/server/src/static/uploads.test.ts`
- `plans/README.md` (status row)
**Out of scope** (do NOT touch):
- The oRPC / auth / MCP / OpenAPI handlers — they return API responses, not
HTML pages; header policy for those is a separate concern.
- Enforcing (non-report-only) CSP — an enforced policy requires collecting
violation reports first; that is explicit follow-up, not this plan.
- Any web-app (`apps/web`) source — headers are set at the server layer.
## Git workflow
- Branch: `advisor/002-security-headers`
- Commit style: conventional commits, e.g.
`feat(server): add CSP report-only and framing headers to web responses`.
- Do NOT push or open a PR unless instructed.
## Steps
### Step 1: Add framing + hardening headers to web responses
In `apps/server/src/static/web.ts`, extend the header objects returned by
`getFallbackResponseHeaders` so that every non-null branch (the `/` branch and
the noindex/public branch) also includes:
```
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "strict-origin-when-cross-origin",
```
Keep the existing `Content-Type` and `X-Robots-Tag` values unchanged. Prefer
adding a small shared constant (e.g. `const BASE_SECURITY_HEADERS = { ... }`)
and spreading it into both branches, so the two paths cannot drift.
**Verify**: `pnpm --filter @reactive-resume/server typecheck` → exit 0.
### Step 2: Add a report-only CSP header to web responses
Add to the same shared header set:
```
"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'",
```
Use `Report-Only` (not the enforcing header) so nothing breaks on rollout.
`'unsafe-inline'` for style/script is intentional for the first pass — the Vite
bundle and inline theme script need it; tightening to nonces is future work.
`frame-ancestors 'none'` is the CSP-level equivalent of `X-Frame-Options: DENY`.
**Verify**: `pnpm --filter @reactive-resume/server typecheck` → exit 0.
### Step 3: Remove the unconditional CORS header from uploads
In `apps/server/src/static/uploads.ts`, delete the line:
```ts
headers.set("Access-Control-Allow-Origin", env.APP_URL);
```
Leave every other header untouched. If, after removal, `env` is no longer
referenced anywhere in the file, remove its now-unused import (check with the
grep in Done criteria); if `env` is still used, keep the import.
**Verify**: `pnpm --filter @reactive-resume/server typecheck` → exit 0
(no unused-import or undefined-symbol errors).
### Step 4: Update/extend tests
- In `web.test.ts`: add assertions that a GET to `/` and to a public resume
path returns `X-Frame-Options: DENY` and a `Content-Security-Policy-Report-Only`
header. Follow the existing test's request/response style in that file.
- In `uploads.test.ts`: if an existing test asserts the presence of
`Access-Control-Allow-Origin`, change it to assert the header is **absent**
(`response.headers.get("Access-Control-Allow-Origin")` is `null`). Keep the
assertions for `Cross-Origin-Resource-Policy` and the others.
**Verify**: `pnpm --filter @reactive-resume/server test -- static` → all pass.
## Test plan
- Extend `web.test.ts` with two cases: `/` and a public-resume path each carry
the new framing + CSP-report-only headers.
- Update `uploads.test.ts` so the ACAO header is asserted absent (and the other
security headers still present).
- Verification: `pnpm --filter @reactive-resume/server test -- static` → all
pass, including the new/updated assertions.
## Done criteria
Machine-checkable. ALL must hold:
- [ ] `pnpm --filter @reactive-resume/server typecheck` exits 0
- [ ] `pnpm --filter @reactive-resume/server test -- static` exits 0
- [ ] `grep -n "X-Frame-Options" apps/server/src/static/web.ts` returns a match
- [ ] `grep -n "Content-Security-Policy-Report-Only" apps/server/src/static/web.ts` returns a match
- [ ] `grep -n "Access-Control-Allow-Origin" apps/server/src/static/uploads.ts` returns **no** matches
- [ ] `git status --porcelain` lists only the four in-scope source/test files and `plans/README.md`
- [ ] `plans/README.md` status row for 002 updated
## STOP conditions
Stop and report back if:
- `web.ts`/`uploads.ts` have drifted from the excerpts above.
- Removing the ACAO header breaks an existing test that documents a *legitimate*
cross-origin consumer of `/uploads/*` you were unaware of (read the test's
intent before assuming it's stale) — report it instead of forcing the change.
- You are tempted to ship an **enforcing** CSP (not report-only) — that is out
of scope and can break the app; stop and confirm.
## Maintenance notes
- The CSP is report-only. Follow-up (separate plan): wire a report endpoint or
read browser console CSP reports, confirm the app fully satisfies the policy,
then promote `Content-Security-Policy-Report-Only` → `Content-Security-Policy`
and drop `'unsafe-inline'` in favor of nonces where feasible.
- If a future feature legitimately needs the app embeddable (e.g. an official
embed widget), `frame-ancestors`/`X-Frame-Options` must be relaxed for that
route only, not globally.
- Reviewer should confirm the header set is shared between both `web.ts`
branches (no drift) and that no API/JSON responses accidentally inherit the
HTML CSP.
+238
View File
@@ -0,0 +1,238 @@
# Plan 003: Fix silent-success mutations and bound bulk-operation inputs
> **Executor instructions**: Follow step by step. Run every verification
> command and confirm the expected result before moving on. Honor "STOP
> conditions". When done, update the status row in `plans/README.md`.
>
> **Drift check (run first)**:
> `git diff --stat 73daf22b2..HEAD -- packages/api/src/features/resume/service.ts packages/api/src/dto/application.ts packages/api/src/features/applications/service.ts`
> If any changed since this plan was written, compare "Current state" excerpts
> against live code; on mismatch, STOP.
## Status
- **Priority**: P1
- **Effort**: S
- **Risk**: LOW
- **Depends on**: 001 (its Step 3 tests characterize the current silent-return
behavior this plan changes — update them here). Not a hard blocker, but if
001 is DONE you must update its assertions in the same PR.
- **Category**: bug
- **Planned at**: commit `73daf22b2`, 2026-07-08
## Why this matters
Two small, independent correctness/robustness fixes on the API layer:
1. **Silent-success mutations.** `setLocked`, `setPassword`, and
`removePassword` in `packages/api/src/features/resume/service.ts` run an
`UPDATE ... WHERE id = ? AND userId = ? RETURNING`, and when no row matches
they do `if (!resume) return;` — returning HTTP 200 success. Every other
mutation in this file (`update`, `delete`) throws `ORPCError("NOT_FOUND")` in
the same situation. The inconsistency means a client calling these on a
resume that doesn't exist (or isn't theirs) is told it succeeded, so the UI
updates its state as if the lock/password change took effect. Ownership is
still enforced (the `WHERE` includes `userId`), so this is not an auth
bypass — it's a misleading false-success that hides not-found/not-owned.
2. **Unbounded bulk inputs.** `bulkUpdate` and `bulkDelete` DTOs in
`packages/api/src/dto/application.ts` accept `ids: z.array(z.string()).min(1)`
with **no `.max()`**. `bulkDelete`'s service loads all target rows into
memory before deleting. A single API call with a very large `ids` array is
unbounded memory/DB work. A `.max()` cap is a one-line defensive fix.
## Current state
### Part A — silent-success (resume/service.ts)
`setLocked` (~663679), `setPassword` (~681699), `removePassword` (~726742)
each look like this (setLocked shown; the other two differ only in the `.set`
and the `mutation` label):
```ts
setLocked: async (input: { id: string; userId: string; isLocked: boolean }) => {
const [resume] = await db
.update(schema.resume)
.set({ isLocked: input.isLocked })
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)))
.returning({ id: schema.resume.id, updatedAt: schema.resume.updatedAt });
if (!resume) return; // <-- silent no-op; should throw NOT_FOUND
await notifyResumeUpdated({ /* ... mutation: "lock" ... */ });
},
```
The reference behavior to match is `update` (~603) and `delete` (~751):
`if (!resume) throw new ORPCError("NOT_FOUND");`. `ORPCError` is already imported
at the top of `service.ts` (used throughout).
The routers that call these (for context; **do not change them**):
- `crud.ts:187` `setLocked``protectedProcedure`, does not currently declare a
`NOT_FOUND` error in `.errors({...})`.
- `sharing.ts:29` `setPassword`, `sharing.ts:80` `removePassword` — likewise.
oRPC will surface a thrown `ORPCError("NOT_FOUND")` as a 404 regardless of
whether it's declared in `.errors()`, matching how `update`/`delete` already
behave (they throw the same without special router declarations). So no router
change is required.
### Part B — unbounded bulk inputs (dto/application.ts)
`packages/api/src/dto/application.ts:163-176`:
```ts
bulkUpdate: {
input: z.object({
ids: z.array(z.string()).min(1),
status: applicationStatusSchema.optional(),
archived: z.boolean().optional(),
addTags: z.array(z.string()).optional(),
}),
output: z.object({ updated: z.number() }),
},
bulkDelete: {
input: z.object({ ids: z.array(z.string()).min(1) }),
output: z.object({ deleted: z.number() }),
},
```
The consuming service is `packages/api/src/features/applications/service.ts`
(`bulkUpdate` ~340381, `bulkDelete` ~383396; `bulkDelete` fetches all target
rows into memory before deleting).
## Commands you will need
| Purpose | Command | Expected |
|-----------|-------------------------------------------------------------------------|--------------------|
| Typecheck | `pnpm --filter @reactive-resume/api typecheck` | exit 0 |
| Test A | `pnpm --filter @reactive-resume/api test -- resume/service.test.ts` | all pass |
| Test B | `pnpm --filter @reactive-resume/api test -- applications` | all pass |
| All api | `pnpm --filter @reactive-resume/api test` | all pass |
(Do NOT run `pnpm check`.)
## Scope
**In scope**:
- `packages/api/src/features/resume/service.ts` (Part A)
- `packages/api/src/features/resume/service.test.ts` (update if 001 is DONE; else
the tests may not exist yet — see Step 3)
- `packages/api/src/dto/application.ts` (Part B)
- `packages/api/src/dto/application.test.ts` if one exists, else add a focused
test near the applications service tests (Step 4)
- `plans/README.md` (status row)
**Out of scope**:
- `crud.ts` / `sharing.ts` routers — no change needed (see Current state).
- The applications service body — a `.max()` on input is sufficient; do not
also rewrite the in-memory `bulkDelete` scan in this plan (that's a separate
perf concern tracked elsewhere).
- Any other service method.
## Git workflow
- Branch: `advisor/003-api-quick-fixes`
- Commit style: conventional commits, e.g.
`fix(api): throw NOT_FOUND on lock/password mutations for missing resume` and
`fix(api): cap bulk application operation id arrays`.
- Do NOT push or open a PR unless instructed.
## Steps
### Step 1: Throw NOT_FOUND in the three mutations
In `resume/service.ts`, change `if (!resume) return;` to
`if (!resume) throw new ORPCError("NOT_FOUND");` in **all three** of `setLocked`,
`setPassword`, `removePassword`. Match the exact form used by `update`/`delete`
in the same file.
**Verify**: `pnpm --filter @reactive-resume/api typecheck` → exit 0.
### Step 2: Cap the bulk id arrays
In `dto/application.ts`, add `.max(200, "Too many items in a single bulk
operation")` to the `ids` array in **both** `bulkUpdate` and `bulkDelete`
inputs (i.e. `z.array(z.string()).min(1).max(200)`). 200 comfortably exceeds
the UI's page size (~25) while bounding abuse; if a comment nearby documents a
different intended cap, use that number and note it.
**Verify**: `pnpm --filter @reactive-resume/api typecheck` → exit 0.
### Step 3: Update / add tests for Part A
- If Plan 001 is DONE (`resume/service.test.ts` exists): update its `setLocked`
/ `setPassword` / `removePassword` "not-found path" cases to now assert the
method **rejects with `NOT_FOUND`** (previously they asserted a silent
resolve). This test diff is the intended proof the behavior changed.
- If Plan 001 is NOT done (no test file): add a minimal
`resume/service.test.ts` covering just these three methods' not-found →
`NOT_FOUND` behavior and success path, using
`packages/api/src/features/applications/service.test.ts` as the mock pattern.
**Verify**: `pnpm --filter @reactive-resume/api test -- resume/service.test.ts`
→ all pass; the three not-found cases assert `NOT_FOUND`.
### Step 4: Test for Part B
Add a small test that `bulkUpdate`/`bulkDelete` input schemas reject an `ids`
array longer than the cap and accept one at the cap. If
`packages/api/src/dto/application.test.ts` exists, add there; otherwise create
it. Example shape:
```ts
import { describe, expect, it } from "vitest";
import { applicationDto } from "./application"; // confirm the real export name
it("rejects oversized bulk id arrays", () => {
const ids = Array.from({ length: 201 }, (_, i) => String(i));
expect(applicationDto.bulkDelete.input.safeParse({ ids }).success).toBe(false);
});
```
Confirm the actual export name/shape by reading the top and bottom of
`dto/application.ts` before writing the import.
**Verify**: `pnpm --filter @reactive-resume/api test -- applications` and
`pnpm --filter @reactive-resume/api test` → all pass.
## Test plan
- Part A: three methods now reject with `NOT_FOUND` on no-match (updated or new
cases in `resume/service.test.ts`).
- Part B: bulk input schemas reject arrays over the cap, accept at the cap.
- Verification: `pnpm --filter @reactive-resume/api test` → all pass.
## Done criteria
Machine-checkable. ALL must hold:
- [ ] `grep -n "if (!resume) return;" packages/api/src/features/resume/service.ts` returns **no** matches
- [ ] `grep -c "throw new ORPCError(\"NOT_FOUND\")" packages/api/src/features/resume/service.ts` is ≥ 5 (update, delete, + the 3 new)
- [ ] `grep -n ".max(" packages/api/src/dto/application.ts` shows the cap on both bulk inputs
- [ ] `pnpm --filter @reactive-resume/api typecheck` exits 0
- [ ] `pnpm --filter @reactive-resume/api test` exits 0
- [ ] `git status --porcelain` lists only in-scope files + `plans/README.md`
- [ ] `plans/README.md` status row for 003 updated
## STOP conditions
Stop and report if:
- The three methods no longer contain `if (!resume) return;` (already fixed by
someone else, or drifted) — reconcile against live code before editing.
- Throwing `NOT_FOUND` breaks an e2e or unit test that *depended on* the silent
success (a client that fires these against unknown ids and expects 200) —
report the caller; do not revert the fix without confirming intent.
- The applications DTO export name differs from `applicationDto` — read the file
and use the real name; do not guess.
## Maintenance notes
- The `.max(200)` cap is an input guard, not a fix for `bulkDelete`'s O(n)
in-memory scan — that remains a known, separate perf item. If bulk operations
ever need to exceed the cap, revisit both the cap and the scan together.
- If the routers later add explicit `.errors({ NOT_FOUND: ... })` declarations
for these procedures (for nicer OpenAPI docs), that's compatible with this
change but not required by it.
+116
View File
@@ -0,0 +1,116 @@
# Plan 004: Correct the README "Custom CSS" feature claim
> **Executor instructions**: Follow step by step. Confirm each verification.
> Honor "STOP conditions". When done, update the status row in `plans/README.md`.
>
> **Drift check (run first)**: `git diff --stat 73daf22b2..HEAD -- README.md`
> If `README.md` changed, re-locate the line described below before editing.
## Status
- **Priority**: P3
- **Effort**: S
- **Risk**: LOW
- **Depends on**: none
- **Category**: docs
- **Planned at**: commit `73daf22b2`, 2026-07-08
## Why this matters
The README advertises a feature that no longer exists. Raw custom CSS was
removed and replaced by a structured Style Rules system — a decision recorded
in `docs/adr/0002-structured-style-rules-for-react-pdf.md` (React PDF accepts
style objects, not arbitrary browser selectors, so user CSS would be a
misleading contract). A user reading the README expects a CSS code editor and
instead finds structured form controls. Fixing the line keeps the public
feature list truthful and consistent with the ADR.
## Current state
- `README.md:63` — under the **Templates** feature list:
```
- Custom CSS for advanced styling
```
- `docs/adr/0002-structured-style-rules-for-react-pdf.md` — states raw CSS is
intentionally out; appearance customization is modeled as structured Style
Rules targeting semantic section/rich-text slots.
- The actual UI lives at
`apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/custom-styles.tsx`
(structured controls: color pickers, number inputs, dropdowns — not a CSS
editor). You do not need to modify it; it is cited only to confirm the fix
wording matches reality.
## Commands you will need
| Purpose | Command | Expected |
|----------------|--------------------------------------|---------------------|
| Locate line | `grep -n "Custom CSS" README.md` | one match (~line 63)|
| Docs lint (opt)| `pnpm lint:docs` | exit 0 |
(Do NOT run `pnpm check` — it rewrites files. This is a one-line docs edit.)
## Scope
**In scope**:
- `README.md` (the single feature-list line)
- `plans/README.md` (status row)
**Out of scope**:
- The Style Rules implementation or ADR.
- Any other README line, unless the drift check shows the feature list moved.
- The Mintlify docs under `docs/` — a separate concern.
## Git workflow
- Branch: `advisor/004-readme-custom-css`
- Commit: `docs: correct README custom-CSS claim to Style Rules`
- Do NOT push or open a PR unless instructed.
## Steps
### Step 1: Replace the line
Change `README.md:63` from:
```
- Custom CSS for advanced styling
```
to:
```
- Structured Style Rules for section and text styling
```
Match the surrounding list's capitalization and punctuation (no trailing
period — the sibling bullets have none).
**Verify**: `grep -n "Custom CSS" README.md` → **no** matches;
`grep -n "Structured Style Rules" README.md` → one match.
## Test plan
No code tests. Optional: `pnpm lint:docs` → exit 0 (markdownlint clean).
## Done criteria
- [ ] `grep -n "Custom CSS" README.md` returns no matches
- [ ] `grep -n "Style Rules" README.md` returns the new line
- [ ] `git status --porcelain` lists only `README.md` and `plans/README.md`
- [ ] `plans/README.md` status row for 004 updated
## STOP conditions
Stop and report if:
- The "Custom CSS" line is already gone or already reworded (someone fixed it).
- The drift check shows the Templates feature list was substantially
restructured — re-locate the correct line before editing.
## Maintenance notes
- If Style Rules ever gains a raw-CSS escape hatch (ADR 0002 lists it as
possible future work), revisit this line.
+224
View File
@@ -0,0 +1,224 @@
# Plan 005: Deduplicate public-resume view-count writes
> **Executor instructions**: Follow step by step. Run every verification and
> confirm before moving on. Honor "STOP conditions". When done, update the
> status row in `plans/README.md`.
>
> **Drift check (run first)**:
> `git diff --stat 73daf22b2..HEAD -- packages/api/src/features/resume/service.ts packages/api/src/features/resume/access-policy.ts`
> If either changed since this plan, compare "Current state" excerpts against
> live code; on mismatch, STOP.
## Status
- **Priority**: P2
- **Effort**: M
- **Risk**: MED
- **Depends on**: 001 recommended (its Step 5 test characterizes
`statistics.increment`, giving you a safety net that this plan changes the
*caller*, not the write body).
- **Category**: perf
- **Planned at**: commit `73daf22b2`, 2026-07-08
## Why this matters
Every public-resume view triggers a database write. `getBySlug`
(`packages/api/src/features/resume/service.ts:475`) calls
`statistics.increment(...)` whenever `shouldCountForStatistics` is true, and
`increment` (lines 199237) runs a transaction with **two** `INSERT ... ON
CONFLICT DO UPDATE` statements (`resumeStatistics` + `resumeStatisticsDaily`).
This fires on every non-owner load — bots, crawlers, and refreshes included.
TanStack Query's 60s `staleTime` only suppresses within-session refetches; it
does nothing for distinct sessions or server-side crawlers. A popular public
resume therefore drives continuous write traffic to two tables for what is
functionally the same view counted many times.
This plan adds a short-window per-viewer dedup so a burst of views from the
same client within a window counts once, cutting write volume without changing
what the counters mean to users (roughly "unique-ish views per window").
## Current state
- `packages/api/src/features/resume/service.ts:475``getBySlug` receives
`input.requestHeaders: Headers`, resolves the resume, and at lines 505507:
```ts
if (shouldCountForStatistics(resume, viewer)) {
await resumeService.statistics.increment({ id: resume.id, views: true });
}
```
- `packages/api/src/features/resume/access-policy.ts:76` —
`shouldCountForStatistics(resume, viewer)` decides *whether* a view counts
(e.g. skip the owner). This plan adds an orthogonal *"have we already counted
this viewer recently?"* gate; it does **not** change `shouldCountForStatistics`.
- `increment` (`service.ts:199-237`) — the two-table transactional write. **Do
not change its body**; this plan changes only whether it is called.
- **Reusable client-identity helper**: `packages/utils/src/rate-limit.ts`
exports `TRUSTED_IP_HEADERS` (the ordered list of proxy IP headers the app
trusts). The rate-limit middleware already derives a client key from these
headers the same way. Reuse `TRUSTED_IP_HEADERS` to read the viewer IP from
`input.requestHeaders`; fall back to a `user-agent`-based key when no trusted
IP header is present (mirror the middleware's fallback so behavior is
consistent).
- **Environment**: `REDIS_URL` exists in `packages/env/src/server.ts` and
`turbo.json` globalEnv, but the app runs as a single Node process by default
and the existing rate limiter uses an **in-memory** store
(`@orpc/experimental-ratelimit/memory`). Match that: an in-memory TTL cache
is the right default here.
## Commands you will need
| Purpose | Command | Expected |
|-----------|--------------------------------------------------------------------|-----------|
| Typecheck | `pnpm --filter @reactive-resume/api typecheck` | exit 0 |
| Test | `pnpm --filter @reactive-resume/api test -- resume` | all pass |
| All api | `pnpm --filter @reactive-resume/api test` | all pass |
(Do NOT run `pnpm check`.)
## Scope
**In scope**:
- A new small helper module, e.g.
`packages/api/src/features/resume/view-dedup.ts` (create) — an in-memory
TTL set keyed by `${resumeId}:${clientKey}` with a `shouldCountView(...)`
function that returns `true` at most once per key per window.
- `packages/api/src/features/resume/view-dedup.test.ts` (create)
- `packages/api/src/features/resume/service.ts` — gate the `increment` call
(lines 505507) on the new helper.
- `plans/README.md` (status row)
**Out of scope**:
- `increment`'s write body and the DB schema — unchanged.
- `shouldCountForStatistics` in `access-policy.ts` — unchanged.
- Any Redis / distributed-cache implementation — an in-memory window is the
agreed default; a distributed store is a documented future upgrade, not this
plan.
- Download counting (`downloads: true`) — this plan is about view writes only;
do not alter download increments.
## Git workflow
- Branch: `advisor/005-stats-view-dedup`
- Commit: `perf(api): dedup public-resume view increments within a short window`
- Do NOT push or open a PR unless instructed.
## Steps
### Step 1: Write the dedup helper
Create `view-dedup.ts` exporting a pure-ish, testable function. Suggested
shape (keep it small — this is not a cache library):
```ts
// ponytail: in-memory per-process dedup window. Single-instance is the default
// deploy; for multi-instance, swap the Map for a Redis SETNX+EXPIRE keyed the
// same way (REDIS_URL already exists in env). Upgrade only if you scale out.
const WINDOW_MS = 60 * 60 * 1000; // 1 hour
const seen = new Map<string, number>(); // key -> expiry timestamp
export function shouldCountView(key: string, now: number): boolean {
const expiry = seen.get(key);
if (expiry !== undefined && expiry > now) return false;
seen.set(key, now + WINDOW_MS);
return true;
}
export function clientKeyFromHeaders(headers: Headers): string { /* uses TRUSTED_IP_HEADERS, UA fallback */ }
```
Take `now` as a parameter (don't call `Date.now()` inside the predicate) so the
test can drive the clock deterministically. Add a lightweight size guard: if
`seen.size` exceeds a cap (e.g. 50_000), prune entries whose expiry has passed
before inserting, so the Map can't grow unbounded. Reuse `TRUSTED_IP_HEADERS`
from `@reactive-resume/utils/rate-limit` inside `clientKeyFromHeaders`.
**Verify**: `pnpm --filter @reactive-resume/api typecheck` → exit 0.
### Step 2: Test the helper
Create `view-dedup.test.ts`:
- `shouldCountView(key, t)` returns `true` the first time, `false` for the same
key within the window, and `true` again once `now` is past the window.
- Two different keys are independent.
- `clientKeyFromHeaders` derives distinct keys for distinct trusted-IP headers
and a stable key for the UA fallback when no IP header is present.
**Verify**: `pnpm --filter @reactive-resume/api test -- view-dedup` → all pass.
### Step 3: Gate the increment call
In `service.ts` `getBySlug`, wrap the existing count so it fires only when both
the policy allows it AND the viewer hasn't been counted this window:
```ts
if (shouldCountForStatistics(resume, viewer)) {
const key = `${resume.id}:${clientKeyFromHeaders(input.requestHeaders)}`;
if (shouldCountView(key, Date.now())) {
await resumeService.statistics.increment({ id: resume.id, views: true });
}
}
```
Do not change anything else in `getBySlug`.
**Verify**: `pnpm --filter @reactive-resume/api typecheck` → exit 0.
### Step 4: Confirm no regression in existing resume tests
**Verify**: `pnpm --filter @reactive-resume/api test -- resume` → all pass. In
particular, if Plan 001's `statistics.increment` characterization test exists,
it should still pass (this plan didn't touch `increment`'s body). If a
`getBySlug` test asserts increment-on-view, it may now need the test to pass a
fresh header/clock so the first view still counts — update it to reflect the
dedup (first view counts, immediate repeat does not).
## Test plan
- `view-dedup.test.ts`: window behavior + key derivation (Step 2 cases).
- If an existing `getBySlug` test asserts view counting, extend it: first call
with a given client key increments; an immediate second call with the same
key does not; a call with a different key does.
- Verification: `pnpm --filter @reactive-resume/api test` → all pass.
## Done criteria
Machine-checkable. ALL must hold:
- [ ] `packages/api/src/features/resume/view-dedup.ts` and its `.test.ts` exist
- [ ] `pnpm --filter @reactive-resume/api test -- view-dedup` passes with ≥ 4 cases
- [ ] `grep -n "shouldCountView" packages/api/src/features/resume/service.ts` shows the gate around the increment call
- [ ] `pnpm --filter @reactive-resume/api typecheck` exits 0
- [ ] `pnpm --filter @reactive-resume/api test` exits 0
- [ ] `git status --porcelain` lists only in-scope files + `plans/README.md`
- [ ] `plans/README.md` status row for 005 updated
## STOP conditions
Stop and report if:
- `getBySlug` no longer calls `statistics.increment` at lines ~505507, or its
signature no longer exposes `requestHeaders` — the code drifted.
- You cannot derive a client key from `input.requestHeaders` because headers
aren't actually populated at this layer in practice (check a real request
path / existing rate-limit middleware usage) — report it; a dedup keyed on an
empty header is worthless.
- You find that view counting must remain exact (product decision that every
raw hit counts) — the dedup changes counter semantics slightly; if unsure,
stop and confirm before shipping.
## Maintenance notes
- **Semantics change**: counters become "unique-ish views per 1h window per
client" rather than "raw hits". Document this near the helper. If the product
wants raw hit counts back, this gate is the single place to remove.
- **Multi-instance ceiling**: the in-memory Map is per-process. If the app is
ever horizontally scaled, each instance dedups independently (still a large
reduction, but not global). The ponytail comment names Redis as the upgrade
path; do it only if scale-out happens.
- Reviewer should confirm `Date.now()` is only called at the call site (Step 3),
not inside the predicate, so the helper stays testable.
+191
View File
@@ -0,0 +1,191 @@
# Plan 006: Stop re-mapping the font list per combobox instance; defer the font-metadata payload
> **Executor instructions**: Follow step by step. Confirm each verification.
> Honor "STOP conditions". When done, update the status row in `plans/README.md`.
>
> **Drift check (run first)**:
> `git diff --stat 73daf22b2..HEAD -- apps/web/src/components/typography/combobox.tsx apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx packages/fonts/src/index.ts`
> If any changed, compare "Current state" excerpts against live code; on
> mismatch, STOP.
## Status
- **Priority**: P2
- **Effort**: S
- **Risk**: LOW
- **Depends on**: none
- **Category**: perf
- **Planned at**: commit `73daf22b2`, 2026-07-08
## Why this matters
`packages/fonts/src/webfontlist.json` is a **476 KB** metadata file (7,526
lines). `packages/fonts/src/index.ts:7` imports it statically and derives
`fontList` (~506 entries). The typography combobox
(`apps/web/src/components/typography/combobox.tsx:11-24`) maps that full list
into option objects inside a `useMemo(..., [])` — which memoizes **per component
instance**, not globally. Two instances render (body + heading font pickers), so
the 506-entry map runs twice at mount for identical output.
Two honest, bounded improvements:
1. **Guaranteed win**: hoist the options mapping to module scope so it runs
once per process regardless of instance count. Small but free and correct.
2. **Payload win (conditional)**: the font metadata only matters where the font
picker renders (the builder typography panel). Deferring it keeps it out of
any route/panel that never opens the picker — *if* the bundler isn't already
forced to load it there for another reason (PDF font registration also
consumes `@reactive-resume/fonts`). Step 3 measures before changing, and
reports back rather than forcing a change that yields nothing.
Scope this realistically: #1 is certain; #2 is worth doing only if the build
shows the payload actually lands on a route that doesn't need it.
## Current state
- `apps/web/src/components/typography/combobox.tsx:1-27``FontFamilyCombobox`:
```ts
import { fontList, getFont, getFontDisplayName, getFontSearchKeywords, sortFontWeights } from "@reactive-resume/fonts";
// ...
export function FontFamilyCombobox({ className, ...props }: FontFamilyComboboxProps) {
const options = useMemo(() => {
return fontList.map((font) => ({
value: font.family,
keywords: getFontSearchKeywords(font.family),
label: <FontDisplay family={font.family} label={getFontDisplayName(font.family)} type={font.type}
url={"preview" in font ? font.preview : undefined} />,
}));
}, []);
return <Combobox {...props} options={options} className={cn("w-full", className)} />;
}
```
The `label` is JSX (`<FontDisplay .../>`), so the mapped array holds React
elements — hoisting must keep the elements' props static (they are: derived
purely from `font`). Nothing in the map depends on component props.
- **Single usage site**:
`apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx:14`
imports and renders `FontFamilyCombobox` (line ~106) and `FontWeightCombobox`
(line ~133). No other file uses these components.
- `packages/fonts/src/index.ts` — statically imports `webfontlist.json` (line 7)
and exports `fontList`, `getFont`, `getFontDisplayName`,
`getFontSearchKeywords`, `sortFontWeights` (used by the combobox) plus
registration helpers used by `packages/pdf`
(`packages/pdf/src/hooks/use-register-fonts.ts`).
- Bundler note: TanStack Router has `autoCodeSplitting`; per prior analysis the
font metadata resolves into the `pdf-document` chunk, not the main entry.
This is exactly why Step 3 measures before assuming a payload win exists.
## Commands you will need
| Purpose | Command | Expected |
|-----------------|---------------------------------------------------|---------------------|
| Typecheck (web) | `pnpm --filter web typecheck` | exit 0 |
| Web tests | `pnpm --filter web test -- typography` | all pass (or none) |
| Build (Step 3) | `pnpm --filter web build` | exit 0; chunk sizes printed |
(Do NOT run `pnpm check`.)
## Scope
**In scope**:
- `apps/web/src/components/typography/combobox.tsx`
- `apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx`
(only if Step 3 justifies lazy-loading)
- `plans/README.md` (status row)
**Out of scope**:
- `packages/fonts/*` — do not restructure the fonts package or change what
`@reactive-resume/fonts` exports. The win here is on the web-app consumer side.
- `packages/pdf` font registration — unrelated consumer.
- The 476 KB JSON content itself.
## Git workflow
- Branch: `advisor/006-font-combobox`
- Commit: `perf(web): compute font-picker options once at module scope`
- Do NOT push or open a PR unless instructed.
## Steps
### Step 1: Hoist the font options to module scope
In `combobox.tsx`, move the `fontList.map(...)` computation out of the component
into a module-level constant (e.g. `const FONT_FAMILY_OPTIONS = fontList.map(...)`)
and pass that constant to `<Combobox options={FONT_FAMILY_OPTIONS} />`. Remove
the now-unnecessary `useMemo` for `FontFamilyCombobox` (its dep array is empty
and the value is now module-constant). Leave `FontWeightCombobox` unchanged —
its options depend on the `fontFamily` prop and must stay per-instance.
**Verify**: `pnpm --filter web typecheck` → exit 0.
### Step 2: Confirm the picker still renders
If a typography test exists, run it; otherwise this is verified by typecheck +
the build in Step 3. Ensure `FontDisplay` still receives the same props.
**Verify**: `pnpm --filter web test -- typography` → passes or reports no tests.
### Step 3: Measure, then decide on deferral (investigate — may end in a report)
Run `pnpm --filter web build` and note the chunk that contains the font
metadata (search the build output / `apps/web/dist/assets` for the large
chunk; the 476 KB JSON shows up as a ~400500 KB contribution). Determine
whether that chunk loads on a route that does **not** need the font picker
(e.g. the dashboard or a public resume page).
- **If the font metadata already only loads with the PDF/preview chunk that the
builder needs anyway** → deferring the combobox yields ~nothing. STOP and
report this finding in `plans/README.md`'s status note; do not add lazy
loading. Step 1 stands on its own as the deliverable.
- **If the font metadata loads on a route with no font picker** → wrap
`FontFamilyCombobox`/`FontWeightCombobox` at the `typography.tsx` usage site in
`React.lazy` + `Suspense` (import the combobox module via dynamic `import()`),
so `@reactive-resume/fonts` is fetched only when the typography panel mounts.
Keep the fallback minimal (the existing field skeleton or a small spinner).
**Verify**: `pnpm --filter web build` → exit 0; record the before/after chunk
observation in the PR description.
## Test plan
- Step 1 is behavior-preserving; typecheck + build are the gates.
- If Step 3 adds lazy loading, manually confirm (or via an existing e2e that
opens the typography panel) that the font picker still populates.
- No new unit test is required for a pure hoist; if `typography.tsx` gains a
Suspense boundary, ensure any existing builder e2e still passes.
## Done criteria
Machine-checkable. ALL must hold:
- [ ] `grep -n "FONT_FAMILY_OPTIONS\|const .*= fontList.map" apps/web/src/components/typography/combobox.tsx` shows a module-scope constant
- [ ] `FontFamilyCombobox` no longer wraps the family options in `useMemo`
- [ ] `pnpm --filter web typecheck` exits 0
- [ ] `pnpm --filter web build` exits 0
- [ ] `git status --porcelain` lists only in-scope files + `plans/README.md`
- [ ] `plans/README.md` status row for 006 updated (note whether Step 3 added
lazy loading or reported it unnecessary)
## STOP conditions
Stop and report if:
- Hoisting breaks the `FontDisplay` label rendering (elements need per-instance
data) — re-check; the current props are purely `font`-derived, so this should
not happen. If it does, the component drifted.
- Step 3's measurement is ambiguous or the build doesn't surface chunk sizes —
report what you observed rather than guessing; do not add lazy loading on a
hunch.
## Maintenance notes
- This is the lowest-leverage item in the current batch; Step 1 is the sure
thing. Do not over-engineer a fonts-package split — that has broad blast
radius (PDF registration depends on the same exports) and isn't justified by
the payload analysis.
- If the fonts list ever grows substantially or becomes user-configurable,
revisit a proper lazy/virtualized font source.
+279
View File
@@ -0,0 +1,279 @@
# Plan 007: Extract a shared template page-shell — spike + one-template pilot
> **Executor instructions**: This is a **spike + pilot**, not a 15-file
> rewrite. You will build a parity net, refactor exactly ONE template behind it,
> and then STOP and report. Do NOT migrate the other templates in this plan.
> Run every verification. Honor "STOP conditions". When done, update the status
> row in `plans/README.md`.
>
> **Drift check (run first)**:
> `git diff --stat 73daf22b2..HEAD -- packages/pdf/src/templates/`
> If the templates changed since this plan, compare "Current state" against
> live code before proceeding; on mismatch, STOP.
## Status
- **Priority**: P2
- **Effort**: L
- **Risk**: HIGH
- **Depends on**: 001 recommended as the characterization-testing exemplar/
discipline (not a technical blocker — the PDF layer needs its own parity net,
which is Step 1 here).
- **Category**: tech-debt
- **Planned at**: commit `73daf22b2`, 2026-07-08
## Why this matters
All 15 resume templates
(`packages/pdf/src/templates/<name>/<Name>Page.tsx`) independently reimplement
the **same page-shell orchestration**: compute `getTemplateMetrics`,
`getTemplatePageSize`, `getTemplatePageMinHeightStyle`, `shouldShowResumeHeader`,
`hasTemplatePicture`, `filterSections(page.main/sidebar)`, then render
`<Page>``<TemplateProvider>` → a two-column `layout` → sidebar/main columns
that `.map` over `<Section>`. Git history proves the cost: a single header
line-height change touched 14 template files (commit `1b0bb067b`); the free-form
layout feature touched 14 (`2cd774dab`). Every new template copies this again,
and drift between copies is invisible until someone diffs all 15.
The goal is a shared, prop-/callback-driven `TemplatePageShell` that owns the
orchestration while each template keeps its own styles and decorative choices.
**Because the visual risk is high and no render-parity test net exists for
templates today, this plan proves the abstraction on ONE template (Pikachu)
behind a characterization snapshot, then stops for review before any rollout.**
## Current state
- `packages/pdf/src/templates/pikachu/PikachuPage.tsx` — the pilot. Structure
(lines 58110): the `PikachuPage` component computes the shared metrics/flags
and renders the shell; a local `Header` component (lines 112161) renders
`basics.name`/`headline` + the shared contact-item components; a
`usePikachuTemplate` hook (lines 163292) builds the per-template `StyleSheet`.
The orchestration block, verbatim:
```tsx
export const PikachuPage = ({ page, pageIndex }: TemplatePageProps) => {
const data = useRender();
const { metadata, picture } = data;
const { colors, styles } = usePikachuTemplate();
const metrics = getTemplateMetrics(metadata.page);
const pageSize = getTemplatePageSize(metadata.page.format);
const pageMinHeightStyle = getTemplatePageMinHeightStyle(metadata.page.format);
const showHeader = shouldShowResumeHeader(data, pageIndex);
const showSidebar = !page.fullWidth;
const hasPicture = hasTemplatePicture(picture);
const mainSections = filterSections(page.main, data);
const sidebarSections = filterSections(page.sidebar, data);
return (
<Page size={pageSize} style={composeStyles(styles.page, pageMinHeightStyle)}>
<TemplateProvider styles={styles} colors={colors} features={pikachuFeatures}>
<View style={styles.layout}>
{showSidebar && (
<View style={composeStyles(styles.sidebarColumn, { width: `${metadata.layout.sidebarWidth}%`, rowGap: metrics.sectionGap })}>
{showHeader && showSidebar && hasPicture && <Image src={picture.url} style={styles.picture} />}
<View style={composeStyles(styles.sidebarContent, { rowGap: metrics.sectionGap })}>
{sidebarSections.map((s) => <Section key={s} section={s} placement="sidebar" />)}
</View>
</View>
)}
<View style={composeStyles(styles.mainColumn, { rowGap: metrics.sectionGap })}>
{showHeader && (
<View style={styles.headerRow}>
{showHeader && !showSidebar && hasPicture && <Image src={picture.url} style={styles.picture} />}
<Header styles={styles} colors={colors} />
</View>
)}
<View style={{ rowGap: metrics.sectionGap }}>
{mainSections.map((s) => <Section key={s} section={s} placement="main" />)}
</View>
</View>
</View>
</TemplateProvider>
</Page>
);
};
```
**Template-specific variation you must preserve** (this is why extraction is
risky — the shell cannot assume one layout): Pikachu wraps its `Header` in a
colored box and places the picture *beside* the header in the main column when
there's no sidebar, but *above* the sidebar sections when there is. Other
templates differ: Azurill (`AzurillPage.tsx`) uses `flexBasis` for sidebar
width instead of a `width` percentage; Onyx (`OnyxPage.tsx`) is single-column
header-on-top; Gengar (`GengarPage.tsx`) calls `getFeaturedSummaryLayout`.
The shared shell must expose enough seams (render callbacks / slots) that each
template keeps these differences — do not flatten them into one hardcoded
layout.
- Shared helpers already exist under `packages/pdf/src/templates/shared/`:
`metrics.ts`, `page-size.ts`, `cover-letter.ts` (`shouldShowResumeHeader`),
`filtering.ts`, `picture.ts`, `contact-item.tsx`, `context.tsx`
(`TemplateProvider`), `sections.tsx` (`Section`), `styles.ts` (`composeStyles`).
The extraction consolidates the *orchestration that wires these together*, not
the helpers themselves.
- **No render-parity test exists per template.** The only per-template test is
`templates/scizor/ScizorPage.test.ts`, which greps source text — not a
structural snapshot. You will build a real parity net in Step 1.
- `packages/pdf/src/document.ts` — defines `TemplatePageProps` and
`TemplatePage`; `templates/index.ts` maps template name → page component.
## Commands you will need
| Purpose | Command | Expected |
|----------------|---------------------------------------------------------------|--------------------|
| Typecheck | `pnpm --filter @reactive-resume/pdf typecheck` | exit 0 |
| PDF tests | `pnpm --filter @reactive-resume/pdf test` | all pass |
| Pilot snapshot | `pnpm --filter @reactive-resume/pdf test -- pikachu` | all pass |
(Do NOT run `pnpm check`.)
## Scope
**In scope**:
- A new shared shell, e.g. `packages/pdf/src/templates/shared/page-shell.tsx`
(create) and its type additions in `shared/types.ts` if needed.
- `packages/pdf/src/templates/pikachu/PikachuPage.tsx` (the ONE pilot migration)
- A new parity snapshot test:
`packages/pdf/src/templates/pikachu/PikachuPage.test.tsx` (create)
- `plans/README.md` (status row + a note on the pilot outcome)
**Out of scope (hard stop)**:
- The other 14 `*Page.tsx` templates — **do not touch them in this plan.** Their
migration is explicit follow-up, gated on this pilot's review.
- The shared helpers' internals (`metrics.ts`, `filtering.ts`, etc.).
- `templates/index.ts` mapping — Pikachu's export name/signature must not change.
- Any visual/styling change — this is a pure structural extraction; the pilot's
rendered output must be byte-identical to before.
## Git workflow
- Branch: `advisor/007-template-shell-spike`
- Commit style: conventional commits, e.g.
`refactor(pdf): extract shared template page-shell; pilot on pikachu`.
- Do NOT push or open a PR unless instructed.
## Steps
### Step 1: Build the parity net for Pikachu (BEFORE refactoring)
Create `packages/pdf/src/templates/pikachu/PikachuPage.test.tsx` that renders
`PikachuPage` with a fixed sample resume and metadata and snapshots the produced
element tree. Approach:
- Use `react-test-renderer` (already available transitively; if not, use the
approach the existing `packages/pdf/src/browser.test.tsx` / `server.test.tsx`
use to exercise components) to render the template wrapped in whatever context
`useRender()` needs. Read `packages/pdf/src/context.tsx` to see how to provide
the render context in a test (there may be a provider/helper the existing
tests use — reuse it).
- Feed it `sampleResumeData` from `@reactive-resume/schema/resume/sample` (used
by `server.test.tsx`) and a representative `page` prop (one with a sidebar,
one full-width — two snapshots).
- Snapshot the tree with `expect(tree.toJSON()).toMatchSnapshot()`.
Run it to generate the baseline snapshot **against the current, un-refactored
PikachuPage**.
**Verify**: `pnpm --filter @reactive-resume/pdf test -- pikachu` → passes and
writes a `__snapshots__` file. Commit this snapshot as the baseline.
> If you cannot render the template in a test without excessive/fragile mocking
> (react-pdf host primitives don't cooperate with the test renderer), STOP and
> report — a structural refactor with no parity net is exactly what this plan
> refuses to ship. Do not proceed to Step 2 without a working baseline.
### Step 2: Extract the shared shell
Create `shared/page-shell.tsx` exporting a `TemplatePageShell` component (or a
hook + component pair) that encapsulates the orchestration from the excerpt:
computing `metrics`/`pageSize`/`pageMinHeightStyle`/`showHeader`/`showSidebar`/
`hasPicture`/`mainSections`/`sidebarSections`, and rendering the
`<Page><TemplateProvider>...<View layout>` scaffold. Expose the per-template
variation through props/render-callbacks, at minimum:
- `styles`, `colors`, `features` (passed through to `TemplateProvider` and used
by the scaffold).
- `renderHeader` / `renderPicture` callbacks (or slot props) so Pikachu can keep
its colored-box header and beside/above picture placement.
- The sidebar-width strategy as data (Pikachu passes a `width` %; Azurill will
later pass `flexBasis`) — model it so both fit without the shell hardcoding one.
Keep the shell's public surface minimal and documented with a short comment.
**Verify**: `pnpm --filter @reactive-resume/pdf typecheck` → exit 0.
### Step 3: Migrate Pikachu onto the shell
Rewrite `PikachuPage.tsx` so `PikachuPage` delegates its orchestration to
`TemplatePageShell`, passing `usePikachuTemplate()`'s styles/colors, the
`pikachuFeatures`, and its `Header`/picture rendering via the callbacks. Keep
`usePikachuTemplate` and the `Header` component in the Pikachu file (styles stay
template-owned). The export name and `TemplatePageProps` signature must not
change.
**Verify**:
- `pnpm --filter @reactive-resume/pdf test -- pikachu` → the Step 1 snapshot
**still matches** (identical tree). If the snapshot changed, the refactor
altered output — investigate and reconcile; do NOT blindly update the
snapshot.
- `pnpm --filter @reactive-resume/pdf test` → the full PDF suite passes.
- `pnpm --filter @reactive-resume/pdf typecheck` → exit 0.
### Step 4: STOP and report — do not roll out
Write a short note in `plans/README.md`'s status area (or the PR description)
covering: whether the shell's seams were sufficient for Pikachu without style
regressions, what the shell's public API ended up being, and which of the other
14 templates look like clean fits vs. which have layouts (e.g. Onyx single
column, Gengar featured-summary) that will need extra seams. This note is the
input for the reviewed rollout plans. **Do not migrate any other template.**
## Test plan
- New `PikachuPage.test.tsx` with two snapshots (with-sidebar, full-width),
established as a baseline in Step 1 and asserted unchanged after Step 3.
- Full `packages/pdf` suite must remain green.
- Verification: `pnpm --filter @reactive-resume/pdf test` → all pass; the
Pikachu snapshot is unchanged between baseline and post-refactor.
## Done criteria
Machine-checkable. ALL must hold:
- [ ] `packages/pdf/src/templates/shared/page-shell.tsx` exists and is imported by `PikachuPage.tsx`
- [ ] `packages/pdf/src/templates/pikachu/PikachuPage.test.tsx` + its `__snapshots__` exist
- [ ] `pnpm --filter @reactive-resume/pdf test` exits 0 (snapshot unchanged post-refactor)
- [ ] `pnpm --filter @reactive-resume/pdf typecheck` exits 0
- [ ] `git diff --name-only` shows **exactly one** `*Page.tsx` changed (pikachu); no other template file modified
- [ ] `plans/README.md` status row for 007 updated with the pilot-outcome note
- [ ] `grep -rL page-shell packages/pdf/src/templates/*/[A-Z]*Page.tsx` still lists 14 templates (i.e. only Pikachu adopted it)
## STOP conditions
Stop and report back (do not improvise) if:
- You cannot build a working render-parity snapshot in Step 1 (Step 1's own
stop clause) — no net, no refactor.
- The Pikachu snapshot changes after Step 3 and you cannot make it identical —
the extraction is not behavior-preserving; report the diff instead of
updating the snapshot to match.
- Making the shell fit Pikachu forces the abstraction to also encode a second
template's layout (you find yourself designing for Onyx/Gengar mid-pilot) —
stop; the pilot's job is to prove one clean seam set, not to pre-solve all 15.
- Any change would touch a template other than Pikachu — that's the rollout,
which is out of scope.
## Maintenance notes
- Rollout is deliberately deferred. After this pilot is reviewed and merged,
create follow-up plans that migrate the remaining templates in small reviewed
batches (group by layout family: full-width/single-column like Onyx; sidebar-%
like Pikachu; flexBasis like Azurill; featured-summary like Gengar). Each
batch gets its own baseline snapshot first.
- The shell's public API is load-bearing for 14 future migrations — a reviewer
should scrutinize its seam design (are the render callbacks expressive enough?)
more than the Pikachu diff itself.
- Prior maintainer guidance: template rewrites should be spike-gated and must
not break the react-pdf/DOCX/JSON export pipeline. This plan honors that by
keeping styles template-owned and asserting identical render output.
+171
View File
@@ -0,0 +1,171 @@
# Implementation Plans
Generated by the `improve` skill (deep audit) on 2026-07-08, against commit
`73daf22b2`. A deep 8-category read-only audit produced 53 raw findings; after
adversarial verification and manual vetting, the user selected the items below
to turn into executor plans.
Each executor: read your plan fully before starting, honor its STOP conditions,
and update your row when done. Plans are self-contained — you do not need the
audit or this repo's history to execute one.
## Execution order & status
| Plan | Title | Priority | Effort | Risk | Depends on | Status |
|------|-------|----------|--------|------|------------|--------|
| 001 | Characterization tests for the resume service | P1 | M | LOW | — | DONE (branch `advisor/resume-service-improvements`) |
| 002 | CSP + framing headers on web pages; gate uploads CORS | P1 | S | LOW | — | DONE (branch `advisor/security-and-docs`) |
| 003 | Fix silent-success mutations; bound bulk-op inputs | P1 | S | LOW | 001 (soft) | DONE (branch `advisor/resume-service-improvements`) |
| 004 | Correct README "Custom CSS" claim | P3 | S | LOW | — | DONE (branch `advisor/security-and-docs`) |
| 005 | Deduplicate public-resume view-count writes | P2 | M | MED | 001 (soft) | DONE (branch `advisor/resume-service-improvements`) |
| 006 | Font-picker options once; defer font payload | P2 | S | LOW | — | DONE (branch `advisor/font-payload`; Step-1 hoist only — build measurement confirmed the payload already loads only with the PDF chunk, so lazy-loading was correctly skipped) |
| 007 | Extract shared template page-shell — spike + pilot | P2 | L | HIGH | 001 (discipline) | REVERTED from integration (user decision) — spike executed & proved the recipe, but full rollout judged not worth it; Pikachu reverted for template uniformity. Work preserved on branch `advisor/template-shell-spike`. |
Status values: TODO | IN PROGRESS | DONE | BLOCKED (one-line reason) | REJECTED (one-line rationale)
### Recommended order
Independent quick wins first (002, 004), then the tests-first foundation (001)
before the behavior change that depends on it (003) and the caller change with
a safety net (005). 006 is independent and low-leverage — do it anytime. 007
(template refactor) is last and is a **spike + one-template pilot that stops for
review**, not a full rollout.
Fastest safe sequence: **002 → 004 → 001 → 003 → 005 → 006 → 007**.
## Execution results (2026-07-08)
All 7 plans were executed by isolated-worktree executor subagents and reviewed
by the advisor (re-ran every done-criterion, scope-checked, read diffs, audited
tests). All **APPROVED**. Four branches, all based on `73daf22b2`:
| Branch | Plans | Package(s) | Verify (re-run by reviewer) |
|--------|-------|------------|------------------------------|
| `advisor/security-and-docs` | 002, 004 | apps/server, README | typecheck 0; `server` static tests 36/36 |
| `advisor/font-payload` | 006 | apps/web | typecheck 0; web suite 457/457; build 0 |
| `advisor/resume-service-improvements` | 001, 003, 005 | packages/api | typecheck 0; api suite 178/178; boundaries clean |
| `advisor/template-shell-spike` | 007 | packages/pdf | typecheck 0; pdf suite 264/264; boundaries clean; parity snapshot byte-identical pre/post refactor |
**Post-execution decision (user, 2026-07-08):** Local integration branch
`improve-integration` created off `main` with plans **001006 merged**. Plan
**007 was reverted** from it (the spike concluded a full 14-template rollout
isn't worth it — the shell only centralizes layout orchestration, not the
per-template style hooks where most recurring edits land; 6 templates are
single-column and don't fit; consistency argues against a partial rollout). The
`advisor/template-shell-spike` branch is kept if 007 is ever revisited.
Known unrelated red: `packages/api/.../storage/service.test.ts` fails on the
pinned **vitest 4.1.9** (`.rejects.toThrow` matcher `TypeError`, fixed in 4.1.10)
— present on base `main` too, in a file no plan touches; the vitest suite isn't
in CI, so it's latent. Bumping vitest to 4.1.10 clears it (a separate base-repo
concern, left untouched).
**Integration notes for the user (merging is your call — the advisor never merges):**
- The four branches touch **disjoint packages**, so they can be merged in any
order with no cross-branch source conflicts. The only shared file is
`pnpm-lock.yaml`, touched **only** by `advisor/template-shell-spike` — no
lockfile conflict between branches.
- **All commits are unsigned** (`--no-gpg-sign`): the executors ran headless and
the repo's 1Password SSH signer needs interactive auth. Re-sign on merge if
branch protection requires signed commits.
- **007 adds test-only devDeps** (`react-dom`, `@types/react-dom`) to
`packages/pdf` + a `vitest.config.ts` transform tweak — both were required to
build the render-parity snapshot the spike depends on (react-test-renderer,
which the plan suggested, is React-19-incompatible). Accept these consciously.
- **007 is a pilot**: only Pikachu is migrated. The executor's recommended
rollout batches for the other 14 templates (each snapshotted first):
1. sidebar-% family (bronzor, ditto, glalie, leafish; likely kakuna, lapras,
meowth, rhyhorn, scizor — verify two-column shape) — trivial, pass
`sidebarColumnStyle={{ width }}`.
2. flexBasis family (azurill, chikorita) — pass `sidebarColumnStyle={{ flexBasis }}`;
the existing seam already covers it.
3. featured-summary family (ditgar, gengar) — needs a new section-rendering
seam; do NOT force into the current shell.
4. single-column/header-on-top (onyx) — needs a separate shell/layout variant;
the current shell hardcodes the two-column scaffold.
## Dependency notes
- **003 depends softly on 001**: 003 flips the three `setLocked`/`setPassword`/
`removePassword` methods from silent-return to throwing `NOT_FOUND`. If 001 is
DONE, its Step 3 assertions must be updated in 003's PR (that test diff is the
intended proof of the behavior change). If 001 is not done, 003 adds a minimal
test itself.
- **005 depends softly on 001**: 001's Step 5 characterizes
`statistics.increment`; 005 changes the *caller* (dedup), not the write body,
so that test should keep passing — a useful regression signal.
- **007 is not technically blocked** by 001 (the PDF layer needs its own render-
parity net, which 007 builds in Step 1), but 001 should be done first as the
characterization-testing exemplar. 007 explicitly does NOT roll out to all 15
templates; rollout is deferred to reviewed follow-up plans.
## Findings considered and rejected
Recorded so they aren't re-audited next run. (Refuted by adversarial
verification or downgraded during manual vetting against the code.)
- **"Production secrets committed to repository"** — FALSE. `.env.production` /
`.env.local` exist in the working tree but are gitignored (`.env*` with an
`.env.example` exception); 0 commits touch them; not in history.
- **"Vulnerable @better-auth/oauth-provider (unbound resource indicators)"** —
the claimed advisory (GHSA-p2fr-6hmx-4528) could not be verified as real, and
the code already configures `validAudiences`. Treat any `@better-auth/*` bump
as routine, not a security fix.
- **Unauthenticated OAuth2 dynamic client registration bypass** — refuted;
three validation layers exist (registration-time, server preflight,
authorization endpoint).
- **bulkUpdate tag-addition race** — refuted; Postgres row-locking serializes
the `UPDATE ... SET tags = <subquery>` correctly.
- **agent `getThread` missing ownership check** — refuted; the `WHERE` clause
filters `userId` and throws `NOT_FOUND` otherwise.
- **Snapshot throttle "bypassed" by AI patches** — deliberate, documented
design; pruning is atomic and bounded at 30 versions, not a reactive overflow.
Not a bug.
- **Missing index on `(user_id, isPublic)`** — no code path filters resumes by
public status; speculative (YAGNI).
- **Stats daily-series "re-queries every 30 days after cache"** — refuted;
TanStack Query 60s staleTime already dedups.
- **Large composite components / sections.tsx / rich-input / custom-styles
"need splitting"** (ARCH-02/05/06/07) — refuted; each is a single-responsibility
internal-only component with no external reuse; a July-2026 audit already chose
not to split `sections.tsx`.
- **Export paths duplicate filtering (ARCH-03)** — refuted; PDF/DOCX/Markdown
have genuinely different filtering needs, not a shared abstraction being
reinvented.
- **`node-html-parser` major-version lag** — refuted; only stable DOM APIs used,
no breaking-change exposure.
- **PORT not validated in server.ts (DX-03)** — refuted; the server *does* read
`process.env.PORT` in production (`apps/server/src/index.ts:13`).
Downgraded to notes (real but low-leverage; not planned unless revisited):
- **Rich-text HTML not sanitized before react-pdf-html** (SEC-07) — PDF-only
surface (browser path uses `stripHtml`; PDFs don't execute scripts), on the
user's own content. Defense-in-depth only.
- **MCP tools don't validate API-key scopes** (SEC-05) — latent; scoped keys
can't be created yet. Revisit if/when scoped API keys ship.
- **Applications attachment cleanup: concurrent-write orphans + swallowed
`Promise.allSettled` failures** (CORRECT-02/03) — real but low severity for
single-user; storage hygiene + observability. Candidate for a future plan.
- **Agent service is 1397 LOC mixing concerns** (ARCH-04) — real tech debt;
large L-effort refactor, deferred.
- **Core deps pinned to prerelease/snapshot** (drizzle-orm/kit rc, drizzle-zod
snapshot hash, TS native-preview dev build) — watch item; de-risk when stable
releases land. Not urgent (not on the CI/production critical path).
- **Vite `chunkSizeWarningLimit: 10MB`** masks bundle bloat — trivial DX nit.
- **Dev-setup docs vs AGENTS.md `dotenvx` drift** (DX-02) — minor docs friction.
- **Applications bulk O(n) in-memory scan** — the input-cap in Plan 003 bounds
abuse; the scan rewrite is deferred.
## Not audited
Honest coverage gaps from this run:
- E2E/Playwright test behavior and flakiness (relied on CI).
- PDF/DOCX template *rendering* correctness beyond structure (no visual-
regression harness exists).
- Email/notification delivery reliability.
- S3 backup/restore and multi-instance storage behavior.
- The AI provider integration layer's retry/streaming internals.
- Lingui extraction/locale-catalog completeness (separate track).
- Deep dependency transitive-tree audit (worked from manifests + lockfile;
`pnpm audit`/`pnpm outdated` network access was not assumed available).