# Stream W: go-live readiness
(written 2026-09-11 by the away-plan finalising session, from `AWAY-PLAN.md` section 4)

> **Status 11 Sep:** not started. Wave two of the away period: starts when a wave-one lane
> (R2, M or G) finishes, in the order the plan's table gives, and may run beside any of them.
> Runs as `/stream-w`.

**Runs:** autonomously, in the one working tree beside whichever lanes are still going. Staging
only. **Nothing in this lane touches production**: every production step is written as a
checklist with the exact commands for rian, never run here. Never deploys.

## Goal
Everything that makes go-live day a flip rather than a project: the legal pages the footer
needs, the client pages' static copy no longer readable without a session, an app-only env
file so the database container reads no app secret, the unauthenticated sweep as a script that
runs again on the day, the production edge checklist, and the two carried corrections.

## Working assumptions this lane proceeds under (rian's to overturn; `AWAY-PLAN.md` §3)
- **The lazy chunks get gated behind the session while the site is members-only.** The running
  list item `decide-the-built-javascript-is-public-while-the-site-is-members` now carries this
  as its assumption. *If rian says leave them public:* the gate is one rule in `access.py` and
  one test; reverting is deleting both.
- **The login wall comes off with E7 on go-live day** (`SITE_ACCESS=public`); nothing here
  flips it. W4's script is what E7's second pass runs.
- **No production deploy, migration or edge change while rian is away**, and none while
  `.logs/runs/window-2026-09-11-night-refresh.md` says a window is open. W3 and W5 are staged
  and documented; rian applies them.

## What already exists, so you build on it
- `services/access.py` is the one route policy: `PUBLIC_ALWAYS`, `PUBLIC_WHEN_OPEN`,
  `SPA_*` sets, `MACHINE_FILES`, `decide()`, and `install()`; `/assets` is mounted in `main.py`
  as static files and is public in every mode by the accounts plan (no data in it). The
  hashed chunk names for the quote, structure and review pages are what an anonymous visitor
  can read today. `tests/test_access.py` and `tests/test_route_inventory.py` (with
  `tests/fixtures/open_routes.json`) walk every route.
- Explainer pages are served **by rule** from `public/` (mounted read-only at
  `/srv/extra-static`) through the `/{page}.html` route in `main.py`; an absent page answers
  404 and `tests/test_site_routes.py` verifies a static page by a string only it contains.
- `components/SiteFooter.tsx` links only pages that exist (0.44.0).
- `docker-compose.yml` gives both the app and the database `env_file: .app.env`; the running
  list item `issue-the-database-container-reads-every-line-of-app-env-split` carries the exact
  recipe, including the ACL trap (`chmod 600` alone breaks `srv-gw deploy`; use `setfacl`).
- The accounts plan §7 step 5 holds the curl list with expected answers; E7's first pass used
  it by hand. `deploy/production.sh` is rian's deploy path; `deploy/caddy/` the droplet's edge.
- `issue-production-edge-for-the-login-caddy-trusted-proxies-on-t` (P1) names the three edge
  facts: trusted proxies on the droplet's Caddy, cache bypass for the auth paths on both
  zones, Bot Fight Mode and managed challenges off.
- `services/indexnow.py` and `cli_pages.py indexnow`: the key file route exists only when
  `INDEXNOW_KEY` is set; the mint is rian's (`do-mint-indexnow-key-in-app-env-before-the-gate-comes-off-p`).
- `issue-extime-paris-not-cdg`: the Paris location is one Extime tenant covering CDG and Orly,
  named for both with code CDG.

## Tasks, in order (ids on /plan; record each with `main/scripts/plan-set.py`; commit prefix `W:`)
1. **W1 Privacy, terms and contact pages.** Three pages in `public/` (`privacy.html`,
   `terms.html`, `contact.html`) served by the existing rule, each with a visible "Draft, for
   Adam's approval" line until rian removes it, and a Legal column (or bottom-bar links) in
   `SiteFooter.tsx`. The privacy page states what the subscribe form collects (name, email, the
   consent sentence already on the form), that prices are observations with dates, and the
   contact address `bot@dutyfreeprofessor.com` only if the running list shows it delivering;
   otherwise the contact page names a form or address rian supplies and says so in the handoff.
   No em dashes, never "free" or "cheap", nothing implying the code moves with the client.
   `test_site_routes.py` gains the three pages, each verified by a string only it contains, and
   the 404 for `public/absent.html`. Resolve `issue-before-launch-the-footer-needs-privacy-terms-and-contact`
   with the commit; wording approval stays a `do` for rian.
2. **W2 Gate the lazy chunks.** While `SITE_ACCESS=members`, `/assets/*` answers 401 (a JSON
   `NOT_SIGNED_IN`, never a 302, since a script tag follows no redirect usefully) for every
   file except the entry bundle, the sign-in chunk and the CSS the login page needs; the
   allow-list is derived from `main/static/.vite/manifest.json` (or the built `index.html`) at
   startup, never typed. In `public` mode everything is public as today. Tests: an anonymous
   request for a lazily loaded chunk answers 401 in members mode and 200 in public mode; the
   entry bundle answers 200 in both; the login page still renders with only the public set. The
   rule lives in `access.py` beside `decide()`; it is the one structural edit to that shared
   file in the away period, so make it in one commit after re-reading the file.
