@serfab/cadre-host 0.7.1 → 0.8.1

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 (135) hide show
  1. package/README.md +296 -101
  2. package/dist/auth/trust-circle.d.ts +44 -6
  3. package/dist/auth/trust-circle.js +115 -58
  4. package/dist/auth/trust-circle.js.map +1 -1
  5. package/dist/auth/types.d.ts +4 -1
  6. package/dist/auth/types.js.map +1 -1
  7. package/dist/authority/authority-node-client.d.ts +77 -0
  8. package/dist/authority/authority-node-client.js +143 -0
  9. package/dist/authority/authority-node-client.js.map +1 -0
  10. package/dist/authority/index.d.ts +11 -0
  11. package/dist/authority/index.js +11 -0
  12. package/dist/authority/index.js.map +1 -0
  13. package/dist/bin/host.js +210 -97
  14. package/dist/bin/host.js.map +1 -1
  15. package/dist/index.d.ts +10 -6
  16. package/dist/index.js +6 -3
  17. package/dist/index.js.map +1 -1
  18. package/dist/installer/browser.js +16 -17
  19. package/dist/installer/browser.js.map +1 -1
  20. package/dist/installer/config.d.ts +25 -0
  21. package/dist/installer/config.js +1 -1
  22. package/dist/installer/config.js.map +1 -1
  23. package/dist/installer/index.d.ts +5 -2
  24. package/dist/installer/index.js +14 -10
  25. package/dist/installer/index.js.map +1 -1
  26. package/dist/installer/service-host/launchd.js +1 -1
  27. package/dist/installer/service-host/launchd.js.map +1 -1
  28. package/dist/installer/service-host/systemd.js +1 -1
  29. package/dist/installer/service-host/systemd.js.map +1 -1
  30. package/dist/nat/index.d.ts +1 -1
  31. package/dist/nat/nat-service.d.ts +45 -3
  32. package/dist/nat/nat-service.js +116 -3
  33. package/dist/nat/nat-service.js.map +1 -1
  34. package/dist/nat/secrets/index.d.ts +7 -0
  35. package/dist/nat/secrets/index.js +28 -2
  36. package/dist/nat/secrets/index.js.map +1 -1
  37. package/dist/nat/types.d.ts +3 -1
  38. package/dist/nat/types.js.map +1 -1
  39. package/dist/orchestrator/host-process-orchestrator.d.ts +60 -1
  40. package/dist/orchestrator/host-process-orchestrator.js +246 -39
  41. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  42. package/dist/orchestrator/index.d.ts +3 -2
  43. package/dist/orchestrator/index.js +1 -1
  44. package/dist/orchestrator/index.js.map +1 -1
  45. package/dist/orchestrator/state-store.d.ts +6 -5
  46. package/dist/orchestrator/state-store.js +10 -2
  47. package/dist/orchestrator/state-store.js.map +1 -1
  48. package/dist/orchestrator/types.d.ts +45 -10
  49. package/dist/orchestrator/types.js.map +1 -1
  50. package/dist/push/index.d.ts +67 -0
  51. package/dist/push/index.js +101 -0
  52. package/dist/push/index.js.map +1 -0
  53. package/dist/server/error-handler.d.ts +2 -0
  54. package/dist/server/error-handler.js +4 -0
  55. package/dist/server/error-handler.js.map +1 -1
  56. package/dist/server/routes/nodes.d.ts +4 -3
  57. package/dist/server/routes/nodes.js +38 -24
  58. package/dist/server/routes/nodes.js.map +1 -1
  59. package/dist/server/static.d.ts +12 -0
  60. package/dist/server/static.js +24 -7
  61. package/dist/server/static.js.map +1 -1
  62. package/dist/ui/assets/index-BlMXQPSw.js +14 -0
  63. package/dist/ui/assets/{index-C9uChk2P.js.map → index-BlMXQPSw.js.map} +1 -1
  64. package/dist/ui/index.html +1 -1
  65. package/dist/update/apply.d.ts +23 -1
  66. package/dist/update/apply.js +46 -4
  67. package/dist/update/apply.js.map +1 -1
  68. package/dist/update/index.d.ts +14 -3
  69. package/dist/update/index.js +35 -2
  70. package/dist/update/index.js.map +1 -1
  71. package/dist/update/manifest.d.ts +28 -9
  72. package/dist/update/manifest.js +56 -29
  73. package/dist/update/manifest.js.map +1 -1
  74. package/dist/update/release-key.d.ts +18 -7
  75. package/dist/update/release-key.js +28 -15
  76. package/dist/update/release-key.js.map +1 -1
  77. package/dist/update/sign.d.ts +46 -0
  78. package/dist/update/sign.js +53 -0
  79. package/dist/update/sign.js.map +1 -0
  80. package/package.json +9 -4
  81. package/service/README.md +95 -86
  82. package/src/__tests__/cli-invite.smoke.test.ts +6 -1
  83. package/src/__tests__/cli-nat.smoke.test.ts +6 -1
  84. package/src/__tests__/cli.smoke.test.ts +6 -1
  85. package/src/__tests__/host-authority.smoke.test.ts +123 -0
  86. package/src/__tests__/orchestrator-authority.test.ts +198 -0
  87. package/src/__tests__/orchestrator-push.test.ts +145 -0
  88. package/src/auth/__tests__/trust-circle-integration.test.ts +40 -21
  89. package/src/auth/__tests__/trust-circle.test.ts +90 -23
  90. package/src/auth/trust-circle.ts +363 -304
  91. package/src/auth/types.ts +4 -1
  92. package/src/authority/__tests__/authority-node-client.test.ts +166 -0
  93. package/src/authority/authority-node-client.ts +199 -0
  94. package/src/authority/index.ts +12 -0
  95. package/src/bin/host.ts +1038 -905
  96. package/src/index.ts +34 -3
  97. package/src/installer/__tests__/installer.smoke.test.ts +64 -0
  98. package/src/installer/browser.ts +40 -42
  99. package/src/installer/config.ts +27 -1
  100. package/src/installer/index.ts +18 -12
  101. package/src/installer/service-host/launchd.ts +1 -1
  102. package/src/installer/service-host/systemd.ts +1 -1
  103. package/src/nat/__tests__/ddns-updater.test.ts +1 -1
  104. package/src/nat/__tests__/nat-service.test.ts +192 -0
  105. package/src/nat/__tests__/port-mapper.test.ts +1 -1
  106. package/src/nat/__tests__/secrets.test.ts +23 -1
  107. package/src/nat/index.ts +1 -1
  108. package/src/nat/nat-service.ts +131 -5
  109. package/src/nat/secrets/index.ts +32 -2
  110. package/src/nat/types.ts +3 -1
  111. package/src/orchestrator/host-process-orchestrator.ts +294 -54
  112. package/src/orchestrator/index.ts +9 -2
  113. package/src/orchestrator/state-store.ts +21 -3
  114. package/src/orchestrator/types.ts +48 -2
  115. package/src/push/__tests__/push.test.ts +90 -0
  116. package/src/push/index.ts +146 -0
  117. package/src/server/__tests__/error-handler.test.ts +4 -4
  118. package/src/server/__tests__/nodes-route.test.ts +90 -10
  119. package/src/server/__tests__/publishers.test.ts +1 -1
  120. package/src/server/__tests__/static.test.ts +145 -0
  121. package/src/server/__tests__/status-route.test.ts +2 -2
  122. package/src/server/error-handler.ts +4 -0
  123. package/src/server/routes/nodes.ts +48 -26
  124. package/src/server/static.ts +24 -8
  125. package/src/update/__tests__/apply.test.ts +58 -1
  126. package/src/update/__tests__/manifest.test.ts +41 -6
  127. package/src/update/__tests__/release-key.test.ts +29 -0
  128. package/src/update/__tests__/sign.test.ts +78 -0
  129. package/src/update/__tests__/update-service.integration.test.ts +44 -9
  130. package/src/update/apply.ts +46 -4
  131. package/src/update/index.ts +41 -3
  132. package/src/update/manifest.ts +214 -172
  133. package/src/update/release-key.ts +30 -16
  134. package/src/update/sign.ts +74 -0
  135. package/dist/ui/assets/index-C9uChk2P.js +0 -14
