@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
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Typed signing core for the release-manifest pipeline.
3
+ *
4
+ * The offline `.mjs` CLI wrappers (`scripts/sign-manifest.mjs`) stay thin by
5
+ * delegating manifest construction, field validation, and the sign + self-verify
6
+ * round-trip to this module — keeping the logic typed and unit-testable instead
7
+ * of hand-rolled in a script. None of this ships in the running binary's hot
8
+ * path: it is only reached by the operator's offline signing tool (and tests).
9
+ */
10
+ import { type KeyObject } from 'node:crypto';
11
+ import type { SignedManifest, UpdateManifest } from './types.js';
12
+ /** Flat input shape the signing CLI collects from flags or a JSON file. */
13
+ export interface ManifestFields {
14
+ /** Released version (semver). */
15
+ version: string;
16
+ /** npm package the release publishes under. */
17
+ package: string;
18
+ /** npm dist-tag (e.g. `latest`). */
19
+ tag: string;
20
+ /** ISO-8601 publish timestamp (must round-trip exactly). */
21
+ publishedAt: string;
22
+ /** Optional human-facing release-notes URL. */
23
+ releaseNotesUrl?: string;
24
+ /** Optional minimum previous version that can step directly to this release. */
25
+ minPreviousVersion?: string;
26
+ }
27
+ /**
28
+ * Construct a well-formed `UpdateManifest` from flat fields and run the exact
29
+ * `validateManifestFields` rules `verifyManifest` will later apply — so the
30
+ * signing tool fails loudly on a bad semver / npm name / non-ISO timestamp
31
+ * rather than emitting a manifest the verifier would reject as `manifest_invalid`.
32
+ */
33
+ export declare function buildManifest(fields: ManifestFields): UpdateManifest;
34
+ /**
35
+ * Derive the raw 32-byte Ed25519 public key (base64) from a private key by
36
+ * stripping the 12-byte SPKI DER header — the same form `release-key.ts`
37
+ * embeds and `ed25519FromRaw` consumes.
38
+ */
39
+ export declare function derivePublicKeyBase64(privateKey: KeyObject): string;
40
+ /**
41
+ * Sign a manifest and immediately verify the result against the public key
42
+ * *derived from the same private key*, independent of whatever key the running
43
+ * environment trusts. Throws if the self-check fails, so the signing tool never
44
+ * emits an envelope it could not itself verify.
45
+ */
46
+ export declare function signAndSelfVerify(manifest: UpdateManifest, privateKey: KeyObject): SignedManifest;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Typed signing core for the release-manifest pipeline.
3
+ *
4
+ * The offline `.mjs` CLI wrappers (`scripts/sign-manifest.mjs`) stay thin by
5
+ * delegating manifest construction, field validation, and the sign + self-verify
6
+ * round-trip to this module — keeping the logic typed and unit-testable instead
7
+ * of hand-rolled in a script. None of this ships in the running binary's hot
8
+ * path: it is only reached by the operator's offline signing tool (and tests).
9
+ */
10
+ import { createPublicKey } from 'node:crypto';
11
+ import { signManifest, validateManifestFields, verifyManifest } from './manifest.js';
12
+ /**
13
+ * Construct a well-formed `UpdateManifest` from flat fields and run the exact
14
+ * `validateManifestFields` rules `verifyManifest` will later apply — so the
15
+ * signing tool fails loudly on a bad semver / npm name / non-ISO timestamp
16
+ * rather than emitting a manifest the verifier would reject as `manifest_invalid`.
17
+ */
18
+ export function buildManifest(fields) {
19
+ const manifest = {
20
+ v: 1,
21
+ version: fields.version,
22
+ publishedAt: fields.publishedAt,
23
+ channels: { npm: { package: fields.package, tag: fields.tag } },
24
+ ...(fields.releaseNotesUrl !== undefined ? { releaseNotesUrl: fields.releaseNotesUrl } : {}),
25
+ ...(fields.minPreviousVersion !== undefined ? { minPreviousVersion: fields.minPreviousVersion } : {}),
26
+ };
27
+ validateManifestFields(manifest);
28
+ return manifest;
29
+ }
30
+ /**
31
+ * Derive the raw 32-byte Ed25519 public key (base64) from a private key by
32
+ * stripping the 12-byte SPKI DER header — the same form `release-key.ts`
33
+ * embeds and `ed25519FromRaw` consumes.
34
+ */
35
+ export function derivePublicKeyBase64(privateKey) {
36
+ const spki = createPublicKey(privateKey).export({ format: 'der', type: 'spki' });
37
+ return Buffer.from(spki.subarray(12)).toString('base64');
38
+ }
39
+ /**
40
+ * Sign a manifest and immediately verify the result against the public key
41
+ * *derived from the same private key*, independent of whatever key the running
42
+ * environment trusts. Throws if the self-check fails, so the signing tool never
43
+ * emits an envelope it could not itself verify.
44
+ */
45
+ export function signAndSelfVerify(manifest, privateKey) {
46
+ const signed = signManifest(manifest, privateKey);
47
+ const publicRawB64 = derivePublicKeyBase64(privateKey);
48
+ // verifyManifest honors the env override; point it at the derived key only so
49
+ // the self-check is against this key pair, not the embedded/dev key.
50
+ verifyManifest(signed, { CADRE_HOST_UPDATE_DEV_KEY: publicRawB64 });
51
+ return signed;
52
+ }
53
+ //# sourceMappingURL=sign.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sign.js","sourceRoot":"","sources":["../../src/update/sign.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,eAAe,EAAkB,MAAM,aAAa,CAAC;AAE9D,OAAO,EAAE,YAAY,EAAE,sBAAsB,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAmBrF;;;;;GAKG;AACH,MAAM,UAAU,aAAa,CAAC,MAAsB;IAClD,MAAM,QAAQ,GAAmB;QAC/B,CAAC,EAAE,CAAC;QACJ,OAAO,EAAE,MAAM,CAAC,OAAO;QACvB,WAAW,EAAE,MAAM,CAAC,WAAW;QAC/B,QAAQ,EAAE,EAAE,GAAG,EAAE,EAAE,OAAO,EAAE,MAAM,CAAC,OAAO,EAAE,GAAG,EAAE,MAAM,CAAC,GAAG,EAAE,EAAE;QAC/D,GAAG,CAAC,MAAM,CAAC,eAAe,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,eAAe,EAAE,MAAM,CAAC,eAAe,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC5F,GAAG,CAAC,MAAM,CAAC,kBAAkB,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,kBAAkB,EAAE,MAAM,CAAC,kBAAkB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KACtG,CAAC;IACF,sBAAsB,CAAC,QAAQ,CAAC,CAAC;IACjC,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,qBAAqB,CAAC,UAAqB;IACzD,MAAM,IAAI,GAAG,eAAe,CAAC,UAAU,CAAC,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,CAAC;IACjF,OAAO,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;AAC3D,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,iBAAiB,CAAC,QAAwB,EAAE,UAAqB;IAC/E,MAAM,MAAM,GAAG,YAAY,CAAC,QAAQ,EAAE,UAAU,CAAC,CAAC;IAClD,MAAM,YAAY,GAAG,qBAAqB,CAAC,UAAU,CAAC,CAAC;IACvD,8EAA8E;IAC9E,qEAAqE;IACrE,cAAc,CAAC,MAAM,EAAE,EAAE,yBAAyB,EAAE,YAAY,EAAuB,CAAC,CAAC;IACzF,OAAO,MAAM,CAAC;AAChB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serfab/cadre-host",
3
- "version": "0.7.1",
3
+ "version": "0.8.1",
4
4
  "type": "module",
