@serfab/cadre-host 0.9.0 → 0.10.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 (113) hide show
  1. package/dist/auth/index.d.ts +2 -0
  2. package/dist/auth/index.js +1 -0
  3. package/dist/auth/index.js.map +1 -1
  4. package/dist/auth/self-label.d.ts +36 -0
  5. package/dist/auth/self-label.js +57 -0
  6. package/dist/auth/self-label.js.map +1 -0
  7. package/dist/auth/trust-circle.d.ts +32 -5
  8. package/dist/auth/trust-circle.js +31 -11
  9. package/dist/auth/trust-circle.js.map +1 -1
  10. package/dist/bin/host.js +41 -5
  11. package/dist/bin/host.js.map +1 -1
  12. package/dist/donation/__tests__/fake-orchestrator.d.ts +103 -0
  13. package/dist/donation/__tests__/fake-orchestrator.js +180 -0
  14. package/dist/donation/__tests__/fake-orchestrator.js.map +1 -0
  15. package/dist/donation/donation-service.d.ts +237 -12
  16. package/dist/donation/donation-service.js +491 -34
  17. package/dist/donation/donation-service.js.map +1 -1
  18. package/dist/donation/donation-store.d.ts +7 -0
  19. package/dist/donation/donation-store.js +7 -0
  20. package/dist/donation/donation-store.js.map +1 -1
  21. package/dist/donation/donation-supervisor.d.ts +167 -0
  22. package/dist/donation/donation-supervisor.js +341 -0
  23. package/dist/donation/donation-supervisor.js.map +1 -0
  24. package/dist/donation/index.d.ts +4 -2
  25. package/dist/donation/index.js +2 -1
  26. package/dist/donation/index.js.map +1 -1
  27. package/dist/donation/types.d.ts +26 -0
  28. package/dist/donation/types.js.map +1 -1
  29. package/dist/index.d.ts +3 -1
  30. package/dist/index.js +3 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/orchestrator/host-process-orchestrator.d.ts +109 -5
  33. package/dist/orchestrator/host-process-orchestrator.js +341 -98
  34. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  35. package/dist/orchestrator/node-identity.d.ts +26 -0
  36. package/dist/orchestrator/node-identity.js +36 -0
  37. package/dist/orchestrator/node-identity.js.map +1 -0
  38. package/dist/orchestrator/port-allocator.d.ts +14 -0
  39. package/dist/orchestrator/port-allocator.js +45 -0
  40. package/dist/orchestrator/port-allocator.js.map +1 -1
  41. package/dist/owner/owner-node-client.d.ts +20 -5
  42. package/dist/owner/owner-node-client.js +21 -4
  43. package/dist/owner/owner-node-client.js.map +1 -1
  44. package/dist/server/error-handler.d.ts +7 -1
  45. package/dist/server/error-handler.js +23 -1
  46. package/dist/server/error-handler.js.map +1 -1
  47. package/dist/server/events/types.d.ts +3 -0
  48. package/dist/server/index.d.ts +7 -0
  49. package/dist/server/index.js +9 -3
  50. package/dist/server/index.js.map +1 -1
  51. package/dist/server/routes/grants.js +22 -3
  52. package/dist/server/routes/grants.js.map +1 -1
  53. package/dist/server/routes/strands.d.ts +20 -0
  54. package/dist/server/routes/strands.js +38 -0
  55. package/dist/server/routes/strands.js.map +1 -0
  56. package/dist/strands/index.d.ts +7 -0
  57. package/dist/strands/index.js +6 -0
  58. package/dist/strands/index.js.map +1 -0
  59. package/dist/strands/strand-service.d.ts +50 -0
  60. package/dist/strands/strand-service.js +103 -0
  61. package/dist/strands/strand-service.js.map +1 -0
  62. package/dist/strands/types.d.ts +93 -0
  63. package/dist/strands/types.js +23 -0
  64. package/dist/strands/types.js.map +1 -0
  65. package/dist/ui/assets/index-BVoqLjyX.css +1 -0
  66. package/dist/ui/assets/index-C-iks41M.js +17 -0
  67. package/dist/ui/assets/index-C-iks41M.js.map +1 -0
  68. package/dist/ui/index.html +2 -2
  69. package/dist/update/release-key.d.ts +1 -1
  70. package/dist/update/release-key.js +1 -1
  71. package/package.json +5 -5
  72. package/src/__tests__/build-targets.test.ts +26 -0
  73. package/src/__tests__/global-setup.ts +51 -0
  74. package/src/__tests__/orchestrator-env-scrub.test.ts +140 -0
  75. package/src/__tests__/orchestrator-node-identity.test.ts +204 -0
  76. package/src/__tests__/orchestrator.test.ts +387 -6
  77. package/src/auth/__tests__/self-label.test.ts +85 -0
  78. package/src/auth/__tests__/trust-circle-integration.test.ts +152 -147
  79. package/src/auth/__tests__/trust-circle.test.ts +45 -1
  80. package/src/auth/index.ts +2 -0
  81. package/src/auth/self-label.ts +74 -0
  82. package/src/auth/trust-circle.ts +45 -13
  83. package/src/bin/host.ts +1299 -1258
  84. package/src/donation/__tests__/donation-service.test.ts +836 -58
  85. package/src/donation/__tests__/donation-supervisor.test.ts +510 -0
  86. package/src/donation/__tests__/fake-orchestrator.test.ts +168 -0
  87. package/src/donation/__tests__/fake-orchestrator.ts +225 -0
  88. package/src/donation/donation-service.ts +611 -41
  89. package/src/donation/donation-store.ts +7 -0
  90. package/src/donation/donation-supervisor.ts +388 -0
  91. package/src/donation/index.ts +15 -0
  92. package/src/donation/types.ts +23 -0
  93. package/src/index.ts +14 -0
  94. package/src/orchestrator/__tests__/node-identity.test.ts +62 -0
  95. package/src/orchestrator/host-process-orchestrator.ts +336 -97
  96. package/src/orchestrator/node-identity.ts +40 -0
  97. package/src/orchestrator/port-allocator.ts +48 -0
  98. package/src/owner/owner-node-client.ts +31 -5
  99. package/src/server/__tests__/grants-route.test.ts +80 -6
  100. package/src/server/__tests__/strands-route.test.ts +233 -0
  101. package/src/server/error-handler.ts +24 -1
  102. package/src/server/events/types.ts +1 -0
  103. package/src/server/index.ts +16 -3
  104. package/src/server/routes/grants.ts +27 -3
  105. package/src/server/routes/strands.ts +54 -0
  106. package/src/strands/__tests__/strand-service.test.ts +165 -0
  107. package/src/strands/index.ts +14 -0
  108. package/src/strands/strand-service.ts +135 -0
  109. package/src/strands/types.ts +102 -0
  110. package/src/update/release-key.ts +1 -1
  111. package/dist/ui/assets/index-BlMXQPSw.js +0 -14
  112. package/dist/ui/assets/index-BlMXQPSw.js.map +0 -1
  113. package/dist/ui/assets/index-BwU5mJGF.css +0 -1
