@serfab/cadre-host 0.10.0 → 0.12.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 (158) hide show
  1. package/README.md +406 -296
  2. package/dist/bin/host.d.ts +11 -7
  3. package/dist/bin/host.js +40 -12
  4. package/dist/bin/host.js.map +1 -1
  5. package/dist/installer/config.d.ts +4 -4
  6. package/dist/installer/config.js +5 -39
  7. package/dist/installer/config.js.map +1 -1
  8. package/dist/installer/identity.d.ts +8 -1
  9. package/dist/installer/identity.js +8 -1
  10. package/dist/installer/identity.js.map +1 -1
  11. package/dist/orchestrator/host-process-orchestrator.d.ts +2 -2
  12. package/dist/orchestrator/host-process-orchestrator.js +9 -4
  13. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  14. package/dist/server/settings-store.d.ts +1 -1
  15. package/dist/server/settings-store.js +5 -1
  16. package/dist/server/settings-store.js.map +1 -1
  17. package/dist/server/static.js +38 -38
  18. package/dist/ui/assets/index-C-iks41M.js.map +1 -1
  19. package/dist/ui/index.html +12 -12
  20. package/package.json +4 -4
  21. package/service/cadre-host.service.tmpl +16 -16
  22. package/service/com.serfab.cadre-host.plist.tmpl +35 -35
  23. package/service/install-service.ps1 +98 -98
  24. package/service/uninstall-service.ps1 +19 -19
  25. package/src/__tests__/build-targets.test.ts +26 -26
  26. package/src/__tests__/cli-invite.smoke.test.ts +192 -192
  27. package/src/__tests__/cli-nat.smoke.test.ts +212 -212
  28. package/src/__tests__/cli.smoke.test.ts +51 -46
  29. package/src/__tests__/global-setup.ts +56 -51
  30. package/src/__tests__/host-owner.smoke.test.ts +123 -123
  31. package/src/__tests__/orchestrator-env-scrub.test.ts +140 -140
  32. package/src/__tests__/orchestrator-node-identity.test.ts +5 -5
  33. package/src/__tests__/orchestrator-owner.test.ts +198 -198
  34. package/src/__tests__/orchestrator-pin-keys.test.ts +153 -153
  35. package/src/__tests__/orchestrator-push.test.ts +145 -145
  36. package/src/__tests__/orchestrator.test.ts +951 -951
  37. package/src/auth/__tests__/duration.test.ts +28 -28
  38. package/src/auth/__tests__/self-label.test.ts +85 -85
  39. package/src/auth/__tests__/trust-circle-store.test.ts +115 -115
  40. package/src/auth/__tests__/trust-circle.test.ts +445 -445
  41. package/src/auth/duration.ts +33 -33
  42. package/src/auth/index.ts +21 -21
  43. package/src/auth/self-label.ts +74 -74
  44. package/src/auth/trust-circle-store.ts +155 -155
  45. package/src/auth/types.ts +99 -99
  46. package/src/bin/__tests__/cli-reference.test.ts +167 -0
  47. package/src/bin/host.ts +1326 -1299
  48. package/src/donation/__tests__/donation-service.test.ts +1068 -1068
  49. package/src/donation/__tests__/donation-supervisor.test.ts +510 -510
  50. package/src/donation/__tests__/fake-orchestrator.test.ts +168 -168
  51. package/src/donation/__tests__/fake-orchestrator.ts +225 -225
  52. package/src/donation/__tests__/grant-service.test.ts +213 -213
  53. package/src/donation/__tests__/grant-store.test.ts +113 -113
  54. package/src/donation/donation-service.ts +962 -962
  55. package/src/donation/donation-store.ts +171 -171
  56. package/src/donation/donation-supervisor.ts +388 -388
  57. package/src/donation/grant-service.ts +200 -200
  58. package/src/donation/grant-store.ts +152 -152
  59. package/src/donation/index.ts +64 -64
  60. package/src/donation/types.ts +243 -243
  61. package/src/installer/__tests__/config.test.ts +134 -112
  62. package/src/installer/__tests__/identity.test.ts +34 -34
  63. package/src/installer/__tests__/paths.test.ts +47 -47
  64. package/src/installer/__tests__/service-host.test.ts +62 -62
  65. package/src/installer/__tests__/wizard.test.ts +64 -64
  66. package/src/installer/config.ts +5 -43
  67. package/src/installer/identity.ts +56 -49
  68. package/src/installer/paths.ts +109 -109
  69. package/src/installer/platform.ts +27 -27
  70. package/src/installer/service-host/index.ts +21 -21
  71. package/src/installer/service-host/template.ts +21 -21
  72. package/src/installer/wizard.ts +103 -103
  73. package/src/nat/__tests__/address-resolver.test.ts +82 -82
  74. package/src/nat/__tests__/ddns-duckdns.test.ts +83 -83
  75. package/src/nat/__tests__/ddns-updater.test.ts +253 -253
  76. package/src/nat/__tests__/external-ip.test.ts +124 -124
  77. package/src/nat/__tests__/nat-service.test.ts +18 -0
  78. package/src/nat/__tests__/nat-store.test.ts +93 -93
  79. package/src/nat/__tests__/port-mapper.test.ts +191 -191
  80. package/src/nat/__tests__/reachability.test.ts +24 -24
  81. package/src/nat/__tests__/secrets.test.ts +178 -178
  82. package/src/nat/address-resolver.ts +51 -51
  83. package/src/nat/ddns/duckdns.ts +76 -76
  84. package/src/nat/ddns/index.ts +55 -55
  85. package/src/nat/ddns/updater.ts +243 -243
  86. package/src/nat/external-ip.ts +154 -154
  87. package/src/nat/index.ts +71 -71
  88. package/src/nat/nat-service.ts +501 -501
  89. package/src/nat/nat-store.ts +154 -154
  90. package/src/nat/port-mapper.ts +329 -329
  91. package/src/nat/reachability.ts +34 -34
  92. package/src/nat/secrets/file-store.ts +130 -130
  93. package/src/nat/secrets/index.ts +112 -112
  94. package/src/nat/secrets/keytar-store.ts +67 -67
  95. package/src/nat/types.ts +159 -159
  96. package/src/orchestrator/__tests__/node-identity.test.ts +62 -62
  97. package/src/orchestrator/host-process-orchestrator.ts +9 -4
  98. package/src/orchestrator/index.ts +10 -10
  99. package/src/orchestrator/log-rotator.ts +53 -53
  100. package/src/orchestrator/node-identity.ts +40 -40
  101. package/src/orchestrator/pid-liveness.ts +24 -24
  102. package/src/orchestrator/port-allocator.ts +91 -91
  103. package/src/orchestrator/state-store.ts +90 -90
  104. package/src/orchestrator/types.ts +141 -141
  105. package/src/owner/__tests__/owner-node-client.test.ts +194 -194
  106. package/src/owner/index.ts +12 -12
  107. package/src/owner/owner-node-client.ts +259 -259
  108. package/src/push/__tests__/push.test.ts +90 -90
  109. package/src/push/index.ts +146 -146
  110. package/src/server/__tests__/bus.test.ts +38 -38
  111. package/src/server/__tests__/error-handler.test.ts +118 -118
  112. package/src/server/__tests__/grants-admin-route.test.ts +73 -73
  113. package/src/server/__tests__/grants-route.test.ts +263 -263
  114. package/src/server/__tests__/nodes-route.test.ts +259 -259
  115. package/src/server/__tests__/origin-guard.test.ts +72 -72
  116. package/src/server/__tests__/publishers.test.ts +216 -216
  117. package/src/server/__tests__/server.smoke.test.ts +253 -253
  118. package/src/server/__tests__/settings-route.test.ts +195 -194
  119. package/src/server/__tests__/sse-route.test.ts +119 -119
  120. package/src/server/__tests__/static.test.ts +145 -145
  121. package/src/server/__tests__/status-route.test.ts +134 -134
  122. package/src/server/__tests__/strands-route.test.ts +233 -233
  123. package/src/server/error-handler.ts +178 -178
  124. package/src/server/events/bus.ts +42 -42
  125. package/src/server/events/sse-route.ts +81 -81
  126. package/src/server/events/types.ts +23 -23
  127. package/src/server/index.ts +267 -267
  128. package/src/server/origin-guard.ts +84 -84
  129. package/src/server/routes/grants-admin.ts +41 -41
  130. package/src/server/routes/grants.ts +210 -210
  131. package/src/server/routes/nat.ts +63 -63
  132. package/src/server/routes/nodes.ts +170 -170
  133. package/src/server/routes/settings.ts +123 -123
  134. package/src/server/routes/status.ts +118 -118
  135. package/src/server/routes/strands.ts +54 -54
  136. package/src/server/routes/trust-circle.ts +49 -49
  137. package/src/server/routes/update.ts +43 -43
  138. package/src/server/server.ts +122 -122
  139. package/src/server/settings-store.ts +5 -1
  140. package/src/server/static.ts +207 -207
  141. package/src/strands/__tests__/strand-service.test.ts +165 -165
  142. package/src/strands/index.ts +14 -14
  143. package/src/strands/strand-service.ts +135 -135
  144. package/src/strands/types.ts +102 -102
  145. package/src/update/__tests__/apply.test.ts +158 -158
  146. package/src/update/__tests__/manifest.test.ts +128 -128
  147. package/src/update/__tests__/release-key.test.ts +42 -42
  148. package/src/update/__tests__/sign.test.ts +78 -78
  149. package/src/update/__tests__/store.test.ts +81 -81
  150. package/src/update/__tests__/update-service.integration.test.ts +256 -256
  151. package/src/update/__tests__/version.test.ts +31 -31
  152. package/src/update/apply.ts +190 -190
  153. package/src/update/index.ts +341 -341
  154. package/src/update/release-key.ts +71 -71
  155. package/src/update/sign.ts +74 -74
  156. package/src/update/store.ts +101 -101
  157. package/src/update/types.ts +109 -109
  158. 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
+ });