# Multi-Domain Codebase Audit: personal-site ("The Construct")

Skill: codebase-audit · Repo: personal-site · Date: 2026-07-01

> Applied the `codebase-audit` skill (THE EXACT PROMPT + Output Template + Severity scale + domain checklists from `references/CHECKLISTS.md`) across four domains: **security · performance · ux/accessibility · api**. Every finding cites real `file:line`, gives root cause, and a fix. Scope is the CODE surface (`src/`, `middleware.ts`, `next.config.ts`); the ~728-file `career/`·`.agents/`·`docs/` knowledge layer is architecture, not audited as code.
>
> Evidence gathered by reading the files and by running `npm audit --json` (Next.js **16.2.4** installed, not the 16.1.7 the scout map claims).

---

## Domain 1 — Security

**Summary — 6 findings: Critical 0 · High 1 · Medium 2 · Low 3.** The bespoke CSP is genuinely good: `script-src` is strict (`'nonce-…' 'strict-dynamic'`, `constants.ts:14`), the deliberate `style-src 'unsafe-inline'` decision is correct and well-documented (`constants.ts:15-22`), JSON-LD is XSS-escaped in the shared path (`jsonld.ts:7-14`), and blog file access is regex-guarded against path traversal (`blog.ts:8-25`). The real exposure is an outdated Next.js with a nonce-specific XSS advisory, plus one inline `<script>` that bypasses both the escaper and the nonce path.

### High

#### H1 — Next.js 16.2.4 ships a known CSP-nonce XSS (and 12 other advisories)
- **Location:** `package.json:` `"next": "^16.1.7"` (resolves to **16.2.4**, confirmed via `npm ls next`)
- **Issue:** `npm audit` flags `next` **high**, range `16.0.0 - 16.2.5`. The advisory list includes **GHSA-ffhc-5mcf-pf4q — "cross-site scripting in App Router applications using CSP nonces"**, which directly targets this app's entire security model (the whole site leans on per-request nonces via `middleware.ts` + `JsonLdScript`). Also present: middleware/proxy bypass (GHSA-26hh-7cqf-hhc6, GHSA-492v-c6pp-mqqv), redirect cache-poisoning (GHSA-3g8h-86w9-wvmq), and Image-Optimization DoS (GHSA-h64f-5h5j-jqjh) — all reachable given this app uses middleware, `next.config.ts` redirects, and `next/image`.
- **Root Cause:** Version drift; `^16.1.7` floats but the lockfile pinned 16.2.4, below the 16.2.6 fix line. No CI dependency gate (`.github/workflows` is empty).
- **Fix:** `npm install next@latest` (≥16.2.6), re-run `npm audit`, and add Renovate/CI enforcement so the framework XSS/middleware fixes land automatically. `renovate.json` exists but nothing blocks a release on an open high advisory.

### Medium

#### M1 — Home-page JSON-LD bypasses both the nonce path and the XSS escaper
- **Location:** `src/app/page.tsx:33-36`
- **Issue:** The home structured data is emitted as a raw inline script:
  ```tsx
  <script type="application/ld+json"
    dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
  ```
  Every other JSON-LD block on the site uses `JsonLdScript` (`JsonLdScript.tsx:11-19`), which (a) reads `x-nonce` and attaches it, and (b) serializes through `serializeJsonLd` which escapes `<`, `>`, `&`, U+2028/9 (`jsonld.ts:7-14`). This one does neither. Because `script-src` is `'strict-dynamic'` + nonce, an inline `<script>` **without** a nonce is not authorized by `'unsafe-inline'` (strict-dynamic makes browsers ignore it) — so in the real prod CSP this block is at risk of being **blocked and the home page losing its Person/WebSite structured data** (an SEO regression, and exactly the class of "works in `next dev`, breaks in prod build" bug the repo memory warns about). Even setting CSP aside, using raw `JSON.stringify` instead of `serializeJsonLd` is the unsafe pattern the codebase already solved.
- **Root Cause:** One route was written before/around the `JsonLdScript` component and never migrated; the data is static today so the missing escaping is latent, not yet exploitable.
- **Fix:** Replace with `<JsonLdScript id="home-jsonld" data={jsonLd} />`. This routes through the nonce + escaper and matches `writing/[slug]/page.tsx:71`.

#### M2 — `img-src` allows any HTTPS origin
- **Location:** `src/lib/constants.ts:32` — `"img-src 'self' data: https: blob:"`
- **Issue:** `https:` permits image loads from **any** HTTPS host, widening the surface for exfiltration-via-image and tracking beacons and weakening the otherwise-tight `default-src 'self'`. The only real remote image origin the app declares is `covers.openlibrary.org` (`next.config.ts:19-23`).
- **Root Cause:** Broad wildcard chosen for convenience (OG/remote covers) instead of an allowlist.
- **Fix:** Narrow to `img-src 'self' data: blob: https://covers.openlibrary.org` (add explicit hosts as needed), mirroring `next.config.ts` `remotePatterns`.

