Skip to content
Merged
Show file tree
Hide file tree
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
16 changes: 10 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,10 @@ it resets, read from the headers of its last response — and when the onion
service was last reached over Tor, from the gateway's own ten-minute self-fetch.
Thirty minutes without one turns `/healthz`'s `status` to `degraded` (still a
200: the clearnet site is serving). The server
also logs one JSON object per request on stdout — `docker logs
basicautomation-site | jq 'select(.status >= 400)'`, or `select(.via == "onion")`
for the visits that came over Tor — and warns once an hour for
also logs one JSON object per request on stdout, failed ones included, each
with the client's `ip`, `ua` and `ref` — `docker logs basicautomation-site | jq
'select(.status >= 400)'`, or `select(.via == "onion")` for the visits that
came over Tor — and warns once an hour for
as long as it has been answering from the snapshot, and once when the GitHub
quota runs out (`event: "upstream.rate_limited"`). Every other line the app
writes is JSON too, with an `event` to select on — a GitHub call that failed and
Expand Down Expand Up @@ -200,7 +201,7 @@ npm run cuts # cut card art down to its srcset widths (needs ImageMagick
npm run bases # runtime Alpine still matches the node base (needs docker)
npm run contrast # WCAG contrast for every palette colour, against the ground
npm run sizes # re-read every image's intrinsic size
npm run audit:runtime # advisories for what .output/server ships (after a build)
npm run audit:runtime # advisories for what the build ships, server and client
```

`npm test` is Vitest over the pure functions in `shared/` — README rewriting,
Expand Down Expand Up @@ -230,7 +231,8 @@ npm run font # re-cut the preloaded core of Fira Code
actually served. The rules that need a layout engine are named and skipped
rather than silently failing — jsdom has none — so this is "every axe rule that
can be judged from markup". `npm run a11y:browser` runs those skipped rules —
touch-target size, keyboard access to scrollable regions, the viewport meta —
touch-target size, keyboard access to scrollable regions, the viewport meta,
whether each control's visible label is part of its accessible name —
in headless Chromium at a desktop and a phone width (set `CHROME_PATH` if
Chromium is not on `PATH`), reading each page to the bottom so lazy content
loads, and fails on any console error, uncaught exception or CSP refusal the
Expand All @@ -257,7 +259,9 @@ READMEs set — that every page preloads, and the full 113 KB font behind it by
`npm run audit:runtime` (in CI, after the build) is `npm audit` narrowed to the
code a visitor's request can reach: the packages Nitro traced into
`.output/server/node_modules`, plus the ones it inlined into the server chunks,
read from their sourcemaps. It fails on a high or critical advisory that applies
read from their sourcemaps, plus every package in the client bundle, which the
build records as it writes the chunks (`.output/server/client-packages.json`;
the client has no sourcemaps to read). It fails on a high or critical advisory that applies
to one of them. Plain `npm audit` reports the whole lockfile, so it also counts
the build's file globbing and the dev server's certificate helper, and it can
stay red on advisories nobody can patch. The onion gateway, the image's other
Expand Down
86 changes: 84 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,13 @@ record. There is no run log.
and `test/log-lines.test.ts` fails a `console` call in `server/` or
`shared/` that does not write JSON. Nitro's own "Listening on" line is
the one left, and is not this app's to change.
- [x] …and a failed request says who asked. The error-hook line — every 404
and 500 — stopped at `via`, though its comment promised the success
line's shape, so the 627 404s in fifteen hours of live log (nearly all
scanners) were the only lines with no `ip`, `ua` or `ref`. Both lines
now take those fields from one `requester()`
(`server/plugins/request-log.ts`); still one line per request, HEAD
still logged as HEAD, onion requests still `ip: null`.
- [x] `/api/posts?project=<unknown>` is a 404, like `/api/projects/<unknown>`,
not an empty list.
- [x] Answer HEAD wherever GET is answered. Nitro routes by filename suffix, so
Expand Down Expand Up @@ -365,6 +372,21 @@ record. There is no run log.
- [ ] Move the type checker to TypeScript 7 once 7.1 ships its API and vue-tsc
adopts it — the 6.0 API is a bridge, not a destination.
<https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/>
- [ ] Move to Nuxt 4.6 once it has had a few days, or a 4.6.1, in the wild.
Published 2026-10-05 22:11 UTC: 420 commits and a CLI major, and its
security section matters here — the internal error route reachable from
outside, unhandled error data handed to the error page, error-render
recursion tracked by a client-controllable header. Trialled the same
night in a scratch worktree (`npm install nuxt@^4.6.0`, `npm dedupe`): typecheck,
226 tests, build, `audit:runtime`, `check`, `a11y` and `a11y:browser`
all clean; 23 routes, error-route probes included, answer as on 4.5.2;
pages byte-identical in size; still one JSON log line per request.
The only visible-to-a-machine change: the CSS minifier rounds one line
height to 16 px where it was 15.98, so the home page drifts by ≤0.05 px
— no difference to the eye. Not landed because the run ends in an
unattended deploy of a release an hour old. Needs Node `^24.15.0`; the
build image has 24.21.
<https://github.com/nuxt/nuxt/releases/tag/v4.6.0>
- [x] Silenced Nitro's own `[request error]` stack-trace block on a 404, without
replacing the error handler. Nitro logs it when the error is `fatal`, and
`fatal` is only load-bearing on the client, where it is what makes a 404
Expand Down Expand Up @@ -489,6 +511,16 @@ record. There is no run log.
Proved in a browser: an injected `<style>` rule is refused, an injected
`style=` attribute and every Shiki colour still apply.
<https://www.w3.org/TR/CSP3/#directive-style-src-elem>
- [x] No `x-powered-by`, and a `Cross-Origin-Opener-Policy`. Nuxt's renderer,
payload and island handlers set `x-powered-by: Nuxt` on every page
themselves, after route rules apply, so it was on every response
(live, 2026-10-05); `server/plugins/powered-by.ts` removes it in
Nitro's `beforeResponse`. `Cross-Origin-Opener-Policy: same-origin`
joins the `/**` headers: no page here keeps a handle on a window it
opens, or is opened by one that should keep a handle on it. `npm run
check` fails a response carrying `x-powered-by` and a page without the
policy; against the previous build it failed on all 94 pages for each.
<https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cross-Origin-Opener-Policy>
- [x] Reserve space for the images the pages render. Every `<img>` this repo
controls now carries its intrinsic `width`/`height`, read out of the file
itself by `npm run sizes` into `data/asset-sizes.generated.json`, with a
Expand Down Expand Up @@ -672,6 +704,26 @@ record. There is no run log.
starts the server on `test/fixtures/content/` (three test posts, one a
draft), so `check`, `a11y` and `a11y:browser` see post pages at all, and
`check` asserts no draft is listed.
- [x] A copy button says it copied. `[copy]` turns `[copied]` on screen, but the
button's name is pinned by its `aria-label`, so a screen reader heard
nothing after pressing it (WCAG 4.1.3, status messages). `CodeLine` and
`CodeBlock` each render an empty `role="status"` region, server-side so
it exists before it changes, which reads "Copied to clipboard" for the
same 1.6 s. Checked in headless Chromium over CDP with clipboard access
granted: the polite region's text in the accessibility tree, the
clipboard's contents, and the reset; `CodeBlock`'s region sits outside
its `figcaption`, so it never becomes part of the figure's name.
<https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html>
- [x] …and says when it could not. A refused clipboard write (denied
permission, insecure context, a frame that forbids it) did nothing at
all — the button stayed `[copy]` and whoever pressed it pasted what
their clipboard already held. Found when the built-in browser's own
clipboard refused it. `useCopy` (`app/composables/useCopy.ts`, now the
one copy of the logic both components carried) selects the text
instead, shows `[selected]` and announces "Could not copy. The text is
selected; copy it with your keyboard." Checked over CDP with the
permission denied: the selection is exactly the command, without the
`$` sigil, and both buttons reset after 4 s.
- [ ] Run axe's `color-contrast` in the browser pass too, once the contrast item
below is decided. Left out deliberately: it would be red today on the
shortfalls already waiting on that decision.
Expand Down Expand Up @@ -748,8 +800,24 @@ record. There is no run log.
chunks, which their sourcemaps name. The registry filters by version
itself; the script re-checks each range as a cross-check on what comes
back. Proved by planting `braces@3.0.3` and
`marked@4.0.9`: it fails on exactly the three advisories that apply. The
client bundle has no sourcemaps, so it is not covered.
`marked@4.0.9`: it fails on exactly the three advisories that apply.
- [x] …and the client bundle too. It has no sourcemaps (they would be served),
so there was nothing to read its packages from. A client-only Vite plugin
(`scripts/lib/client-packages.ts`) records every package with code
rendered into a client chunk, with the version from its own
`package.json`, and writes `.output/server/client-packages.json` once
Nitro has compiled — inside the image, never under `public/` (a 404 from
the server). `audit:runtime` asks the registry about those as well: 32
today, 14 of them never seen by the server audit (`reka-ui`, `@nuxt/ui`,
`@nuxt/icon`, `@nuxtjs/color-mode`…). It fails on a planted
`marked@4.0.9`, on a missing inventory and on an empty one. Also learned:
`@tiptap/markdown`'s `marked@17` never reaches a browser.
- [ ] Upstream: `@nuxt/devtools` 3.4.2 pins `simple-git ^3.36.0`, and
`npm audit` reports four advisories against it (two critical, command
execution through git options), patched only in `simple-git` 4. Not
reachable here — `devtools: { enabled: false }`, and nothing of it
ships — and Nuxt 4.6 still resolves the same versions. Moves when
devtools does. <https://github.com/advisories/GHSA-858h-whjf-mvg5>
- [x] …and the image's other binary. The onion gateway's 594 crates were
audited by nothing. CI's onion job now runs `cargo audit --deny yanked
--deny unsound`. First run, 2026-10-02: `yoke-derive` 0.8.3 was yanked,
Expand All @@ -764,6 +832,20 @@ record. There is no run log.
<https://github.com/advisories/GHSA-vfj7-8cjw-p6xm>
<https://github.com/advisories/GHSA-86w9-cpqp-85rv>

- [x] A README cannot stall the server. READMEs are fetched and rendered at
request time, on the server's one thread, so a pattern that backtracks
over one stalls every page the process serves. Two sources, both closed
2026-10-05: marked 18.0.14's link-destination rule, cubic on a run of
unicode whitespace (4 KB of U+00A0 after `[](`: 10.3 s here; 0.1 ms on
18.1.0), and eight of this site's own patterns in
`shared/markdown/readme.ts` and `slug.ts`, quadratic on input that opens
and never closes (100 KB of `[`: 5.2 s; a 100 KB heading of `<a`: 2.5 s;
about a millisecond each now). The eighth is the `<a href>` rule that
arrived with splimes the same night, written in the old shape (100 KB of
`<a `: 711 ms) and made linear in the merge. All 8 READMEs and the 76
live posts render byte-identical before and after. `test/markdown-backtracking.test.ts`
times each input; every case fails on the old code.
<https://github.com/markedjs/marked/pull/4106>
- [x] A real 404 check: every internal link, every render, on every route — `npm run check`
- [x] …and the two XML documents nobody looks at, checked the same way. A feed
breaks silently for every subscriber at once, and both documents carry
Expand Down
31 changes: 10 additions & 21 deletions app/components/CodeBlock.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,8 @@ const { code, label, html } = defineProps<{
html?: string | null
}>()

const copied = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined

async function copy() {
try {
await navigator.clipboard.writeText(code)
copied.value = true
clearTimeout(timer)
timer = setTimeout(() => (copied.value = false), 1600)
}
catch {
// Clipboard unavailable (insecure context, denied permission) — the code is
// on screen to select by hand.
}
}

onBeforeUnmount(() => clearTimeout(timer))
const { copy, label: buttonText, announcement } = useCopy()
const target = useTemplateRef<HTMLElement>('target')
</script>

<template>
Expand All @@ -33,15 +18,19 @@ onBeforeUnmount(() => clearTimeout(timer))
:ui="{ base: 'bg-transparent! p-0! ring-0! font-mono text-xs cursor-pointer hover:bg-transparent!' }"
class="ml-auto shrink-0 text-pn-muted hover:text-pn-fg-bright"
:aria-label="`Copy ${label}`"
@click="copy"
@click="copy(code, target)"
>
{{ copied ? '[copied]' : '[copy]' }}
{{ buttonText }}
</UButton>
</figcaption>
<!-- The button's name is pinned by its aria-label, so its text changes
nothing a screen reader reads out. This says it, outside the
figcaption so it never becomes part of the figure's name. -->
<span v-if="label" role="status" class="sr-only">{{ announcement }}</span>

<!-- Highlighted on the server by Shiki, in the site's own palette. -->
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-if="html" class="code" v-html="html" />
<pre v-else class="code"><code>{{ code }}</code></pre>
<div v-if="html" ref="target" class="code" v-html="html" />
<pre v-else ref="target" class="code"><code>{{ code }}</code></pre>
</figure>
</template>
28 changes: 8 additions & 20 deletions app/components/CodeLine.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,8 @@ const { code, prompt = true } = defineProps<{
prompt?: boolean
}>()

const copied = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined

async function copy() {
try {
await navigator.clipboard.writeText(code)
copied.value = true
clearTimeout(timer)
timer = setTimeout(() => (copied.value = false), 1600)
}
catch {
// Clipboard unavailable (insecure context, denied permission) — the command
// is on screen to select by hand.
}
}

onBeforeUnmount(() => clearTimeout(timer))
const { copy, label: buttonText, announcement } = useCopy()
const target = useTemplateRef<HTMLElement>('target')
</script>

<template>
Expand All @@ -30,16 +15,19 @@ onBeforeUnmount(() => clearTimeout(timer))
address or a URL is one unbroken token, and it used to overflow into a
scroll region on a phone — one a keyboard could not reach, and one that
hid most of the address it exists to show. -->
<code class="min-w-0 flex-1 overflow-x-auto py-1 text-sm wrap-anywhere text-pn-green prompt" :style="prompt ? undefined : { '--prompt': '\'\'' }">{{ code }}</code>
<code ref="target" class="min-w-0 flex-1 overflow-x-auto py-1 text-sm wrap-anywhere text-pn-green prompt" :style="prompt ? undefined : { '--prompt': '\'\'' }">{{ code }}</code>
<!-- UButton for the focus ring, keyboard handling and disabled semantics;
app.config.ts strips it back to a bracketed mono label. -->
<UButton
:ui="{ base: 'bg-transparent! p-0! ring-0! font-mono text-xs cursor-pointer hover:bg-transparent!' }"
class="shrink-0 text-pn-muted hover:text-pn-fg-bright"
:aria-label="`Copy to clipboard: ${code}`"
@click="copy"
@click="copy(code, target)"
>
{{ copied ? '[copied]' : '[copy]' }}
{{ buttonText }}
</UButton>
<!-- The button's name is pinned by its aria-label, so its text changes
nothing a screen reader reads out. This says it. -->
<span role="status" class="sr-only">{{ announcement }}</span>
</div>
</template>
51 changes: 51 additions & 0 deletions app/composables/useCopy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* The copy buttons' behaviour, for `CodeLine` and `CodeBlock`.
*
* The clipboard can refuse: an insecure context, a denied permission, a
* browser or an embedding frame that does not allow it. That used to fail in
* silence — the button stayed `[copy]`, nothing was announced, and whoever
* pressed it pasted whatever their clipboard held before. Now a refusal
* selects the text instead, so the platform's own copy shortcut finishes the
* job, and says so both on the button and to a screen reader.
*
* The button's name is pinned by its `aria-label`, so its visible text changes
* nothing a screen reader reads out: `announcement` is for a `role="status"`
* region beside it.
*/
export function useCopy() {
const state = ref<'idle' | 'copied' | 'selected'>('idle')
let timer: ReturnType<typeof setTimeout> | undefined

function settle(next: 'copied' | 'selected', ms: number) {
state.value = next
clearTimeout(timer)
timer = setTimeout(() => (state.value = 'idle'), ms)
}

async function copy(text: string, fallback: HTMLElement | null | undefined) {
try {
await navigator.clipboard.writeText(text)
settle('copied', 1600)
}
catch {
const selection = window.getSelection()
if (!fallback || !selection) return
const range = document.createRange()
range.selectNodeContents(fallback)
selection.removeAllRanges()
selection.addRange(range)
settle('selected', 4000)
}
}

onBeforeUnmount(() => clearTimeout(timer))

const label = computed(() => ({ idle: '[copy]', copied: '[copied]', selected: '[selected]' })[state.value])
const announcement = computed(() => ({
idle: '',
copied: 'Copied to clipboard',
selected: 'Could not copy. The text is selected; copy it with your keyboard.',
})[state.value])

return { copy, label, announcement }
}
32 changes: 16 additions & 16 deletions data/projects.generated.json

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { contentSecurityPolicy } from './shared/security/csp'
import { projects } from './data/projects'
import { clientPackages } from './scripts/lib/client-packages'

// Which packages the client bundle is made of, for `npm run audit:runtime`.
const shippedToBrowsers = clientPackages()

export default defineNuxtConfig({
// Nuxt UI carries its own Tailwind v4 pipeline, so the standalone
Expand Down Expand Up @@ -92,6 +96,11 @@ export default defineNuxtConfig({
'Referrer-Policy': 'strict-origin-when-cross-origin',
'X-Frame-Options': 'DENY',
'Permissions-Policy': 'geolocation=(), camera=(), microphone=()',
// No page here is opened by, or opens, a window it should keep a
// handle on, so none shares a browsing-context group with a
// cross-origin one: a page that `window.open`s this site gets no
// reference back into it.
'Cross-Origin-Opener-Policy': 'same-origin',
// What a page may load — see `shared/security/csp.ts`. This is the
// nonce-less form every response gets; rendered pages have it
// replaced with one carrying a per-request nonce by
Expand Down Expand Up @@ -132,4 +141,14 @@ export default defineNuxtConfig({

compressPublicAssets: { brotli: true, gzip: true },
},

vite: {
plugins: [shippedToBrowsers.plugin],
},

hooks: {
'nitro:init'(nitro) {
nitro.hooks.hook('compiled', () => shippedToBrowsers.write(nitro.options.output.serverDir))
},
},
})
Loading
Loading