@serfab/cadre-host 0.10.0 → 0.11.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 (138) hide show
  1. package/dist/server/static.js +38 -38
  2. package/dist/ui/assets/index-C-iks41M.js.map +1 -1
  3. package/dist/ui/index.html +12 -12
  4. package/package.json +4 -4
  5. package/service/cadre-host.service.tmpl +16 -16
  6. package/service/com.serfab.cadre-host.plist.tmpl +35 -35
  7. package/service/install-service.ps1 +98 -98
  8. package/service/uninstall-service.ps1 +19 -19
  9. package/src/__tests__/build-targets.test.ts +26 -26
  10. package/src/__tests__/cli-invite.smoke.test.ts +192 -192
  11. package/src/__tests__/cli-nat.smoke.test.ts +212 -212
  12. package/src/__tests__/cli.smoke.test.ts +46 -46
  13. package/src/__tests__/global-setup.ts +51 -51
  14. package/src/__tests__/host-owner.smoke.test.ts +123 -123
  15. package/src/__tests__/orchestrator-env-scrub.test.ts +140 -140
  16. package/src/__tests__/orchestrator-node-identity.test.ts +204 -204
  17. package/src/__tests__/orchestrator-owner.test.ts +198 -198
  18. package/src/__tests__/orchestrator-pin-keys.test.ts +153 -153
  19. package/src/__tests__/orchestrator-push.test.ts +145 -145
  20. package/src/__tests__/orchestrator.test.ts +951 -951
  21. package/src/auth/__tests__/duration.test.ts +28 -28
  22. package/src/auth/__tests__/self-label.test.ts +85 -85
  23. package/src/auth/__tests__/trust-circle-store.test.ts +115 -115
  24. package/src/auth/__tests__/trust-circle.test.ts +445 -445
  25. package/src/auth/duration.ts +33 -33
  26. package/src/auth/index.ts +21 -21
  27. package/src/auth/self-label.ts +74 -74
  28. package/src/auth/trust-circle-store.ts +155 -155
  29. package/src/auth/types.ts +99 -99
  30. package/src/donation/__tests__/donation-service.test.ts +1068 -1068
  31. package/src/donation/__tests__/donation-supervisor.test.ts +510 -510
  32. package/src/donation/__tests__/fake-orchestrator.test.ts +168 -168
  33. package/src/donation/__tests__/fake-orchestrator.ts +225 -225
  34. package/src/donation/__tests__/grant-service.test.ts +213 -213
  35. package/src/donation/__tests__/grant-store.test.ts +113 -113
  36. package/src/donation/donation-service.ts +962 -962
  37. package/src/donation/donation-store.ts +171 -171
  38. package/src/donation/donation-supervisor.ts +388 -388
  39. package/src/donation/grant-service.ts +200 -200
  40. package/src/donation/grant-store.ts +152 -152
  41. package/src/donation/index.ts +64 -64
  42. package/src/donation/types.ts +243 -243
  43. package/src/installer/__tests__/identity.test.ts +34 -34
  44. package/src/installer/__tests__/paths.test.ts +47 -47
  45. package/src/installer/__tests__/service-host.test.ts +62 -62
  46. package/src/installer/__tests__/wizard.test.ts +64 -64
  47. package/src/installer/identity.ts +49 -49
  48. package/src/installer/paths.ts +109 -109
  49. package/src/installer/platform.ts +27 -27
  50. package/src/installer/service-host/index.ts +21 -21
  51. package/src/installer/service-host/template.ts +21 -21
  52. package/src/installer/wizard.ts +103 -103
  53. package/src/nat/__tests__/address-resolver.test.ts +82 -82
  54. package/src/nat/__tests__/ddns-duckdns.test.ts +83 -83
  55. package/src/nat/__tests__/ddns-updater.test.ts +253 -253
  56. package/src/nat/__tests__/external-ip.test.ts +124 -124
  57. package/src/nat/__tests__/nat-service.test.ts +497 -497
  58. package/src/nat/__tests__/nat-store.test.ts +93 -93
  59. package/src/nat/__tests__/port-mapper.test.ts +191 -191
  60. package/src/nat/__tests__/reachability.test.ts +24 -24
  61. package/src/nat/__tests__/secrets.test.ts +178 -178
  62. package/src/nat/address-resolver.ts +51 -51
  63. package/src/nat/ddns/duckdns.ts +76 -76
  64. package/src/nat/ddns/index.ts +55 -55
  65. package/src/nat/ddns/updater.ts +243 -243
  66. package/src/nat/external-ip.ts +154 -154
  67. package/src/nat/index.ts +71 -71
  68. package/src/nat/nat-service.ts +501 -501
  69. package/src/nat/nat-store.ts +154 -154
  70. package/src/nat/port-mapper.ts +329 -329
  71. package/src/nat/reachability.ts +34 -34
  72. package/src/nat/secrets/file-store.ts +130 -130
  73. package/src/nat/secrets/index.ts +112 -112
  74. package/src/nat/secrets/keytar-store.ts +67 -67
  75. package/src/nat/types.ts +159 -159
  76. package/src/orchestrator/__tests__/node-identity.test.ts +62 -62
  77. package/src/orchestrator/host-process-orchestrator.ts +1133 -1133
  78. package/src/orchestrator/index.ts +10 -10
  79. package/src/orchestrator/log-rotator.ts +53 -53
  80. package/src/orchestrator/node-identity.ts +40 -40
  81. package/src/orchestrator/pid-liveness.ts +24 -24
  82. package/src/orchestrator/port-allocator.ts +91 -91
  83. package/src/orchestrator/state-store.ts +90 -90
  84. package/src/orchestrator/types.ts +141 -141
  85. package/src/owner/__tests__/owner-node-client.test.ts +194 -194
  86. package/src/owner/index.ts +12 -12
  87. package/src/owner/owner-node-client.ts +259 -259
  88. package/src/push/__tests__/push.test.ts +90 -90
  89. package/src/push/index.ts +146 -146
  90. package/src/server/__tests__/bus.test.ts +38 -38
  91. package/src/server/__tests__/error-handler.test.ts +118 -118
  92. package/src/server/__tests__/grants-admin-route.test.ts +73 -73
  93. package/src/server/__tests__/grants-route.test.ts +263 -263
  94. package/src/server/__tests__/nodes-route.test.ts +259 -259
  95. package/src/server/__tests__/origin-guard.test.ts +72 -72
  96. package/src/server/__tests__/publishers.test.ts +216 -216
  97. package/src/server/__tests__/server.smoke.test.ts +253 -253
  98. package/src/server/__tests__/settings-route.test.ts +194 -194
  99. package/src/server/__tests__/sse-route.test.ts +119 -119
  100. package/src/server/__tests__/static.test.ts +145 -145
  101. package/src/server/__tests__/status-route.test.ts +134 -134
  102. package/src/server/__tests__/strands-route.test.ts +233 -233
  103. package/src/server/error-handler.ts +178 -178
  104. package/src/server/events/bus.ts +42 -42
  105. package/src/server/events/sse-route.ts +81 -81
  106. package/src/server/events/types.ts +23 -23
  107. package/src/server/index.ts +267 -267
  108. package/src/server/origin-guard.ts +84 -84
  109. package/src/server/routes/grants-admin.ts +41 -41
  110. package/src/server/routes/grants.ts +210 -210
  111. package/src/server/routes/nat.ts +63 -63
  112. package/src/server/routes/nodes.ts +170 -170
  113. package/src/server/routes/settings.ts +123 -123
  114. package/src/server/routes/status.ts +118 -118
  115. package/src/server/routes/strands.ts +54 -54
  116. package/src/server/routes/trust-circle.ts +49 -49
  117. package/src/server/routes/update.ts +43 -43
  118. package/src/server/server.ts +122 -122
  119. package/src/server/settings-store.ts +37 -37
  120. package/src/server/static.ts +207 -207
  121. package/src/strands/__tests__/strand-service.test.ts +165 -165
  122. package/src/strands/index.ts +14 -14
  123. package/src/strands/strand-service.ts +135 -135
  124. package/src/strands/types.ts +102 -102
  125. package/src/update/__tests__/apply.test.ts +158 -158
  126. package/src/update/__tests__/manifest.test.ts +128 -128
  127. package/src/update/__tests__/release-key.test.ts +42 -42
  128. package/src/update/__tests__/sign.test.ts +78 -78
  129. package/src/update/__tests__/store.test.ts +81 -81
  130. package/src/update/__tests__/update-service.integration.test.ts +256 -256
  131. package/src/update/__tests__/version.test.ts +31 -31
  132. package/src/update/apply.ts +190 -190
  133. package/src/update/index.ts +341 -341
  134. package/src/update/release-key.ts +71 -71
  135. package/src/update/sign.ts +74 -74
  136. package/src/update/store.ts +101 -101
  137. package/src/update/types.ts +109 -109
  138. package/src/update/version.ts +80 -80
