wtclaude e71ff4acd4
All checks were successful
PR checks / checks (pull_request) Successful in 9m36s
feat(polish): phase 10 — search, accessibility, SEO and a real CSP
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>
2026-08-25 14:14:53 -05:00
2026-08-19 19:55:15 +00:00

runicgateway.com

The public marketing and documentation site for Runic Gateway — the platform that puts a private game server's live state on a public website without ever exposing the game to the internet.

Two audiences: server administrators who want to understand and install the platform, and developers who want to build modules and integrations for it. A third arrives with the Android closed beta: players, who want the app.

The design of record is PLAN.md. It is not a sketch — it carries the verified platform state, the org lead's decisions, the information architecture, and the build phases. Read it before changing anything here.

Status: phase 5 of 12 — the app and the beta. The foundation, the branding pipeline, the homepage and the five marketing pages are built, and /app/ and /beta/ now join them: a signed APK beside the closed-test signup, backed by a SQLite store on a bind mount and an export CLI. Next are the legal pages (phase 6) and then the documentation — the installation path, which is the priority of the whole project — in phases 7 and 8.


Running it

npm install
npm run dev          # http://localhost:4321
npm run build        # → dist/  (prerendered pages + the Node server entry)
npm start            # serve the built site

Node 22 LTS or newer.

The checks, and why they are not optional

Eleven of them, from PLAN.md §12. None is a linter; each one enforces a promise the site makes that would otherwise decay quietly.

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                                     # 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

checkFacts.mjs re-reads every version, protocol number and bundle tag in src/data/platform.json from its source of truth over the Gitea API — the sidecar's PROTOCOL_VERSION, the overlay's overlay.toml, the website's MODULE_API_VERSION, the installer's published bundle manifest, and each repository's latest release — and fails on any disagreement.

When the platform moves, this repository goes red so that someone updates the site. That failure is the feature, the same mechanism and the same intent as the Integration Kit's checkCoreApi.js. §1 of the plan records what it is guarding against: an earlier draft confidently stated the platform was on protocol 3, because every checkout in the workspace sat on a feature branch whose local main had never been fetched.

It needs a token — anonymous raw fetches fail on this Gitea instance, and a check that silently skips itself is worse than no check at all. It must be able to read the other repositories in the organisation, not just this one. CI already has this: the workflow maps the org-level REGISTRY_TOKEN secret into GITEA_TOKEN for that step.

checkTokens.mjs fails the build if a colour literal appears anywhere in src/ outside src/styles/tokens.css. §7 promises that recolouring the site is a file copy and a container restart; a mounted theme.css can only redefine custom properties, so a literal in a component is a piece of the site an operator can never reach. Without the check, "one CSS file changes the appearance" becomes "one CSS file changes most of the appearance".

checkBrand.mjs guards the two things about the branding pipeline that fail quietly. It puts every literal /brand/... URL in the source through the route's own classifier, so a template asking for a size that is not on the allowlist fails the build rather than 404ing in a browser; and it refuses a brand string short enough that replacing it blindly at boot could corrupt a page.

checkLinks.mjs reads dist/client rather than src/, because half the links these pages carry are assembled from data files and template literals and a source scan sees an expression. It 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 window, the per-connection rate limit, the global cap, address validation, idempotent duplicates and removal. Run the file by name — node --test test/ fails on Node 22, which is what CI uses.

The closed-beta signup

/beta is the only page that renders per request and the only one that writes anything. It handles its own POST, so the form works with JavaScript disabled and every outcome renders in the real layout. The store is SQLite on the data/ bind mount; the raw IP address is never recorded, only a salted hash used to rate-limit.

There is no admin page, by design — the tester list is managed from a shell:

npm run beta -- stats                  # counts, and where the store lives
npm run beta -- export                 # a CSV record + a .txt to paste into Play; marks rows exported
npm run beta -- export -- --all        # everything, including already-exported rows
npm run beta -- remove someone@example.com
Variable Default What it does
DATA_DIR ./data The bind mount holding beta.sqlite and exports/
BETA_IP_SALT random per process Salts ip_hash. Unset means rate limits reset on restart
BETA_FORM_KEY random per process Signs the form token, so a script must fetch the page before posting
BETA_TOTAL_CAP 500 Rows above which the form closes and says so
BETA_PER_HOUR / BETA_PER_DAY 3 / 24 Attempts one connection may make

