feat(polish): phase 10 — search, accessibility, SEO and a real CSP
All checks were successful
PR checks / checks (pull_request) Successful in 9m36s

PLAN.md §13 phase 10, with four decisions of record — D47-D50, taking the count
to fifty. Three were straightforward; the CSP turned into the phase's real work,
because the thing meant to be a configuration flag was broken in a dependency and
broken silently.

D47 — search reaches the marketing pages, and the header gets a box.
Base.astro marks its <main> as a Pagefind body, so all ten join the index the
docs already query, and Search.astro opens it in a <dialog>. Nothing is fetched
until the dialog is opened (the bundle is 120 kB and these pages otherwise ship
almost no JavaScript). Pagefind titles a result from the first <h1>, and these
pages have editorial ones — "The app for a deployment you already use" — so the
index is given the page's short name instead. applyBrand.mjs now re-indexes after
a rewrite, closing a note phase 2 left for this phase.

D48 — the CSP is a real response header, sent by the container. Not a <meta>,
which ignores frame-ancestors, and not advice for someone's reverse proxy, which
puts the strictest promise in §6 outside what this repo tests. Three things
fought it, all the same shape — correct build, broken page, no error:

  * Astro does not hash <script is:inline>, and Starlight ships six per docs
    page, so the first build with CSP on had a strict header and a dead theme
    switcher. The hashes are now generated into src/config/cspHashes.mjs and
    checkCsp.mjs verifies every inline block against its own page's policy.
  * Expressive Code writes ~3,700 inline style ATTRIBUTES, which cannot be
    hashed, hence style-src-attr 'unsafe-inline' — scoped to that directive, so
    script-src is untouched.
  * @astrojs/node matched a request to a policy with pathname.includes(), a
    substring test: /modules/ was served /docs/modules/building-a-module's
    policy and rendered with its own stylesheet refused. scripts/serve.mjs keeps
    the same _headers.json and matches by equality; test/headers.test.mjs starts
    the server and reads the responses, because nothing that reads dist/ can see
    this.

D49 — robots.txt allows everything and names the sitemap (there was no way to
find it: no robots.txt, and D9 rules out a search console). D50 — Organization
and SoftwareApplication, no ratings and no docs-wide Article markup.

checkA11y.mjs is the eleventh check: seven structural rules over all fifty pages,
verified by breaking each in turn. The walk at 390/768/1280 found no overflow
anywhere, the CSP violations above, a 17x17 consent checkbox (WCAG 2.2 SC 2.5.8
wants 24), and a skip link that moved the scroll but not the focus.

npm run verify is green: fourteen steps, both test suites, all eleven checks.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-25 14:14:53 -05:00
parent a34c2ce538
commit e71ff4acd4
22 changed files with 1724 additions and 19 deletions

View File

@@ -158,3 +158,45 @@ jobs:
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: npm run check:reference
- name: The headers the server actually sends
# PLAN.md §6 / D48, phase 10. Every other check reads dist/; this one starts
# scripts/serve.mjs and reads the responses, because the defect it exists for
# happened after the build was already correct. @astrojs/node matched a request to a
# policy with a SUBSTRING test, so /modules/ was served the policy built for
# /docs/modules/building-a-module — every file on disk right, the bytes on the wire
# wrong, and the page rendered with its own stylesheet refused.
#
# It needs the build, so it cannot live in the "Unit tests" step above.
run: npm run test:served
- name: Accessibility
# PLAN.md §13, phase 10. Seven structural rules over every built page: one <h1> and
# no skipped heading level, an alt attribute on every image, a label on every form
# control, an accessible name on every link and button, <html lang>, one <main> with
# a skip link that reaches it, and no positive tabindex.
#
# Structural on purpose. A static check cannot measure contrast on a rendered page
# or find a focus trap, and a check that pretended to would be trusted for things it
# cannot see. What it does catch is the class of defect that is invisible to a
# sighted author and permanent once shipped — and it covers Starlight's forty pages
# too, so a dependency upgrade that loses a label is a red build rather than a
# discovery.
#
# After the build, because it reads dist/client. No token and no network.
run: npm run check:a11y
- name: Content-Security-Policy
# PLAN.md §6 / D48. The policy is a real response header — the Node adapter's
# staticHeaders writes dist/_headers.json and the standalone server sends it — so
# frame-ancestors applies and the operator's proxy needs no CSP config.
#
# The check that matters is the second one: every inline script and style must be
# covered by a hash in ITS OWN page's policy. Astro does not hash <script is:inline>,
# and Starlight ships six of them per documentation page, so the first build with CSP
# enabled had a strict, correct header and a dead theme switcher — a failure with no
# symptom except a console message. A Starlight upgrade can reintroduce it at any
# time, which is why this runs on every PR rather than once.
#
# After the build, because it reads dist/. No token and no network.
run: npm run check:csp

127
PLAN.md
View File

@@ -236,7 +236,7 @@ Taken by the org lead (Colby Whitlock) on 2026-08-19. Recorded so they are not r
**Decisions after D13 are recorded where they were taken**, in the section describing the phase that
raised them, rather than appended here — a decision is only re-litigated when its reasoning is
somewhere other than the thing it decided. The count of record is **forty-six**:
somewhere other than the thing it decided. The count of record is **fifty**:
| # | Where | What it settled |
|---|---|---|
@@ -248,6 +248,7 @@ somewhere other than the thing it decided. The count of record is **forty-six**:
| D34D37 | §10, "How phase 7 built the documentation journey" | One PR for all twenty pages, a self-contained install quickstart with a drift check, every admin screen walked before it was described, a thirteenth Administration page for content |
| D38D41 | §10, "How phase 8 built the builder and reference docs" | One PR for all twenty pages again, Reference enumerates names and checks every one of them, the docs section links to the drawn diagrams rather than importing them, `plannedSidebar` becomes a checked invariant |
| D42D46 | §10, "How phase 9 took the screenshots" | The full rig behind the imagery, a neutral demo brand, the captures beside the claims, a committed and checked capture pipeline, the world dressed in the plugin repo's scaffolding |
| D47D50 | §6, "How phase 10 polished it" | Search reaches the marketing pages, the CSP is a real response header from the container, `robots.txt` allows everything and names the sitemap, two blocks of structured data and no more |
---
@@ -295,6 +296,126 @@ inside the org's existing tooling family. Node 22 LTS.
- **No authenticated surface exists on the site at all.** The CSV export is a CLI run against the
bind mount, not an HTTP route — see §8.
### How phase 10 polished it
Four decisions, D47D50, taken 2026-08-25. Three of them were straightforward; the fourth turned
into the phase's real work, because the thing that was supposed to be a configuration flag was
broken in a dependency and broken *silently*.
**D47 — search reaches the marketing pages, and the marketing header gets a box.** The
documentation had search from phase 1: Starlight builds a Pagefind index at the end of every build.
The marketing pages were outside it twice over — not indexed, so a reader searching "Teams" in the
docs found the architecture page and never the feature page; and with no box, so a reader who
arrived on the homepage had a four-item nav and no way to ask a question. `Base.astro` now marks its
`<main>` as a Pagefind body, which puts all ten in the index the docs already query, and a
`Search.astro` in the header opens the same index in a `<dialog>`.
Three things about the build are worth keeping. **Nothing is fetched until the dialog is opened**
Pagefind's UI bundle is 120 kB before the index and the WASM, and these pages otherwise ship almost
no JavaScript, so the button is inert markup and the first open injects the script. **`<dialog>`
rather than a hand-built overlay**, because the browser supplies the focus trap, the inert
background, Escape-to-close and the top layer, and every one of those is something an accessibility
pass would otherwise find missing. And **the index needed an explicit title**: Pagefind titles a
result from the first `<h1>`, and these pages have editorial ones — `/app/`'s is "The app for a
deployment you already use", `/terms/`'s is "Short, and only about what we run". Correct on the page
under an eyebrow that names the section; unscannable as four rows in a result list, which is exactly
what the first walk of the finished search produced. `data-pagefind-meta` now carries the page's
short name, the one already in the nav and the browser tab.
**Two things about styling somebody else's widget.** Pagefind's UI takes a `resetStyles`
option; setting it to `false` — on the reasoning that the site's own type and colour should
show through — is wrong, because that reset is what styles Pagefind's own input and buttons.
Without it they fall back to user-agent defaults, which on this ground meant black text typed
into a dark field and a Clear button with an `outset` border. The palette is bound through
Pagefind's custom properties instead. And the match highlight needed one extra class in the
selector: the reset declares `.pagefind-ui--reset mark { all: revert }`, same specificity as a
plain descendant rule and injected after our stylesheet, so it won on order and put the
user-agent yellow back on every result.
It also closed a note phase 2 left here. Pagefind indexes at build time, so the boot rewrite
(§7, D15) reached the pages and not the search results: a site renamed through the mount would
answer a search for its own name with the stock one. `applyBrand.mjs` now re-indexes after a rewrite
— only when it actually rewrote something, so a stock deployment still pays nothing.
**D48 — the CSP is a real response header, sent by the container.** The alternatives were a
`<meta http-equiv>`, which is what Astro emits by default and which silently ignores
`frame-ancestors` — the one directive that stops the site being framed — and writing the headers
into an operator's reverse-proxy configuration, which puts the strictest promise in §6 outside the
artifact this repository builds and tests. Neither is good enough for a security boundary, so the
Node adapter's `staticHeaders` is on: the build writes one policy per prerendered route into
`dist/_headers.json` and the server sends it.
**Three things fought this, and each is the same shape: correct build, broken page, no error.**
1. **Astro does not hash `<script is:inline>`.** It hashes what it processes; an inline script is
the author's own text, which it never parses. Starlight ships six per documentation page — the
theme provider, the theme-picker sync, the mobile menu, the sidebar scroll restore. The first
build with CSP enabled produced a strict, correct header and a documentation site whose theme
switch and mobile sidebar did nothing, with the explanation only in a console. `'unsafe-inline'`
would have fixed all six and given up the single directive CSP exists to enforce, so instead the
hashes are enumerated in a generated `src/config/cspHashes.mjs` and `scripts/checkCsp.mjs`
verifies, per page, that every inline block is covered by *that page's own* policy. A Starlight
upgrade that edits one byte turns the build red; `npm run csp:hashes` re-harvests it.
2. **Expressive Code cannot be hashed at all.** Around 3,700 inline `style` **attributes** across
the documentation carry every syntax colour, and CSP hashes cover `<style>` elements, never
attributes — Astro's own documentation records Shiki as incompatible with CSP for this reason.
The policy therefore carries `style-src-attr 'unsafe-inline'`, scoped to that directive: a style
attribute cannot execute script, so `script-src` is untouched. The marketing pages emit none.
3. **`@astrojs/node` served the wrong page's policy.** Its per-request lookup is
`headersMap.find((h) => h.pathname.includes(baselessPathname))` — a substring test taking the
first match. `/modules/` was served the policy built for `/docs/modules/building-a-module`;
`/architecture/` got a docs page's; and `/`, a substring of every path in the file, got whichever
record came first, which was `/404`. Since each policy is a list of per-page hashes, the browser
refused each page's own stylesheet: `/modules/` and `/architecture/` were rendering unstyled,
and the homepage looked perfect only because it happened to share a hash with the 404 page.
`scripts/serve.mjs` — a thin wrapper `npm start` now runs instead of the adapter's entry — keeps
the same `_headers.json` and matches by equality. It is small on purpose so it can be deleted
whole when upstream is fixed, and it is where the non-CSP security headers live too.
**This is why `test/headers.test.mjs` exists.** Every other check in this repository reads
`dist/`, and every file on disk was right — the bytes on the wire were not. It starts the server
and reads the responses, and reverting the wrapper to the substring lookup fails it.
**D49 — `robots.txt` allows everything and names the sitemap.** The sitemap has covered all fifty
URLs since phase 1 (Starlight bundles `@astrojs/sitemap`) and nothing pointed at it; a crawler finds
one either from this file or from a search console, and D9's posture extends to not having an
account with anyone. Nothing is disallowed: there is no authenticated surface (§6), `/brand/*` is
derived images with no text, and `/beta/` is a page a person is meant to find. The 404 is kept out
of the *search index* instead, with `data-pagefind-ignore`, which is the right layer for it.
**D50 — two blocks of structured data, and no more.** `Organization` so the project's name resolves
to an entity rather than to whichever page ranks, and `SoftwareApplication` because what the site
describes is software someone installs. No ratings, no counts, no invented `aggregateRating` — §11's
understated honesty applies to markup a reader never sees, and inventing a rating is what gets
structured data ignored. Breadcrumb and `Article` markup on the forty documentation pages was
rejected: Starlight already renders breadcrumbs a reader can see, and it would be forty more places
for a fact to go stale. Every value is read from `brand.json` or `platform.json`, so `checkFacts.mjs`
already guards them.
It is a `<script type="application/ld+json">`, which is a data block: no browser executes it and no
CSP hash covers it. **Both `checkCsp.mjs` and `applyBrand.mjs` had to be taught that explicitly**
the first would have demanded a hash for text that changes whenever a fact does, and the second
would have refused to rewrite the homepage at all, which is §7 failing on the page that matters
most.
**What the walk found.** Ten marketing pages and a documentation sample, at 390, 768 and 1280 in
real Chrome. No horizontal overflow at any width, on any page — the responsive work of phases 3 and
4 held, including with a search button added to the header. The CSP violations above. The consent
checkbox on `/beta` measured 17×17 against WCAG 2.2 SC 2.5.8's 24px minimum, and is now 24 — the one
control on the site a person must hit precisely, on the page a phone is most likely to arrive at.
And following the skip link moved the scroll but not the focus, because a `<main>` is not focusable;
Chrome papers over that and not every browser does, so it now carries `tabindex="-1"`.
**`checkA11y.mjs` is the eleventh check**, and the eighth in CI. Seven structural rules over every
built page, ours and Starlight's forty. Structural on purpose: a static check cannot measure
contrast on a rendered page or find a focus trap, and one that pretended to would be trusted for
things it cannot see. Its own first run reported every marketing page as having two `<main>`
landmarks — this repository comments its markup heavily, and one of those comments quotes the tag it
is explaining, so comments are stripped before anything is counted. It was then verified by breaking
each of its rules in turn.
---
## 7. Branding is bind-mounted data
@@ -1269,8 +1390,8 @@ a mechanism rather than diligence:
| **7** | Docs — the journey: Getting started (7) + Administration (**13**, per D37) — twenty pages in one PR (D34), with the install page self-contained and drift-checked (D35) and every admin screen walked before it was described (D36). **The installation path is the priority of the whole project** |
| **8** | Docs — builder and reference: Modules (8) + Architecture (5) + Reference (7) — twenty pages in one PR (D38), with Reference enumerating names and **checking every one of them** against its source (D39), and `plannedSidebar` becoming a checked invariant (D41) |
| **9** | Screenshots (D4): stand up the local review stack, seed presentable content, capture the admin panel, Teams, forums, marketplace, spawn atlas and shard console; build the screenshot components. **Plus an emulator pass against the same seeded stack** to fill `/app/`'s reserved slot (D26) |
| **10** | Polish: responsive, accessibility, SEO/OpenGraph/sitemap/robots, full-text search, CSP headers |
| **11** | Validation: `astro check`, production build, **all nine check scripts** (tokens, brand, links, facts, quickstart, data safety, reference, sidebar, screens), mobile layout verified in a real browser, a signup walked end to end |
| **10** | Polish: responsive, accessibility, SEO/OpenGraph/sitemap/robots, full-text search, CSP headers. See D47D50 — the CSP was the work, because `@astrojs/node` served every page another page's policy |
| **11** | Validation: `astro check`, production build, **all eleven check scripts** (tokens, brand, links, facts, quickstart, data safety, reference, sidebar, screens, a11y, CSP) plus both test suites, mobile layout verified in a real browser, a signup walked end to end |
| **12** | Delivery: Dockerfile, `docker-compose.yml` with both bind mounts documented, Gitea Actions workflow publishing to the registry, README, CONTRIBUTING with the AI-disclosure requirement, and an operator note covering DNS, TLS and the reverse proxy (D6) |
Phases 5 and 6 are deliberately adjacent and early: the beta cannot start without `/privacy`, and

