Skip to content

routine 2026-10-05 (2): README rendering can't stall the server, copy buttons that speak and fail loudly, client-bundle audit, header hardening, 404s that say who asked - #15

Merged
physics515 merged 12 commits into
mainfrom
routine/site-2026-10-05-2
Oct 8, 2026
Merged

physics515 merged 12 commits into
mainfrom
routine/site-2026-10-05-2

Conversation

@physics515

Copy link
Copy Markdown
Contributor

Nightly routine, 2026-10-05 (2), started 21:10 CDT. This is the second run today; the morning run is #14.

Rebased onto main at aa0220c, which includes two commits another session pushed during this run: splimes, and its own source-map-js override (see "Concurrent work" below).

Increments

1. marked 18.1.0: link destinations no longer backtrack cubically (53e9fa7)

Files: package.json, package-lock.json, test/markdown-backtracking.test.ts (new).

Problem: READMEs are fetched at request time and rendered on the server's one thread. marked 18.0.14 backtracked cubically on []( followed by a run of unicode whitespace (markedjs/marked#4106). Through the site's own renderMarkdown, 4 KB of U+00A0 took 10.3 s. One README edit could have stalled every page.

Fix: marked 18.1.0, published today. The same input now takes 0.1 ms.

Evidence:

  • All 87 documents the site renders (7 READMEs, the 76 live posts backed up from the container, 4 fixtures) are byte-identical on both versions.
  • The new test renders 8 KB of three different unicode spaces. On 18.0.14 it does not finish inside 60 s.
  • Every route answered as expected.
  • check, a11y and a11y:browser clean over 95 pages, including the live posts.
  • Screenshots: built-in browser pane at desktop and mobile, plus headless 1280/390.

2. axe-core 4.14, with its new default rule judged in the browser (fd89785)

Files: package.json, package-lock.json, scripts/check-a11y{,-browser}.mjs, README.

Problem: axe 4.14 turns on label-content-name-mismatch (WCAG 2.5.3) by default. jsdom cannot tell what text is visible, so the rule only ever came back "incomplete" on the copy buttons. Nothing was actually judging it.

Fix: the rule moves to the real-browser pass.

Evidence:

  • Today's pages pass.
  • With a mismatched aria-label planted in CodeBlock, the browser pass fails with 12 violations (one per project page, per width).

3. The README patterns run in linear time (31d878e)

Files: shared/markdown/readme.ts, shared/markdown/slug.ts, test/markdown-backtracking.test.ts, ROADMAP.

Problem: eight of the site's own regexes over fetched README text were quadratic on input that opens something and never closes it. At 100 KB:

Input Before After
absolutize, a run of [ 5.2 s 0.5 ms
slugify, a run of < 5.0 s 0.9 ms
a 100 KB heading 2.5 s 34 ms
stripLeadingLogo 1.0 s 0.2 ms
lazyImages 1.0 s 0.2 ms
<img runs 0.4 s 0.2 ms

The eighth pattern is the <a href> rule the splimes commit added tonight, which had the same shape (100 KB of <a : 711 ms). I made it linear while resolving the rebase.

Fix: a link label stops at the next [, a tag stops at the next <, and a URL may end at the end of the input. The logo test now runs in a callback instead of the pattern.

Evidence:

  • All 8 READMEs (prepared markdown and HTML, splimes included, compared against main's readme.ts) and the 76 posts are byte-identical before and after.
  • 12 timing cases, all failing on the old code.
  • Three tests pin the edges that move: a URL ending the input, ![a [b](x), and a stray < before a tag.

4. A copy button announces that it copied (9acb737)

Files: app/components/CodeLine.vue, app/components/CodeBlock.vue, ROADMAP.

Problem: the button's text changes to [copied], but its name is pinned by aria-label, so a screen reader heard nothing (WCAG 4.1.3).

Fix: each component now has an empty role="status" region, rendered on the server.

Evidence: checked over CDP with clipboard access granted.

  • The polite region reads "Copied to clipboard" in the accessibility tree.
  • The clipboard holds the command.
  • Both reset after 1.6 s.
  • CodeBlock's region sits outside its figcaption, so it doesn't change the figure's name.

5. No x-powered-by, and a Cross-Origin-Opener-Policy (3b5eaff)

Files: server/plugins/powered-by.ts (new), nuxt.config.ts, scripts/check-site.mjs, ROADMAP.

Problem: Nuxt's renderer sets x-powered-by: Nuxt on every page after route rules apply, so a route rule cannot remove it.

Fix:

  • The header is removed in Nitro's beforeResponse.
  • COOP same-origin is added to the /** route rule.
  • npm run check now fails on either one being wrong.

Evidence:

  • Against the previous build, the new check failed all 94 pages, once for each header.
  • After the fix, x-powered-by is gone from pages, the 404 page, the API, the XML routes, static files and HEAD responses.
  • Client navigation in the pane (home → onyums → its blog) logs no console errors.

6. A refused clipboard selects the text and says so (f551840)

Files: app/composables/useCopy.ts (new), both copy components, ROADMAP.

Problem: found while testing #4, when the browser pane's own clipboard refused the write. On a refused write, the button did nothing, so the visitor would paste whatever their clipboard already held.

Fix: useCopy is now the one copy of the logic both components carried. On refusal it selects the text, shows [selected] and announces how to finish the copy.

Evidence: checked over CDP with the permission denied (NotAllowedError).

  • The selection is exactly the command, without the $.
  • Both buttons reset after 4 s.
  • No overflow at 390 px; screenshot taken.

7. A failed request's log line says who asked (893eee5)

Files: server/plugins/request-log.ts, README, ROADMAP.

Problem: the error-hook line covers every 404 and 500. It promised the same shape as the success line but stopped at via. In 15 h of live log, 627 lines (almost all scanners) had no ip, ua or ref.

Fix: both lines now share one requester() function.

Evidence: on a built server, with a forwarded address, user agent and referer:

  • 200, page 404, API 404 and HEAD 404 lines all carry the three fields.
  • An onion-marked request keeps ip: null.
  • There is still exactly one line per request.

8. audit:runtime covers the client bundle (962f2a5, README f1735aa)

Files: scripts/lib/client-packages.ts (new), nuxt.config.ts, scripts/audit-runtime.mjs, README, ROADMAP.

Problem: the roadmap said it outright: the client bundle had no sourcemaps, so the audit had nothing to read its packages from.

Fix:

  • A client-only Vite plugin records every package with rendered code, at its exact version.
  • It writes the list to .output/server/client-packages.json. That file is inside the image but never under public/, and the server answers 404 for it.
  • The audit now asks about those packages too.

Evidence:

  • 32 client packages, 14 of which the server audit never saw. Clean.
  • It fails on a planted marked@4.0.9, on a missing inventory and on an empty one.
  • It also showed that tiptap's marked@17 never reaches a browser.

Dropped in the rebase: a source-map-js 1.2.2 lockfile bump

audit:runtime went red tonight on GHSA-68fv-2mgg-jv7q. I fixed it with a lockfile bump, then the other session landed the same fix as an override on main. The rebase dropped my commit as already upstream, and the audit is clean on the rebased tree.

Also: fallback snapshot refreshed (665c58f)

npm run sync (8 projects): issue counts and one push date moved. Snapshot test passes.

Final verification (rebased tree, combined)

Checks:

  • npm ci on the merged lockfile.
  • Typecheck clean, 231 tests, build green.
  • audit:runtime: 79 packages (server and client), clean.
  • cargo audit --deny yanked --deny unsound (onion): only the two known unmaintained warnings.
  • check: 96 pages, 153 URLs, no problems.
  • a11y: 97 pages, clean.
  • a11y:browser: 97 pages at 1280 and 390 px, no violations and no console errors.

Real status codes:

  • /healthz, /, /projects, /news, /status, /sitemap.xml, /robots.txt, /releases.xml, /news.xml, /api/projects and /api/projects/splimes: 200.
  • Every /projects/<slug> (8): 301. Their /about and /blog: 200.
  • /projects/no-such-project, /no-such-page, /api/projects/nope, /api/admin/posts and onion-marked /admin: 404.
  • /onion-frame: 503, expected locally because there is no gateway.
  • HEAD /healthz: 200.

Screenshots: home, Skidbladnir, Nisaba and splimes at desktop and mobile, headless, in the run's scratch dir. I also looked at the splimes page and home in the built-in browser pane: no overflow, and no relative links left in the splimes README.

Concurrent work

Another Claude session worked in this same checkout during the run. It pushed 4278d22 (splimes) and aa0220c (source-map-js override) straight to main, and redeployed the live container at 22:37.

It also killed my local verification servers twice, on two different ports, partway through a11y:browser. I worked around it by serving a copy of the build from a distinct path. Those kills were external, not a site fault: the same pages served fine in isolation, and the full pass is clean.

The live container runs as uid 100 and was unaffected.

Research

Done vs open

Eight increments landed, plus the snapshot refresh.

New [ ]:

  • Nuxt 4.6, after a soak.
  • The devtools/simple-git upstream item.

Unchanged and still owner-gated or upstream:

  • Owner decisions: the GitHub App token, distroless (blocked on the compose healthcheck), Onion-Location, HSTS, basic-automation.github.io, and contrast together with axe color-contrast.
  • Screenshots for Nisaba and Enlil.
  • Upstream: the x265 and onyums README links, and the TypeScript 7.1 API.

STOP REASON: no workable next item, at ~2 h 20 min, short of the 4 h bar. After eight increments, sweeps of the live logs, response headers, regex surfaces, the npm and cargo advisories, the client bundle and the external links turned up nothing more. The one new item, Nuxt 4.6, was deliberately held back. I didn't make up work to fill the clock.

Recommended next step

  1. Land Nuxt 4.6 in a run once it has had a few days, or once 4.6.1 is out. The trial lockfile is in the run's scratch dir.
  2. Make the one-line upstream fixes to the Skidbladnir README (x265 link) and the onyums README (anchor).
  3. Agree a rule for concurrent sessions in this checkout. Tonight one pushed to main and killed node .output/server/index.mjs processes mid-run.

🤖 Generated with Claude Code

physics515 and others added 12 commits October 5, 2026 23:14
… cubically

marked 18.0.14 matched the gap before a link destination with `\s*`, which
overlaps the destination class on unicode whitespace, so `[](` followed by a
run of U+00A0 backtracked cubically (markedjs/marked#4106). Through this site's
own renderMarkdown, 4 KB of it took 10.3 s, all on the server's one thread —
one README edit in any of the seven repos could stall every page.
On 18.1.0 the same input renders in 0.1 ms.

All 87 documents the site renders today (7 READMEs, the 76 live posts, the
4 test fixtures) come out byte-identical on both versions.

test/markdown-backtracking.test.ts renders 8 KB of three unicode spaces
through both the README and post paths in under 500 ms; on 18.0.14 it does
not finish inside 60 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…owser

axe-core 4.14.0 turns `label-content-name-mismatch` on by default (WCAG 2.5.3,
label in name). It compares a control's accessible name with its visible
text, which jsdom cannot determine, so in `npm run a11y` it only ever came back
undecided — on the copy buttons of every project page — and no pass judged it.

It moves to the layout list: skipped by name in scripts/check-a11y.mjs, run
in scripts/check-a11y-browser.mjs at 1280 and 390 px. Today's pages pass
("[copy]" sits at the start of "Copy …"). Proved it bites: with CodeBlock's
label planted as "Download …", the browser pass fails with 12 violations,
one per project page per width.

Both passes clean over 95 pages, including the 76 live posts. README
names the new rule beside the other browser-only ones.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
READMEs are fetched and prepared at request time on the server's one
thread. Seven patterns in shared/markdown/readme.ts and slug.ts were
quadratic on input that opens something and never closes it: a global
regex is retried at every offset, and each of these could scan from one
start past where the next one began. At 100 KB:

  absolutize, a run of `[`            5.2 s   → 0.5 ms
  absolutize, a run of `![`           2.7 s   → 0.5 ms
  absolutize, a run of `[a](`         1.9 s   → 0.2 ms
  absolutize / lazyImages, `<img `×   0.4/1.0 s → 0.2 ms
  stripLeadingLogo, logo URL, no `)`  1.0 s   → 0.2 ms
  slugify, a run of `<`               5.0 s   → 0.9 ms
  renderMarkdown, 100 KB heading      2.5 s   → 34 ms

A link label now stops at the next `[`, a tag at the next `<`, a URL may
end at the end of the input, and the logo test moved out of the pattern
into a callback. All 7 READMEs (prepared markdown and HTML) and the 76
live posts are byte-identical before and after. The edges that move are
pinned in the test: `![a [b](x)` is now read as text and a link, the way
marked renders it, and a stray `<` before a tag stays out of it, which
is what GitHub's slug of that heading is made from.

test/markdown-backtracking.test.ts times each input: 11 cases, every one
failing on the old patterns. ROADMAP gains the item, with the marked fix
from the previous commit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Pressing [copy] changed the visible text to [copied], but the button's
accessible name is its aria-label ("Copy to clipboard: …", "Copy <label>"),
so a screen reader announced nothing (WCAG 4.1.3, status messages).

app/components/CodeLine.vue and CodeBlock.vue each render an empty
`role="status"` sr-only region on the server, so it exists before it
changes, and it reads "Copied to clipboard" for the same 1.6 s as the
visible label. CodeBlock's sits beside its figcaption, not in it, so it
is never part of the figure's name.

Checked in headless Chromium over CDP with clipboard permission: after
activation the polite live region's text in the accessibility tree is
"Copied to clipboard", the clipboard holds the command (and the
config.toml block for CodeBlock), and both reset after 1.6 s. Pages are
unchanged to the eye: the pixel difference against the previous build is
in the masthead's animated glass only, and the same size as between two
earlier builds. check, a11y and a11y:browser clean over 95 pages.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every live response carried `x-powered-by: Nuxt`. Nuxt's renderer, payload
and island handlers set it themselves after route rules apply, so no rule
can take it off; server/plugins/powered-by.ts removes it in Nitro's
`beforeResponse`, which runs after any handler. It tells a scanner which
framework's advisories to try first and nothing else.

`Cross-Origin-Opener-Policy: same-origin` joins the `/**` headers in
nuxt.config.ts: no page keeps a handle on a window it opens, and a
cross-origin page that opens this site gets none into it.

scripts/check-site.mjs fails any response with x-powered-by and any page
without the policy. Against the previous build: 94 failures for each.
After: gone from pages, the 404 page, /healthz, /api, the XML routes,
static files and HEAD; check, a11y and a11y:browser clean over 95 pages;
routes unchanged; client navigation home → onyums → its blog in the
browser with no console errors; screenshots differ only in the
masthead's animated glass, as between any two builds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
When `navigator.clipboard.writeText` was refused (denied permission,
insecure context, a frame that forbids it) the copy buttons did nothing:
still `[copy]`, nothing announced, and the visitor pasted whatever their
clipboard held before. Found when the built-in browser pane refused it
while the previous commit was being tested.

app/composables/useCopy.ts is now the one copy of the logic CodeLine and
CodeBlock each carried. On refusal it selects the code element's
contents, so the platform's copy shortcut finishes the job, shows
`[selected]` for 4 s and announces "Could not copy. The text is
selected; copy it with your keyboard." in the status region.

Checked in headless Chromium over CDP:
- permission granted: `[copied]`, "Copied to clipboard", the clipboard
  holds the command / the config.toml block, reset after 1.6 s
- permission denied (`NotAllowedError`): `[selected]`, the message
  above, the selection is exactly the command without its `$` sigil
  (and the whole config.toml block), reset after 4 s, no overflow at
  390 px (screenshot in the run's scratch dir)
check, a11y, a11y:browser clean over 95 pages; 226 tests; routes
unchanged; page screenshots unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Nuxt 4.6.0 was published an hour into tonight's run. A trial in a
scratch worktree was clean on every gate (typecheck, 226 tests, build,
audit:runtime, check, a11y, a11y:browser; 23 routes as on 4.5.2;
byte-identical page sizes; one JSON log line per request) and changes
nothing visible: one CSS line height rounds to 16 px instead of 15.98.
It stays a `[ ]` with that evidence: a 420-commit minor with a CLI
major, an hour old, should not go out in an unattended deploy.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The request log has two writers: `afterResponse` for answered requests,
and the `error` hook for requests that threw, every 404 and 500. Its
comment promised "same shape as the line above", but the error line
stopped at `via`: no `ip`, `ua` or `ref`. In fifteen hours of live log
that was 627 lines, nearly all scanners probing `.env`, `.php` and
`.git`, and the only lines with no address or user agent.

server/plugins/request-log.ts builds both from one `requester(event)`.
Checked on a built server with a forwarded address, user agent and
referer: a 200, a page 404, an API 404 and a HEAD 404 all carry the
three fields; an onion-marked request keeps `ip: null` and `via: onion`;
each request is still exactly one line. Gate: typecheck, 226 tests,
build, check, a11y, a11y:browser; routes and pages unchanged. README
says the fields are on every line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The runtime audit asked the registry about what the server ships and
said plainly that the client bundle was not covered: it is built without
sourcemaps (rightly; they would be served), so there was nothing to read
its packages from.

scripts/lib/client-packages.ts is a Vite plugin applied only to the
client environment. In generateBundle it maps every module with rendered
code to its package and that package's own version, and a Nitro
`compiled` hook in nuxt.config.ts writes the list to
.output/server/client-packages.json: inside the image, never under
public/, and a 404 from the server at every spelling tried.
scripts/audit-runtime.mjs adds those packages to its registry query
and fails when the file is missing or empty, rather than passing with
the browser half unasked.

Today: 32 client packages, 14 of which the server audit never saw;
79 asked in all; clean. Proved it bites: a planted marked@4.0.9 fails
with its two advisories; a missing and an empty inventory both fail.
Gate: typecheck, 226 tests, build, check, a11y, a11y:browser (95 pages,
both widths); routes and pages unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
npm run sync, authenticated, all 8 projects including splimes: issue
counts and one push date moved. test/snapshot.test.ts passes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… the splimes merge

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@physics515
physics515 merged commit 40c9ca4 into main Oct 8, 2026
3 checks passed
physics515 added a commit that referenced this pull request Oct 8, 2026
…24 CI actions (#17)

* feat(deps): Nuxt 4.6.0

Lands the roadmap's "Nuxt 4.6 after a soak" item, two and a half days
after release. 4.6.0's security section covers the internal error route
being reachable from outside, unhandled error data reaching the error
page, and error-render recursion tracked by a client-controllable header.

package.json, package-lock.json: nuxt ^4.6.0, then npm dedupe.
ROADMAP.md: the item ticked, with what was checked.

Typecheck, 236 tests, build, audit:runtime, check, a11y and a11y:browser
clean; every route answers as on 4.5.2 with same-sized bodies; the image
builds on node 24.21.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(deps): shell-quote 1.12.0, past its quote() injection advisory

Only @nuxt/devtools' launch-editor depends on it, and devtools is
disabled and never ships, so nothing reachable changes. It is a
lockfile-only, in-range patch that clears a critical from npm audit.

package-lock.json: shell-quote 1.10.0 -> 1.12.0.
ROADMAP.md: the devtools upstream item records it, and the two advisories
left (braces, node-forge) that have no patched release.

Typecheck, build and audit:runtime clean; every route answers as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs(roadmap): vue-router 5.4, after a soak and a browser check

Research from this run: 5.4.0 changes client-side scroll and hash
restoration defaults, which no server gate exercises.
https://github.com/vuejs/router/releases/tag/v5.4.0

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(data): refresh the fallback snapshot

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* ci: every action on its first Node 24 major

GitHub forces Node 20 actions onto Node 24 and annotates each run as
deprecated. Moved: actions/checkout v5, actions/setup-node v5,
docker/build-push-action v7, docker/setup-buildx-action v4,
docker/login-action v4, docker/metadata-action v6. None of their breaking
changes touch this repo: the inputs they removed are unused, and
setup-node's automatic cache needs a packageManager field package.json
does not have.

.github/workflows/{ci,release,links}.yml, ROADMAP.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant