@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,155 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import debug from 'debug';
4
+
5
+ import type {
6
+ PendingInvite,
7
+ TrustCircleFile,
8
+ TrustCircleMember,
9
+ } from './types.js';
10
+ import { TrustCircleError } from './types.js';
11
+
12
+ const log = debug('cadre:host:trust-circle-store');
13
+
14
+ const FILE_VERSION = 1;
15
+
16
+ /**
17
+ * Atomic JSON store for trust-circle.json. Mirrors the pattern in
18
+ * `orchestrator/state-store.ts`: write to `<path>.tmp`, then rename.
19
+ *
20
+ * Concurrency assumption: only one cadre-host process owns a given rootDir
21
+ * (the orchestrator already enforces this). No file-locking primitives.
22
+ */
23
+ export class TrustCircleStore {
24
+ private readonly path: string;
25
+ private cache: TrustCircleFile | null = null;
26
+
27
+ constructor(rootDir: string) {
28
+ mkdirSync(rootDir, { recursive: true });
29
+ this.path = join(rootDir, 'trust-circle.json');
30
+ }
31
+
32
+ filePath(): string {
33
+ return this.path;
34
+ }
35
+
36
+ /** Load (with caching). A missing file returns an empty store. */
37
+ load(): TrustCircleFile {
38
+ if (this.cache) return this.cache;
39
+ if (!existsSync(this.path)) {
40
+ this.cache = emptyFile();
41
+ return this.cache;
42
+ }
43
+ let raw: string;
44
+ try {
45
+ raw = readFileSync(this.path, 'utf8');
46
+ } catch (err) {
47
+ throw new TrustCircleError(
48
+ 'storage_error',
49
+ `failed to read trust-circle file at ${this.path}: ${(err as Error).message}`,
50
+ );
51
+ }
52
+ let parsed: unknown;
53
+ try {
54
+ parsed = JSON.parse(raw);
55
+ } catch (err) {
56
+ throw new TrustCircleError(
57
+ 'storage_error',
58
+ `trust-circle file at ${this.path} is not valid JSON: ${(err as Error).message}`,
59
+ );
60
+ }
61
+ if (!isTrustCircleFile(parsed)) {
62
+ throw new TrustCircleError(
63
+ 'storage_error',
64
+ `trust-circle file at ${this.path} has unexpected shape`,
65
+ );
66
+ }
67
+ this.cache = parsed;
68
+ return this.cache;
69
+ }
70
+
71
+ /** Persist the current state atomically (write then rename). */
72
+ save(state: TrustCircleFile): void {
73
+ mkdirSync(dirname(this.path), { recursive: true });
74
+ const payload = JSON.stringify({ ...state, version: FILE_VERSION }, null, 2);
75
+ const tmp = `${this.path}.tmp`;
76
+ writeFileSync(tmp, payload, { encoding: 'utf8' });
77
+ renameSync(tmp, this.path);
78
+ this.cache = { ...state, version: FILE_VERSION };
79
+ log('saved trust-circle (%d members, %d pending) to %s',
80
+ Object.keys(state.members).length,
81
+ Object.keys(state.pendingInvites).length,
82
+ this.path);
83
+ }
84
+
85
+ // --- Member ops ---
86
+
87
+ addMember(member: TrustCircleMember): void {
88
+ const state = this.load();
89
+ const { peerId, ...rest } = member;
90
+ state.members[peerId] = rest;
91
+ this.save(state);
92
+ }
93
+
94
+ removeMember(peerId: string): boolean {
95
+ const state = this.load();
96
+ if (!(peerId in state.members)) return false;
97
+ delete state.members[peerId];
98
+ this.save(state);
99
+ return true;
100
+ }
101
+
102
+ getMember(peerId: string): TrustCircleMember | undefined {
103
+ const state = this.load();
104
+ const row = state.members[peerId];
105
+ if (!row) return undefined;
106
+ return { peerId, ...row };
107
+ }
108
+
109
+ listMembers(): TrustCircleMember[] {
110
+ const state = this.load();
111
+ return Object.entries(state.members).map(([peerId, row]) => ({ peerId, ...row }));
112
+ }
113
+
114
+ // --- Pending invite ops ---
115
+
116
+ addPending(invite: PendingInvite): void {
117
+ const state = this.load();
118
+ const { token, ...rest } = invite;
119
+ state.pendingInvites[token] = rest;
120
+ this.save(state);
121
+ }
122
+
123
+ removePending(token: string): boolean {
124
+ const state = this.load();
125
+ if (!(token in state.pendingInvites)) return false;
126
+ delete state.pendingInvites[token];
127
+ this.save(state);
128
+ return true;
129
+ }
130
+
131
+ getPending(token: string): PendingInvite | undefined {
132
+ const state = this.load();
133
+ const row = state.pendingInvites[token];
134
+ if (!row) return undefined;
135
+ return { token, ...row };
136
+ }
137
+
138
+ listPending(): PendingInvite[] {
139
+ const state = this.load();
140
+ return Object.entries(state.pendingInvites).map(([token, row]) => ({ token, ...row }));
141
+ }
142
+ }
143
+
144
+ function emptyFile(): TrustCircleFile {
145
+ return { version: FILE_VERSION, members: {}, pendingInvites: {} };
146
+ }
147
+
148
+ function isTrustCircleFile(v: unknown): v is TrustCircleFile {
149
+ if (!v || typeof v !== 'object') return false;
150
+ const obj = v as Record<string, unknown>;
151
+ if (obj.version !== FILE_VERSION) return false;
152
+ if (!obj.members || typeof obj.members !== 'object') return false;
153
+ if (!obj.pendingInvites || typeof obj.pendingInvites !== 'object') return false;
154
+ return true;
155
+ }
@@ -0,0 +1,304 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import debug from 'debug';
3
+
4
+ import type { CadreNode } from '@serfab/cadre-core';
5
+ import type { CadreInvite, ControlDatabase } from '@serfab/cadre-core';
6
+
7
+ import type { TrustCircleStore } from './trust-circle-store.js';
8
+ import type {
9
+ PendingInvite,
10
+ TrustCircleHandlers,
11
+ TrustCircleMember,
12
+ TrustCircleSnapshot,
13
+ } from './types.js';
14
+ import { TrustCircleError } from './types.js';
15
+
16
+ const log = debug('cadre:host:trust-circle');
17
+
18
+ /** Default invite lifetime: 24 hours. */
19
+ export const DEFAULT_INVITE_TTL_MS = 24 * 60 * 60 * 1000;
20
+
21
+ /** Token byte length (base64url-encoded; ~43 chars). */
22
+ const TOKEN_BYTES = 32;
23
+
24
+ /** Constructor options. */
25
+ export interface TrustCircleServiceOptions {
26
+ /** The host's cadre node — provides createInvite / acceptPhone / removePeer. */
27
+ cadreNode: CadreNodeLike;
28
+ /** Local labels + pending-invites store. */
29
+ store: TrustCircleStore;
30
+ /** Clock override for tests. */
31
+ now?: () => Date;
32
+ }
33
+
34
+ /**
35
+ * Minimal slice of CadreNode that the service uses. Defined as an interface
36
+ * (rather than reaching into CadreNode directly) so unit tests can inject
37
+ * a mock without standing up a real libp2p node.
38
+ */
39
+ export interface CadreNodeLike {
40
+ createInvite(token?: string, expiresIn?: number): Promise<{ invite: CadreInvite; encodedInvite: string }>;
41
+ acceptPhone(options: { phonePeerId: string; token?: string }, issuedInvite?: CadreInvite): Promise<void>;
42
+ removePeer(peerId: string): Promise<void>;
43
+ encodeInvite(invite: CadreInvite): string;
44
+ getControlDatabase(): ControlDatabase | null;
45
+ }
46
+
47
+ /**
48
+ * TrustCircleService — orchestrates invite issuance, redemption, and
49
+ * membership management.
50
+ *
51
+ * Membership is canonical in the cadre control DB (`CadrePeer` table);
52
+ * labels and pending tokens live in the host-local `TrustCircleStore`.
53
+ */
54
+ export class TrustCircleService {
55
+ private readonly cadreNode: CadreNodeLike;
56
+ private readonly store: TrustCircleStore;
57
+ private readonly now: () => Date;
58
+
59
+ constructor(opts: TrustCircleServiceOptions) {
60
+ this.cadreNode = opts.cadreNode;
61
+ this.store = opts.store;
62
+ this.now = opts.now ?? (() => new Date());
63
+ }
64
+
65
+ /**
66
+ * Issue a new invite. Generates a token, persists a pending row, and
67
+ * delegates to cadre-core to embed the host's authority addresses.
68
+ */
69
+ async issueInvite(opts: { label: string; ttlMs?: number }): Promise<{
70
+ encodedInvite: string;
71
+ invite: CadreInvite;
72
+ token: string;
73
+ expiresAt?: Date;
74
+ }> {
75
+ const label = normaliseLabel(opts.label);
76
+ const ttlMs = opts.ttlMs ?? DEFAULT_INVITE_TTL_MS;
77
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
78
+ throw new TrustCircleError('invalid_token', `Invalid ttlMs: ${ttlMs}`);
79
+ }
80
+
81
+ const token = generateToken();
82
+ const created = this.now();
83
+ const expiresAt = new Date(created.getTime() + ttlMs);
84
+
85
+ const { invite, encodedInvite } = await this.cadreNode.createInvite(token, ttlMs);
86
+
87
+ const pending: PendingInvite = {
88
+ token,
89
+ label,
90
+ createdAt: created.toISOString(),
91
+ expiresAt: expiresAt.toISOString(),
92
+ };
93
+ this.store.addPending(pending);
94
+
95
+ log('issued invite for "%s" (token=%s, expires=%s)', label, token, expiresAt.toISOString());
96
+
97
+ return { encodedInvite, invite, token, expiresAt };
98
+ }
99
+
100
+ /**
101
+ * Redeem an invite for an incoming peer ID. On success:
102
+ * - cadre-core authorizes the peer (inserts CadrePeer row).
103
+ * - The pending row is consumed (one-time enforcement).
104
+ * - A labelled member row is written locally.
105
+ */
106
+ async redeemInvite(opts: { token: string; peerId: string }): Promise<{ peerId: string; label: string }> {
107
+ const { token, peerId } = opts;
108
+ if (!token || !peerId) {
109
+ throw new TrustCircleError('invalid_token', 'token and peerId are required');
110
+ }
111
+
112
+ // Atomically claim the pending row before doing async work. This closes
113
+ // the race between two concurrent redemption requests for the same token —
114
+ // only the call that wins the synchronous removePending() proceeds; the
115
+ // other gets `already_redeemed`. The remove is durable (write-then-rename),
116
+ // so a crash after this point loses the token, which is the safe failure
117
+ // mode for a one-time credential.
118
+ const pending = this.store.getPending(token);
119
+ if (!pending || !this.store.removePending(token)) {
120
+ // Either never issued, or another concurrent caller just consumed it.
121
+ throw new TrustCircleError('already_redeemed', 'Invite not found or already redeemed');
122
+ }
123
+
124
+ if (pending.expiresAt) {
125
+ const expiresAt = new Date(pending.expiresAt);
126
+ if (Number.isFinite(expiresAt.getTime()) && this.now() > expiresAt) {
127
+ // Already removed above — just surface the expiry.
128
+ throw new TrustCircleError('expired', 'Invite has expired');
129
+ }
130
+ }
131
+
132
+ // Reconstruct the CadreInvite that was originally issued. cadre-core's
133
+ // acceptPhone validates `issuedInvite.token === options.token` (already
134
+ // matches by construction) and re-checks expiration.
135
+ const reconstructed: CadreInvite = {
136
+ partyId: '',
137
+ authorityAddrs: [],
138
+ token: pending.token,
139
+ createdAt: new Date(pending.createdAt).getTime(),
140
+ ...(pending.expiresAt ? { expiresAt: new Date(pending.expiresAt).getTime() } : {}),
141
+ };
142
+
143
+ await this.cadreNode.acceptPhone({ phonePeerId: peerId, token }, reconstructed);
144
+
145
+ // If the member write fails the peer is still authorized in CadrePeer —
146
+ // the next call to list() will pick it up unlabeled, which is the
147
+ // documented graceful degradation.
148
+ const member: TrustCircleMember = {
149
+ peerId,
150
+ label: pending.label,
151
+ addedAt: this.now().toISOString(),
152
+ };
153
+ this.store.addMember(member);
154
+
155
+ log('redeemed invite for "%s" (peerId=%s, token=%s)', pending.label, peerId, token);
156
+
157
+ return { peerId, label: pending.label };
158
+ }
159
+
160
+ /** Revoke a pending invite by token. No-op if not found. */
161
+ async revokePending(token: string): Promise<void> {
162
+ if (!this.store.removePending(token)) {
163
+ throw new TrustCircleError('not_found', `Unknown pending invite: ${token}`);
164
+ }
165
+ log('revoked pending invite (token=%s)', token);
166
+ }
167
+
168
+ /**
169
+ * Remove an authorised member. Deletes the CadrePeer row and the local
170
+ * label. Throws not_found when the peer is in neither place.
171
+ */
172
+ async removeMember(peerId: string): Promise<void> {
173
+ const inLocal = this.store.getMember(peerId);
174
+ const inControl = await this.isMember(peerId);
175
+
176
+ if (!inLocal && !inControl) {
177
+ throw new TrustCircleError('not_found', `Unknown member: ${peerId}`);
178
+ }
179
+
180
+ if (inControl) {
181
+ await this.cadreNode.removePeer(peerId);
182
+ }
183
+ if (inLocal) {
184
+ this.store.removeMember(peerId);
185
+ }
186
+ log('removed member peerId=%s', peerId);
187
+ }
188
+
189
+ /** Whether the peer is present in the cadre's CadrePeer table. */
190
+ async isMember(peerId: string): Promise<boolean> {
191
+ const db = this.cadreNode.getControlDatabase();
192
+ if (!db) return false;
193
+ const inner = db.getDatabase();
194
+ for await (const row of inner.eval(
195
+ 'select PeerId from CadreControl.CadrePeer where PeerId = ?',
196
+ [peerId],
197
+ )) {
198
+ if (row.PeerId === peerId) return true;
199
+ }
200
+ return false;
201
+ }
202
+
203
+ /**
204
+ * UI snapshot. Joins the canonical CadrePeer list with local labels;
205
+ * prunes any orphan labels (peer is no longer authorised).
206
+ */
207
+ async list(): Promise<TrustCircleSnapshot> {
208
+ const labels = new Map<string, Omit<TrustCircleMember, 'peerId'>>();
209
+ for (const m of this.store.listMembers()) {
210
+ labels.set(m.peerId, { label: m.label, addedAt: m.addedAt, ...(m.self ? { self: true } : {}) });
211
+ }
212
+
213
+ const members: TrustCircleMember[] = [];
214
+ const seenPeerIds = new Set<string>();
215
+ const db = this.cadreNode.getControlDatabase();
216
+ if (db) {
217
+ const inner = db.getDatabase();
218
+ for await (const row of inner.eval('select PeerId from CadreControl.CadrePeer')) {
219
+ const peerId = row.PeerId as string;
220
+ seenPeerIds.add(peerId);
221
+ const label = labels.get(peerId);
222
+ members.push({
223
+ peerId,
224
+ label: label?.label ?? peerId,
225
+ addedAt: label?.addedAt ?? '',
226
+ ...(label?.self ? { self: true } : {}),
227
+ });
228
+ }
229
+ }
230
+
231
+ // Prune labels for peers that no longer exist in CadrePeer.
232
+ // Only prune when we successfully consulted the control DB, otherwise
233
+ // we'd wipe labels for a transient connectivity issue.
234
+ if (db) {
235
+ for (const peerId of labels.keys()) {
236
+ if (!seenPeerIds.has(peerId)) {
237
+ this.store.removeMember(peerId);
238
+ }
239
+ }
240
+ } else {
241
+ // Fallback: return labels as-is.
242
+ for (const [peerId, row] of labels) {
243
+ members.push({ peerId, ...row });
244
+ }
245
+ }
246
+
247
+ const pending = this.store.listPending();
248
+
249
+ // Sort for stable UI ordering.
250
+ members.sort((a, b) => a.peerId.localeCompare(b.peerId));
251
+ pending.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
252
+
253
+ return { members, pending };
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Wrap a TrustCircleService into the typed handler shape consumed by
259
+ * `cadre-host-local-ui`. Errors propagate as TrustCircleError; the UI ticket
260
+ * maps `.code` → HTTP status.
261
+ */
262
+ export function createTrustCircleHandlers(service: TrustCircleService): TrustCircleHandlers {
263
+ return {
264
+ async postInvite(body) {
265
+ if (!body || typeof body.label !== 'string') {
266
+ throw new TrustCircleError('invalid_label', 'label is required');
267
+ }
268
+ const result = await service.issueInvite({ label: body.label, ttlMs: body.ttlMs });
269
+ return {
270
+ encodedInvite: result.encodedInvite,
271
+ token: result.token,
272
+ ...(result.expiresAt ? { expiresAt: result.expiresAt.toISOString() } : {}),
273
+ };
274
+ },
275
+ async deleteInvite(token) {
276
+ await service.revokePending(token);
277
+ },
278
+ async deleteMember(peerId) {
279
+ await service.removeMember(peerId);
280
+ },
281
+ async listTrustCircle() {
282
+ return await service.list();
283
+ },
284
+ };
285
+ }
286
+
287
+ function generateToken(): string {
288
+ // base64url so it survives in URLs, QR codes, and shell arguments.
289
+ return randomBytes(TOKEN_BYTES).toString('base64url');
290
+ }
291
+
292
+ function normaliseLabel(label: unknown): string {
293
+ if (typeof label !== 'string') {
294
+ throw new TrustCircleError('invalid_label', 'label must be a string');
295
+ }
296
+ const trimmed = label.trim();
297
+ if (trimmed.length === 0) {
298
+ throw new TrustCircleError('invalid_label', 'label must not be empty');
299
+ }
300
+ if (trimmed.length > 200) {
301
+ throw new TrustCircleError('invalid_label', 'label must be 200 characters or fewer');
302
+ }
303
+ return trimmed;
304
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Trust-circle types for cadre-host.
3
+ *
4
+ * The trust circle is the set of devices (peers) authorised to participate
5
+ * in the host's cadre. Membership is stored canonically in the cadre control
6
+ * network's `CadrePeer` table (via cadre-core). This module layers two pieces
7
+ * of *host-local* state on top:
8
+ *
9
+ * - **Labels**: human-readable names ("Mom's phone") assigned by the host
10
+ * admin. Display-only; loss just shows the bare peer ID.
11
+ * - **Pending invites**: tokens that have been issued but not yet redeemed.
12
+ * Operational state — lives on the issuing node, doesn't replicate.
13
+ *
14
+ * Both live in `<rootDir>/trust-circle.json`, written atomically.
15
+ */
16
+
17
+ /** An authorised member of the trust circle (matches a `CadrePeer` row). */
18
+ export interface TrustCircleMember {
19
+ /** libp2p peer ID. */
20
+ peerId: string;
21
+ /** Display label assigned by the host admin. */
22
+ label: string;
23
+ /** ISO timestamp when the peer was added. */
24
+ addedAt: string;
25
+ /** True if this row represents the host's own cadre node (self-enrolment). */
26
+ self?: boolean;
27
+ }
28
+
29
+ /** An invite that has been issued but not yet redeemed. */
30
+ export interface PendingInvite {
31
+ /** Random token (base64url) used to match the redemption. */
32
+ token: string;
33
+ /** Display label that will be assigned to the redeemer on success. */
34
+ label: string;
35
+ /** ISO timestamp when the invite was issued. */
36
+ createdAt: string;
37
+ /** Optional ISO expiration timestamp. */
38
+ expiresAt?: string;
39
+ }
40
+
41
+ /** On-disk shape of `trust-circle.json`. */
42
+ export interface TrustCircleFile {
43
+ version: 1;
44
+ /** keyed by peer ID. */
45
+ members: Record<string, Omit<TrustCircleMember, 'peerId'>>;
46
+ /** keyed by token. */
47
+ pendingInvites: Record<string, Omit<PendingInvite, 'token'>>;
48
+ }
49
+
50
+ /** Snapshot returned to the management UI. */
51
+ export interface TrustCircleSnapshot {
52
+ members: TrustCircleMember[];
53
+ pending: PendingInvite[];
54
+ }
55
+
56
+ /**
57
+ * Typed HTTP handlers exposed to `cadre-host-local-ui` for Fastify wiring.
58
+ *
59
+ * Handlers take typed objects and either return typed results or throw a
60
+ * `TrustCircleError` whose `.code` the UI ticket maps to an HTTP status:
61
+ *
62
+ * - `not_found` → 404 (unknown token / unknown peer)
63
+ * - `expired` → 410 (invite past expiresAt)
64
+ * - `already_redeemed` → 409 (token was used and is gone)
65
+ * - default → 500
66
+ */
67
+ export interface TrustCircleHandlers {
68
+ postInvite(body: { label: string; ttlMs?: number }): Promise<{
69
+ encodedInvite: string;
70
+ token: string;
71
+ expiresAt?: string;
72
+ }>;
73
+ deleteInvite(token: string): Promise<void>;
74
+ deleteMember(peerId: string): Promise<void>;
75
+ listTrustCircle(): Promise<TrustCircleSnapshot>;
76
+ }
77
+
78
+ /** Error codes thrown by TrustCircleService. */
79
+ export type TrustCircleErrorCode =
80
+ | 'not_found'
81
+ | 'expired'
82
+ | 'already_redeemed'
83
+ | 'invalid_label'
84
+ | 'invalid_token'
85
+ | 'storage_error';
86
+
87
+ /** Typed error carrying a stable `code` for HTTP mapping. */
88
+ export class TrustCircleError extends Error {
89
+ readonly code: TrustCircleErrorCode;
90
+
91
+ constructor(code: TrustCircleErrorCode, message: string) {
92
+ super(message);
93
+ this.name = 'TrustCircleError';
94
+ this.code = code;
95
+ }
96
+ }