@serfab/cadre-host 0.7.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.
- package/README.md +101 -0
- package/dist/auth/duration.d.ts +1 -0
- package/dist/auth/duration.js +32 -0
- package/dist/auth/duration.js.map +1 -0
- package/dist/auth/index.d.ts +9 -0
- package/dist/auth/index.js +8 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/trust-circle-store.d.ts +26 -0
- package/dist/auth/trust-circle-store.js +129 -0
- package/dist/auth/trust-circle-store.js.map +1 -0
- package/dist/auth/trust-circle.d.ts +91 -0
- package/dist/auth/trust-circle.js +230 -0
- package/dist/auth/trust-circle.js.map +1 -0
- package/dist/auth/types.d.ts +81 -0
- package/dist/auth/types.js +24 -0
- package/dist/auth/types.js.map +1 -0
- package/dist/bin/host.d.ts +13 -0
- package/dist/bin/host.js +826 -0
- package/dist/bin/host.js.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/installer/browser.d.ts +14 -0
- package/dist/installer/browser.js +35 -0
- package/dist/installer/browser.js.map +1 -0
- package/dist/installer/config.d.ts +43 -0
- package/dist/installer/config.js +113 -0
- package/dist/installer/config.js.map +1 -0
- package/dist/installer/identity.d.ts +23 -0
- package/dist/installer/identity.js +36 -0
- package/dist/installer/identity.js.map +1 -0
- package/dist/installer/index.d.ts +83 -0
- package/dist/installer/index.js +204 -0
- package/dist/installer/index.js.map +1 -0
- package/dist/installer/paths.d.ts +33 -0
- package/dist/installer/paths.js +84 -0
- package/dist/installer/paths.js.map +1 -0
- package/dist/installer/platform.d.ts +9 -0
- package/dist/installer/platform.js +21 -0
- package/dist/installer/platform.js.map +1 -0
- package/dist/installer/service-host/index.d.ts +11 -0
- package/dist/installer/service-host/index.js +16 -0
- package/dist/installer/service-host/index.js.map +1 -0
- package/dist/installer/service-host/launchd.d.ts +16 -0
- package/dist/installer/service-host/launchd.js +79 -0
- package/dist/installer/service-host/launchd.js.map +1 -0
- package/dist/installer/service-host/nssm.d.ts +20 -0
- package/dist/installer/service-host/nssm.js +88 -0
- package/dist/installer/service-host/nssm.js.map +1 -0
- package/dist/installer/service-host/systemd.d.ts +16 -0
- package/dist/installer/service-host/systemd.js +78 -0
- package/dist/installer/service-host/systemd.js.map +1 -0
- package/dist/installer/service-host/template.d.ts +6 -0
- package/dist/installer/service-host/template.js +20 -0
- package/dist/installer/service-host/template.js.map +1 -0
- package/dist/installer/service-host/types.d.ts +40 -0
- package/dist/installer/service-host/types.js +6 -0
- package/dist/installer/service-host/types.js.map +1 -0
- package/dist/installer/wizard.d.ts +31 -0
- package/dist/installer/wizard.js +67 -0
- package/dist/installer/wizard.js.map +1 -0
- package/dist/nat/address-resolver.d.ts +22 -0
- package/dist/nat/address-resolver.js +31 -0
- package/dist/nat/address-resolver.js.map +1 -0
- package/dist/nat/ddns/duckdns.d.ts +12 -0
- package/dist/nat/ddns/duckdns.js +57 -0
- package/dist/nat/ddns/duckdns.js.map +1 -0
- package/dist/nat/ddns/index.d.ts +30 -0
- package/dist/nat/ddns/index.js +21 -0
- package/dist/nat/ddns/index.js.map +1 -0
- package/dist/nat/ddns/updater.d.ts +68 -0
- package/dist/nat/ddns/updater.js +194 -0
- package/dist/nat/ddns/updater.js.map +1 -0
- package/dist/nat/external-ip.d.ts +62 -0
- package/dist/nat/external-ip.js +114 -0
- package/dist/nat/external-ip.js.map +1 -0
- package/dist/nat/index.d.ts +22 -0
- package/dist/nat/index.js +16 -0
- package/dist/nat/index.js.map +1 -0
- package/dist/nat/nat-service.d.ts +101 -0
- package/dist/nat/nat-service.js +284 -0
- package/dist/nat/nat-service.js.map +1 -0
- package/dist/nat/nat-store.d.ts +19 -0
- package/dist/nat/nat-store.js +136 -0
- package/dist/nat/nat-store.js.map +1 -0
- package/dist/nat/port-mapper.d.ts +98 -0
- package/dist/nat/port-mapper.js +226 -0
- package/dist/nat/port-mapper.js.map +1 -0
- package/dist/nat/reachability.d.ts +19 -0
- package/dist/nat/reachability.js +29 -0
- package/dist/nat/reachability.js.map +1 -0
- package/dist/nat/secrets/file-store.d.ts +23 -0
- package/dist/nat/secrets/file-store.js +102 -0
- package/dist/nat/secrets/file-store.js.map +1 -0
- package/dist/nat/secrets/index.d.ts +36 -0
- package/dist/nat/secrets/index.js +56 -0
- package/dist/nat/secrets/index.js.map +1 -0
- package/dist/nat/secrets/keytar-store.d.ts +18 -0
- package/dist/nat/secrets/keytar-store.js +52 -0
- package/dist/nat/secrets/keytar-store.js.map +1 -0
- package/dist/nat/types.d.ts +120 -0
- package/dist/nat/types.js +24 -0
- package/dist/nat/types.js.map +1 -0
- package/dist/orchestrator/host-process-orchestrator.d.ts +87 -0
- package/dist/orchestrator/host-process-orchestrator.js +557 -0
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -0
- package/dist/orchestrator/index.d.ts +3 -0
- package/dist/orchestrator/index.js +2 -0
- package/dist/orchestrator/index.js.map +1 -0
- package/dist/orchestrator/log-rotator.d.ts +25 -0
- package/dist/orchestrator/log-rotator.js +56 -0
- package/dist/orchestrator/log-rotator.js.map +1 -0
- package/dist/orchestrator/pid-liveness.d.ts +11 -0
- package/dist/orchestrator/pid-liveness.js +26 -0
- package/dist/orchestrator/pid-liveness.js.map +1 -0
- package/dist/orchestrator/port-allocator.d.ts +16 -0
- package/dist/orchestrator/port-allocator.js +38 -0
- package/dist/orchestrator/port-allocator.js.map +1 -0
- package/dist/orchestrator/state-store.d.ts +32 -0
- package/dist/orchestrator/state-store.js +44 -0
- package/dist/orchestrator/state-store.js.map +1 -0
- package/dist/orchestrator/types.d.ts +85 -0
- package/dist/orchestrator/types.js +31 -0
- package/dist/orchestrator/types.js.map +1 -0
- package/dist/server/error-handler.d.ts +26 -0
- package/dist/server/error-handler.js +95 -0
- package/dist/server/error-handler.js.map +1 -0
- package/dist/server/events/bus.d.ts +17 -0
- package/dist/server/events/bus.js +36 -0
- package/dist/server/events/bus.js.map +1 -0
- package/dist/server/events/sse-route.d.ts +22 -0
- package/dist/server/events/sse-route.js +65 -0
- package/dist/server/events/sse-route.js.map +1 -0
- package/dist/server/events/types.d.ts +25 -0
- package/dist/server/events/types.js +8 -0
- package/dist/server/events/types.js.map +1 -0
- package/dist/server/index.d.ts +67 -0
- package/dist/server/index.js +149 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/origin-guard.d.ts +24 -0
- package/dist/server/origin-guard.js +72 -0
- package/dist/server/origin-guard.js.map +1 -0
- package/dist/server/routes/nat.d.ts +16 -0
- package/dist/server/routes/nat.js +42 -0
- package/dist/server/routes/nat.js.map +1 -0
- package/dist/server/routes/nodes.d.ts +23 -0
- package/dist/server/routes/nodes.js +128 -0
- package/dist/server/routes/nodes.js.map +1 -0
- package/dist/server/routes/settings.d.ts +26 -0
- package/dist/server/routes/settings.js +97 -0
- package/dist/server/routes/settings.js.map +1 -0
- package/dist/server/routes/status.d.ts +42 -0
- package/dist/server/routes/status.js +76 -0
- package/dist/server/routes/status.js.map +1 -0
- package/dist/server/routes/trust-circle.d.ts +17 -0
- package/dist/server/routes/trust-circle.js +35 -0
- package/dist/server/routes/trust-circle.js.map +1 -0
- package/dist/server/routes/update.d.ts +16 -0
- package/dist/server/routes/update.js +29 -0
- package/dist/server/routes/update.js.map +1 -0
- package/dist/server/server.d.ts +40 -0
- package/dist/server/server.js +90 -0
- package/dist/server/server.js.map +1 -0
- package/dist/server/settings-store.d.ts +24 -0
- package/dist/server/settings-store.js +27 -0
- package/dist/server/settings-store.js.map +1 -0
- package/dist/server/static.d.ts +25 -0
- package/dist/server/static.js +177 -0
- package/dist/server/static.js.map +1 -0
- package/dist/ui/assets/index-BwU5mJGF.css +1 -0
- package/dist/ui/assets/index-C9uChk2P.js +14 -0
- package/dist/ui/assets/index-C9uChk2P.js.map +1 -0
- package/dist/ui/index.html +14 -0
- package/dist/update/apply.d.ts +37 -0
- package/dist/update/apply.js +102 -0
- package/dist/update/apply.js.map +1 -0
- package/dist/update/index.d.ts +101 -0
- package/dist/update/index.js +228 -0
- package/dist/update/index.js.map +1 -0
- package/dist/update/manifest.d.ts +48 -0
- package/dist/update/manifest.js +152 -0
- package/dist/update/manifest.js.map +1 -0
- package/dist/update/release-key.d.ts +24 -0
- package/dist/update/release-key.js +52 -0
- package/dist/update/release-key.js.map +1 -0
- package/dist/update/store.d.ts +22 -0
- package/dist/update/store.js +81 -0
- package/dist/update/store.js.map +1 -0
- package/dist/update/types.d.ts +90 -0
- package/dist/update/types.js +22 -0
- package/dist/update/types.js.map +1 -0
- package/dist/update/version.d.ts +18 -0
- package/dist/update/version.js +76 -0
- package/dist/update/version.js.map +1 -0
- package/package.json +80 -0
- package/service/README.md +86 -0
- package/service/cadre-host.service.tmpl +16 -0
- package/service/com.serfab.cadre-host.plist.tmpl +35 -0
- package/service/install-service.ps1 +98 -0
- package/service/uninstall-service.ps1 +19 -0
- package/src/__tests__/cli-invite.smoke.test.ts +187 -0
- package/src/__tests__/cli-nat.smoke.test.ts +207 -0
- package/src/__tests__/cli.smoke.test.ts +41 -0
- package/src/__tests__/orchestrator.test.ts +570 -0
- package/src/auth/__tests__/duration.test.ts +28 -0
- package/src/auth/__tests__/trust-circle-integration.test.ts +128 -0
- package/src/auth/__tests__/trust-circle-store.test.ts +115 -0
- package/src/auth/__tests__/trust-circle.test.ts +334 -0
- package/src/auth/duration.ts +33 -0
- package/src/auth/index.ts +19 -0
- package/src/auth/trust-circle-store.ts +155 -0
- package/src/auth/trust-circle.ts +304 -0
- package/src/auth/types.ts +96 -0
- package/src/bin/host.ts +905 -0
- package/src/index.ts +167 -0
- package/src/installer/__tests__/config.test.ts +86 -0
- package/src/installer/__tests__/identity.test.ts +34 -0
- package/src/installer/__tests__/installer.smoke.test.ts +124 -0
- package/src/installer/__tests__/paths.test.ts +47 -0
- package/src/installer/__tests__/service-host.test.ts +62 -0
- package/src/installer/__tests__/wizard.test.ts +60 -0
- package/src/installer/browser.ts +42 -0
- package/src/installer/config.ts +153 -0
- package/src/installer/identity.ts +49 -0
- package/src/installer/index.ts +303 -0
- package/src/installer/paths.ts +109 -0
- package/src/installer/platform.ts +27 -0
- package/src/installer/service-host/index.ts +21 -0
- package/src/installer/service-host/launchd.ts +90 -0
- package/src/installer/service-host/nssm.ts +100 -0
- package/src/installer/service-host/systemd.ts +88 -0
- package/src/installer/service-host/template.ts +21 -0
- package/src/installer/service-host/types.ts +48 -0
- package/src/installer/wizard.ts +92 -0
- package/src/nat/__tests__/address-resolver.test.ts +82 -0
- package/src/nat/__tests__/ddns-duckdns.test.ts +83 -0
- package/src/nat/__tests__/ddns-updater.test.ts +253 -0
- package/src/nat/__tests__/external-ip.test.ts +124 -0
- package/src/nat/__tests__/nat-service.test.ts +305 -0
- package/src/nat/__tests__/nat-store.test.ts +93 -0
- package/src/nat/__tests__/port-mapper.test.ts +191 -0
- package/src/nat/__tests__/reachability.test.ts +24 -0
- package/src/nat/__tests__/secrets.test.ts +156 -0
- package/src/nat/address-resolver.ts +51 -0
- package/src/nat/ddns/duckdns.ts +76 -0
- package/src/nat/ddns/index.ts +55 -0
- package/src/nat/ddns/updater.ts +243 -0
- package/src/nat/external-ip.ts +154 -0
- package/src/nat/index.ts +71 -0
- package/src/nat/nat-service.ts +375 -0
- package/src/nat/nat-store.ts +154 -0
- package/src/nat/port-mapper.ts +329 -0
- package/src/nat/reachability.ts +34 -0
- package/src/nat/secrets/file-store.ts +130 -0
- package/src/nat/secrets/index.ts +82 -0
- package/src/nat/secrets/keytar-store.ts +67 -0
- package/src/nat/types.ts +157 -0
- package/src/orchestrator/host-process-orchestrator.ts +626 -0
- package/src/orchestrator/index.ts +3 -0
- package/src/orchestrator/log-rotator.ts +53 -0
- package/src/orchestrator/pid-liveness.ts +24 -0
- package/src/orchestrator/port-allocator.ts +43 -0
- package/src/orchestrator/state-store.ts +72 -0
- package/src/orchestrator/types.ts +95 -0
- package/src/server/__tests__/bus.test.ts +38 -0
- package/src/server/__tests__/error-handler.test.ts +96 -0
- package/src/server/__tests__/nodes-route.test.ts +179 -0
- package/src/server/__tests__/origin-guard.test.ts +72 -0
- package/src/server/__tests__/publishers.test.ts +216 -0
- package/src/server/__tests__/server.smoke.test.ts +178 -0
- package/src/server/__tests__/settings-route.test.ts +177 -0
- package/src/server/__tests__/sse-route.test.ts +119 -0
- package/src/server/__tests__/status-route.test.ts +113 -0
- package/src/server/error-handler.ts +108 -0
- package/src/server/events/bus.ts +42 -0
- package/src/server/events/sse-route.ts +81 -0
- package/src/server/events/types.ts +22 -0
- package/src/server/index.ts +209 -0
- package/src/server/origin-guard.ts +84 -0
- package/src/server/routes/nat.ts +63 -0
- package/src/server/routes/nodes.ts +147 -0
- package/src/server/routes/settings.ts +115 -0
- package/src/server/routes/status.ts +108 -0
- package/src/server/routes/trust-circle.ts +49 -0
- package/src/server/routes/update.ts +43 -0
- package/src/server/server.ts +122 -0
- package/src/server/settings-store.ts +37 -0
- package/src/server/static.ts +191 -0
- package/src/update/__tests__/apply.test.ts +101 -0
- package/src/update/__tests__/manifest.test.ts +93 -0
- package/src/update/__tests__/store.test.ts +81 -0
- package/src/update/__tests__/update-service.integration.test.ts +221 -0
- package/src/update/__tests__/version.test.ts +31 -0
- package/src/update/apply.ts +148 -0
- package/src/update/index.ts +303 -0
- package/src/update/manifest.ts +172 -0
- package/src/update/release-key.ts +55 -0
- package/src/update/store.ts +101 -0
- package/src/update/types.ts +109 -0
- package/src/update/version.ts +80 -0
package/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function parseDuration(input: string): number;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny duration parser for invite TTLs.
|
|
3
|
+
*
|
|
4
|
+
* Accepts a single value+unit token: `30s`, `5m`, `24h`, `7d`, `2w`.
|
|
5
|
+
* Whitespace is allowed around it. Bare numbers are treated as milliseconds.
|
|
6
|
+
*
|
|
7
|
+
* Returns the duration in milliseconds. Throws on unparseable input.
|
|
8
|
+
*/
|
|
9
|
+
const UNIT_MULTIPLIERS = {
|
|
10
|
+
ms: 1,
|
|
11
|
+
s: 1000,
|
|
12
|
+
m: 60 * 1000,
|
|
13
|
+
h: 60 * 60 * 1000,
|
|
14
|
+
d: 24 * 60 * 60 * 1000,
|
|
15
|
+
w: 7 * 24 * 60 * 60 * 1000,
|
|
16
|
+
};
|
|
17
|
+
const DURATION_RE = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)?$/;
|
|
18
|
+
export function parseDuration(input) {
|
|
19
|
+
const trimmed = input.trim();
|
|
20
|
+
const match = DURATION_RE.exec(trimmed);
|
|
21
|
+
if (!match) {
|
|
22
|
+
throw new Error(`Invalid duration: ${JSON.stringify(input)} (expected e.g. "24h", "7d", "30m")`);
|
|
23
|
+
}
|
|
24
|
+
const value = parseFloat(match[1]);
|
|
25
|
+
const unit = match[2] ?? 'ms';
|
|
26
|
+
const multiplier = UNIT_MULTIPLIERS[unit];
|
|
27
|
+
if (multiplier === undefined) {
|
|
28
|
+
throw new Error(`Unknown duration unit: ${unit}`);
|
|
29
|
+
}
|
|
30
|
+
return Math.round(value * multiplier);
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=duration.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"duration.js","sourceRoot":"","sources":["../../src/auth/duration.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,MAAM,gBAAgB,GAA2B;IAC/C,EAAE,EAAE,CAAC;IACL,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,EAAE,GAAG,IAAI;IACZ,CAAC,EAAE,EAAE,GAAG,EAAE,GAAG,IAAI;IACjB,CAAC,EAAE,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI;IACtB,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI;CAC3B,CAAC;AAEF,MAAM,WAAW,GAAG,qCAAqC,CAAC;AAE1D,MAAM,UAAU,aAAa,CAAC,KAAa;IACzC,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC;IAC7B,MAAM,KAAK,GAAG,WAAW,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IACxC,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,MAAM,IAAI,KAAK,CAAC,qBAAqB,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,qCAAqC,CAAC,CAAC;IACnG,CAAC;IACD,MAAM,KAAK,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC,CAAE,CAAC,CAAC;IACpC,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC;IAC9B,MAAM,UAAU,GAAG,gBAAgB,CAAC,IAAI,CAAC,CAAC;IAC1C,IAAI,UAAU,KAAK,SAAS,EAAE,CAAC;QAC7B,MAAM,IAAI,KAAK,CAAC,0BAA0B,IAAI,EAAE,CAAC,CAAC;IACpD,CAAC;IACD,OAAO,IAAI,CAAC,KAAK,CAAC,KAAK,GAAG,UAAU,CAAC,CAAC;AACxC,CAAC"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trust-circle auth for cadre-host. See ./trust-circle.ts and ./types.ts.
|
|
3
|
+
*/
|
|
4
|
+
export { TrustCircleService, createTrustCircleHandlers, DEFAULT_INVITE_TTL_MS } from './trust-circle.js';
|
|
5
|
+
export type { TrustCircleServiceOptions, CadreNodeLike } from './trust-circle.js';
|
|
6
|
+
export { TrustCircleStore } from './trust-circle-store.js';
|
|
7
|
+
export { parseDuration } from './duration.js';
|
|
8
|
+
export { TrustCircleError, } from './types.js';
|
|
9
|
+
export type { TrustCircleMember, PendingInvite, TrustCircleFile, TrustCircleSnapshot, TrustCircleHandlers, TrustCircleErrorCode, } from './types.js';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trust-circle auth for cadre-host. See ./trust-circle.ts and ./types.ts.
|
|
3
|
+
*/
|
|
4
|
+
export { TrustCircleService, createTrustCircleHandlers, DEFAULT_INVITE_TTL_MS } from './trust-circle.js';
|
|
5
|
+
export { TrustCircleStore } from './trust-circle-store.js';
|
|
6
|
+
export { parseDuration } from './duration.js';
|
|
7
|
+
export { TrustCircleError, } from './types.js';
|
|
8
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,kBAAkB,EAAE,yBAAyB,EAAE,qBAAqB,EAAE,MAAM,mBAAmB,CAAC;AAEzG,OAAO,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAC3D,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EACL,gBAAgB,GACjB,MAAM,YAAY,CAAC"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { PendingInvite, TrustCircleFile, TrustCircleMember } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Atomic JSON store for trust-circle.json. Mirrors the pattern in
|
|
4
|
+
* `orchestrator/state-store.ts`: write to `<path>.tmp`, then rename.
|
|
5
|
+
*
|
|
6
|
+
* Concurrency assumption: only one cadre-host process owns a given rootDir
|
|
7
|
+
* (the orchestrator already enforces this). No file-locking primitives.
|
|
8
|
+
*/
|
|
9
|
+
export declare class TrustCircleStore {
|
|
10
|
+
private readonly path;
|
|
11
|
+
private cache;
|
|
12
|
+
constructor(rootDir: string);
|
|
13
|
+
filePath(): string;
|
|
14
|
+
/** Load (with caching). A missing file returns an empty store. */
|
|
15
|
+
load(): TrustCircleFile;
|
|
16
|
+
/** Persist the current state atomically (write then rename). */
|
|
17
|
+
save(state: TrustCircleFile): void;
|
|
18
|
+
addMember(member: TrustCircleMember): void;
|
|
19
|
+
removeMember(peerId: string): boolean;
|
|
20
|
+
getMember(peerId: string): TrustCircleMember | undefined;
|
|
21
|
+
listMembers(): TrustCircleMember[];
|
|
22
|
+
addPending(invite: PendingInvite): void;
|
|
23
|
+
removePending(token: string): boolean;
|
|
24
|
+
getPending(token: string): PendingInvite | undefined;
|
|
25
|
+
listPending(): PendingInvite[];
|
|
26
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import debug from 'debug';
|
|
4
|
+
import { TrustCircleError } from './types.js';
|
|
5
|
+
const log = debug('cadre:host:trust-circle-store');
|
|
6
|
+
const FILE_VERSION = 1;
|
|
7
|
+
/**
|
|
8
|
+
* Atomic JSON store for trust-circle.json. Mirrors the pattern in
|
|
9
|
+
* `orchestrator/state-store.ts`: write to `<path>.tmp`, then rename.
|
|
10
|
+
*
|
|
11
|
+
* Concurrency assumption: only one cadre-host process owns a given rootDir
|
|
12
|
+
* (the orchestrator already enforces this). No file-locking primitives.
|
|
13
|
+
*/
|
|
14
|
+
export class TrustCircleStore {
|
|
15
|
+
constructor(rootDir) {
|
|
16
|
+
this.cache = null;
|
|
17
|
+
mkdirSync(rootDir, { recursive: true });
|
|
18
|
+
this.path = join(rootDir, 'trust-circle.json');
|
|
19
|
+
}
|
|
20
|
+
filePath() {
|
|
21
|
+
return this.path;
|
|
22
|
+
}
|
|
23
|
+
/** Load (with caching). A missing file returns an empty store. */
|
|
24
|
+
load() {
|
|
25
|
+
if (this.cache)
|
|
26
|
+
return this.cache;
|
|
27
|
+
if (!existsSync(this.path)) {
|
|
28
|
+
this.cache = emptyFile();
|
|
29
|
+
return this.cache;
|
|
30
|
+
}
|
|
31
|
+
let raw;
|
|
32
|
+
try {
|
|
33
|
+
raw = readFileSync(this.path, 'utf8');
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
throw new TrustCircleError('storage_error', `failed to read trust-circle file at ${this.path}: ${err.message}`);
|
|
37
|
+
}
|
|
38
|
+
let parsed;
|
|
39
|
+
try {
|
|
40
|
+
parsed = JSON.parse(raw);
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
throw new TrustCircleError('storage_error', `trust-circle file at ${this.path} is not valid JSON: ${err.message}`);
|
|
44
|
+
}
|
|
45
|
+
if (!isTrustCircleFile(parsed)) {
|
|
46
|
+
throw new TrustCircleError('storage_error', `trust-circle file at ${this.path} has unexpected shape`);
|
|
47
|
+
}
|
|
48
|
+
this.cache = parsed;
|
|
49
|
+
return this.cache;
|
|
50
|
+
}
|
|
51
|
+
/** Persist the current state atomically (write then rename). */
|
|
52
|
+
save(state) {
|
|
53
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
54
|
+
const payload = JSON.stringify({ ...state, version: FILE_VERSION }, null, 2);
|
|
55
|
+
const tmp = `${this.path}.tmp`;
|
|
56
|
+
writeFileSync(tmp, payload, { encoding: 'utf8' });
|
|
57
|
+
renameSync(tmp, this.path);
|
|
58
|
+
this.cache = { ...state, version: FILE_VERSION };
|
|
59
|
+
log('saved trust-circle (%d members, %d pending) to %s', Object.keys(state.members).length, Object.keys(state.pendingInvites).length, this.path);
|
|
60
|
+
}
|
|
61
|
+
// --- Member ops ---
|
|
62
|
+
addMember(member) {
|
|
63
|
+
const state = this.load();
|
|
64
|
+
const { peerId, ...rest } = member;
|
|
65
|
+
state.members[peerId] = rest;
|
|
66
|
+
this.save(state);
|
|
67
|
+
}
|
|
68
|
+
removeMember(peerId) {
|
|
69
|
+
const state = this.load();
|
|
70
|
+
if (!(peerId in state.members))
|
|
71
|
+
return false;
|
|
72
|
+
delete state.members[peerId];
|
|
73
|
+
this.save(state);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
getMember(peerId) {
|
|
77
|
+
const state = this.load();
|
|
78
|
+
const row = state.members[peerId];
|
|
79
|
+
if (!row)
|
|
80
|
+
return undefined;
|
|
81
|
+
return { peerId, ...row };
|
|
82
|
+
}
|
|
83
|
+
listMembers() {
|
|
84
|
+
const state = this.load();
|
|
85
|
+
return Object.entries(state.members).map(([peerId, row]) => ({ peerId, ...row }));
|
|
86
|
+
}
|
|
87
|
+
// --- Pending invite ops ---
|
|
88
|
+
addPending(invite) {
|
|
89
|
+
const state = this.load();
|
|
90
|
+
const { token, ...rest } = invite;
|
|
91
|
+
state.pendingInvites[token] = rest;
|
|
92
|
+
this.save(state);
|
|
93
|
+
}
|
|
94
|
+
removePending(token) {
|
|
95
|
+
const state = this.load();
|
|
96
|
+
if (!(token in state.pendingInvites))
|
|
97
|
+
return false;
|
|
98
|
+
delete state.pendingInvites[token];
|
|
99
|
+
this.save(state);
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
getPending(token) {
|
|
103
|
+
const state = this.load();
|
|
104
|
+
const row = state.pendingInvites[token];
|
|
105
|
+
if (!row)
|
|
106
|
+
return undefined;
|
|
107
|
+
return { token, ...row };
|
|
108
|
+
}
|
|
109
|
+
listPending() {
|
|
110
|
+
const state = this.load();
|
|
111
|
+
return Object.entries(state.pendingInvites).map(([token, row]) => ({ token, ...row }));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function emptyFile() {
|
|
115
|
+
return { version: FILE_VERSION, members: {}, pendingInvites: {} };
|
|
116
|
+
}
|
|
117
|
+
function isTrustCircleFile(v) {
|
|
118
|
+
if (!v || typeof v !== 'object')
|
|
119
|
+
return false;
|
|
120
|
+
const obj = v;
|
|
121
|
+
if (obj.version !== FILE_VERSION)
|
|
122
|
+
return false;
|
|
123
|
+
if (!obj.members || typeof obj.members !== 'object')
|
|
124
|
+
return false;
|
|
125
|
+
if (!obj.pendingInvites || typeof obj.pendingInvites !== 'object')
|
|
126
|
+
return false;
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
//# sourceMappingURL=trust-circle-store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"trust-circle-store.js","sourceRoot":"","sources":["../../src/auth/trust-circle-store.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { CadreInvite, ControlDatabase } from '@serfab/cadre-core';
|
|
2
|
+
import type { TrustCircleStore } from './trust-circle-store.js';
|
|
3
|
+
import type { TrustCircleHandlers, TrustCircleSnapshot } from './types.js';
|
|
4
|
+
/** Default invite lifetime: 24 hours. */
|
|
5
|
+
export declare const DEFAULT_INVITE_TTL_MS: number;
|
|
6
|
+
/** Constructor options. */
|
|
7
|
+
export interface TrustCircleServiceOptions {
|
|
8
|
+
/** The host's cadre node — provides createInvite / acceptPhone / removePeer. */
|
|
9
|
+
cadreNode: CadreNodeLike;
|
|
10
|
+
/** Local labels + pending-invites store. */
|
|
11
|
+
store: TrustCircleStore;
|
|
12
|
+
/** Clock override for tests. */
|
|
13
|
+
now?: () => Date;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Minimal slice of CadreNode that the service uses. Defined as an interface
|
|
17
|
+
* (rather than reaching into CadreNode directly) so unit tests can inject
|
|
18
|
+
* a mock without standing up a real libp2p node.
|
|
19
|
+
*/
|
|
20
|
+
export interface CadreNodeLike {
|
|
21
|
+
createInvite(token?: string, expiresIn?: number): Promise<{
|
|
22
|
+
invite: CadreInvite;
|
|
23
|
+
encodedInvite: string;
|
|
24
|
+
}>;
|
|
25
|
+
acceptPhone(options: {
|
|
26
|
+
phonePeerId: string;
|
|
27
|
+
token?: string;
|
|
28
|
+
}, issuedInvite?: CadreInvite): Promise<void>;
|
|
29
|
+
removePeer(peerId: string): Promise<void>;
|
|
30
|
+
encodeInvite(invite: CadreInvite): string;
|
|
31
|
+
getControlDatabase(): ControlDatabase | null;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* TrustCircleService — orchestrates invite issuance, redemption, and
|
|
35
|
+
* membership management.
|
|
36
|
+
*
|
|
37
|
+
* Membership is canonical in the cadre control DB (`CadrePeer` table);
|
|
38
|
+
* labels and pending tokens live in the host-local `TrustCircleStore`.
|
|
39
|
+
*/
|
|
40
|
+
export declare class TrustCircleService {
|
|
41
|
+
private readonly cadreNode;
|
|
42
|
+
private readonly store;
|
|
43
|
+
private readonly now;
|
|
44
|
+
constructor(opts: TrustCircleServiceOptions);
|
|
45
|
+
/**
|
|
46
|
+
* Issue a new invite. Generates a token, persists a pending row, and
|
|
47
|
+
* delegates to cadre-core to embed the host's authority addresses.
|
|
48
|
+
*/
|
|
49
|
+
issueInvite(opts: {
|
|
50
|
+
label: string;
|
|
51
|
+
ttlMs?: number;
|
|
52
|
+
}): Promise<{
|
|
53
|
+
encodedInvite: string;
|
|
54
|
+
invite: CadreInvite;
|
|
55
|
+
token: string;
|
|
56
|
+
expiresAt?: Date;
|
|
57
|
+
}>;
|
|
58
|
+
/**
|
|
59
|
+
* Redeem an invite for an incoming peer ID. On success:
|
|
60
|
+
* - cadre-core authorizes the peer (inserts CadrePeer row).
|
|
61
|
+
* - The pending row is consumed (one-time enforcement).
|
|
62
|
+
* - A labelled member row is written locally.
|
|
63
|
+
*/
|
|
64
|
+
redeemInvite(opts: {
|
|
65
|
+
token: string;
|
|
66
|
+
peerId: string;
|
|
67
|
+
}): Promise<{
|
|
68
|
+
peerId: string;
|
|
69
|
+
label: string;
|
|
70
|
+
}>;
|
|
71
|
+
/** Revoke a pending invite by token. No-op if not found. */
|
|
72
|
+
revokePending(token: string): Promise<void>;
|
|
73
|
+
/**
|
|
74
|
+
* Remove an authorised member. Deletes the CadrePeer row and the local
|
|
75
|
+
* label. Throws not_found when the peer is in neither place.
|
|
76
|
+
*/
|
|
77
|
+
removeMember(peerId: string): Promise<void>;
|
|
78
|
+
/** Whether the peer is present in the cadre's CadrePeer table. */
|
|
79
|
+
isMember(peerId: string): Promise<boolean>;
|
|
80
|
+
/**
|
|
81
|
+
* UI snapshot. Joins the canonical CadrePeer list with local labels;
|
|
82
|
+
* prunes any orphan labels (peer is no longer authorised).
|
|
83
|
+
*/
|
|
84
|
+
list(): Promise<TrustCircleSnapshot>;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Wrap a TrustCircleService into the typed handler shape consumed by
|
|
88
|
+
* `cadre-host-local-ui`. Errors propagate as TrustCircleError; the UI ticket
|
|
89
|
+
* maps `.code` → HTTP status.
|
|
90
|
+
*/
|
|
91
|
+
export declare function createTrustCircleHandlers(service: TrustCircleService): TrustCircleHandlers;
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import debug from 'debug';
|
|
3
|
+
import { TrustCircleError } from './types.js';
|
|
4
|
+
const log = debug('cadre:host:trust-circle');
|
|
5
|
+
/** Default invite lifetime: 24 hours. */
|
|
6
|
+
export const DEFAULT_INVITE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
7
|
+
/** Token byte length (base64url-encoded; ~43 chars). */
|
|
8
|
+
const TOKEN_BYTES = 32;
|
|
9
|
+
/**
|
|
10
|
+
* TrustCircleService — orchestrates invite issuance, redemption, and
|
|
11
|
+
* membership management.
|
|
12
|
+
*
|
|
13
|
+
* Membership is canonical in the cadre control DB (`CadrePeer` table);
|
|
14
|
+
* labels and pending tokens live in the host-local `TrustCircleStore`.
|
|
15
|
+
*/
|
|
16
|
+
export class TrustCircleService {
|
|
17
|
+
constructor(opts) {
|
|
18
|
+
this.cadreNode = opts.cadreNode;
|
|
19
|
+
this.store = opts.store;
|
|
20
|
+
this.now = opts.now ?? (() => new Date());
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Issue a new invite. Generates a token, persists a pending row, and
|
|
24
|
+
* delegates to cadre-core to embed the host's authority addresses.
|
|
25
|
+
*/
|
|
26
|
+
async issueInvite(opts) {
|
|
27
|
+
const label = normaliseLabel(opts.label);
|
|
28
|
+
const ttlMs = opts.ttlMs ?? DEFAULT_INVITE_TTL_MS;
|
|
29
|
+
if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
|
|
30
|
+
throw new TrustCircleError('invalid_token', `Invalid ttlMs: ${ttlMs}`);
|
|
31
|
+
}
|
|
32
|
+
const token = generateToken();
|
|
33
|
+
const created = this.now();
|
|
34
|
+
const expiresAt = new Date(created.getTime() + ttlMs);
|
|
35
|
+
const { invite, encodedInvite } = await this.cadreNode.createInvite(token, ttlMs);
|
|
36
|
+
const pending = {
|
|
37
|
+
token,
|
|
38
|
+
label,
|
|
39
|
+
createdAt: created.toISOString(),
|
|
40
|
+
expiresAt: expiresAt.toISOString(),
|
|
41
|
+
};
|
|
42
|
+
this.store.addPending(pending);
|
|
43
|
+
log('issued invite for "%s" (token=%s, expires=%s)', label, token, expiresAt.toISOString());
|
|
44
|
+
return { encodedInvite, invite, token, expiresAt };
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Redeem an invite for an incoming peer ID. On success:
|
|
48
|
+
* - cadre-core authorizes the peer (inserts CadrePeer row).
|
|
49
|
+
* - The pending row is consumed (one-time enforcement).
|
|
50
|
+
* - A labelled member row is written locally.
|
|
51
|
+
*/
|
|
52
|
+
async redeemInvite(opts) {
|
|
53
|
+
const { token, peerId } = opts;
|
|
54
|
+
if (!token || !peerId) {
|
|
55
|
+
throw new TrustCircleError('invalid_token', 'token and peerId are required');
|
|
56
|
+
}
|
|
57
|
+
// Atomically claim the pending row before doing async work. This closes
|
|
58
|
+
// the race between two concurrent redemption requests for the same token —
|
|
59
|
+
// only the call that wins the synchronous removePending() proceeds; the
|
|
60
|
+
// other gets `already_redeemed`. The remove is durable (write-then-rename),
|
|
61
|
+
// so a crash after this point loses the token, which is the safe failure
|
|
62
|
+
// mode for a one-time credential.
|
|
63
|
+
const pending = this.store.getPending(token);
|
|
64
|
+
if (!pending || !this.store.removePending(token)) {
|
|
65
|
+
// Either never issued, or another concurrent caller just consumed it.
|
|
66
|
+
throw new TrustCircleError('already_redeemed', 'Invite not found or already redeemed');
|
|
67
|
+
}
|
|
68
|
+
if (pending.expiresAt) {
|
|
69
|
+
const expiresAt = new Date(pending.expiresAt);
|
|
70
|
+
if (Number.isFinite(expiresAt.getTime()) && this.now() > expiresAt) {
|
|
71
|
+
// Already removed above — just surface the expiry.
|
|
72
|
+
throw new TrustCircleError('expired', 'Invite has expired');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Reconstruct the CadreInvite that was originally issued. cadre-core's
|
|
76
|
+
// acceptPhone validates `issuedInvite.token === options.token` (already
|
|
77
|
+
// matches by construction) and re-checks expiration.
|
|
78
|
+
const reconstructed = {
|
|
79
|
+
partyId: '',
|
|
80
|
+
authorityAddrs: [],
|
|
81
|
+
token: pending.token,
|
|
82
|
+
createdAt: new Date(pending.createdAt).getTime(),
|
|
83
|
+
...(pending.expiresAt ? { expiresAt: new Date(pending.expiresAt).getTime() } : {}),
|
|
84
|
+
};
|
|
85
|
+
await this.cadreNode.acceptPhone({ phonePeerId: peerId, token }, reconstructed);
|
|
86
|
+
// If the member write fails the peer is still authorized in CadrePeer —
|
|
87
|
+
// the next call to list() will pick it up unlabeled, which is the
|
|
88
|
+
// documented graceful degradation.
|
|
89
|
+
const member = {
|
|
90
|
+
peerId,
|
|
91
|
+
label: pending.label,
|
|
92
|
+
addedAt: this.now().toISOString(),
|
|
93
|
+
};
|
|
94
|
+
this.store.addMember(member);
|
|
95
|
+
log('redeemed invite for "%s" (peerId=%s, token=%s)', pending.label, peerId, token);
|
|
96
|
+
return { peerId, label: pending.label };
|
|
97
|
+
}
|
|
98
|
+
/** Revoke a pending invite by token. No-op if not found. */
|
|
99
|
+
async revokePending(token) {
|
|
100
|
+
if (!this.store.removePending(token)) {
|
|
101
|
+
throw new TrustCircleError('not_found', `Unknown pending invite: ${token}`);
|
|
102
|
+
}
|
|
103
|
+
log('revoked pending invite (token=%s)', token);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Remove an authorised member. Deletes the CadrePeer row and the local
|
|
107
|
+
* label. Throws not_found when the peer is in neither place.
|
|
108
|
+
*/
|
|
109
|
+
async removeMember(peerId) {
|
|
110
|
+
const inLocal = this.store.getMember(peerId);
|
|
111
|
+
const inControl = await this.isMember(peerId);
|
|
112
|
+
if (!inLocal && !inControl) {
|
|
113
|
+
throw new TrustCircleError('not_found', `Unknown member: ${peerId}`);
|
|
114
|
+
}
|
|
115
|
+
if (inControl) {
|
|
116
|
+
await this.cadreNode.removePeer(peerId);
|
|
117
|
+
}
|
|
118
|
+
if (inLocal) {
|
|
119
|
+
this.store.removeMember(peerId);
|
|
120
|
+
}
|
|
121
|
+
log('removed member peerId=%s', peerId);
|
|
122
|
+
}
|
|
123
|
+
/** Whether the peer is present in the cadre's CadrePeer table. */
|
|
124
|
+
async isMember(peerId) {
|
|
125
|
+
const db = this.cadreNode.getControlDatabase();
|
|
126
|
+
if (!db)
|
|
127
|
+
return false;
|
|
128
|
+
const inner = db.getDatabase();
|
|
129
|
+
for await (const row of inner.eval('select PeerId from CadreControl.CadrePeer where PeerId = ?', [peerId])) {
|
|
130
|
+
if (row.PeerId === peerId)
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* UI snapshot. Joins the canonical CadrePeer list with local labels;
|
|
137
|
+
* prunes any orphan labels (peer is no longer authorised).
|
|
138
|
+
*/
|
|
139
|
+
async list() {
|
|
140
|
+
const labels = new Map();
|
|
141
|
+
for (const m of this.store.listMembers()) {
|
|
142
|
+
labels.set(m.peerId, { label: m.label, addedAt: m.addedAt, ...(m.self ? { self: true } : {}) });
|
|
143
|
+
}
|
|
144
|
+
const members = [];
|
|
145
|
+
const seenPeerIds = new Set();
|
|
146
|
+
const db = this.cadreNode.getControlDatabase();
|
|
147
|
+
if (db) {
|
|
148
|
+
const inner = db.getDatabase();
|
|
149
|
+
for await (const row of inner.eval('select PeerId from CadreControl.CadrePeer')) {
|
|
150
|
+
const peerId = row.PeerId;
|
|
151
|
+
seenPeerIds.add(peerId);
|
|
152
|
+
const label = labels.get(peerId);
|
|
153
|
+
members.push({
|
|
154
|
+
peerId,
|
|
155
|
+
label: label?.label ?? peerId,
|
|
156
|
+
addedAt: label?.addedAt ?? '',
|
|
157
|
+
...(label?.self ? { self: true } : {}),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// Prune labels for peers that no longer exist in CadrePeer.
|
|
162
|
+
// Only prune when we successfully consulted the control DB, otherwise
|
|
163
|
+
// we'd wipe labels for a transient connectivity issue.
|
|
164
|
+
if (db) {
|
|
165
|
+
for (const peerId of labels.keys()) {
|
|
166
|
+
if (!seenPeerIds.has(peerId)) {
|
|
167
|
+
this.store.removeMember(peerId);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
// Fallback: return labels as-is.
|
|
173
|
+
for (const [peerId, row] of labels) {
|
|
174
|
+
members.push({ peerId, ...row });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const pending = this.store.listPending();
|
|
178
|
+
// Sort for stable UI ordering.
|
|
179
|
+
members.sort((a, b) => a.peerId.localeCompare(b.peerId));
|
|
180
|
+
pending.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
181
|
+
return { members, pending };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Wrap a TrustCircleService into the typed handler shape consumed by
|
|
186
|
+
* `cadre-host-local-ui`. Errors propagate as TrustCircleError; the UI ticket
|
|
187
|
+
* maps `.code` → HTTP status.
|
|
188
|
+
*/
|
|
189
|
+
export function createTrustCircleHandlers(service) {
|
|
190
|
+
return {
|
|
191
|
+
async postInvite(body) {
|
|
192
|
+
if (!body || typeof body.label !== 'string') {
|
|
193
|
+
throw new TrustCircleError('invalid_label', 'label is required');
|
|
194
|
+
}
|
|
195
|
+
const result = await service.issueInvite({ label: body.label, ttlMs: body.ttlMs });
|
|
196
|
+
return {
|
|
197
|
+
encodedInvite: result.encodedInvite,
|
|
198
|
+
token: result.token,
|
|
199
|
+
...(result.expiresAt ? { expiresAt: result.expiresAt.toISOString() } : {}),
|
|
200
|
+
};
|
|
201
|
+
},
|
|
202
|
+
async deleteInvite(token) {
|
|
203
|
+
await service.revokePending(token);
|
|
204
|
+
},
|
|
205
|
+
async deleteMember(peerId) {
|
|
206
|
+
await service.removeMember(peerId);
|
|
207
|
+
},
|
|
208
|
+
async listTrustCircle() {
|
|
209
|
+
return await service.list();
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
function generateToken() {
|
|
214
|
+
// base64url so it survives in URLs, QR codes, and shell arguments.
|
|
215
|
+
return randomBytes(TOKEN_BYTES).toString('base64url');
|
|
216
|
+
}
|
|
217
|
+
function normaliseLabel(label) {
|
|
218
|
+
if (typeof label !== 'string') {
|
|
219
|
+
throw new TrustCircleError('invalid_label', 'label must be a string');
|
|
220
|
+
}
|
|
221
|
+
const trimmed = label.trim();
|
|
222
|
+
if (trimmed.length === 0) {
|
|
223
|
+
throw new TrustCircleError('invalid_label', 'label must not be empty');
|
|
224
|
+
}
|
|
225
|
+
if (trimmed.length > 200) {
|
|
226
|
+
throw new TrustCircleError('invalid_label', 'label must be 200 characters or fewer');
|
|
227
|
+
}
|
|
228
|
+
return trimmed;
|
|
229
|
+
}
|
|
230
|
+
//# sourceMappingURL=trust-circle.js.map
|