feat(delivery): phase 12 — the container, and the defect only a proxy could find #17

Merged
whitlocktech merged 2 commits from feat/phase-12-delivery into main 2026-08-27 03:53:56 +00:00
Member

PLAN.md §13 phase 12 — delivery, and the last phase. Four decisions of record, D54–D57, taking the count to fifty-seven; recorded in §6, "How phase 12 delivered it".

A two-stage Dockerfile, a pull-only docker-compose.yml carrying both bind mounts, .env.example, the workflow that publishes and deploys, CONTRIBUTING.md, the community-health files this was the only repository of the ten to lack, and DEPLOY.md.

It also found a defect that would have made the closed beta impossible, and it is the only phase that could have found it. Every phase before this ran the site the way a developer runs it: one process, plain HTTP, an origin the browser and the server agree about by construction. The site does not run that way, and standing a real container behind a real proxy had a different answer.

D56 — the beta signup was dead behind a TLS-terminating proxy

@astrojs/node builds the URL of every request from the connection and the Host header alone. In astro/app/node's createRequestFromNodeRequest:

const isEncrypted = "encrypted" in req.socket && req.socket.encrypted;
const protocol = isEncrypted ? "https" : "http";

x-forwarded-proto is never consulted on that path — and security.allowedDomains, which sounds like the answer, is not: on this code path it gates only whether Astro.clientAddress may come from x-forwarded-for. So behind a proxy that terminates TLS the browser sends Origin: https://runicgateway.com and the container computes http://runicgateway.com, because its own socket is plaintext. Astro's CSRF middleware then compares the two for equality:

const isSameOrigin = request.headers.get("origin") === url.origin;

Every beta signup, from every visitor, answered 403 Cross-site POST form submissions are forbidden — on the one route that accepts a POST, on the site whose nearest real deadline is a closed test that cannot start without it. No proxy configuration fixes it; a proxy cannot make the container's socket encrypted. Fifty pages look perfectly healthy while the form silently refuses everyone.

scripts/serve.mjs already exists to wrap this adapter's mistakes (D48), so the fix went there: x-forwarded-proto: https marks the socket encrypted, and x-forwarded-host replaces Host for the proxies that rewrite it to the upstream address rather than passing it through.

Both are trusted unconditionally, with no flag to set — your call, and the right one. The image is meant to be deployed and work; an operator who has to discover a TRUST_PROXY variable to make the signup work is an operator who ships a dead form, and the broken configuration would be the default. It costs nothing: a cross-site form submission cannot make a victim's browser send x-forwarded-proto, so the CSRF check is exactly as strong as it was, and the site has no cookie, session or credential to protect in the first place. Two assertions in test/headers.test.mjs hold both halves — a proxy-shaped POST is accepted, and a genuinely cross-origin one is still refused.

D54 — a merge deploys, which amends D6

D6 said "ship the image and the compose file; the org lead deploys", and it was written before there was a host to deploy to. build-image.yml now builds, pushes runicgateway-site:latest and :sha-<7>, and rolls the container over on a runner labelled rgcom out of /opt/runicgateway.com. There is no release step and no promotion, so a merge is a publication.

What D6 was protecting is held by something else: every check and both test suites have already run on the pull request, and needs: build means a failed build never reaches the host. The deploy job then waits for the container's own healthcheck rather than for up -d to return — npm start runs the brand rewrite before the server starts, so "running" arrives well before "serving", and without the wait the job would go green on a container about to crash-loop. A pinned IMAGE_TAG survives an automatic deploy, because a pin is a decision and a merge should not quietly undo it.

The runner registers as rgcom:host. The :host suffix is what makes jobs run on the machine rather than in a job container; without it the deploy fails on the cd, having no Docker socket and no compose directory. DEPLOY.md §7 has the registration command.

D55 — a generic proxy, documented by its requirements