package/README.md CHANGED
@@ -1,101 +1,296 @@
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
- ```bash
10
- npm install -g @serfab/cadre-host
11
- cadre-host install
12
- ```
13
-
14
- The wizard:
15
-
16
- 1. Prompts for the data directory, UI port, libp2p port, and UPnP toggle (defaults shown in `[...]`).
17
- 2. Generates a fresh Ed25519 node identity (`<dataDir>/identity.key`, mode 600 on POSIX).
18
- 3. Writes `<dataDir>/host.config.json` and seeds `<dataDir>/nat.json` with the chosen libp2p port.
19
- 4. Registers a system service: `systemctl --user` unit (Linux), `LaunchAgent` (macOS), or NSSM service (Windows; requires `nssm.exe` on PATH — see `service/README.md`).
20
- 5. Opens `http://127.0.0.1:<uiPort>/` in your browser.
21
- 6. Issues a 24-hour enrollment invite and prints it as both a QR code and a paste-friendly token.
22
-
23
- Run `cadre-host install --non-interactive --data-dir <path>` for unattended provisioning.
24
-
25
- ### Per-user vs system install
26
-
27
- v1 supports per-user installs only. `cadre-host install --system` is accepted by the CLI but errors out; running cadre-host as a dedicated system user requires data-dir ownership work that's tracked separately.
28
-
29
- ### Service-host details
30
-
31
- The rendered unit files live at:
32
-
33
- | Platform | Path |
34
- | -------- | ---- |
35
- | Linux | `~/.config/systemd/user/cadre-host.service` |
36
- | macOS | `~/Library/LaunchAgents/com.serfab.cadre-host.plist` |
37
- | Windows | NSSM-managed service `CadreHost` (registry-stored config) |
38
-
39
- See [`service/README.md`](./service/README.md) for templates, manual-smoke instructions, and the cross-platform CI gap.
40
-
41
- ## Uninstall
42
-
43
- ```bash
44
- cadre-host uninstall # stop + deregister, keep data
45
- cadre-host uninstall --remove-data --yes
46
- ```
47
-
48
- ## CLI subcommands once running
49
-
50
- - `cadre-host invite <label>` issues a trust-circle invite via the running management API. See [docs/cadre-host.md](../../docs/cadre-host.md#trust-circle).
51
- - `cadre-host trust list` and `cadre-host trust revoke <token-or-peerId>` round out trust-circle management.
52
- - `cadre-host nat status`, `cadre-host nat test`, `cadre-host nat ddns set duckdns --hostname <h> --token <t>`, `cadre-host nat ddns external --hostname <h>`, `cadre-host nat settings [--external-port N] [--no-upnp]` manage NAT / DDNS. See [docs/cadre-host.md](../../docs/cadre-host.md#nat-and-ddns).
53
- - `cadre-host ui` prints the local-UI URL and opens it in the default browser. Reads the configured port from `host.config.json`; doesn't require the cadre-host service to be running (if it isn't, the browser will fail to connect — that's obvious feedback).
54
- - `HostProcessOrchestrator` runs cadre nodes as native child processes.
55
-
56
- ## What `cadre-host start` does today
57
-
58
- `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:
59
-
60
- - `/auth/*` (trust circle), `/nat/*` (NAT/DDNS), `/update/*` (update flow) — these match the CLI's contract.
61
- - `/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.
62
- - `/` — 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`).
63
-
64
- 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).
65
-
66
- ## Updates
67
-
68
- 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`).
69
-
70
- The manifest URL is overridable two ways:
71
- - `CADRE_HOST_UPDATE_MANIFEST_URL` env var (wins over config).
72
- - `updates.manifestUrl` in `host.config.json` (settable from the local UI).
73
-
74
- 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.
75
-
76
- **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.
77
-
78
- 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.
79
-
80
- ## Local UI
81
-
82
- `cadre-host start` serves a Svelte 5 SPA at `http://127.0.0.1:<uiPort>/`. Five pages cover the day-to-day operations:
83
-
84
- - **Home / Status** — green/yellow/red dot, service version + uptime, trust-circle size, connectivity at a glance, "update available" banner.
85
- - **Trust Circle** — list members, invite a friend (modal generates a paste-friendly token + QR), revoke pending invites or remove members.
86
- - **Connectivity** — port-forwarding status, "Test reachability", DDNS provider configuration, manual port-forward instructions when UPnP isn't working.
87
- - **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.
88
- - **Settings** — update preferences (autoApply toggle, manifest URL override), install metadata (install ID, data dir, ports), uninstall pointer.
89
-
90
- 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.
91
-
92
- ### Building the SPA
93
-
94
- `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.
95
-
96
- 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`).
97
-
98
- ## More
99
-
100
- - [docs/cadre-host.md](../../docs/cadre-host.md) — persona, package boundary, deployment model, security posture.
101
- - [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 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,4 +1,4 @@
1
- import type { CadreInvite, ControlDatabase } from '@serfab/cadre-core';
1
+ import type { CadreInvite } from '@serfab/cadre-core';
2
2
  import type { TrustCircleStore } from './trust-circle-store.js';
3
3
  import type { TrustCircleHandlers, TrustCircleSnapshot } from './types.js';
4
4
  /** Default invite lifetime: 24 hours. */
@@ -28,19 +28,35 @@ export interface CadreNodeLike {
28
28
  }, issuedInvite?: CadreInvite): Promise<void>;
29
29
  removePeer(peerId: string): Promise<void>;
30
30
  encodeInvite(invite: CadreInvite): string;
31
- getControlDatabase(): ControlDatabase | null;
31
+ /** Enumerate the cadre's `CadrePeer` membership (over the admin channel). */
32
+ listMembers(): Promise<Array<{
33
+ peerId: string;
34
+ multiaddr: string | null;
35
+ }>>;
36
+ /** Probe whether a peer is a `CadrePeer` member (over the admin channel). */
37
+ isMember(peerId: string): Promise<boolean>;
32
38
  }
