Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 100 additions & 35 deletions docs/dependency-upgrade-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -655,9 +655,9 @@ detail needed to act on them; kept here as the record of what this phase chose n
- [ ] Burn down the 263 `vue-tsc` errors. Now two distinct groups: ~208 pre-existing (149 in
`composables/useDirectus.ts`) and **55 newly surfaced `noUncheckedIndexedAccess` violations**,
which are the more interesting set — each is a real unchecked index.
- [ ] Stop the prerender crawler walking image URLs, so a full local build works:
`nitro: { prerender: { ignore: ['/_ipx'] } }`. Pre-existing on `main`; affects only local
full builds, since CI skips discovery and Vercel does not prerender.
- [x] Stop the prerender crawler walking image URLs, so a full local build works — done 2026-08-04
(#240): 193s and intermittently failing became 29s. Scoped to non-static builds; see the
follow-up backlog for why that guard matters.
- [ ] `tailwind.config.js` `content` globs are `'./pages/**/*.{html,js}'` and
`'./components/**/*.{html,js}'` — **no `.vue`**. The site is styled only because
`@nuxtjs/tailwindcss` injects its own defaults over the top. Worth fixing before Tailwind 4
Expand Down Expand Up @@ -1340,11 +1340,26 @@ So the swap needed real text, not tag-stripped HTML:
| regex + `{{ }}` (the naive fix) | ❌ shows `für` | ❌ | ✅ inert |
| `getPlainText` + `{{ }}` (shipped) | ✅ `für` | ✅ `&` | ✅ inert |

`helpers/getPlainText.ts` sanitises with `ALLOWED_TAGS: []` and `RETURN_DOM_FRAGMENT`, then reads
`textContent`. That returns genuine text with every entity decoded, which is safe for `{{ }}` and must
never be handed to `v-html`. It parses instead of pattern-matching, which is the whole point: a regex
cannot match a tag containing `<` or `>`, so `<img<a> src=x onerror=alert(1)>` survived
`/<[^<>]+>/g` as a working tag.
`getPlainText` sanitises with `ALLOWED_TAGS: []` and `RETURN_DOM_FRAGMENT`, then reads `textContent`.
That returns genuine text with every entity decoded, which is safe for `{{ }}` and must never be handed
to `v-html`. It parses instead of pattern-matching, which is the whole point: a regex cannot match a tag
containing `<` or `>`, so `<img<a> src=x onerror=alert(1)>` survived `/<[^<>]+>/g` as a working tag.

**`helpers/sanitize.ts` is the only module that touches DOMPurify**, after a review comment on #237

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update the renamed sanitizer test reference

When documenting the consolidation into helpers/sanitize.ts, also update the verification paragraph below: it still directs readers to test/getPlainText.test.ts, which does not exist at this commit because the tests moved to nuxt-app/test/sanitize.test.ts. The stated five-case coverage is also stale relative to the consolidated suite, so following this verification record currently leads to a nonexistent file.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct on both counts — fixed in 2c721a5.

The path was stale because the tests moved to test/sanitize.test.ts when the DOMPurify policy was consolidated into helpers/sanitize.ts (the change this very paragraph documents), and the "5 cases" predated that. So the verification record pointed at a file that does not exist — following it would have led nowhere.

Now:

Unit tests cover the bypass payloads, so the regex cannot come back unnoticed: test/sanitize.test.ts, 18 cases across sanitizeHtml, sanitizeInlineHtml and getPlainText […] The danger check the assertions use is itself tested against unsanitised input, so it cannot pass vacuously — the first version did exactly that, matching onerror in text where it was inert content.

Counted rather than estimated: vitest reports 91 tests overall, 18 of them in that file.

Four other things had gone stale, not just this one

Your comment sent me through the document properly, and #240 had merged in the meantime. Also folded in:

  • The /_ipx follow-up is done, and this document described its failure wrongly: it said the build "currently fails" when it fails intermittently — which is exactly why my first reproduction attempt passed in 193s and the next failed. Corrected, with the before/after numbers and a note that the nitro.static guard is load-bearing even now that the generate script is gone, since npx nuxi generate still exists.
  • Static generation dropped, with the capacity figures that decided it (6183 image fetches; response times reaching 292s before ipx 500s).
  • Two "known issues" entries still describing a full local build as broken.
  • Five decision-log rows for Stop logging auth payloads; stop prerendering image URLs #240 — including the two places where checking changed a claim rather than confirming it: the token exposure is unconfirmed rather than confirmed, and the first version of the ignore rule was verified against the wrong mechanism.

All five intra-doc anchors re-verified against github-slugger, and a sweep confirms no getPlainText.test.ts or helpers/getPlainText.ts references remain. The one surviving npm run generate mention is deliberate — it is the past-tense record of why the script was removed.

pointed out that the two ProfileCreation components had each grown their own
`computed(() => DOMPurify.sanitize(...))`, duplicating `InnerHtml.vue`. It cited this repo's own rule
in `AGENTS.md` — never duplicate logic across modules — and was right. Three exports:

| export | for |
| --- | --- |
| `sanitizeHtml` | default policy, anything bound to `v-html` |
| `sanitizeInlineHtml` | as above but forbids `<p>`, for the news ticker's single scrolling line |
| `getPlainText` | real plain text, for `{{ }}` |

`InnerHtml.vue` and `NewsTicker.vue` were routed through it too, rather than fixing only the two new
duplicates — otherwise two of four policies would still have been inline, which is the appearance of
the rule rather than the rule. `NewsTicker`'s `{ FORBID_TAGS: ['p'] }` became a named export rather
than a config parameter, so DOMPurify options do not leak back out to callers.

It is deliberately **not** re-exported from `helpers/index.ts`. That barrel is imported by server
routes, and pulling `isomorphic-dompurify` through it would instantiate jsdom for consumers that only
Expand All @@ -1363,9 +1378,11 @@ classes. So these two were inconsistent outliers rather than a deliberate choice

### Verification

Unit tests cover the bypass payloads, so the regex cannot come back unnoticed: `test/getPlainText.test.ts`,
5 cases, including `<img<a> src=x onerror=alert(1)>`, `<svg<a> onload=…>` and the `<math><mtext><script>`
mXSS vector.
Unit tests cover the bypass payloads, so the regex cannot come back unnoticed: **`test/sanitize.test.ts`**,
18 cases across `sanitizeHtml`, `sanitizeInlineHtml` and `getPlainText`, including
`<img<a> src=x onerror=alert(1)>`, `<svg<a> onload=…>` and the `<math><mtext><script>` mXSS vector. The
danger check the assertions use is itself tested against unsanitised input, so it cannot pass vacuously —
the first version did exactly that, matching `onerror` in text where it was inert content.

Rendered output needed a browser, because `SpeakerList`, `PickOfTheDayList` and `SearchResultCard` are
client-rendered — SSR HTML shows nothing for them. Five pages checked locally, **30 descriptions
Expand Down Expand Up @@ -1501,12 +1518,19 @@ computed on the podcast index. Fixing two lines restores type checking to a lot
one (via a cache-busting query) are **byte-identical**, so SSR output is stable over time.
- **`useNow.ts`.** Not used on this page at all — only `pages/konferenz/[slug]/index.vue` — and
it exists specifically to avoid this, by serialising the SSR timestamp into the payload.
- **`useWeightedRandomSelection` / `TestimonialSlider`.** Not on this page either. **But it is a
genuine latent instance of this bug elsewhere** — it seeds off `Math.floor(Date.now() /
3600000)`, an hour bucket, and renders on `/`, `/meetup`, `/konferenz` and
`/konferenz/[slug]`. With `isr: 3600`, HTML cached in one hour bucket can be hydrated by a
client in the next, selecting different testimonials. Those pages showed no warning when
checked, which only means the check did not straddle a boundary.
- **`useWeightedRandomSelection` / `TestimonialSlider`.** This document previously called it "a
genuine latent instance of this bug elsewhere", on the grounds that it seeds off
`Math.floor(Date.now() / 3600000)` and renders on four `isr: 3600` pages. **That was wrong, and
the correction is worth keeping**, because the reasoning looked sound: `TestimonialSlider.vue`
wraps its entire list in **`<ClientOnly>`**, so testimonials are never server-rendered —
confirmed against production, whose SSR HTML contains no testimonial text, only the
component's stylesheet link. There is nothing for hydration to compare, however far the clocks
diverge.

A fix was written and then reverted (2026-08-04). What settled it was a negative control: a
browser test shifted the client clock 61 minutes before any page JS ran, and **the unfixed
build produced zero warnings too**, so the test proved nothing. Pinning the seed would also
have tied rotation to when the page was *cached* rather than when it is viewed.
- **The `<template><!----></template>` that SSR emits as the first child of `<main>`.** It looked
conclusive — it is absent from the hydrated DOM — but it appears on *every* page, including
the three that produce no warning. It is an artefact of comparing `innerHTML`: browsers put
Expand All @@ -1517,20 +1541,51 @@ computed on the podcast index. Fixing two lines restores type checking to a lot
against a server-rendered real page and produced a cascade of mismatch warnings that are
artefacts of that failure. Retry from a clean `.nuxt` before trusting anything it reports.

**The most promising lead is a real bug in its own right:** `composables/useLoadingScreen.ts`
holds `isLoading` in a **module-scope `ref`**. On the server that module is instantiated once per
worker, so the flag is shared across concurrent requests — one visitor's navigation can change
what another's SSR renders. `<LoadingScreen />` is the first child of `<main>`, exactly where the
divergence appears. Fix that regardless of whether it turns out to be this mismatch: SSR state
belongs in `useState`, not a module-level `ref`.
**`useLoadingScreen` was fixed anyway** (#238, 2026-08-04) — it held `isLoading` in a
module-scope `ref`, created once per server worker and shared by every concurrent request, which
is the footgun Nuxt documents. `useAsyncData` awaits during SSR, so requests interleave and one
can resume rendering with another's flag. Now `useState`, which is per-request.

**It is probably not this mismatch**, and was not shipped as though it were: `isLoading` is
`false` on both sides in normal operation, and hammering six pages concurrently never reproduced
a leak. That fix rests on the documented anti-pattern, not on a reproduction.

So **both leads are now eliminated and this remains open.** The next thing to try is the dev-mode
reproduction from a clean `.nuxt`, since that is the only tool that names the offending element.
- [ ] ⚠️ **Remove `nitro.externals.inline: ['pinia']`.** Waiting on an upstream Pinia fix — see
[Waiting on upstream: the Pinia 4 export map](#waiting-on-upstream-the-pinia-4-export-map)
below for exactly what to watch for and how to check.
- [ ] **Make a full local `npm run build` work again.** Currently fails, and fails identically on
`main` — the prerender crawler walks ~1500 `/_ipx/` image URLs and exhausts connections to the
CMS. One line: `nitro: { prerender: { ignore: ['/_ipx'] } }`. Invisible in CI (which sets
`SKIP_PRERENDER_ROUTE_DISCOVERY=true`) and on Vercel (which does not prerender at all), so the
only person it hurts is a developer running a real build locally.
- [x] ✅ **A full local `npm run build` works again** — done 2026-08-04 (#240), via
`nitro.prerender.ignore: ['/_ipx']` applied only when a server will serve those URLs.

**This document described the failure wrongly.** It said the build "currently fails". It fails
*intermittently*: the first attempt at reproducing it passed in 193s, the second failed with
`ERROR terminated` from undici. The retry wrapper added in Phase 0 is what lets it sometimes
survive. Measured on the same commit:

| | without the ignore | with it |
| --- | --- | --- |
| result | **exit 1** (an earlier run passed) | exit 0 |
| duration | 193s | **29s** |
| `_ipx` files | 1475 (~100 MB) | **0** |
| prerendered HTML routes | 44 | **44** |
| `[directus]` retry warnings | up to 4 | **0** |

**The guard on `nitro.static` is load-bearing** — a review comment caught that the first version
ignored `/_ipx` unconditionally, which would have shipped a static build whose every optimised
image 404s, because there the crawler's output *is* the image pipeline. The prerendered HTML
carries hundreds of those references (73 in `index.html`, 253 in `podcast/index.html`). Keep the
guard even now that the `generate` script is gone: `npx nuxi generate` still works and still sets
`nitro.static`.
- [x] ✅ **Static generation dropped** — done 2026-08-04 (#240). `npm run generate` could not finish
against this CMS: a static build must prerender every `<nuxt-img>` variant, **6183** downloads
and resizes, and the CMS degrades until ipx returns 500 and `failOnError` ends the build. Response
times late in a run: 169s, 194s, 210s, 245s, 292s.

Three full runs, each clearing one blocker and exposing the next — crawled 404s from schemeless
CMS links (twice, on different surfaces), then the ipx capacity wall. Nothing depended on the
script: no workflow or Vercel config invoked it, and Vercel serves images through `_vercel/image`.
`AGENTS.md` records the absence so it is not reinstated as an oversight.
- [ ] ⚠️ **`tailwind.config.js` `content` globs do not include `.vue`.** They are
`'./pages/**/*.{html,js}'` and `'./components/**/*.{html,js}'`, so they match essentially
nothing; styling works only because `@nuxtjs/tailwindcss` injects its own defaults on top.
Expand Down Expand Up @@ -1890,13 +1945,15 @@ Tracked so nobody has to rediscover them. None are urgent on their own.
- The `nitro:config` hook fetches live Directus data during build with
`nitro.prerender.failOnError: true`. A CMS outage therefore fails the build. Acceptable for
deploys; the reason CI's build step is run with route discovery disabled.
- **A full local `npm run build` (route discovery enabled) currently fails, and has done since
before Phase 4.** The prerender crawler follows `/_ipx/…` image URLs and tries to prerender ~1500
image transforms, which exhausts connections to the CMS. Measured identically on Nuxt 3 and Nuxt
4; no page or payload fails, only image transforms. Neither CI nor Vercel is affected — CI skips
discovery, and the Vercel build does not prerender at all (ISR `routeRules` make routes on-demand
functions, and the build emits a `__fallback.func` in ~58s). To build fully offline-safe locally,
either set `SKIP_PRERENDER_ROUTE_DISCOVERY=true` or add `nitro.prerender.ignore: ['/_ipx']`.
- **A full local `npm run build` used to fail intermittently and no longer does** — fixed 2026-08-04
(#240) with `nitro.prerender.ignore: ['/_ipx']`, guarded on `nitro.static`. The crawler was
following `/_ipx/…` URLs and resizing ~1500 image transforms, which exhausted connections to the
CMS; no page or payload ever failed, only image transforms. Neither CI nor Vercel was affected — CI
skips discovery, and the Vercel build does not prerender at all (ISR `routeRules` make routes
on-demand functions, and the build emits a `__fallback.func` in ~58s).
- **There is no static-generation path**, deliberately. See the follow-up backlog: prerendering every
image variant is ~6200 CMS fetches and does not complete. `npx nuxi generate` still exists as a CLI
command, which is why the `nitro.static` guard on the ignore rule has to stay.
- **`tailwind.config.js` `content` globs do not include `.vue`** — they are
`'./pages/**/*.{html,js}'` and `'./components/**/*.{html,js}'`. Nothing is broken today only
because `@nuxtjs/tailwindcss` supplies its own default globs on top. A consequence worth knowing
Expand Down Expand Up @@ -1969,6 +2026,14 @@ Tracked so nobody has to rediscover them. None are urgent on their own.
| 2026-08-03 | Keep jsdom for anything still using DOMPurify. Its mXSS handling depends on parsing in a real DOM — verified when `<math><mtext><script>` reduced to `<math><mtext></mtext></math>` — so swapping to a parser-based sanitiser would trade security properties for install size. If weight ever matters, try `linkedom` + DOMPurify and re-run the 64-case harness. |
| 2026-08-03 | The `v-html` audit shipped: 9 live bindings became 4, all sanitising. Five excerpt sites moved to a parsing text-extractor plus `{{ }}`, removing the sink; the two unfiltered ProfileCreation sites gained DOMPurify because they genuinely render rich HTML. Two commented-out dead bindings deleted. |
| 2026-08-03 | **Corrected this document twice in the process.** It claimed eleven bindings and described the two in `PodcastPlayer.vue` as fine build-time SVG inlining — they were commented-out dead code calling `require()`. And "the fix is `{{ }}`" would have shipped a visible bug: the fields carry HTML entities that the old regex never decoded, relying on the browser to do it via `v-html`, so plain interpolation would have printed `f&uuml;r` on every umlaut. The shipped helper parses and returns real text instead. |
| 2026-08-04 | Auth payloads and user objects removed from browser `console.log`s (#240). Stated the exposure precisely rather than dramatically: the user object reaching the console is confirmed, the token fields are **not** — the app uses `authentication('session')`, Directus does not document the session-mode response body, and it cannot be observed without authenticating. The fix stands either way, and that line would print both tokens if the app ever moved to `json` mode. |
| 2026-08-04 | `nitro.prerender.ignore: ['/_ipx']` added, then **scoped to `!nitroConfig.static`** after review. The first version was verified the wrong way: I confirmed `/_ipx` still resolves at runtime, which is true for `nuxt build` and irrelevant for a static build, where the crawler's output *is* the image pipeline. The prerendered HTML carries hundreds of those references. Lesson: verify the mechanism you changed, not the one you were thinking about. |
| 2026-08-04 | CMS-entered links normalised at two surfaces, after the prerender crawler turned out to be acting as a link checker. Platform fields go through `normalizeExternalUrl(value, kind)`, where the `kind` is what makes a handle resolvable — `t.muelleer` is a valid Instagram username that reads like a hostname, and Bluesky handles *are* domains. Links inside rich text are fixed in a DOMPurify `afterSanitizeAttributes` hook, which only fills in a *missing* scheme so it cannot second-guess DOMPurify's URI policy and strip inline `data:` images. |
| 2026-08-04 | Two speaker social links had been resolving to 404s on our own domain for real visitors, because a schemeless `href` resolves against the current page. Found by the prerender crawler, not by anything user-facing — worth remembering as an argument for keeping `failOnError` on. |
| 2026-08-04 | Kept a reviewer's scheme allowlist (`javascript:` and friends now dropped rather than passed through) and fixed the typecheck regression it caused: `match()[1]` is `string | undefined` under `noUncheckedIndexedAccess`, so the ratchet correctly reported 264 against a baseline of 263. Rewritten as two regex tests. The ratchet earning its keep on someone else's patch is the argument for having it. |
| 2026-08-04 | Centralised the DOMPurify policy into `helpers/sanitize.ts` after a review comment on #237. Two components had each grown their own `computed(() => DOMPurify.sanitize(...))`, duplicating `InnerHtml.vue` — three places to edit for one policy change, against this repo's own `AGENTS.md` rule. `InnerHtml` and `NewsTicker` were folded in too, so `DOMPurify` now appears in exactly one file; fixing only the two new duplicates would have been the appearance of the rule rather than the rule. |
| 2026-08-04 | **Retracted this document's claim that `useWeightedRandomSelection` was a latent hydration-mismatch source.** `TestimonialSlider` wraps its list in `<ClientOnly>`, so testimonials are never server-rendered and the hourly seed cannot participate in hydration — confirmed against production, whose SSR HTML contains no testimonial text. A fix was written and reverted. What settled it was a negative control: with the client clock shifted past an hour boundary, the *unfixed* build produced zero warnings too, so the test proved nothing. |
| 2026-08-04 | `useLoadingScreen` moved from a module-scope `ref` to `useState` (#238). A module-scope ref is created once per server worker and shared across concurrent requests, and `useAsyncData` awaits during SSR so requests interleave. Shipped on the documented anti-pattern, explicitly **not** as a fix for the podcast hydration mismatch — that was never reproduced. Declined the reviewer's suggestion to split read from write: 8 of 34 pages never call the composable, so the no-argument reset is what clears the overlay, and removing it would strand a full-screen overlay on those routes. |
| 2026-08-03 | Left `helpers/getMetaInfo.ts` on the same vulnerable regex on purpose: it writes to a `<meta content>` attribute, not innerHTML. Verified Nuxt escapes attribute values by finding a CMS description containing a raw `"` and confirming production renders `&quot;`. Changing it would pull `isomorphic-dompurify` into the `helpers` barrel for no security gain. |
| 2026-08-03 | The `v-html` audit supersedes the jsdom question. 2 of 11 bindings sanitise; several regex-strip tags and the strip is bypassable by nested-tag reconstruction (`<img<a> src=x onerror=…>` reassembles). Since those components strip *all* markup, `{{ }}` is both safer and simpler and removes the sink — a smaller fix than the dependency debate it was hiding behind. |
| 2026-08-03 | `@directus/sdk` taken to **24.0.0** despite the server being frozen at 11.17.4, because the SDK major tracks the Directus monorepo rather than a server API contract: 21.3.0 is simply the SDK that shipped *with* 11.17.4, and 22/23/24 shipped with 12.0/12.1/12.2. Of the four breaking changes, three touch commands this app never calls. Holding at 21 would have deferred nothing real. |
Expand Down
Loading