The site runs on its own host behind whatever proxy you put there, so DEPLOY.md states the four things the proxy must do rather than a Caddyfile that would be wrong for three readers out of four, and the container binds 127.0.0.1 by default so the safe configuration is the default one. Two of the four are silent when wrong:

  • X-Forwarded-For is load-bearing. The signup rate-limits from the first entry of that header, falling back to the peer address. Behind a proxy that does not set it, the peer address is the proxy — every visitor shares one bucket, and the third signup of any hour closes the form for everybody. It fails toward refusing signups rather than toward accepting abuse, which is the right direction and still a broken page.
  • A second CSP from the proxy breaks every page. Browsers enforce the intersection of duplicate policies, and ours is a list of per-page hashes, so a generic policy added at the proxy forbids the page's own stylesheet. The container already sends all five security headers (D48). The same section names the three Cloudflare features that rewrite HTML — Rocket Loader, HTML minification, email obfuscation — because each breaks the hashes the same way, from a dashboard rather than a config file.

D57 — the operator note is its own file, and two policies are pointers

DEPLOY.md rather than a README section: the README is for someone working on the site and was already long; deployment is a different task for a different sitting, and it is the file that gets opened on the host. Ten sections — what the site needs, first deploy, the proxy, DNS and TLS, branding without a rebuild, the tester-list CLI, updating, rolling back, backups, and a symptoms table.

This repository was also the only one of the ten with no CONTRIBUTING.md, CODE_OF_CONDUCT.md, SECURITY.md or issue/PR templates. The two policy files are pointers to the org's copies in docs, not copies — a copy would hard-code the contact address in a tenth place, and D13's whole promise is that the address lives only in brand.json. checkFacts.mjs scans src/ and scripts/; these honour the same rule voluntarily and say so, so nobody later "fixes" the missing address. SECURITY.md is not only a pointer, though: it names what is worth reporting here — the signup's store, rate limit and signed form token; traversal out of the branding mount; a page served no policy or another page's — and states that there is no authenticated surface to attack.

No twelfth check. D53 was re-tested against this phase's new drift risk (the env vars the code reads vs .env.example vs the README table) and you held the line. Eleven checks stand.

What was actually run

npm run verify, and then the part no script does — the image built, and run twice: once directly with both mounts, once through the real docker-compose.yml.

checkSidebar: 5 groups and 40 pages agree with plannedSidebar.
checkScreens: 17 screens, all present, sized and used.
checkTokens: 55 files scanned, every colour comes from src/styles/tokens.css.
checkBrand: brand-default is complete, 19 /brand/ URL(s) resolve, …
playDataSafety: PLAY_DATA_SAFETY.md matches src/data/collection.mjs.
astro check: 0 errors
npm test: 36 pass, 0 fail
checkLinks: 2410 internal link(s) resolve, 124 repository link(s) point at a branch.
checkFacts: 19 facts agree with their authorities.
checkQuickstart: 59 checks passed against website main.
checkReference: 22 enumeration check(s) passed against their sources.
test:served: 7 pass, 0 fail        ← two of them new
checkA11y: 50 built pages pass all seven structural checks.
checkCsp: 50 pages carry a policy; 410 inline scripts and 104 inline styles all hashed.

In the container, against the mounts:

Image 757 MB, two stages, npm prune --omit=dev between them
Boot healthy in ~25s; docker stop returns in 1.1s
CSP /modules/ and /docs/modules/building-a-module served different policies — the D48 workaround holds inside the image
Per-request routes /beta and /brand/* get frame-ancestors 'none'; all four non-CSP headers present
Brand mount a complete brand.json reached 51 files and re-indexed all 50 search pages; /beta followed it too (D51 holds)
Per-file fallback mounted theme.css served as x-brand-source: mount while logo.png stayed default and the favicon derived:default
betaOptInUrl a mounted URL reached the confirmation screen with no restart
Signup proxy-shaped POST (Host + X-Forwarded-Proto: https + Origin: https://…) → stored, where it was 403 before the fix
og:url now https://… on the per-request page, which it also could not have been
Export CLI wrote data/exports/2026-08-25.csv and .txt to the host bind mount
Backup §9's VACUUM INTO one-liner runs against the live store

docker compose config earned its place in the walk. The healthcheck was written as a block sequence, and YAML reads the : inside r.ok ? 0 : 1 as a key/value separator: services.site.healthcheck.test.3 must be a string. It is a quoted flow sequence now, with a comment saying why.

What is left, and it is not a phase

Point the DNS record at the host (§14, N1), start the rgcom runner, and — when the demo VM exists (§15) and the Play track is open — put two URLs into the mounted brand.json. None of those is a code change, which was the point.


  • AI-assisted — written with Claude Code (Opus 5); commits carry Co-Authored-By: Claude.

🤖 Generated with Claude Code

PLAN.md §13 phase 12 — delivery, and the last phase. Four decisions of record, **D54–D57**, taking the count to **fifty-seven**; recorded in §6, "How phase 12 delivered it". A two-stage `Dockerfile`, a pull-only `docker-compose.yml` carrying both bind mounts, `.env.example`, the workflow that publishes **and** deploys, `CONTRIBUTING.md`, the community-health files this was the only repository of the ten to lack, and `DEPLOY.md`. It also found a defect that would have made the closed beta impossible, and it is the only phase that could have found it. Every phase before this ran the site the way a developer runs it: one process, plain HTTP, an origin the browser and the server agree about by construction. **The site does not run that way**, and standing a real container behind a real proxy had a different answer. ## D56 — the beta signup was dead behind a TLS-terminating proxy `@astrojs/node` builds the URL of every request from the connection and the `Host` header alone. In `astro/app/node`'s `createRequestFromNodeRequest`: ```js const isEncrypted = "encrypted" in req.socket && req.socket.encrypted; const protocol = isEncrypted ? "https" : "http"; ``` `x-forwarded-proto` is never consulted on that path — and `security.allowedDomains`, which sounds like the answer, is not: on this code path it gates only whether `Astro.clientAddress` may come from `x-forwarded-for`. So behind a proxy that terminates TLS the browser sends `Origin: https://runicgateway.com` and the container computes `http://runicgateway.com`, because its own socket is plaintext. Astro's CSRF middleware then compares the two for equality: ```js const isSameOrigin = request.headers.get("origin") === url.origin; ``` **Every beta signup, from every visitor, answered `403 Cross-site POST form submissions are forbidden`** — on the one route that accepts a POST, on the site whose nearest real deadline is a closed test that cannot start without it. No proxy configuration fixes it; a proxy cannot make the container's socket encrypted. Fifty pages look perfectly healthy while the form silently refuses everyone. `scripts/serve.mjs` already exists to wrap this adapter's mistakes (D48), so the fix went there: `x-forwarded-proto: https` marks the socket encrypted, and `x-forwarded-host` replaces `Host` for the proxies that rewrite it to the upstream address rather than passing it through. **Both are trusted unconditionally, with no flag to set** — your call, and the right one. The image is meant to be deployed and work; an operator who has to discover a `TRUST_PROXY` variable to make the signup work is an operator who ships a dead form, and the broken configuration would be the default. It costs nothing: a cross-site form submission cannot make a victim's browser send `x-forwarded-proto`, so the CSRF check is exactly as strong as it was, and the site has no cookie, session or credential to protect in the first place. Two assertions in `test/headers.test.mjs` hold both halves — a proxy-shaped POST is accepted, and a genuinely cross-origin one is still refused. ## D54 — a merge deploys, which amends D6 D6 said "ship the image and the compose file; the org lead deploys", and it was written before there was a host to deploy to. `build-image.yml` now builds, pushes `runicgateway-site:latest` and `:sha-<7>`, and rolls the container over on a runner labelled **`rgcom`** out of **`/opt/runicgateway.com`**. There is no release step and no promotion, so a merge is a publication. What D6 was protecting is held by something else: every check and both test suites have already run on the pull request, and `needs: build` means a failed build never reaches the host. The deploy job then **waits for the container's own healthcheck** rather than for `up -d` to return — `npm start` runs the brand rewrite before the server starts, so "running" arrives well before "serving", and without the wait the job would go green on a container about to crash-loop. A pinned `IMAGE_TAG` survives an automatic deploy, because a pin is a decision and a merge should not quietly undo it. The runner registers as `rgcom:host`. The `:host` suffix is what makes jobs run on the machine rather than in a job container; without it the deploy fails on the `cd`, having no Docker socket and no compose directory. `DEPLOY.md` §7 has the registration command. ## D55 — a generic proxy, documented by its requirements The site runs on its own host behind whatever proxy you put there, so `DEPLOY.md` states the four things the proxy must do rather than a Caddyfile that would be wrong for three readers out of four, and the container binds **`127.0.0.1`** by default so the safe configuration is the default one. Two of the four are silent when wrong: - **`X-Forwarded-For` is load-bearing.** The signup rate-limits from the first entry of that header, falling back to the peer address. Behind a proxy that does not set it, the peer address is *the proxy* — every visitor shares one bucket, and the third signup of any hour closes the form for everybody. It fails toward refusing signups rather than toward accepting abuse, which is the right direction and still a broken page. - **A second CSP from the proxy breaks every page.** Browsers enforce the intersection of duplicate policies, and ours is a list of per-page hashes, so a generic policy added at the proxy forbids the page's own stylesheet. The container already sends all five security headers (D48). The same section names the three Cloudflare features that rewrite HTML — **Rocket Loader, HTML minification, email obfuscation** — because each breaks the hashes the same way, from a dashboard rather than a config file. ## D57 — the operator note is its own file, and two policies are pointers `DEPLOY.md` rather than a README section: the README is for someone working *on* the site and was already long; deployment is a different task for a different sitting, and it is the file that gets opened on the host. Ten sections — what the site needs, first deploy, the proxy, DNS and TLS, branding without a rebuild, the tester-list CLI, updating, rolling back, backups, and a symptoms table. This repository was also the only one of the ten with no `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md` or issue/PR templates. **The two policy files are pointers to the org's copies in `docs`, not copies** — a copy would hard-code the contact address in a tenth place, and D13's whole promise is that the address lives only in `brand.json`. `checkFacts.mjs` scans `src/` and `scripts/`; these honour the same rule voluntarily and say so, so nobody later "fixes" the missing address. `SECURITY.md` is not only a pointer, though: it names what is worth reporting *here* — the signup's store, rate limit and signed form token; traversal out of the branding mount; a page served no policy or another page's — and states that there is no authenticated surface to attack. **No twelfth check.** D53 was re-tested against this phase's new drift risk (the env vars the code reads vs `.env.example` vs the README table) and you held the line. Eleven checks stand. ## What was actually run `npm run verify`, and then the part no script does — the image built, and run twice: once directly with both mounts, once through the real `docker-compose.yml`. ``` checkSidebar: 5 groups and 40 pages agree with plannedSidebar. checkScreens: 17 screens, all present, sized and used. checkTokens: 55 files scanned, every colour comes from src/styles/tokens.css. checkBrand: brand-default is complete, 19 /brand/ URL(s) resolve, … playDataSafety: PLAY_DATA_SAFETY.md matches src/data/collection.mjs. astro check: 0 errors npm test: 36 pass, 0 fail checkLinks: 2410 internal link(s) resolve, 124 repository link(s) point at a branch. checkFacts: 19 facts agree with their authorities. checkQuickstart: 59 checks passed against website main. checkReference: 22 enumeration check(s) passed against their sources. test:served: 7 pass, 0 fail ← two of them new checkA11y: 50 built pages pass all seven structural checks. checkCsp: 50 pages carry a policy; 410 inline scripts and 104 inline styles all hashed. ``` In the container, against the mounts: | | | |---|---| | Image | 757 MB, two stages, `npm prune --omit=dev` between them | | Boot | healthy in ~25s; `docker stop` returns in 1.1s | | CSP | `/modules/` and `/docs/modules/building-a-module` served **different** policies — the D48 workaround holds inside the image | | Per-request routes | `/beta` and `/brand/*` get `frame-ancestors 'none'`; all four non-CSP headers present | | Brand mount | a complete `brand.json` reached **51 files** and re-indexed all 50 search pages; `/beta` followed it too (D51 holds) | | Per-file fallback | mounted `theme.css` served as `x-brand-source: mount` while `logo.png` stayed `default` and the favicon `derived:default` | | `betaOptInUrl` | a mounted URL reached the confirmation screen with **no restart** | | Signup | proxy-shaped POST (`Host` + `X-Forwarded-Proto: https` + `Origin: https://…`) → **stored**, where it was 403 before the fix | | `og:url` | now `https://…` on the per-request page, which it also could not have been | | Export CLI | wrote `data/exports/2026-08-25.csv` and `.txt` to the **host** bind mount | | Backup | §9's `VACUUM INTO` one-liner runs against the live store | **`docker compose config` earned its place in the walk.** The healthcheck was written as a block sequence, and YAML reads the `: ` inside `r.ok ? 0 : 1` as a key/value separator: `services.site.healthcheck.test.3 must be a string`. It is a quoted flow sequence now, with a comment saying why. ## What is left, and it is not a phase Point the DNS record at the host (§14, N1), start the `rgcom` runner, and — when the demo VM exists (§15) and the Play track is open — put two URLs into the mounted `brand.json`. None of those is a code change, which was the point. --- - [x] **AI-assisted** — written with Claude Code (Opus 5); commits carry `Co-Authored-By: Claude`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
wtclaude added 1 commit 2026-08-25 21:55:39 +00:00
feat(delivery): phase 12 — the container, and the defect only a proxy could find
All checks were successful
PR checks / checks (pull_request) Successful in 9m46s
f2e59a2426
PLAN.md §13 phase 12, the last one. Four decisions of record, D54–D57, taking the
count to fifty-seven; recorded in §6, "How phase 12 delivered it".

A two-stage Dockerfile, a pull-only docker-compose.yml carrying both bind mounts,
.env.example, the workflow that publishes and deploys, CONTRIBUTING.md, the
community-health files this was the only repository of the ten to lack, and
DEPLOY.md.

D54 — a merge deploys, amending D6. build-image.yml pushes
runicgateway-site:latest and :sha-<7>, then rolls the container over on the
`rgcom` runner out of /opt/runicgateway.com, and waits for the container's own
healthcheck rather than for `up -d` to return.

D55 — the site runs on its own host behind a generic reverse proxy, so DEPLOY.md
states the four requirements rather than one worked example, and the container
binds 127.0.0.1 so the safe configuration is the default.

D56 — @astrojs/node derives the request protocol from req.socket.encrypted and
never reads x-forwarded-proto, so behind a TLS-terminating proxy the browser sends
Origin: https://… while the container computes http://… and Astro's CSRF check
compares them for equality. Every beta signup, from every visitor, was answered
403. serve.mjs now normalises both forwarded headers, unconditionally — the image
should deploy and work. Two assertions in test/headers.test.mjs hold both halves.

D57 — DEPLOY.md rather than a README section; SECURITY.md and CODE_OF_CONDUCT.md
are pointers to the org's copies rather than copies, because a copy would hard-code
the contact address D13 confines to brand.json.

Verified: npm run verify green (eleven checks, 36 unit tests, 7 served tests,
astro check 0 errors). The image was built and run with both mounts — a mounted
brand reached 51 files and all 50 search pages, /brand/* fell back per file, a
proxy-shaped signup reached the store, and the export CLI wrote both Play files to
the host mount. docker compose config caught a YAML trap in the healthcheck: a
block sequence reads the `: ` in `r.ok ? 0 : 1` as a mapping.

Co-Authored-By: Claude <noreply@anthropic.com>
wtclaude added 1 commit 2026-08-25 21:57:31 +00:00
docs(deploy): correct the image size, and say what a missing rgcom runner does
All checks were successful
PR checks / checks (pull_request) Successful in 1m19s
1558111050
The image measures 757 MB, not the ~600 MB the requirements table guessed. And
until the rgcom runner exists the deploy job simply queues, which is worth stating
in both the workflow and DEPLOY.md: build has already published the image by then,
so the manual pull works throughout and the queued job goes when the runner does.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit 3b4067fa37 into main 2026-08-27 03:53:56 +00:00
whitlocktech deleted branch feat/phase-12-delivery 2026-08-27 03:53:57 +00:00
Sign in to join this conversation.
No description provided.