33
39
  /**
34
40
  * TrustCircleService — orchestrates invite issuance, redemption, and
35
41
  * membership management.
36
42
  *
37
- * Membership is canonical in the cadre control DB (`CadrePeer` table);
38
- * labels and pending tokens live in the host-local `TrustCircleStore`.
43
+ * Membership is canonical in the cadre control DB (`CadrePeer` table),
44
+ * reached over the authority node's admin channel; labels and pending tokens
45
+ * live in the host-local `TrustCircleStore`. When the node is unreachable,
46
+ * authority operations surface `node_unavailable` (→ 503) and `list()`
47
+ * degrades to the local labels file.
39
48
  */
40
49
  export declare class TrustCircleService {
41
50
  private readonly cadreNode;
42
51
  private readonly store;
43
52
  private readonly now;
53
+ /**
54
+ * In-flight redemption claims (by token). Serialises concurrent redeems
55
+ * for the same token without burning the durable pending row before
56
+ * `acceptPhone` succeeds. Intra-process only — the orchestrator enforces
57
+ * single-process ownership of a given rootDir, so no file lock is needed.
58
+ */
59
+ private readonly inFlightRedemptions;
44
60
  constructor(opts: TrustCircleServiceOptions);
45
61
  /**
46
62
  * Issue a new invite. Generates a token, persists a pending row, and
@@ -60,6 +76,20 @@ export declare class TrustCircleService {
60
76
  * - cadre-core authorizes the peer (inserts CadrePeer row).
61
77
  * - The pending row is consumed (one-time enforcement).
62
78
  * - A labelled member row is written locally.
79
+ *
80
+ * Ordering: the synchronous *claim* against `inFlightRedemptions` serialises
81
+ * concurrent redeems for the same token (second concurrent caller rejects
82
+ * synchronously before its first await). The durable `removePending` only
83
+ * runs *after* `acceptPhone` succeeds — so a transient `node_unavailable`
84
+ * leaves the token re-redeemable rather than permanently burning it.
85
+ *
86
+ * Crash-safety tradeoff: if the host crashes between `acceptPhone` success
87
+ * and the durable `removePending`, a retry will dial in fine but the
88
+ * authority node's `acceptPhone` will reject the second `CadrePeer` insert
89
+ * with a PK constraint error (cadre-core does not upsert). That surfaces to
90
+ * the redeemer as a non-`node_unavailable` failure; the admin can revoke
91
+ * the lingering pending row. This is a narrower window than the prior
92
+ * behaviour, where *any* transient node outage burned the token.
63
93
  */
64
94
  redeemInvite(opts: {
65
95
  token: string;
@@ -78,10 +108,18 @@ export declare class TrustCircleService {
78
108
  /** Whether the peer is present in the cadre's CadrePeer table. */
79
109
  isMember(peerId: string): Promise<boolean>;
80
110
  /**
81
- * UI snapshot. Joins the canonical CadrePeer list with local labels;
82
- * prunes any orphan labels (peer is no longer authorised).
111
+ * UI snapshot. Joins the canonical CadrePeer list (fetched over the admin
112
+ * channel) with local labels; prunes any orphan labels (peer is no longer
113
+ * authorised). When the authority node is unreachable, degrades to the
114
+ * local labels file so listing keeps working while the node is down.
83
115
  */
84
116
  list(): Promise<TrustCircleSnapshot>;
117
+ /**
118
+ * Translate an admin-channel transport failure into a `node_unavailable`
119
+ * TrustCircleError (→ 503). Re-throws anything else unchanged. Returns
120
+ * `never` so callers can `.catch(err => this.toDomainError(err))`.
121
+ */
122
+ private toDomainError;
85
123
  }
86
124
  /**
87
125
  * Wrap a TrustCircleService into the typed handler shape consumed by