docs: what actually gates access, now that there is a deployment
Build and Push Containers / test (push) Failing after 8s
Build and Push Containers / build-and-push-app (push) Skipped

The internal deployment exists (deployments repo, `mirror/`: quadlet, nginx
edge, certbot, route53, VM SOP), so the docs that said "the real fix is HTTPS"
were describing a future that has arrived from a different direction — TLS
terminates in nginx on the service VM, not in this process, and there is no
`--https` flag here.

SECURITY.md's trust model said "trusted: anyone who can open a TCP connection".
With an allow list in production that sentence was simply no longer true, so the
table now names the address space, and a new section says what the list is and
what it is not: one wall (network/nginx) and one belt (the app, for the path
that bypasses nginx at 127.0.0.1:8080), plus the thing neither can do — once two
browsers are in the room their media never asks the server again, and it can
never tell you *who*, only *where from*. Saying "defence in depth" about that
would have been the dishonest version.

PLAN.md gains A19 (TLS + admission in production, LOCKED) and A18 becomes
deprioritised rather than deleted — standalone `mirror serve --https` is still
worth having for offline/ad-hoc use, it just stopped being the thing blocking
cross-device sharing, and candidate phase 1 is now share-page resilience.
AGENTS.md gains the two rules most likely to be broken by a well-meaning edit:
trust XFF only from a trusted proxy, and change the nginx and app lists in the
same commit.

LLM-Generated: true
This commit is contained in:
2026-11-01 17:47:31 -05:00
parent 7988da2b93
commit 2e79430ac7
4 changed files with 91 additions and 15 deletions
+13 -6
View File
@@ -62,7 +62,8 @@ on a phone, and a person on a work machine can all join by opening a URL.**
| A15 | CLI | `argparse`, single `serve` subcommand, flags also accepted bare | One command; no dependency for a two-option CLI | **LOCKED** |
| A16 | Quality gates | `ruff check` + `ruff format --check` + `pyright` (0 errors) + `pytest` green, always | Non-negotiable before a phase is called complete | **LOCKED** |
| A17 | Scale-out beyond a mesh | *Proposed*: WebSocket/MSE relay or WebCodecs SFU when a sharer's uplink, not the CPU, is the limit | Revisit only with a measured problem. Status: **PROPOSED** | Proposed |
| A18 | TLS | *Proposed*: `mirror serve --https` with an auto-generated self-signed cert | Solves the secure-context problem (§6) for LAN IPs without a CA. Status: **PROPOSED** | Proposed |
| A18 | TLS (standalone) | *Proposed*: `mirror serve --https` with an auto-generated self-signed cert | Still open for a bare `mirror serve`. The problem it existed to solve is solved in production by A19's deployment, which terminates TLS in nginx. Status: **PROPOSED** (deprioritised) |
| A19 | TLS + admission in production | TLS terminates in **nginx on the service VM** (no gateway, no `proxy_protocol`); access is decided by **source address** at both edges — nginx `allow`/`deny` and `MIRROR_ALLOW_CIDRS`, with `X-Forwarded-For` believed only from `MIRROR_TRUSTED_PROXIES` (loopback) | There are no accounts, so the address *is* the access control; the app repeats the decision because a container port can be reached without the proxy. Over HTTPS every device gets `getDisplayMedia()`, which is what the fleet needs. Deployment lives in `deployments/mirror/` | **LOCKED** |
## 3. Architecture
@@ -198,7 +199,8 @@ MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay # Linux-native capture
| Constraint | Why | Handling |
|---|---|---|
| `getDisplayMedia` needs a secure context | Browser rule. `http://<lan-ip>` is not one | `/mirror` detects it and prints the three ways out (localhost, HTTPS, the per-origin Chrome flag). Real fix is A18 |
| `getDisplayMedia` needs a secure context | Browser rule. `http://<lan-ip>` is not one | `/mirror` detects it and prints the three ways out (localhost, HTTPS, the per-origin Chrome flag). Production is HTTPS at the edge (A19), so this only bites a bare `mirror serve` on a LAN IP |
| No accounts means the address list is the whole gate | Product promise (§1) | Enforced twice (nginx + app, A19). `/healthz` is the one exempt route, because the container health probe comes from loopback. See `SECURITY.md` §4 |
| Mesh upload = N × bitrate per sharer | WebRTC mesh | Per-leg `maxBitrate` cap (default 12 Mbit/s). A17 when it bites |
| Wayland capture depends on the portal | Compositor policy | Each share is confirmed by the user; X11 needs no such prompt. Documented in `SECURITY.md` |
| No reconnection of a *sharer* identity | Peer ids are per-socket | Viewer clears the tile and the sharer re-publishes on reconnect; the wall recovers by itself |
@@ -210,12 +212,17 @@ Ordered by what a user would notice first. Each would become
`.agents/phases/todo/NN_name/{00_phase.md,NN_task.md}` via the `phase-authoring`
skill, with its own independently-runnable test suite.
1. **HTTPS mode** — self-signed cert + a "how to trust it" screen (A18). Unblocks
capture from `http://<lan-ip>` on other devices, the single biggest papercut.
2. **Share-page resilience** — reconnect with roster resync, "source lost"
> HTTPS is no longer first, because the deployment solved it: TLS terminates in
> nginx (A19) and every device on the LAN can share. `mirror serve --https` (A18)
> stays on the list for the standalone case, ranked where its real cost lands.
1. **Share-page resilience** — reconnect with roster resync, "source lost"
states, `surfaceSwitching` UX, per-leg health on the sharer panel.
3. **Wall UX** — keyboard navigation (j/k/Enter/Esc), per-tile mute, layout
2. **Wall UX** — keyboard navigation (j/k/Enter/Esc), per-tile mute, layout
memory, "auto-stage newest mirror", PiP.
3. **HTTPS for `mirror serve`** — A18: self-signed cert, the "why is this
untrusted" screen, and a hint that a real deployment should terminate TLS in
nginx instead. For ad-hoc and offline use only now.
4. **Capacity & fairness** — adaptive bitrate per leg by watcher count,
`MIRROR_MAX_MIRRORS` surfaced in the UI, graceful degradation for a weak
sharer uplink.