@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.
- package/README.md +406 -296
- package/dist/bin/host.d.ts +11 -7
- package/dist/bin/host.js +40 -12
- package/dist/bin/host.js.map +1 -1
- package/dist/installer/config.d.ts +4 -4
- package/dist/installer/config.js +5 -39
- package/dist/installer/config.js.map +1 -1
- package/dist/installer/identity.d.ts +8 -1
- package/dist/installer/identity.js +8 -1
- package/dist/installer/identity.js.map +1 -1
- package/dist/orchestrator/host-process-orchestrator.d.ts +2 -2
- package/dist/orchestrator/host-process-orchestrator.js +9 -4
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
- package/dist/server/settings-store.d.ts +1 -1
- package/dist/server/settings-store.js +5 -1
- package/dist/server/settings-store.js.map +1 -1
- package/dist/server/static.js +38 -38
- package/dist/ui/assets/index-C-iks41M.js.map +1 -1
- package/dist/ui/index.html +12 -12
- package/package.json +4 -4
- package/service/cadre-host.service.tmpl +16 -16
- package/service/com.serfab.cadre-host.plist.tmpl +35 -35
- package/service/install-service.ps1 +98 -98
- package/service/uninstall-service.ps1 +19 -19
- package/src/__tests__/build-targets.test.ts +26 -26
- package/src/__tests__/cli-invite.smoke.test.ts +192 -192
- package/src/__tests__/cli-nat.smoke.test.ts +212 -212
- package/src/__tests__/cli.smoke.test.ts +51 -46
- package/src/__tests__/global-setup.ts +56 -51
- package/src/__tests__/host-owner.smoke.test.ts +123 -123
- package/src/__tests__/orchestrator-env-scrub.test.ts +140 -140
- package/src/__tests__/orchestrator-node-identity.test.ts +5 -5
- package/src/__tests__/orchestrator-owner.test.ts +198 -198
- package/src/__tests__/orchestrator-pin-keys.test.ts +153 -153
- package/src/__tests__/orchestrator-push.test.ts +145 -145
- package/src/__tests__/orchestrator.test.ts +951 -951
- package/src/auth/__tests__/duration.test.ts +28 -28
- package/src/auth/__tests__/self-label.test.ts +85 -85
- package/src/auth/__tests__/trust-circle-store.test.ts +115 -115
- package/src/auth/__tests__/trust-circle.test.ts +445 -445
- package/src/auth/duration.ts +33 -33
- package/src/auth/index.ts +21 -21
- package/src/auth/self-label.ts +74 -74
- package/src/auth/trust-circle-store.ts +155 -155
- package/src/auth/types.ts +99 -99
- package/src/bin/__tests__/cli-reference.test.ts +167 -0
- package/src/bin/host.ts +1326 -1299
- package/src/donation/__tests__/donation-service.test.ts +1068 -1068
- package/src/donation/__tests__/donation-supervisor.test.ts +510 -510
- package/src/donation/__tests__/fake-orchestrator.test.ts +168 -168
- package/src/donation/__tests__/fake-orchestrator.ts +225 -225
- package/src/donation/__tests__/grant-service.test.ts +213 -213
- package/src/donation/__tests__/grant-store.test.ts +113 -113
- package/src/donation/donation-service.ts +962 -962
- package/src/donation/donation-store.ts +171 -171
- package/src/donation/donation-supervisor.ts +388 -388
- package/src/donation/grant-service.ts +200 -200
- package/src/donation/grant-store.ts +152 -152
- package/src/donation/index.ts +64 -64
- package/src/donation/types.ts +243 -243
- package/src/installer/__tests__/config.test.ts +134 -112
- package/src/installer/__tests__/identity.test.ts +34 -34
- package/src/installer/__tests__/paths.test.ts +47 -47
- package/src/installer/__tests__/service-host.test.ts +62 -62
- package/src/installer/__tests__/wizard.test.ts +64 -64
- package/src/installer/config.ts +5 -43
- package/src/installer/identity.ts +56 -49
- package/src/installer/paths.ts +109 -109
- package/src/installer/platform.ts +27 -27
- package/src/installer/service-host/index.ts +21 -21
- package/src/installer/service-host/template.ts +21 -21
- package/src/installer/wizard.ts +103 -103
- package/src/nat/__tests__/address-resolver.test.ts +82 -82
- package/src/nat/__tests__/ddns-duckdns.test.ts +83 -83
- package/src/nat/__tests__/ddns-updater.test.ts +253 -253
- package/src/nat/__tests__/external-ip.test.ts +124 -124
- package/src/nat/__tests__/nat-service.test.ts +18 -0
- package/src/nat/__tests__/nat-store.test.ts +93 -93
- package/src/nat/__tests__/port-mapper.test.ts +191 -191
- package/src/nat/__tests__/reachability.test.ts +24 -24
- package/src/nat/__tests__/secrets.test.ts +178 -178
- package/src/nat/address-resolver.ts +51 -51
- package/src/nat/ddns/duckdns.ts +76 -76
- package/src/nat/ddns/index.ts +55 -55
- package/src/nat/ddns/updater.ts +243 -243
- package/src/nat/external-ip.ts +154 -154
- package/src/nat/index.ts +71 -71
- package/src/nat/nat-service.ts +501 -501
- package/src/nat/nat-store.ts +154 -154
- package/src/nat/port-mapper.ts +329 -329
- package/src/nat/reachability.ts +34 -34
- package/src/nat/secrets/file-store.ts +130 -130
- package/src/nat/secrets/index.ts +112 -112
- package/src/nat/secrets/keytar-store.ts +67 -67
- package/src/nat/types.ts +159 -159
- package/src/orchestrator/__tests__/node-identity.test.ts +62 -62
- package/src/orchestrator/host-process-orchestrator.ts +9 -4
- package/src/orchestrator/index.ts +10 -10
- package/src/orchestrator/log-rotator.ts +53 -53
- package/src/orchestrator/node-identity.ts +40 -40
- package/src/orchestrator/pid-liveness.ts +24 -24
- package/src/orchestrator/port-allocator.ts +91 -91
- package/src/orchestrator/state-store.ts +90 -90
- package/src/orchestrator/types.ts +141 -141
- package/src/owner/__tests__/owner-node-client.test.ts +194 -194
- package/src/owner/index.ts +12 -12
- package/src/owner/owner-node-client.ts +259 -259
- package/src/push/__tests__/push.test.ts +90 -90
- package/src/push/index.ts +146 -146
- package/src/server/__tests__/bus.test.ts +38 -38
- package/src/server/__tests__/error-handler.test.ts +118 -118
- package/src/server/__tests__/grants-admin-route.test.ts +73 -73
- package/src/server/__tests__/grants-route.test.ts +263 -263
- package/src/server/__tests__/nodes-route.test.ts +259 -259
- package/src/server/__tests__/origin-guard.test.ts +72 -72
- package/src/server/__tests__/publishers.test.ts +216 -216
- package/src/server/__tests__/server.smoke.test.ts +253 -253
- package/src/server/__tests__/settings-route.test.ts +195 -194
- package/src/server/__tests__/sse-route.test.ts +119 -119
- package/src/server/__tests__/static.test.ts +145 -145
- package/src/server/__tests__/status-route.test.ts +134 -134
- package/src/server/__tests__/strands-route.test.ts +233 -233
- package/src/server/error-handler.ts +178 -178
- package/src/server/events/bus.ts +42 -42
- package/src/server/events/sse-route.ts +81 -81
- package/src/server/events/types.ts +23 -23
- package/src/server/index.ts +267 -267
- package/src/server/origin-guard.ts +84 -84
- package/src/server/routes/grants-admin.ts +41 -41
- package/src/server/routes/grants.ts +210 -210
- package/src/server/routes/nat.ts +63 -63
- package/src/server/routes/nodes.ts +170 -170
- package/src/server/routes/settings.ts +123 -123
- package/src/server/routes/status.ts +118 -118
- package/src/server/routes/strands.ts +54 -54
- package/src/server/routes/trust-circle.ts +49 -49
- package/src/server/routes/update.ts +43 -43
- package/src/server/server.ts +122 -122
- package/src/server/settings-store.ts +5 -1
- package/src/server/static.ts +207 -207
- package/src/strands/__tests__/strand-service.test.ts +165 -165
- package/src/strands/index.ts +14 -14
- package/src/strands/strand-service.ts +135 -135
- package/src/strands/types.ts +102 -102
- package/src/update/__tests__/apply.test.ts +158 -158
- package/src/update/__tests__/manifest.test.ts +128 -128
- package/src/update/__tests__/release-key.test.ts +42 -42
- package/src/update/__tests__/sign.test.ts +78 -78
- package/src/update/__tests__/store.test.ts +81 -81
- package/src/update/__tests__/update-service.integration.test.ts +256 -256
- package/src/update/__tests__/version.test.ts +31 -31
- package/src/update/apply.ts +190 -190
- package/src/update/index.ts +341 -341
- package/src/update/release-key.ts +71 -71
- package/src/update/sign.ts +74 -74
- package/src/update/store.ts +101 -101
- package/src/update/types.ts +109 -109
- 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
|
+
});
|