@@ -1,147 +1,152 @@
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
- import { generateKeyPair } from '@libp2p/crypto/keys';
6
- import { peerIdFromPrivateKey } from '@libp2p/peer-id';
7
- import { CadreNode, ed25519KeyPairFromLibp2p } from '@serfab/cadre-core';
8
-
9
- import { TrustCircleService } from '../trust-circle.js';
10
- import { TrustCircleStore } from '../trust-circle-store.js';
11
-
12
- /**
13
- * End-to-end integration test: stand up a real host CadreNode with a real
14
- * Quereus control DB and exercise the full TrustCircleService issue → redeem
15
- * → list → remove cycle. The "phone" peerId is derived from a fresh Ed25519
16
- * key (no second libp2p node) since the test only validates host-side state.
17
- *
18
- * This replaces the mock CadreNodeLike in trust-circle.test.ts with the real
19
- * CadreNode to validate that:
20
- * - `acceptPhone` (called from redeemInvite with a sparse reconstructed
21
- * invite) actually inserts a CadrePeer row;
22
- * - `list()` picks up the new peer via the canonical CadrePeer query;
23
- * - `removeMember` deletes the row from CadrePeer via the
24
- * DELETE-with-context syntax that cadre-core trusts from
25
- * `quereus/docs/sql.md`.
26
- */
27
- describe('TrustCircleService — real CadreNode integration', () => {
28
- let tmpRoot: string;
29
- let host: CadreNode;
30
- let store: TrustCircleStore;
31
- let service: TrustCircleService;
32
- let hostOwnerPrivateKey: string;
33
- let hostOwnerPublicKey: string;
34
-
35
- beforeEach(async () => {
36
- tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-tc-int-'));
37
-
38
- // Own-owner host: the node's libp2p identity key IS its owner key
39
- // (the cadre-cli `--owner` shape), so registerSelf can owner-sign the
40
- // INSERT of its own CadrePeer row.
41
- const hostKey = await generateKeyPair('Ed25519');
42
- const owner = ed25519KeyPairFromLibp2p(hostKey);
43
- hostOwnerPrivateKey = owner.privateKeyB64;
44
- hostOwnerPublicKey = owner.publicKeyB64;
45
-
46
- const baseId = Math.random().toString(36).slice(2);
47
-
48
- host = new CadreNode({
49
- controlNetwork: { partyId: `host-${baseId}`, bootstrapNodes: [] },
50
- privateKey: hostKey,
51
- profile: 'transaction'
52
- });
53
-
54
- await host.start();
55
-
56
- const db = host.getControlDatabase();
57
- expect(db).not.toBeNull();
58
- await db!.insertOwnerKey(hostOwnerPublicKey);
59
-
60
- host.initializeSeedBootstrap(hostOwnerPrivateKey);
61
-
62
- // The owner writes its own CadrePeer row up-front (the implement ticket's
63
- // CLI change). After this the host's own peerId is a member, so it shows up
64
- // alongside any redeemed peers in the trust-circle listing.
65
- await host.registerSelf();
66
-
67
- store = new TrustCircleStore(tmpRoot);
68
- service = new TrustCircleService({ cadreNode: host, store });
69
- }, 60_000);
70
-
71
- afterEach(async () => {
72
- try { await host.stop(); } catch { /* ignore */ }
73
- try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
74
- });
75
-
76
- it('issues → redeems → lists against the real control DB (listMembers path)', async () => {
77
- // Derive a shape-valid peerId from a fresh Ed25519 key — same pattern as
78
- // packages/cadre-core/test/seed-bootstrap.spec.ts. The AuthorizedInsert
79
- // constraint only checks the signature over digest(PeerId,…); peerId
80
- // *liveness* doesn't matter for the host-side CRUD path under test.
81
- const phoneKey = await generateKeyPair('Ed25519');
82
- const phonePeerId = peerIdFromPrivateKey(phoneKey).toString();
83
-
84
- // Issue
85
- const issued = await service.issueInvite({ label: "Mom's phone" });
86
- expect(issued.token).toBeTruthy();
87
- expect(issued.encodedInvite).toBeTruthy();
88
-
89
- // Redeem — this calls host.acceptPhone() with the sparse reconstructed
90
- // invite, which in turn calls authorizePeer and inserts CadrePeer.
91
- const redeemed = await service.redeemInvite({
92
- token: issued.token,
93
- peerId: phonePeerId
94
- });
95
- expect(redeemed).toEqual({ peerId: phonePeerId, label: "Mom's phone" });
96
-
97
- // Verify directly against the control DB: the row is there.
98
- expect(await isInCadrePeer(host, phonePeerId)).toBe(true);
99
-
100
- // List — exercises the CadreNode.listMembers() path. CadrePeer now carries
101
- // the host's own self-registered row in addition to the redeemed phone.
102
- const snap = await service.list();
103
- expect(snap.members).toHaveLength(2);
104
- const phoneMember = snap.members.find((m) => m.peerId === phonePeerId);
105
- expect(phoneMember).toBeDefined();
106
- expect(phoneMember!.label).toBe("Mom's phone");
107
- // The host's own peerId is present too (unlabeled → bare peerId as label).
108
- expect(snap.members.some((m) => m.peerId === host.peerId!.toString())).toBe(true);
109
- expect(snap.pending).toHaveLength(0);
110
- }, 90_000);
111
-
112
- // The signed `CadrePeer` DELETE previously threw a Quereus deferred-constraint
113
- // error ("No row context found for column PeerId") inside cadre-core's
114
- // removePeer. That upstream bug is fixed (`quereus-cadrepeer-delete-no-row-context`,
115
- // landed), so the full issue → redeem → remove cycle is now exercised here.
116
- it('removes a member from CadrePeer', async () => {
117
- const phoneKey = await generateKeyPair('Ed25519');
118
- const phonePeerId = peerIdFromPrivateKey(phoneKey).toString();
119
-
120
- const issued = await service.issueInvite({ label: "Mom's phone" });
121
- await service.redeemInvite({ token: issued.token, peerId: phonePeerId });
122
- expect(await isInCadrePeer(host, phonePeerId)).toBe(true);
123
-
124
- await service.removeMember(phonePeerId);
125
-
126
- expect(await isInCadrePeer(host, phonePeerId)).toBe(false);
127
- expect(store.getMember(phonePeerId)).toBeUndefined();
128
- const afterRemove = await service.list();
129
- // The phone is gone; the host's own self-registered row remains.
130
- expect(afterRemove.members.map((m) => m.peerId)).not.toContain(phonePeerId);
131
- expect(afterRemove.members).toHaveLength(1);
132
- expect(afterRemove.members[0]!.peerId).toBe(host.peerId!.toString());
133
- }, 90_000);
134
- });
135
-
136
- async function isInCadrePeer(node: CadreNode, peerId: string): Promise<boolean> {
137
- const db = node.getControlDatabase();
138
- if (!db) return false;
139
- const inner = db.getDatabase();
140
- for await (const row of inner.eval(
141
- 'select PeerId from CadreControl.CadrePeer where PeerId = ?',
142
- [peerId]
143
- )) {
144
- if (row.PeerId === peerId) return true;
145
- }
146
- return false;
147
- }
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
+ import { generateKeyPair } from '@libp2p/crypto/keys';
6
+ import { peerIdFromPrivateKey } from '@libp2p/peer-id';
7
+ import { CadreNode, ed25519KeyPairFromLibp2p } from '@serfab/cadre-core';
8
+
9
+ import { ensureSelfLabel } from '../self-label.js';
10
+ import { TrustCircleService } from '../trust-circle.js';
11
+ import { TrustCircleStore } from '../trust-circle-store.js';
12
+
13
+ /**
14
+ * End-to-end integration test: stand up a real host CadreNode with a real
15
+ * Quereus control DB and exercise the full TrustCircleService issue → redeem
16
+ * → list → remove cycle. The "phone" peerId is derived from a fresh Ed25519
17
+ * key (no second libp2p node) since the test only validates host-side state.
18
+ *
19
+ * This replaces the mock CadreNodeLike in trust-circle.test.ts with the real
20
+ * CadreNode to validate that:
21
+ * - `acceptPhone` (called from redeemInvite with a sparse reconstructed
22
+ * invite) actually inserts a CadrePeer row;
23
+ * - `list()` picks up the new peer via the canonical CadrePeer query;
24
+ * - `removeMember` deletes the row from CadrePeer via the
25
+ * DELETE-with-context syntax that cadre-core trusts from
26
+ * `quereus/docs/sql.md`.
27
+ */
28
+ describe('TrustCircleService — real CadreNode integration', () => {
29
+ let tmpRoot: string;
30
+ let host: CadreNode;
31
+ let store: TrustCircleStore;
32
+ let service: TrustCircleService;
33
+ let hostOwnerPrivateKey: string;
34
+ let hostOwnerPublicKey: string;
35
+
36
+ beforeEach(async () => {
37
+ tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-tc-int-'));
38
+
39
+ // Own-owner host: the node's libp2p identity key IS its owner key
40
+ // (the cadre-cli `--owner` shape), so registerSelf can owner-sign the
41
+ // INSERT of its own CadrePeer row.
42
+ const hostKey = await generateKeyPair('Ed25519');
43
+ const owner = ed25519KeyPairFromLibp2p(hostKey);
44
+ hostOwnerPrivateKey = owner.privateKeyB64;
45
+ hostOwnerPublicKey = owner.publicKeyB64;
46
+
47
+ const baseId = Math.random().toString(36).slice(2);
48
+
49
+ host = new CadreNode({
50
+ controlNetwork: { partyId: `host-${baseId}`, bootstrapNodes: [] },
51
+ privateKey: hostKey,
52
+ profile: 'transaction'
53
+ });
54
+
55
+ await host.start();
56
+
57
+ const db = host.getControlDatabase();
58
+ expect(db).not.toBeNull();
59
+ await db!.insertOwnerKey(hostOwnerPublicKey);
60
+
61
+ host.initializeSeedBootstrap(hostOwnerPrivateKey);
62
+
63
+ // The owner writes its own CadrePeer row up-front (the implement ticket's
64
+ // CLI change). After this the host's own peerId is a member, so it shows up
65
+ // alongside any redeemed peers in the trust-circle listing.
66
+ await host.registerSelf();
67
+
68
+ store = new TrustCircleStore(tmpRoot);
69
+ // Same self-labelling call bin/host.ts makes at startup, wired to the real
70
+ // node's peer ID instead of an OwnerNodeClient (this test constructs
71
+ // TrustCircleService directly against a CadreNode, so no client is in play).
72
+ await ensureSelfLabel({ store, getPeerId: async () => host.peerId!.toString() });
73
+ service = new TrustCircleService({ cadreNode: host, store });
74
+ }, 60_000);
75
+
76
+ afterEach(async () => {
77
+ try { await host.stop(); } catch { /* ignore */ }
78
+ try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
79
+ });
80
+
81
+ it('issues → redeems → lists against the real control DB (listMembers path)', async () => {
82
+ // Derive a shape-valid peerId from a fresh Ed25519 key — same pattern as
83
+ // packages/cadre-core/test/seed-bootstrap.spec.ts. The AuthorizedInsert
84
+ // constraint only checks the signature over digest(PeerId,…); peerId
85
+ // *liveness* doesn't matter for the host-side CRUD path under test.
86
+ const phoneKey = await generateKeyPair('Ed25519');
87
+ const phonePeerId = peerIdFromPrivateKey(phoneKey).toString();
88
+
89
+ // Issue
90
+ const issued = await service.issueInvite({ label: "Mom's phone" });
91
+ expect(issued.token).toBeTruthy();
92
+ expect(issued.encodedInvite).toBeTruthy();
93
+
94
+ // Redeem — this calls host.acceptPhone() with the sparse reconstructed
95
+ // invite, which in turn calls authorizePeer and inserts CadrePeer.
96
+ const redeemed = await service.redeemInvite({
97
+ token: issued.token,
98
+ peerId: phonePeerId
99
+ });
100
+ expect(redeemed).toEqual({ peerId: phonePeerId, label: "Mom's phone" });
101
+
102
+ // Verify directly against the control DB: the row is there.
103
+ expect(await isInCadrePeer(host, phonePeerId)).toBe(true);
104
+
105
+ // List — exercises the CadreNode.listMembers() path. CadrePeer now carries
106
+ // the host's own self-registered row in addition to the redeemed phone.
107
+ const snap = await service.list();
108
+ expect(snap.members).toHaveLength(2);
109
+ const phoneMember = snap.members.find((m) => m.peerId === phonePeerId);
110
+ expect(phoneMember).toBeDefined();
111
+ expect(phoneMember!.label).toBe("Mom's phone");
112
+ // The host's own peerId is present too (unlabeled → bare peerId as label).
113
+ expect(snap.members.some((m) => m.peerId === host.peerId!.toString())).toBe(true);
114
+ expect(snap.pending).toHaveLength(0);
115
+ }, 90_000);
116
+
117
+ // The signed `CadrePeer` DELETE previously threw a Quereus deferred-constraint
118
+ // error ("No row context found for column PeerId") inside cadre-core's
119
+ // removePeer. That upstream bug is fixed (`quereus-cadrepeer-delete-no-row-context`,
120
+ // landed), so the full issue → redeem → remove cycle is now exercised here.
121
+ it('removes a member from CadrePeer', async () => {
122
+ const phoneKey = await generateKeyPair('Ed25519');
123
+ const phonePeerId = peerIdFromPrivateKey(phoneKey).toString();
124
+
125
+ const issued = await service.issueInvite({ label: "Mom's phone" });
126
+ await service.redeemInvite({ token: issued.token, peerId: phonePeerId });
127
+ expect(await isInCadrePeer(host, phonePeerId)).toBe(true);
128
+
129
+ await service.removeMember(phonePeerId);
130
+
131
+ expect(await isInCadrePeer(host, phonePeerId)).toBe(false);
132
+ expect(store.getMember(phonePeerId)).toBeUndefined();
133
+ const afterRemove = await service.list();
134
+ // The phone is gone; the host's own self-registered row remains.
135
+ expect(afterRemove.members.map((m) => m.peerId)).not.toContain(phonePeerId);
136
+ expect(afterRemove.members).toHaveLength(1);
137
+ expect(afterRemove.members[0]!.peerId).toBe(host.peerId!.toString());
138
+ }, 90_000);
139
+ });
140
+
141
+ async function isInCadrePeer(node: CadreNode, peerId: string): Promise<boolean> {
142
+ const db = node.getControlDatabase();
143
+ if (!db) return false;
144
+ const inner = db.getDatabase();
145
+ for await (const row of inner.eval(
146
+ 'select PeerId from CadreControl.CadrePeer where PeerId = ?',
147
+ [peerId]
148
+ )) {
149
+ if (row.PeerId === peerId) return true;
150
+ }
151
+ return false;
152
+ }
@@ -17,7 +17,15 @@ import { OwnerNodeUnavailableError } from '../../owner/owner-node-client.js';
17
17
  * real libp2p control network or admin channel.
