From d589c31fe121a94b1863ec8d18292614a0d32a57 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 2 Sep 2026 17:02:27 -0700 Subject: [PATCH 1/3] docs(self-host): state the front-door contract any private network must meet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SELF_HOST.md read as though Tailscale were a dependency. It is not — nothing in the server or the Host is Tailscale-aware, `DORMOUSE_ORIGIN` takes any absolute URL and `DORMOUSE_REMOTE_CONNECT_SRC` any source list. What Tailscale supplies is a front door: a stable DNS name, a certificate the phone already trusts, and a reverse proxy in front of the loopback server. Say that in the intro, and add "Bring your own private network" with the five rules another network has to satisfy — a DNS name rather than an IP (an IP literal is not a valid WebAuthn RP ID, so no passkey can be registered at all), a trusted certificate, reachability nothing checks for you now that the Funnel assertion has no generic equivalent, the loopback bind intact, and an origin stable for the life of the passkey — plus what is forfeited by leaving the scripted path. Tailscale stays the one supported path and the title still names it: the whole runbook body is Tailscale commands, and the installers refuse without the CLI. The word budget rises 7450 -> 8050; the file had five words of headroom. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UabNVcotSFB6R9WJpCxjmk --- SELF_HOST.md | 69 +++++++++++++++++++++++++++++++--- scripts/spec-word-budgets.json | 2 +- 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/SELF_HOST.md b/SELF_HOST.md index 30802d9a..43aae33c 100644 --- a/SELF_HOST.md +++ b/SELF_HOST.md @@ -9,9 +9,17 @@ This installs the Dormouse coordinating server on the user's own laptop, reachable only from their tailnet at `https://..ts.net`. That -is the whole self-host story today. To keep the relay up while that machine -sleeps, run the same installer on an always-on tailnet box — see "Keeping the -relay up while the laptop sleeps". +is the whole *scripted* self-host story today. To keep the relay up while that +machine sleeps, run the same installer on an always-on tailnet box — see +"Keeping the relay up while the laptop sleeps". + +**Tailscale is a front door, not a dependency.** Nothing in the server or the +Host is Tailscale-aware — `DORMOUSE_ORIGIN` is any absolute URL and +`DORMOUSE_REMOTE_CONNECT_SRC` any source list. What Tailscale supplies is the +three things an installer would otherwise have to ask for: a stable DNS name, a +certificate the phone already trusts, and a reverse proxy in front of the +loopback server. Another private network supplying all three works, unassisted: +[Bring your own private network](#bring-your-own-private-network). The installer already exists — one idempotent command that ships in this repository, in a macOS, a Windows and a Linux edition: @@ -93,7 +101,10 @@ known: - **A tailnet.** The user needs a Tailscale account with MagicDNS and HTTPS certificates enabled, Tailscale running on this laptop, and Tailscale on the phone that will run Pocket. A tailnet-only origin is not reachable merely - because the laptop is on the tailnet. + because the laptop is on the tailnet. All three installers refuse to run + without the Tailscale CLI; on another private network, stop here and read + [Bring your own private network](#bring-your-own-private-network) with the + user rather than improvising around that refusal. - **macOS, Windows or Linux.** Each installer refuses to run on the other platforms. On a fourth OS, or on a Linux box without systemd, stop and design the native service manager with the user rather than translating LaunchAgent, @@ -134,8 +145,9 @@ known: `standalone/scripts/build-sidecar-proxy.mjs` and `vscode-ext/scripts/esbuild.mjs` bake that variable into their respective - Node Host bundles. The relay socket no longer lives in either webview, so - changing a webview CSP does not widen this allowlist. + Node Host bundles. The value is a source list rather than a Tailscale check, + so a non-tailnet origin goes in verbatim. The relay socket no longer lives in + either webview, so changing a webview CSP does not widen this allowlist. ## What the installer does @@ -669,6 +681,51 @@ which is the one thing the above does not give. That belongs with the multi-tenant work in `docs/specs/server.md` `## Future`, not with a single-user install, and is not designed here. +## Bring your own private network + +Every installer refuses without the Tailscale CLI. The server does not care: +point `DORMOUSE_ORIGIN` at any absolute URL, build the Host with a matching +`DORMOUSE_REMOTE_CONNECT_SRC`, and WireGuard, a corporate VPN, or a reverse +proxy on a home LAN all work. There is no script for it, so what follows is a +contract rather than a runbook — a front door must supply all five: + +- **A DNS name, never an IP address.** The WebAuthn `rpId` is the origin's + hostname (`server/src/app.ts`), and an IP literal is not a valid RP ID, so no + passkey can be registered against `https://10.0.0.4:8443` at all. A + non-default port is fine; the origin carries it and `rpId` does not. +- **A certificate the phone already trusts.** Passkeys need a secure context. + Publicly trusted means owning a domain and running ACME — usually DNS-01, + since the name resolves to a private address — which is a renewal credential + the Tailscale path never has to store. A private CA also works, but every + phone must install *and* fully trust the root before Pocket will load. +- **Reachability limited to that network, checked by you.** There is no generic + equivalent of the Funnel assertion, so a proxy that starts answering from the + internet does it silently. `SECURITY.md` → "The setup password — accepted + risk" is the analysis this premise carries; a network reaching further than a + tailnet is a materially different risk than the one analyzed there. +- **The server still bound to `127.0.0.1`.** The listen interface is the + boundary whenever the TLS proxy is local (`docs/specs/server.md` → + Configuration). A proxy in front of an all-interfaces bind is not a front + door, it is a second one. +- **An origin stable for the life of the passkey.** `DORMOUSE_ORIGIN` is durable + WebAuthn identity, so changing it invalidates the registered passkey and every + enrolled Host. A DHCP-dependent name is a re-enrollment waiting to happen. + +Everything in "Definition of done" is then yours to reproduce by hand: no +`manage verify`, no release pointers, no rollback, and none of the `SECURITY.md` +`FAIL IF` lines `scripts/deploy-lint.mjs` holds the installers to. The +credential posture is the part to copy first — `config/` and `state/` reachable +only by the account running the server, the setup password only in `server.env` +— and the [Installer contract](#installer-contract-maintainers) below is the +list of what that account is being trusted with. + +The nearest thing to staying on the scripted path is a Tailscale-CLI-compatible +control plane such as Headscale, where the CLI, MagicDNS and `tailscale serve` +are unchanged and most of this file still applies. Confirm before committing to +it that the control plane issues the HTTPS certificate `tailscale serve` asks it +for; that leg is Tailscale's own control server in the shipped path, and it is +the one this file leans on hardest. + ## Installer contract (maintainers) The runbook above is the operator half; this section is the *spec* for the diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 7cc27fb6..99fe97fd 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -1,6 +1,6 @@ { "AGENTS.md": 2890, - "SELF_HOST.md": 7450, + "SELF_HOST.md": 8050, "docs/specs/alert.md": 7750, "docs/specs/alert.rationale.md": 950, "docs/specs/auto-update.md": 1500, From 1f87999d17f566d525eaecfa5580385734ac8e3b Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 2 Sep 2026 17:25:07 -0700 Subject: [PATCH 2/3] docs(self-host): tighten the front-door contract, retitle the file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleanup pass over the previous commit. The contract said `DORMOUSE_ORIGIN` takes any absolute URL — `requireOrigin` demands an `http(s)` scheme — and the intro's "three things Tailscale supplies" contradicted the section's "all five", so a reader provisioning from the intro shipped an all-interfaces bind. The loopback and durable-origin bullets were third copies of invariants the Installer contract and `SECURITY.md` already own; they collapse into the one thing that is new off the scripted path, which is that nothing holds them for you. Two silent failures were missing and are now stated: an origin over 103 characters fails at pairing-QR mint time, and a non-443 origin must carry its port in `DORMOUSE_REMOTE_CONNECT_SRC`, since `parseSource` defaults a portless source to 443 and matches on exact equality. The IP-literal rule cited `server/src/app.ts` as though the server refused one; it does not — it boots and answers, and the failure appears in the phone's browser at passkey registration. The Headscale paragraph recommended a control plane nobody here has run. It now states that it is untested and names the unknown, along with the fact that makes guessing expensive: Serve requests the certificate only after the release switch has landed. Retitled from "Run the Dormouse server behind Tailscale", which contradicted the new framing on the one line every reader sees. The body still opens on the tailnet origin, so the scripted path is established immediately. Nothing referenced the old title; both lints key off the path. Net -106 words, and the budget ratchets 8050 -> 7975 rather than holding the slack the first commit claimed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UabNVcotSFB6R9WJpCxjmk --- SELF_HOST.md | 97 ++++++++++++++++------------------ scripts/spec-word-budgets.json | 2 +- 2 files changed, 46 insertions(+), 53 deletions(-) diff --git a/SELF_HOST.md b/SELF_HOST.md index 43aae33c..81596f7b 100644 --- a/SELF_HOST.md +++ b/SELF_HOST.md @@ -1,4 +1,4 @@ -# Run the Dormouse server behind Tailscale +# Self-host the Dormouse server > This is an assistant-run setup playbook. Start a fresh Claude instance in > this repository and say: `read @SELF_HOST.md and walk me through it`. @@ -13,12 +13,11 @@ is the whole *scripted* self-host story today. To keep the relay up while that machine sleeps, run the same installer on an always-on tailnet box — see "Keeping the relay up while the laptop sleeps". -**Tailscale is a front door, not a dependency.** Nothing in the server or the -Host is Tailscale-aware — `DORMOUSE_ORIGIN` is any absolute URL and -`DORMOUSE_REMOTE_CONNECT_SRC` any source list. What Tailscale supplies is the -three things an installer would otherwise have to ask for: a stable DNS name, a -certificate the phone already trusts, and a reverse proxy in front of the -loopback server. Another private network supplying all three works, unassisted: +**Tailscale is the front door this runbook installs, not a dependency.** Nothing +in the server or the Host is Tailscale-aware; what it supplies is a stable DNS +name, a certificate the phone already trusts, and a reverse proxy in front of +the loopback server. Another private network can supply the same, under the +contract in [Bring your own private network](#bring-your-own-private-network). The installer already exists — one idempotent command that ships in this @@ -101,10 +100,10 @@ known: - **A tailnet.** The user needs a Tailscale account with MagicDNS and HTTPS certificates enabled, Tailscale running on this laptop, and Tailscale on the phone that will run Pocket. A tailnet-only origin is not reachable merely - because the laptop is on the tailnet. All three installers refuse to run - without the Tailscale CLI; on another private network, stop here and read - [Bring your own private network](#bring-your-own-private-network) with the - user rather than improvising around that refusal. + because the laptop is on the tailnet. All three installers refuse without the + Tailscale CLI; on another private network **never improvise around that + refusal** — read + [Bring your own private network](#bring-your-own-private-network) instead. - **macOS, Windows or Linux.** Each installer refuses to run on the other platforms. On a fourth OS, or on a Linux box without systemd, stop and design the native service manager with the user rather than translating LaunchAgent, @@ -145,8 +144,8 @@ known: `standalone/scripts/build-sidecar-proxy.mjs` and `vscode-ext/scripts/esbuild.mjs` bake that variable into their respective - Node Host bundles. The value is a source list rather than a Tailscale check, - so a non-tailnet origin goes in verbatim. The relay socket no longer lives in + Node Host bundles. A non-tailnet origin goes in whole, port included — a + source with no port matches 443 only. The relay socket no longer lives in either webview, so changing a webview CSP does not widen this allowlist. ## What the installer does @@ -683,48 +682,42 @@ install, and is not designed here. ## Bring your own private network -Every installer refuses without the Tailscale CLI. The server does not care: -point `DORMOUSE_ORIGIN` at any absolute URL, build the Host with a matching -`DORMOUSE_REMOTE_CONNECT_SRC`, and WireGuard, a corporate VPN, or a reverse -proxy on a home LAN all work. There is no script for it, so what follows is a -contract rather than a runbook — a front door must supply all five: - -- **A DNS name, never an IP address.** The WebAuthn `rpId` is the origin's - hostname (`server/src/app.ts`), and an IP literal is not a valid RP ID, so no - passkey can be registered against `https://10.0.0.4:8443` at all. A - non-default port is fine; the origin carries it and `rpId` does not. +A front door is configuration of the shipped server rather than a different +hosting model, so it needs no design: point `DORMOUSE_ORIGIN` at an `http(s)` +origin, build the Host with a matching `DORMOUSE_REMOTE_CONNECT_SRC`, and +WireGuard, a corporate VPN or a reverse proxy on a home LAN can front it. +Nothing checks any of the following for you. + +- **A DNS name, never an IP address, and at most 103 characters.** The WebAuthn + `rpId` is the origin's hostname, and an IP literal is not a valid RP ID. + Nothing refuses one: the server boots and answers, and the failure surfaces in + the phone's browser at passkey registration. The length cap is the pairing + QR's and does fail at mint time (`docs/specs/server.md` → Setup tokens and the + pairing QR). A non-default port is fine; the origin carries it, `rpId` does + not. - **A certificate the phone already trusts.** Passkeys need a secure context. - Publicly trusted means owning a domain and running ACME — usually DNS-01, - since the name resolves to a private address — which is a renewal credential - the Tailscale path never has to store. A private CA also works, but every - phone must install *and* fully trust the root before Pocket will load. -- **Reachability limited to that network, checked by you.** There is no generic - equivalent of the Funnel assertion, so a proxy that starts answering from the - internet does it silently. `SECURITY.md` → "The setup password — accepted - risk" is the analysis this premise carries; a network reaching further than a - tailnet is a materially different risk than the one analyzed there. -- **The server still bound to `127.0.0.1`.** The listen interface is the - boundary whenever the TLS proxy is local (`docs/specs/server.md` → - Configuration). A proxy in front of an all-interfaces bind is not a front - door, it is a second one. -- **An origin stable for the life of the passkey.** `DORMOUSE_ORIGIN` is durable - WebAuthn identity, so changing it invalidates the registered passkey and every - enrolled Host. A DHCP-dependent name is a re-enrollment waiting to happen. + Public trust means owning a domain and running ACME — usually DNS-01, since + the name resolves to a private address, so you hold a renewal credential the + Tailscale path never does. A private CA also works, but every phone must + install *and* fully trust the root before Pocket will load. +- **Reachability no wider than that network.** There is no generic equivalent of + the Funnel assertion, so a proxy that starts answering from the internet does + it silently — and `SECURITY.md` → "The setup password — accepted risk" is + premised on exactly that bound. +- **The invariants the installer would have held, held by you.** The loopback + bind and the durable origin are unchanged ([Installer contract](#invariants)), + but nothing pins the first, and nothing refuses to rewrite the second, so a + DHCP-dependent name is a re-enrollment waiting to happen. Everything in "Definition of done" is then yours to reproduce by hand: no -`manage verify`, no release pointers, no rollback, and none of the `SECURITY.md` -`FAIL IF` lines `scripts/deploy-lint.mjs` holds the installers to. The -credential posture is the part to copy first — `config/` and `state/` reachable -only by the account running the server, the setup password only in `server.env` -— and the [Installer contract](#installer-contract-maintainers) below is the -list of what that account is being trusted with. - -The nearest thing to staying on the scripted path is a Tailscale-CLI-compatible -control plane such as Headscale, where the CLI, MagicDNS and `tailscale serve` -are unchanged and most of this file still applies. Confirm before committing to -it that the control plane issues the HTTPS certificate `tailscale serve` asks it -for; that leg is Tailscale's own control server in the shipped path, and it is -the one this file leans on hardest. +`manage verify`, no release pointers, no rollback, and none of the `FAIL IF` +lines in `SECURITY.md` that `scripts/deploy-lint.mjs` enforces. The server still +does its own half — `state/` and every file in it (`SECURITY.md` → "Credentials +at rest") — leaving `config/` and the service registration to you. + +A Tailscale-CLI-compatible control plane such as Headscale is untested here. The +open question is whether it satisfies the certificate `tailscale serve` +requests, which Serve asks for only after the release switch has landed. ## Installer contract (maintainers) diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 99fe97fd..ff1000bb 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -1,6 +1,6 @@ { "AGENTS.md": 2890, - "SELF_HOST.md": 8050, + "SELF_HOST.md": 7975, "docs/specs/alert.md": 7750, "docs/specs/alert.rationale.md": 950, "docs/specs/auto-update.md": 1500, From af1b09a2be18fee619d2a79482192797659a6d46 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 2 Sep 2026 17:32:51 -0700 Subject: [PATCH 3/3] docs(self-host): fix the origin cap and the Windows state/ claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings, both verified against the code. The 103-character cap bounds `DORMOUSE_ORIGIN` whole, not the DNS name: `formatPairingInvitationUrl` builds `${origin}/#pair?${fragment}` over a fixed 146-character fragment, so the throw at 256 leaves 103 for scheme, host and port together. A 100-character name read as inside the budget under the old wording and mints a 108-character URL, and "a non-default port is fine" made it worse by implying the port was free. `state/` is not the server's own half everywhere: `state.ts`'s `0700`/`0600` are a no-op on Windows, where the installer's DACL was the only thing holding it — so on the BYO path a Windows operator owns `state/` too, holding a live `hostToken` and the VAPID private key. `run/` was missing from the same list. Budget 7975 -> 8000 rather than dropping the `run/` mention to fit; an unspent enrollment offer redeems for a Host enrollment, so it belongs in the list. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UabNVcotSFB6R9WJpCxjmk --- SELF_HOST.md | 23 +++++++++++++---------- scripts/spec-word-budgets.json | 2 +- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/SELF_HOST.md b/SELF_HOST.md index 81596f7b..54a98221 100644 --- a/SELF_HOST.md +++ b/SELF_HOST.md @@ -688,13 +688,14 @@ origin, build the Host with a matching `DORMOUSE_REMOTE_CONNECT_SRC`, and WireGuard, a corporate VPN or a reverse proxy on a home LAN can front it. Nothing checks any of the following for you. -- **A DNS name, never an IP address, and at most 103 characters.** The WebAuthn - `rpId` is the origin's hostname, and an IP literal is not a valid RP ID. - Nothing refuses one: the server boots and answers, and the failure surfaces in - the phone's browser at passkey registration. The length cap is the pairing - QR's and does fail at mint time (`docs/specs/server.md` → Setup tokens and the - pairing QR). A non-default port is fine; the origin carries it, `rpId` does - not. +- **A DNS name, never an IP address, and an origin of at most 103 characters.** + The WebAuthn `rpId` is the origin's hostname, and an IP literal is not a valid + RP ID. Nothing refuses one: the server boots and answers, and the failure + surfaces in the phone's browser at passkey registration. The length cap is the + pairing QR's and does fail at mint time (`docs/specs/server.md` → Setup tokens + and the pairing QR); it bounds the whole origin, so the scheme and any + non-default port come out of the same 103 — 95 characters of hostname under a + portless `https://`. `rpId` itself carries no port. - **A certificate the phone already trusts.** Passkeys need a secure context. Public trust means owning a domain and running ACME — usually DNS-01, since the name resolves to a private address, so you hold a renewal credential the @@ -711,9 +712,11 @@ Nothing checks any of the following for you. Everything in "Definition of done" is then yours to reproduce by hand: no `manage verify`, no release pointers, no rollback, and none of the `FAIL IF` -lines in `SECURITY.md` that `scripts/deploy-lint.mjs` enforces. The server still -does its own half — `state/` and every file in it (`SECURITY.md` → "Credentials -at rest") — leaving `config/` and the service registration to you. +lines in `SECURITY.md` that `scripts/deploy-lint.mjs` enforces. The server holds +`state/` itself on unix (`0700`/`0600`); on Windows those modes are a no-op and +the installer's DACL was the only thing holding it, so there `state/` is yours +too (`SECURITY.md` → "Credentials at rest"). `config/`, `run/` and the service +registration are always yours. A Tailscale-CLI-compatible control plane such as Headscale is untested here. The open question is whether it satisfies the certificate `tailscale serve` diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index ff1000bb..72212702 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -1,6 +1,6 @@ { "AGENTS.md": 2890, - "SELF_HOST.md": 7975, + "SELF_HOST.md": 8000, "docs/specs/alert.md": 7750, "docs/specs/alert.rationale.md": 950, "docs/specs/auto-update.md": 1500,