@serfab/cadre-host 0.8.1 → 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 (198) 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 +36 -9
  8. package/dist/auth/trust-circle.js +40 -20
  9. package/dist/auth/trust-circle.js.map +1 -1
  10. package/dist/auth/types.d.ts +2 -2
  11. package/dist/bin/host.js +291 -54
  12. package/dist/bin/host.js.map +1 -1
  13. package/dist/donation/__tests__/fake-orchestrator.d.ts +103 -0
  14. package/dist/donation/__tests__/fake-orchestrator.js +180 -0
  15. package/dist/donation/__tests__/fake-orchestrator.js.map +1 -0
  16. package/dist/donation/donation-service.d.ts +361 -0
  17. package/dist/donation/donation-service.js +763 -0
  18. package/dist/donation/donation-service.js.map +1 -0
  19. package/dist/donation/donation-store.d.ts +49 -0
  20. package/dist/donation/donation-store.js +148 -0
  21. package/dist/donation/donation-store.js.map +1 -0
  22. package/dist/donation/donation-supervisor.d.ts +167 -0
  23. package/dist/donation/donation-supervisor.js +341 -0
  24. package/dist/donation/donation-supervisor.js.map +1 -0
  25. package/dist/donation/grant-service.d.ts +71 -0
  26. package/dist/donation/grant-service.js +163 -0
  27. package/dist/donation/grant-service.js.map +1 -0
  28. package/dist/donation/grant-store.d.ts +38 -0
  29. package/dist/donation/grant-store.js +135 -0
  30. package/dist/donation/grant-store.js.map +1 -0
  31. package/dist/donation/index.d.ts +20 -0
  32. package/dist/donation/index.js +17 -0
  33. package/dist/donation/index.js.map +1 -0
  34. package/dist/donation/types.d.ts +201 -0
  35. package/dist/donation/types.js +40 -0
  36. package/dist/donation/types.js.map +1 -0
  37. package/dist/index.d.ts +9 -5
  38. package/dist/index.js +7 -3
  39. package/dist/index.js.map +1 -1
  40. package/dist/installer/config.d.ts +26 -0
  41. package/dist/installer/config.js +13 -0
  42. package/dist/installer/config.js.map +1 -1
  43. package/dist/installer/index.d.ts +2 -0
  44. package/dist/installer/index.js +3 -0
  45. package/dist/installer/index.js.map +1 -1
  46. package/dist/installer/wizard.d.ts +4 -0
  47. package/dist/installer/wizard.js +5 -1
  48. package/dist/installer/wizard.js.map +1 -1
  49. package/dist/nat/nat-service.d.ts +5 -5
  50. package/dist/nat/nat-service.js +9 -9
  51. package/dist/nat/nat-service.js.map +1 -1
  52. package/dist/nat/types.d.ts +1 -1
  53. package/dist/orchestrator/host-process-orchestrator.d.ts +144 -32
  54. package/dist/orchestrator/host-process-orchestrator.js +397 -128
  55. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  56. package/dist/orchestrator/index.d.ts +3 -3
  57. package/dist/orchestrator/index.js +1 -1
  58. package/dist/orchestrator/index.js.map +1 -1
  59. package/dist/orchestrator/node-identity.d.ts +26 -0
  60. package/dist/orchestrator/node-identity.js +36 -0
  61. package/dist/orchestrator/node-identity.js.map +1 -0
  62. package/dist/orchestrator/port-allocator.d.ts +14 -0
  63. package/dist/orchestrator/port-allocator.js +45 -0
  64. package/dist/orchestrator/port-allocator.js.map +1 -1
  65. package/dist/orchestrator/state-store.d.ts +5 -5
  66. package/dist/orchestrator/state-store.js +2 -2
  67. package/dist/orchestrator/state-store.js.map +1 -1
  68. package/dist/orchestrator/types.d.ts +8 -8
  69. package/dist/owner/index.d.ts +11 -0
  70. package/dist/owner/index.js +11 -0
  71. package/dist/owner/index.js.map +1 -0
  72. package/dist/owner/owner-node-client.d.ts +110 -0
  73. package/dist/{authority/authority-node-client.js → owner/owner-node-client.js} +58 -17
  74. package/dist/owner/owner-node-client.js.map +1 -0
  75. package/dist/server/error-handler.d.ts +22 -1
  76. package/dist/server/error-handler.js +64 -1
  77. package/dist/server/error-handler.js.map +1 -1
  78. package/dist/server/events/types.d.ts +3 -0
  79. package/dist/server/index.d.ts +33 -2
  80. package/dist/server/index.js +43 -16
  81. package/dist/server/index.js.map +1 -1
  82. package/dist/server/routes/grants-admin.d.ts +17 -0
  83. package/dist/server/routes/grants-admin.js +32 -0
  84. package/dist/server/routes/grants-admin.js.map +1 -0
  85. package/dist/server/routes/grants.d.ts +28 -0
  86. package/dist/server/routes/grants.js +182 -0
  87. package/dist/server/routes/grants.js.map +1 -0
  88. package/dist/server/routes/nodes.d.ts +5 -4
  89. package/dist/server/routes/nodes.js +19 -18
  90. package/dist/server/routes/nodes.js.map +1 -1
  91. package/dist/server/routes/settings.d.ts +6 -1
  92. package/dist/server/routes/settings.js +5 -2
  93. package/dist/server/routes/settings.js.map +1 -1
  94. package/dist/server/routes/status.d.ts +8 -4
  95. package/dist/server/routes/status.js +9 -4
  96. package/dist/server/routes/status.js.map +1 -1
  97. package/dist/server/routes/strands.d.ts +20 -0
  98. package/dist/server/routes/strands.js +38 -0
  99. package/dist/server/routes/strands.js.map +1 -0
  100. package/dist/strands/index.d.ts +7 -0
  101. package/dist/strands/index.js +6 -0
  102. package/dist/strands/index.js.map +1 -0
  103. package/dist/strands/strand-service.d.ts +50 -0
  104. package/dist/strands/strand-service.js +103 -0
  105. package/dist/strands/strand-service.js.map +1 -0
  106. package/dist/strands/types.d.ts +93 -0
  107. package/dist/strands/types.js +23 -0
  108. package/dist/strands/types.js.map +1 -0
  109. package/dist/ui/assets/index-BVoqLjyX.css +1 -0
  110. package/dist/ui/assets/index-C-iks41M.js +17 -0
  111. package/dist/ui/assets/index-C-iks41M.js.map +1 -0
  112. package/dist/ui/index.html +2 -2
  113. package/dist/update/release-key.d.ts +1 -1
  114. package/dist/update/release-key.js +8 -6
  115. package/dist/update/release-key.js.map +1 -1
  116. package/package.json +5 -5
  117. package/src/__tests__/build-targets.test.ts +26 -0
  118. package/src/__tests__/global-setup.ts +51 -0
  119. package/src/__tests__/{host-authority.smoke.test.ts → host-owner.smoke.test.ts} +7 -7
  120. package/src/__tests__/orchestrator-env-scrub.test.ts +140 -0
  121. package/src/__tests__/orchestrator-node-identity.test.ts +204 -0
  122. package/src/__tests__/{orchestrator-authority.test.ts → orchestrator-owner.test.ts} +33 -33
  123. package/src/__tests__/orchestrator-pin-keys.test.ts +153 -0
  124. package/src/__tests__/orchestrator-push.test.ts +10 -10
  125. package/src/__tests__/orchestrator.test.ts +387 -6
  126. package/src/auth/__tests__/self-label.test.ts +85 -0
  127. package/src/auth/__tests__/trust-circle-integration.test.ts +152 -147
  128. package/src/auth/__tests__/trust-circle.test.ts +55 -11
  129. package/src/auth/index.ts +2 -0
  130. package/src/auth/self-label.ts +74 -0
  131. package/src/auth/trust-circle.ts +54 -22
  132. package/src/auth/types.ts +2 -2
  133. package/src/bin/host.ts +1299 -1038
  134. package/src/donation/__tests__/donation-service.test.ts +1068 -0
  135. package/src/donation/__tests__/donation-supervisor.test.ts +510 -0
  136. package/src/donation/__tests__/fake-orchestrator.test.ts +168 -0
  137. package/src/donation/__tests__/fake-orchestrator.ts +225 -0
  138. package/src/donation/__tests__/grant-service.test.ts +213 -0
  139. package/src/donation/__tests__/grant-store.test.ts +113 -0
  140. package/src/donation/donation-service.ts +962 -0
  141. package/src/donation/donation-store.ts +171 -0
  142. package/src/donation/donation-supervisor.ts +388 -0
  143. package/src/donation/grant-service.ts +200 -0
  144. package/src/donation/grant-store.ts +152 -0
  145. package/src/donation/index.ts +64 -0
  146. package/src/donation/types.ts +243 -0
  147. package/src/index.ts +51 -5
  148. package/src/installer/__tests__/config.test.ts +27 -1
  149. package/src/installer/__tests__/installer.smoke.test.ts +31 -1
  150. package/src/installer/__tests__/wizard.test.ts +5 -1
  151. package/src/installer/config.ts +34 -0
  152. package/src/installer/index.ts +5 -0
  153. package/src/installer/wizard.ts +12 -1
  154. package/src/nat/__tests__/nat-service.test.ts +5 -5
  155. package/src/nat/nat-service.ts +10 -10
  156. package/src/nat/types.ts +1 -1
  157. package/src/orchestrator/__tests__/node-identity.test.ts +62 -0
  158. package/src/orchestrator/host-process-orchestrator.ts +401 -134
  159. package/src/orchestrator/index.ts +3 -3
  160. package/src/orchestrator/node-identity.ts +40 -0
  161. package/src/orchestrator/port-allocator.ts +48 -0
  162. package/src/orchestrator/state-store.ts +7 -7
  163. package/src/orchestrator/types.ts +8 -8
  164. package/src/{authority/__tests__/authority-node-client.test.ts → owner/__tests__/owner-node-client.test.ts} +45 -17
  165. package/src/owner/index.ts +12 -0
  166. package/src/{authority/authority-node-client.ts → owner/owner-node-client.ts} +83 -23
  167. package/src/server/__tests__/error-handler.test.ts +22 -0
  168. package/src/server/__tests__/grants-admin-route.test.ts +73 -0
  169. package/src/server/__tests__/grants-route.test.ts +263 -0
  170. package/src/server/__tests__/nodes-route.test.ts +25 -25
  171. package/src/server/__tests__/server.smoke.test.ts +75 -0
  172. package/src/server/__tests__/settings-route.test.ts +17 -0
  173. package/src/server/__tests__/status-route.test.ts +21 -0
  174. package/src/server/__tests__/strands-route.test.ts +233 -0
  175. package/src/server/error-handler.ts +67 -1
  176. package/src/server/events/types.ts +1 -0
  177. package/src/server/index.ts +75 -17
  178. package/src/server/routes/grants-admin.ts +41 -0
  179. package/src/server/routes/grants.ts +210 -0
  180. package/src/server/routes/nodes.ts +19 -18
  181. package/src/server/routes/settings.ts +11 -3
  182. package/src/server/routes/status.ts +18 -8
  183. package/src/server/routes/strands.ts +54 -0
  184. package/src/strands/__tests__/strand-service.test.ts +165 -0
  185. package/src/strands/index.ts +14 -0
  186. package/src/strands/strand-service.ts +135 -0
  187. package/src/strands/types.ts +102 -0
  188. package/src/update/__tests__/release-key.test.ts +16 -3
  189. package/src/update/release-key.ts +8 -6
  190. package/dist/authority/authority-node-client.d.ts +0 -77
  191. package/dist/authority/authority-node-client.js.map +0 -1
  192. package/dist/authority/index.d.ts +0 -11
  193. package/dist/authority/index.js +0 -11
  194. package/dist/authority/index.js.map +0 -1
  195. package/dist/ui/assets/index-BlMXQPSw.js +0 -14
  196. package/dist/ui/assets/index-BlMXQPSw.js.map +0 -1
  197. package/dist/ui/assets/index-BwU5mJGF.css +0 -1
  198. package/src/authority/index.ts +0 -12
