Network design for the experiment
Topology
Reader's browser, on the tailnet
├─ https://yyz.fio.sh static site (public hosting, unchanged)
├─ https://yqa.fio.sh fio-redline service
│ DNS: Tailscale A record → GitLab host (100.x.y.z)
│ TLS: host nginx, same pattern as glab.fio.sh
│ nginx → http://fio-redline:8787 (Docker)
│ fio-redline → http://glab.fio.sh-server (Docker bridge glab.fio.sh-bridge)
└─ https://glab.fio.sh GitLab, for the OAuth authorize step only
DNS: existing Tailscale A record
Reader's browser, off the tailnet
├─ https://yyz.fio.sh works; shows build-time history
└─ https://yqa.fio.sh DNS resolves to 100.x.y.z; TCP connect fails
→ browser fetch rejects with TypeError
→ client state offline-network
No Cloudflare Access, no Tunnel, no cloudflared container. The network is the perimeter; GitLab OAuth is the identity.
Constraints that fix the design
- Session cookies must be same-site with the page. The service sets its
OAuth session cookie with
SameSite=Lax, so browsers send it on a cross-originfetchonly when page and service share a registrable domain.yyz.fio.shandyqa.fio.shsharefio.sh; a*.ts.netMagicDNS name would not. Therefore the service must be published on afio.shname that resolves to the Tailscale address. This is the same patternglab.fio.shalready uses. - The cookie prefix
__Host-requires HTTPS. Browsers reject a__Host-cookie without theSecureattribute and a secure origin. The service must be served over TLS by host nginx. For local and e2e runs overhttp://localhost, the service drops the prefix and theSecureflag whenPUBLIC_SERVICE_URLishttp:(T0.4); production validation refuseshttp:. - CORS.
SITES_JSON.<site>.allowedOriginsmust list every page origin that will call the service:https://yyz.fio.shfor production, andhttp://localhost:4321for the harness. - OAuth callback. The GitLab OAuth application’s redirect URI is exactly
https://yqa.fio.sh/v1/auth/gitlab/callback. The authorize step happens in the browser againsthttps://glab.fio.sh, which is also tailnet-only, so it fails off-net in the same way the service does. The client only offers the connect link after a successful service response, so an off-net reader is never sent to a dead redirect. - Service to GitLab. The container joins the external Docker network and
calls GitLab by its bridge alias, so this edge does not depend on Tailscale.
It fails only when GitLab itself is down or the bridge is misconfigured.
The service reports that as
gitlab-unreachableand the health probe reportsgitlab.reachable=false.
The three failure states, by edge
| Edge that failed | How the browser sees it | Client state | What the reader can do |
|---|---|---|---|
| browser → service | fetch rejects (TypeError) | offline-network | join the tailnet |
| service → GitLab | HTTP 502 with code gitlab-unreachable | offline-gitlab | wait; nothing on their side |
| identity | HTTP 401 | unauthenticated | sign in (not expected in the experiment; reads are anonymous) |
Playwright reproduces the first by aborting requests to the service origin,
the second by switching the mock GitLab to down, the third by running the
service with ALLOW_ANONYMOUS_READ=false.
Harness topology (local and CI)
Chromium (Playwright)
├─ http://localhost:4321 fixture Astro site, `astro preview`
├─ http://localhost:8787 fio-redline service, IDENTITY_MODE=gitlab-oauth
└─ http://localhost:9999 mock GitLab (T0.1), also the OAuth authorize origin
All three are localhost, which browsers treat as one site for cookies and as
a secure context, so the cookie flow matches production without TLS.
Alternative identity: Tailscale headers
GitLab OAuth is the first choice because it attributes writes to the person’s
own GitLab account. It is not a constraint. If the OAuth chain proves
unworkable on the tailnet (application cannot be registered, redirect chain
fails, cookies rejected), the executor may add IDENTITY_MODE=tailscale:
- Publish the service with
tailscale serveinstead of host nginx. Tailscale terminates TLS and injectsTailscale-User-Login,Tailscale-User-NameandTailscale-User-Profile-Picon every proxied request; they cannot be forged from the browser because the proxy strips incoming copies. - The service trusts those headers only in this mode, performs GitLab writes
with the service token, and prefixes every note body with the person’s
login so attribution still reaches GitLab. The
redline-auditline carries the same login. - The same-site cookie constraint disappears because there is no session
cookie; the service name may then be a
ts.netMagicDNS name, and the CORS allow-list is the only coupling to the site origin.
Record the switch in STATUS.md and add a Playwright spec that drives the
service with the headers set by page.setExtraHTTPHeaders.
Operator inputs (T4.3)
| Item | Value or owner |
|---|---|
| DNS | yqa.fio.sh A record → GitLab host Tailscale IP, same zone as glab.fio.sh |
| TLS | host nginx vhost yqa.fio.sh, certificate by the same method as glab.fio.sh |
| nginx upstream | http://127.0.0.1:<published port> or the Docker network alias fio-redline:8787 |
| GitLab OAuth app | confidential, scope api, redirect https://yqa.fio.sh/v1/auth/gitlab/callback |
| GitLab read token | project or group token, read_api only |
| Site registration | SITES_JSON for yyz as in ops/service.env.example, with features.editing=false |
| Secrets | ops/secrets/* files as the compose file lists, except the tunnel token |