### Low

#### L1 — CSP is also written onto the *request* headers
- **Location:** `middleware.ts:13`
- **Issue:** `requestHeaders.set('Content-Security-Policy', …)` sets CSP on the inbound request headers in addition to the response (`:21`). Only the `x-nonce` request header is actually needed downstream (`JsonLdScript.tsx:12`); the request-side CSP is inert and confusing.
- **Fix:** Drop the request-side CSP set; keep only `x-nonce` on the request and CSP on the response.

#### L2 — `/api/health` has an unreachable failure branch that always reports 200
- **Location:** `src/app/api/health/route.ts:9-14`
- **Issue:** The body is `NextResponse.json({ status: 'healthy' })` on a static object — it cannot throw, so the `catch` returning `500 unhealthy` is dead code. The healthcheck (docker-compose) can never actually detect an unhealthy process.
- **Fix:** Either accept it as a shallow liveness probe (document that) or add a real readiness check (e.g. `getAllPosts()` reads, disk access) inside `try` so the 500 path is reachable.

#### L3 — No rate limiting on the JSON API route handlers
- **Location:** `src/app/api/posts/route.ts`, `src/app/api/posts/[slug]/route.ts`, `src/app/llms.txt/route.ts`
- **Issue:** Handlers are unauthenticated and unthrottled. Impact is low (read-only static content, CDN-cached with `s-maxage`), but `/api/posts/[slug]` re-reads and re-parses a file per uncached slug.
- **Fix:** Rely on the CDN cache headers already present; if abuse appears, add edge rate limiting. Low priority given the read-only surface.

---

## Domain 2 — Performance

**Summary — 4 findings: Critical 0 · High 0 · Medium 2 · Low 2.** Image handling is textbook (`next/image` with `sizes`, `quality`, and `priority` on the hero — `HomeClient.tsx:50-58`, `MDXComponents.tsx:27-42`). The content set is tiny (25 MDX files) so there is no unbounded-globbing risk. The real costs come from the nonce-driven forced-dynamic rendering interacting with per-request MDX compilation and a blocking `git` subprocess in the sitemap.

### Medium

#### P1 — Forced dynamic rendering re-compiles+highlights every MDX post on every request
- **Location:** `src/app/layout.tsx:103` (`await connection()`) vs `src/app/writing/[slug]/page.tsx:21` (`generateStaticParams`) + `:96-105` (`MDXRemote` with `rehypeHighlight`)
- **Issue:** `connection()` in the root layout opts the **entire site** into dynamic rendering (required so each request gets a fresh CSP nonce). But `writing/[slug]` still declares `generateStaticParams`, implying static intent — which `connection()` defeats. Net effect: every blog-post view re-parses the MDX source and re-runs `rehype-highlight` server-side (`:99-104`) instead of serving a build-time-rendered HTML shell. Syntax highlighting is the most expensive step and it runs per request.
- **Root Cause:** The per-request nonce (CSP model) is fundamentally at odds with full static generation; the MDX compile is the casualty.
- **Fix:** Wrap the compile in a cache: memoize `getPostBySlug`/the compiled MDX with React `cache()` (or `unstable_cache`) keyed by slug, so repeated requests reuse the highlighted output while the nonce still varies per request. Alternatively pre-render post HTML into the data layer and inject the nonce only into the surrounding scripts.

#### P2 — Sitemap shells out to `git log` synchronously, up to ~9× per request
- **Location:** `src/app/sitemap.ts:25-30` (`spawnSync('git', …)`), called from `:47-127` for each of `SITEMAP_SOURCE_PATHS` (8 paths) plus `work/page.tsx`
- **Issue:** `spawnSync` is a blocking child-process spawn; `sitemap()` fires it ~9 times per generation. On Vercel serverless the deploy often has no `.git`, so each call fails and falls through to `statSync` + a `console.warn` (`:35`) — 9 warnings per sitemap request and a wasted process spawn each time.
- **Root Cause:** Using VCS as the source of `lastModified` at request time rather than at build time.
- **Fix:** Compute git dates once at build (a prebuild script writing a JSON manifest) and read that, or drop `git` entirely and use frontmatter/`builds.ts` dates. At minimum, short-circuit the git attempt when `.git` is absent to kill the per-path spawn + warn.

### Low