18
18
  */
19
19
  class MockCadreNode implements CadreNodeLike {
20
+ /** CadrePeer rows — the real (authorized) membership test. */
20
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>();
21
29
  readonly issuedInvites: Array<{ token?: string; expiresIn?: number }> = [];
22
30
  readonly accepted: Array<{ phonePeerId: string; token?: string }> = [];
23
31
  readonly removed: string[] = [];
@@ -48,6 +56,7 @@ class MockCadreNode implements CadreNodeLike {
48
56
  async removePeer(peerId: string) {
49
57
  this.removed.push(peerId);
50
58
  this.authorizedPeers.delete(peerId);
59
+ this.unauthorizedAddressablePeers.delete(peerId);
51
60
  }
52
61
 
53
62
  encodeInvite(invite: CadreInvite): string {
@@ -56,10 +65,21 @@ class MockCadreNode implements CadreNodeLike {
56
65
 
57
66
  async listMembers(): Promise<Array<{ peerId: string; multiaddr: string | null }>> {
58
67
  if (!this.nodeAvailable) throw new OwnerNodeUnavailableError('node down');
59
- return [...this.authorizedPeers].map((peerId) => ({ peerId, multiaddr: null }));
68
+ return [...this.authorizedPeers, ...this.unauthorizedAddressablePeers]
69
+ .map((peerId) => ({ peerId, multiaddr: null }));
60
70
  }
61
71
 
62
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> {
63
83
  if (!this.nodeAvailable) throw new OwnerNodeUnavailableError('node down');
64
84
  return this.authorizedPeers.has(peerId);
65
85
  }
@@ -373,6 +393,30 @@ describe('TrustCircleService.list', () => {
373
393
  const entry = snap.members.find(m => m.peerId === 'unlabeled-peer');
374
394
  expect(entry?.label).toBe('unlabeled-peer');
375
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
+ });
376
420
  });
377
421
 
378
422
  describe('createTrustCircleHandlers', () => {
package/src/auth/index.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  export { TrustCircleService, createTrustCircleHandlers, DEFAULT_INVITE_TTL_MS } from './trust-circle.js';
6
6
  export type { TrustCircleServiceOptions, CadreNodeLike } from './trust-circle.js';
7
7
  export { TrustCircleStore } from './trust-circle-store.js';
8
+ export { ensureSelfLabel, SELF_LABEL } from './self-label.js';
9
+ export type { EnsureSelfLabelOptions } from './self-label.js';
8
10
  export { parseDuration } from './duration.js';
9
11
  export {
10
12
  TrustCircleError,
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Self-labelling for the trust-circle listing.
3
+ *
4
+ * `TrustCircleService.list()` shows the cadre's *authorized* membership, which
5
+ * deliberately excludes the node's own self-published row — a node's own
6
+ * address record isn't something an owner key vouched for. To keep the owner's
7
+ * own device visible in the listing, cadre-host records a local
8
+ * `self: true` label for it and `list()` splices that row back in.
9
+ *
10
+ * Writing that label is this module's job. Kept out of `bin/host.ts` so it is
11
+ * unit-testable without standing up the CLI.
12
+ */
13
+
14
+ import debug from 'debug';
15
+
16
+ import type { TrustCircleStore } from './trust-circle-store.js';
17
+
18
+ const log = debug('cadre:host:trust-circle-self');
19
+
20
+ /** Label written for the owner's own device when none exists yet. */
21
+ export const SELF_LABEL = 'This device';
22
+
23
+ export interface EnsureSelfLabelOptions {
24
+ /** Local labels store to write into. */
25
+ store: TrustCircleStore;
26
+ /** Resolves the owner node's peer ID; '' when the node isn't ready yet. */
27
+ getPeerId: () => Promise<string>;
28
+ /** Clock override for tests. */
29
+ now?: () => Date;
30
+ }
31
+
32
+ /**
33
+ * Record the owner node's own peer ID as a `self: true` local label.
34
+ *
35
+ * Idempotent: an existing row for that peer ID is left alone, so an admin's
36
+ * rename survives restarts. Stale `self` rows (a different peer ID — the node
37
+ * identity was replaced) are dropped, since `list()` splices every `self` row
38
+ * in unconditionally and would otherwise show a device that no longer exists.
39
+ *
40
+ * Returns the peer ID that is now labelled, or `undefined` when the node
41
+ * wasn't ready. Throws whatever `getPeerId` throws — callers treat a failure
42
+ * as best-effort (the next start heals it).
43
+ */
44
+ export async function ensureSelfLabel(opts: EnsureSelfLabelOptions): Promise<string | undefined> {
45
+ const { store, getPeerId } = opts;
46
+ const now = opts.now ?? (() => new Date());
47
+
48
+ const peerId = await getPeerId();
49
+ if (!peerId) {
50
+ log('owner node not ready — no self label written');
51
+ return undefined;
52
+ }
53
+
54
+ for (const member of store.listMembers()) {
55
+ if (member.self && member.peerId !== peerId) {
56
+ log('dropping stale self label for %s (current identity is %s)', member.peerId, peerId);
57
+ store.removeMember(member.peerId);
58
+ }
59
+ }
60
+
61
+ const existing = store.getMember(peerId);
62
+ if (!existing) {
63
+ store.addMember({ peerId, label: SELF_LABEL, addedAt: now().toISOString(), self: true });
64
+ log('labelled own device %s as "%s"', peerId, SELF_LABEL);
65
+ } else if (!existing.self) {
66
+ // Row predates self-labelling (or was written by a redemption). Stamp the
67
+ // flag, keeping the label: without it `list()` prunes the row, since the
68
+ // authorized set never contains self.
69
+ store.addMember({ ...existing, self: true });
70
+ log('stamped existing label for own device %s as self', peerId);
71
+ }
72
+
73
+ return peerId;
74
+ }
@@ -42,10 +42,22 @@ export interface CadreNodeLike {
42
42
  acceptPhone(options: { phonePeerId: string; token?: string }, issuedInvite?: CadreInvite): Promise<void>;
43
43
  removePeer(peerId: string): Promise<void>;
44
44
  encodeInvite(invite: CadreInvite): string;
45
- /** Enumerate the cadre's `CadrePeer` membership (over the admin channel). */
45
+ /**
46
+ * Enumerate the cadre's *addressable* peers (over the admin channel) —
47
+ * anyone with an address record, including devices no owner vouched for.
48
+ * Used only for {@link removeMember}, where "the row exists" is the point.
49
+ */
46
50
  listMembers(): Promise<Array<{ peerId: string; multiaddr: string | null }>>;
47
- /** Probe whether a peer is a `CadrePeer` member (over the admin channel). */
51
+ /** Probe whether a peer is *addressable* (over the admin channel). See {@link listMembers}. */
48
52
  isMember(peerId: string): Promise<boolean>;
53
+ /**
54
+ * Enumerate the cadre's *authorized* membership (over the admin channel) —
55
+ * devices an owner key vouched for. Excludes the node's own self-published
56
+ * row. This is the real membership test the trust-circle listing shows.
57
+ */
58
+ listAuthorizedMembers(): Promise<Array<{ peerId: string; multiaddr: string | null }>>;
59
+ /** Probe whether a peer is *authorized*. See {@link listAuthorizedMembers}. */
60
+ isAuthorizedMember(peerId: string): Promise<boolean>;
49
61
  }
50
62
 
51
63
  /**
@@ -212,6 +224,13 @@ export class TrustCircleService {
212
224
  /**
213
225
  * Remove an authorised member. Deletes the CadrePeer row and the local
214
226
  * label. Throws not_found when the peer is in neither place.
227
+ *
228
+ * NOTE: no special case for the host's own peer ID — the local UI hides the
229
+ * Remove button on the `self` row, but `DELETE /auth/members/<ownPeerId>`
230
+ * (or `cadre-host trust revoke <ownPeerId>`) will delete the node's own
231
+ * `CadrePeer` row. cadre-core re-registers self on node start, so a restart
232
+ * heals it. If that stops being true, or the removal path grows a
233
+ * non-loopback caller, guard `self` rows here.
215
234
  */
216
235
  async removeMember(peerId: string): Promise<void> {
217
236
  const inLocal = this.store.getMember(peerId);
@@ -240,10 +259,15 @@ export class TrustCircleService {
240
259
  }
241
260
 
242
261
  /**
243
- * UI snapshot. Joins the canonical CadrePeer list (fetched over the admin
262
+ * UI snapshot. Joins the *authorized* membership (fetched over the admin
244
263
  * channel) with local labels; prunes any orphan labels (peer is no longer
245
- * authorised). When the owner node is unreachable, degrades to the
246
- * local labels file so listing keeps working while the node is down.
264
+ * authorised). Addressable-but-unauthorized peers — devices that merely
265
+ * published an address record without an owner voucher — are deliberately
266
+ * excluded; this is the listing an operator uses to decide who belongs.
267
+ * The node's own self-published row is spliced back in from the local
268
+ * label, since the authorized set excludes self by design. When the owner
269
+ * node is unreachable, degrades to the local labels file so listing keeps
270
+ * working while the node is down.
247
271
  */
248
272
  async list(): Promise<TrustCircleSnapshot> {
249
273
  const labels = new Map<string, Omit<TrustCircleMember, 'peerId'>>();
@@ -256,17 +280,17 @@ export class TrustCircleService {
256
280
  // Consult the control DB over the admin channel. A node-unavailable error
257
281
  // is non-fatal — fall back to the local labels rather than 503-ing a
258
282
  // read-only listing.
259
- let controlMembers: Array<{ peerId: string }> | null;
283
+ let authorizedMembers: Array<{ peerId: string }> | null;
260
284
  try {
261
- controlMembers = await this.cadreNode.listMembers();
285
+ authorizedMembers = await this.cadreNode.listAuthorizedMembers();
262
286
  } catch (err) {
263
287
  if (!(err instanceof OwnerNodeUnavailableError)) throw err;
264
- controlMembers = null;
288
+ authorizedMembers = null;
265
289
  }
266
290
 
267
- if (controlMembers) {
291
+ if (authorizedMembers) {
268
292
  const seenPeerIds = new Set<string>();
269
- for (const { peerId } of controlMembers) {
293
+ for (const { peerId } of authorizedMembers) {
270
294
  seenPeerIds.add(peerId);
271
295
  const label = labels.get(peerId);
272
296
  members.push({
@@ -276,9 +300,17 @@ export class TrustCircleService {
276
300
  ...(label?.self ? { self: true } : {}),
277
301
  });
278
302
  }
279
- // Prune labels for peers that no longer exist in CadrePeer. Only when we
280
- // successfully consulted the node — otherwise a transient outage would
281
- // wipe labels.
303
+ // The authorized set excludes the node's own self-published row —
304
+ // splice it back in from the local label rather than dropping it.
305
+ for (const [peerId, row] of labels) {
306
+ if (row.self && !seenPeerIds.has(peerId)) {
307
+ seenPeerIds.add(peerId);
308
+ members.push({ peerId, ...row });
309
+ }
310
+ }
311
+ // Prune labels for peers that are neither authorized nor self. Only
312
+ // when we successfully consulted the node — otherwise a transient
313
+ // outage would wipe labels.
282
314
  for (const peerId of labels.keys()) {
283
315
  if (!seenPeerIds.has(peerId)) {
284
316
  this.store.removeMember(peerId);