View File

@@ -35,17 +35,24 @@ Node 22 LTS or newer.
## The checks, and why they are not optional
Two of them, both from `PLAN.md` §12. Neither is a linter; each one enforces a promise the site
makes that would otherwise decay quietly.
Eleven of them, from `PLAN.md` §12. None is a linter; each one enforces a promise the site makes
that would otherwise decay quietly.
```bash
npm run check:sidebar # the rendered docs tree still matches the planned one
npm run check:screens # every screenshot has an entry, at the size declared
npm run check:tokens # no colour literal outside the token file
npm run check:brand # the branding pipeline's two quiet failures
npm run check:datasafety # the Play declaration still matches /privacy
npm run check # astro check
npm test # the beta signup's decision path, and the policy data
npm run build && npm run check:links # every internal link resolves (reads the build)
npm run build # everything below reads the build
npm run check:links # every internal link resolves
GITEA_TOKEN=<token> npm run check:facts # every version agrees with its authority
GITEA_TOKEN=<token> npm run check:quickstart # the install page still matches website's own files
GITEA_TOKEN=<token> npm run check:reference # every name the Reference lists still exists
npm run check:a11y # seven structural accessibility rules, every page
npm run check:csp # every inline script and style is hashed in its policy
npm run verify # all of the above, in that order
```
@@ -81,6 +88,22 @@ carry are assembled from data files and template literals and a source scan sees
also refuses a commit permalink into any org repository — those stop tracking the document they name
without ever 404ing, which is the failure a link checker would otherwise call healthy.
**`checkA11y.mjs`** applies seven structural rules to every built page — one `<h1>` and no skipped
heading level, an `alt` on every image, a label on every form control, an accessible name on every
link and button, `<html lang>`, one `<main>` with a skip link that reaches it, and no positive
`tabindex`. Structural on purpose: a static check cannot measure contrast on a rendered page or find
a focus trap, and one that pretended to would be trusted for things it cannot see. It covers
Starlight's forty pages as well as our ten, so a dependency upgrade that loses a label turns the
build red rather than becoming a discovery.
**`checkCsp.mjs`** verifies that every route has a policy and that **every inline script and style is
covered by a hash in its own page's policy**. That second rule is the one that earns its keep: Astro
does not hash `<script is:inline>`, and Starlight ships six of them per documentation page, so the
first build with CSP enabled had a strict, correct header and a dead theme switcher — a failure whose
only symptom is a console message. When Starlight is upgraded and a hash stops matching,
`npm run csp:hashes` rebuilds, re-harvests `src/config/cspHashes.mjs` and rebuilds again; read the
diff before committing it, because that file is a list of scripts allowed to run.
**`npm test`** is the one check that reads none of the above. Everything else inspects built output,
and the beta signup's logic does not appear there: a honeypot can stop working entirely and produce
a build identical to one where it works. It covers the honeypot, the signed form token, the timing
@@ -147,6 +170,40 @@ Regenerating the stock assets is a separate, manual step — `npm run brand:asse
the emblem and the Cinzel outlines from the sibling checkouts in the workspace. Its output is
committed so that CI never needs either.
## Security headers, and the one workaround in the server
`npm start` runs `scripts/applyBrand.mjs` and then `scripts/serve.mjs` — not
`dist/server/entry.mjs` directly. `serve.mjs` is a thin wrapper around the adapter's own handler,
and it exists for two reasons.
The first is a bug in `@astrojs/node`. Its `staticHeaders` option writes one Content-Security-Policy
per prerendered route into `dist/_headers.json`, then looks the right one up per request with
`headersMap.find((h) => h.pathname.includes(baselessPathname))` — a **substring** test taking the
first match. So `/modules/` was served the policy built for `/docs/modules/building-a-module`,
`/architecture/` got a docs page's, and `/`, being a substring of every path in the file, got
whichever record came first. Because each policy is a list of per-page hashes, that is not a
cosmetic mismatch: the browser refused the page's own stylesheet, and `/modules/` and
`/architecture/` rendered unstyled with `Refused to apply inline style` in a console. The wrapper
keeps the same `_headers.json` and matches by **equality**. It is deliberately small so it can be
deleted whole once the upstream `find` is fixed; the test for that is whether `/modules/` and
`/docs/modules/building-a-module` are served different policies.
The second is the handful of headers that have nothing to do with Astro: `X-Content-Type-Options`,
`Referrer-Policy`, `X-Frame-Options` and a `Permissions-Policy` that turns off hardware this site has
no reason to ask for. They are set in the container rather than written into an operator's
reverse-proxy configuration, because the image should be correct on its own and a proxy somebody
else configures is a promise this repository cannot check. The two routes that render per request —
`/beta` and `/brand/*` — have no prerendered policy, so they get `frame-ancestors 'none'` on its own:
the one directive a `<meta>` CSP cannot express, and therefore the one thing Astro's per-page meta
tag leaves them missing.
`style-src-attr 'unsafe-inline'` is the single relaxation in the policy, and it is scoped to that
directive. Starlight and Expressive Code write around 3,700 inline `style` attributes into the
documentation — icon sizes, the theme select's width, and every syntax colour — which cannot be
hashed, because CSP hashes cover `<style>` elements and never attributes. A style attribute cannot
execute script, so this leaves `script-src`, the directive CSP exists for, untouched. The marketing
pages emit no inline style attributes at all.
## Layout
```
@@ -170,9 +227,10 @@ src/
lib/betaSignup.mjs Everything between a POST body and a row. Never throws.
lib/tokens.mjs Reads tokens.css at build time, for the few values that leave CSS.
config/sidebar.mjs The documentation journey, and the planned tree behind it.
config/cspHashes.mjs GENERATED. Starlight's inline scripts, which Astro does not hash.
brand-default/ The stock brand, baked into the image and always complete.
scripts/ The build-time checks, plus applyBrand (boot), brand:assets (manual)
and beta.mjs (the tester-list CLI).
scripts/ The build-time checks, plus applyBrand and serve (boot),
brand:assets (manual) and beta.mjs (the tester-list CLI).
test/ node --test. The logic the other checks cannot see.
PLAY_DATA_SAFETY.md GENERATED. The answers to Google Play's Data Safety form, from
src/data/collection.mjs. Edit the data, run npm run play:datasafety.

View File

@@ -3,6 +3,7 @@ import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
import starlight from '@astrojs/starlight';
import { inlineScriptHashes, inlineStyleHashes } from './src/config/cspHashes.mjs';
import { docsSidebar } from './src/config/sidebar.mjs';
/**
@@ -20,13 +21,72 @@ import { docsSidebar } from './src/config/sidebar.mjs';
export default defineConfig({
site: 'https://runicgateway.com',
output: 'static',
adapter: node({ mode: 'standalone' }),
// `staticHeaders` is what turns §6's CSP from a promise into a response header (D48).
// Without it the policy ships as a `<meta http-equiv>`, and a meta CSP silently ignores
// `frame-ancestors` — the one directive that stops the site being framed. With it, the
// build writes `_headers.json` next to the server entry and the standalone server sends
// the policy as a real header on every prerendered route, so the operator's reverse proxy
// needs no CSP configuration at all and cannot get it wrong.
adapter: node({ mode: 'standalone', staticHeaders: true }),
build: {
// Directory-style URLs, so every link in prose can end in a slash and mean it.
format: 'directory',
},
security: {
csp: {
directives: [
// The whole posture in one line: nothing loads from anywhere but this origin.
// §6 could promise this without exceptions because the fonts are self-hosted and
// D9 rules out analytics — there is no CDN to whitelist and no beacon to allow.
"default-src 'self'",
// Not covered by `default-src`, and each one closes a specific door: no injected
// `<base>` can re-point every relative URL on the page, the signup form can only
// post to us, no plugin content at all, and the site cannot be framed. The last
// of those is the reason `staticHeaders` is on.
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
// One `url(data:image/svg+xml)` survives bundling into the stylesheet. Data URLs
// are a real (if small) exfiltration-free risk surface, so this is the only
// relaxation of `default-src` on the image directive and it is scoped to images.
"img-src 'self' data:",
],
scriptDirective: {
resources: [
"'self'",
// Pagefind (D47) compiles its index with `WebAssembly.instantiate`, which a
// strict `script-src` blocks outright — search silently returns nothing. This
// permits WASM compilation *only*; it does not restore `eval`.
"'wasm-unsafe-eval'",
],
// Starlight's own `is:inline` scripts, which Astro does not hash because it never
// parses them. Generated — see src/config/cspHashes.mjs and `npm run check:csp`.
hashes: inlineScriptHashes,
},
styleDirective: {
// No `'self'` here, though `style-src` needs it and gets it: Astro's default
// already supplies it, and naming it alongside an `attribute`-kind resource makes
// the build warn — browsers do not fall back from `style-src-attr` to `style-src`,
// so a `'self'` written here would apply to neither scope the author meant.
resources: [
// Starlight and Expressive Code write ~3,700 inline `style` attributes into the
// documentation — icon sizing, the theme select's width, and every syntax
// colour, which Expressive Code emits as custom properties on the element. They
// cannot be hashed (CSP hashes cover `<style>` elements, never attributes), and
// Astro's own docs record Shiki as incompatible with CSP for exactly this
// reason. Scoped to `style-src-attr` deliberately: a style attribute cannot
// execute script, so this leaves the directive CSP exists for — `script-src` —
// untouched. The marketing pages emit zero inline style attributes.
{ resource: "'unsafe-inline'", kind: 'attribute' },
],
hashes: inlineStyleHashes,
},
},
},
integrations: [
starlight({
title: 'Runic Gateway',

1
package-lock.json generated
View File

@@ -15,6 +15,7 @@
"@fontsource-variable/inter": "^5.3.0",
"astro": "^7.2.4",
"better-sqlite3": "^12.11.1",
"pagefind": "^1.5.2",
"sharp": "^0.35.3"
},
"devDependencies": {

View File

@@ -12,7 +12,7 @@
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"start": "node scripts/applyBrand.mjs && node ./dist/server/entry.mjs",
"start": "node scripts/applyBrand.mjs && node scripts/serve.mjs",
"check": "astro check",
"check:facts": "node scripts/checkFacts.mjs",
"check:tokens": "node scripts/checkTokens.mjs",
@@ -23,12 +23,16 @@
"check:reference": "node scripts/checkReference.mjs",
"check:sidebar": "node scripts/checkSidebar.mjs",
"check:screens": "node scripts/checkScreens.mjs",
"check:a11y": "node scripts/checkA11y.mjs",
"check:csp": "node scripts/checkCsp.mjs",
"play:datasafety": "node scripts/playDataSafety.mjs",
"beta": "node scripts/beta.mjs",
"test": "node --test test/beta.test.mjs test/legal.test.mjs",
"brand:assets": "node scripts/buildBrandAssets.mjs",
"screens:capture": "node scripts/captureScreens.mjs",
"verify": "npm run check:sidebar && npm run check:screens && npm run check:tokens && npm run check:brand && npm run check:datasafety && npm run check && npm test && npm run build && npm run check:links && npm run check:facts && npm run check:quickstart && npm run check:reference"
"csp:hashes": "node scripts/checkCsp.mjs --reset && astro build && node scripts/checkCsp.mjs --write && astro build && node scripts/checkCsp.mjs",
"verify": "npm run check:sidebar && npm run check:screens && npm run check:tokens && npm run check:brand && npm run check:datasafety && npm run check && npm test && npm run build && npm run check:links && npm run check:facts && npm run check:quickstart && npm run check:reference && npm run test:served && npm run check:a11y && npm run check:csp",
"test:served": "node --test test/headers.test.mjs"
},
"dependencies": {
"@astrojs/node": "^11.1.4",
@@ -37,6 +41,7 @@
"@fontsource-variable/inter": "^5.3.0",
"astro": "^7.2.4",
"better-sqlite3": "^12.11.1",
"pagefind": "^1.5.2",
"sharp": "^0.35.3"
},
"devDependencies": {

View File

@@ -229,10 +229,71 @@ const counts = new Map(replacements.map((r) => [r.field, 0]));
counts.set('demoDeep', 0);
let filesTouched = 0;
/**
* The CSP (§6, D48) hashes every inline `<script>` and `<style>` in the build. This script
* runs after that hashing and rewrites the same files, so a brand value that happened to
* sit inside an inline block would change its bytes, invalidate its hash and get the block
* refused by the browser — with no error anywhere except a console nobody has open. The
* page would render perfectly and the script simply would not run.
*
* Nothing puts brand text in an inline script today, and the replacements are guarded by
* MIN_REWRITABLE_LENGTH so they are unlikely to collide by accident. "Unlikely" is not the
* standard for a failure this quiet, so the collision is checked rather than reasoned
* about: if a rewrite ever lands inside an inline block, this refuses to write that file
* and says so, and the page keeps its stock text instead of losing its behaviour.
*/
const INLINE_BLOCK = /<(script|style)(?![^>]*\bsrc\s*=)([^>]*)>([\s\S]*?)<\/\1>/g;
/**
* The structured-data block (D50) is a `<script>` that no browser executes and no CSP hash
* covers, so it is not one of the blocks this guard protects — and it MUST NOT be, because
* it contains the site's name. Treating it as a script would make the guard refuse to
* rewrite the homepage, which is §7 failing on the one page that matters most.
*/
const DATA_BLOCK = /type\s*=\s*["']application\/(ld\+json|json)["']/i;
const inlineRanges = (html) => {
const ranges = [];
INLINE_BLOCK.lastIndex = 0;
let match;
while ((match = INLINE_BLOCK.exec(html))) {
if (match[1] === 'script' && DATA_BLOCK.test(match[2])) continue;
// Where the block's CONTENT starts — measured back from the end of the whole match, so
// the opening tag's attributes cannot throw the offset off: `</script>` is the tag name
// plus three characters.
const closing = match[1].length + 3;
const start = match.index + match[0].length - closing - match[3].length;
ranges.push([start, start + match[3].length]);
}
return ranges;
};
const hitsInlineBlock = (html, needle) => {
if (!needle || !html.includes(needle)) return false;
const ranges = inlineRanges(html);
if (ranges.length === 0) return false;
for (let at = html.indexOf(needle); at !== -1; at = html.indexOf(needle, at + 1)) {
const end = at + needle.length;
if (ranges.some(([from, to]) => at < to && end > from)) return true;
}
return false;
};
const inlineCollisions = [];
for (const file of walk(CLIENT)) {
const before = readFileSync(file, 'utf8');
let after = before;
if (path.extname(file) === '.html') {
const colliding = replacements.filter(({ from }) => hitsInlineBlock(before, from));
if (colliding.length) {
inlineCollisions.push({
file: path.relative(CLIENT, file),
fields: [...new Set(colliding.map((c) => c.field))],
});
continue;
}
}
for (const { field, from, to } of replacements) {
if (!after.includes(from)) continue;
counts.set(field, counts.get(field) + after.split(from).length - 1);
@@ -270,6 +331,47 @@ if (counts.get('demoDeep')) {
);
}
// Pagefind builds its search index from the HTML at BUILD time (phase 10), so a rename
// applied here reaches the pages but not the search results. Worth fixing when search
// lands; recorded here rather than in a plan section nobody will re-read.
if (inlineCollisions.length) {
console.error(
`\n[brand] ${inlineCollisions.length} file(s) were LEFT UNCHANGED: a brand value occurs ` +
`inside an inline <script> or <style>, and rewriting it would break that block's CSP ` +
`hash (§6, D48) — the page would render and the script would silently not run.\n`
);
for (const { file, fields } of inlineCollisions) {
console.error(` ! ${file} (${fields.join(', ')})`);
}
console.error(
`\n Those pages keep the stock text. Fix it by taking the brand value out of the inline\n` +
` block — move it into markup the CSP does not hash, or into /brand/theme.css.\n`
);
}
/* ---------------------------------------------------------------------------------------
Search
--------------------------------------------------------------------------------------- */
/**
* Pagefind builds its index from the built HTML at BUILD time, so everything above reaches
* the pages and none of it reaches the search results: a site renamed through the mount
* would answer a search for its own name with the stock one, and every result title would
* still carry the old suffix. Phase 2 recorded that and left it for this phase, when
* search became site-wide (D47).
*
* The fix is to re-index, which is cheap and needs nothing the container does not already
* have — Pagefind is what Starlight ran at build. It only runs when a rewrite actually
* happened, so the stock deployment, which is the common case, still pays nothing.
*/
if (filesTouched > 0) {
const pagefind = await import('pagefind');
try {
const { index } = await pagefind.createIndex();
const { page_count } = await index.addDirectory({ path: CLIENT });
await index.writeFiles({ outputPath: path.join(CLIENT, 'pagefind') });
console.log(`[brand] re-indexed ${page_count} page(s) for search so results agree with it.`);
} catch (error) {
// Search degrading to stale titles is not a reason to refuse to serve the site.
console.error(`[brand] could not rebuild the search index; it keeps the built one: ${error.message}`);
} finally {
await pagefind.close();
}
}

278
scripts/checkA11y.mjs Normal file
View File

@@ -0,0 +1,278 @@
#!/usr/bin/env node
/**
* checkA11y.mjs — PLAN.md §13 phase 10.
*
* Every other rule this repository cares about is enforced by a script — the facts, the
* links, the tokens, the sidebar, the screenshots, the CSP. Accessibility was the exception:
* it was a thing someone checked once, by hand, on the pages they happened to open. This
* makes it the eleventh check so a regression fails a build instead of waiting for a reader
* who cannot use the page and will not file an issue.
*
* node scripts/checkA11y.mjs
*
* ── What it checks, and why each one ────────────────────────────────────────
* A static check cannot measure contrast against a rendered page or find a focus trap, and
* pretending otherwise would be worse than not checking. What it CAN do is catch the class
* of defect that is invisible to a sighted author and permanent once shipped:
*
* 1. **One `<h1>` per page, and no skipped heading level.** The heading tree is the
* document outline a screen-reader user navigates by. Two `<h1>`s or an `<h2>` under
* nothing reads as a page with no structure at all.
* 2. **Every `<img>` has an `alt`.** Not "a non-empty alt": `alt=""` is correct and
* deliberate for the header mark, which sits inside a link that already says the
* product's name. A MISSING attribute is what makes a screen reader read the filename.
* 3. **Every form control has a label.** `<label for>`, a wrapping `<label>`,
* `aria-label` or `aria-labelledby`. The signup form is the only place on this site
* where a person is asked to type something, so it is the one place this must hold.
* 4. **Every link and button has an accessible name.** An icon-only control with no text
* and no `aria-label` is announced as "link", which is no name at all. The search
* button is icon-only under 46rem, which is exactly this hazard.
* 5. **`<html lang>` is set**, or a screen reader reads English prose with whatever voice
* the reader last used.
* 6. **One `<main>` per page and a skip link that points at it.** The site's header is a
* lockup, four links and a search box in front of every page; without a working skip
* link a keyboard user walks all six on every navigation.
* 7. **No positive `tabindex`.** It reorders the tab sequence away from the visual one
* and is almost never what the author meant.
*
* Both chromes are checked — the marketing pages and Starlight's forty. Starlight is
* generally careful, so the docs half is a regression alarm on a dependency rather than a
* review of our own markup, and it has already earned its place once: it is what would have
* caught the `<h2>`-without-`<h1>` shape if a docs page had ever lost its title.
*
* No token and no network: everything read here is in `dist/`.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const clientDir = path.join(root, 'dist', 'client');
if (!fs.existsSync(clientDir)) {
console.error('\ncheckA11y: dist/client does not exist. Run `npm run build` first.\n');
process.exit(1);
}
const failures = [];
const fail = (page, what, detail) => failures.push({ page, what, detail });
const pages = [];
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (entry.name.endsWith('.html')) pages.push(full);
}
};
walk(clientDir);
/* ---------------------------------------------------------------------------------------
A very small amount of HTML reading
Not a parser. Everything below is a tag-level question — does this element carry this
attribute, what text sits between these two tags — and a regex answers those on
generated, well-formed output. A DOM parser would be a dependency, and this repository's
checks are dependency-free on purpose (§12): the reader runs them the same way CI does.
--------------------------------------------------------------------------------------- */
/** Takes the ATTRIBUTE STRING — what is between the tag name and the `>` — not the tag. */
const attrs = (attrString) => {
const found = new Map();
const re = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
let m;
while ((m = re.exec(attrString))) {
found.set(m[1].toLowerCase(), m[2] ?? m[3] ?? m[4] ?? '');
}
return found;
};
/** Text a screen reader would announce: markup and comments stripped, entities loosened. */
const textOf = (html) =>
html
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/<[^>]*>/g, ' ')
.replace(/&[a-zA-Z#0-9]+;/g, ' ')
.replace(/\s+/g, ' ')
.trim();
/** An element's own accessible name, near enough for "is there one at all". */
const named = (tag, inner) => {
const a = attrs(tag);
if (a.get('aria-label')?.trim()) return true;
if (a.get('aria-labelledby')?.trim()) return true;
if (a.get('title')?.trim()) return true;
if (textOf(inner)) return true;
// An image child with alt text names the control.
for (const img of inner.matchAll(/<img\b([^>]*)>/gi)) {
if (attrs(img[1]).get('alt')?.trim()) return true;
}
// An SVG with a title element does too.
if (/<svg\b[^>]*>[\s\S]*?<title\b[^>]*>[^<]+<\/title>/i.test(inner)) return true;
return false;
};
for (const file of pages) {
const page = '/' + path.relative(clientDir, file).replace(/\\/g, '/');
/**
* Comments are stripped before anything is counted, and that is not a nicety: this
* repository comments its markup heavily, and several of those comments quote the tags
* they are explaining. `Base.astro`'s note about `data-pagefind-body` contains the text
* "<main>", and the first run of this check reported every marketing page as having two
* `<main>` landmarks because of it. Stripping once, up front, also keeps every offset
* below measured against the same string.
*/
const html = fs.readFileSync(file, 'utf8').replace(/<!--[\s\S]*?-->/g, '');
// ── 5. lang ───────────────────────────────────────────────────────────────
const htmlTag = /<html\b([^>]*)>/i.exec(html);
if (!htmlTag) fail(page, '<html>', 'has no <html> element');
else if (!attrs(htmlTag[1]).get('lang')?.trim()) fail(page, '<html>', 'has no lang attribute');
// ── 1. headings ───────────────────────────────────────────────────────────
const headings = [...html.matchAll(/<h([1-6])\b([^>]*)>([\s\S]*?)<\/h\1>/gi)]
// `aria-hidden` headings are decorative and out of the outline by definition.
.filter((m) => attrs(m[2]).get('aria-hidden') !== 'true')
.map((m) => ({ level: Number(m[1]), text: textOf(m[3]) }));
const h1s = headings.filter((h) => h.level === 1);
if (h1s.length === 0) fail(page, 'headings', 'has no <h1>');
if (h1s.length > 1) {
fail(page, 'headings', `has ${h1s.length} <h1>s: ${h1s.map((h) => JSON.stringify(h.text)).join(', ')}`);
}
let previous = 0;
for (const heading of headings) {
if (previous && heading.level > previous + 1) {
fail(
page,
'headings',
`jumps from h${previous} to h${heading.level} at ${JSON.stringify(heading.text.slice(0, 50))}`,
);
}
previous = heading.level;
}
// ── 2. images ─────────────────────────────────────────────────────────────
for (const img of html.matchAll(/<img\b([^>]*)>/gi)) {
const a = attrs(img[1]);
if (!a.has('alt')) {
fail(page, '<img>', `has no alt attribute: src=${a.get('src') ?? '(none)'}`);
}
}
// ── 3. form controls ──────────────────────────────────────────────────────
const labelledIds = new Set(
[...html.matchAll(/<label\b([^>]*)>/gi)]
.map((m) => attrs(m[1]).get('for'))
.filter(Boolean),
);
/**
* A control wrapped in its own `<label>` needs no `for` and — this is the part that took
* a wrong answer to find — needs no `id` either, so it cannot be recorded by id. Starlight
* labels its theme and language selects exactly this way. What is recorded instead is the
* character offset of each wrapped control, which identifies it uniquely without
* requiring it to have any attributes at all.
*/
const wrappedAt = new Set();
for (const label of html.matchAll(/<label\b[^>]*>([\s\S]*?)<\/label>/gi)) {
const base = label.index + label[0].indexOf(label[1]);
for (const control of label[1].matchAll(/<(input|select|textarea)\b[^>]*>/gi)) {
wrappedAt.add(base + control.index);
}
}
for (const control of html.matchAll(/<(input|select|textarea)\b([^>]*)>/gi)) {
const a = attrs(control[2]);
const type = (a.get('type') ?? 'text').toLowerCase();
// These are not things a person types into and are named by other means.
if (['hidden', 'submit', 'button', 'reset', 'image'].includes(type)) continue;
const id = a.get('id');
const hasLabel =
wrappedAt.has(control.index) ||
(id && labelledIds.has(id)) ||
a.get('aria-label')?.trim() ||
a.get('aria-labelledby')?.trim() ||
a.get('title')?.trim();
if (!hasLabel) {
fail(
page,
`<${control[1]}>`,
`has no label: ${id ? `id="${id}"` : `name="${a.get('name') ?? '(none)'}"`}` +
'a placeholder is not a label',
);
}
}
// ── 4. link and button names ──────────────────────────────────────────────
for (const [, tag, attrString, inner] of html.matchAll(/<(a|button)\b([^>]*)>([\s\S]*?)<\/\1>/gi)) {
const a = attrs(attrString);
if (a.get('aria-hidden') === 'true') continue;
// An <a> with no href is not a link; it is a target for one.
if (tag.toLowerCase() === 'a' && !a.has('href')) continue;
if (named(attrString, inner)) continue;
fail(
page,
`<${tag}>`,
`has no accessible name: ${a.get('href') ? `href="${a.get('href')}"` : `class="${a.get('class') ?? ''}"`}`,
);
}
// ── 6. main and the skip link ─────────────────────────────────────────────
const mains = [...html.matchAll(/<main\b([^>]*)>/gi)];
if (mains.length === 0) fail(page, '<main>', 'has no <main> landmark');
if (mains.length > 1) fail(page, '<main>', `has ${mains.length} <main> elements`);
const skip = /<a\b([^>]*class="[^"]*skip-link[^"]*"[^>]*)>/i.exec(html);
if (skip) {
const target = attrs(skip[1]).get('href') ?? '';
if (!target.startsWith('#')) {
fail(page, 'skip link', `points at ${JSON.stringify(target)}, which is not an in-page anchor`);
} else {
const id = target.slice(1);
if (!new RegExp(`\\bid=["']${id}["']`).test(html)) {
fail(page, 'skip link', `points at #${id}, and nothing on the page has that id`);
}
}
}
// ── 7. positive tabindex ──────────────────────────────────────────────────
for (const m of html.matchAll(/\btabindex\s*=\s*["']?(-?\d+)/gi)) {
if (Number(m[1]) > 0) {
fail(page, 'tabindex', `is ${m[1]} — a positive tabindex reorders the tab sequence`);
}
}
}
/* ---------------------------------------------------------------------------------------
Report
--------------------------------------------------------------------------------------- */
if (failures.length === 0) {
console.log(`checkA11y: ${pages.length} built pages pass all seven structural checks.`);
} else {
// Grouped by page: a shared component's defect otherwise prints fifty times and buries
// the one page that has a real problem of its own.
const byPage = new Map();
for (const f of failures) {
if (!byPage.has(f.page)) byPage.set(f.page, []);
byPage.get(f.page).push(f);
}
console.error(`\ncheckA11y: ${failures.length} problem(s) across ${byPage.size} page(s):\n`);
for (const [page, items] of byPage) {
console.error(` ${page}`);
for (const item of items) console.error(`${item.what}: ${item.detail}`);
}
console.error(`
These are structural, so they are the same in every browser and for every reader. A defect
repeated across many pages is usually one shared component — fix it there rather than on
each page.
`);
process.exit(1);
}

263
scripts/checkCsp.mjs Normal file
View File

@@ -0,0 +1,263 @@
#!/usr/bin/env node
/**
* checkCsp.mjs — PLAN.md §6 and D48, added in phase 10.
*
* §6 promises "a strict CSP with no external origins". D48 decided that promise should be
* a real response header sent by the container itself, not a `<meta>` (which ignores
* `frame-ancestors`) and not advice in an operator's proxy config (which lives outside the
* artifact we ship and test). `astro.config.mjs` sets it up; this checks it arrived.
*
* node scripts/checkCsp.mjs # verify the built output
* node scripts/checkCsp.mjs --write # rewrite src/config/cspHashes.mjs from the build
* node scripts/checkCsp.mjs --reset # empty it, so the next harvest starts from nothing
*
* Three things are checked, and each one has already been wrong once:
*
* 1. **Every built route has a policy.** `staticHeaders` writes `dist/_headers.json`; a
* route missing from it is a page served with no CSP at all, which is the failure mode
* nobody notices because the page looks perfect.
*
* 2. **Every inline script and style is covered by its page's own policy.** This is the
* real check. Astro does not hash `<script is:inline>`, and Starlight ships six of
* them per documentation page — so the first build with CSP on had a strict, correct
* header and a dead theme switcher. Hashing is verified per page against that page's
* header, not against a global list, because that is what the browser does.
*
* 3. **The directives §6 actually promised are present.** A policy that lost
* `frame-ancestors` in a refactor still passes checks 1 and 2 while no longer stopping
* anything.
*
* `--write` harvests the hashes from check 2 into `src/config/cspHashes.mjs`, which
* `astro.config.mjs` feeds back into the next build. So the sequence is reset → build →
* write → build → verify, which is what `npm run csp:hashes` runs. It resets first because
* harvesting only ever collects what the build did NOT cover — see `--reset` below.
*
* No token and no network: everything read here is in `dist/`.
*/
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const headersFile = path.join(root, 'dist', '_headers.json');
const clientDir = path.join(root, 'dist', 'client');
const hashesFile = path.join(root, 'src', 'config', 'cspHashes.mjs');
const write = process.argv.includes('--write');
const reset = process.argv.includes('--reset');
const failures = [];
const fail = (what, detail) => failures.push({ what, detail });
/**
* Rewrites the two exported arrays in `src/config/cspHashes.mjs`, leaving every comment and
* the JSDoc types above them untouched.
*/
const writeHashes = (script, style) => {
const source = fs.readFileSync(hashesFile, 'utf8');
const list = (hashes) =>
hashes.size === 0 ? '[]' : `[\n${[...hashes].sort().map((h) => ` '${h}',`).join('\n')}\n]`;
fs.writeFileSync(
hashesFile,
source
.replace(
/export const inlineScriptHashes = [\s\S]*?;\n/,
`export const inlineScriptHashes = ${list(script)};\n`,
)
.replace(
/export const inlineStyleHashes = [\s\S]*?;\n/,
`export const inlineStyleHashes = ${list(style)};\n`,
),
);
};
/**
* `--reset` empties the generated file, and `npm run csp:hashes` runs it FIRST.
*
* Without it the regeneration is not idempotent, and its failure mode is the worst
* available: harvesting collects the blocks the build did not cover, so running it against
* a build that is already correct finds nothing, writes two empty arrays and produces a
* build with no hashes at all. Emptying first means the harvest always sees the same thing
* — every inline block Astro does not hash on its own — whatever state the file was in.
*/
if (reset) {
writeHashes(new Set(), new Set());
console.log('checkCsp --reset: src/config/cspHashes.mjs emptied, ready to re-harvest.');
process.exit(0);
}
// ── The build has to be there ───────────────────────────────────────────────
if (!fs.existsSync(headersFile)) {
console.error(`
checkCsp: dist/_headers.json does not exist.
That file is written by the Node adapter's \`staticHeaders\` option, so either the build
has not run (\`npm run build\`) or \`staticHeaders\` was turned off in astro.config.mjs —
in which case the CSP is a <meta> tag and \`frame-ancestors\` is being ignored (D48).
`);
process.exit(1);
}
/**
* `_headers.json` is keyed by an internal route id, so the pathname lives in the record.
* Normalised without a trailing slash: the file says `/docs/first-run`, the built page is
* at `docs/first-run/index.html`, and `build.format: 'directory'` serves it at
* `/docs/first-run/`.
*/
const byPath = new Map();
for (const record of Object.values(JSON.parse(fs.readFileSync(headersFile, 'utf8')))) {
const csp = record.headers?.find((h) => h.key.toLowerCase() === 'content-security-policy');
byPath.set(record.pathname.replace(/\/$/, '') || '/', csp?.value ?? null);
}
// ── Walk the built HTML ─────────────────────────────────────────────────────
const pages = [];
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (entry.name === 'index.html' || entry.name.endsWith('.html')) pages.push(full);
}
};
walk(clientDir);
/**
* Inline only: anything with a `src` is a fetched file and is covered by `'self'`.
* The body is hashed exactly as written, because that is what the browser hashes — one
* byte of whitespace either side changes the digest.
*/
const INLINE_SCRIPT = /<script(?![^>]*\bsrc\s*=)([^>]*)>([\s\S]*?)<\/script>/g;
const INLINE_STYLE = /<style([^>]*)>([\s\S]*?)<\/style>/g;
/**
* `<script type="application/ld+json">` (D50) is a data block, not code: the browser never
* executes it, and CSP's script-src is not enforced against it. Demanding a hash for one
* would be wrong twice over — it would add the structured data's own text to the list of
* scripts allowed to run, and that text changes whenever a fact or the brand name does, so
* the generated hash file would churn on edits that cannot affect security.
*/
const DATA_BLOCK = /type\s*=\s*["']application\/(ld\+json|json)["']/i;
const sha256 = (body) => `sha256-${createHash('sha256').update(body, 'utf8').digest('base64')}`;
const harvested = { script: new Set(), style: new Set() };
let inlineScripts = 0;
let inlineStyles = 0;
let uncovered = 0;
for (const file of pages) {
const rel = path.relative(clientDir, file).replace(/\\/g, '/');
const pathname = '/' + rel.replace(/index\.html$/, '').replace(/\.html$/, '').replace(/\/$/, '');
const csp = byPath.get(pathname === '/' ? '/' : pathname.replace(/\/$/, ''));
if (csp === undefined) {
fail(pathname, 'is a built page with no entry in dist/_headers.json — it ships with no CSP');
continue;
}
if (csp === null) {
fail(pathname, 'has an entry in dist/_headers.json but no Content-Security-Policy header');
continue;
}
const html = fs.readFileSync(file, 'utf8');
for (const [kind, re, counter] of [
['script', INLINE_SCRIPT, 'inlineScripts'],
['style', INLINE_STYLE, 'inlineStyles'],
]) {
re.lastIndex = 0;
let match;
while ((match = re.exec(html))) {
const [, attrs, body] = match;
if (kind === 'script' && DATA_BLOCK.test(attrs)) continue;
// An empty inline block needs no hash; browsers do not enforce one.
if (body.trim() === '') continue;
if (counter === 'inlineScripts') inlineScripts++;
else inlineStyles++;
const hash = sha256(body);
if (csp.includes(hash)) continue;
uncovered++;
harvested[kind].add(hash);
if (!write) {
fail(
`${pathname} (inline <${kind}>)`,
`${hash} is not in that page's policy — ${JSON.stringify(body.trim().slice(0, 60))}`,
);
}
}
}
}
// ── The directives §6 promised, on a page that has to have them ─────────────
const REQUIRED = [
"default-src 'self'",
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
];
const home = byPath.get('/');
if (!home) {
fail('/', 'the homepage has no CSP header at all');
} else {
for (const directive of REQUIRED) {
if (!home.includes(directive)) fail('/ policy', `is missing "${directive}" (PLAN.md §6)`);
}
// The point of the whole exercise: a hash and 'unsafe-inline' in the same script
// directive means browsers ignore 'unsafe-inline' — but if the hashes ever went away it
// would quietly start applying.
const scriptSrc = /script-src ([^;]*)/.exec(home)?.[1] ?? '';
if (scriptSrc.includes("'unsafe-inline'")) {
fail('/ policy', "script-src contains 'unsafe-inline' — D48 says the hashes carry this");
}
if (scriptSrc.includes("'unsafe-eval'")) {
fail('/ policy', "script-src contains 'unsafe-eval' ('wasm-unsafe-eval' is the intended one)");
}
}
// ── --write: regenerate the hash file ───────────────────────────────────────
if (write) {
writeHashes(harvested.script, harvested.style);
console.log(
`checkCsp --write: harvested ${harvested.script.size} script and ${harvested.style.size} ` +
`style hash(es) from ${pages.length} pages into src/config/cspHashes.mjs.`,
);
if (failures.length) {
console.error('\ncheckCsp --write: the build is still wrong in ways hashes cannot fix:\n');
for (const f of failures) console.error(`${f.what}\n ${f.detail}`);
process.exit(1);
}
console.log('Now rebuild so the next build embeds them (npm run csp:hashes does both).');
process.exit(0);
}
// ── Report ──────────────────────────────────────────────────────────────────
if (failures.length === 0) {
console.log(
`checkCsp: ${pages.length} pages carry a policy; ` +
`${inlineScripts} inline script(s) and ${inlineStyles} inline style(s) are all hashed.`,
);
} else {
console.error(`\ncheckCsp: ${failures.length} problem(s) with the Content-Security-Policy:\n`);
for (const f of failures) console.error(`${f.what}\n ${f.detail}`);
if (uncovered) {
console.error(`
${uncovered} inline block(s) are not covered by a hash. In a browser this is silent: the
page renders and the script simply never runs — Starlight's theme switch and mobile
sidebar are inline scripts, so this is how the documentation loses them.
If the inline block is legitimate (usually: Starlight was upgraded), run
npm run csp:hashes
which rebuilds, harvests the hashes into src/config/cspHashes.mjs and rebuilds again.
Read what changed before committing it — that file is a list of scripts allowed to run.
`);
}
process.exit(1);
}

145
scripts/serve.mjs Normal file
View File

@@ -0,0 +1,145 @@
#!/usr/bin/env node
/**
* serve.mjs — the production entry point (PLAN.md §6, D48).
*
* `npm start` runs `applyBrand.mjs` and then this, instead of `dist/server/entry.mjs`
* directly. It is a thin wrapper around the adapter's own handler and exists for two
* reasons, one of them a bug in a dependency.
*
* ---------------------------------------------------------------------------------------
* 1. THE ADAPTER SERVES THE WRONG PAGE'S CONTENT-SECURITY-POLICY
* ---------------------------------------------------------------------------------------
* `@astrojs/node`'s `staticHeaders` writes one policy per prerendered route into
* `dist/_headers.json` and looks the right one up per request. The lookup, in
* `dist/serve-static.js`, is:
*
* headersMap.find((header) => header.pathname.includes(baselessPathname))
*
* `String.includes` — a SUBSTRING test, not equality, taking the first match. So:
*
* - `/modules/` matches the record for `/docs/modules/building-a-module`,
* - `/architecture/` matches `/docs/architecture/...`,
* - and `/`, which is a substring of every path in the file, matches whichever record
* happens to be first — here `/404`.
*
* Every prerendered page was therefore served some other page's policy. Because the
* policies are per-page hash lists, that is not a cosmetic mismatch: the browser refused
* the page's own stylesheet. `/modules/` and `/architecture/` rendered unstyled sections
* with `Refused to apply inline style` in a console, and the homepage only looked fine
* because it happens to share a hash with the 404 page.
*
* Astro's static-header machinery is otherwise exactly what §6 wants, so this replaces the
* lookup rather than the mechanism: the same `_headers.json`, matched by pathname
* EQUALITY. The workaround is deliberately small and obvious so it can be deleted whole
* when the upstream `find` is fixed — the check for that is whether `/modules/` and
* `/docs/modules/building-a-module` are served different policies.
*
* ---------------------------------------------------------------------------------------
* 2. THE HEADERS THAT ARE NOT CSP
* ---------------------------------------------------------------------------------------
* A few security headers have nothing to do with Astro and no other place to live. They
* are set here rather than written into an operator's reverse-proxy configuration (D48,
* again): the container should be correct on its own, and a proxy someone else configures
* is a promise this repository cannot check.
*
* The two routes that render per request — `/beta` and `/brand/*` — have no entry in
* `_headers.json`, because nothing prerendered them. They get `frame-ancestors 'none'` on
* its own, which is the one directive a `<meta>` CSP cannot express and therefore the one
* thing Astro's per-page meta tag leaves them missing.
*/
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.join(here, '..');
const port = Number(process.env.PORT ?? 4321);
const host = process.env.HOST ?? '0.0.0.0';
/* ---------------------------------------------------------------------------------------
The policies, matched exactly
--------------------------------------------------------------------------------------- */
const normalise = (pathname) => {
const clean = pathname.split('?')[0].split('#')[0];
const trimmed = clean.replace(/\/+$/, '');
return trimmed === '' ? '/' : trimmed;
};
const policies = new Map();
const headersFile = path.join(root, 'dist', '_headers.json');
if (fs.existsSync(headersFile)) {
for (const record of Object.values(JSON.parse(fs.readFileSync(headersFile, 'utf8')))) {
const csp = record.headers?.find((h) => h.key.toLowerCase() === 'content-security-policy');
if (csp) policies.set(normalise(record.pathname), csp.value);
}
} else {
// Not fatal: the site still serves, with the per-page <meta> policy Astro also emits.
// Loud, because a deployment silently losing its response-header CSP is exactly what §6
// is trying to prevent.
console.error(
'[serve] dist/_headers.json is missing — pages will be served without a CSP response\n' +
' header. Check that astro.config.mjs still sets `staticHeaders: true`.'
);
}
const FRAME_ONLY = "frame-ancestors 'none'";
/**
* Headers with no page-by-page component. Each is the browser default made explicit, and
* each closes something the CSP does not:
*
* - `X-Content-Type-Options` stops a browser guessing that a .txt is HTML.
* - `Referrer-Policy` keeps the path of the page a reader came from out of requests to
* other origins — there are none today (D9), and this is what keeps that true if a
* link is ever followed off-site.
* - `X-Frame-Options` says again, for anything too old to honour `frame-ancestors`.
* - `Permissions-Policy` turns off hardware this site has no reason to ask for. A
* marketing page requesting a camera should be impossible, not merely unlikely.
*/
const STATIC_HEADERS = {
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'X-Frame-Options': 'DENY',
'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), payment=(), usb=()',
};
/* ---------------------------------------------------------------------------------------
The server
--------------------------------------------------------------------------------------- */
// The adapter's entry starts its own listener on import unless this is set.
process.env.ASTRO_NODE_AUTOSTART = 'disabled';
// `pathToFileURL`, not the bare path: on Windows an absolute path starts with a drive
// letter, and Node's ESM loader reads `c:` as an unsupported URL scheme.
const { handler } = await import(pathToFileURL(path.join(root, 'dist', 'server', 'entry.mjs')).href);
const server = http.createServer((req, res) => {
const policy = policies.get(normalise(req.url ?? '/'));
for (const [key, value] of Object.entries(STATIC_HEADERS)) res.setHeader(key, value);
res.setHeader('Content-Security-Policy', policy ?? FRAME_ONLY);
/**
* The adapter will set its own (wrong) `Content-Security-Policy` from inside the static
* handler, overwriting what was just set. Rather than race it, every later attempt to
* set that one header is ignored — the correct value is already on the response, and
* this request's policy cannot change halfway through serving it.
*/
const setHeader = res.setHeader.bind(res);
res.setHeader = (name, value) => {
if (String(name).toLowerCase() === 'content-security-policy') return res;
return setHeader(name, value);
};
handler(req, res);
});
server.listen(port, host, () => {
console.log(`[serve] listening on http://${host}:${port}${policies.size} prerendered policies`);
});

View File

@@ -1,4 +1,5 @@
---
import Search from './Search.astro';
import { brand } from '../lib/brand.mjs';
/**
@@ -55,6 +56,8 @@ const isCurrent = (href: string) =>
))
}
</nav>
<Search />
</div>
</header>

279
src/components/Search.astro Normal file
View File

@@ -0,0 +1,279 @@
---
/**
* Site search for the marketing pages (D47).
*
* The documentation has had search since phase 1 — Starlight builds a Pagefind index at the
* end of every build and puts a box in its own header. The marketing pages were outside it
* twice over: not indexed, so a reader searching "Teams" in the docs found the architecture
* page and never the feature page; and with no box, so a reader who arrived on the homepage
* had a four-item nav and no way to ask a question.
*
* D47 closed both. `Base.astro` marks its `<main>` as a Pagefind body, which puts the ten
* marketing pages in the same index the docs already query, and this is the box.
*
* ── Why it is built this way ────────────────────────────────────────────────
* The marketing pages ship almost no JavaScript, and Pagefind's own UI bundle is 120 kB
* before the index and the WASM. Loading that on a homepage so that some visitors can
* search would be a poor trade, so **nothing is fetched until the dialog is opened** —
* the button is inert markup, and the first open injects the stylesheet and the script.
* Opening search a second time costs nothing more.
*
* `<dialog>` rather than a hand-built overlay: the browser gives us the focus trap, the
* inert background, Escape-to-close and the top layer for free, and every one of those is
* a thing an accessibility pass would otherwise have to find missing.
*
* In `astro dev` there is no `/pagefind/` — the index is written by the build. Rather than
* fail silently, the dialog says so.
*/
---
<div class="site-search">
<button type="button" class="site-search__open" data-search-open aria-haspopup="dialog">
<svg aria-hidden="true" focusable="false" viewBox="0 0 20 20" width="16" height="16">
<circle cx="9" cy="9" r="6" fill="none" stroke="currentColor" stroke-width="2"></circle>
<line x1="13.5" y1="13.5" x2="18" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"></line>
</svg>
<span>Search</span>
<kbd aria-hidden="true">/</kbd>
</button>
<dialog class="site-search__dialog" data-search-dialog aria-label="Search this site">
<div class="site-search__panel">
<div class="site-search__head">
<h2 class="site-search__title">Search</h2>
<button type="button" class="site-search__close" data-search-close>Close</button>
</div>
<div data-search-mount></div>
<p class="site-search__note" data-search-note hidden>
Search is built with the site, so it is not available in the dev server. Run
<code>npm run build &amp;&amp; npm start</code> to try it.
</p>
</div>
</dialog>
</div>
<script>
const dialog = document.querySelector<HTMLDialogElement>('[data-search-dialog]');
const mount = document.querySelector<HTMLElement>('[data-search-mount]');
const note = document.querySelector<HTMLElement>('[data-search-note]');
if (dialog && mount) {
let loaded: Promise<void> | null = null;
/**
* Pagefind's UI bundle is an IIFE that hangs `PagefindUI` off `window`, so it is a
* `<script src>` and not a dynamic `import()`. Both are covered by `script-src 'self'`
* (D48); the WASM the index needs is why that directive also carries
* `'wasm-unsafe-eval'`.
*/
const load = () =>
(loaded ??= new Promise<void>((resolve, reject) => {
const css = document.createElement('link');
css.rel = 'stylesheet';
css.href = '/pagefind/pagefind-ui.css';
document.head.append(css);
const js = document.createElement('script');
js.src = '/pagefind/pagefind-ui.js';
js.onload = () => {
new (window as any).PagefindUI({
element: mount,
showSubResults: true,
showImages: false,
// `resetStyles: false` was tried and is wrong here. Pagefind's reset is what
// styles its own input and buttons; without it they fall back to user-agent
// defaults, which on this ground meant black text typed into a dark field and
// a Clear button with a 1990s `outset` border. The palette is bound to our
// tokens below instead, which is the supported way round.
translations: {
placeholder: 'Search the site and documentation',
zero_results: 'Nothing found for [SEARCH_TERM]',
},
});
resolve();
};
js.onerror = () => reject(new Error('pagefind-ui.js did not load'));
document.head.append(js);
}).catch((error) => {
// A dev server, or a build served without its index. Say which.
if (note) note.hidden = false;
loaded = null;
throw error;
}));
const open = () => {
// Deliberately not awaited: the dialog should appear at once and fill in, rather
// than the button seeming dead for as long as the bundle takes.
load().catch(() => {});
if (!dialog.open) dialog.showModal();
window.setTimeout(() => {
dialog.querySelector<HTMLInputElement>('input[type="text"]')?.focus();
}, 50);
};
document
.querySelectorAll<HTMLButtonElement>('[data-search-open]')
.forEach((button) => button.addEventListener('click', open));
document
.querySelectorAll<HTMLButtonElement>('[data-search-close]')
.forEach((button) => button.addEventListener('click', () => dialog.close()));
// Clicking the backdrop closes it. `<dialog>` reports backdrop clicks as clicks on the
// dialog itself, so the test is whether the click landed outside the panel's box.
dialog.addEventListener('click', (event) => {
if (event.target !== dialog) return;
const box = dialog.getBoundingClientRect();
const outside =
event.clientX < box.left ||
event.clientX > box.right ||
event.clientY < box.top ||
event.clientY > box.bottom;
if (outside) dialog.close();
});
/**
* `/` and Ctrl/⌘-K, the two the documentation already answers to — the shortcut a
* reader learns in the docs should work on the way back out.
*/
document.addEventListener('keydown', (event) => {
if (dialog.open) return;
const target = event.target as HTMLElement | null;
const typing =
target?.isContentEditable ||
['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName ?? '');
if (typing) return;
if (event.key === '/' || ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k')) {
event.preventDefault();
open();
}
});
}
</script>
<style>
.site-search__open {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.7rem;
border: 1px solid var(--line);
border-radius: var(--radius-pill);
background: transparent;
color: var(--muted);
font: inherit;
font-size: 0.92rem;
cursor: pointer;
}
.site-search__open:hover {
color: var(--ink);
border-color: var(--gold);
}
.site-search__open kbd {
padding: 0 0.35rem;
border: 1px solid var(--line);
border-radius: 4px;
font: inherit;
font-size: 0.78rem;
line-height: 1.4;
}
/* Narrow viewports get the icon alone: the header has a lockup and four links to fit,
and "Search" beside a magnifier is the word the icon already says. */
@media (max-width: 46rem) {
.site-search__open span,
.site-search__open kbd {
display: none;
}
.site-search__open {
padding: 0.45rem;
}
}
.site-search__dialog {
width: min(46rem, calc(100vw - 2rem));
margin-inline: auto;
margin-block-start: min(12vh, 6rem);
padding: 0;
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--panel-flat);
color: var(--ink);
}
.site-search__dialog::backdrop {
background: var(--scrim);
}
.site-search__panel {
padding: 1.1rem 1.25rem 1.4rem;
}
.site-search__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.85rem;
}
.site-search__title {
margin: 0;
font-size: 1.05rem;
letter-spacing: 0.02em;
}
.site-search__close {
border: 0;
background: transparent;
color: var(--muted);
font: inherit;
cursor: pointer;
}
.site-search__close:hover {
color: var(--ink);
}
.site-search__note {
margin: 0.75rem 0 0;
color: var(--muted);
font-size: 0.92rem;
}
/* Pagefind ships its own palette; these bind it to the site's tokens so the dialog is
not a differently-coloured window sitting on the page. */
.site-search__panel :global(.pagefind-ui) {
--pagefind-ui-primary: var(--ink);
--pagefind-ui-text: var(--ink);
--pagefind-ui-background: var(--panel-flat);
--pagefind-ui-border: var(--line);
--pagefind-ui-tag: var(--bg);
--pagefind-ui-border-width: 1px;
--pagefind-ui-border-radius: var(--radius-input);
--pagefind-ui-font: inherit;
}
.site-search__panel :global(.pagefind-ui__result-link) {
color: var(--gold);
}
/* The match highlight. Pagefind marks matched terms with <mark>, and the user-agent
default for that is black on pure yellow — legible, and a hole punched through the
palette on every result. Gold at low opacity reads as a highlight against this ground
without becoming the loudest thing on the page.
`.pagefind-ui--reset` is in the selector because Pagefind's reset declares
`.pagefind-ui--reset mark { all: revert }`, which is the same specificity as a plain
descendant rule and is injected after this stylesheet — so it won on order and put the
yellow back. One more class is enough; `!important` is not needed and would be a worse
way to say the same thing. */
.site-search__panel :global(.pagefind-ui--reset mark) {
background: color-mix(in srgb, var(--gold) 26%, transparent);
color: var(--ink);
}
</style>

View File

@@ -0,0 +1,73 @@
---
import platform from '../data/platform.json';
import { brand } from '../lib/brand.mjs';
/**
* Structured data for the homepage (D50, phase 10).
*
* Two blocks and no more. `Organization` so the project's name resolves to an entity with a
* mark and a support channel rather than to whichever page happens to rank; and
* `SoftwareApplication` because what the site describes is software someone installs, and
* the licence and platform are facts a search result can usefully carry.
*
* ── What this deliberately is not ───────────────────────────────────────────
* It carries no ratings, no counts, no price, no `aggregateRating` — the vocabulary is
* full of fields that turn a result into an advert, and every one of them here would be
* invented. §11's "understated honesty" applies to markup a reader never sees as much as to
* the prose, and inventing a rating is the exact thing that gets structured data ignored.
*
* Breadcrumb and Article markup for the forty documentation pages was considered and
* rejected: Starlight already renders breadcrumbs a reader can see, and forty more blocks
* would be forty more places for a fact to go stale.
*
* ── Where the values come from ──────────────────────────────────────────────
* Every one is read — `brand.mjs` for text, `platform.json` for the platform's facts —
* so `checkFacts.mjs` already guards them and the mount already reaches them. Nothing here
* is typed twice. It is a data block, not code: no browser executes it, no CSP hash covers
* it, and `applyBrand.mjs` is free to rewrite the name inside it at boot (both scripts know
* about `application/ld+json` explicitly, because both would otherwise get it wrong).
*/
const site = Astro.site!;
const url = (p: string) => new URL(p, site).href;
const organization = {
'@type': 'Organization',
'@id': url('/#organization'),
name: brand.siteName,
url: url('/'),
logo: url('/brand/icon-512.png'),
description: brand.tagline,
// The support front door (D10). The Gitea org is where the code is; Discord is where a
// person gets an answer, so both are listed and neither is described as the other.
sameAs: [brand.giteaOrg, brand.discordInvite].filter(Boolean),
};
const application = {
'@type': 'SoftwareApplication',
'@id': url('/#software'),
name: brand.siteName,
url: url('/'),
description: brand.tagline,
applicationCategory: 'WebApplication',
// What an operator actually runs it on: a container on their own host, and an Android
// client. Not "Windows" — the installer runs there, the platform does not require it.
operatingSystem: 'Linux, Windows, Android',
license: 'https://www.gnu.org/licenses/gpl-3.0.html',
softwareVersion: platform.bundle.tag,
publisher: { '@id': url('/#organization') },
// Self-hosted and free, and `offers` is the only way the vocabulary can say so. Omitting
// it reads as "price unknown"; stating zero is simply true.
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'USD',
},
};
const graph = {
'@context': 'https://schema.org',
'@graph': [organization, application],
};
---
<script type="application/ld+json" set:html={JSON.stringify(graph)} is:inline />

41
src/config/cspHashes.mjs Normal file
View File

@@ -0,0 +1,41 @@
/**
* cspHashes.mjs — GENERATED. Do not edit by hand.
*
* Regenerate with `npm run csp:hashes` (which builds, harvests and rebuilds).
* `npm run check:csp` fails if this file no longer covers what the build emits.
*
* ── Why this file exists ────────────────────────────────────────────────────
* Astro's `security.csp` (D48) hashes the scripts and styles it processes itself. It does
* not hash `<script is:inline>` — by design, because an inline script is the author's own
* text and Astro never parses it. Starlight ships six of them on every documentation page:
* the theme provider, the theme-picker sync, the mobile menu, the sidebar scroll restore.
*
* That combination fails in the worst way available. The build succeeds, the header is
* strict and correct, every page renders — and the theme switch, the mobile sidebar and
* the sidebar's scroll position are dead, with the explanation only in a browser console
* nobody opens. `'unsafe-inline'` would fix all six and give up the single directive CSP
* exists to enforce, so instead the hashes are enumerated here and checked.
*
* These are Starlight's, not ours: a Starlight upgrade that edits one byte of one of those
* scripts invalidates a hash. `check:csp` is what turns that from a silent breakage into a
* red build, and regenerating this file is the acknowledgement that the upgrade was read.
*/
/**
* SHA-256 hashes of inline `<script>` bodies Astro does not hash for us.
*
* The template-literal type is not decoration: Astro types this option as `CspHashEntry[]`,
* so a plain `string[]` fails `astro check`.
*
* @type {`sha256-${string}`[]}
*/
export const inlineScriptHashes = [
'sha256-7eCV4jtsr4t4knb3c4FCRPeu7GGZeOUGE3XvWix0XOQ=',
'sha256-GkZBRnvSuhtx/cvzvukVkX2JJZW+DdPlVr7BX8Tefqo=',
'sha256-VWo5Wp4aqSj6nSgMpeAp9cKieaoIfwFUAunAVugI5gA=',
'sha256-f/zAUE74ucc3JYp4r4QQvkJofoQdkOIhHYK+jeZ6eko=',
'sha256-wX2yOADeV+NMngflD5uYi3vl50SHC4sfM1EmylVjlX4=',
];
/** @type {`sha256-${string}`[]} SHA-256 hashes of inline `<style>` bodies Astro does not hash. */
export const inlineStyleHashes = [];

View File

@@ -85,7 +85,37 @@ const canonical = new URL(Astro.url.pathname, Astro.site);
<div class="site">
<Header />
<main id="main">
<!--
`data-pagefind-body` is what puts the marketing pages into the search index the
documentation already had (D47). It is on `<main>` and not on `<body>` deliberately:
the header and footer are on all ten pages, so indexing them would make every page
a result for "Discord", "Privacy" and the product's own name.
Pagefind indexes a page only if it finds this attribute, which is why the docs were
the whole index before now — Starlight marks its own content and nothing else did.
The explicit title is worth the second attribute. Pagefind titles a result from the
first <h1> it finds, and these pages have EDITORIAL h1s — /app/'s is "The app for a
deployment you already use", /terms/'s is "Short, and only about what we run". Read
on the page under an eyebrow that says "Android app" those are right; read as four
rows in a result list they are unscannable, and the first walk of this search turned
up exactly that. The page's short name — the one in the nav and the browser tab —
is what a reader is looking for in a list.
-->
<!--
`tabindex="-1"` is what makes the skip link above actually skip. Following it moves
the SCROLL to this element, but a container is not focusable, so focus stays where
it was; Chrome papers over that by moving its sequential-navigation point, and not
every browser or screen reader does. `-1` makes the element focusable by script and
fragment only — never by Tab — so the link lands here for everyone and the tab order
is unchanged. (Negative, so it is not the positive `tabindex` checkA11y refuses.)
-->
<main
id="main"
tabindex="-1"
data-pagefind-body
data-pagefind-meta={`title:${bareTitle ? brand.siteName : title}`}
>
<slot />
</main>

View File

@@ -9,7 +9,9 @@ import Base from '../layouts/Base.astro';
---
<Base title="Page not found" description="That page does not exist on this site.">
<section class="page notfound">
<!-- The one page `Base`'s `data-pagefind-body` should not reach (D47): a search result
reading "That page does not exist" would be a small cruelty. -->
<section class="page notfound" data-pagefind-ignore>
<p class="eyebrow">404</p>
<h1>That page does not exist</h1>
<p class="prose">

View File

@@ -596,9 +596,13 @@ const formToken = issueFormToken();
.beta-form__consent input {
flex: none;
margin-top: 0.2rem;
width: 1.05rem;
height: 1.05rem;
/* 24px exactly — WCAG 2.2 SC 2.5.8's minimum target size, which this box missed at
1.05rem (17px). It is the only control on the site a person has to hit precisely,
and it is on the page a phone is most likely to arrive at, so the phase 10 walk
measuring it at 17x17 on a 390px viewport was worth acting on rather than
explaining away. */
width: 1.5rem;
height: 1.5rem;
accent-color: var(--portal);
}

View File

@@ -1,5 +1,6 @@
---
import Base from '../layouts/Base.astro';
import StructuredData from '../components/StructuredData.astro';
import { brand } from '../lib/brand.mjs';
import Hero from '../components/home/Hero.astro';
@@ -27,6 +28,8 @@ import GetStarted from '../components/home/GetStarted.astro';
---
<Base title={`${brand.siteName} — ${brand.tagline}`} description={brand.tagline} bareTitle>
<StructuredData slot="head" />
<Hero />
<DataPath />
<WhatItLooksLike />

40
src/pages/robots.txt.ts Normal file
View File

@@ -0,0 +1,40 @@
import type { APIRoute } from 'astro';
/**
* `/robots.txt` (D49, phase 10).
*
* The site had a sitemap covering all fifty URLs — Starlight bundles `@astrojs/sitemap`, so
* it has been written on every build since phase 1 — and nothing that pointed at it. A
* crawler finds a sitemap two ways: submitted by hand in a search console, or named here.
* There is no search console for this project (D9's no-analytics posture extends to not
* having accounts with anyone), so this file is the only way it is ever found.
*
* **Everything is allowed.** The site has no authenticated surface at all (§6), so there is
* no private area to keep out of an index, and the two exclusions worth arguing about were
* both rejected:
*
* - `/brand/*` is derived images and a stylesheet. Nothing links them as pages and they
* carry no text; disallowing them would only stop an image crawler fetching the OG card
* that exists to be fetched.
* - `/beta/` is a live page a person is meant to find. It is the closed test's front door
* and the nearest real deadline this project has — hiding it from search to keep the
* signup list small would be solving a problem nobody has.
*
* The 404 page is excluded from the *search index* instead (`data-pagefind-ignore`), which
* is the right layer for it: it is never a URL a crawler is given.
*
* Written as a route rather than a file in `public/` so the sitemap URL is derived from
* `site` in `astro.config.mjs`. A hand-written copy would be one more place the domain is
* spelled out, and the first thing to go stale if it ever changes.
*/
export const GET: APIRoute = ({ site }) =>
new Response(
[
'User-agent: *',
'Allow: /',
'',
`Sitemap: ${new URL('sitemap-index.xml', site)}`,
'',
].join('\n'),
{ headers: { 'Content-Type': 'text/plain; charset=utf-8' } }
);

View File

@@ -243,6 +243,20 @@ svg {
padding-inline: 0.45rem;
font-size: 0.86rem;
}
/* Search (D47) shares the second row with the links rather than taking a third. `order`
was the obvious way to lift it beside the lockup instead, and it was rejected: the tab
order follows the DOM, so a keyboard user would tab from the lockup down to the links
and back up to a button above them. Keeping the visual order the same as the focus
order is worth more here than the row it saves. */
.site-nav {
width: auto;
flex: 1 1 auto;
}
.site-search {
margin-left: auto;
}
}
/* ---- Footer ------------------------------------------------------------- */

View File

@@ -113,6 +113,11 @@
--shadow-raised: 0 22px 48px rgb(0 0 0 / 38%);
--panel-grad: linear-gradient(180deg, var(--panel-a), var(--panel-b));
--glow-portal: 0 0 32px rgb(21 180 222 / 22%);
/* Behind the search dialog (phase 10, D47). A token rather than a literal for the same
reason as everything else here: a mounted theme.css can only redefine properties, so
a scrim written into the component is a piece of the site an operator can never
recolour — and a light theme would need this one lighter, not merely less opaque. */
--scrim: rgb(0 0 0 / 60%);
/* ---- Layout ------------------------------------------------------------
Here rather than in global.css so a theme can widen the measure without

136
test/headers.test.mjs Normal file
View File

@@ -0,0 +1,136 @@
/**
* The headers the built site actually sends. PLAN.md §6 / D48, phase 10.
*
* ---------------------------------------------------------------------------------------
* WHY THIS IS A TEST AND NOT A CHECK SCRIPT
* ---------------------------------------------------------------------------------------
* `scripts/checkCsp.mjs` reads `dist/_headers.json` and proves the build computed the right
* policy for every route. That is necessary and it is not sufficient, because the defect
* this file exists for happened entirely *after* the build was correct: `@astrojs/node`
* matched a request to a policy with `pathname.includes(...)`, a substring test, and served
* `/modules/` the policy built for `/docs/modules/building-a-module`. Every file on disk was
* right. The bytes on the wire were not.
*
* Nothing that reads `dist/` can see that. The only way to know what a reader receives is
* to start the server and ask it, so this starts `scripts/serve.mjs` on an ephemeral port
* and reads the responses.
*
* The symptom is worth restating, because it is what makes this worth a test rather than a
* comment: a page served another page's hash list renders with its own stylesheet REFUSED.
* `/modules/` and `/architecture/` were shipping unstyled sections, and the only trace was
* a console message. The homepage looked perfect throughout — it happened to share a hash
* with the 404 page it was being given.
*
* ---------------------------------------------------------------------------------------
* IT NEEDS A BUILD
* ---------------------------------------------------------------------------------------
* `dist/` is an input here, so this file is NOT in `npm test` — that runs before the build,
* both locally and in CI. It is `npm run test:served`, which `npm run verify` runs after
* `npm run build`. With no build present it skips rather than fails, so that a developer
* running the whole file by hand gets an explanation instead of a stack trace.
*/
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { after, before, describe, it } from 'node:test';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const built = fs.existsSync(path.join(root, 'dist', 'server', 'entry.mjs'));
const PORT = 41732;
const base = `http://127.0.0.1:${PORT}`;
let server;
/** Wait for the port to answer rather than sleeping a guessed number of milliseconds. */
async function waitForServer(timeoutMs = 30000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
await fetch(base + '/', { signal: AbortSignal.timeout(1000) });
return;
} catch {
await new Promise((resolve) => setTimeout(resolve, 200));
}
}
throw new Error(`serve.mjs did not answer on ${base} within ${timeoutMs}ms`);
}
describe('the headers the server sends', { skip: built ? false : 'no build in dist/ — run npm run build first' }, () => {
before(async () => {
server = spawn(process.execPath, [path.join(root, 'scripts', 'serve.mjs')], {
cwd: root,
env: { ...process.env, PORT: String(PORT), HOST: '127.0.0.1' },
stdio: 'ignore',
});
await waitForServer();
});
after(() => server?.kill());
const cspOf = async (route) => {
const res = await fetch(base + route);
assert.equal(res.status, route === '/404' ? 404 : 200, `${route} status`);
const csp = res.headers.get('content-security-policy');
assert.ok(csp, `${route} has no Content-Security-Policy header`);
return csp;
};
it('gives each prerendered route its OWN policy, not a substring match', async () => {
// The exact pair the upstream bug confused: one is a substring of the other.
const marketing = await cspOf('/modules/');
const docs = await cspOf('/docs/modules/building-a-module/');
assert.notEqual(marketing, docs, '/modules/ was served the docs page\'s policy');
// And the homepage, which matched whichever record came first in the file.
const home = await cspOf('/');
const notFound = await cspOf('/404');
assert.notEqual(home, notFound, '/ was served the 404 page\'s policy');
});
it("covers a page's own inline styles with hashes in the policy it is served", async () => {
for (const route of ['/', '/modules/', '/architecture/', '/docs/']) {
const csp = await cspOf(route);
const html = await (await fetch(base + route)).text();
let counted = 0;
for (const [, body] of html.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g)) {
if (body.trim() === '') continue;
counted++;
const hash = `sha256-${createHash('sha256').update(body, 'utf8').digest('base64')}`;
assert.ok(csp.includes(hash), `${route}: an inline <style> is not hashed in its own policy`);
}
assert.ok(counted > 0, `${route}: expected at least one inline style to check`);
}
});
it('refuses framing everywhere, including the routes that render per request', async () => {
for (const route of ['/', '/docs/', '/beta/', '/brand/theme.css']) {
const res = await fetch(base + route);
const csp = res.headers.get('content-security-policy') ?? '';
assert.match(csp, /frame-ancestors 'none'/, `${route} can be framed`);
}
});
it('sends the non-CSP security headers on every response', async () => {
for (const route of ['/', '/docs/', '/beta/']) {
const res = await fetch(base + route);
assert.equal(res.headers.get('x-content-type-options'), 'nosniff', route);
assert.equal(res.headers.get('x-frame-options'), 'DENY', route);
assert.match(res.headers.get('referrer-policy') ?? '', /strict-origin/, route);
assert.match(res.headers.get('permissions-policy') ?? '', /camera=\(\)/, route);
}
});
it("never falls back to 'unsafe-inline' for scripts", async () => {
for (const route of ['/', '/docs/', '/beta/']) {
const csp = (await fetch(base + route)).headers.get('content-security-policy') ?? '';
const scriptSrc = /script-src ([^;]*)/.exec(csp)?.[1] ?? '';
assert.ok(!scriptSrc.includes("'unsafe-inline'"), `${route} script-src allows unsafe-inline`);
}
});
});