#### P3 — `PageLayout` is a Client Component wrapping every page's shell
- **Location:** `src/components/PageLayout.tsx:1` (`'use client'`)
- **Issue:** The shell is marked client only to compose `Header`/`Footer` (which need client for `useState`/`usePathname`). `children` passed as props stay server-rendered, so the blast radius is limited — but the wrapper still ships as a client boundary unnecessarily.
- **Fix:** Keep `PageLayout` a Server Component and let only `Header` (already `'use client'`) be the client island; `Footer` can likely be a Server Component too. Verify `Footer` truly needs interactivity.

#### P4 — Repeated full content re-reads per dynamic render
- **Location:** `src/lib/blog.ts:31-70` (`getAllPosts` does `readdirSync` + `readFileSync` for all files) called by sitemap, `/llms.txt`, `/api/posts`, and the writing index
- **Issue:** Under forced-dynamic rendering there is no build-time cache, so each of those routes re-reads and re-parses all 25 files per request. Volume is small today; it scales linearly with essay count.
- **Fix:** Wrap `getAllPosts` in React `cache()` for per-request dedupe and/or `unstable_cache` with a tag invalidated on deploy.

---

## Domain 3 — UX / Accessibility

**Summary — 4 findings: Critical 0 · High 0 · Medium 2 · Low 2.** Accessibility is unusually well-considered for a bespoke design system: a real skip link (`layout.tsx:108-113`), a `noscript` fallback that forces draw-in end-states (`:116-118`), global `prefers-reduced-motion` kill-switch (`globals.css:282-294`) plus per-animation guards, a documented WCAG-passing focus indicator (`globals.css:270-279`), decorative SVGs correctly `aria-hidden` (`SiteBackground.tsx:23`, `primitives.tsx:242`), and the complex hero schematic exposed as a single labeled image (`HomeClient.tsx:182-186`, `role="img"` + descriptive `aria-label`). The gaps are collapsed-menu focusability and low-contrast accent metadata text.

### Medium

#### UX1 — Collapsed mobile nav links stay in the tab order (invisible but focusable)
- **Location:** `src/components/Header.tsx:92-155`
- **Issue:** The mobile menu is hidden with `max-h-0 opacity-0 overflow-hidden` (`:94-97`) but is **not** `display:none`/`hidden`/`aria-hidden` when closed, and the `<Link>`s inside carry no `tabIndex={-1}`. A keyboard or screen-reader user tabbing the closed nav lands on ~7 invisible, off-screen links (`/writing`, `/about`, …, `/first-win`, `/training`, `/ai-partner`) with no visible focus target. `aria-expanded` is set on the toggle (`:84`) but the panel contents remain reachable regardless.
- **Root Cause:** Animating height/opacity for the slide effect instead of toggling actual visibility/focusability.
- **Fix:** When closed, add `hidden`/`aria-hidden="true"` to `#mobile-menu` (or `inert`), or set `tabIndex={-1}` on the collapsed links and only enable them when `isOpen`. `inert` is the cleanest one-attribute fix.

#### UX2 — Reduced-opacity accent text fails WCAG AA contrast for informational text
- **Location:** `src/app/writing/[slug]/page.tsx:80-82` (date · reading time · tags at `rgba(var(--acc),0.6)`, `fontSize: 11`); the `TICK` token (`tokens.ts:19-24`, `rgba(var(--acc),0.6)`, 10px) used site-wide for metadata; `primitives.tsx:45` (`// label` headings at `rgba(var(--acc),0.85)`)
- **Issue:** `--acc` is green-500 `34,197,94` (`globals.css:23,30`). At full opacity on `#070a08` it is ~8.6:1 (fine), but composited at **0.6 alpha** it drops to ≈**3.6:1** — below the 4.5:1 AA threshold for normal-size text. The affected text is not decorative: it's post date, reading time, tags, and section labels.
- **Root Cause:** Opacity is used as a visual-hierarchy dial without checking the resulting composite contrast for text that conveys information.
- **Fix:** For informational text, raise to ≥0.8 alpha (≈4.9:1) or bump the base to green-400 (`--acc-b`, `74,222,128`). Reserve ≤0.6 alpha for genuinely decorative glyphs (arrows, hairlines). Add an axe contrast assertion to the e2e a11y spec so this regresses loudly.

### Low

#### UX3 — MDX maps `#` to an `<h1>`, allowing a second page-level H1
- **Location:** `src/components/blog/MDXComponents.tsx:44` (`h1: … <Heading level={1} …>`) vs `writing/[slug]/page.tsx:84-89` (the post title is already the page `<h1>`)
- **Issue:** If any essay body uses a top-level `#`, the rendered page gets two `<h1>`s and a broken heading outline for screen readers.
- **Fix:** Map MDX `h1` to `<h2>` (demote in-body headings by one level) so the page keeps a single `<h1>`.

