@serfab/cadre-host 0.7.1 → 0.9.0

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 (193) hide show
  1. package/README.md +296 -101
  2. package/dist/auth/trust-circle.d.ts +45 -7
  3. package/dist/auth/trust-circle.js +116 -59
  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/bin/host.js +426 -112
  8. package/dist/bin/host.js.map +1 -1
  9. package/dist/donation/donation-service.d.ts +136 -0
  10. package/dist/donation/donation-service.js +306 -0
  11. package/dist/donation/donation-service.js.map +1 -0
  12. package/dist/donation/donation-store.d.ts +42 -0
  13. package/dist/donation/donation-store.js +141 -0
  14. package/dist/donation/donation-store.js.map +1 -0
  15. package/dist/donation/grant-service.d.ts +71 -0
  16. package/dist/donation/grant-service.js +163 -0
  17. package/dist/donation/grant-service.js.map +1 -0
  18. package/dist/donation/grant-store.d.ts +38 -0
  19. package/dist/donation/grant-store.js +135 -0
  20. package/dist/donation/grant-store.js.map +1 -0
  21. package/dist/donation/index.d.ts +18 -0
  22. package/dist/donation/index.js +16 -0
  23. package/dist/donation/index.js.map +1 -0
  24. package/dist/donation/types.d.ts +175 -0
  25. package/dist/donation/types.js +40 -0
  26. package/dist/donation/types.js.map +1 -0
  27. package/dist/index.d.ts +12 -6
  28. package/dist/index.js +8 -3
  29. package/dist/index.js.map +1 -1
  30. package/dist/installer/browser.js +16 -17
  31. package/dist/installer/browser.js.map +1 -1
  32. package/dist/installer/config.d.ts +51 -0
  33. package/dist/installer/config.js +14 -1
  34. package/dist/installer/config.js.map +1 -1
  35. package/dist/installer/index.d.ts +7 -2
  36. package/dist/installer/index.js +17 -10
  37. package/dist/installer/index.js.map +1 -1
  38. package/dist/installer/service-host/launchd.js +1 -1
  39. package/dist/installer/service-host/launchd.js.map +1 -1
  40. package/dist/installer/service-host/systemd.js +1 -1
  41. package/dist/installer/service-host/systemd.js.map +1 -1
  42. package/dist/installer/wizard.d.ts +4 -0
  43. package/dist/installer/wizard.js +5 -1
  44. package/dist/installer/wizard.js.map +1 -1
  45. package/dist/nat/index.d.ts +1 -1
  46. package/dist/nat/nat-service.d.ts +45 -3
  47. package/dist/nat/nat-service.js +116 -3
  48. package/dist/nat/nat-service.js.map +1 -1
  49. package/dist/nat/secrets/index.d.ts +7 -0
  50. package/dist/nat/secrets/index.js +28 -2
  51. package/dist/nat/secrets/index.js.map +1 -1
  52. package/dist/nat/types.d.ts +3 -1
  53. package/dist/nat/types.js.map +1 -1
  54. package/dist/orchestrator/host-process-orchestrator.d.ts +68 -1
  55. package/dist/orchestrator/host-process-orchestrator.js +272 -39
  56. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  57. package/dist/orchestrator/index.d.ts +3 -2
  58. package/dist/orchestrator/index.js +1 -1
  59. package/dist/orchestrator/index.js.map +1 -1
  60. package/dist/orchestrator/state-store.d.ts +6 -5
  61. package/dist/orchestrator/state-store.js +10 -2
  62. package/dist/orchestrator/state-store.js.map +1 -1
  63. package/dist/orchestrator/types.d.ts +45 -10
  64. package/dist/orchestrator/types.js.map +1 -1
  65. package/dist/owner/index.d.ts +11 -0
  66. package/dist/owner/index.js +11 -0
  67. package/dist/owner/index.js.map +1 -0
  68. package/dist/owner/owner-node-client.d.ts +95 -0
  69. package/dist/owner/owner-node-client.js +167 -0
  70. package/dist/owner/owner-node-client.js.map +1 -0
  71. package/dist/push/index.d.ts +67 -0
  72. package/dist/push/index.js +101 -0
  73. package/dist/push/index.js.map +1 -0
  74. package/dist/server/error-handler.d.ts +17 -0
  75. package/dist/server/error-handler.js +45 -0
  76. package/dist/server/error-handler.js.map +1 -1
  77. package/dist/server/index.d.ts +26 -2
  78. package/dist/server/index.js +37 -16
  79. package/dist/server/index.js.map +1 -1
  80. package/dist/server/routes/grants-admin.d.ts +17 -0
  81. package/dist/server/routes/grants-admin.js +32 -0
  82. package/dist/server/routes/grants-admin.js.map +1 -0
  83. package/dist/server/routes/grants.d.ts +28 -0
  84. package/dist/server/routes/grants.js +163 -0
  85. package/dist/server/routes/grants.js.map +1 -0
  86. package/dist/server/routes/nodes.d.ts +5 -3
  87. package/dist/server/routes/nodes.js +39 -24
  88. package/dist/server/routes/nodes.js.map +1 -1
  89. package/dist/server/routes/settings.d.ts +6 -1
  90. package/dist/server/routes/settings.js +5 -2
  91. package/dist/server/routes/settings.js.map +1 -1
  92. package/dist/server/routes/status.d.ts +8 -4
  93. package/dist/server/routes/status.js +9 -4
  94. package/dist/server/routes/status.js.map +1 -1
  95. package/dist/server/static.d.ts +12 -0
  96. package/dist/server/static.js +24 -7
  97. package/dist/server/static.js.map +1 -1
  98. package/dist/ui/assets/index-BlMXQPSw.js +14 -0
  99. package/dist/ui/assets/{index-C9uChk2P.js.map → index-BlMXQPSw.js.map} +1 -1
  100. package/dist/ui/index.html +1 -1
  101. package/dist/update/apply.d.ts +23 -1
  102. package/dist/update/apply.js +46 -4
  103. package/dist/update/apply.js.map +1 -1
  104. package/dist/update/index.d.ts +14 -3
  105. package/dist/update/index.js +35 -2
  106. package/dist/update/index.js.map +1 -1
  107. package/dist/update/manifest.d.ts +28 -9
  108. package/dist/update/manifest.js +56 -29
  109. package/dist/update/manifest.js.map +1 -1
  110. package/dist/update/release-key.d.ts +18 -7
  111. package/dist/update/release-key.js +29 -14
  112. package/dist/update/release-key.js.map +1 -1
  113. package/dist/update/sign.d.ts +46 -0
  114. package/dist/update/sign.js +53 -0
  115. package/dist/update/sign.js.map +1 -0
  116. package/package.json +9 -4
  117. package/service/README.md +95 -86
  118. package/src/__tests__/cli-invite.smoke.test.ts +6 -1
  119. package/src/__tests__/cli-nat.smoke.test.ts +6 -1
  120. package/src/__tests__/cli.smoke.test.ts +6 -1
  121. package/src/__tests__/host-owner.smoke.test.ts +123 -0
  122. package/src/__tests__/orchestrator-owner.test.ts +198 -0
  123. package/src/__tests__/orchestrator-pin-keys.test.ts +153 -0
  124. package/src/__tests__/orchestrator-push.test.ts +145 -0
  125. package/src/auth/__tests__/trust-circle-integration.test.ts +44 -25
  126. package/src/auth/__tests__/trust-circle.test.ts +91 -24
  127. package/src/auth/trust-circle.ts +363 -304
  128. package/src/auth/types.ts +4 -1
  129. package/src/bin/host.ts +1258 -905
  130. package/src/donation/__tests__/donation-service.test.ts +290 -0
  131. package/src/donation/__tests__/grant-service.test.ts +213 -0
  132. package/src/donation/__tests__/grant-store.test.ts +113 -0
  133. package/src/donation/donation-service.ts +392 -0
  134. package/src/donation/donation-store.ts +164 -0
  135. package/src/donation/grant-service.ts +200 -0
  136. package/src/donation/grant-store.ts +152 -0
  137. package/src/donation/index.ts +49 -0
  138. package/src/donation/types.ts +220 -0
  139. package/src/index.ts +66 -3
  140. package/src/installer/__tests__/config.test.ts +27 -1
  141. package/src/installer/__tests__/installer.smoke.test.ts +94 -0
  142. package/src/installer/__tests__/wizard.test.ts +5 -1
  143. package/src/installer/browser.ts +40 -42
  144. package/src/installer/config.ts +61 -1
  145. package/src/installer/index.ts +23 -12
  146. package/src/installer/service-host/launchd.ts +1 -1
  147. package/src/installer/service-host/systemd.ts +1 -1
  148. package/src/installer/wizard.ts +12 -1
  149. package/src/nat/__tests__/ddns-updater.test.ts +1 -1
  150. package/src/nat/__tests__/nat-service.test.ts +192 -0
  151. package/src/nat/__tests__/port-mapper.test.ts +1 -1
  152. package/src/nat/__tests__/secrets.test.ts +23 -1
  153. package/src/nat/index.ts +1 -1
  154. package/src/nat/nat-service.ts +131 -5
  155. package/src/nat/secrets/index.ts +32 -2
  156. package/src/nat/types.ts +3 -1
  157. package/src/orchestrator/host-process-orchestrator.ts +322 -54
  158. package/src/orchestrator/index.ts +9 -2
  159. package/src/orchestrator/state-store.ts +21 -3
  160. package/src/orchestrator/types.ts +48 -2
  161. package/src/owner/__tests__/owner-node-client.test.ts +194 -0
  162. package/src/owner/index.ts +12 -0
  163. package/src/owner/owner-node-client.ts +233 -0
  164. package/src/push/__tests__/push.test.ts +90 -0
  165. package/src/push/index.ts +146 -0
  166. package/src/server/__tests__/error-handler.test.ts +26 -4
  167. package/src/server/__tests__/grants-admin-route.test.ts +73 -0
  168. package/src/server/__tests__/grants-route.test.ts +189 -0
  169. package/src/server/__tests__/nodes-route.test.ts +90 -10
  170. package/src/server/__tests__/publishers.test.ts +1 -1
  171. package/src/server/__tests__/server.smoke.test.ts +75 -0
  172. package/src/server/__tests__/settings-route.test.ts +17 -0
  173. package/src/server/__tests__/static.test.ts +145 -0
  174. package/src/server/__tests__/status-route.test.ts +23 -2
  175. package/src/server/error-handler.ts +47 -0
  176. package/src/server/index.ts +62 -17
  177. package/src/server/routes/grants-admin.ts +41 -0
  178. package/src/server/routes/grants.ts +186 -0
  179. package/src/server/routes/nodes.ts +49 -26
  180. package/src/server/routes/settings.ts +11 -3
  181. package/src/server/routes/status.ts +18 -8
  182. package/src/server/static.ts +24 -8
  183. package/src/update/__tests__/apply.test.ts +58 -1
  184. package/src/update/__tests__/manifest.test.ts +41 -6
  185. package/src/update/__tests__/release-key.test.ts +42 -0
  186. package/src/update/__tests__/sign.test.ts +78 -0
  187. package/src/update/__tests__/update-service.integration.test.ts +44 -9
  188. package/src/update/apply.ts +46 -4
  189. package/src/update/index.ts +41 -3
  190. package/src/update/manifest.ts +214 -172
  191. package/src/update/release-key.ts +31 -15
  192. package/src/update/sign.ts +74 -0
  193. package/dist/ui/assets/index-C9uChk2P.js +0 -14
