@serfab/cadre-host 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +406 -296
  2. package/dist/bin/host.d.ts +11 -7
  3. package/dist/bin/host.js +40 -12
  4. package/dist/bin/host.js.map +1 -1
  5. package/dist/installer/config.d.ts +4 -4
  6. package/dist/installer/config.js +5 -39
  7. package/dist/installer/config.js.map +1 -1
  8. package/dist/installer/identity.d.ts +8 -1
  9. package/dist/installer/identity.js +8 -1
  10. package/dist/installer/identity.js.map +1 -1
  11. package/dist/orchestrator/host-process-orchestrator.d.ts +2 -2
  12. package/dist/orchestrator/host-process-orchestrator.js +14 -4
  13. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  14. package/dist/server/routes/bootstrap-node-validation.d.ts +96 -0
  15. package/dist/server/routes/bootstrap-node-validation.js +178 -0
  16. package/dist/server/routes/bootstrap-node-validation.js.map +1 -0
  17. package/dist/server/routes/grants.js +8 -25
  18. package/dist/server/routes/grants.js.map +1 -1
  19. package/dist/server/routes/provision-request-validation.d.ts +45 -0
  20. package/dist/server/routes/provision-request-validation.js +97 -0
  21. package/dist/server/routes/provision-request-validation.js.map +1 -0
  22. package/dist/server/settings-store.d.ts +1 -1
  23. package/dist/server/settings-store.js +5 -1
  24. package/dist/server/settings-store.js.map +1 -1
  25. package/package.json +5 -4
  26. package/src/__tests__/cli.smoke.test.ts +8 -3
  27. package/src/__tests__/global-setup.ts +9 -4
  28. package/src/__tests__/orchestrator-node-identity.test.ts +204 -204
  29. package/src/__tests__/orchestrator.test.ts +951 -951
  30. package/src/bin/__tests__/cli-reference.test.ts +167 -0
  31. package/src/bin/host.ts +1326 -1299
  32. package/src/donation/__tests__/donation-service.test.ts +1068 -1068
  33. package/src/donation/__tests__/donation-supervisor.test.ts +510 -510
  34. package/src/donation/__tests__/fake-orchestrator.test.ts +168 -168
  35. package/src/installer/__tests__/config.test.ts +134 -112
  36. package/src/installer/config.ts +5 -43
  37. package/src/installer/identity.ts +8 -1
  38. package/src/nat/__tests__/nat-service.test.ts +515 -497
  39. package/src/orchestrator/host-process-orchestrator.ts +1143 -1133
  40. package/src/server/__tests__/bootstrap-node-validation.test.ts +134 -0
  41. package/src/server/__tests__/grants-route.test.ts +326 -263
  42. package/src/server/__tests__/settings-route.test.ts +8 -7
  43. package/src/server/routes/bootstrap-node-validation.ts +187 -0
  44. package/src/server/routes/grants.ts +9 -26
  45. package/src/server/routes/provision-request-validation.ts +105 -0
  46. package/src/server/settings-store.ts +41 -37
