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
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:
135
PLAN.md
135
PLAN.md
@@ -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*
|
||||
| D42–D46 | §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 |
|
||||
| D47–D50 | §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 |
|
||||
| D51–D53 | §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 |
|
||||
| D54–D57 | §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, **D54–D57**, 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 D47–D50 — 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 D51–D53 — 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
|
||||
|
||||
Reference in New Issue
Block a user