@@ -1,304 +1,363 @@
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
- }
1
+ import { randomBytes } from 'node:crypto';
2
+ import debug from 'debug';
3
+
4
+ import type { CadreInvite } from '@serfab/cadre-core';
5
+
6
+ import { OwnerNodeUnavailableError } from '../owner/owner-node-client.js';
7
+
8
+ import type { TrustCircleStore } from './trust-circle-store.js';
9
+ import type {
10
+ PendingInvite,
11
+ TrustCircleHandlers,
12
+ TrustCircleMember,
13
+ TrustCircleSnapshot,
14
+ } from './types.js';
15
+ import { TrustCircleError } from './types.js';
16
+
17
+ const log = debug('cadre:host:trust-circle');
18
+
19
+ /** Default invite lifetime: 24 hours. */
20
+ export const DEFAULT_INVITE_TTL_MS = 24 * 60 * 60 * 1000;
21
+
22
+ /** Token byte length (base64url-encoded; ~43 chars). */
23
+ const TOKEN_BYTES = 32;
24
+
25
+ /** Constructor options. */
26
+ export interface TrustCircleServiceOptions {
27
+ /** The host's cadre node — provides createInvite / acceptPhone / removePeer. */
28
+ cadreNode: CadreNodeLike;
29
+ /** Local labels + pending-invites store. */
30
+ store: TrustCircleStore;
31
+ /** Clock override for tests. */
32
+ now?: () => Date;
33
+ }
34
+
35
+ /**
36
+ * Minimal slice of CadreNode that the service uses. Defined as an interface
37
+ * (rather than reaching into CadreNode directly) so unit tests can inject
38
+ * a mock without standing up a real libp2p node.
39
+ */
40
+ export interface CadreNodeLike {
41
+ createInvite(token?: string, expiresIn?: number): Promise<{ invite: CadreInvite; encodedInvite: string }>;
42
+ acceptPhone(options: { phonePeerId: string; token?: string }, issuedInvite?: CadreInvite): Promise<void>;
43
+ removePeer(peerId: string): Promise<void>;
44
+ encodeInvite(invite: CadreInvite): string;
45
+ /** Enumerate the cadre's `CadrePeer` membership (over the admin channel). */
46
+ listMembers(): Promise<Array<{ peerId: string; multiaddr: string | null }>>;
47
+ /** Probe whether a peer is a `CadrePeer` member (over the admin channel). */
48
+ isMember(peerId: string): Promise<boolean>;
49
+ }
50
+
51
+ /**
52
+ * TrustCircleService — orchestrates invite issuance, redemption, and
53
+ * membership management.
54
+ *
55
+ * Membership is canonical in the cadre control DB (`CadrePeer` table),
56
+ * reached over the owner node's admin channel; labels and pending tokens
57
+ * live in the host-local `TrustCircleStore`. When the node is unreachable,
58
+ * owner operations surface `node_unavailable` (→ 503) and `list()`
59
+ * degrades to the local labels file.
60
+ */
61
+ export class TrustCircleService {
62
+ private readonly cadreNode: CadreNodeLike;
63
+ private readonly store: TrustCircleStore;
64
+ private readonly now: () => Date;
65
+ /**
66
+ * In-flight redemption claims (by token). Serialises concurrent redeems
67
+ * for the same token without burning the durable pending row before
68
+ * `acceptPhone` succeeds. Intra-process only — the orchestrator enforces
69
+ * single-process ownership of a given rootDir, so no file lock is needed.
70
+ */
71
+ private readonly inFlightRedemptions = new Set<string>();
72
+
73
+ constructor(opts: TrustCircleServiceOptions) {
74
+ this.cadreNode = opts.cadreNode;
75
+ this.store = opts.store;
76
+ this.now = opts.now ?? (() => new Date());
77
+ }
78
+
79
+ /**
80
+ * Issue a new invite. Generates a token, persists a pending row, and
81
+ * delegates to cadre-core to embed the host's owner addresses.
82
+ */
83
+ async issueInvite(opts: { label: string; ttlMs?: number }): Promise<{
84
+ encodedInvite: string;
85
+ invite: CadreInvite;
86
+ token: string;
87
+ expiresAt?: Date;
88
+ }> {
89
+ const label = normaliseLabel(opts.label);
90
+ const ttlMs = opts.ttlMs ?? DEFAULT_INVITE_TTL_MS;
91
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
92
+ throw new TrustCircleError('invalid_token', `Invalid ttlMs: ${ttlMs}`);
93
+ }
94
+
95
+ const token = generateToken();
96
+ const created = this.now();
97
+ const expiresAt = new Date(created.getTime() + ttlMs);
98
+
99
+ const { invite, encodedInvite } = await this.cadreNode
100
+ .createInvite(token, ttlMs)
101
+ .catch((err) => this.toDomainError(err));
102
+
103
+ const pending: PendingInvite = {
104
+ token,
105
+ label,
106
+ createdAt: created.toISOString(),
107
+ expiresAt: expiresAt.toISOString(),
108
+ };
109
+ this.store.addPending(pending);
110
+
111
+ log('issued invite for "%s" (token=%s, expires=%s)', label, token, expiresAt.toISOString());
112
+
113
+ return { encodedInvite, invite, token, expiresAt };
114
+ }
115
+
116
+ /**
117
+ * Redeem an invite for an incoming peer ID. On success:
118
+ * - cadre-core authorizes the peer (inserts CadrePeer row).
119
+ * - The pending row is consumed (one-time enforcement).
120
+ * - A labelled member row is written locally.
121
+ *
122
+ * Ordering: the synchronous *claim* against `inFlightRedemptions` serialises
123
+ * concurrent redeems for the same token (second concurrent caller rejects
124
+ * synchronously before its first await). The durable `removePending` only
125
+ * runs *after* `acceptPhone` succeeds — so a transient `node_unavailable`
126
+ * leaves the token re-redeemable rather than permanently burning it.
127
+ *
128
+ * Crash-safety tradeoff: if the host crashes between `acceptPhone` success
129
+ * and the durable `removePending`, a retry will dial in fine but the
130
+ * owner node's `acceptPhone` will reject the second `CadrePeer` insert
131
+ * with a PK constraint error (cadre-core does not upsert). That surfaces to
132
+ * the redeemer as a non-`node_unavailable` failure; the admin can revoke
133
+ * the lingering pending row. This is a narrower window than the prior
134
+ * behaviour, where *any* transient node outage burned the token.
135
+ */
136
+ async redeemInvite(opts: { token: string; peerId: string }): Promise<{ peerId: string; label: string }> {
137
+ const { token, peerId } = opts;
138
+ if (!token || !peerId) {
139
+ throw new TrustCircleError('invalid_token', 'token and peerId are required');
140
+ }
141
+
142
+ const pending = this.store.getPending(token);
143
+ if (!pending) {
144
+ throw new TrustCircleError('already_redeemed', 'Invite not found or already redeemed');
145
+ }
146
+
147
+ if (pending.expiresAt) {
148
+ const expiresAt = new Date(pending.expiresAt);
149
+ if (Number.isFinite(expiresAt.getTime()) && this.now() > expiresAt) {
150
+ // Expiry is permanent — reap the pending row durably.
151
+ this.store.removePending(token);
152
+ throw new TrustCircleError('expired', 'Invite has expired');
153
+ }
154
+ }
155
+
156
+ // Synchronous in-memory claim. Closes the race between two concurrent
157
+ // redemption requests for the same token — the loser sees the token in
158
+ // the set and rejects synchronously before its first `await`.
159
+ if (this.inFlightRedemptions.has(token)) {
160
+ throw new TrustCircleError('already_redeemed', 'Invite not found or already redeemed');
161
+ }
162
+ this.inFlightRedemptions.add(token);
163
+
164
+ try {
165
+ // Reconstruct the CadreInvite that was originally issued. cadre-core's
166
+ // acceptPhone validates `issuedInvite.token === options.token` (already
167
+ // matches by construction) and re-checks expiration.
168
+ const reconstructed: CadreInvite = {
169
+ partyId: '',
170
+ ownerAddrs: [],
171
+ token: pending.token,
172
+ createdAt: new Date(pending.createdAt).getTime(),
173
+ ...(pending.expiresAt ? { expiresAt: new Date(pending.expiresAt).getTime() } : {}),
174
+ };
175
+
176
+ await this.cadreNode
177
+ .acceptPhone({ phonePeerId: peerId, token }, reconstructed)
178
+ .catch((err) => this.toDomainError(err));
179
+
180
+ // acceptPhone succeeded → durably consume the pending row. If we crash
181
+ // between here and the next line, the token stays pending (re-redeem
182
+ // would fail at acceptPhone with a duplicate-CadrePeer error, not
183
+ // node_unavailable, and the admin can revoke).
184
+ this.store.removePending(token);
185
+
186
+ // If the member write fails the peer is still authorized in CadrePeer —
187
+ // the next call to list() will pick it up unlabeled, which is the
188
+ // documented graceful degradation.
189
+ const member: TrustCircleMember = {
190
+ peerId,
191
+ label: pending.label,
192
+ addedAt: this.now().toISOString(),
193
+ };
194
+ this.store.addMember(member);
195
+
196
+ log('redeemed invite for "%s" (peerId=%s, token=%s)', pending.label, peerId, token);
197
+
198
+ return { peerId, label: pending.label };
199
+ } finally {
200
+ this.inFlightRedemptions.delete(token);
201
+ }
202
+ }
203
+
204
+ /** Revoke a pending invite by token. No-op if not found. */
205
+ async revokePending(token: string): Promise<void> {
206
+ if (!this.store.removePending(token)) {
207
+ throw new TrustCircleError('not_found', `Unknown pending invite: ${token}`);
208
+ }
209
+ log('revoked pending invite (token=%s)', token);
210
+ }
211
+
212
+ /**
213
+ * Remove an authorised member. Deletes the CadrePeer row and the local
214
+ * label. Throws not_found when the peer is in neither place.
215
+ */
216
+ async removeMember(peerId: string): Promise<void> {
217
+ const inLocal = this.store.getMember(peerId);
218
+ const inControl = await this.isMember(peerId);
219
+
220
+ if (!inLocal && !inControl) {
221
+ throw new TrustCircleError('not_found', `Unknown member: ${peerId}`);
222
+ }
223
+
224
+ if (inControl) {
225
+ await this.cadreNode.removePeer(peerId).catch((err) => this.toDomainError(err));
226
+ }
227
+ if (inLocal) {
228
+ this.store.removeMember(peerId);
229
+ }
230
+ log('removed member peerId=%s', peerId);
231
+ }
232
+
233
+ /** Whether the peer is present in the cadre's CadrePeer table. */
234
+ async isMember(peerId: string): Promise<boolean> {
235
+ try {
236
+ return await this.cadreNode.isMember(peerId);
237
+ } catch (err) {
238
+ this.toDomainError(err);
239
+ }
240
+ }
241
+
242
+ /**
243
+ * UI snapshot. Joins the canonical CadrePeer list (fetched over the admin
244
+ * channel) with local labels; prunes any orphan labels (peer is no longer
245
+ * authorised). When the owner node is unreachable, degrades to the
246
+ * local labels file so listing keeps working while the node is down.
247
+ */
248
+ async list(): Promise<TrustCircleSnapshot> {
249
+ const labels = new Map<string, Omit<TrustCircleMember, 'peerId'>>();
250
+ for (const m of this.store.listMembers()) {
251
+ labels.set(m.peerId, { label: m.label, addedAt: m.addedAt, ...(m.self ? { self: true } : {}) });
252
+ }
253
+
254
+ const members: TrustCircleMember[] = [];
255
+
256
+ // Consult the control DB over the admin channel. A node-unavailable error
257
+ // is non-fatal — fall back to the local labels rather than 503-ing a
258
+ // read-only listing.
259
+ let controlMembers: Array<{ peerId: string }> | null;
260
+ try {
261
+ controlMembers = await this.cadreNode.listMembers();
262
+ } catch (err) {
263
+ if (!(err instanceof OwnerNodeUnavailableError)) throw err;
264
+ controlMembers = null;
265
+ }
266
+
267
+ if (controlMembers) {
268
+ const seenPeerIds = new Set<string>();
269
+ for (const { peerId } of controlMembers) {
270
+ seenPeerIds.add(peerId);
271
+ const label = labels.get(peerId);
272
+ members.push({
273
+ peerId,
274
+ label: label?.label ?? peerId,
275
+ addedAt: label?.addedAt ?? '',
276
+ ...(label?.self ? { self: true } : {}),
277
+ });
278
+ }
279
+ // Prune labels for peers that no longer exist in CadrePeer. Only when we
280
+ // successfully consulted the node — otherwise a transient outage would
281
+ // wipe labels.
282
+ for (const peerId of labels.keys()) {
283
+ if (!seenPeerIds.has(peerId)) {
284
+ this.store.removeMember(peerId);
285
+ }
286
+ }
287
+ } else {
288
+ // Node down: return labels as-is (degradation; no pruning).
289
+ for (const [peerId, row] of labels) {
290
+ members.push({ peerId, ...row });
291
+ }
292
+ }
293
+
294
+ const pending = this.store.listPending();
295
+
296
+ // Sort for stable UI ordering.
297
+ members.sort((a, b) => a.peerId.localeCompare(b.peerId));
298
+ pending.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
299
+
300
+ return { members, pending };
301
+ }
302
+
303
+ /**
304
+ * Translate an admin-channel transport failure into a `node_unavailable`
305
+ * TrustCircleError (→ 503). Re-throws anything else unchanged. Returns
306
+ * `never` so callers can `.catch(err => this.toDomainError(err))`.
307
+ */
308
+ private toDomainError(err: unknown): never {
309
+ if (err instanceof OwnerNodeUnavailableError) {
310
+ throw new TrustCircleError('node_unavailable', `Owner node unavailable: ${err.message}`);
311
+ }
312
+ throw err instanceof Error ? err : new Error(String(err));
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Wrap a TrustCircleService into the typed handler shape consumed by
318
+ * `cadre-host-local-ui`. Errors propagate as TrustCircleError; the UI ticket
319
+ * maps `.code` → HTTP status.
320
+ */
321
+ export function createTrustCircleHandlers(service: TrustCircleService): TrustCircleHandlers {
322
+ return {
323
+ async postInvite(body) {
324
+ if (!body || typeof body.label !== 'string') {
325
+ throw new TrustCircleError('invalid_label', 'label is required');
326
+ }
327
+ const result = await service.issueInvite({ label: body.label, ttlMs: body.ttlMs });
328
+ return {
329
+ encodedInvite: result.encodedInvite,
330
+ token: result.token,
331
+ ...(result.expiresAt ? { expiresAt: result.expiresAt.toISOString() } : {}),
332
+ };
333
+ },
334
+ async deleteInvite(token) {
335
+ await service.revokePending(token);
336
+ },
337
+ async deleteMember(peerId) {
338
+ await service.removeMember(peerId);
339
+ },
340
+ async listTrustCircle() {
341
+ return await service.list();
342
+ },
343
+ };
344
+ }
345
+
346
+ function generateToken(): string {
347
+ // base64url so it survives in URLs, QR codes, and shell arguments.
348
+ return randomBytes(TOKEN_BYTES).toString('base64url');
349
+ }
350
+
351
+ function normaliseLabel(label: unknown): string {
352
+ if (typeof label !== 'string') {
353
+ throw new TrustCircleError('invalid_label', 'label must be a string');
354
+ }
355
+ const trimmed = label.trim();
356
+ if (trimmed.length === 0) {
357
+ throw new TrustCircleError('invalid_label', 'label must not be empty');
358
+ }
359
+ if (trimmed.length > 200) {
360
+ throw new TrustCircleError('invalid_label', 'label must be 200 characters or fewer');
361
+ }
362
+ return trimmed;
363
+ }