@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.
Files changed (300) hide show
  1. package/README.md +101 -0
  2. package/dist/auth/duration.d.ts +1 -0
  3. package/dist/auth/duration.js +32 -0
  4. package/dist/auth/duration.js.map +1 -0
  5. package/dist/auth/index.d.ts +9 -0
  6. package/dist/auth/index.js +8 -0
  7. package/dist/auth/index.js.map +1 -0
  8. package/dist/auth/trust-circle-store.d.ts +26 -0
  9. package/dist/auth/trust-circle-store.js +129 -0
  10. package/dist/auth/trust-circle-store.js.map +1 -0
  11. package/dist/auth/trust-circle.d.ts +91 -0
  12. package/dist/auth/trust-circle.js +230 -0
  13. package/dist/auth/trust-circle.js.map +1 -0
  14. package/dist/auth/types.d.ts +81 -0
  15. package/dist/auth/types.js +24 -0
  16. package/dist/auth/types.js.map +1 -0
  17. package/dist/bin/host.d.ts +13 -0
  18. package/dist/bin/host.js +826 -0
  19. package/dist/bin/host.js.map +1 -0
  20. package/dist/index.d.ts +32 -0
  21. package/dist/index.js +27 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/installer/browser.d.ts +14 -0
  24. package/dist/installer/browser.js +35 -0
  25. package/dist/installer/browser.js.map +1 -0
  26. package/dist/installer/config.d.ts +43 -0
  27. package/dist/installer/config.js +113 -0
  28. package/dist/installer/config.js.map +1 -0
  29. package/dist/installer/identity.d.ts +23 -0
  30. package/dist/installer/identity.js +36 -0
  31. package/dist/installer/identity.js.map +1 -0
  32. package/dist/installer/index.d.ts +83 -0
  33. package/dist/installer/index.js +204 -0
  34. package/dist/installer/index.js.map +1 -0
  35. package/dist/installer/paths.d.ts +33 -0
  36. package/dist/installer/paths.js +84 -0
  37. package/dist/installer/paths.js.map +1 -0
  38. package/dist/installer/platform.d.ts +9 -0
  39. package/dist/installer/platform.js +21 -0
  40. package/dist/installer/platform.js.map +1 -0
  41. package/dist/installer/service-host/index.d.ts +11 -0
  42. package/dist/installer/service-host/index.js +16 -0
  43. package/dist/installer/service-host/index.js.map +1 -0
  44. package/dist/installer/service-host/launchd.d.ts +16 -0
  45. package/dist/installer/service-host/launchd.js +79 -0
  46. package/dist/installer/service-host/launchd.js.map +1 -0
  47. package/dist/installer/service-host/nssm.d.ts +20 -0
  48. package/dist/installer/service-host/nssm.js +88 -0
  49. package/dist/installer/service-host/nssm.js.map +1 -0
  50. package/dist/installer/service-host/systemd.d.ts +16 -0
  51. package/dist/installer/service-host/systemd.js +78 -0
  52. package/dist/installer/service-host/systemd.js.map +1 -0
  53. package/dist/installer/service-host/template.d.ts +6 -0
  54. package/dist/installer/service-host/template.js +20 -0
  55. package/dist/installer/service-host/template.js.map +1 -0
  56. package/dist/installer/service-host/types.d.ts +40 -0
  57. package/dist/installer/service-host/types.js +6 -0
  58. package/dist/installer/service-host/types.js.map +1 -0
  59. package/dist/installer/wizard.d.ts +31 -0
  60. package/dist/installer/wizard.js +67 -0
  61. package/dist/installer/wizard.js.map +1 -0
  62. package/dist/nat/address-resolver.d.ts +22 -0
  63. package/dist/nat/address-resolver.js +31 -0
  64. package/dist/nat/address-resolver.js.map +1 -0
  65. package/dist/nat/ddns/duckdns.d.ts +12 -0
  66. package/dist/nat/ddns/duckdns.js +57 -0
  67. package/dist/nat/ddns/duckdns.js.map +1 -0
  68. package/dist/nat/ddns/index.d.ts +30 -0
  69. package/dist/nat/ddns/index.js +21 -0
  70. package/dist/nat/ddns/index.js.map +1 -0
  71. package/dist/nat/ddns/updater.d.ts +68 -0
  72. package/dist/nat/ddns/updater.js +194 -0
  73. package/dist/nat/ddns/updater.js.map +1 -0
  74. package/dist/nat/external-ip.d.ts +62 -0
  75. package/dist/nat/external-ip.js +114 -0
  76. package/dist/nat/external-ip.js.map +1 -0
  77. package/dist/nat/index.d.ts +22 -0
  78. package/dist/nat/index.js +16 -0
  79. package/dist/nat/index.js.map +1 -0
  80. package/dist/nat/nat-service.d.ts +101 -0
  81. package/dist/nat/nat-service.js +284 -0
  82. package/dist/nat/nat-service.js.map +1 -0
  83. package/dist/nat/nat-store.d.ts +19 -0
  84. package/dist/nat/nat-store.js +136 -0
  85. package/dist/nat/nat-store.js.map +1 -0
  86. package/dist/nat/port-mapper.d.ts +98 -0
  87. package/dist/nat/port-mapper.js +226 -0
  88. package/dist/nat/port-mapper.js.map +1 -0
  89. package/dist/nat/reachability.d.ts +19 -0
  90. package/dist/nat/reachability.js +29 -0
  91. package/dist/nat/reachability.js.map +1 -0
  92. package/dist/nat/secrets/file-store.d.ts +23 -0
  93. package/dist/nat/secrets/file-store.js +102 -0
  94. package/dist/nat/secrets/file-store.js.map +1 -0
  95. package/dist/nat/secrets/index.d.ts +36 -0
  96. package/dist/nat/secrets/index.js +56 -0
  97. package/dist/nat/secrets/index.js.map +1 -0
  98. package/dist/nat/secrets/keytar-store.d.ts +18 -0
  99. package/dist/nat/secrets/keytar-store.js +52 -0
  100. package/dist/nat/secrets/keytar-store.js.map +1 -0
  101. package/dist/nat/types.d.ts +120 -0
  102. package/dist/nat/types.js +24 -0
  103. package/dist/nat/types.js.map +1 -0
  104. package/dist/orchestrator/host-process-orchestrator.d.ts +87 -0
  105. package/dist/orchestrator/host-process-orchestrator.js +557 -0
  106. package/dist/orchestrator/host-process-orchestrator.js.map +1 -0
  107. package/dist/orchestrator/index.d.ts +3 -0
  108. package/dist/orchestrator/index.js +2 -0
  109. package/dist/orchestrator/index.js.map +1 -0
  110. package/dist/orchestrator/log-rotator.d.ts +25 -0
  111. package/dist/orchestrator/log-rotator.js +56 -0
  112. package/dist/orchestrator/log-rotator.js.map +1 -0
  113. package/dist/orchestrator/pid-liveness.d.ts +11 -0
  114. package/dist/orchestrator/pid-liveness.js +26 -0
  115. package/dist/orchestrator/pid-liveness.js.map +1 -0
  116. package/dist/orchestrator/port-allocator.d.ts +16 -0
  117. package/dist/orchestrator/port-allocator.js +38 -0
  118. package/dist/orchestrator/port-allocator.js.map +1 -0
  119. package/dist/orchestrator/state-store.d.ts +32 -0
  120. package/dist/orchestrator/state-store.js +44 -0
  121. package/dist/orchestrator/state-store.js.map +1 -0
  122. package/dist/orchestrator/types.d.ts +85 -0
  123. package/dist/orchestrator/types.js +31 -0
  124. package/dist/orchestrator/types.js.map +1 -0
  125. package/dist/server/error-handler.d.ts +26 -0
  126. package/dist/server/error-handler.js +95 -0
  127. package/dist/server/error-handler.js.map +1 -0
  128. package/dist/server/events/bus.d.ts +17 -0
  129. package/dist/server/events/bus.js +36 -0
  130. package/dist/server/events/bus.js.map +1 -0
  131. package/dist/server/events/sse-route.d.ts +22 -0
  132. package/dist/server/events/sse-route.js +65 -0
  133. package/dist/server/events/sse-route.js.map +1 -0
  134. package/dist/server/events/types.d.ts +25 -0
  135. package/dist/server/events/types.js +8 -0
  136. package/dist/server/events/types.js.map +1 -0
  137. package/dist/server/index.d.ts +67 -0
  138. package/dist/server/index.js +149 -0
  139. package/dist/server/index.js.map +1 -0
  140. package/dist/server/origin-guard.d.ts +24 -0
  141. package/dist/server/origin-guard.js +72 -0
  142. package/dist/server/origin-guard.js.map +1 -0
  143. package/dist/server/routes/nat.d.ts +16 -0
  144. package/dist/server/routes/nat.js +42 -0
  145. package/dist/server/routes/nat.js.map +1 -0
  146. package/dist/server/routes/nodes.d.ts +23 -0
  147. package/dist/server/routes/nodes.js +128 -0
  148. package/dist/server/routes/nodes.js.map +1 -0
  149. package/dist/server/routes/settings.d.ts +26 -0
  150. package/dist/server/routes/settings.js +97 -0
  151. package/dist/server/routes/settings.js.map +1 -0
  152. package/dist/server/routes/status.d.ts +42 -0
  153. package/dist/server/routes/status.js +76 -0
  154. package/dist/server/routes/status.js.map +1 -0
  155. package/dist/server/routes/trust-circle.d.ts +17 -0
  156. package/dist/server/routes/trust-circle.js +35 -0
  157. package/dist/server/routes/trust-circle.js.map +1 -0
  158. package/dist/server/routes/update.d.ts +16 -0
  159. package/dist/server/routes/update.js +29 -0
  160. package/dist/server/routes/update.js.map +1 -0
  161. package/dist/server/server.d.ts +40 -0
  162. package/dist/server/server.js +90 -0
  163. package/dist/server/server.js.map +1 -0
  164. package/dist/server/settings-store.d.ts +24 -0
  165. package/dist/server/settings-store.js +27 -0
  166. package/dist/server/settings-store.js.map +1 -0
  167. package/dist/server/static.d.ts +25 -0
  168. package/dist/server/static.js +177 -0
  169. package/dist/server/static.js.map +1 -0
  170. package/dist/ui/assets/index-BwU5mJGF.css +1 -0
  171. package/dist/ui/assets/index-C9uChk2P.js +14 -0
  172. package/dist/ui/assets/index-C9uChk2P.js.map +1 -0
  173. package/dist/ui/index.html +14 -0
  174. package/dist/update/apply.d.ts +37 -0
  175. package/dist/update/apply.js +102 -0
  176. package/dist/update/apply.js.map +1 -0
  177. package/dist/update/index.d.ts +101 -0
  178. package/dist/update/index.js +228 -0
  179. package/dist/update/index.js.map +1 -0
  180. package/dist/update/manifest.d.ts +48 -0
  181. package/dist/update/manifest.js +152 -0
  182. package/dist/update/manifest.js.map +1 -0
  183. package/dist/update/release-key.d.ts +24 -0
  184. package/dist/update/release-key.js +52 -0
  185. package/dist/update/release-key.js.map +1 -0
  186. package/dist/update/store.d.ts +22 -0
  187. package/dist/update/store.js +81 -0
  188. package/dist/update/store.js.map +1 -0
  189. package/dist/update/types.d.ts +90 -0
  190. package/dist/update/types.js +22 -0
  191. package/dist/update/types.js.map +1 -0
  192. package/dist/update/version.d.ts +18 -0
  193. package/dist/update/version.js +76 -0
  194. package/dist/update/version.js.map +1 -0
  195. package/package.json +80 -0
  196. package/service/README.md +86 -0
  197. package/service/cadre-host.service.tmpl +16 -0
  198. package/service/com.serfab.cadre-host.plist.tmpl +35 -0
  199. package/service/install-service.ps1 +98 -0
  200. package/service/uninstall-service.ps1 +19 -0
  201. package/src/__tests__/cli-invite.smoke.test.ts +187 -0
  202. package/src/__tests__/cli-nat.smoke.test.ts +207 -0
  203. package/src/__tests__/cli.smoke.test.ts +41 -0
  204. package/src/__tests__/orchestrator.test.ts +570 -0
  205. package/src/auth/__tests__/duration.test.ts +28 -0
  206. package/src/auth/__tests__/trust-circle-integration.test.ts +128 -0
  207. package/src/auth/__tests__/trust-circle-store.test.ts +115 -0
  208. package/src/auth/__tests__/trust-circle.test.ts +334 -0
  209. package/src/auth/duration.ts +33 -0
  210. package/src/auth/index.ts +19 -0
  211. package/src/auth/trust-circle-store.ts +155 -0
  212. package/src/auth/trust-circle.ts +304 -0
  213. package/src/auth/types.ts +96 -0
  214. package/src/bin/host.ts +905 -0
  215. package/src/index.ts +167 -0
  216. package/src/installer/__tests__/config.test.ts +86 -0
  217. package/src/installer/__tests__/identity.test.ts +34 -0
  218. package/src/installer/__tests__/installer.smoke.test.ts +124 -0
  219. package/src/installer/__tests__/paths.test.ts +47 -0
  220. package/src/installer/__tests__/service-host.test.ts +62 -0
  221. package/src/installer/__tests__/wizard.test.ts +60 -0
  222. package/src/installer/browser.ts +42 -0
  223. package/src/installer/config.ts +153 -0
  224. package/src/installer/identity.ts +49 -0
  225. package/src/installer/index.ts +303 -0
  226. package/src/installer/paths.ts +109 -0
  227. package/src/installer/platform.ts +27 -0
  228. package/src/installer/service-host/index.ts +21 -0
  229. package/src/installer/service-host/launchd.ts +90 -0
  230. package/src/installer/service-host/nssm.ts +100 -0
  231. package/src/installer/service-host/systemd.ts +88 -0
  232. package/src/installer/service-host/template.ts +21 -0
  233. package/src/installer/service-host/types.ts +48 -0
  234. package/src/installer/wizard.ts +92 -0
  235. package/src/nat/__tests__/address-resolver.test.ts +82 -0
  236. package/src/nat/__tests__/ddns-duckdns.test.ts +83 -0
  237. package/src/nat/__tests__/ddns-updater.test.ts +253 -0
  238. package/src/nat/__tests__/external-ip.test.ts +124 -0
  239. package/src/nat/__tests__/nat-service.test.ts +305 -0
  240. package/src/nat/__tests__/nat-store.test.ts +93 -0
  241. package/src/nat/__tests__/port-mapper.test.ts +191 -0
  242. package/src/nat/__tests__/reachability.test.ts +24 -0
  243. package/src/nat/__tests__/secrets.test.ts +156 -0
  244. package/src/nat/address-resolver.ts +51 -0
  245. package/src/nat/ddns/duckdns.ts +76 -0
  246. package/src/nat/ddns/index.ts +55 -0
  247. package/src/nat/ddns/updater.ts +243 -0
  248. package/src/nat/external-ip.ts +154 -0
  249. package/src/nat/index.ts +71 -0
  250. package/src/nat/nat-service.ts +375 -0
  251. package/src/nat/nat-store.ts +154 -0
  252. package/src/nat/port-mapper.ts +329 -0
  253. package/src/nat/reachability.ts +34 -0
  254. package/src/nat/secrets/file-store.ts +130 -0
  255. package/src/nat/secrets/index.ts +82 -0
  256. package/src/nat/secrets/keytar-store.ts +67 -0
  257. package/src/nat/types.ts +157 -0
  258. package/src/orchestrator/host-process-orchestrator.ts +626 -0
  259. package/src/orchestrator/index.ts +3 -0
  260. package/src/orchestrator/log-rotator.ts +53 -0
  261. package/src/orchestrator/pid-liveness.ts +24 -0
  262. package/src/orchestrator/port-allocator.ts +43 -0
  263. package/src/orchestrator/state-store.ts +72 -0
  264. package/src/orchestrator/types.ts +95 -0
  265. package/src/server/__tests__/bus.test.ts +38 -0
  266. package/src/server/__tests__/error-handler.test.ts +96 -0
  267. package/src/server/__tests__/nodes-route.test.ts +179 -0
  268. package/src/server/__tests__/origin-guard.test.ts +72 -0
  269. package/src/server/__tests__/publishers.test.ts +216 -0
  270. package/src/server/__tests__/server.smoke.test.ts +178 -0
  271. package/src/server/__tests__/settings-route.test.ts +177 -0
  272. package/src/server/__tests__/sse-route.test.ts +119 -0
  273. package/src/server/__tests__/status-route.test.ts +113 -0
  274. package/src/server/error-handler.ts +108 -0
  275. package/src/server/events/bus.ts +42 -0
  276. package/src/server/events/sse-route.ts +81 -0
  277. package/src/server/events/types.ts +22 -0
  278. package/src/server/index.ts +209 -0
  279. package/src/server/origin-guard.ts +84 -0
  280. package/src/server/routes/nat.ts +63 -0
  281. package/src/server/routes/nodes.ts +147 -0
  282. package/src/server/routes/settings.ts +115 -0
  283. package/src/server/routes/status.ts +108 -0
  284. package/src/server/routes/trust-circle.ts +49 -0
  285. package/src/server/routes/update.ts +43 -0
  286. package/src/server/server.ts +122 -0
  287. package/src/server/settings-store.ts +37 -0
  288. package/src/server/static.ts +191 -0
  289. package/src/update/__tests__/apply.test.ts +101 -0
  290. package/src/update/__tests__/manifest.test.ts +93 -0
  291. package/src/update/__tests__/store.test.ts +81 -0
  292. package/src/update/__tests__/update-service.integration.test.ts +221 -0
  293. package/src/update/__tests__/version.test.ts +31 -0
  294. package/src/update/apply.ts +148 -0
  295. package/src/update/index.ts +303 -0
  296. package/src/update/manifest.ts +172 -0
  297. package/src/update/release-key.ts +55 -0
  298. package/src/update/store.ts +101 -0
  299. package/src/update/types.ts +109 -0
  300. package/src/update/version.ts +80 -0