package/README.md CHANGED
@@ -1,296 +1,406 @@
1
- # @serfab/cadre-host
2
-
3
- Self-hosted cadre node manager for basement-PC deployments. Runs one always-on host machine, manages cadre nodes for a small trust circle (family, friends, hobby group), exposes a localhost web UI, and handles NAT/DDNS so members behind residential connections can still be reached.
4
-
5
- The sibling of [`@serfab/cadre-provider`](../cadre-provider/README.md): the provider is a multi-tenant hosting service with API keys, billing, and Docker; cadre-host is a single-household manager with trust-circle auth, native child processes, and an installer.
6
-
7
- ## Install
8
-
9
- cadre-host installs entirely within your user account. **No step requires root**, with one optional exception (`loginctl enable-linger`) called out under *Root requirements* below.
10
-
11
- Pick whichever install style suits you:
12
-
13
- ### Global install (binary on PATH)
14
-
15
- ```bash
16
- npm install -g @serfab/cadre-host # sudo only if your npm prefix is system-owned (e.g. /usr/lib)
17
- cadre-host install
18
- ```
19
-
20
- ### Local install (self-contained — recommended if you want everything under one folder)
21
-
22
- ```bash
23
- mkdir -p ~/cadre && cd ~/cadre
24
- npm install @serfab/cadre-host
25
- npx cadre-host install --data-dir ~/cadre/data
26
- ```
27
-
28
- From inside `~/cadre`, `npx cadre-host <command>` runs the local binary (`invite`, `trust`, `nat`, `uninstall`, …) without any path prefix — `npx` resolves it from `node_modules/.bin/`. **Caveat:** outside `~/cadre`, `npx cadre-host` won't find the local install and will silently download a fresh copy from the npm registry. To avoid that, either always `cd ~/cadre` first or symlink the binary onto your PATH:
29
-
30
- ```bash
31
- ln -s ~/cadre/node_modules/.bin/cadre-host ~/.local/bin/cadre-host
32
- # now `cadre-host invite …` works from anywhere
33
- ```
34
-
35
- If you'd rather skip `npx` and the symlink, the explicit path `./node_modules/.bin/cadre-host <command>` always works from `~/cadre` too.
36
-
37
- The wizard (run either way):
38
-
39
- 1. Prompts for the data directory, UI port, libp2p port, and UPnP toggle (defaults shown in `[...]`).
40
- 2. Generates a fresh Ed25519 node identity (`<dataDir>/identity.key`, mode 600 on POSIX).
41
- 3. Writes `<dataDir>/host.config.json` and seeds `<dataDir>/nat.json` with the chosen libp2p port.
42
- 4. Registers a per-user service: `systemctl --user` unit (Linux), `LaunchAgent` (macOS), or NSSM service (Windows; requires `nssm.exe` on PATH — see `service/README.md`).
43
- 5. Opens `http://127.0.0.1:<uiPort>/` in your browser.
44
- 6. Issues a 24-hour enrollment invite and prints it as both a QR code and a paste-friendly token.
45
-
46
- Run `cadre-host install --non-interactive --data-dir <path>` for unattended provisioning.
47
-
48
- ### Root requirements
49
-
50
- The installer never writes outside your home directory. On Linux the unit goes to `$XDG_CONFIG_HOME/systemd/user/cadre-host.service` (default `~/.config/systemd/user/cadre-host.service`); the data dir defaults to `$XDG_DATA_HOME/cadre-host` (default `~/.local/share/cadre-host`) or wherever you point `--data-dir`. Registering a `systemctl --user` unit does **not** require root — any user can do it.
51
-
52
- There is **one optional** root command:
53
-
54
- ```bash
55
- sudo loginctl enable-linger <your-user>
56
- ```
57
-
58
- `enable-linger` is what allows your `systemctl --user` services to start at boot and keep running after you log out. Without linger, cadre-host runs only while you have an active session (desktop login or SSH) and stops when that session ends. With linger, it runs whenever the machine is on.
59
-
60
- The installer attempts `loginctl enable-linger` for you and logs a warning if it can't (you aren't root, `loginctl` is missing, running inside a container, …). **The install succeeds either way** — you can enable linger separately, before or after `cadre-host install`. Without linger you can still verify everything by logging in and running `systemctl --user start cadre-host` by hand.
61
-
62
- ### System-wide install (not yet supported)
63
-
64
- `cadre-host install --system` is accepted by the CLI but currently errors out. A proper system-wide install — dedicated `cadre` user, unit at `/etc/systemd/system/`, install under `/opt/cadre/...` — needs additional work around data-dir ownership and capability dropping and is tracked separately.
65
-
66
- ### Service-host details
67
-
68
- The rendered unit files live at:
69
-
70
- | Platform | Path |
71
- | -------- | ---- |
72
- | Linux | `~/.config/systemd/user/cadre-host.service` |
73
- | macOS | `~/Library/LaunchAgents/com.serfab.cadre-host.plist` |
74
- | Windows | NSSM-managed service `CadreHost` (registry-stored config) |
75
-
76
- See [`service/README.md`](./service/README.md) for templates, manual-smoke instructions, and the cross-platform CI gap.
77
-
78
- ## After install — getting your first user running
79
-
80
- `cadre-host install` leaves you with a running management service, a local UI, and **no cadre nodes yet**. In v1, cadre-host does not pre-spawn nodes; each one materializes when a person you trust enrolls a device against an invite you issue. This walkthrough goes from "install just finished" to "first member is enrolled."
81
-
82
- ### 1. Verify the service is running
83
-
84
- ```bash
85
- systemctl --user status cadre-host
86
- ```
87
-
88
- Look for `Active: active (running)`. If it's not active, `journalctl --user -u cadre-host -f` shows the live log.
89
-
90
- ### 2. Open the local UI
91
-
92
- The UI listens on `http://127.0.0.1:<uiPort>/` (default port 8765) on the host machine itself.
93
-
94
- - **On the host:** open the URL in a browser, or run `cadre-host ui` to print + open it.
95
- - **From a different machine on your LAN** (most basement PCs are headless): the UI is bound to loopback only and has no authentication — that's intentional, the trust boundary is "same machine, same user as cadre-host." Use SSH port-forwarding from the client side:
96
-
97
- ```bash
98
- # from your laptop:
99
- ssh -L 8765:127.0.0.1:8765 user@my-basement-pc
100
- # then open http://127.0.0.1:8765 in the laptop's browser
101
- ```
102
-
103
- The forward stays up as long as the SSH session does.
104
-
105
- ### 3. Issue your first invite
106
-
107
- Decide whose cadre you want to host first — yourself (from your phone), a family member, or a friend. You'll deliver the invite to that device out-of-band (in person, Signal, etc.).
108
-
109
- ```bash
110
- cadre-host invite "<label>"
111
- ```
112
-
113
- `<label>` is a human-readable name **you** choose to identify this device or person in your member list. It's purely for your own bookkeeping — the system doesn't use it for anything. Quote it if it has spaces. Examples:
114
-
115
- ```bash
116
- cadre-host invite "Mom's phone"
117
- cadre-host invite mylaptop
118
- cadre-host invite "Friend — tablet"
119
- ```
120
-
121
- The command prints two things — a terminal-rendered QR code and an encoded invite string — both representing the same single-use token. The recipient can use either form. By default the token expires in 24 hours; override with `--ttl 7d` (or `30m`, `1h`, etc.).
122
-
123
- Hand the QR or token to the device's owner in person if possible. **Anyone who gets the token can claim the cadre identity it grants**, so treat it like a one-time password.
124
-
125
- Terminology aside: cadre-host's **trust circle** is just the cryptographically-authenticated list of identities allowed to have a cadre node on this host. An invite adds one new identity to that list.
126
-
127
- ### 4. The invitee redeems the invite
128
-
129
- On the invitee's device, they install a cadre-aware app — the React Native reference app at [`@serfab/reference-app-rn`](../reference-app-rn), or any sApp built on `@serfab/cadre-core` — and scan or paste the invite. The app dials your cadre-host over libp2p (NAT/DDNS permitting; see step 6) and redeems the token. At that moment:
130
-
131
- - A child cadre node spawns inside your cadre-host process for this member's identity.
132
- - The member appears in `cadre-host trust list` and on the Trust Circle and Nodes pages of the UI.
133
- - The invitee's device can now use that cadre to participate in strands (shared SQL databases) and connect to other members.
134
-
135
- Until someone redeems an invite, the Nodes page in the UI stays empty — that's expected.
136
-
137
- ### 5. Manage members
138
-
139
- ```bash
140
- $ cadre-host trust list
141
- Members:
142
- 12D3KooWHcdEf... kyle's laptop [self]
143
- 12D3KooW2aB... Mom's phone
144
-
145
- Pending invites:
146
- invite_a3f8... Friend — tablet (expires 2026-06-01T12:00:00Z)
147
- ```
148
-
149
- A **peerId** is a libp2p identity string — for the common Ed25519 case it starts with `12D3Koo...` (you'll also see `16Uiu2HAm...` for secp256k1 or `Qm...` for legacy RSA). Each member has exactly one.
150
-
151
- Remove a pending invite or evict an existing member:
152
-
153
- ```bash
154
- cadre-host trust revoke <token-or-peerId>
155
- ```
156
-
157
- cadre-host auto-detects whether the argument is a pending-invite token or a member peerId. Force interpretation with `--kind invite` or `--kind member` if it ever guesses wrong.
158
-
159
- ### 6. Reachability — can people actually reach you?
160
-
161
- After install (and any time your network changes):
162
-
163
- ```bash
164
- cadre-host nat status # current external IP, port-mapping state, reachability
165
- cadre-host nat test # re-run the reachability probe right now
166
- ```
167
-
168
- If `Reachability` shows anything other than `direct`, peers will fall back to libp2p relays — slower but still functional. If UPnP didn't work on your router, the UI's **Connectivity** page has manual port-forwarding instructions.
169
-
170
- When your residential IP changes (it will), members can't find you on the old IP. DDNS (a hostname that auto-updates to your current IP) fixes this:
171
-
172
- ```bash
173
- # DuckDNS — cadre-host updates the record itself. Sign up at duckdns.org and grab a token first.
174
- cadre-host nat ddns set duckdns --hostname mybox.duckdns.org --token <duckdns-token>
175
-
176
- # Externally-managed — your router or another tool updates DNS; cadre-host just records the hostname.
177
- cadre-host nat ddns external --hostname mybox.example.com
178
- ```
179
-
180
- If you use the DuckDNS form, the token is stored in the OS keychain when `libsecret` is installed (`sudo apt install libsecret-1-0` on Debian/Ubuntu), and unencrypted in `<dataDir>/nat-secrets.json` otherwise. The service logs a warning at startup when it falls back to unencrypted storage.
181
-
182
- ## CLI reference
183
-
184
- All commands except `install`, `uninstall`, `start`, and `ui` talk to the running cadre-host management API over loopback. They print a connection error if the service isn't running.
185
-
186
- ### `cadre-host status`
187
-
188
- Print whether the service is registered and currently active.
189
-
190
- ```
191
- $ cadre-host status
192
- Service installed: yes
193
- Service running: yes
194
- ```
195
-
196
- ### `cadre-host ui [--no-browser]`
197
-
198
- Print the local-UI URL (e.g. `http://127.0.0.1:8765`) and open it in the default browser. Reads `uiPort` from `host.config.json`; doesn't require the service to be running (if not, the browser will fail to connect — that's feedback enough). Pass `--no-browser` to just print the URL.
199
-
200
- ### `cadre-host invite <label> [--ttl <duration>]`
201
-
202
- Generate a single-use invite token to add a member to your trust circle. `<label>` is whatever human-readable name helps you track the invitee — quote it if it has spaces. `--ttl` defaults to `24h`; accepts `s`/`m`/`h`/`d` suffixes (e.g. `7d`, `30m`). Prints a QR code and the encoded invite string.
203
-
204
- ### `cadre-host trust list`
205
-
206
- Print all current trust-circle members (with peerIds) and pending invites (with tokens and expiry).
207
-
208
- ### `cadre-host trust revoke <token-or-peerId> [--kind invite|member|auto]`
209
-
210
- Revoke a pending invite (by token) or evict an existing member (by peerId). `--kind` defaults to `auto`, which decides based on whether the argument looks like a peerId.
211
-
212
- ### `cadre-host nat status [--json]`
213
-
214
- Print current NAT state — port-mapping mode, external IP, CGNAT detection, direct-reachability result, DDNS configuration. With `--json`, dumps the raw response from the management API.
215
-
216
- ### `cadre-host nat test [--json]`
217
-
218
- Re-run the reachability probe right now and print the updated NAT state. Useful after changing port-forwarding rules on your router.
219
-
220
- ### `cadre-host nat settings [--external-port N] [--internal-port N] [--no-upnp]`
221
-
222
- Adjust NAT settings. Pass only the flag(s) you want to change.
223
-
224
- ### `cadre-host nat ddns set <provider> --hostname <h> [--token <t>]`
225
-
226
- Configure cadre-host to push DNS updates itself. Currently the only `<provider>` is `duckdns`. If `--token` is omitted, the command prompts for it (with echo suppressed) when stdin is a TTY. The token is stored in the OS keychain when available, otherwise unencrypted at `<dataDir>/nat-secrets.json` (a startup warning surfaces this).
227
-
228
- ### `cadre-host nat ddns external --hostname <h>`
229
-
230
- Tell cadre-host that some other tool (your router firmware, a separate `ddclient`, etc.) is updating DNS, and to record the hostname for publishing to peers without trying to update it itself.
231
-
232
- ### `cadre-host start [--data-dir <path>] [--no-tui]`
233
-
234
- Run cadre-host in the foreground. Normally invoked by the systemd unit, not directly. `--data-dir` overrides the install-time data directory (also honors `$CADRE_HOST_DATA_DIR`).
235
-
236
- ### `cadre-host install [...flags]`
237
-
238
- Run the first-run wizard. See [**Install**](#install) at the top of this README.
239
-
240
- ### `cadre-host uninstall [--remove-data] [--yes]`
241
-
242
- Stop and deregister the service. Preserves the data dir by default; pass `--remove-data --yes` to wipe identity, trust circle, NAT state, and update state too.
243
-
244
- ```bash
245
- cadre-host uninstall # stop + deregister, keep data
246
- cadre-host uninstall --remove-data --yes # also delete the data dir
247
- ```
248
-
249
- ## What `cadre-host start` does today
250
-
251
- `start` loads `host.config.json` + the identity, brings up the trust-circle / NAT / update services, and binds the Fastify management server on `127.0.0.1:<uiPort>` (loopback only). Routes:
252
-
253
- - `/auth/*` (trust circle), `/nat/*` (NAT/DDNS), `/update/*` (update flow) — these match the CLI's contract.
254
- - `/api/status`, `/api/nodes`, `/api/nodes/:id/{logs,stop,start,restart}`, `/api/settings`, `/api/events` (Server-Sent Events) — the local-UI surface consumed by the Svelte SPA.
255
- - `/` — the SPA bundle (or a placeholder HTML when running from source before the SPA is built — see `6.5.2-cadre-host-local-ui-spa`).
256
-
257
- If the configured `uiPort` is in use the server tries `uiPort+1..uiPort+9`; on total failure it exits with a message listing every port attempted. An origin guard rejects requests whose `Host` or `Origin` is not `127.0.0.1[:port]` / `localhost[:port]` (defeats DNS-rebind from a malicious page). There is no login — the security model is "same machine as the cadre-host user" (see threat model below).
258
-
259
- ## Updates
260
-
261
- cadre-host checks `https://releases.serfab.io/cadre-host/latest.json` once per `start` and every 24 hours thereafter. **Notify-by-default**: an available update is recorded in `<dataDir>/update-state.json` and surfaced by the local UI; the user clicks "apply" to install it. Auto-apply is opt-in via the local-UI settings page (writes `updates.autoApply: true` into `host.config.json`).
262
-
263
- The manifest URL is overridable two ways:
264
- - `CADRE_HOST_UPDATE_MANIFEST_URL` env var (wins over config).
265
- - `updates.manifestUrl` in `host.config.json` (settable from the local UI).
266
-
267
- Manifests are signed with Ed25519; cadre-host refuses to apply any release whose signature doesn't match the embedded release key. For CI / dev signing, set `CADRE_HOST_UPDATE_DEV_KEY` to a base64-encoded raw 32-byte public key.
268
-
269
- **Threat model.** Any local process running as the cadre-host user can fully control cadre-host (read identity, mutate trust circle, install arbitrary global packages). Signature verification protects against a compromised release CDN — it is **not** a defense against local-machine compromise. Treat the host like any other long-running service: limit who can run shells as that user, keep the OS patched, and rely on the trust-circle membership model for inter-cadre auth.
270
-
271
- Apply flow: re-fetch + re-verify the manifest, record `applyInProgress`, run `npm install -g @serfab/cadre-host@<version>` (5-minute timeout), and restart the OS service unit so the new binary takes effect. On install failure, the previous version is reinstalled and the error is surfaced via `update-state.json` — the still-running binary continues to serve. The service-host restart is best-effort; if it fails, the binary swap already succeeded and the user can restart manually.
272
-
273
- ## Local UI
274
-
275
- `cadre-host start` serves a Svelte 5 SPA at `http://127.0.0.1:<uiPort>/`. **Local-only by design:** the server binds to loopback (`127.0.0.1`) only and rejects requests whose `Host` or `Origin` header is not a loopback hostname, so the UI is unreachable from your LAN even though it has no login. To use it from another machine, SSH-port-forward as shown in [*After install*, step 2](#2-open-the-local-ui).
276
-
277
- Five pages cover the day-to-day operations:
278
-
279
- - **Home / Status** — green/yellow/red dot, service version + uptime, trust-circle size, connectivity at a glance, "update available" banner.
280
- - **Trust Circle** — list members, invite a friend (modal generates a paste-friendly token + QR), revoke pending invites or remove members.
281
- - **Connectivity** — port-forwarding status, "Test reachability", DDNS provider configuration, manual port-forward instructions when UPnP isn't working.
282
- - **Nodes** — per-managed-node detail, recent stats, log tail (last 200 lines, "Refresh" pulls again), start/stop/restart. `cadre-host` v1 doesn't auto-spawn nodes, so this list is usually empty until a trust-circle member completes enrollment.
283
- - **Settings** — update preferences (autoApply toggle, manifest URL override), install metadata (install ID, data dir, ports), uninstall pointer.
284
-
285
- The SPA opens an `EventSource` against `/api/events` and re-fetches the relevant slice when a node state changes, the trust circle changes, connectivity changes, or an update is announced. No login — the page is bound to loopback only, with an Origin/Host guard for DNS-rebind defence. See the threat-model note in the *Updates* section above and in [docs/cadre-host.md](../../docs/cadre-host.md) for the full security posture.
286
-
287
- ### Building the SPA
288
-
289
- `yarn workspace @serfab/cadre-host build` compiles both the server (TypeScript) and the SPA (`vite build` against `ui/`). The bundle lands in `dist/ui/` and is served by the same Fastify instance that handles `/api/*` and friends. When `dist/ui/` is missing (e.g. running from source without building), the server still answers all API routes and shows a placeholder at `/` explaining how to build.
290
-
291
- For UI-only iteration: `yarn workspace @serfab/cadre-host dev:ui` starts Vite on `:5173` and proxies `/api`, `/auth`, `/nat`, `/update` to `127.0.0.1:8765` (override with `CADRE_HOST_PORT`).
292
-
293
- ## More
294
-
295
- - [docs/cadre-host.md](../../docs/cadre-host.md) — persona, package boundary, deployment model, security posture.
296
- - [docs/architecture.md](../../docs/architecture.md) — overall cadre architecture.
1
+ # @serfab/cadre-host
2
+
3
+ Self-hosted cadre node manager for basement-PC deployments. Runs one always-on machine whose primary job is to **donate cadre nodes to other people's cadres**: a friend or family member keeps their own device as the authority for their cadre, and your box contributes always-on capacity by running an extra node that joins *theirs*. It exposes a localhost web UI to manage that, and can optionally also run a personal cadre of your own.
4
+
5
+ The sibling of [`@serfab/cadre-provider`](../cadre-provider/README.md): the provider donates nodes to paying tenants with API keys, billing, and Docker; cadre-host donates them for free to a handful of people you trust, as native child processes, with a one-shot installer.
6
+
7
+ cadre-host has **two independent roles**, and only the first is on by default:
8
+
9
+ - **Node donor (primary, always on).** Contribute nodes to cadres *other people* own. Who may ask is gated by **grant tokens** you hand out. Needs no cadre of your own.
10
+ - **Founder (opt-in — `ownCadre.enabled` in `host.config.json`, default `false`).** *Also* run your own personal cadre on this machine. This is what turns on the trust circle (`/auth/*`) and the NAT/DDNS layer (`/nat/*`); both are unmounted and 404 until you enable it.
11
+
12
+ [docs/cadre-host.md](../../docs/cadre-host.md) is the design source of truth for both roles.
13
+
14
+ ## Install
15
+
16
+ cadre-host installs entirely within your user account. **No step requires root**, with one optional exception (`loginctl enable-linger`) called out under *Root requirements* below.
17
+
18
+ Pick whichever install style suits you:
19
+
20
+ ### Global install (binary on PATH)
21
+
22
+ ```bash
23
+ npm install -g @serfab/cadre-host # sudo only if your npm prefix is system-owned (e.g. /usr/lib)
24
+ cadre-host install
25
+ ```
26
+
27
+ ### Local install (self-contained — recommended if you want everything under one folder)
28
+
29
+ ```bash
30
+ mkdir -p ~/cadre && cd ~/cadre
31
+ npm install @serfab/cadre-host
32
+ npx cadre-host install --data-dir ~/cadre/data
33
+ ```
34
+
35
+ From inside `~/cadre`, `npx cadre-host <command>` runs the local binary (`grant`, `invite`, `nat`, `uninstall`, …) without any path prefix — `npx` resolves it from `node_modules/.bin/`. **Caveat:** outside `~/cadre`, `npx cadre-host` won't find the local install and will silently download a fresh copy from the npm registry. To avoid that, either always `cd ~/cadre` first or symlink the binary onto your PATH:
36
+
37
+ ```bash
38
+ ln -s ~/cadre/node_modules/.bin/cadre-host ~/.local/bin/cadre-host
39
+ # now `cadre-host grant issue …` works from anywhere
40
+ ```
41
+
42
+ If you'd rather skip `npx` and the symlink, the explicit path `./node_modules/.bin/cadre-host <command>` always works from `~/cadre` too.
43
+
44
+ The wizard (run either way):
45
+
46
+ 1. Prompts for the data directory, UI port, libp2p port, UPnP toggle, and whether to **also run your own personal cadre on this machine** — the opt-in founder role, default **no** (defaults shown in `[...]`).
47
+ 2. Generates a fresh Ed25519 node identity (`<dataDir>/identity.key`, mode 600 on POSIX).
48
+ 3. Writes `<dataDir>/host.config.json` and seeds `<dataDir>/nat.json` with the chosen libp2p port.
49
+ 4. Registers a per-user service: `systemctl --user` unit (Linux), `LaunchAgent` (macOS), or NSSM service (Windows; requires `nssm.exe` on PATH — see `service/README.md`).
50
+ 5. Opens `http://127.0.0.1:<uiPort>/` in your browser.
51
+ 6. **Founder installs only, and only when interactive:** issues a 24-hour enrollment invite for your own cadre and prints it as both a QR code and a paste-friendly token. A donor-only install has no cadre of its own to enroll into, so this step is silently skipped — you hand out a grant token instead (see [*After install*](#after-install--donating-your-first-node)).
52
+
53
+ Run `cadre-host install --non-interactive --data-dir <path>` for unattended provisioning; add `--own-cadre` to enable the founder role without prompting.
54
+
55
+ ### Root requirements
56
+
57
+ The installer never writes outside your home directory. On Linux the unit goes to `$XDG_CONFIG_HOME/systemd/user/cadre-host.service` (default `~/.config/systemd/user/cadre-host.service`); the data dir defaults to `$XDG_DATA_HOME/cadre-host` (default `~/.local/share/cadre-host`) or wherever you point `--data-dir`. Registering a `systemctl --user` unit does **not** require root — any user can do it.
58
+
59
+ There is **one optional** root command:
60
+
61
+ ```bash
62
+ sudo loginctl enable-linger <your-user>
63
+ ```
64
+
65
+ `enable-linger` is what allows your `systemctl --user` services to start at boot and keep running after you log out. Without linger, cadre-host runs only while you have an active session (desktop login or SSH) and stops when that session ends. With linger, it runs whenever the machine is on.
66
+
67
+ The installer attempts `loginctl enable-linger` for you and logs a warning if it can't (you aren't root, `loginctl` is missing, running inside a container, …). **The install succeeds either way** — you can enable linger separately, before or after `cadre-host install`. Without linger you can still verify everything by logging in and running `systemctl --user start cadre-host` by hand.
68
+
69
+ ### System-wide install (not yet supported)
70
+
71
+ `cadre-host install --system` is accepted by the CLI but currently errors out. A proper system-wide install — dedicated `cadre` user, unit at `/etc/systemd/system/`, install under `/opt/cadre/...` — needs additional work around data-dir ownership and capability dropping and is tracked separately.
72
+
73
+ ### Service-host details
74
+
75
+ The rendered unit files live at:
76
+
77
+ | Platform | Path |
78
+ | -------- | ---- |
79
+ | Linux | `~/.config/systemd/user/cadre-host.service` |
80
+ | macOS | `~/Library/LaunchAgents/com.serfab.cadre-host.plist` |
81
+ | Windows | NSSM-managed service `CadreHost` (registry-stored config) |
82
+
83
+ See [`service/README.md`](./service/README.md) for templates, manual-smoke instructions, and the cross-platform CI gap.
84
+
85
+ ## After install — donating your first node
86
+
87
+ `cadre-host install` leaves you with a running management service, a local UI, and **no cadre nodes yet**. cadre-host never pre-spawns nodes; each one materializes when someone you trust asks for one. This walkthrough goes from "install just finished" to "first donated node is running."
88
+
89
+ It assumes the **default install — donor-only**, i.e. you answered *no* to the wizard's *"Also run your own personal cadre on this machine?"*. In that mode this host's whole job is to lend always-on capacity to *other people's* cadres: your friend's phone stays the authority for their cadre, and your box runs an extra node that joins **theirs**. Your host never holds their owner key and never becomes the authority for their data. See [docs/cadre-host.md § Node donation](../../docs/cadre-host.md#node-donation-the-primary-role) for the full lifecycle.
90
+
91
+ If you answered *yes*, everything below still applies — you additionally get the opt-in **founder** surfaces, covered in [the founder section](#the-founder-role--running-your-own-cadre-here-opt-in) after step 5.
92
+
93
+ ### 1. Verify the service is running
94
+
95
+ ```bash
96
+ systemctl --user status cadre-host
97
+ ```
98
+
99
+ Look for `Active: active (running)`. If it's not active, `journalctl --user -u cadre-host -f` shows the live log.
100
+
101
+ ### 2. Open the local UI
102
+
103
+ The UI listens on `http://127.0.0.1:<uiPort>/` (default port 8765) on the host machine itself.
104
+
105
+ - **On the host:** open the URL in a browser, or run `cadre-host ui` to print + open it.
106
+ - **From a different machine on your LAN** (most basement PCs are headless): the UI is bound to loopback only and has no authentication — that's intentional, the trust boundary is "same machine, same user as cadre-host." Use SSH port-forwarding from the client side:
107
+
108
+ ```bash
109
+ # from your laptop:
110
+ ssh -L 8765:127.0.0.1:8765 user@my-basement-pc
111
+ # then open http://127.0.0.1:8765 in the laptop's browser
112
+ ```
113
+
114
+ The forward stays up as long as the SSH session does.
115
+
116
+ ### 3. Issue your first grant token
117
+
118
+ Decide whose cadre you want to help keep online — a family member, a friend, or your own phone. That person already has (or is about to create) **their own** cadre; you are donating capacity to it, not enrolling them into anything of yours.
119
+
120
+ Before anyone can ask, you issue them a **grant token**: a bearer credential meaning "this person may ask my host to donate nodes."
121
+
122
+ ```bash
123
+ cadre-host grant issue "<label>"
124
+ ```
125
+
126
+ `<label>` is a human-readable name **you** choose to identify this grantee in your grant list. It's purely for your own bookkeeping — the system doesn't use it for anything. Quote it if it has spaces. Examples:
127
+
128
+ ```bash
129
+ cadre-host grant issue "Mom's cadre"
130
+ cadre-host grant issue alice
131
+ cadre-host grant issue "Friend — hobby group"
132
+ ```
133
+
134
+ The command prints two things — a terminal-rendered QR code and the encoded token — both representing the same secret. The recipient can use either form. `--max-nodes N` caps how many donated nodes that grantee may keep running here at once (default 1); `--ttl 30d` gives the grant an expiry (`s`/`m`/`h`/`d` suffixes) — omit it and the grant never expires; `--no-qr` prints only the token.
135
+
136
+ Hand the QR or token to the grantee in person if possible. **Anyone who gets the token can claim what it grants — the right to make your machine run nodes for them** — so treat it like a password. One difference from a trust-circle invite: a grant is **not** one-time. It stays spendable, up to `--max-nodes` at a time, until it expires or you revoke it.
137
+
138
+ Terminology aside: your **trust circle** in the everyday sense is the handful of people you have handed a grant token to — the people allowed to have a node donated to them here. Don't confuse it with the *feature* named trust circle (`cadre-host trust`, the `/auth/*` routes, the UI's Trust Circle page): that one is membership in the host's **own** cadre and exists only in the founder role.
139
+
140
+ ### 4. The grantee requests a node
141
+
142
+ The grantee's cadre authority — typically their phone — presents the grant token as `Authorization: Bearer <grant-token>` and drives the donation lifecycle against your host:
143
+
144
+ 1. `POST /grants` with their party id, bootstrap addresses, and owner public key(s) → your host spawns a child cadre node that pins **their** owner key and joins **their** cadre. Each bootstrap address must be a full multiaddr naming both where to reach the peer and who it is (`/dns4/…/tcp/443/wss/p2p/12D3KooW…`); anything the donated node could not dial is refused as `400 invalid_request` naming the bad entry, before anything is spawned.
145
+ 2. `GET /grants/:id/peer` → the new node's peerId and multiaddrs.
146
+ 3. Their device signs a seed for that peer and `PUT /grants/:id/seed` hands it back; the node accepts it precisely because their owner key was pinned at spawn.
147
+ 4. `DELETE /grants/:id` when they're done — the node is stopped and removed.
148
+
149
+ At that moment:
150
+
151
+ - cadre-host spawns a child cadre-node process for this grantee's cadre (the manager itself never joins a cadre).
152
+ - The node appears on the Nodes page of the UI, and the grant it was spent against shows up in `cadre-host grant list`.
153
+ - The node stays up: if it crashes or dies in a reboot, cadre-host respawns it from the donation's recorded spawn inputs.
154
+
155
+ Until someone requests a node, the Nodes page in the UI stays empty — that's expected.
156
+
157
+ **What is not built yet (v1):** `/grants` mounts on the same loopback-only management server as everything else, so today a grantee can only reach it from *this machine* or through an SSH tunnel like the one in step 2. Letting a friend's phone reach it across the internet is deferred (`backlog/feat-cadre-host-wan-grant-reachability`), and no app drives the four calls above for you yet — it is raw HTTP today. Issuing grants and running donated nodes work now; the last hop from a remote phone does not.
158
+
159
+ ### 5. Manage grants
160
+
161
+ ```bash
162
+ $ cadre-host grant list
163
+ Grants:
164
+ Zx8kq1... Mom's cadre max=2
165
+ Ld93af... Friend — hobby group max=1 (expires 2026-09-01T12:00:00Z)
166
+ ```
167
+
168
+ Revoke a grant:
169
+
170
+ ```bash
171
+ cadre-host grant revoke <token>
172
+ ```
173
+
174
+ Revoking denies every future request on that token — including the grantee's own `DELETE /grants/:id`, which is refused (403) once the grant is revoked.
175
+
176
+ **Nodes already donated under it keep running, and there is no supported way to tear them down from the host side yet.** Stopping one from the UI's Nodes page does not stick: the respawn supervisor treats a live donation as "expected to be running" and brings it straight back. So if you want a node *gone*, ask the grantee to release it with `DELETE /grants/:id` **before** you revoke the grant. Tracked as `backlog/bug-cadre-host-donated-node-teardown-unavailable`.
177
+
178
+ ## The founder role — running your own cadre here (opt-in)
179
+
180
+ Everything above needs no cadre of your own. If you *also* want this machine to run your **own** personal cadre — your devices, your trust circle, your data — that is the **founder** role. It is opt-in: answer *yes* to the wizard's *"Also run your own personal cadre on this machine?"*, or install with `cadre-host install --own-cadre`. It is stored as `ownCadre.enabled` in `<dataDir>/host.config.json` (default **false**) and is install-time only — to change it later, edit that file and restart the service.
181
+
182
+ Until it is enabled, the founder-only surfaces are **not mounted** and return **404**:
183
+
184
+ | Surface | Commands | Local UI page |
185
+ | --- | --- | --- |
186
+ | `/auth/*` — trust circle | `cadre-host invite`, `cadre-host trust list`, `cadre-host trust revoke` | Trust Circle |
187
+ | `/nat/*` — NAT/DDNS | `cadre-host nat …` | Connectivity |
188
+ | `/api/strands` | — | Strands |
189
+
190
+ So if you followed the default install and `cadre-host invite` or `cadre-host nat status` reports a 404, nothing is broken — those belong to a role you didn't turn on. The UI still lists those three pages in its nav and they error when opened on a donor-only install; that's a known gap, not a misconfiguration.
191
+
192
+ The rest of this section applies **only** with the founder role enabled.
193
+
194
+ ### Enrolling your own devices (founder role)
195
+
196
+ ```bash
197
+ cadre-host invite "<label>"
198
+ ```
199
+
200
+ Same bookkeeping-only `<label>` rule as a grant. The command prints a QR code and an encoded invite string — both representing the same single-use token. By default the token expires in 24 hours; override with `--ttl 7d` (or `30m`, `1h`, etc.).
201
+
202
+ Hand the QR or token to the device's owner in person if possible. **Anyone who gets the token can claim the cadre identity it grants**, so treat it like a one-time password.
203
+
204
+ On the invitee's device, they install a cadre-aware app — the React Native reference app at [`@serfab/reference-app-rn`](../reference-app-rn), or any sApp built on `@serfab/cadre-core` — and scan or paste the invite. The app dials your cadre-host over libp2p (NAT/DDNS permitting; see below) and redeems the token, which authorizes their peer in *your* cadre and lets them participate in its strands (shared SQL databases).
205
+
206
+ ```bash
207
+ $ cadre-host trust list
208
+ Members:
209
+ 12D3KooWHcdEf... kyle's laptop [self]
210
+ 12D3KooW2aB... Mom's phone
211
+
212
+ Pending invites:
213
+ invite_a3f8... Friend — tablet (expires 2026-06-01T12:00:00Z)
214
+ ```
215
+
216
+ A **peerId** is a libp2p identity string — for the common Ed25519 case it starts with `12D3Koo...` (you'll also see `16Uiu2HAm...` for secp256k1 or `Qm...` for legacy RSA). Each member has exactly one.
217
+
218
+ Remove a pending invite or evict an existing member:
219
+
220
+ ```bash
221
+ cadre-host trust revoke <token-or-peerId>
222
+ ```
223
+
224
+ cadre-host auto-detects whether the argument is a pending-invite token or a member peerId. Force interpretation with `--kind invite` or `--kind member` if it ever guesses wrong.
225
+
226
+ ### Reachability — can people actually reach you? (founder role)
227
+
228
+ Reachability work targets your **own** cadre's owner node. After enabling the founder role (and any time your network changes):
229
+
230
+ ```bash
231
+ cadre-host nat status # current external IP, port-mapping state, reachability
232
+ cadre-host nat test # re-run the reachability probe right now
233
+ ```
234
+
235
+ If `Reachability` shows anything other than `direct`, peers will fall back to libp2p relays — slower but still functional. If UPnP didn't work on your router, the UI's **Connectivity** page has manual port-forwarding instructions.
236
+
237
+ When your residential IP changes (it will), members can't find you on the old IP. DDNS (a hostname that auto-updates to your current IP) fixes this:
238
+
239
+ ```bash
240
+ # DuckDNS — cadre-host updates the record itself. Sign up at duckdns.org and grab a token first.
241
+ cadre-host nat ddns set duckdns --hostname mybox.duckdns.org --token <duckdns-token>
242
+
243
+ # Externally-managed — your router or another tool updates DNS; cadre-host just records the hostname.
244
+ cadre-host nat ddns external --hostname mybox.example.com
245
+ ```
246
+
247
+ If you use the DuckDNS form, the token is stored in the OS keychain when `libsecret` is installed (`sudo apt install libsecret-1-0` on Debian/Ubuntu), and unencrypted in `<dataDir>/nat-secrets.json` otherwise. The service logs a warning at startup when it falls back to unencrypted storage.
248
+
249
+ Donated nodes do **not** depend on any of this — they dial outward into the grantee's cadre. Per-donated-node WAN reachability is separate, deferred work (`backlog/feat-cadre-host-wan-grant-reachability`).
250
+
251
+ ## CLI reference
252
+
253
+ All commands except `install`, `uninstall`, `start`, `ui`, and the `push` group talk to the running cadre-host management API over loopback. They print a connection error if the service isn't running.
254
+
255
+ Commands marked **founder role only** additionally need `ownCadre.enabled`; on a donor-only install their routes are unmounted and the command reports a 404.
256
+
257
+ The `cadre-host push` group is the exception on both counts. It needs **no running service** — the commands write straight to the data dir's secret store and `host.config.json` — and it is **not** founder-role only, because donated nodes get push credentials too. Private keys land in the OS keychain when one is available, otherwise a plain-JSON fallback at `<dataDir>/nat-secrets.json` (mode `0600` on POSIX; **on Windows the permission bits don't apply, so any account on the machine can read it** — install keytar's native dependency to avoid that); the non-secret bits (APNs bundle id / sandbox toggle, cooldown, debounce) land in `host.config.json`. Credentials are re-resolved on every node spawn, so restart cadre-host to apply them immediately.
258
+
259
+ ### `cadre-host status`
260
+
261
+ Print whether the service is registered and currently active.
262
+
263
+ ```
264
+ $ cadre-host status
265
+ Service installed: yes
266
+ Service running: yes
267
+ ```
268
+
269
+ ### `cadre-host ui [--no-browser]`
270
+
271
+ Print the local-UI URL (e.g. `http://127.0.0.1:8765`) and open it in the default browser. Reads `uiPort` from `host.config.json`; doesn't require the service to be running (if not, the browser will fail to connect — that's feedback enough). Pass `--no-browser` to just print the URL.
272
+
273
+ ### `cadre-host grant issue <label> [--max-nodes N] [--ttl <duration>] [--no-qr]`
274
+
275
+ Issue a grant token — the credential that lets one person ask this host to donate cadre nodes into *their* cadre. `<label>` is whatever human-readable name helps you track the grantee — quote it if it has spaces. `--max-nodes` caps how many donated nodes that grantee may keep running here at once (default 1); `--ttl` gives the grant an expiry (`s`/`m`/`h`/`d` suffixes, e.g. `30d`) — omit it and the grant never expires. Prints a QR code and the encoded token; `--no-qr` prints the token only.
276
+
277
+ ### `cadre-host grant list`
278
+
279
+ Print every issued grant token with its label, node cap, expiry, and revoked state.
280
+
281
+ ### `cadre-host grant revoke <token>`
282
+
283
+ Revoke a grant token. Every future request presenting it is denied, the grantee's own `DELETE /grants/:id` included. **Nodes already donated under it keep running and cannot yet be torn down host-side** — see [*Manage grants*](#5-manage-grants).
284
+
285
+ ### `cadre-host invite <label> [--ttl <duration>]`
286
+
287
+ **Founder role only.** Generate a single-use invite token to add a member to your trust circle. `<label>` is whatever human-readable name helps you track the invitee — quote it if it has spaces. `--ttl` defaults to `24h`; accepts `s`/`m`/`h`/`d` suffixes (e.g. `7d`, `30m`). Prints a QR code and the encoded invite string.
288
+
289
+ ### `cadre-host trust list`
290
+
291
+ **Founder role only.** Print all current trust-circle members (with peerIds) and pending invites (with tokens and expiry).
292
+
293
+ ### `cadre-host trust revoke <token-or-peerId> [--kind invite|member|auto]`
294
+
295
+ **Founder role only.** Revoke a pending invite (by token) or evict an existing member (by peerId). `--kind` defaults to `auto`, which decides based on whether the argument looks like a peerId.
296
+
297
+ ### `cadre-host nat status [--json]`
298
+
299
+ **Founder role only** (as is every `cadre-host nat` subcommand — NAT/DDNS maps a port for the host's *own* owner node). Print current NAT state — port-mapping mode, external IP, CGNAT detection, direct-reachability result, DDNS configuration. With `--json`, dumps the raw response from the management API.
300
+
301
+ ### `cadre-host nat test [--json]`
302
+
303
+ Re-run the reachability probe right now and print the updated NAT state. Useful after changing port-forwarding rules on your router.
304
+
305
+ ### `cadre-host nat settings [--external-port N] [--internal-port N] [--no-upnp]`
306
+
307
+ Adjust NAT settings. Pass only the flag(s) you want to change.
308
+
309
+ ### `cadre-host nat ddns set <provider> --hostname <h> [--token <t>]`
310
+
311
+ Configure cadre-host to push DNS updates itself. Currently the only `<provider>` is `duckdns`. If `--token` is omitted, the command prompts for it (with echo suppressed) when stdin is a TTY. The token is stored in the OS keychain when available, otherwise unencrypted at `<dataDir>/nat-secrets.json` (a startup warning surfaces this).
312
+
313
+ ### `cadre-host nat ddns external --hostname <h>`
314
+
315
+ Tell cadre-host that some other tool (your router firmware, a separate `ddclient`, etc.) is updating DNS, and to record the hostname for publishing to peers without trying to update it itself.
316
+
317
+ ### `cadre-host push fcm --project-id <id> --client-email <email> [--private-key-file <path>] [--private-key <pem>] [--data-dir <path>]`
318
+
319
+ Store Firebase Cloud Messaging (Android) service-account credentials so the owner/storage node can wake suspended mobile apps. The three values come from the Firebase service-account JSON (`project_id`, `client_email`, `private_key`). Supply the key either as a file (`--private-key-file`, preferred) or inline (`--private-key`); with neither, the command exits with an error. See [docs/cadre-host.md § Push credentials](../../docs/cadre-host.md#push-credentials-fcmapns) for how to mint the credentials and how they reach the spawned node.
320
+
321
+ ### `cadre-host push apns --key-id <id> --team-id <id> --bundle-id <id> [--private-key-file <path>] [--private-key <pem>] [--production] [--data-dir <path>]`
322
+
323
+ Store Apple Push Notification service (iOS) auth-key credentials. `--key-id`/`--team-id` identify the `.p8` auth key downloaded from the Apple Developer portal; `--bundle-id` becomes the `apns-topic`. As with `push fcm`, pass the key via `--private-key-file` (preferred) or `--private-key`. Targets the **sandbox** APNs host by default — pass `--production` for an App Store build. A token minted for one host is rejected by the other, so this must match the build under test.
324
+
325
+ ### `cadre-host push options [--cooldown-ms <ms>] [--debounce-ms <ms>] [--data-dir <path>]`
326
+
327
+ Set the non-secret push tuning knobs: `--cooldown-ms` is the minimum gap between wakes for one (peer, strand) pair (anti-spam), `--debounce-ms` the per-strand burst-coalescing window. Pass only the flag(s) you want to change.
328
+
329
+ ### `cadre-host push clear <target> [--data-dir <path>]`
330
+
331
+ Remove stored push credentials. `<target>` is `fcm`, `apns`, or `all`. Clearing `apns` also drops the bundle id / sandbox toggle from `host.config.json`. With nothing configured, no `push` block is written into the spawned node's `cadre.json` and the node falls back to control-network push-wake only.
332
+
333
+ ### `cadre-host push status [--data-dir <path>]`
334
+
335
+ Print which push platforms are configured, the APNs bundle id and sandbox/production mode, and the current cooldown/debounce values. Never prints key material.
336
+
337
+ ### `cadre-host start [--data-dir <path>] [--no-tui]`
338
+
339
+ Run cadre-host in the foreground. Normally invoked by the systemd unit, not directly. `--data-dir` overrides the install-time data directory (also honors `$CADRE_HOST_DATA_DIR`).
340
+
341
+ ### `cadre-host install [...flags]`
342
+
343
+ Run the first-run wizard. See [**Install**](#install) at the top of this README.
344
+
345
+ ### `cadre-host uninstall [--remove-data] [--yes]`
346
+
347
+ Stop and deregister the service. Preserves the data dir by default; pass `--remove-data --yes` to wipe identity, issued grants, donated-node records, trust circle, NAT state, and update state too.
348
+
349
+ ```bash
350
+ cadre-host uninstall # stop + deregister, keep data
351
+ cadre-host uninstall --remove-data --yes # also delete the data dir
352
+ ```
353
+
354
+ ## What `cadre-host start` does today
355
+
356
+ `start` loads `host.config.json` + the identity, brings up the orchestrator, the donation grant layer, and the update service, and binds the Fastify management server on `127.0.0.1:<uiPort>` (loopback only). Only when `ownCadre.enabled` does it also spawn the host's own owner node and bring up the trust-circle and NAT services. Routes:
357
+
358
+ - `/grants-admin` (issue/list/revoke grants — no bearer; same-machine admin) and `/grants` (the bearer-gated surface a grantee drives to request, seed, and release a donated node) — the always-on donor surface.
359
+ - `/update/*` (update flow) — matches the CLI's contract.
360
+ - `/auth/*` (trust circle), `/nat/*` (NAT/DDNS), `/api/strands` — **founder role only**; left unmounted and 404 on a donor-only install.
361
+ - `/api/status`, `/api/nodes`, `/api/nodes/:id/{logs,stop,start,restart}`, `/api/settings`, `/api/events` (Server-Sent Events) — the local-UI surface consumed by the Svelte SPA.
362
+ - `/` — the SPA bundle (or a placeholder HTML when running from source before the SPA is built — see `6.5.2-cadre-host-local-ui-spa`).
363
+
364
+ If the configured `uiPort` is in use the server tries `uiPort+1..uiPort+9`; on total failure it exits with a message listing every port attempted. An origin guard rejects requests whose `Host` or `Origin` is not `127.0.0.1[:port]` / `localhost[:port]` (defeats DNS-rebind from a malicious page). There is no login — the security model is "same machine as the cadre-host user" (see threat model below).
365
+
366
+ ## Updates
367
+
368
+ cadre-host checks `https://releases.serfab.io/cadre-host/latest.json` once per `start` and every 24 hours thereafter. **Notify-by-default**: an available update is recorded in `<dataDir>/update-state.json` and surfaced by the local UI; the user clicks "apply" to install it. Auto-apply is opt-in via the local-UI settings page (writes `updates.autoApply: true` into `host.config.json`).
369
+
370
+ The manifest URL is overridable two ways:
371
+ - `CADRE_HOST_UPDATE_MANIFEST_URL` env var (wins over config).
372
+ - `updates.manifestUrl` in `host.config.json` (settable from the local UI).
373
+
374
+ Manifests are signed with Ed25519; cadre-host refuses to apply any release whose signature doesn't match the embedded release key. For CI / dev signing, set `CADRE_HOST_UPDATE_DEV_KEY` to a base64-encoded raw 32-byte public key.
375
+
376
+ **Threat model.** Any local process running as the cadre-host user can fully control cadre-host (read identity, issue or revoke grants, mutate the trust circle, install arbitrary global packages). Signature verification protects against a compromised release CDN — it is **not** a defense against local-machine compromise. Treat the host like any other long-running service: limit who can run shells as that user, keep the OS patched, and rely on grant tokens — plus, in the founder role, trust-circle membership — for inter-cadre auth.
377
+
378
+ Apply flow: re-fetch + re-verify the manifest, record `applyInProgress`, run `npm install -g @serfab/cadre-host@<version>` (5-minute timeout), and restart the OS service unit so the new binary takes effect. On install failure, the previous version is reinstalled and the error is surfaced via `update-state.json` — the still-running binary continues to serve. The service-host restart is best-effort; if it fails, the binary swap already succeeded and the user can restart manually.
379
+
380
+ ## Local UI
381
+
382
+ `cadre-host start` serves a Svelte 5 SPA at `http://127.0.0.1:<uiPort>/`. **Local-only by design:** the server binds to loopback (`127.0.0.1`) only and rejects requests whose `Host` or `Origin` header is not a loopback hostname, so the UI is unreachable from your LAN even though it has no login. To use it from another machine, SSH-port-forward as shown in [*After install*, step 2](#2-open-the-local-ui).
383
+
384
+ Six pages cover the day-to-day operations. Three of them belong to the opt-in founder role and are marked as such:
385
+
386
+ - **Home / Status** — green/yellow/red dot, service version + uptime, "update available" banner. Its trust-circle-size and connectivity tiles are fed by founder-only routes.
387
+ - **Nodes** — per-managed-node detail, recent stats, log tail (last 200 lines, "Refresh" pulls again), start/stop/restart. `cadre-host` v1 doesn't auto-spawn nodes, so this list is empty until a grantee requests a donated node (or, in the founder role, until your own owner node starts).
388
+ - **Settings** — update preferences (autoApply toggle, manifest URL override), install metadata (install ID, data dir, ports), uninstall pointer.
389
+ - **Trust Circle** *(founder role only)* — list members, invite a friend (modal generates a paste-friendly token + QR), revoke pending invites or remove members.
390
+ - **Connectivity** *(founder role only)* — port-forwarding status, "Test reachability", DDNS provider configuration, manual port-forward instructions when UPnP isn't working.
391
+ - **Strands** *(founder role only)* — the shared SQL databases your own cadre belongs to.
392
+
393
+ The SPA does not yet hide the founder-only pages on a donor-only install: they stay in the nav and error when opened, and Home's connectivity tile never resolves. There is no donor-side view of grants or donated nodes beyond the Nodes page. Tracked as `backlog/feat-cadre-host-donor-aware-ui`.
394
+
395
+ The SPA opens an `EventSource` against `/api/events` and re-fetches the relevant slice when a node state changes, the trust circle changes, connectivity changes, or an update is announced. No login — the page is bound to loopback only, with an Origin/Host guard for DNS-rebind defence. See the threat-model note in the *Updates* section above and in [docs/cadre-host.md](../../docs/cadre-host.md) for the full security posture.
396
+
397
+ ### Building the SPA
398
+
399
+ `yarn workspace @serfab/cadre-host build` compiles both the server (TypeScript) and the SPA (`vite build` against `ui/`). The bundle lands in `dist/ui/` and is served by the same Fastify instance that handles `/api/*` and friends. When `dist/ui/` is missing (e.g. running from source without building), the server still answers all API routes and shows a placeholder at `/` explaining how to build.
400
+
401
+ For UI-only iteration: `yarn workspace @serfab/cadre-host dev:ui` starts Vite on `:5173` and proxies `/api`, `/auth`, `/nat`, `/update` to `127.0.0.1:8765` (override with `CADRE_HOST_PORT`).
402
+
403
+ ## More
404
+
405
+ - [docs/cadre-host.md](../../docs/cadre-host.md) — persona, package boundary, deployment model, security posture.
406
+ - [docs/architecture.md](../../docs/architecture.md) — overall cadre architecture.