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

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>
This commit is contained in:
2026-08-25 16:54:38 -05:00
parent 18064062a9
commit f2e59a2426
17 changed files with 1451 additions and 14 deletions

135
PLAN.md
View File

@@ -225,7 +225,7 @@ Taken by the org lead (Colby Whitlock) on 2026-08-19. Recorded so they are not r
| **D3** | **All ten documentation conflicts are fixed**, not just the operator-facing five. | §4. |
| **D4** | **Real web screenshots**, captured from the local review stack, not placeholders. | §13 phase 6. Needs seeded, presentable demo content. |
| **D5** | **Claude drafts `/privacy` and `/terms`** from what the code actually collects; the org lead reviews before ship. | §9. |
| **D6** | **Ship the image and compose file; the org lead deploys.** DNS and TLS terminate at their existing reverse proxy. | §13 phase 9. This repo never touches the production host. |
| **D6** | **Ship the image and compose file; the org lead deploys.** DNS and TLS terminate at their existing reverse proxy. | §13 phase 9. **Amended by D54 (2026-08-25):** a merge to `main` now builds, publishes and deploys. What D6 protected is held by `needs: build` and by every check having run on the pull request. |
| **D7** | **The site sends no email at all.** No SMTP, no notifications, no mailbox behind the domain yet. | §8 designs the signup so it works anyway — see "The opt-in link removes the need for email". A contact address is still required; D13 supplies it. |
| **D8** | **Understated honesty.** The site reads as finished; factual badges appear only where they save a reader wasted effort. **The Integration Kit stays marked draft until a second module is successfully built against it.** | §11, §10. A status with an exit criterion, not a mood. |
| **D9** | **No analytics.** No tracking scripts, no third-party requests, no cookie banner. | Reverse-proxy access logs are the only traffic data. |
@@ -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 **fifty-three**:
somewhere other than the thing it decided. The count of record is **fifty-seven**:
| # | Where | What it settled |
|---|---|---|
@@ -250,6 +250,7 @@ somewhere other than the thing it decided. The count of record is **fifty-three*
| 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 |
| D51D53 | §6, "How phase 11 validated it" | The chrome and the head follow the brand mount while the consent sentence does not, the documentation half gets phase 10's skip-link fix, and no twelfth check |
| D54D57 | §6, "How phase 12 delivered it" | A merge deploys (amending D6), the proxy is documented by its requirements rather than by an example, the container trusts the forwarded headers with nothing to configure, and the operator note is its own file while the two policy files are pointers |
---
@@ -514,6 +515,125 @@ per-capability deep links pointed into the mounted demo. And an opt-in URL paste
file reached the confirmation screen on the next request, with no restart — which is the one part of
§7 that has to be true on the day the closed test opens.
### How phase 12 delivered it
The last phase, and the one that turns a repository into a deployment: a two-stage `Dockerfile`, a
pull-only `docker-compose.yml` carrying both bind mounts, `.env.example`, the publishing workflow,
`CONTRIBUTING.md` with the AI-disclosure requirement, the community-health files this repository was
the only one in the organisation to lack, and `DEPLOY.md`. Four decisions, **D54D57**, taking the
count of record to **fifty-seven**.
It also found a defect that would have made the closed beta impossible, and it is the only phase
that could have found it. Everything 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. Standing a real container behind a real proxy is a different question, and it had
a different answer.
#### 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. The org lead amended it on 2026-08-25: `.gitea/workflows/build-image.yml`
builds, pushes `runicgateway-site:latest` and `:sha-<7>`, and then rolls the container over on a
runner labelled **`rgcom`** on the site's own host, 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 now. Every one of the eleven checks and both test
suites have already run on the pull request; the deploy job is `needs: build`, so a failed build
never reaches the host at all; and the job then waits for the container's own healthcheck rather
than for `up -d` to return, because `npm start` runs the brand rewrite *before* the server starts and
"running" therefore arrives well before "serving". A pinned `IMAGE_TAG` survives an automatic
deploy — Compose recreates on the pinned tag — because a pin is a decision and a merge should not
quietly undo it.
The runner registers with the label `rgcom:host`. The `:host` suffix is what makes jobs run on the
machine rather than inside a job container; without it the deploy fails on the `cd`, having no
Docker socket and no compose directory.
#### D55 — a generic proxy, documented by its requirements rather than by an example
The site runs on its own host, behind whatever reverse proxy the org lead puts there. So
`DEPLOY.md` does not carry a worked Caddyfile or nginx block that would be wrong for three readers
out of four; it states the four things the proxy must do, and the container binds to **`127.0.0.1`**
by default so that the safe configuration is the default one.
Two of the four are worth repeating here because they are silent when wrong:
- **`X-Forwarded-For` is load-bearing.** The signup rate-limits per client from the first entry of
that header, falling back to the connection's peer address. Behind a proxy that does not set it,
the peer address is *the proxy* — so every visitor on earth 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 Content-Security-Policy from the proxy breaks every page.** Browsers enforce the
intersection of duplicate policies, and this site's 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 CSP,
`X-Content-Type-Options`, `Referrer-Policy`, `X-Frame-Options` and `Permissions-Policy` (D48), so
the proxy's job is to add none of them. The same paragraph names the three Cloudflare features
that rewrite HTML — Rocket Loader, HTML minification and email obfuscation — because each of them
breaks the hashes in exactly the same way, from a dashboard rather than from a config file.
#### D56 — the container trusts the forwarded headers, with nothing to configure
**The defect.** `@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`. 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;
```
So **every beta signup, from every visitor, is 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 instead of passing it through.
**Both are trusted unconditionally, with no flag to set** — the org lead's 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.
#### 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, and deployment is a different task for a different sitting — it is also the file that
gets opened on the host. It carries 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`, no
`CODE_OF_CONDUCT.md`, no `SECURITY.md` and no issue or pull-request templates, so phase 12 added
them. **The two policy files are pointers to the organisation's copies in `docs`, not copies**
because 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` and moves for the cost of a file copy. `checkFacts.mjs` scans
`src/` and `scripts/`; these files honour the same rule voluntarily, and say so, so that the next
person does not "fix" the missing address.
`SECURITY.md` is not only a pointer, though. It names what is actually worth reporting *here*
the signup's store, rate limit and signed form token; path traversal out of the branding mount; and
a page served no policy or another page's — and states plainly that there is no authenticated
surface to attack.
**No twelfth check.** D53 was re-tested against this phase's new drift risk — the environment
variables the code reads, `.env.example` declares and the README tabulates — and the org lead held
the line. Eleven checks stand.
---
## 7. Branding is bind-mounted data
@@ -1491,11 +1611,16 @@ a mechanism rather than diligence:
| **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. 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. See D51D53 — the scripts were green before the phase started; the browser walk and a real brand mount are what found the three defects |
| **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) |
| **12** | Delivery: two-stage Dockerfile, pull-only `docker-compose.yml` with both bind mounts, `.env.example`, the Gitea Actions workflow that publishes **and deploys** (D54), README, CONTRIBUTING, the community-health files this repo alone lacked, and `DEPLOY.md` (D55, D57). It found the one defect that only a container behind a proxy can find — see D56 |
Phases 5 and 6 are deliberately adjacent and early: the beta cannot start without `/privacy`, and
the closed test is the nearest real deadline.
**All twelve are built, as of 2026-08-25.** What is left 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.
---
## 14. Still needed from the org lead
@@ -1503,7 +1628,9 @@ the closed test is the nearest real deadline.
None of these block starting Phase 0 or Phase 1.
**N1 — Resolved.** `runicgateway.com` is registered through **Cloudflare**, with DNS on Cloudflare.
The domain does not resolve to anything yet; the record is pointed at the host in phase 12.
The domain does not resolve to anything yet. Phase 12 shipped everything needed to point it: the
`A` record, the proxy requirements and the three Cloudflare features that break a hash-based CSP are
in `DEPLOY.md` §4. Creating the record is the org lead's, on the day the host is up.
**N2 — Settled by D13, and no longer blocking anything.** As of 2026-08-19 no mailbox exists at the
domain and the org lead chose not to wait for one. **`whitlocktech@gmail.com` is the published