3. **W3 An app-only env file.** Security review first, written into your handoff against the
   server checklist (`/srv/CLAUDE.md` "What to check"): the database service reads a
   `.db.env` holding only `POSTGRES_*`; the app keeps `.app.env`; `.app.env.example` and a new
   `.db.env.example` updated; `docker-compose.yml` changed on staging **and not deployed** (rian
   runs `srv-gw deploy --project dutyfreeprofessor` when back; say so). Run `srv-gw
   security-audit` after the compose edit and record its verdict. The ACL recipe from the
   running list goes into `RUNBOOK.md` verbatim, plus the droplet twin (`deploy/production.sh`
   reads which file, and the equivalent lines on `bwlive`) as a checklist step for rian. Never
   create, read or print a secret; the new file is staged with `REPLACE_WITH_` placeholders and
   0600. Resolve the running-list issue only after `security-audit` is clean.
4. **W4 The unauthenticated sweep as a script.** `main/scripts/route-sweep.sh <base-url>`: every
   route in `App.tsx` and every `/api/*` read the pages call, curled without a session, printing
   one line per route with the answer and the expected class from `services/access.py`
   (`public-always`, `public-when-open`, `member`, `permission`), and a non-zero exit if any
   internal route answers 200 without a session or any public route answers anything but 200
   when the site is open. It reads the route list from the code (`access.py`'s sets and
   `App.tsx`), never from a typed list. Run it against staging in members mode and record the
   output in `.logs/runs/`; `RUNBOOK.md`'s go-live section names it as the second pass E7 runs.
5. **W5 The production edge checklist.** `deploy/EDGE-CHECKLIST.md`: the exact Caddy
   `trusted_proxies` block for the droplet (from `deploy/caddy/`), the Cloudflare cache rules
   that bypass `/api/*`, `/login`, `/forgot`, `/welcome/*`, `/reset/*`, `/account*`, `/admin*`
   on both zones, Bot Fight Mode and managed challenges off, and the verification curl for
   each (what a wrong answer looks like), in the order rian runs them. Nothing is applied
   here. Link it from the running-list issue (`--update`) and from `RUNBOOK.md`'s go-live
   section.
6. **W6 The two carried corrections.** (a) Extime is Paris: every place the page or copy says
   CDG alone for the Extime location says Paris (CDG and Orly) instead, in the location name,
   the airports list, the guide seed and the server body; **the address does not change** (a
   redirect is rian's, after launch). (b) The IndexNow mint: `RUNBOOK.md`'s go-live section
   carries the one command block rian runs in his own shell (mint, set in `.app.env`,
   recreate, verify `/<key>.txt` answers 200); nothing here mints or reads a key, and a test
   pins that the key-file route is absent when `INDEXNOW_KEY` is unset. Resolve
   `issue-extime-paris-not-cdg` with the commit; the mint item stays rian's.

## Owns
New: `public/privacy.html`, `public/terms.html`, `public/contact.html`, `.db.env.example`,
`main/scripts/route-sweep.sh`, `deploy/EDGE-CHECKLIST.md`, `main/tests/test_assets_gate.py`,
`main/tests/test_route_sweep.py`.
Existing: `main/web/src/components/SiteFooter.tsx` and `.css`, `docker-compose.yml` (W3 only,
after the security review), `.app.env.example`, `deploy/*` (additions and the checklist; the
deploy script itself only for the env-file line), `main/app/services/indexnow.py`,
`main/tests/test_site_routes.py` (W1's cases; if Stream G is still running, append your cases
under a `class TestLegalPages` and nothing else), the Extime location's name and copy where it
is set (`services/collectors/extime.py` name constant, `airport_guides.py` seed line,
`AirportsPage.tsx` label), `main/app/services/access.py` for W2's rule (see Shared).

## Shared, append your own lines only
`main/app/services/access.py` (W2 is the one structural edit; every other lane only appends
entries: re-read, edit once, commit at once), `main/web/src/App.tsx`, `main/docs/RUNBOOK.md`
(your "Go-live" section), `main/CHANGELOG.md` Unreleased, `import/progress.json` through
`plan-set.py`, `.logs/handoff.md` prepend-only, `import/items.json` through `items.py` only.

## Must not touch
Stream M's, R2's and G's columns as their briefs list them (identity, merges, `/collectors`;
discussion, the client pages, the header; the airport page, hours, `seo.py`'s airport and
category functions). `.env`, `.app.env`, `.bw-auth.env` (read nothing, print nothing); the
kit under `main/app/vendor/*` and `main/web/src/vendor/*`; the live database; production in
any form (`bwlive`, its Caddy, the Cloudflare zone); `deploy/production.sh` beyond the
env-file line W3 names.

## Rules
- **Security review before W3, and `srv-gw security-audit` after it**, with the verdict in the
  handoff. A compose change is exactly the class of change the server's paranoid mode exists
  for; answer the checklist in writing before the edit.
- **Nothing on production, nothing deployed, no key minted, no secret read.** A production
  step is a checklist line with its command and its expected answer.
- **No address moves.** W6's correction is copy and name; the URL stays.
- **A GET never writes; every route is in exactly one class of `access.py` or it is refused.**
- No em dashes, never "free" or "cheap", nothing implying the code moves with the client, in
  every page a person reads; `test_house_style.py` walks the new pages.
- Everything else per `OVERNIGHT-RULES.md` and `AWAY-PLAN.md` §2: own files only,
  `main/check.sh` green before every commit, one task one `W:` commit, handoff ≤25 lines,
  `/plan` updated, decisions on the running list with `--blocks` and `--weight` and the
  assumption you proceeded under.