#### UX4 — Author-image alt text silently defaults to empty (decorative)
- **Location:** `src/components/blog/MDXComponents.tsx:33` — `alt={alt || ''}`
- **Issue:** An MDX image with a missing/omitted alt renders as decorative (`alt=""`), which is the safe default — but it means a content author who forgets alt gets no warning and conveys no information to AT. The scout-noted "real diagrams worth showing" are exactly the images that need descriptive alt.
- **Fix:** Keep the `''` fallback for genuinely decorative images, but add a `slop-lint`/build check that warns on MDX `![](…)` with empty alt so authors supply text for diagrams.

---

## Domain 4 — API

**Summary — 3 findings: Critical 0 · High 0 · Medium 0 · Low 3.** The route handlers are clean and correct for their scope: proper `GET`-only semantics (other verbs auto-405 via Next), correct status codes (`200`, `404`), cache headers matched to mutability (`public, max-age=3600, s-maxage=86400` for content; `no-store` for health and 404s), and — importantly — path-traversal is prevented upstream because `/api/posts/[slug]` flows through `getPostBySlug` → `SAFE_SLUG_PATTERN` (`blog.ts:19-25`). Findings are consistency/robustness polish only.

### Low

#### API1 — Error/response envelope is inconsistent across handlers
- **Location:** `src/app/api/posts/route.ts:6-9` (`{ count, posts }`), `src/app/api/posts/[slug]/route.ts:9-20` (bare post object on success, `{ error: 'Post not found' }` on 404)
- **Issue:** List responses are enveloped (`{count, posts}`), single-item success returns a raw object, and errors use `{error}`. A consumer can't rely on one shape. There is also no versioning strategy (no `/v1`, no header), so any shape change is breaking.
- **Fix:** Standardize on `{ data, error }` (or `{ data, meta }`) across all three, and document a version prefix or `Api-Version` header before external consumers depend on it.

#### API2 — Handlers assume the content layer never throws
- **Location:** `src/app/api/posts/route.ts:4-9`, `src/app/llms.txt/route.ts:11-54`
- **Issue:** `getAllPosts()` does synchronous `fs` reads and `gray-matter` parsing (`blog.ts:36-49`). A malformed frontmatter block or a transient FS error throws an unhandled 500 with a framework stack, not a controlled error shape.
- **Fix:** Wrap the handler bodies in `try/catch` returning a consistent `{ error }` + `500`, matching the pattern already used (correctly, if unreachably) in `/api/health` (`route.ts:9-14`).

#### API3 — `/llms.txt` and `/api/posts` share the same cache TTL but different freshness needs
- **Location:** `src/app/llms.txt/route.ts:51-52` and `src/app/api/posts/route.ts:8`
- **Issue:** Both use `max-age=3600, s-maxage=86400`. `/llms.txt` also enumerates `builds`/`siteSystems` (`llms.txt/route.ts:12-13`) which change on deploy; a 24h shared-cache TTL can serve a stale index for a day after a content change with no revalidation tag.
- **Fix:** Add a deploy-invalidated cache tag (`unstable_cache` tags or `revalidateTag` on deploy) so content changes purge immediately rather than waiting out `s-maxage`.

---

## Cross-Domain Roll-Up

| Domain | Critical | High | Medium | Low | Standout |
|---|---|---|---|---|---|
| Security | 0 | 1 | 2 | 3 | Update Next.js off the nonce-XSS advisory (H1); migrate home JSON-LD to `JsonLdScript` (M1) |
| Performance | 0 | 0 | 2 | 2 | Cache the per-request MDX compile (P1); kill the blocking git spawn in sitemap (P2) |
| UX / A11y | 0 | 0 | 2 | 2 | Make collapsed mobile nav non-focusable (UX1); fix 0.6-alpha accent contrast (UX2) |
| API | 0 | 0 | 0 | 3 | Standardize response/error envelope (API1) |
| **Total** | **0** | **1** | **6** | **10** | |

**Highest-leverage next actions:** (1) `npm install next@latest` and gate CI on `npm audit` — closes the CSP-nonce XSS the whole security model is built around. (2) Swap `page.tsx:33-36` for `JsonLdScript` — closes the last raw-inline-script/escaper gap in one line. (3) `inert` the closed mobile nav (`Header.tsx:92`) and raise informational accent text to ≥0.8 alpha — closes both a11y Mediums cheaply.

**What's genuinely solid (verified, not assumed):** the CSP `style-src`/`script-src` split and its documented rationale (`constants.ts:14-45`), path-traversal guards (`blog.ts:8-25`), the XSS-escaping JSON-LD serializer (`jsonld.ts:7-14`), reduced-motion + focus-visible + skip-link accessibility scaffolding (`layout.tsx:108-118`, `globals.css:270-294`), the labeled hero schematic (`HomeClient.tsx:182-186`), and correct `next/image` usage throughout.