@@ -0,0 +1,128 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { generatePrivateKey, getPublicKey } from '@optimystic/quereus-plugin-crypto';
6
+ import { generateKeyPair } from '@libp2p/crypto/keys';
7
+ import { peerIdFromPrivateKey } from '@libp2p/peer-id';
8
+ import { CadreNode } from '@serfab/cadre-core';
9
+
10
+ import { TrustCircleService } from '../trust-circle.js';
11
+ import { TrustCircleStore } from '../trust-circle-store.js';
12
+
13
+ /**
14
+ * End-to-end integration test: stand up a real host CadreNode with a real
15
+ * Quereus control DB and exercise the full TrustCircleService issue → redeem
16
+ * → list → remove cycle. The "phone" peerId is derived from a fresh Ed25519
17
+ * key (no second libp2p node) since the test only validates host-side state.
18
+ *
19
+ * This replaces the mock CadreNodeLike in trust-circle.test.ts with the real
20
+ * CadreNode to validate that:
21
+ * - `acceptPhone` (called from redeemInvite with a sparse reconstructed
22
+ * invite) actually inserts a CadrePeer row;
23
+ * - `list()` picks up the new peer via the canonical CadrePeer query;
24
+ * - `removeMember` deletes the row from CadrePeer via the
25
+ * DELETE-with-context syntax that cadre-core trusts from
26
+ * `quereus/docs/sql.md`.
27
+ */
28
+ describe('TrustCircleService — real CadreNode integration', () => {
29
+ let tmpRoot: string;
30
+ let host: CadreNode;
31
+ let store: TrustCircleStore;
32
+ let service: TrustCircleService;
33
+ let hostAuthorityPrivateKey: string;
34
+ let hostAuthorityPublicKey: string;
35
+
36
+ beforeEach(async () => {
37
+ tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-tc-int-'));
38
+
39
+ hostAuthorityPrivateKey = generatePrivateKey('ed25519', 'base64url') as string;
40
+ hostAuthorityPublicKey = getPublicKey(
41
+ hostAuthorityPrivateKey,
42
+ 'ed25519',
43
+ 'base64url',
44
+ 'base64url'
45
+ ) as string;
46
+
47
+ const baseId = Math.random().toString(36).slice(2);
48
+
49
+ host = new CadreNode({
50
+ controlNetwork: { partyId: `host-${baseId}`, bootstrapNodes: [] },
51
+ profile: 'transaction'
52
+ });
53
+
54
+ await host.start();
55
+
56
+ const db = host.getControlDatabase();
57
+ expect(db).not.toBeNull();
58
+ await db!.insertAuthorityKey(hostAuthorityPublicKey);
59
+
60
+ host.initializeSeedBootstrap(hostAuthorityPrivateKey);
61
+
62
+ store = new TrustCircleStore(tmpRoot);
63
+ service = new TrustCircleService({ cadreNode: host, store });
64
+ }, 60_000);
65
+
66
+ afterEach(async () => {
67
+ try { await host.stop(); } catch { /* ignore */ }
68
+ try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
69
+ });
70
+
71
+ it('completes the full issue → redeem → list → remove cycle', async () => {
72
+ // Derive a shape-valid peerId from a fresh Ed25519 key — same pattern as
73
+ // packages/cadre-core/test/seed-bootstrap.spec.ts. The AuthorizedInsert
74
+ // constraint only checks the signature over digest(PeerId,…); peerId
75
+ // *liveness* doesn't matter for the host-side CRUD path under test.
76
+ const phoneKey = await generateKeyPair('Ed25519');
77
+ const phonePeerId = peerIdFromPrivateKey(phoneKey).toString();
78
+
79
+ // Issue
80
+ const issued = await service.issueInvite({ label: "Mom's phone" });
81
+ expect(issued.token).toBeTruthy();
82
+ expect(issued.encodedInvite).toBeTruthy();
83
+
84
+ // Redeem — this calls host.acceptPhone() with the sparse reconstructed
85
+ // invite, which in turn calls authorizePeer and inserts CadrePeer.
86
+ const redeemed = await service.redeemInvite({
87
+ token: issued.token,
88
+ peerId: phonePeerId
89
+ });
90
+ expect(redeemed).toEqual({ peerId: phonePeerId, label: "Mom's phone" });
91
+
92
+ // Verify directly against the control DB: the row is there.
93
+ expect(await isInCadrePeer(host, phonePeerId)).toBe(true);
94
+
95
+ // List — should see the peer once, labelled.
96
+ const snap = await service.list();
97
+ expect(snap.members).toHaveLength(1);
98
+ expect(snap.members[0]!.peerId).toBe(phonePeerId);
99
+ expect(snap.members[0]!.label).toBe("Mom's phone");
100
+ expect(snap.pending).toHaveLength(0);
101
+
102
+ // Remove
103
+ await service.removeMember(phonePeerId);
104
+
105
+ // CadrePeer row is gone (validates DELETE-with-context end-to-end).
106
+ expect(await isInCadrePeer(host, phonePeerId)).toBe(false);
107
+
108
+ // Local label is gone.
109
+ expect(store.getMember(phonePeerId)).toBeUndefined();
110
+
111
+ // list() reflects the removal.
112
+ const afterRemove = await service.list();
113
+ expect(afterRemove.members).toHaveLength(0);
114
+ }, 90_000);
115
+ });
116
+
117
+ async function isInCadrePeer(node: CadreNode, peerId: string): Promise<boolean> {
118
+ const db = node.getControlDatabase();
119
+ if (!db) return false;
120
+ const inner = db.getDatabase();
121
+ for await (const row of inner.eval(
122
+ 'select PeerId from CadreControl.CadrePeer where PeerId = ?',
123
+ [peerId]
124
+ )) {
125
+ if (row.PeerId === peerId) return true;
126
+ }
127
+ return false;
128
+ }
@@ -0,0 +1,115 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ import { TrustCircleStore } from '../trust-circle-store.js';
7
+ import { TrustCircleError } from '../types.js';
8
+
9
+ let tmpRoot: string;
10
+
11
+ beforeEach(() => {
12
+ tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-tc-store-'));
13
+ });
14
+
15
+ afterEach(() => {
16
+ try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
17
+ });
18
+
19
+ describe('TrustCircleStore', () => {
20
+ it('returns empty state when file is missing', () => {
21
+ const store = new TrustCircleStore(tmpRoot);
22
+ expect(store.listMembers()).toEqual([]);
23
+ expect(store.listPending()).toEqual([]);
24
+ });
25
+
26
+ it('round-trips members through save/load', () => {
27
+ const store = new TrustCircleStore(tmpRoot);
28
+ store.addMember({
29
+ peerId: '12D3KooWAlice',
30
+ label: "Alice's phone",
31
+ addedAt: '2025-01-01T00:00:00.000Z',
32
+ });
33
+ store.addMember({
34
+ peerId: '12D3KooWBob',
35
+ label: 'Bob laptop',
36
+ addedAt: '2025-01-02T00:00:00.000Z',
37
+ self: true,
38
+ });
39
+
40
+ // Fresh instance reads from disk.
41
+ const reread = new TrustCircleStore(tmpRoot);
42
+ const members = reread.listMembers();
43
+ expect(members).toHaveLength(2);
44
+ expect(reread.getMember('12D3KooWAlice')).toMatchObject({ label: "Alice's phone" });
45
+ expect(reread.getMember('12D3KooWBob')?.self).toBe(true);
46
+ });
47
+
48
+ it('round-trips pending invites', () => {
49
+ const store = new TrustCircleStore(tmpRoot);
50
+ store.addPending({
51
+ token: 'token-A',
52
+ label: 'Mom',
53
+ createdAt: '2025-01-01T00:00:00.000Z',
54
+ expiresAt: '2025-01-02T00:00:00.000Z',
55
+ });
56
+ store.addPending({
57
+ token: 'token-B',
58
+ label: 'Dad',
59
+ createdAt: '2025-01-02T00:00:00.000Z',
60
+ });
61
+
62
+ const reread = new TrustCircleStore(tmpRoot);
63
+ expect(reread.listPending()).toHaveLength(2);
64
+ expect(reread.getPending('token-A')?.label).toBe('Mom');
65
+ expect(reread.getPending('token-B')?.expiresAt).toBeUndefined();
66
+ });
67
+
68
+ it('removes members and pending invites', () => {
69
+ const store = new TrustCircleStore(tmpRoot);
70
+ store.addMember({ peerId: 'p1', label: 'one', addedAt: 't' });
71
+ store.addPending({ token: 'tok', label: 'two', createdAt: 't' });
72
+
73
+ expect(store.removeMember('p1')).toBe(true);
74
+ expect(store.removeMember('p1')).toBe(false);
75
+ expect(store.removePending('tok')).toBe(true);
76
+ expect(store.removePending('tok')).toBe(false);
77
+
78
+ const reread = new TrustCircleStore(tmpRoot);
79
+ expect(reread.listMembers()).toEqual([]);
80
+ expect(reread.listPending()).toEqual([]);
81
+ });
82
+
83
+ it('writes through a temp file (atomic rename)', () => {
84
+ const store = new TrustCircleStore(tmpRoot);
85
+ store.addMember({ peerId: 'p1', label: 'one', addedAt: 't' });
86
+
87
+ // After save, no .tmp residue should remain.
88
+ expect(existsSync(join(tmpRoot, 'trust-circle.json'))).toBe(true);
89
+ expect(existsSync(join(tmpRoot, 'trust-circle.json.tmp'))).toBe(false);
90
+ });
91
+
92
+ it('throws on malformed file rather than silently wiping', () => {
93
+ writeFileSync(join(tmpRoot, 'trust-circle.json'), 'not json', 'utf8');
94
+ const store = new TrustCircleStore(tmpRoot);
95
+ expect(() => store.load()).toThrow(TrustCircleError);
96
+ });
97
+
98
+ it('throws on file with wrong shape', () => {
99
+ writeFileSync(
100
+ join(tmpRoot, 'trust-circle.json'),
101
+ JSON.stringify({ version: 99, foo: 'bar' }),
102
+ 'utf8',
103
+ );
104
+ const store = new TrustCircleStore(tmpRoot);
105
+ expect(() => store.load()).toThrow(TrustCircleError);
106
+ });
107
+
108
+ it('persists version field in saved JSON', () => {
109
+ const store = new TrustCircleStore(tmpRoot);
110
+ store.addMember({ peerId: 'p1', label: 'one', addedAt: 't' });
111
+ const raw = JSON.parse(readFileSync(join(tmpRoot, 'trust-circle.json'), 'utf8'));
112
+ expect(raw.version).toBe(1);
113
+ expect(raw.members.p1.label).toBe('one');
114
+ });
115
+ });
@@ -0,0 +1,334 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ import type { CadreInvite } from '@serfab/cadre-core';
7
+
8
+ import { TrustCircleService, createTrustCircleHandlers } from '../trust-circle.js';
9
+ import { TrustCircleStore } from '../trust-circle-store.js';
10
+ import { TrustCircleError } from '../types.js';
11
+ import type { CadreNodeLike } from '../trust-circle.js';
12
+
13
+ /**
14
+ * Minimal in-memory mock of CadreNode + ControlDatabase. Records calls so
15
+ * assertions can verify the auth + redemption flow without standing up a
16
+ * real libp2p control network.
17
+ */
18
+ class MockCadreNode implements CadreNodeLike {
19
+ readonly authorizedPeers = new Set<string>();
20
+ readonly issuedInvites: Array<{ token?: string; expiresIn?: number }> = [];
21
+ readonly accepted: Array<{ phonePeerId: string; token?: string }> = [];
22
+ readonly removed: string[] = [];
23
+
24
+ /** Toggle to make acceptPhone reject. */
25
+ acceptShouldThrow: Error | null = null;
26
+ /** Toggle to make getControlDatabase return null (simulate offline). */
27
+ dbAvailable = true;
28
+
29
+ async createInvite(token?: string, expiresIn?: number) {
30
+ this.issuedInvites.push({ token, expiresIn });
31
+ const invite: CadreInvite = {
32
+ partyId: 'party-test',
33
+ authorityAddrs: ['/ip4/127.0.0.1/tcp/4001'],
34
+ token,
35
+ createdAt: Date.now(),
36
+ ...(expiresIn ? { expiresAt: Date.now() + expiresIn } : {}),
37
+ };
38
+ return { invite, encodedInvite: 'enc:' + JSON.stringify(invite) };
39
+ }
40
+
41
+ async acceptPhone(options: { phonePeerId: string; token?: string }, _issuedInvite?: CadreInvite) {
42
+ if (this.acceptShouldThrow) throw this.acceptShouldThrow;
43
+ this.accepted.push({ ...options });
44
+ this.authorizedPeers.add(options.phonePeerId);
45
+ }
46
+
47
+ async removePeer(peerId: string) {
48
+ this.removed.push(peerId);
49
+ this.authorizedPeers.delete(peerId);
50
+ }
51
+
52
+ encodeInvite(invite: CadreInvite): string {
53
+ return 'enc:' + JSON.stringify(invite);
54
+ }
55
+
56
+ getControlDatabase() {
57
+ if (!this.dbAvailable) return null;
58
+ const peers = this.authorizedPeers;
59
+ return {
60
+ getDatabase: () => ({
61
+ async *eval(_sql: string, params?: unknown[]) {
62
+ if (params && params.length > 0) {
63
+ const target = params[0] as string;
64
+ if (peers.has(target)) yield { PeerId: target };
65
+ return;
66
+ }
67
+ for (const p of peers) yield { PeerId: p };
68
+ },
69
+ }),
70
+ } as unknown as ReturnType<CadreNodeLike['getControlDatabase']>;
71
+ }
72
+ }
73
+
74
+ let tmpRoot: string;
75
+ let cadreNode: MockCadreNode;
76
+ let store: TrustCircleStore;
77
+ let service: TrustCircleService;
78
+
79
+ beforeEach(() => {
80
+ tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-tc-svc-'));
81
+ cadreNode = new MockCadreNode();
82
+ store = new TrustCircleStore(tmpRoot);
83
+ service = new TrustCircleService({ cadreNode, store });
84
+ });
85
+
86
+ afterEach(() => {
87
+ try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
88
+ });
89
+
90
+ describe('TrustCircleService.issueInvite', () => {
91
+ it('creates a pending row, returns an encoded invite, and uses default TTL', async () => {
92
+ const result = await service.issueInvite({ label: "Mom's phone" });
93
+
94
+ expect(result.token).toBeTruthy();
95
+ expect(result.encodedInvite.startsWith('enc:')).toBe(true);
96
+ expect(result.expiresAt).toBeInstanceOf(Date);
97
+
98
+ const pending = store.getPending(result.token);
99
+ expect(pending).toBeDefined();
100
+ expect(pending!.label).toBe("Mom's phone");
101
+ expect(pending!.expiresAt).toBe(result.expiresAt!.toISOString());
102
+
103
+ expect(cadreNode.issuedInvites).toHaveLength(1);
104
+ expect(cadreNode.issuedInvites[0]!.token).toBe(result.token);
105
+ });
106
+
107
+ it('rejects empty / whitespace labels', async () => {
108
+ await expect(service.issueInvite({ label: ' ' })).rejects.toBeInstanceOf(TrustCircleError);
109
+ await expect(service.issueInvite({ label: '' })).rejects.toBeInstanceOf(TrustCircleError);
110
+ });
111
+
112
+ it('honours custom ttl', async () => {
113
+ const start = Date.now();
114
+ const ttlMs = 5 * 60 * 1000;
115
+ const result = await service.issueInvite({ label: 'Carol', ttlMs });
116
+ const expiresMs = result.expiresAt!.getTime();
117
+ expect(expiresMs - start).toBeGreaterThanOrEqual(ttlMs - 50);
118
+ expect(expiresMs - start).toBeLessThanOrEqual(ttlMs + 500);
119
+ });
120
+
121
+ it('rejects non-positive ttl', async () => {
122
+ await expect(service.issueInvite({ label: 'X', ttlMs: 0 })).rejects.toBeInstanceOf(TrustCircleError);
123
+ await expect(service.issueInvite({ label: 'X', ttlMs: -1 })).rejects.toBeInstanceOf(TrustCircleError);
124
+ });
125
+ });
126
+
127
+ describe('TrustCircleService.redeemInvite', () => {
128
+ it('happy path: authorises peer, consumes pending, writes member', async () => {
129
+ const { token } = await service.issueInvite({ label: "Mom's phone" });
130
+
131
+ const result = await service.redeemInvite({ token, peerId: '12D3KooWMom' });
132
+ expect(result).toEqual({ peerId: '12D3KooWMom', label: "Mom's phone" });
133
+
134
+ expect(cadreNode.accepted).toHaveLength(1);
135
+ expect(cadreNode.accepted[0]).toMatchObject({ phonePeerId: '12D3KooWMom', token });
136
+ expect(cadreNode.authorizedPeers.has('12D3KooWMom')).toBe(true);
137
+
138
+ expect(store.getPending(token)).toBeUndefined();
139
+ expect(store.getMember('12D3KooWMom')?.label).toBe("Mom's phone");
140
+ });
141
+
142
+ it('rejects unknown token', async () => {
143
+ await expect(service.redeemInvite({ token: 'nope', peerId: 'p1' }))
144
+ .rejects.toMatchObject({ code: 'already_redeemed' });
145
+ expect(cadreNode.accepted).toHaveLength(0);
146
+ });
147
+
148
+ it('rejects expired invite and reaps the pending row', async () => {
149
+ let clock = new Date('2025-01-01T00:00:00Z');
150
+ const svc = new TrustCircleService({ cadreNode, store, now: () => clock });
151
+ const { token } = await svc.issueInvite({ label: 'Dad', ttlMs: 60_000 });
152
+
153
+ // Move clock past expiry.
154
+ clock = new Date('2025-01-02T00:00:00Z');
155
+
156
+ await expect(svc.redeemInvite({ token, peerId: 'p1' }))
157
+ .rejects.toMatchObject({ code: 'expired' });
158
+ expect(store.getPending(token)).toBeUndefined();
159
+ expect(cadreNode.accepted).toHaveLength(0);
160
+ });
161
+
162
+ it('double-redeem: second attempt is rejected because pending was consumed', async () => {
163
+ const { token } = await service.issueInvite({ label: 'Carol' });
164
+
165
+ await service.redeemInvite({ token, peerId: 'pA' });
166
+ await expect(service.redeemInvite({ token, peerId: 'pB' }))
167
+ .rejects.toMatchObject({ code: 'already_redeemed' });
168
+
169
+ // The second attempt must NOT have authorised pB.
170
+ expect(cadreNode.authorizedPeers.has('pB')).toBe(false);
171
+ });
172
+
173
+ it('requires both token and peerId', async () => {
174
+ await expect(service.redeemInvite({ token: '', peerId: 'p1' }))
175
+ .rejects.toBeInstanceOf(TrustCircleError);
176
+ await expect(service.redeemInvite({ token: 't', peerId: '' }))
177
+ .rejects.toBeInstanceOf(TrustCircleError);
178
+ });
179
+
180
+ it('claims pending atomically so a concurrent second call cannot also redeem', async () => {
181
+ const { token } = await service.issueInvite({ label: 'Race' });
182
+
183
+ // Block acceptPhone until we release it, simulating the async window
184
+ // between getPending() and removePending() under naive ordering.
185
+ let release!: () => void;
186
+ const gate = new Promise<void>((r) => { release = r; });
187
+ const originalAccept = cadreNode.acceptPhone.bind(cadreNode);
188
+ cadreNode.acceptPhone = async (options, issued) => {
189
+ await gate;
190
+ await originalAccept(options, issued);
191
+ };
192
+
193
+ const first = service.redeemInvite({ token, peerId: 'pA' });
194
+ const second = service.redeemInvite({ token, peerId: 'pB' });
195
+
196
+ // The second call must be rejected synchronously (before the gate opens)
197
+ // because the pending row was already claimed by `first`.
198
+ await expect(second).rejects.toMatchObject({ code: 'already_redeemed' });
199
+
200
+ release();
201
+ await expect(first).resolves.toMatchObject({ peerId: 'pA' });
202
+
203
+ // Only one peer was authorized.
204
+ expect(cadreNode.authorizedPeers.has('pA')).toBe(true);
205
+ expect(cadreNode.authorizedPeers.has('pB')).toBe(false);
206
+ });
207
+ });
208
+
209
+ describe('TrustCircleService.issueInvite — label length', () => {
210
+ it('rejects labels over 200 characters', async () => {
211
+ await expect(service.issueInvite({ label: 'a'.repeat(201) }))
212
+ .rejects.toMatchObject({ code: 'invalid_label' });
213
+ });
214
+
215
+ it('accepts labels at the 200-character limit', async () => {
216
+ await expect(service.issueInvite({ label: 'a'.repeat(200) }))
217
+ .resolves.toBeDefined();
218
+ });
219
+ });
220
+
221
+ describe('TrustCircleService.revokePending', () => {
222
+ it('removes a pending row and prevents subsequent redemption', async () => {
223
+ const { token } = await service.issueInvite({ label: 'Eve' });
224
+ await service.revokePending(token);
225
+ expect(store.getPending(token)).toBeUndefined();
226
+
227
+ await expect(service.redeemInvite({ token, peerId: 'p1' }))
228
+ .rejects.toMatchObject({ code: 'already_redeemed' });
229
+ });
230
+
231
+ it('throws not_found for an unknown token', async () => {
232
+ await expect(service.revokePending('nope'))
233
+ .rejects.toMatchObject({ code: 'not_found' });
234
+ });
235
+ });
236
+
237
+ describe('TrustCircleService.removeMember', () => {
238
+ it('removes from CadrePeer and the local label store', async () => {
239
+ const { token } = await service.issueInvite({ label: 'Frank' });
240
+ await service.redeemInvite({ token, peerId: 'pF' });
241
+
242
+ await service.removeMember('pF');
243
+ expect(cadreNode.removed).toEqual(['pF']);
244
+ expect(cadreNode.authorizedPeers.has('pF')).toBe(false);
245
+ expect(store.getMember('pF')).toBeUndefined();
246
+ });
247
+
248
+ it('throws not_found for an unknown peer', async () => {
249
+ await expect(service.removeMember('unknown'))
250
+ .rejects.toMatchObject({ code: 'not_found' });
251
+ });
252
+
253
+ it('removes a control-only peer (CadrePeer row without a local label)', async () => {
254
+ cadreNode.authorizedPeers.add('pCtrlOnly');
255
+ expect(store.getMember('pCtrlOnly')).toBeUndefined();
256
+
257
+ await service.removeMember('pCtrlOnly');
258
+ expect(cadreNode.removed).toEqual(['pCtrlOnly']);
259
+ expect(cadreNode.authorizedPeers.has('pCtrlOnly')).toBe(false);
260
+ });
261
+ });
262
+
263
+ describe('TrustCircleService.isMember', () => {
264
+ it('reads from the CadrePeer table', async () => {
265
+ expect(await service.isMember('pX')).toBe(false);
266
+ cadreNode.authorizedPeers.add('pX');
267
+ expect(await service.isMember('pX')).toBe(true);
268
+ });
269
+ });
270
+
271
+ describe('TrustCircleService.list', () => {
272
+ it('joins CadrePeer with local labels and prunes orphan labels', async () => {
273
+ // Pre-seed a label whose peer is NOT in CadrePeer — should be pruned.
274
+ store.addMember({ peerId: 'orphan', label: 'ghost', addedAt: 't' });
275
+
276
+ const r1 = await service.issueInvite({ label: 'Alice' });
277
+ await service.redeemInvite({ token: r1.token, peerId: 'pA' });
278
+ const r2 = await service.issueInvite({ label: 'Bob' });
279
+ await service.redeemInvite({ token: r2.token, peerId: 'pB' });
280
+ // A still-pending invite that should appear in `pending`.
281
+ const r3 = await service.issueInvite({ label: 'Carol' });
282
+
283
+ const snap = await service.list();
284
+
285
+ expect(snap.members.map(m => m.peerId).sort()).toEqual(['pA', 'pB']);
286
+ expect(snap.members.find(m => m.peerId === 'pA')?.label).toBe('Alice');
287
+ expect(snap.members.find(m => m.peerId === 'pB')?.label).toBe('Bob');
288
+ expect(snap.pending.map(p => p.token)).toContain(r3.token);
289
+
290
+ expect(store.getMember('orphan')).toBeUndefined();
291
+ });
292
+
293
+ it('falls back to local labels when control DB is unavailable', async () => {
294
+ store.addMember({ peerId: 'pZ', label: 'Zoe', addedAt: 't' });
295
+ cadreNode.dbAvailable = false;
296
+
297
+ const snap = await service.list();
298
+ expect(snap.members.some(m => m.peerId === 'pZ' && m.label === 'Zoe')).toBe(true);
299
+ // Orphan check is skipped when DB is unavailable.
300
+ expect(store.getMember('pZ')).toBeDefined();
301
+ });
302
+
303
+ it('shows unlabelled CadrePeer rows with peerId as label', async () => {
304
+ cadreNode.authorizedPeers.add('unlabeled-peer');
305
+ const snap = await service.list();
306
+ const entry = snap.members.find(m => m.peerId === 'unlabeled-peer');
307
+ expect(entry?.label).toBe('unlabeled-peer');
308
+ });
309
+ });
310
+
311
+ describe('createTrustCircleHandlers', () => {
312
+ it('exposes the HTTP-handler surface', async () => {
313
+ const handlers = createTrustCircleHandlers(service);
314
+
315
+ const issued = await handlers.postInvite({ label: 'Pat' });
316
+ expect(issued.encodedInvite.startsWith('enc:')).toBe(true);
317
+ expect(typeof issued.token).toBe('string');
318
+ expect(issued.expiresAt).toBeTruthy();
319
+
320
+ let snap = await handlers.listTrustCircle();
321
+ expect(snap.pending.map(p => p.token)).toContain(issued.token);
322
+ expect(snap.members).toEqual([]);
323
+
324
+ await handlers.deleteInvite(issued.token);
325
+ snap = await handlers.listTrustCircle();
326
+ expect(snap.pending).toEqual([]);
327
+ });
328
+
329
+ it('postInvite rejects missing label', async () => {
330
+ const handlers = createTrustCircleHandlers(service);
331
+ await expect(handlers.postInvite({ label: undefined as unknown as string }))
332
+ .rejects.toMatchObject({ code: 'invalid_label' });
333
+ });
334
+ });
@@ -0,0 +1,33 @@
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: Record<string, number> = {
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
+
18
+ const DURATION_RE = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)?$/;
19
+
20
+ export function parseDuration(input: string): number {
21
+ const trimmed = input.trim();
22
+ const match = DURATION_RE.exec(trimmed);
23
+ if (!match) {
24
+ throw new Error(`Invalid duration: ${JSON.stringify(input)} (expected e.g. "24h", "7d", "30m")`);
25
+ }
26
+ const value = parseFloat(match[1]!);
27
+ const unit = match[2] ?? 'ms';
28
+ const multiplier = UNIT_MULTIPLIERS[unit];
29
+ if (multiplier === undefined) {
30
+ throw new Error(`Unknown duration unit: ${unit}`);
31
+ }
32
+ return Math.round(value * multiplier);
33
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Trust-circle auth for cadre-host. See ./trust-circle.ts and ./types.ts.
3
+ */
4
+
5
+ export { TrustCircleService, createTrustCircleHandlers, DEFAULT_INVITE_TTL_MS } from './trust-circle.js';
6
+ export type { TrustCircleServiceOptions, CadreNodeLike } from './trust-circle.js';
7
+ export { TrustCircleStore } from './trust-circle-store.js';
8
+ export { parseDuration } from './duration.js';
9
+ export {
10
+ TrustCircleError,
11
+ } from './types.js';
12
+ export type {
13
+ TrustCircleMember,
14
+ PendingInvite,
15
+ TrustCircleFile,
16
+ TrustCircleSnapshot,
17
+ TrustCircleHandlers,
18
+ TrustCircleErrorCode,
19
+ } from './types.js';