@@ -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, authorityKeyFromLibp2p } 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 hostAuthorityPrivateKey: string;
33
- let hostAuthorityPublicKey: string;
34
-
35
- beforeEach(async () => {
36
- tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-tc-int-'));
37
-
38
- // Own-authority host: the node's libp2p identity key IS its authority key
39
- // (the cadre-cli `--authority` shape), so registerSelf can authority-sign the
40
- // INSERT of its own CadrePeer row.
41
- const hostKey = await generateKeyPair('Ed25519');
42
- const authority = authorityKeyFromLibp2p(hostKey);
43
- hostAuthorityPrivateKey = authority.privateKeyB64;
44
- hostAuthorityPublicKey = authority.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!.insertAuthorityKey(hostAuthorityPublicKey);
59
-
60
- host.initializeSeedBootstrap(hostAuthorityPrivateKey);
61
-
62
- // The authority 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
+ }
@@ -9,29 +9,37 @@ import { TrustCircleService, createTrustCircleHandlers } from '../trust-circle.j
9
9
  import { TrustCircleStore } from '../trust-circle-store.js';
10
10
  import { TrustCircleError } from '../types.js';
11
11
  import type { CadreNodeLike } from '../trust-circle.js';
12
- import { AuthorityNodeUnavailableError } from '../../authority/authority-node-client.js';
12
+ import { OwnerNodeUnavailableError } from '../../owner/owner-node-client.js';
13
13
 
14
14
  /**
15
- * Minimal in-memory mock of the authority-node channel adapter. Records calls
15
+ * Minimal in-memory mock of the owner-node channel adapter. Records calls
16
16
  * so assertions can verify the auth + redemption flow without standing up a
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[] = [];
24
32
 
25
33
  /** Toggle to make acceptPhone reject. */
26
34
  acceptShouldThrow: Error | null = null;
27
- /** When false, membership calls throw AuthorityNodeUnavailableError. */
35
+ /** When false, membership calls throw OwnerNodeUnavailableError. */
28
36
  nodeAvailable = true;
29
37
 
30
38
  async createInvite(token?: string, expiresIn?: number) {
31
39
  this.issuedInvites.push({ token, expiresIn });
32
40
  const invite: CadreInvite = {
33
41
  partyId: 'party-test',
34
- authorityAddrs: ['/ip4/127.0.0.1/tcp/4001'],
42
+ ownerAddrs: ['/ip4/127.0.0.1/tcp/4001'],
35
43
  token,
36
44
  createdAt: Date.now(),
37
45
  ...(expiresIn ? { expiresAt: Date.now() + expiresIn } : {}),
@@ -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 {
@@ -55,12 +64,23 @@ class MockCadreNode implements CadreNodeLike {
55
64
  }
56
65
 
57
66
  async listMembers(): Promise<Array<{ peerId: string; multiaddr: string | null }>> {
58
- if (!this.nodeAvailable) throw new AuthorityNodeUnavailableError('node down');
59
- return [...this.authorizedPeers].map((peerId) => ({ peerId, multiaddr: null }));
67
+ if (!this.nodeAvailable) throw new OwnerNodeUnavailableError('node down');
68
+ return [...this.authorizedPeers, ...this.unauthorizedAddressablePeers]
69
+ .map((peerId) => ({ peerId, multiaddr: null }));
60
70
  }
61
71
 
62
72
  async isMember(peerId: string): Promise<boolean> {
63
- if (!this.nodeAvailable) throw new AuthorityNodeUnavailableError('node down');
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');
64
84
  return this.authorizedPeers.has(peerId);
65
85
  }
66
86
  }
@@ -201,7 +221,7 @@ describe('TrustCircleService.redeemInvite', () => {
201
221
 
202
222
  it('preserves the pending token when acceptPhone fails with node_unavailable', async () => {
203
223
  const { token } = await service.issueInvite({ label: 'Transient' });
204
- cadreNode.acceptShouldThrow = new AuthorityNodeUnavailableError('node down');
224
+ cadreNode.acceptShouldThrow = new OwnerNodeUnavailableError('node down');
205
225
 
206
226
  await expect(service.redeemInvite({ token, peerId: 'pTrans' }))
207
227
  .rejects.toMatchObject({ code: 'node_unavailable' });
@@ -225,7 +245,7 @@ describe('TrustCircleService.redeemInvite', () => {
225
245
  // first redeem hits node_unavailable. The second redeem should still
226
246
  // bounce synchronously while the gate is held.
227
247
  let release!: () => void;
228
- const gate = new Promise<void>((_, reject) => { release = () => reject(new AuthorityNodeUnavailableError('node down')); });
248
+ const gate = new Promise<void>((_, reject) => { release = () => reject(new OwnerNodeUnavailableError('node down')); });
229
249
  cadreNode.acceptPhone = async () => {
230
250
  await gate;
231
251
  };
@@ -349,7 +369,7 @@ describe('TrustCircleService.list', () => {
349
369
  expect(store.getMember('orphan')).toBeUndefined();
350
370
  });
351
371
 
352
- it('falls back to local labels when the authority node is unavailable', async () => {
372
+ it('falls back to local labels when the owner node is unavailable', async () => {
353
373
  store.addMember({ peerId: 'pZ', label: 'Zoe', addedAt: 't' });
354
374
  cadreNode.nodeAvailable = false;
355
375
 
@@ -361,7 +381,7 @@ describe('TrustCircleService.list', () => {
361
381
 
362
382
  it('issueInvite surfaces node_unavailable when the channel is down', async () => {
363
383
  const downNode = new MockCadreNode();
364
- downNode.createInvite = async () => { throw new AuthorityNodeUnavailableError('refused'); };
384
+ downNode.createInvite = async () => { throw new OwnerNodeUnavailableError('refused'); };
365
385
  const svc = new TrustCircleService({ cadreNode: downNode, store });
366
386
  await expect(svc.issueInvite({ label: 'Nina' }))
367
387
  .rejects.toMatchObject({ code: 'node_unavailable' });
@@ -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
+ }