5
5
  "description": "Self-hosted cadre node manager for basement-PC deployments",
6
6
  "main": "dist/index.js",
@@ -38,6 +38,8 @@
38
38
  "build:server": "tsc -p tsconfig.build.json",
39
39
  "build:ui": "vite build --config ui/vite.config.ts",
40
40
  "dev:ui": "vite --config ui/vite.config.ts",
41
+ "typecheck": "tsc -p tsconfig.typecheck.json --noEmit",
42
+ "check:svelte": "svelte-check --tsconfig tsconfig.json --workspace ui",
41
43
  "test": "vitest run --passWithNoTests",
42
44
  "dev:test": "vitest",
43
45
  "start": "node dist/bin/host.js"
@@ -59,9 +61,12 @@
59
61
  },
60
62
  "dependencies": {
61
63
  "@achingbrain/nat-port-mapper": "^4.0.0",
62
- "@serfab/cadre-cli": "^0.7.1",
63
- "@serfab/cadre-core": "^0.7.1",
64
- "@serfab/cadre-provider": "^0.7.1",
64
+ "@libp2p/crypto": "^5.1.13",
65
+ "@libp2p/interface": "^3.1.0",
66
+ "@libp2p/peer-id": "^6.0.4",
67
+ "@serfab/cadre-cli": "^0.8.1",
68
+ "@serfab/cadre-core": "^0.8.1",
69
+ "@serfab/cadre-provider": "^0.8.1",
65
70
  "commander": "^14.0.2",
66
71
  "debug": "^4.4.3",
67
72
  "fastify": "^5.7.1",
package/service/README.md CHANGED
@@ -1,86 +1,95 @@
1
- # Cadre Host Service Units
2
-
3
- Host cadre-host as a per-user system service. All three units are configured
4
- so that **a clean exit (code 0) is final** — `cadre-host uninstall` (or any
5
- explicit `systemctl stop` / `nssm stop`) leaves the service stopped. Crashes
6
- still trigger a restart.
7
-
8
- | Platform | File | Service host |
9
- | -------- | ---- | ------------ |
10
- | Linux | [`cadre-host.service.tmpl`](./cadre-host.service.tmpl) | systemd (user) |
11
- | macOS | [`com.serfab.cadre-host.plist.tmpl`](./com.serfab.cadre-host.plist.tmpl) | launchd (LaunchAgent) |
12
- | Windows | [`install-service.ps1`](./install-service.ps1) / [`uninstall-service.ps1`](./uninstall-service.ps1) | NSSM |
13
-
14
- The `.tmpl` files contain `@TOKEN@` placeholders that the installer
15
- substitutes at install time (`@NODE_PATH@`, `@HOST_JS@`, `@DATA_DIR@`).
16
- Inspect the rendered files under `~/.config/systemd/user/cadre-host.service`
17
- or `~/Library/LaunchAgents/com.serfab.cadre-host.plist` after running
18
- `cadre-host install`.
19
-
20
- ## Scope (v1)
21
-
22
- **Per-user install only.** The systemd unit is installed under the user's
23
- control (`systemctl --user`), the launchd unit is a `LaunchAgent` (not a
24
- `LaunchDaemon`), and NSSM runs as the current user.
25
-
26
- A `--system` flag is accepted by `cadre-host install` but currently errors
27
- out — running cadre-host as a dedicated system user requires more work
28
- around data-dir ownership and capability dropping. Tracked separately.
29
-
30
- ## Linux (systemd) — manual smoke
31
-
32
- ```bash
33
- # Render + install + enable + start in one go:
34
- cadre-host install --non-interactive --data-dir ~/.local/share/cadre-host
35
-
36
- # After install, the unit lives at:
37
- # ~/.config/systemd/user/cadre-host.service
38
- # Inspect status:
39
- systemctl --user status cadre-host
40
- journalctl --user -u cadre-host -f
41
- ```
42
-
43
- `Restart=on-failure` + `RestartPreventExitStatus=0` make exit 0 final.
44
-
45
- The installer also runs `loginctl enable-linger <user>` so the unit survives
46
- logout / reboot.
47
-
48
- ## macOS (launchd) — manual smoke
49
-
50
- ```bash
51
- cadre-host install --non-interactive --data-dir "$HOME/Library/Application Support/CadreHost"
52
-
53
- # After install, the plist lives at:
54
- # ~/Library/LaunchAgents/com.serfab.cadre-host.plist
55
- launchctl print gui/$(id -u)/com.serfab.cadre-host
56
- tail -f "$HOME/Library/Application Support/CadreHost/logs/out.log"
57
- ```
58
-
59
- `KeepAlive.SuccessfulExit = false` plus `KeepAlive.Crashed = true` mean a
60
- clean exit stays down while a crash is relaunched.
61
-
62
- ## Windows (NSSM) — manual smoke
63
-
64
- Install [NSSM](https://nssm.cc/download) and put `nssm.exe` on PATH first.
65
-
66
- ```powershell
67
- cadre-host install --non-interactive --data-dir $env:LocalAppData\CadreHost
68
-
69
- # After install, inspect the service:
70
- nssm.exe status CadreHost
71
- Get-Service CadreHost
72
- Get-Content $env:LocalAppData\CadreHost\logs\out.log -Tail 30 -Wait
73
- ```
74
-
75
- `AppExit 0 Exit` tells NSSM not to restart on clean exit; other exit codes
76
- are handled by `AppExit Default Restart` with a 5-second delay and a
77
- 10-second throttle. NSSM is **not** bundled with the npm package; the
78
- standalone-binary distribution (see `tickets/backlog/cadre-host-standalone-binary.md`)
79
- will ship it.
80
-
81
- ## CI gap
82
-
83
- Cross-platform CI does **not** invoke `systemctl --user enable`,
84
- `launchctl bootstrap`, or `nssm install`. The installer's unit-rendering
85
- logic is covered by unit tests; the actual service-host registration must
86
- be smoke-tested manually on each platform before tagging a release.
1
+ # Cadre Host Service Units
2
+
3
+ Host cadre-host as a per-user system service. All three units are configured
4
+ so that **a clean exit (code 0) is final** — `cadre-host uninstall` (or any
5
+ explicit `systemctl stop` / `nssm stop`) leaves the service stopped. Crashes
6
+ still trigger a restart.
7
+
8
+ | Platform | File | Service host |
9
+ | -------- | ---- | ------------ |
10
+ | Linux | [`cadre-host.service.tmpl`](./cadre-host.service.tmpl) | systemd (user) |
11
+ | macOS | [`com.serfab.cadre-host.plist.tmpl`](./com.serfab.cadre-host.plist.tmpl) | launchd (LaunchAgent) |
12
+ | Windows | [`install-service.ps1`](./install-service.ps1) / [`uninstall-service.ps1`](./uninstall-service.ps1) | NSSM |
13
+
14
+ The `.tmpl` files contain `@TOKEN@` placeholders that the installer
15
+ substitutes at install time (`@NODE_PATH@`, `@HOST_JS@`, `@DATA_DIR@`).
16
+ Inspect the rendered files under `~/.config/systemd/user/cadre-host.service`
17
+ or `~/Library/LaunchAgents/com.serfab.cadre-host.plist` after running
18
+ `cadre-host install`.
19
+
20
+ ## Scope (v1)
21
+
22
+ **Per-user install only.** The systemd unit is installed under the user's
23
+ control (`systemctl --user`), the launchd unit is a `LaunchAgent` (not a
24
+ `LaunchDaemon`), and NSSM runs as the current user.
25
+
26
+ A `--system` flag is accepted by `cadre-host install` but currently errors
27
+ out — running cadre-host as a dedicated system user requires more work
28
+ around data-dir ownership and capability dropping. Tracked separately.
29
+
30
+ ## Linux (systemd) — manual smoke
31
+
32
+ ```bash
33
+ # Render + install + enable + start in one go:
34
+ cadre-host install --non-interactive --data-dir ~/.local/share/cadre-host
35
+
36
+ # After install, the unit lives at:
37
+ # ~/.config/systemd/user/cadre-host.service
38
+ # Inspect status:
39
+ systemctl --user status cadre-host
40
+ journalctl --user -u cadre-host -f
41
+ ```
42
+
43
+ `Restart=on-failure` + `RestartPreventExitStatus=0` make exit 0 final.
44
+
45
+ The installer attempts `loginctl enable-linger <user>` so the unit survives
46
+ logout / reboot. This is the **one** step in the install that requires root,
47
+ and it is opportunistic: if `loginctl` is missing or you aren't root, the
48
+ installer logs a warning and continues. Without linger the user-systemd
49
+ service runs only while you have an active session (desktop or SSH) and
50
+ stops when you log out. You can enable linger separately, before or after
51
+ `cadre-host install`:
52
+
53
+ ```bash
54
+ sudo loginctl enable-linger "$USER"
55
+ ```
56
+
57
+ ## macOS (launchd) — manual smoke
58
+
59
+ ```bash
60
+ cadre-host install --non-interactive --data-dir "$HOME/Library/Application Support/CadreHost"
61
+
62
+ # After install, the plist lives at:
63
+ # ~/Library/LaunchAgents/com.serfab.cadre-host.plist
64
+ launchctl print gui/$(id -u)/com.serfab.cadre-host
65
+ tail -f "$HOME/Library/Application Support/CadreHost/logs/out.log"
66
+ ```
67
+
68
+ `KeepAlive.SuccessfulExit = false` plus `KeepAlive.Crashed = true` mean a
69
+ clean exit stays down while a crash is relaunched.
70
+
71
+ ## Windows (NSSM) — manual smoke
72
+
73
+ Install [NSSM](https://nssm.cc/download) and put `nssm.exe` on PATH first.
74
+
75
+ ```powershell
76
+ cadre-host install --non-interactive --data-dir $env:LocalAppData\CadreHost
77
+
78
+ # After install, inspect the service:
79
+ nssm.exe status CadreHost
80
+ Get-Service CadreHost
81
+ Get-Content $env:LocalAppData\CadreHost\logs\out.log -Tail 30 -Wait
82
+ ```
83
+
84
+ `AppExit 0 Exit` tells NSSM not to restart on clean exit; other exit codes
85
+ are handled by `AppExit Default Restart` with a 5-second delay and a
86
+ 10-second throttle. NSSM is **not** bundled with the npm package; the
87
+ standalone-binary distribution (see `tickets/backlog/cadre-host-standalone-binary.md`)
88
+ will ship it.
89
+
90
+ ## CI gap
91
+
92
+ Cross-platform CI does **not** invoke `systemctl --user enable`,
93
+ `launchctl bootstrap`, or `nssm install`. The installer's unit-rendering
94
+ logic is covered by unit tests; the actual service-host registration must
95
+ be smoke-tested manually on each platform before tagging a release.
@@ -13,11 +13,16 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
13
13
  import { existsSync } from 'node:fs';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { dirname, resolve } from 'node:path';
16
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
16
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
17
17
 
18
18
  const here = dirname(fileURLToPath(import.meta.url));
19
19
  const binPath = resolve(here, '../../dist/bin/host.js');
20
20
 
21
+ // These smoke tests cold-start the built CLI as a child process (Node boot +,
22
+ // for some, an HTTP server). That comfortably exceeds vitest's 5s default under
23
+ // heavy parallel load, so give the subprocess generous headroom.
24
+ vi.setConfig({ testTimeout: 30000 });
25
+
21
26
  interface StubServer {
22
27
  server: Server;
23
28
  port: number;
@@ -15,11 +15,16 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
15
15
  import { existsSync } from 'node:fs';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import { dirname, resolve } from 'node:path';
18
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
18
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
19
19
 
20
20
  const here = dirname(fileURLToPath(import.meta.url));
21
21
  const binPath = resolve(here, '../../dist/bin/host.js');
22
22
 
23
+ // These smoke tests cold-start the built CLI as a child process (Node boot +,
24
+ // for some, an HTTP server). That comfortably exceeds vitest's 5s default under
25
+ // heavy parallel load, so give the subprocess generous headroom.
26
+ vi.setConfig({ testTimeout: 30000 });
27
+
23
28
  interface StubServer {
24
29
  server: Server;
25
30
  port: number;
@@ -7,11 +7,16 @@ import { execFileSync } from 'node:child_process';
7
7
  import { existsSync } from 'node:fs';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { dirname, resolve } from 'node:path';
10
- import { describe, it, expect } from 'vitest';
10
+ import { describe, it, expect, vi } from 'vitest';
11
11
 
12
12
  const here = dirname(fileURLToPath(import.meta.url));
13
13
  const binPath = resolve(here, '../../dist/bin/host.js');
14
14
 
15
+ // These smoke tests cold-start the built CLI as a child process (Node boot +,
16
+ // for some, an HTTP server). That comfortably exceeds vitest's 5s default under
17
+ // heavy parallel load, so give the subprocess generous headroom.
18
+ vi.setConfig({ testTimeout: 30000 });
19
+
15
20
  describe('cadre-host CLI smoke test', () => {
16
21
  it('prints help with all subcommands', () => {
17
22
  if (!existsSync(binPath)) {
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Server-level smoke: the /auth/* HTTP contract over the authority-node
3
+ * channel adapter. Validates the two behaviours the realignment must
4
+ * preserve:
5
+ * 1. With the node up, POST /auth/invites returns an encodedInvite (no 500).
6
+ * 2. With the node refusing connections, GET /auth/trust-circle still lists
7
+ * local labels, and POST /auth/invites returns 503 (not a raw 500).
8
+ *
9
+ * The authority node is represented by a stub HTTP server matching the 6.6
10
+ * admin contract (the spawn path itself is covered by
11
+ * orchestrator-authority.test.ts).
12
+ */
13
+
14
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
15
+ import Fastify from 'fastify';
16
+ import http from 'node:http';
17
+ import { mkdtempSync, rmSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import { join } from 'node:path';
20
+
21
+ import { TrustCircleService, TrustCircleStore, createTrustCircleHandlers } from '../auth/index.js';
22
+ import { AuthorityNodeClient } from '../authority/index.js';
23
+ import { registerErrorHandler } from '../server/error-handler.js';
24
+ import { registerTrustCircleRoutes } from '../server/routes/trust-circle.js';
25
+ import { EventBus } from '../server/events/bus.js';
26
+
27
+ const TOKEN = 'tok';
28
+
29
+ let tmpRoot: string;
30
+ beforeEach(() => { tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-smoke-')); });
31
+ afterEach(() => { try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ } });
32
+
33
+ function buildApp(service: TrustCircleService) {
34
+ const app = Fastify();
35
+ registerErrorHandler(app);
36
+ registerTrustCircleRoutes(app, { handlers: createTrustCircleHandlers(service), events: new EventBus() });
37
+ return app;
38
+ }
39
+
40
+ describe('host /auth delegation — node up', () => {
41
+ let server: http.Server;
42
+ let baseUrl: string;
43
+
44
+ beforeEach(async () => {
45
+ server = http.createServer((req, res) => {
46
+ const chunks: Buffer[] = [];
47
+ req.on('data', (c) => chunks.push(c as Buffer));
48
+ req.on('end', () => {
49
+ if (req.headers['authorization'] !== `Bearer ${TOKEN}`) {
50
+ res.writeHead(401); res.end(JSON.stringify({ ok: false, error: { code: 'not_authorized', message: 'x' } })); return;
51
+ }
52
+ if (req.url === '/admin/invites' && req.method === 'POST') {
53
+ res.writeHead(200, { 'content-type': 'application/json' });
54
+ res.end(JSON.stringify({ ok: true, data: { invite: { partyId: 'p', authorityAddrs: [], token: 't', createdAt: 1 }, encodedInvite: 'ENC' } }));
55
+ return;
56
+ }
57
+ if (req.url === '/admin/members' && req.method === 'GET') {
58
+ res.writeHead(200, { 'content-type': 'application/json' });
59
+ res.end(JSON.stringify({ ok: true, data: { members: [] } }));
60
+ return;
61
+ }
62
+ res.writeHead(400); res.end(JSON.stringify({ ok: false, error: { code: 'bad_request', message: req.url } }));
63
+ });
64
+ });
65
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
66
+ const a = server.address();
67
+ baseUrl = `http://127.0.0.1:${typeof a === 'object' && a ? a.port : 0}`;
68
+ });
69
+
70
+ afterEach(async () => { await new Promise<void>((resolve) => server.close(() => resolve())); });
71
+
72
+ it('POST /auth/invites returns an encodedInvite (no 500)', async () => {
73
+ const client = new AuthorityNodeClient({ baseUrl, token: TOKEN });
74
+ const service = new TrustCircleService({ cadreNode: client, store: new TrustCircleStore(tmpRoot) });
75
+ const app = buildApp(service);
76
+ try {
77
+ const res = await app.inject({ method: 'POST', url: '/auth/invites', payload: { label: 'Mom' } });
78
+ expect(res.statusCode).toBe(200);
79
+ const body = res.json() as { encodedInvite: string };
80
+ expect(body.encodedInvite).toBe('ENC');
81
+ } finally {
82
+ await app.close();
83
+ }
84
+ });
85
+ });
86
+
87
+ describe('host /auth delegation — node refusing connections', () => {
88
+ let closedBaseUrl: string;
89
+
90
+ beforeEach(async () => {
91
+ const tmp = http.createServer();
92
+ const port = await new Promise<number>((resolve) => {
93
+ tmp.listen(0, '127.0.0.1', () => {
94
+ const a = tmp.address();
95
+ resolve(typeof a === 'object' && a ? a.port : 0);
96
+ });
97
+ });
98
+ await new Promise<void>((resolve) => tmp.close(() => resolve()));
99
+ closedBaseUrl = `http://127.0.0.1:${port}`;
100
+ });
101
+
102
+ it('GET /auth/trust-circle lists local labels and POST /auth/invites returns 503', async () => {
103
+ const store = new TrustCircleStore(tmpRoot);
104
+ store.addMember({ peerId: '12D3KooWLocal', label: 'My laptop', addedAt: '2025-01-01T00:00:00Z' });
105
+
106
+ const client = new AuthorityNodeClient({ baseUrl: closedBaseUrl, token: TOKEN });
107
+ const service = new TrustCircleService({ cadreNode: client, store });
108
+ const app = buildApp(service);
109
+ try {
110
+ const list = await app.inject({ method: 'GET', url: '/auth/trust-circle' });
111
+ expect(list.statusCode).toBe(200);
112
+ const snap = list.json() as { members: Array<{ peerId: string; label: string }> };
113
+ expect(snap.members.some((m) => m.peerId === '12D3KooWLocal' && m.label === 'My laptop')).toBe(true);
114
+
115
+ const invite = await app.inject({ method: 'POST', url: '/auth/invites', payload: { label: 'Dad' } });
116
+ expect(invite.statusCode).toBe(503);
117
+ const body = invite.json() as { error: { code: string } };
118
+ expect(body.error.code).toBe('node_unavailable');
119
+ } finally {
120
+ await app.close();
121
+ }
122
+ });
123
+ });