Neither random default is a placeholder to be replaced by a constant: a hard-coded salt would make every deployment's hashes identical and therefore reversible by anyone holding this repository.

Branding is bind-mounted data

brand-default/ is baked into the image and always complete. brand/ is the bind mount and may be empty, partial or full. Every file resolves against the mount first and the defaults second, per file, so overriding only theme.css leaves every logo stock and an empty mount produces exactly the stock site. Nothing here goes through Vite, which would fingerprint the filenames into the build and put them out of the mount's reach.

Rebranding is one file. brand-default/ holds a single raster — logo.png — and GET /brand/* derives every size the site asks for from whichever logo.png is in force: the header mark at three pixel ratios, the install icons, the apple-touch icon, the favicons and a real multi-resolution favicon.ico. Drop in one file, restart, and the browser tab and the installed icon change with the header.

Brand text is applied at boot. Pages are prerendered, so the site name, tagline and links are baked into HTML that a mounted file cannot reach. npm start runs scripts/applyBrand.mjs first, which rewrites the built HTML from what it last applied to what the mount now says — recorded in dist/.brand-applied.json, so the second edit works as well as the first. An empty mount makes it a no-op.

A mounted theme.css wins by cascade layer, not by link order. tokens.css is inside @layer tokens; the mounted stylesheet is unlayered and therefore beats it wherever the browser encounters it. Do not "fix" this by reordering the links — Astro emits its own stylesheet after the head markup, which is what made the ordering approach silently useless.

To try it: put a theme.css, a logo.png or a brand.json in brand/, run npm run build and npm start. curl -I any /brand/* URL and the X-Brand-Source header says which of mount, defaults or derivation answered.

Regenerating the stock assets is a separate, manual step — npm run brand:assets — because it reads 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

src/
  data/platform.json     Every externally-sourced fact. No version is written in prose.
  data/collection.mjs    What is collected, in three scopes. /privacy renders it and the
                         Play Data Safety notes are generated from it — one inventory.
  data/legal.mjs         The values /terms, /privacy and the consent sentence must share.
  styles/tokens.css      THE token file — the only place a colour literal may appear.
  styles/global.css      The layout shell, built entirely from tokens.
  styles/starlight.css   Restates our tokens as Starlight's, so the docs cannot drift.
  layouts/, components/  The marketing chrome.
  pages/                 Marketing routes.
  pages/beta.astro       The signup. Renders AND handles its own POST — runs per request.
  content/docs/docs/     Documentation. The extra level mounts Starlight at /docs.
  pages/brand/           GET /brand/* — the mount, resolved and derived. Runs per request.
  lib/brand.mjs          The single accessor for brand text, plus liveBrand() for the two
                         routes that render per request and so miss the boot rewrite.
  lib/brandAssets.mjs    Mount-first resolution and on-demand derivation.
  lib/betaStore.mjs      The SQLite store: schema, dedupe, rate-limit window, cap, removal.
  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 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.

Two directories are bind mounts at runtime and are not in the repository: brand/ overrides anything in brand-default/ per file, and data/ holds the beta signup store. See PLAN.md §6 and §7.

Contributing

Branch from main (feature/…, fix/…, docs/…, chore/…) and use Conventional Commits. Run npm run verify before opening a pull request.

AI-assisted contributions must be disclosed, per org policy: tick the box in the pull request template naming the tool, and mark AI-authored commits with a trailer such as Co-Authored-By: Claude <noreply@anthropic.com>. Undisclosed AI-generated contributions may be closed.

Licence

GPL-3.0-or-later, in common with every repository in the organisation. See LICENSE.

Description
No description provided
Readme GPL-3.0 4.3 MiB
Languages
JavaScript 45.3%
MDX 26.7%
Astro 22.4%
CSS 4%
TypeScript 0.8%
Other 0.8%