@@ -1,445 +1,445 @@
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
- import { OwnerNodeUnavailableError } from '../../owner/owner-node-client.js';
13
-
14
- /**
15
- * Minimal in-memory mock of the owner-node channel adapter. Records calls
16
- * so assertions can verify the auth + redemption flow without standing up a
17
- * real libp2p control network or admin channel.
18
- */
19
- class MockCadreNode implements CadreNodeLike {
20
- /** CadrePeer rows — the real (authorized) membership test. */
21
- readonly authorizedPeers = new Set<string>();
22
- /**
23
- * Addressable-only rows — peers that published an address record without
24
- * ever being vouched for. Models the "stranger writes its own row" bug:
25
- * present in `listMembers`/`isMember` (addressable), absent from
26
- * `listAuthorizedMembers`/`isAuthorizedMember` (authorized).
27
- */
28
- readonly unauthorizedAddressablePeers = new Set<string>();
29
- readonly issuedInvites: Array<{ token?: string; expiresIn?: number }> = [];
30
- readonly accepted: Array<{ phonePeerId: string; token?: string }> = [];
31
- readonly removed: string[] = [];
32
-
33
- /** Toggle to make acceptPhone reject. */
34
- acceptShouldThrow: Error | null = null;
35
- /** When false, membership calls throw OwnerNodeUnavailableError. */
36
- nodeAvailable = true;
37
-
38
- async createInvite(token?: string, expiresIn?: number) {
39
- this.issuedInvites.push({ token, expiresIn });
40
- const invite: CadreInvite = {
41
- partyId: 'party-test',
42
- ownerAddrs: ['/ip4/127.0.0.1/tcp/4001'],
43
- token,
44
- createdAt: Date.now(),
45
- ...(expiresIn ? { expiresAt: Date.now() + expiresIn } : {}),
46
- };
47
- return { invite, encodedInvite: 'enc:' + JSON.stringify(invite) };
48
- }
49
-
50
- async acceptPhone(options: { phonePeerId: string; token?: string }, _issuedInvite?: CadreInvite) {
51
- if (this.acceptShouldThrow) throw this.acceptShouldThrow;
52
- this.accepted.push({ ...options });
53
- this.authorizedPeers.add(options.phonePeerId);
54
- }
55
-
56
- async removePeer(peerId: string) {
57
- this.removed.push(peerId);
58
- this.authorizedPeers.delete(peerId);
59
- this.unauthorizedAddressablePeers.delete(peerId);
60
- }
61
-
62
- encodeInvite(invite: CadreInvite): string {
63
- return 'enc:' + JSON.stringify(invite);
64
- }
65
-
66
- async listMembers(): Promise<Array<{ peerId: string; multiaddr: string | null }>> {
67
- if (!this.nodeAvailable) throw new OwnerNodeUnavailableError('node down');
68
- return [...this.authorizedPeers, ...this.unauthorizedAddressablePeers]
69
- .map((peerId) => ({ peerId, multiaddr: null }));
70
- }
71
-
72
- async isMember(peerId: string): Promise<boolean> {
73
- if (!this.nodeAvailable) throw new OwnerNodeUnavailableError('node down');
74
- return this.authorizedPeers.has(peerId) || this.unauthorizedAddressablePeers.has(peerId);
75
- }
76
-
77
- async listAuthorizedMembers(): Promise<Array<{ peerId: string; multiaddr: string | null }>> {
78
- if (!this.nodeAvailable) throw new OwnerNodeUnavailableError('node down');
79
- return [...this.authorizedPeers].map((peerId) => ({ peerId, multiaddr: null }));
80
- }
81
-
82
- async isAuthorizedMember(peerId: string): Promise<boolean> {
83
- if (!this.nodeAvailable) throw new OwnerNodeUnavailableError('node down');
84
- return this.authorizedPeers.has(peerId);
85
- }
86
- }
87
-
88
- let tmpRoot: string;
89
- let cadreNode: MockCadreNode;
90
- let store: TrustCircleStore;
91
- let service: TrustCircleService;
92
-
93
- beforeEach(() => {
94
- tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-tc-svc-'));
95
- cadreNode = new MockCadreNode();
96
- store = new TrustCircleStore(tmpRoot);
97
- service = new TrustCircleService({ cadreNode, store });
98
- });
99
-
100
- afterEach(() => {
101
- try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
102
- });
103
-
104
- describe('TrustCircleService.issueInvite', () => {
105
- it('creates a pending row, returns an encoded invite, and uses default TTL', async () => {
106
- const result = await service.issueInvite({ label: "Mom's phone" });
107
-
108
- expect(result.token).toBeTruthy();
109
- expect(result.encodedInvite.startsWith('enc:')).toBe(true);
110
- expect(result.expiresAt).toBeInstanceOf(Date);
111
-
112
- const pending = store.getPending(result.token);
113
- expect(pending).toBeDefined();
114
- expect(pending!.label).toBe("Mom's phone");
115
- expect(pending!.expiresAt).toBe(result.expiresAt!.toISOString());
116
-
117
- expect(cadreNode.issuedInvites).toHaveLength(1);
118
- expect(cadreNode.issuedInvites[0]!.token).toBe(result.token);
119
- });
120
-
121
- it('rejects empty / whitespace labels', async () => {
122
- await expect(service.issueInvite({ label: ' ' })).rejects.toBeInstanceOf(TrustCircleError);
123
- await expect(service.issueInvite({ label: '' })).rejects.toBeInstanceOf(TrustCircleError);
124
- });
125
-
126
- it('honours custom ttl', async () => {
127
- const start = Date.now();
128
- const ttlMs = 5 * 60 * 1000;
129
- const result = await service.issueInvite({ label: 'Carol', ttlMs });
130
- const expiresMs = result.expiresAt!.getTime();
131
- expect(expiresMs - start).toBeGreaterThanOrEqual(ttlMs - 50);
132
- expect(expiresMs - start).toBeLessThanOrEqual(ttlMs + 500);
133
- });
134
-
135
- it('rejects non-positive ttl', async () => {
136
- await expect(service.issueInvite({ label: 'X', ttlMs: 0 })).rejects.toBeInstanceOf(TrustCircleError);
137
- await expect(service.issueInvite({ label: 'X', ttlMs: -1 })).rejects.toBeInstanceOf(TrustCircleError);
138
- });
139
- });
140
-
141
- describe('TrustCircleService.redeemInvite', () => {
142
- it('happy path: authorises peer, consumes pending, writes member', async () => {
143
- const { token } = await service.issueInvite({ label: "Mom's phone" });
144
-
145
- const result = await service.redeemInvite({ token, peerId: '12D3KooWMom' });
146
- expect(result).toEqual({ peerId: '12D3KooWMom', label: "Mom's phone" });
147
-
148
- expect(cadreNode.accepted).toHaveLength(1);
149
- expect(cadreNode.accepted[0]).toMatchObject({ phonePeerId: '12D3KooWMom', token });
150
- expect(cadreNode.authorizedPeers.has('12D3KooWMom')).toBe(true);
151
-
152
- expect(store.getPending(token)).toBeUndefined();
153
- expect(store.getMember('12D3KooWMom')?.label).toBe("Mom's phone");
154
- });
155
-
156
- it('rejects unknown token', async () => {
157
- await expect(service.redeemInvite({ token: 'nope', peerId: 'p1' }))
158
- .rejects.toMatchObject({ code: 'already_redeemed' });
159
- expect(cadreNode.accepted).toHaveLength(0);
160
- });
161
-
162
- it('rejects expired invite and reaps the pending row', async () => {
163
- let clock = new Date('2025-01-01T00:00:00Z');
164
- const svc = new TrustCircleService({ cadreNode, store, now: () => clock });
165
- const { token } = await svc.issueInvite({ label: 'Dad', ttlMs: 60_000 });
166
-
167
- // Move clock past expiry.
168
- clock = new Date('2025-01-02T00:00:00Z');
169
-
170
- await expect(svc.redeemInvite({ token, peerId: 'p1' }))
171
- .rejects.toMatchObject({ code: 'expired' });
172
- expect(store.getPending(token)).toBeUndefined();
173
- expect(cadreNode.accepted).toHaveLength(0);
174
- });
175
-
176
- it('double-redeem: second attempt is rejected because pending was consumed', async () => {
177
- const { token } = await service.issueInvite({ label: 'Carol' });
178
-
179
- await service.redeemInvite({ token, peerId: 'pA' });
180
- await expect(service.redeemInvite({ token, peerId: 'pB' }))
181
- .rejects.toMatchObject({ code: 'already_redeemed' });
182
-
183
- // The second attempt must NOT have authorised pB.
184
- expect(cadreNode.authorizedPeers.has('pB')).toBe(false);
185
- });
186
-
187
- it('requires both token and peerId', async () => {
188
- await expect(service.redeemInvite({ token: '', peerId: 'p1' }))
189
- .rejects.toBeInstanceOf(TrustCircleError);
190
- await expect(service.redeemInvite({ token: 't', peerId: '' }))
191
- .rejects.toBeInstanceOf(TrustCircleError);
192
- });
193
-
194
- it('claims pending atomically so a concurrent second call cannot also redeem', async () => {
195
- const { token } = await service.issueInvite({ label: 'Race' });
196
-
197
- // Block acceptPhone until we release it, simulating the async window
198
- // between getPending() and removePending() under naive ordering.
199
- let release!: () => void;
200
- const gate = new Promise<void>((r) => { release = r; });
201
- const originalAccept = cadreNode.acceptPhone.bind(cadreNode);
202
- cadreNode.acceptPhone = async (options, issued) => {
203
- await gate;
204
- await originalAccept(options, issued);
205
- };
206
-
207
- const first = service.redeemInvite({ token, peerId: 'pA' });
208
- const second = service.redeemInvite({ token, peerId: 'pB' });
209
-
210
- // The second call must be rejected synchronously (before the gate opens)
211
- // because the pending row was already claimed by `first`.
212
- await expect(second).rejects.toMatchObject({ code: 'already_redeemed' });
213
-
214
- release();
215
- await expect(first).resolves.toMatchObject({ peerId: 'pA' });
216
-
217
- // Only one peer was authorized.
218
- expect(cadreNode.authorizedPeers.has('pA')).toBe(true);
219
- expect(cadreNode.authorizedPeers.has('pB')).toBe(false);
220
- });
221
-
222
- it('preserves the pending token when acceptPhone fails with node_unavailable', async () => {
223
- const { token } = await service.issueInvite({ label: 'Transient' });
224
- cadreNode.acceptShouldThrow = new OwnerNodeUnavailableError('node down');
225
-
226
- await expect(service.redeemInvite({ token, peerId: 'pTrans' }))
227
- .rejects.toMatchObject({ code: 'node_unavailable' });
228
-
229
- // Token must still be redeemable.
230
- expect(store.getPending(token)).toBeDefined();
231
- expect(cadreNode.authorizedPeers.has('pTrans')).toBe(false);
232
-
233
- // Retry after the node recovers.
234
- cadreNode.acceptShouldThrow = null;
235
- await expect(service.redeemInvite({ token, peerId: 'pTrans' }))
236
- .resolves.toMatchObject({ peerId: 'pTrans', label: 'Transient' });
237
- expect(cadreNode.authorizedPeers.has('pTrans')).toBe(true);
238
- expect(store.getPending(token)).toBeUndefined();
239
- });
240
-
241
- it('concurrent redeem during a gated node_unavailable leaves the token re-redeemable', async () => {
242
- const { token } = await service.issueInvite({ label: 'GatedFail' });
243
-
244
- // Gate acceptPhone — reject on release with a transient error so the
245
- // first redeem hits node_unavailable. The second redeem should still
246
- // bounce synchronously while the gate is held.
247
- let release!: () => void;
248
- const gate = new Promise<void>((_, reject) => { release = () => reject(new OwnerNodeUnavailableError('node down')); });
249
- cadreNode.acceptPhone = async () => {
250
- await gate;
251
- };
252
-
253
- const first = service.redeemInvite({ token, peerId: 'pA' });
254
- const second = service.redeemInvite({ token, peerId: 'pB' });
255
-
256
- await expect(second).rejects.toMatchObject({ code: 'already_redeemed' });
257
-
258
- release();
259
- await expect(first).rejects.toMatchObject({ code: 'node_unavailable' });
260
-
261
- // Pending row preserved; in-flight Set cleared via finally.
262
- expect(store.getPending(token)).toBeDefined();
263
- expect(cadreNode.authorizedPeers.has('pA')).toBe(false);
264
- expect(cadreNode.authorizedPeers.has('pB')).toBe(false);
265
-
266
- // Third redeem (after in-flight slot freed) succeeds against a healthy node.
267
- cadreNode.acceptPhone = async (options) => {
268
- cadreNode.authorizedPeers.add(options.phonePeerId);
269
- };
270
- await expect(service.redeemInvite({ token, peerId: 'pC' }))
271
- .resolves.toMatchObject({ peerId: 'pC', label: 'GatedFail' });
272
- expect(store.getPending(token)).toBeUndefined();
273
- expect(cadreNode.authorizedPeers.has('pC')).toBe(true);
274
- });
275
-
276
- it('preserves the pending token on non-transient acceptPhone failures (admin must revoke)', async () => {
277
- const { token } = await service.issueInvite({ label: 'OtherFail' });
278
- cadreNode.acceptShouldThrow = new Error('cadre-core rejected the peer');
279
-
280
- await expect(service.redeemInvite({ token, peerId: 'pX' }))
281
- .rejects.toThrow('cadre-core rejected the peer');
282
-
283
- expect(store.getPending(token)).toBeDefined();
284
- expect(cadreNode.authorizedPeers.has('pX')).toBe(false);
285
- });
286
- });
287
-
288
- describe('TrustCircleService.issueInvite — label length', () => {
289
- it('rejects labels over 200 characters', async () => {
290
- await expect(service.issueInvite({ label: 'a'.repeat(201) }))
291
- .rejects.toMatchObject({ code: 'invalid_label' });
292
- });
293
-
294
- it('accepts labels at the 200-character limit', async () => {
295
- await expect(service.issueInvite({ label: 'a'.repeat(200) }))
296
- .resolves.toBeDefined();
297
- });
298
- });
299
-
300
- describe('TrustCircleService.revokePending', () => {
301
- it('removes a pending row and prevents subsequent redemption', async () => {
302
- const { token } = await service.issueInvite({ label: 'Eve' });
303
- await service.revokePending(token);
304
- expect(store.getPending(token)).toBeUndefined();
305
-
306
- await expect(service.redeemInvite({ token, peerId: 'p1' }))
307
- .rejects.toMatchObject({ code: 'already_redeemed' });
308
- });
309
-
310
- it('throws not_found for an unknown token', async () => {
311
- await expect(service.revokePending('nope'))
312
- .rejects.toMatchObject({ code: 'not_found' });
313
- });
314
- });
315
-
316
- describe('TrustCircleService.removeMember', () => {
317
- it('removes from CadrePeer and the local label store', async () => {
318
- const { token } = await service.issueInvite({ label: 'Frank' });
319
- await service.redeemInvite({ token, peerId: 'pF' });
320
-
321
- await service.removeMember('pF');
322
- expect(cadreNode.removed).toEqual(['pF']);
323
- expect(cadreNode.authorizedPeers.has('pF')).toBe(false);
324
- expect(store.getMember('pF')).toBeUndefined();
325
- });
326
-
327
- it('throws not_found for an unknown peer', async () => {
328
- await expect(service.removeMember('unknown'))
329
- .rejects.toMatchObject({ code: 'not_found' });
330
- });
331
-
332
- it('removes a control-only peer (CadrePeer row without a local label)', async () => {
333
- cadreNode.authorizedPeers.add('pCtrlOnly');
334
- expect(store.getMember('pCtrlOnly')).toBeUndefined();
335
-
336
- await service.removeMember('pCtrlOnly');
337
- expect(cadreNode.removed).toEqual(['pCtrlOnly']);
338
- expect(cadreNode.authorizedPeers.has('pCtrlOnly')).toBe(false);
339
- });
340
- });
341
-
342
- describe('TrustCircleService.isMember', () => {
343
- it('reads from the CadrePeer table', async () => {
344
- expect(await service.isMember('pX')).toBe(false);
345
- cadreNode.authorizedPeers.add('pX');
346
- expect(await service.isMember('pX')).toBe(true);
347
- });
348
- });
349
-
350
- describe('TrustCircleService.list', () => {
351
- it('joins CadrePeer with local labels and prunes orphan labels', async () => {
352
- // Pre-seed a label whose peer is NOT in CadrePeer — should be pruned.
353
- store.addMember({ peerId: 'orphan', label: 'ghost', addedAt: 't' });
354
-
355
- const r1 = await service.issueInvite({ label: 'Alice' });
356
- await service.redeemInvite({ token: r1.token, peerId: 'pA' });
357
- const r2 = await service.issueInvite({ label: 'Bob' });
358
- await service.redeemInvite({ token: r2.token, peerId: 'pB' });
359
- // A still-pending invite that should appear in `pending`.
360
- const r3 = await service.issueInvite({ label: 'Carol' });
361
-
362
- const snap = await service.list();
363
-
364
- expect(snap.members.map(m => m.peerId).sort()).toEqual(['pA', 'pB']);
365
- expect(snap.members.find(m => m.peerId === 'pA')?.label).toBe('Alice');
366
- expect(snap.members.find(m => m.peerId === 'pB')?.label).toBe('Bob');
367
- expect(snap.pending.map(p => p.token)).toContain(r3.token);
368
-
369
- expect(store.getMember('orphan')).toBeUndefined();
370
- });
371
-
372
- it('falls back to local labels when the owner node is unavailable', async () => {
373
- store.addMember({ peerId: 'pZ', label: 'Zoe', addedAt: 't' });
374
- cadreNode.nodeAvailable = false;
375
-
376
- const snap = await service.list();
377
- expect(snap.members.some(m => m.peerId === 'pZ' && m.label === 'Zoe')).toBe(true);
378
- // Orphan check is skipped when the node is unavailable.
379
- expect(store.getMember('pZ')).toBeDefined();
380
- });
381
-
382
- it('issueInvite surfaces node_unavailable when the channel is down', async () => {
383
- const downNode = new MockCadreNode();
384
- downNode.createInvite = async () => { throw new OwnerNodeUnavailableError('refused'); };
385
- const svc = new TrustCircleService({ cadreNode: downNode, store });
386
- await expect(svc.issueInvite({ label: 'Nina' }))
387
- .rejects.toMatchObject({ code: 'node_unavailable' });
388
- });
389
-
390
- it('shows unlabelled CadrePeer rows with peerId as label', async () => {
391
- cadreNode.authorizedPeers.add('unlabeled-peer');
392
- const snap = await service.list();
393
- const entry = snap.members.find(m => m.peerId === 'unlabeled-peer');
394
- expect(entry?.label).toBe('unlabeled-peer');
395
- });
396
-
397
- it('excludes addressable-only peers nobody authorized', async () => {
398
- cadreNode.authorizedPeers.add('invited-peer');
399
- cadreNode.unauthorizedAddressablePeers.add('stranger-peer');
400
-
401
- const snap = await service.list();
402
-
403
- expect(snap.members.map(m => m.peerId)).toContain('invited-peer');
404
- expect(snap.members.map(m => m.peerId)).not.toContain('stranger-peer');
405
- });
406
-
407
- it('keeps the self row even though the authorized set excludes self', async () => {
408
- store.addMember({ peerId: 'self-peer', label: 'This device', addedAt: 't', self: true });
409
- // Self is addressable (would show under listMembers) but never authorized.
410
-
411
- const snap = await service.list();
412
-
413
- const self = snap.members.find(m => m.peerId === 'self-peer');
414
- expect(self).toBeDefined();
415
- expect(self?.self).toBe(true);
416
- expect(self?.label).toBe('This device');
417
- // Not pruned as an orphan label despite being absent from the authorized set.
418
- expect(store.getMember('self-peer')).toBeDefined();
419
- });
420
- });
421
-
422
- describe('createTrustCircleHandlers', () => {
423
- it('exposes the HTTP-handler surface', async () => {
424
- const handlers = createTrustCircleHandlers(service);
425
-
426
- const issued = await handlers.postInvite({ label: 'Pat' });
427
- expect(issued.encodedInvite.startsWith('enc:')).toBe(true);
428
- expect(typeof issued.token).toBe('string');
429
- expect(issued.expiresAt).toBeTruthy();
430
-
431
- let snap = await handlers.listTrustCircle();
432
- expect(snap.pending.map(p => p.token)).toContain(issued.token);
433
- expect(snap.members).toEqual([]);
434
-
435
- await handlers.deleteInvite(issued.token);
436
- snap = await handlers.listTrustCircle();
437
- expect(snap.pending).toEqual([]);
438
- });
439
-
440
- it('postInvite rejects missing label', async () => {
441
- const handlers = createTrustCircleHandlers(service);
442
- await expect(handlers.postInvite({ label: undefined as unknown as string }))
443
- .rejects.toMatchObject({ code: 'invalid_label' });
444
- });
445
- });
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
+ import { OwnerNodeUnavailableError } from '../../owner/owner-node-client.js';
13
+
14
+ /**
15
+ * Minimal in-memory mock of the owner-node channel adapter. Records calls
16
+ * so assertions can verify the auth + redemption flow without standing up a
17
+ * real libp2p control network or admin channel.
18
+ */
19
+ class MockCadreNode implements CadreNodeLike {
20
+ /** CadrePeer rows — the real (authorized) membership test. */
21
+ readonly authorizedPeers = new Set<string>();
22
+ /**
23
+ * Addressable-only rows — peers that published an address record without
24
+ * ever being vouched for. Models the "stranger writes its own row" bug:
25
+ * present in `listMembers`/`isMember` (addressable), absent from
26
+ * `listAuthorizedMembers`/`isAuthorizedMember` (authorized).
27
+ */
28
+ readonly unauthorizedAddressablePeers = new Set<string>();
29
+ readonly issuedInvites: Array<{ token?: string; expiresIn?: number }> = [];
30
+ readonly accepted: Array<{ phonePeerId: string; token?: string }> = [];
31
+ readonly removed: string[] = [];
32
+
33
+ /** Toggle to make acceptPhone reject. */
34
+ acceptShouldThrow: Error | null = null;
35
+ /** When false, membership calls throw OwnerNodeUnavailableError. */
36
+ nodeAvailable = true;
37
+
38
+ async createInvite(token?: string, expiresIn?: number) {
39
+ this.issuedInvites.push({ token, expiresIn });
40
+ const invite: CadreInvite = {
41
+ partyId: 'party-test',
42
+ ownerAddrs: ['/ip4/127.0.0.1/tcp/4001'],
43
+ token,
44
+ createdAt: Date.now(),
45
+ ...(expiresIn ? { expiresAt: Date.now() + expiresIn } : {}),
46
+ };
47
+ return { invite, encodedInvite: 'enc:' + JSON.stringify(invite) };
48
+ }
49
+
50
+ async acceptPhone(options: { phonePeerId: string; token?: string }, _issuedInvite?: CadreInvite) {
51
+ if (this.acceptShouldThrow) throw this.acceptShouldThrow;
52
+ this.accepted.push({ ...options });
53
+ this.authorizedPeers.add(options.phonePeerId);
54
+ }
55
+
56
+ async removePeer(peerId: string) {
57
+ this.removed.push(peerId);
58
+ this.authorizedPeers.delete(peerId);
59
+ this.unauthorizedAddressablePeers.delete(peerId);
60
+ }
61
+
62
+ encodeInvite(invite: CadreInvite): string {
63
+ return 'enc:' + JSON.stringify(invite);
64
+ }
65
+
66
+ async listMembers(): Promise<Array<{ peerId: string; multiaddr: string | null }>> {
67
+ if (!this.nodeAvailable) throw new OwnerNodeUnavailableError('node down');
68
+ return [...this.authorizedPeers, ...this.unauthorizedAddressablePeers]
69
+ .map((peerId) => ({ peerId, multiaddr: null }));
70
+ }
71
+
72
+ async isMember(peerId: string): Promise<boolean> {
73
+ if (!this.nodeAvailable) throw new OwnerNodeUnavailableError('node down');
74
+ return this.authorizedPeers.has(peerId) || this.unauthorizedAddressablePeers.has(peerId);
75
+ }
76
+
77
+ async listAuthorizedMembers(): Promise<Array<{ peerId: string; multiaddr: string | null }>> {
78
+ if (!this.nodeAvailable) throw new OwnerNodeUnavailableError('node down');
79
+ return [...this.authorizedPeers].map((peerId) => ({ peerId, multiaddr: null }));
80
+ }
81
+
82
+ async isAuthorizedMember(peerId: string): Promise<boolean> {
83
+ if (!this.nodeAvailable) throw new OwnerNodeUnavailableError('node down');
84
+ return this.authorizedPeers.has(peerId);
85
+ }
86
+ }
87
+
88
+ let tmpRoot: string;
89
+ let cadreNode: MockCadreNode;
90
+ let store: TrustCircleStore;
91
+ let service: TrustCircleService;
92
+
93
+ beforeEach(() => {
94
+ tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-tc-svc-'));
95
+ cadreNode = new MockCadreNode();
96
+ store = new TrustCircleStore(tmpRoot);
97
+ service = new TrustCircleService({ cadreNode, store });
98
+ });
99
+
100
+ afterEach(() => {
101
+ try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
102
+ });
103
+
104
+ describe('TrustCircleService.issueInvite', () => {
105
+ it('creates a pending row, returns an encoded invite, and uses default TTL', async () => {
106
+ const result = await service.issueInvite({ label: "Mom's phone" });
107
+
108
+ expect(result.token).toBeTruthy();
109
+ expect(result.encodedInvite.startsWith('enc:')).toBe(true);
110
+ expect(result.expiresAt).toBeInstanceOf(Date);
111
+
112
+ const pending = store.getPending(result.token);
113
+ expect(pending).toBeDefined();
114
+ expect(pending!.label).toBe("Mom's phone");
115
+ expect(pending!.expiresAt).toBe(result.expiresAt!.toISOString());
116
+
117
+ expect(cadreNode.issuedInvites).toHaveLength(1);
118
+ expect(cadreNode.issuedInvites[0]!.token).toBe(result.token);
119
+ });
120
+
121
+ it('rejects empty / whitespace labels', async () => {
122
+ await expect(service.issueInvite({ label: ' ' })).rejects.toBeInstanceOf(TrustCircleError);
123
+ await expect(service.issueInvite({ label: '' })).rejects.toBeInstanceOf(TrustCircleError);
124
+ });
125
+
126
+ it('honours custom ttl', async () => {
127
+ const start = Date.now();
128
+ const ttlMs = 5 * 60 * 1000;
129
+ const result = await service.issueInvite({ label: 'Carol', ttlMs });
130
+ const expiresMs = result.expiresAt!.getTime();
131
+ expect(expiresMs - start).toBeGreaterThanOrEqual(ttlMs - 50);
132
+ expect(expiresMs - start).toBeLessThanOrEqual(ttlMs + 500);
133
+ });
134
+
135
+ it('rejects non-positive ttl', async () => {
136
+ await expect(service.issueInvite({ label: 'X', ttlMs: 0 })).rejects.toBeInstanceOf(TrustCircleError);
137
+ await expect(service.issueInvite({ label: 'X', ttlMs: -1 })).rejects.toBeInstanceOf(TrustCircleError);
138
+ });
139
+ });
140
+
141
+ describe('TrustCircleService.redeemInvite', () => {
142
+ it('happy path: authorises peer, consumes pending, writes member', async () => {
143
+ const { token } = await service.issueInvite({ label: "Mom's phone" });
144
+
145
+ const result = await service.redeemInvite({ token, peerId: '12D3KooWMom' });
146
+ expect(result).toEqual({ peerId: '12D3KooWMom', label: "Mom's phone" });
147
+
148
+ expect(cadreNode.accepted).toHaveLength(1);
149
+ expect(cadreNode.accepted[0]).toMatchObject({ phonePeerId: '12D3KooWMom', token });
150
+ expect(cadreNode.authorizedPeers.has('12D3KooWMom')).toBe(true);
151
+
152
+ expect(store.getPending(token)).toBeUndefined();
153
+ expect(store.getMember('12D3KooWMom')?.label).toBe("Mom's phone");
154
+ });
155
+
156
+ it('rejects unknown token', async () => {
157
+ await expect(service.redeemInvite({ token: 'nope', peerId: 'p1' }))
158
+ .rejects.toMatchObject({ code: 'already_redeemed' });
159
+ expect(cadreNode.accepted).toHaveLength(0);
160
+ });
161
+
162
+ it('rejects expired invite and reaps the pending row', async () => {
163
+ let clock = new Date('2025-01-01T00:00:00Z');
164
+ const svc = new TrustCircleService({ cadreNode, store, now: () => clock });
165
+ const { token } = await svc.issueInvite({ label: 'Dad', ttlMs: 60_000 });
166
+
167
+ // Move clock past expiry.
168
+ clock = new Date('2025-01-02T00:00:00Z');
169
+
170
+ await expect(svc.redeemInvite({ token, peerId: 'p1' }))
171
+ .rejects.toMatchObject({ code: 'expired' });
172
+ expect(store.getPending(token)).toBeUndefined();
173
+ expect(cadreNode.accepted).toHaveLength(0);
174
+ });
175
+
176
+ it('double-redeem: second attempt is rejected because pending was consumed', async () => {
177
+ const { token } = await service.issueInvite({ label: 'Carol' });
178
+
179
+ await service.redeemInvite({ token, peerId: 'pA' });
180
+ await expect(service.redeemInvite({ token, peerId: 'pB' }))
181
+ .rejects.toMatchObject({ code: 'already_redeemed' });
182
+
183
+ // The second attempt must NOT have authorised pB.
184
+ expect(cadreNode.authorizedPeers.has('pB')).toBe(false);
185
+ });
186
+
187
+ it('requires both token and peerId', async () => {
188
+ await expect(service.redeemInvite({ token: '', peerId: 'p1' }))
189
+ .rejects.toBeInstanceOf(TrustCircleError);
190
+ await expect(service.redeemInvite({ token: 't', peerId: '' }))
191
+ .rejects.toBeInstanceOf(TrustCircleError);
192
+ });
193
+
194
+ it('claims pending atomically so a concurrent second call cannot also redeem', async () => {
195
+ const { token } = await service.issueInvite({ label: 'Race' });
196
+
197
+ // Block acceptPhone until we release it, simulating the async window
198
+ // between getPending() and removePending() under naive ordering.
199
+ let release!: () => void;
200
+ const gate = new Promise<void>((r) => { release = r; });
201
+ const originalAccept = cadreNode.acceptPhone.bind(cadreNode);
202
+ cadreNode.acceptPhone = async (options, issued) => {
203
+ await gate;
204
+ await originalAccept(options, issued);
205
+ };
206
+
207
+ const first = service.redeemInvite({ token, peerId: 'pA' });
208
+ const second = service.redeemInvite({ token, peerId: 'pB' });
209
+
210
+ // The second call must be rejected synchronously (before the gate opens)
211
+ // because the pending row was already claimed by `first`.
212
+ await expect(second).rejects.toMatchObject({ code: 'already_redeemed' });
213
+
214
+ release();
215
+ await expect(first).resolves.toMatchObject({ peerId: 'pA' });
216
+
217
+ // Only one peer was authorized.
218
+ expect(cadreNode.authorizedPeers.has('pA')).toBe(true);
219
+ expect(cadreNode.authorizedPeers.has('pB')).toBe(false);
220
+ });
221
+
222
+ it('preserves the pending token when acceptPhone fails with node_unavailable', async () => {
223
+ const { token } = await service.issueInvite({ label: 'Transient' });
224
+ cadreNode.acceptShouldThrow = new OwnerNodeUnavailableError('node down');
225
+
226
+ await expect(service.redeemInvite({ token, peerId: 'pTrans' }))
227
+ .rejects.toMatchObject({ code: 'node_unavailable' });
228
+
229
+ // Token must still be redeemable.
230
+ expect(store.getPending(token)).toBeDefined();
231
+ expect(cadreNode.authorizedPeers.has('pTrans')).toBe(false);
232
+
233
+ // Retry after the node recovers.
234
+ cadreNode.acceptShouldThrow = null;
235
+ await expect(service.redeemInvite({ token, peerId: 'pTrans' }))
236
+ .resolves.toMatchObject({ peerId: 'pTrans', label: 'Transient' });
237
+ expect(cadreNode.authorizedPeers.has('pTrans')).toBe(true);
238
+ expect(store.getPending(token)).toBeUndefined();
239
+ });
240
+
241
+ it('concurrent redeem during a gated node_unavailable leaves the token re-redeemable', async () => {
242
+ const { token } = await service.issueInvite({ label: 'GatedFail' });
243
+
244
+ // Gate acceptPhone — reject on release with a transient error so the
245
+ // first redeem hits node_unavailable. The second redeem should still
246
+ // bounce synchronously while the gate is held.
247
+ let release!: () => void;
248
+ const gate = new Promise<void>((_, reject) => { release = () => reject(new OwnerNodeUnavailableError('node down')); });
249
+ cadreNode.acceptPhone = async () => {
250
+ await gate;
251
+ };
252
+
253
+ const first = service.redeemInvite({ token, peerId: 'pA' });
254
+ const second = service.redeemInvite({ token, peerId: 'pB' });
255
+
256
+ await expect(second).rejects.toMatchObject({ code: 'already_redeemed' });
257
+
258
+ release();
259
+ await expect(first).rejects.toMatchObject({ code: 'node_unavailable' });
260
+
261
+ // Pending row preserved; in-flight Set cleared via finally.
262
+ expect(store.getPending(token)).toBeDefined();
263
+ expect(cadreNode.authorizedPeers.has('pA')).toBe(false);
264
+ expect(cadreNode.authorizedPeers.has('pB')).toBe(false);
265
+
266
+ // Third redeem (after in-flight slot freed) succeeds against a healthy node.
267
+ cadreNode.acceptPhone = async (options) => {
268
+ cadreNode.authorizedPeers.add(options.phonePeerId);
269
+ };
270
+ await expect(service.redeemInvite({ token, peerId: 'pC' }))
271
+ .resolves.toMatchObject({ peerId: 'pC', label: 'GatedFail' });
272
+ expect(store.getPending(token)).toBeUndefined();
273
+ expect(cadreNode.authorizedPeers.has('pC')).toBe(true);
274
+ });
275
+
276
+ it('preserves the pending token on non-transient acceptPhone failures (admin must revoke)', async () => {
277
+ const { token } = await service.issueInvite({ label: 'OtherFail' });
278
+ cadreNode.acceptShouldThrow = new Error('cadre-core rejected the peer');
279
+
280
+ await expect(service.redeemInvite({ token, peerId: 'pX' }))
281
+ .rejects.toThrow('cadre-core rejected the peer');
282
+
283
+ expect(store.getPending(token)).toBeDefined();
284
+ expect(cadreNode.authorizedPeers.has('pX')).toBe(false);
285
+ });
286
+ });
287
+
288
+ describe('TrustCircleService.issueInvite — label length', () => {
289
+ it('rejects labels over 200 characters', async () => {
290
+ await expect(service.issueInvite({ label: 'a'.repeat(201) }))
291
+ .rejects.toMatchObject({ code: 'invalid_label' });
292
+ });
293
+
294
+ it('accepts labels at the 200-character limit', async () => {
295
+ await expect(service.issueInvite({ label: 'a'.repeat(200) }))
296
+ .resolves.toBeDefined();
297
+ });
298
+ });
299
+
300
+ describe('TrustCircleService.revokePending', () => {
301
+ it('removes a pending row and prevents subsequent redemption', async () => {
302
+ const { token } = await service.issueInvite({ label: 'Eve' });
303
+ await service.revokePending(token);
304
+ expect(store.getPending(token)).toBeUndefined();
305
+
306
+ await expect(service.redeemInvite({ token, peerId: 'p1' }))
307
+ .rejects.toMatchObject({ code: 'already_redeemed' });
308
+ });
309
+
310
+ it('throws not_found for an unknown token', async () => {
311
+ await expect(service.revokePending('nope'))
312
+ .rejects.toMatchObject({ code: 'not_found' });
313
+ });
314
+ });
315
+
316
+ describe('TrustCircleService.removeMember', () => {
317
+ it('removes from CadrePeer and the local label store', async () => {
318
+ const { token } = await service.issueInvite({ label: 'Frank' });
319
+ await service.redeemInvite({ token, peerId: 'pF' });
320
+
321
+ await service.removeMember('pF');
322
+ expect(cadreNode.removed).toEqual(['pF']);
323
+ expect(cadreNode.authorizedPeers.has('pF')).toBe(false);
324
+ expect(store.getMember('pF')).toBeUndefined();
325
+ });
326
+
327
+ it('throws not_found for an unknown peer', async () => {
328
+ await expect(service.removeMember('unknown'))
329
+ .rejects.toMatchObject({ code: 'not_found' });
330
+ });
331
+
332
+ it('removes a control-only peer (CadrePeer row without a local label)', async () => {
333
+ cadreNode.authorizedPeers.add('pCtrlOnly');
334
+ expect(store.getMember('pCtrlOnly')).toBeUndefined();
335
+
336
+ await service.removeMember('pCtrlOnly');
337
+ expect(cadreNode.removed).toEqual(['pCtrlOnly']);
338
+ expect(cadreNode.authorizedPeers.has('pCtrlOnly')).toBe(false);
339
+ });
340
+ });
341
+
342
+ describe('TrustCircleService.isMember', () => {
343
+ it('reads from the CadrePeer table', async () => {
344
+ expect(await service.isMember('pX')).toBe(false);
345
+ cadreNode.authorizedPeers.add('pX');
346
+ expect(await service.isMember('pX')).toBe(true);
347
+ });
348
+ });
349
+
350
+ describe('TrustCircleService.list', () => {
351
+ it('joins CadrePeer with local labels and prunes orphan labels', async () => {
352
+ // Pre-seed a label whose peer is NOT in CadrePeer — should be pruned.
353
+ store.addMember({ peerId: 'orphan', label: 'ghost', addedAt: 't' });
354
+
355
+ const r1 = await service.issueInvite({ label: 'Alice' });
356
+ await service.redeemInvite({ token: r1.token, peerId: 'pA' });
357
+ const r2 = await service.issueInvite({ label: 'Bob' });
358
+ await service.redeemInvite({ token: r2.token, peerId: 'pB' });
359
+ // A still-pending invite that should appear in `pending`.
360
+ const r3 = await service.issueInvite({ label: 'Carol' });
361
+
362
+ const snap = await service.list();
363
+
364
+ expect(snap.members.map(m => m.peerId).sort()).toEqual(['pA', 'pB']);
365
+ expect(snap.members.find(m => m.peerId === 'pA')?.label).toBe('Alice');
366
+ expect(snap.members.find(m => m.peerId === 'pB')?.label).toBe('Bob');
367
+ expect(snap.pending.map(p => p.token)).toContain(r3.token);
368
+
369
+ expect(store.getMember('orphan')).toBeUndefined();
370
+ });
371
+
372
+ it('falls back to local labels when the owner node is unavailable', async () => {
373
+ store.addMember({ peerId: 'pZ', label: 'Zoe', addedAt: 't' });
374
+ cadreNode.nodeAvailable = false;
375
+
376
+ const snap = await service.list();
377
+ expect(snap.members.some(m => m.peerId === 'pZ' && m.label === 'Zoe')).toBe(true);
378
+ // Orphan check is skipped when the node is unavailable.
379
+ expect(store.getMember('pZ')).toBeDefined();
380
+ });
381
+
382
+ it('issueInvite surfaces node_unavailable when the channel is down', async () => {
383
+ const downNode = new MockCadreNode();
384
+ downNode.createInvite = async () => { throw new OwnerNodeUnavailableError('refused'); };
385
+ const svc = new TrustCircleService({ cadreNode: downNode, store });
386
+ await expect(svc.issueInvite({ label: 'Nina' }))
387
+ .rejects.toMatchObject({ code: 'node_unavailable' });
388
+ });
389
+
390
+ it('shows unlabelled CadrePeer rows with peerId as label', async () => {
391
+ cadreNode.authorizedPeers.add('unlabeled-peer');
392
+ const snap = await service.list();
393
+ const entry = snap.members.find(m => m.peerId === 'unlabeled-peer');
394
+ expect(entry?.label).toBe('unlabeled-peer');
395
+ });
396
+
397
+ it('excludes addressable-only peers nobody authorized', async () => {
398
+ cadreNode.authorizedPeers.add('invited-peer');
399
+ cadreNode.unauthorizedAddressablePeers.add('stranger-peer');
400
+
401
+ const snap = await service.list();
402
+
403
+ expect(snap.members.map(m => m.peerId)).toContain('invited-peer');
404
+ expect(snap.members.map(m => m.peerId)).not.toContain('stranger-peer');
405
+ });
406
+
407
+ it('keeps the self row even though the authorized set excludes self', async () => {
408
+ store.addMember({ peerId: 'self-peer', label: 'This device', addedAt: 't', self: true });
409
+ // Self is addressable (would show under listMembers) but never authorized.
410
+
411
+ const snap = await service.list();
412
+
413
+ const self = snap.members.find(m => m.peerId === 'self-peer');
414
+ expect(self).toBeDefined();
415
+ expect(self?.self).toBe(true);
416
+ expect(self?.label).toBe('This device');
417
+ // Not pruned as an orphan label despite being absent from the authorized set.
418
+ expect(store.getMember('self-peer')).toBeDefined();
419
+ });
420
+ });
421
+
422
+ describe('createTrustCircleHandlers', () => {
423
+ it('exposes the HTTP-handler surface', async () => {
424
+ const handlers = createTrustCircleHandlers(service);
425
+
426
+ const issued = await handlers.postInvite({ label: 'Pat' });
427
+ expect(issued.encodedInvite.startsWith('enc:')).toBe(true);
428
+ expect(typeof issued.token).toBe('string');
429
+ expect(issued.expiresAt).toBeTruthy();
430
+
431
+ let snap = await handlers.listTrustCircle();
432
+ expect(snap.pending.map(p => p.token)).toContain(issued.token);
433
+ expect(snap.members).toEqual([]);
434
+
435
+ await handlers.deleteInvite(issued.token);
436
+ snap = await handlers.listTrustCircle();
437
+ expect(snap.pending).toEqual([]);
438
+ });
439
+
440
+ it('postInvite rejects missing label', async () => {
441
+ const handlers = createTrustCircleHandlers(service);
442
+ await expect(handlers.postInvite({ label: undefined as unknown as string }))
443
+ .rejects.toMatchObject({ code: 'invalid_label' });
444
+ });
445
+ });