@serfab/cadre-core 1.16.0 → 1.18.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 (268) hide show
  1. package/README.md +4 -3
  2. package/dist/bootstrap-peer-store-file.js +1 -1
  3. package/dist/bootstrap-peer-store.d.ts +1 -1
  4. package/dist/bootstrap-peer-store.js +4 -4
  5. package/dist/cadre-invite-protocol.d.ts +81 -142
  6. package/dist/cadre-invite-protocol.js +168 -458
  7. package/dist/cadre-invite-protocol.js.map +1 -1
  8. package/dist/cadre-join-request.d.ts +15 -2
  9. package/dist/cadre-join-request.js +22 -3
  10. package/dist/cadre-join-request.js.map +1 -1
  11. package/dist/cadre-node.d.ts +1089 -475
  12. package/dist/cadre-node.js +2170 -1037
  13. package/dist/cadre-node.js.map +1 -1
  14. package/dist/control-authorization.d.ts +36 -14
  15. package/dist/control-authorization.js +20 -3
  16. package/dist/control-authorization.js.map +1 -1
  17. package/dist/control-cohort.d.ts +88 -32
  18. package/dist/control-cohort.js +91 -33
  19. package/dist/control-cohort.js.map +1 -1
  20. package/dist/control-database.d.ts +856 -318
  21. package/dist/control-database.js +1422 -701
  22. package/dist/control-database.js.map +1 -1
  23. package/dist/control-formation-recorder.d.ts +46 -43
  24. package/dist/control-formation-recorder.js +77 -78
  25. package/dist/control-formation-recorder.js.map +1 -1
  26. package/dist/control-protocol-guard.d.ts +11 -4
  27. package/dist/control-protocol-guard.js +20 -5
  28. package/dist/control-protocol-guard.js.map +1 -1
  29. package/dist/control-schema-version.d.ts +97 -0
  30. package/dist/control-schema-version.js +225 -0
  31. package/dist/control-schema-version.js.map +1 -0
  32. package/dist/control-schema.d.ts +45 -5
  33. package/dist/control-schema.js +262 -78
  34. package/dist/control-schema.js.map +1 -1
  35. package/dist/control-write-retry.d.ts +45 -10
  36. package/dist/control-write-retry.js +92 -22
  37. package/dist/control-write-retry.js.map +1 -1
  38. package/dist/delegate-admission.d.ts +13 -0
  39. package/dist/delegate-admission.js +27 -0
  40. package/dist/delegate-admission.js.map +1 -1
  41. package/dist/ed25519-key.d.ts +10 -7
  42. package/dist/ed25519-key.js +23 -20
  43. package/dist/ed25519-key.js.map +1 -1
  44. package/dist/edge-node/config.d.ts +122 -0
  45. package/dist/edge-node/config.js +199 -0
  46. package/dist/edge-node/config.js.map +1 -0
  47. package/dist/edge-node/dissolve.d.ts +95 -0
  48. package/dist/edge-node/dissolve.js +184 -0
  49. package/dist/edge-node/dissolve.js.map +1 -0
  50. package/dist/edge-node/edge-join.d.ts +44 -0
  51. package/dist/edge-node/edge-join.js +227 -0
  52. package/dist/edge-node/edge-join.js.map +1 -0
  53. package/dist/edge-node/edge-node.d.ts +295 -0
  54. package/dist/edge-node/edge-node.js +704 -0
  55. package/dist/edge-node/edge-node.js.map +1 -0
  56. package/dist/edge-node/index.d.ts +18 -0
  57. package/dist/edge-node/index.js +18 -0
  58. package/dist/edge-node/index.js.map +1 -0
  59. package/dist/edge-node/join-credential.d.ts +11 -0
  60. package/dist/edge-node/join-credential.js +11 -0
  61. package/dist/edge-node/join-credential.js.map +1 -0
  62. package/dist/edge-node/lazy-raw-storage.d.ts +40 -0
  63. package/dist/edge-node/lazy-raw-storage.js +134 -0
  64. package/dist/edge-node/lazy-raw-storage.js.map +1 -0
  65. package/dist/edge-node/node-local.d.ts +60 -0
  66. package/dist/edge-node/node-local.js +58 -0
  67. package/dist/edge-node/node-local.js.map +1 -0
  68. package/dist/edge-node/options.d.ts +83 -0
  69. package/dist/edge-node/options.js +110 -0
  70. package/dist/edge-node/options.js.map +1 -0
  71. package/dist/edge-node/owner-grant.d.ts +41 -0
  72. package/dist/edge-node/owner-grant.js +38 -0
  73. package/dist/edge-node/owner-grant.js.map +1 -0
  74. package/dist/edge-node/platform.d.ts +140 -0
  75. package/dist/edge-node/platform.js +12 -0
  76. package/dist/edge-node/platform.js.map +1 -0
  77. package/dist/edge-node/strand-move.d.ts +115 -0
  78. package/dist/edge-node/strand-move.js +223 -0
  79. package/dist/edge-node/strand-move.js.map +1 -0
  80. package/dist/edge-node/strands.d.ts +28 -0
  81. package/dist/edge-node/strands.js +60 -0
  82. package/dist/edge-node/strands.js.map +1 -0
  83. package/dist/enrolled-machine-store.d.ts +3 -3
  84. package/dist/enrolled-machine-store.js +3 -3
  85. package/dist/formation-token.d.ts +48 -0
  86. package/dist/formation-token.js +26 -0
  87. package/dist/formation-token.js.map +1 -0
  88. package/dist/index.d.ts +27 -17
  89. package/dist/index.js +39 -17
  90. package/dist/index.js.map +1 -1
  91. package/dist/joined-strand-store.d.ts +29 -12
  92. package/dist/joined-strand-store.js +66 -27
  93. package/dist/joined-strand-store.js.map +1 -1
  94. package/dist/key-store.d.ts +9 -4
  95. package/dist/key-store.js +9 -4
  96. package/dist/key-store.js.map +1 -1
  97. package/dist/link-budget.d.ts +9 -7
  98. package/dist/link-budget.js +9 -7
  99. package/dist/link-budget.js.map +1 -1
  100. package/dist/machine-removal.d.ts +19 -39
  101. package/dist/machine-removal.js +24 -98
  102. package/dist/machine-removal.js.map +1 -1
  103. package/dist/member-redemption.d.ts +256 -0
  104. package/dist/member-redemption.js +490 -0
  105. package/dist/member-redemption.js.map +1 -0
  106. package/dist/membership-view.d.ts +91 -0
  107. package/dist/membership-view.js +110 -0
  108. package/dist/membership-view.js.map +1 -0
  109. package/dist/owner-anchor-sync.d.ts +4 -4
  110. package/dist/owner-anchor-sync.js +16 -15
  111. package/dist/owner-anchor-sync.js.map +1 -1
  112. package/dist/owner-grant-protocol.d.ts +251 -0
  113. package/dist/owner-grant-protocol.js +344 -0
  114. package/dist/owner-grant-protocol.js.map +1 -0
  115. package/dist/owner-key-export.d.ts +70 -0
  116. package/dist/owner-key-export.js +279 -0
  117. package/dist/owner-key-export.js.map +1 -0
  118. package/dist/owner-key-flows.d.ts +55 -0
  119. package/dist/owner-key-flows.js +60 -0
  120. package/dist/owner-key-flows.js.map +1 -0
  121. package/dist/owner-key-store.d.ts +31 -0
  122. package/dist/owner-key-store.js +81 -0
  123. package/dist/owner-key-store.js.map +1 -0
  124. package/dist/owner-keys.d.ts +78 -0
  125. package/dist/owner-keys.js +133 -0
  126. package/dist/owner-keys.js.map +1 -0
  127. package/dist/owner-signer.d.ts +54 -0
  128. package/dist/owner-signer.js +51 -0
  129. package/dist/owner-signer.js.map +1 -0
  130. package/dist/peer-addr-book.d.ts +10 -1
  131. package/dist/peer-addr-book.js +4 -3
  132. package/dist/peer-addr-book.js.map +1 -1
  133. package/dist/peer-authorization.d.ts +83 -18
  134. package/dist/peer-authorization.js +123 -53
  135. package/dist/peer-authorization.js.map +1 -1
  136. package/dist/peer-join-backfill.d.ts +89 -20
  137. package/dist/peer-join-backfill.js +265 -108
  138. package/dist/peer-join-backfill.js.map +1 -1
  139. package/dist/pending-join-runner.d.ts +48 -18
  140. package/dist/pending-join-runner.js +74 -56
  141. package/dist/pending-join-runner.js.map +1 -1
  142. package/dist/provider/rent-node.js +1 -1
  143. package/dist/provider/rent-node.js.map +1 -1
  144. package/dist/seed-bootstrap.d.ts +162 -133
  145. package/dist/seed-bootstrap.js +300 -283
  146. package/dist/seed-bootstrap.js.map +1 -1
  147. package/dist/seed-trust-policy.js +4 -4
  148. package/dist/strand-addr-protocol.d.ts +4 -1
  149. package/dist/strand-addr-protocol.js +8 -2
  150. package/dist/strand-addr-protocol.js.map +1 -1
  151. package/dist/strand-cohort.d.ts +3 -0
  152. package/dist/strand-cohort.js.map +1 -1
  153. package/dist/strand-database.d.ts +24 -2
  154. package/dist/strand-database.js +29 -6
  155. package/dist/strand-database.js.map +1 -1
  156. package/dist/strand-first-sync-gate.d.ts +176 -88
  157. package/dist/strand-first-sync-gate.js +288 -121
  158. package/dist/strand-first-sync-gate.js.map +1 -1
  159. package/dist/strand-formation-deadlines.d.ts +9 -9
  160. package/dist/strand-formation-deadlines.js +9 -9
  161. package/dist/strand-formation-manager.d.ts +23 -17
  162. package/dist/strand-formation-manager.js +46 -44
  163. package/dist/strand-formation-manager.js.map +1 -1
  164. package/dist/strand-formation-protocol.d.ts +5 -8
  165. package/dist/strand-formation-protocol.js +101 -16
  166. package/dist/strand-formation-protocol.js.map +1 -1
  167. package/dist/strand-instance-manager.d.ts +27 -18
  168. package/dist/strand-instance-manager.js +119 -50
  169. package/dist/strand-instance-manager.js.map +1 -1
  170. package/dist/strand-membership-reconciler.d.ts +16 -13
  171. package/dist/strand-membership-reconciler.js +30 -22
  172. package/dist/strand-membership-reconciler.js.map +1 -1
  173. package/dist/strand-membership-writer.d.ts +45 -22
  174. package/dist/strand-membership-writer.js +226 -131
  175. package/dist/strand-membership-writer.js.map +1 -1
  176. package/dist/strand-move.d.ts +6 -5
  177. package/dist/strand-move.js +36 -32
  178. package/dist/strand-move.js.map +1 -1
  179. package/dist/strand-peer-redial.js +2 -3
  180. package/dist/strand-peer-redial.js.map +1 -1
  181. package/dist/strand-revocation-enforcer.d.ts +134 -60
  182. package/dist/strand-revocation-enforcer.js +144 -71
  183. package/dist/strand-revocation-enforcer.js.map +1 -1
  184. package/dist/strand-solicitation.d.ts +50 -39
  185. package/dist/strand-solicitation.js +10 -3
  186. package/dist/strand-solicitation.js.map +1 -1
  187. package/dist/strand-watcher.d.ts +80 -13
  188. package/dist/strand-watcher.js +152 -48
  189. package/dist/strand-watcher.js.map +1 -1
  190. package/dist/table-change-watch.d.ts +63 -0
  191. package/dist/table-change-watch.js +209 -0
  192. package/dist/table-change-watch.js.map +1 -0
  193. package/dist/timeout-scheduler.d.ts +4 -3
  194. package/dist/timeout-scheduler.js +4 -3
  195. package/dist/timeout-scheduler.js.map +1 -1
  196. package/dist/types.d.ts +374 -150
  197. package/dist/types.js +4 -4
  198. package/dist/types.js.map +1 -1
  199. package/package.json +17 -8
  200. package/src/bootstrap-peer-store-file.ts +44 -44
  201. package/src/bootstrap-peer-store.ts +214 -214
  202. package/src/cadre-invite-protocol.ts +740 -1052
  203. package/src/cadre-join-request.ts +24 -3
  204. package/src/cadre-node.ts +2402 -1079
  205. package/src/control-authorization.ts +47 -14
  206. package/src/control-cohort.ts +204 -128
  207. package/src/control-database.ts +1749 -820
  208. package/src/control-formation-recorder.ts +359 -353
  209. package/src/control-protocol-guard.ts +23 -6
  210. package/src/control-schema-version.ts +301 -0
  211. package/src/control-schema.ts +273 -78
  212. package/src/control-write-retry.ts +641 -567
  213. package/src/delegate-admission.ts +351 -314
  214. package/src/ed25519-key.ts +174 -171
  215. package/src/edge-node/config.ts +277 -0
  216. package/src/edge-node/dissolve.ts +251 -0
  217. package/src/edge-node/edge-join.ts +319 -0
  218. package/src/edge-node/edge-node.ts +1045 -0
  219. package/src/edge-node/index.ts +59 -0
  220. package/src/edge-node/join-credential.ts +16 -0
  221. package/src/edge-node/lazy-raw-storage.ts +178 -0
  222. package/src/edge-node/node-local.ts +84 -0
  223. package/src/edge-node/options.ts +179 -0
  224. package/src/edge-node/owner-grant.ts +63 -0
  225. package/src/edge-node/platform.ts +151 -0
  226. package/src/edge-node/strand-move.ts +270 -0
  227. package/src/edge-node/strands.ts +72 -0
  228. package/src/enrolled-machine-store.ts +3 -3
  229. package/src/formation-token.ts +63 -0
  230. package/src/index.ts +899 -789
  231. package/src/joined-strand-store.ts +73 -28
  232. package/src/key-store.ts +10 -4
  233. package/src/link-budget.ts +9 -7
  234. package/src/machine-removal.ts +26 -111
  235. package/src/member-redemption.ts +649 -0
  236. package/src/membership-view.ts +144 -0
  237. package/src/owner-anchor-sync.ts +19 -18
  238. package/src/owner-grant-protocol.ts +547 -0
  239. package/src/owner-key-export.ts +348 -0
  240. package/src/owner-key-flows.ts +109 -0
  241. package/src/owner-key-store.ts +85 -0
  242. package/src/owner-keys.ts +178 -0
  243. package/src/owner-signer.ts +94 -0
  244. package/src/peer-addr-book.ts +4 -3
  245. package/src/peer-authorization.ts +558 -488
  246. package/src/peer-join-backfill.ts +971 -761
  247. package/src/pending-join-runner.ts +76 -53
  248. package/src/provider/rent-node.ts +1 -1
  249. package/src/seed-bootstrap.ts +343 -349
  250. package/src/seed-trust-policy.ts +4 -4
  251. package/src/strand-addr-protocol.ts +547 -538
  252. package/src/strand-cohort.ts +4 -0
  253. package/src/strand-database.ts +352 -319
  254. package/src/strand-first-sync-gate.ts +334 -133
  255. package/src/strand-formation-deadlines.ts +227 -227
  256. package/src/strand-formation-manager.ts +731 -725
  257. package/src/strand-formation-protocol.ts +122 -24
  258. package/src/strand-instance-manager.ts +2092 -2026
  259. package/src/strand-membership-reconciler.ts +33 -25
  260. package/src/strand-membership-writer.ts +2093 -1974
  261. package/src/strand-move.ts +50 -38
  262. package/src/strand-peer-redial.ts +2 -3
  263. package/src/strand-revocation-enforcer.ts +740 -654
  264. package/src/strand-solicitation.ts +61 -34
  265. package/src/strand-watcher.ts +175 -56
  266. package/src/table-change-watch.ts +285 -0
  267. package/src/timeout-scheduler.ts +29 -28
  268. package/src/types.ts +391 -152
@@ -1,1052 +1,740 @@
1
- /**
2
- * Cadre invitation redemption transport: `/sereus/cadre-invite/1.0.0`.
3
- *
4
- * A device holding a cadre invitation (an owner-signed `CadreInvite` row plus the private
5
- * half of the invitation keypair, see {@link CadreInvitation}) dials ANY member machine the
6
- * invitation names and proves possession; the member seats the row if it does not hold it
7
- * yet, writes the device's `CadrePeer` row (and `OwnerKey` row, when the invitation grants
8
- * ownership) through `ControlDatabase.redeemCadreInvite`, and answers with the party id,
9
- * the row and dial hints. The owner that minted the invitation need not be online.
10
- *
11
- * Same shape as the seed and wake protocols: one length-prefixed JSON frame each way over
12
- * the shared primitives in `control-stream.ts`. Stranger-facing by design — the device is
13
- * not a member when it dials — so the connection gate admits a stranger while a live
14
- * invitation exists (`membership-connection-gater.ts`), and the handler's own trust
15
- * decision is the proof of possession: nothing touches the database for a caller that
16
- * cannot prove it holds the invitation and is the identity it names.
17
- *
18
- * The seed protocol is left untouched so `SeedMessage` can grow independently.
19
- */
20
-
21
- import debug from 'debug';
22
- import { multiaddr, type Multiaddr } from '@multiformats/multiaddr';
23
- import type { Connection, Libp2p } from '@libp2p/interface';
24
- import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
25
- import { sign } from '@optimystic/quereus-plugin-crypto';
26
- import { type ControlStream, withDeadline, exchangeFrame, readStreamToEnd, replyAndClose } from './control-stream.js';
27
- import { decodeLengthPrefixedFrame } from './seed-bootstrap.js';
28
- import { relayedRequestBudgetMs } from './link-budget.js';
29
- import {
30
- cadreInviteRedeemMessage,
31
- cadreInviteConsentMessage,
32
- CadreInviteIssuerUnknownError,
33
- InvitationExhaustedError,
34
- parseNullableStoredDatetimeMs,
35
- type ControlDatabase
36
- } from './control-database.js';
37
- import { verifyCadreInviteRow, verifyCadreInviteRedemption } from './peer-authorization.js';
38
- import { ed25519PublicKeyB64FromPeerId, requireEd25519PublicKeyB64 } from './ed25519-key.js';
39
- import { dialAddr, nextMacrotask, trailingPeerId } from './peer-dial.js';
40
- import { chainMessages } from './control-retry.js';
41
- import { isRetriableControlWriteFailure } from './control-write-retry.js';
42
- import { CADRE_INVITATION_PREFIX, CADRE_JOIN_REQUEST_PREFIX } from './cadre-join-request.js';
43
- import type { CadreInviteRow, CadreJoinProgress, SeedPeer } from './types.js';
44
-
45
- const log = debug('sereus:cadre:invite-proto');
46
-
47
- /** Protocol id for cadre invitation redemption (parallel to `/sereus/seed/1.0.0`). */
48
- export const CADRE_INVITE_PROTOCOL = '/sereus/cadre-invite/1.0.0';
49
-
50
- /**
51
- * Cap on either frame. A request is one signed row, two signatures and a few addresses;
52
- * a reply adds a peer list of a cadre's size. Both fit in a few KB; 64 KiB is the
53
- * defensive bound a stranger can make the handler buffer.
54
- */
55
- const MAX_CADRE_INVITE_MSG_SIZE = 64 * 1024;
56
-
57
- /**
58
- * Default time the member waits for the request frame before aborting the read (ms). The
59
- * read only: a stranger opens the stream, so the bound is on a caller that opens one and
60
- * never sends. Same figure as the seed handler's.
61
- */
62
- // eslint-disable-next-line no-restricted-syntax -- link-independent: a receiver cap on one frame on a stream the peer already opened; it bounds a peer that opens a stream and never sends, not the dial
63
- const DEFAULT_REDEEM_READ_TIMEOUT_MS = 10_000;
64
-
65
- /** Default cap on concurrent inbound redemptions; over it the handler answers `busy`. */
66
- const DEFAULT_MAX_CONCURRENT_REDEMPTIONS = 100;
67
-
68
- /** Longest string field accepted off the wire; a stamp is 342 base64url chars, a signature 86. */
69
- const MAX_WIRE_FIELD_LENGTH = 1024;
70
-
71
- /** Most addresses a request may carry for the device, and a reply per peer (`sanitizeAddrs`). */
72
- const MAX_ADDRS = 16;
73
-
74
- /** Most machines an invitation bundle may name; the issuer caps its own list far below this. */
75
- const MAX_BUNDLE_MEMBERS = 64;
76
-
77
- /** Most peers a reply may carry as dial hints. */
78
- const MAX_REPLY_PEERS = 256;
79
-
80
- const BASE64URL = /^[A-Za-z0-9_-]+$/;
81
-
82
- // ── The invitation bundle ────────────────────────────────────────────────────
83
-
84
- /**
85
- * What an owner hands out (QR, paste, link) and a device redeems. An invitation is an
86
- * ed25519 keypair: the row holds the public half, `invitePrivateKey` is the proof of
87
- * possession, and whoever holds this bundle can redeem it — treat it as a bearer
88
- * credential for the row's lifetime (an untargeted owner-granting one defaults to a
89
- * 15-minute expiry for that reason).
90
- *
91
- * Carrying the signed row is what makes redemption work at a member that has not received
92
- * the row by replication: the member seats it from the bundle under its stored issuer
93
- * signature. The row's own signature covers every column, so a bundle whose row was
94
- * altered cannot be seated anywhere.
95
- */
96
- export interface CadreInvitation {
97
- v: 1;
98
- /** The cadre the invitation joins; a device refuses one for another party. */
99
- partyId: string;
100
- /** The invitation's ed25519 seed, base64url: the proof of possession. */
101
- invitePrivateKey: string;
102
- /** The owner-signed row exactly as issued (`ControlDatabase.insertCadreInvite`). */
103
- invite: CadreInviteRow;
104
- /**
105
- * The issuer's anchored owner keys. The redeeming device pins them before it dials
106
- * (`CadreNode.trustOwnerKeys`) and checks the member's reply against them. Sourced from
107
- * the issuer's node-local anchor only, never the replicated `OwnerKey` table, for the
108
- * reason the seed invite gives: the device anchors whatever arrives here.
109
- */
110
- ownerKeys: string[];
111
- /**
112
- * Multiaddrs ending in `/p2p/<peerId>`: the issuer's own first, then other members', at
113
- * most `CADRE_INVITATION_MAX_ADDRS` in all (`selectCadreInvitationMembers` in
114
- * `invitation-bootstrap.ts`). The device tries them in order.
115
- */
116
- members: string[];
117
- }
118
-
119
- /**
120
- * Encode a bundle for out-of-band delivery: {@link CADRE_INVITATION_PREFIX}, then base64url
121
- * over JSON. The prefix lets an app tell an invitation from a join request
122
- * (`cadre-join-request.ts`) and name the mistake when one is pasted for the other.
123
- */
124
- export function encodeCadreInvitation(invitation: CadreInvitation): string {
125
- return `${CADRE_INVITATION_PREFIX}${uint8ArrayToString(new TextEncoder().encode(JSON.stringify(invitation)), 'base64url')}`;
126
- }
127
-
128
- /**
129
- * Decode a bundle, refusing anything that is not a well-formed version-1
130
- * {@link CadreInvitation} behind {@link CADRE_INVITATION_PREFIX}. Standalone so a client
131
- * without a node (a settings screen that wants to show which cadre an invitation joins) can
132
- * decode one.
133
- *
134
- * @throws on a join request, a string without the prefix or that is not base64url JSON after
135
- * it, or one that decodes to another shape or version.
136
- */
137
- export function decodeCadreInvitation(encoded: string): CadreInvitation {
138
- const trimmed = encoded.trim();
139
- if (trimmed.startsWith(CADRE_JOIN_REQUEST_PREFIX)) {
140
- throw new Error('This is a join request, not a cadre invitation; paste it into the app that manages your cadre');
141
- }
142
- if (!trimmed.startsWith(CADRE_INVITATION_PREFIX)) {
143
- throw new Error(`Cadre invitation must start with "${CADRE_INVITATION_PREFIX}" (this one starts with "${trimmed.slice(0, CADRE_INVITATION_PREFIX.length)}")`);
144
- }
145
- let parsed: unknown;
146
- try {
147
- parsed = JSON.parse(new TextDecoder().decode(uint8ArrayFromString(trimmed.slice(CADRE_INVITATION_PREFIX.length), 'base64url')));
148
- } catch (error) {
149
- throw new Error('Cadre invitation does not decode as base64url JSON', { cause: error });
150
- }
151
- if (!isWellFormedCadreInvitation(parsed)) {
152
- throw new Error('Cadre invitation is malformed or of an unsupported version');
153
- }
154
- return parsed;
155
- }
156
-
157
- function isWellFormedCadreInvitation(value: unknown): value is CadreInvitation {
158
- if (typeof value !== 'object' || value === null) return false;
159
- const { v, partyId, invitePrivateKey, invite, ownerKeys, members } = value as Record<string, unknown>;
160
- return v === 1
161
- && isBoundedString(partyId)
162
- && isEd25519KeyB64(invitePrivateKey)
163
- && isWellFormedCadreInviteRow(invite)
164
- && Array.isArray(ownerKeys) && ownerKeys.length > 0 && ownerKeys.every((key) => isEd25519KeyB64(key))
165
- && Array.isArray(members) && members.length <= MAX_BUNDLE_MEMBERS && members.every((addr) => isBoundedString(addr));
166
- }
167
-
168
- // ── Wire messages ────────────────────────────────────────────────────────────
169
-
170
- /** Device → member: one frame, the whole redemption. */
171
- export interface CadreInviteRedeemRequest {
172
- v: 1;
173
- /** The cadre the device believes it is joining; a machine serving another party refuses by name. */
174
- partyId: string;
175
- /** The owner-signed row from the bundle. */
176
- invite: CadreInviteRow;
177
- /** The device's ed25519 public key, base64url; must be the key behind the connecting peer id. */
178
- peerKey: string;
179
- /** The device's dialable addresses; empty for a phone. Stored on its `CadrePeer` row. */
180
- multiaddrs: string[];
181
- /** Single-use nonce the device minted (`generateStampId`); both signatures cover it. */
182
- usageStampId: string;
183
- /** By `invitePrivateKey` over the `'redeem'` digest. */
184
- inviteSig: string;
185
- /** By the device's identity key over the `'consent'` digest. */
186
- peerSig: string;
187
- }
188
-
189
- /**
190
- * Why a member refused a redemption. Retryability is {@link CADRE_INVITE_REJECTION_RETRYABLE}'s;
191
- * a retryable code moves the device to the next address the bundle names.
192
- */
193
- export type CadreInviteRejectionCode =
194
- /**
195
- * The issuer's `OwnerKey` row is not on this member (not replicated yet), or the issuer was
196
- * removed; the member cannot tell which, so the device tries the next address.
197
- */
198
- | 'issuer-unknown'
199
- /**
200
- * A signature does not verify, the peer key is not the connecting identity, the request or
201
- * the row is malformed, or the invitation names another device.
202
- */
203
- | 'invite-invalid'
204
- /** Expired, withdrawn, exhausted, or issued by a key that is no longer an owner here. */
205
- | 'invite-spent'
206
- /** This machine serves another cadre than the one the invitation names. */
207
- | 'party-mismatch'
208
- /** The member is at its cap on concurrent redemptions. */
209
- | 'busy'
210
- /** The write failed transiently (the write retry gave up); nothing was recorded. */
211
- | 'conflict'
212
- /**
213
- * An unexpected member-side failure, or a request frame the member could not read (timed
214
- * out, oversized, not JSON): nothing was proved either way, so the device tries the next
215
- * address, as the formation responder's `internal` is read.
216
- */
217
- | 'internal';
218
-
219
- /** Which refusals are worth repeating at the next address — the one place that says so. */
220
- export const CADRE_INVITE_REJECTION_RETRYABLE: Readonly<Record<CadreInviteRejectionCode, boolean>> = {
221
- 'issuer-unknown': true,
222
- 'invite-invalid': false,
223
- 'invite-spent': false,
224
- 'party-mismatch': false,
225
- 'busy': true,
226
- 'conflict': true,
227
- 'internal': true
228
- };
229
-
230
- /** Is `value` a code this build knows? Own keys only, so a peer's `'constructor'` does not pass. */
231
- export function isCadreInviteRejectionCode(value: unknown): value is CadreInviteRejectionCode {
232
- return typeof value === 'string' && Object.prototype.hasOwnProperty.call(CADRE_INVITE_REJECTION_RETRYABLE, value);
233
- }
234
-
235
- /** Member → device. */
236
- export interface CadreInviteRedeemReply {
237
- accepted: boolean;
238
- /** Present iff `!accepted`. */
239
- code?: CadreInviteRejectionCode;
240
- /** Human-facing text; present iff `!accepted`. */
241
- reason?: string;
242
- /** The member's party id; present iff `accepted`. */
243
- partyId?: string;
244
- /** The invitation row as this member holds it; present iff `accepted`. */
245
- invite?: CadreInviteRow;
246
- /**
247
- * Dial hints for the rest of the cadre, unsigned: the member's own projection of its
248
- * `CadrePeer` rows (`ControlDatabase.querySeedPeers`). `isOwner` is a dial hint only, as
249
- * the seed's NOTE says; the device derives trust from its anchor, never from this.
250
- */
251
- peers?: SeedPeer[];
252
- }
253
-
254
- /** An accepting reply, every optional field present. */
255
- export interface CadreInviteAccepted extends CadreInviteRedeemReply {
256
- accepted: true;
257
- partyId: string;
258
- invite: CadreInviteRow;
259
- peers: SeedPeer[];
260
- }
261
-
262
- /** A refusal as the member sends it: a code, a reason, nothing else. */
263
- interface CadreInviteRejection extends CadreInviteRedeemReply {
264
- accepted: false;
265
- code: CadreInviteRejectionCode;
266
- reason: string;
267
- }
268
-
269
- /**
270
- * A refusal as the device reads it: the code and reason verbatim off the wire, untyped, so
271
- * {@link CadreInviteRejectedError} is the one place that classifies a code this build does
272
- * not know (as `'unrecognized'`, retryable) rather than the decoder folding it into a code
273
- * it does.
274
- */
275
- interface CadreInviteRefusalFrame {
276
- accepted: false;
277
- code: unknown;
278
- reason: unknown;
279
- }
280
-
281
- function rejection(code: CadreInviteRejectionCode, reason: string): CadreInviteRejection {
282
- return { accepted: false, code, reason };
283
- }
284
-
285
- // ── Errors the device sees ───────────────────────────────────────────────────
286
-
287
- /**
288
- * A member answered and refused. `code` is `'unrecognized'` when the frame carried none this
289
- * build knows (the two machines may run different versions), and such a refusal counts as
290
- * retryable: trying the next address on an unknown answer is safe, giving up is not.
291
- */
292
- export class CadreInviteRejectedError extends Error {
293
- readonly code: CadreInviteRejectionCode | 'unrecognized';
294
- readonly reason: string;
295
- readonly retryable: boolean;
296
-
297
- /** Both arguments come straight off the wire, so neither is trusted to have its declared type. */
298
- constructor(code: unknown, reason: unknown) {
299
- const text = typeof reason === 'string' ? reason : 'no reason provided';
300
- super(`Cadre invitation refused: ${text}`);
301
- this.name = 'CadreInviteRejectedError';
302
- this.code = isCadreInviteRejectionCode(code) ? code : 'unrecognized';
303
- this.reason = text;
304
- this.retryable = this.code === 'unrecognized' || CADRE_INVITE_REJECTION_RETRYABLE[this.code];
305
- }
306
- }
307
-
308
- /** What one address the bundle named came to. */
309
- export interface CadreInviteAddressOutcome {
310
- addr: string;
311
- /** A dial or exchange failure, or a retryable {@link CadreInviteRejectedError}. */
312
- error: Error;
313
- }
314
-
315
- /**
316
- * Every address the bundle names was tried and none accepted: each failed to dial, answered
317
- * malformed, or refused retryably. Always retryable; `outcomes` says what each address did.
318
- */
319
- export class CadreInviteUnreachableError extends Error {
320
- readonly retryable = true;
321
-
322
- constructor(readonly outcomes: readonly CadreInviteAddressOutcome[]) {
323
- super(outcomes.length === 0
324
- ? 'Cadre invitation names no dialable member'
325
- : `Cadre invitation could not be redeemed at any of its ${outcomes.length} address(es): `
326
- + outcomes.map((outcome) => `${outcome.addr}: ${outcome.error.message}`).join('; '));
327
- this.name = 'CadreInviteUnreachableError';
328
- }
329
- }
330
-
331
- /**
332
- * A member accepted but its reply does not check out: another party id, another invitation
333
- * key, or a row not signed by one of the pinned owner keys. Final: a machine that answers
334
- * like this is not a member of the cadre the invitation names, and the row it wrote (if any)
335
- * is in a database the device will never trust.
336
- */
337
- export class CadreInviteReplyInvalidError extends Error {
338
- readonly retryable = false;
339
-
340
- constructor(message: string) {
341
- super(`Cadre invitation reply refused: ${message}`);
342
- this.name = 'CadreInviteReplyInvalidError';
343
- }
344
- }
345
-
346
- // ── Shape checks (both directions) ───────────────────────────────────────────
347
-
348
- function isBoundedString(value: unknown, max = MAX_WIRE_FIELD_LENGTH): value is string {
349
- return typeof value === 'string' && value.length > 0 && value.length <= max;
350
- }
351
-
352
- function isEd25519KeyB64(value: unknown): value is string {
353
- if (!isBoundedString(value) || !BASE64URL.test(value)) return false;
354
- try {
355
- requireEd25519PublicKeyB64(value, 'key');
356
- return true;
357
- } catch {
358
- return false;
359
- }
360
- }
361
-
362
- /** Is `value` a {@link CadreInviteRow} with every column in its stored form and bounded? */
363
- export function isWellFormedCadreInviteRow(value: unknown): value is CadreInviteRow {
364
- if (typeof value !== 'object' || value === null) return false;
365
- const row = value as Record<string, unknown>;
366
- return isEd25519KeyB64(row.key)
367
- && (row.peerId === null || isBoundedString(row.peerId))
368
- && typeof row.grantsOwner === 'boolean'
369
- && (row.expiresAt === null || isBoundedString(row.expiresAt))
370
- && (row.totalUses === null || (Number.isInteger(row.totalUses) && (row.totalUses as number) >= 1))
371
- && isBoundedString(row.stampId)
372
- && isEd25519KeyB64(row.issuerKey)
373
- && isBoundedString(row.issuerSig);
374
- }
375
-
376
- function isWellFormedRedeemRequest(value: unknown): value is CadreInviteRedeemRequest {
377
- if (typeof value !== 'object' || value === null) return false;
378
- const { v, partyId, invite, peerKey, multiaddrs, usageStampId, inviteSig, peerSig } = value as Record<string, unknown>;
379
- return v === 1
380
- && isBoundedString(partyId)
381
- && isWellFormedCadreInviteRow(invite)
382
- && isEd25519KeyB64(peerKey)
383
- && Array.isArray(multiaddrs)
384
- && isBoundedString(usageStampId)
385
- && isBoundedString(inviteSig)
386
- && isBoundedString(peerSig);
387
- }
388
-
389
- /**
390
- * Keep only parsable, distinct multiaddr strings, capped at {@link MAX_ADDRS}, order kept.
391
- * Applied on both sides: the member bounds what it stores on the device's row, the device
392
- * bounds what it merges into its peer store. A malformed entry is skipped, not fatal.
393
- */
394
- export function sanitizeAddrs(addrs: unknown): string[] {
395
- if (!Array.isArray(addrs)) return [];
396
- const out: string[] = [];
397
- const seen = new Set<string>();
398
- for (const entry of addrs) {
399
- if (out.length >= MAX_ADDRS) break;
400
- if (!isBoundedString(entry) || seen.has(entry)) continue;
401
- try {
402
- multiaddr(entry);
403
- } catch (error) {
404
- log('addrs: skipping unparsable entry: %o', error);
405
- continue;
406
- }
407
- seen.add(entry);
408
- out.push(entry);
409
- }
410
- return out;
411
- }
412
-
413
- /** The reply's peer list, bounded and each entry's addresses sanitized; anything else dropped. */
414
- function sanitizePeers(peers: unknown): SeedPeer[] {
415
- if (!Array.isArray(peers)) return [];
416
- const out: SeedPeer[] = [];
417
- for (const entry of peers) {
418
- if (out.length >= MAX_REPLY_PEERS) break;
419
- if (typeof entry !== 'object' || entry === null) continue;
420
- const { peerId, multiaddrs, isOwner, publicKey } = entry as Record<string, unknown>;
421
- if (!isBoundedString(peerId)) continue;
422
- out.push({
423
- peerId,
424
- multiaddrs: sanitizeAddrs(multiaddrs),
425
- isOwner: isOwner === true,
426
- ...(isEd25519KeyB64(publicKey) ? { publicKey } : {})
427
- });
428
- }
429
- return out;
430
- }
431
-
432
- /**
433
- * Read a reply frame into a typed reply, sanitizing the attacker-influenced fields of an
434
- * acceptance. A refusal keeps only its code and reason, as sent. Throws on a frame that is
435
- * neither.
436
- */
437
- function decodeReply(value: unknown): CadreInviteAccepted | CadreInviteRefusalFrame {
438
- if (typeof value !== 'object' || value === null) {
439
- throw new Error('Cadre invitation reply is not an object');
440
- }
441
- const reply = value as Record<string, unknown>;
442
- if (reply.accepted !== true) {
443
- return { accepted: false, code: reply.code, reason: reply.reason };
444
- }
445
- if (!isBoundedString(reply.partyId) || !isWellFormedCadreInviteRow(reply.invite)) {
446
- throw new Error('Cadre invitation acceptance is missing its party id or a well-formed row');
447
- }
448
- return { accepted: true, partyId: reply.partyId, invite: reply.invite, peers: sanitizePeers(reply.peers) };
449
- }
450
-
451
- // ── Request construction (device side) ───────────────────────────────────────
452
-
453
- /** What the device signs with: its identity keypair in the crypto plugin's base64url form. */
454
- export interface RedeemSigner {
455
- /** The device's ed25519 public key, base64url — the key behind its peer id. */
456
- peerKey: string;
457
- /** The device's ed25519 seed, base64url. */
458
- peerPrivateKey: string;
459
- }
460
-
461
- /**
462
- * Build and sign the request for `invitation`: the holder's `'redeem'` signature with the
463
- * invitation's private key and the device's `'consent'` signature with its own, both over
464
- * (invitation key, `usageStampId`, device key) for the invitation's party. The same bytes
465
- * the schema verifies at the member (`cadreInviteRedeemMessage` / `cadreInviteConsentMessage`),
466
- * whose own `party_id()` must therefore be the party the bundle names.
467
- */
468
- export function signRedeemRequest(
469
- invitation: CadreInvitation,
470
- signer: RedeemSigner,
471
- usageStampId: string,
472
- multiaddrs: string[]
473
- ): CadreInviteRedeemRequest {
474
- const fields = { inviteKey: invitation.invite.key, usageStampId, peerKey: signer.peerKey };
475
- const signB64 = (message: Uint8Array, privateKey: string): string =>
476
- sign(message, privateKey, 'ed25519', 'bytes', 'base64url', 'base64url') as string;
477
- return {
478
- v: 1,
479
- partyId: invitation.partyId,
480
- invite: invitation.invite,
481
- peerKey: signer.peerKey,
482
- multiaddrs: sanitizeAddrs(multiaddrs),
483
- usageStampId,
484
- inviteSig: signB64(cadreInviteRedeemMessage(invitation.partyId, fields), invitation.invitePrivateKey),
485
- peerSig: signB64(cadreInviteConsentMessage(invitation.partyId, fields), signer.peerPrivateKey)
486
- };
487
- }
488
-
489
- /**
490
- * The device's check on an acceptance: the same party, the same invitation key, and a row
491
- * signed by an issuer the device pinned from the bundle. Throws
492
- * {@link CadreInviteReplyInvalidError} naming the first failure.
493
- *
494
- * What it proves is bounded: the request already carried the owner-signed row, so a machine
495
- * that is not a member can echo it. The check catches a reply for another party or
496
- * invitation and a row the pinned owners never signed; whether the answering machine is a
497
- * member is settled afterwards, when the device syncs the control database and judges every
498
- * row against its anchor (`CadreNode.listAuthorizedMembers`).
499
- */
500
- export function verifyRedeemReply(
501
- reply: CadreInviteAccepted,
502
- invitation: CadreInvitation,
503
- isTrustedIssuer: (ownerKey: string) => boolean
504
- ): void {
505
- if (reply.partyId !== invitation.partyId) {
506
- throw new CadreInviteReplyInvalidError(`the member serves party ${reply.partyId}, the invitation names ${invitation.partyId}`);
507
- }
508
- if (reply.invite.key !== invitation.invite.key) {
509
- throw new CadreInviteReplyInvalidError('the member answered for a different invitation');
510
- }
511
- if (!isTrustedIssuer(reply.invite.issuerKey)) {
512
- throw new CadreInviteReplyInvalidError('the row the member holds is not issued by one of the invitation\'s owner keys');
513
- }
514
- if (!verifyCadreInviteRow(invitation.partyId, reply.invite)) {
515
- throw new CadreInviteReplyInvalidError('the row the member holds is not signed by its issuer');
516
- }
517
- }
518
-
519
- // ── Member side (handler) ────────────────────────────────────────────────────
520
-
521
- /** The control-database surface a redemption needs; a `ControlDatabase` satisfies it. */
522
- export type CadreInviteStore = Pick<ControlDatabase, 'seatCadreInvite' | 'isCadreInviteLive' | 'redeemCadreInvite' | 'querySeedPeers'>;
523
-
524
- export interface CadreInviteHandlerOptions {
525
- /** This member's party id; a request for another party is refused by name. */
526
- partyId: string;
527
- store: CadreInviteStore;
528
- /**
529
- * Push this member's control store to the device, resolving once the push has finished, and
530
- * keep the device counted in this member's control write cohort until the returned release
531
- * is called. Called after the request verified and before the row is seated and the
532
- * admission is written; the handler releases once both have settled. Absent, the writes go
533
- * ahead with the device neither caught up nor in the cohort, so it does not hold them; see
534
- * `admitDeviceIfLive`.
535
- */
536
- admitDevice?: (peerId: string) => Promise<() => void>;
537
- /** Time to wait for the request frame; default {@link DEFAULT_REDEEM_READ_TIMEOUT_MS}. */
538
- redeemReadTimeoutMs?: number;
539
- /** Cap on concurrent inbound redemptions; default {@link DEFAULT_MAX_CONCURRENT_REDEMPTIONS}. */
540
- maxConcurrentRedemptions?: number;
541
- }
542
-
543
- /** Constraint names the schema refuses a redemption with, and the code each maps to. */
544
- const REDEEM_CONSTRAINT_CODES: ReadonlyArray<[RegExp, CadreInviteRejectionCode, string]> = [
545
- [/CHECK constraint failed: Authorized\b/, 'invite-spent', 'The invitation is expired, withdrawn, exhausted, or its issuer is no longer an owner'],
546
- [/CHECK constraint failed: (InvitePossessed|PeerConsented|PeerExists|OwnerExists)\b/, 'invite-invalid', 'The redemption does not satisfy the invitation'],
547
- [/CHECK constraint failed: NotRevoked\b/, 'invite-spent', 'The invitation was withdrawn'],
548
- ];
549
-
550
- /** Which named constraint, if any, refused the write — read off the error's cause chain. */
551
- function constraintRejection(error: unknown): CadreInviteRejection | null {
552
- if (!(error instanceof Error)) return null;
553
- const messages = chainMessages(error);
554
- for (const [pattern, code, reason] of REDEEM_CONSTRAINT_CODES) {
555
- if (messages.some((message) => pattern.test(message))) return rejection(code, reason);
556
- }
557
- return null;
558
- }
559
-
560
- /** A write failure that is not a refusal: the retry gave up (`conflict`), or something else (`internal`). */
561
- function writeFailureRejection(error: unknown): CadreInviteRejection {
562
- // NOTE: a seat torn on a member alone with an uncaught-up device was measured answering
563
- // `internal`, not `conflict` (cadre-invite-redeemed-at-a-member-without-the-row). Both are
564
- // retryable, so the device is unaffected; if the two codes ever diverge in retryability,
565
- // find which tear `isRetriableControlWriteFailure` does not recognise.
566
- return isRetriableControlWriteFailure(error)
567
- ? rejection('conflict', 'The redemption write failed transiently; nothing was recorded')
568
- : rejection('internal', 'Internal redemption error');
569
- }
570
-
571
- /**
572
- * Member side: registers the handler and drives each inbound stream through the checks in
573
- * cost order — shape, connecting identity, both signatures, target device — so no database
574
- * work happens for a stranger that cannot prove anything, then admits the device to this
575
- * member's control replication (`admitDevice`), seats the row and redeems.
576
- * Hardened as the seed handler is: a concurrency cap (over it, `busy`) and a read timeout.
577
- *
578
- * Registered only by a started `CadreNode`, after its control database is up, like the
579
- * formation responder; nothing here is reachable on a node without one.
580
- */
581
- export class CadreInviteHandler {
582
- private readonly options: CadreInviteHandlerOptions;
583
- private readonly readTimeoutMs: number;
584
- private readonly maxConcurrent: number;
585
- private readonly registered = new Set<Libp2p>();
586
- private active = 0;
587
-
588
- constructor(options: CadreInviteHandlerOptions) {
589
- this.options = options;
590
- this.readTimeoutMs = options.redeemReadTimeoutMs ?? DEFAULT_REDEEM_READ_TIMEOUT_MS;
591
- this.maxConcurrent = options.maxConcurrentRedemptions ?? DEFAULT_MAX_CONCURRENT_REDEMPTIONS;
592
- }
593
-
594
- /** In-flight inbound redemptions. */
595
- get activeCount(): number {
596
- return this.active;
597
- }
598
-
599
- /**
600
- * Register on `node`; rejects when libp2p refuses the registration. `runOnLimitedConnection`
601
- * because a phone redeems through a relay, whose connection libp2p marks limited; the
602
- * exchange is two small frames, well inside any relay's cap.
603
- */
604
- async register(node: Libp2p, protocolId: string = CADRE_INVITE_PROTOCOL): Promise<void> {
605
- if (this.registered.has(node)) return;
606
- await node.handle(protocolId, async (rawStream: unknown, rawConnection: unknown) => {
607
- await this.handleStream(rawStream as ControlStream, (rawConnection as Connection).remotePeer.toString());
608
- }, { runOnLimitedConnection: true });
609
- this.registered.add(node);
610
- log('cadre invitation handler registered (%s)', protocolId);
611
- }
612
-
613
- async unregister(node: Libp2p, protocolId: string = CADRE_INVITE_PROTOCOL): Promise<void> {
614
- if (!this.registered.has(node)) return;
615
- await node.unhandle(protocolId);
616
- this.registered.delete(node);
617
- log('cadre invitation handler unregistered (%s)', protocolId);
618
- }
619
-
620
- /**
621
- * One inbound redemption: read the frame, decide, reply, close. Public as the seam the
622
- * protocol spec drives directly; `remotePeerId` is the connection's authenticated peer.
623
- */
624
- async handleStream(stream: ControlStream, remotePeerId: string): Promise<void> {
625
- if (this.active >= this.maxConcurrent) {
626
- log('refusing redemption from %s: %d in flight at cap %d', remotePeerId, this.active, this.maxConcurrent);
627
- await replyAndClose(stream, rejection('busy', 'Too many concurrent redemptions'), 'Cadre invite');
628
- return;
629
- }
630
- this.active++;
631
- try {
632
- const reply = await this.decide(stream, remotePeerId);
633
- log('redemption from %s: %s', remotePeerId, reply.accepted ? 'accepted' : `refused (${reply.code})`);
634
- await replyAndClose(stream, reply, 'Cadre invite');
635
- } catch (error) {
636
- log('redemption from %s failed: %o', remotePeerId, error);
637
- await replyAndClose(stream, rejection('internal', 'Internal redemption error'), 'Cadre invite');
638
- } finally {
639
- this.active--;
640
- }
641
- }
642
-
643
- /**
644
- * Read the request frame to EOF, bounded and size-capped; JSON only, shape is `decide`'s.
645
- * Throws on a frame that cannot be read, which {@link handleStream} answers `internal`:
646
- * a read timeout or a cut frame proves nothing about the invitation, so the device must
647
- * stay free to try the next address.
648
- */
649
- private async readRequest(stream: ControlStream): Promise<unknown> {
650
- const data = await readStreamToEnd(stream, { maxBytes: MAX_CADRE_INVITE_MSG_SIZE, timeoutMs: this.readTimeoutMs, label: 'Cadre invite' });
651
- return JSON.parse(new TextDecoder().decode(decodeLengthPrefixedFrame(data, MAX_CADRE_INVITE_MSG_SIZE)));
652
- }
653
-
654
- private async decide(stream: ControlStream, remotePeerId: string): Promise<CadreInviteRedeemReply> {
655
- const request = await this.readRequest(stream);
656
- const refused = this.checkRequest(request, remotePeerId);
657
- if (refused) return refused;
658
- return await this.seatAndRedeem(request as CadreInviteRedeemRequest, remotePeerId);
659
- }
660
-
661
- /**
662
- * The checks that cost no database work, in order: shape, party, connecting identity (an
663
- * ed25519 peer id is an identity multihash of the very key, and this is the only layer that
664
- * can check the pair), both signatures, and a targeted invitation's device.
665
- */
666
- private checkRequest(request: unknown, remotePeerId: string): CadreInviteRejection | null {
667
- if (!isWellFormedRedeemRequest(request)) {
668
- return rejection('invite-invalid', 'Malformed redemption request');
669
- }
670
- if (request.partyId !== this.options.partyId) {
671
- return rejection('party-mismatch', 'This machine serves another cadre');
672
- }
673
- if (ed25519PublicKeyB64FromPeerId(remotePeerId) !== request.peerKey) {
674
- return rejection('invite-invalid', 'The peer key is not the connecting identity');
675
- }
676
- const fields = { inviteKey: request.invite.key, usageStampId: request.usageStampId, peerKey: request.peerKey };
677
- if (!verifyCadreInviteRedemption(this.options.partyId, fields, request.inviteSig, request.peerSig)) {
678
- return rejection('invite-invalid', 'The redemption signatures do not verify');
679
- }
680
- if (request.invite.peerId !== null && request.invite.peerId !== remotePeerId) {
681
- return rejection('invite-invalid', 'The invitation names another device');
682
- }
683
- return null;
684
- }
685
-
686
- /**
687
- * Admit the device to this member's control replication (see {@link admitDeviceIfLive}),
688
- * seat the row from the bundle (its own transaction; a no-op when held), then redeem. The
689
- * admission comes first because the seat is already a write the device takes part in. An
690
- * issuer this member does not know as an owner is the one retryable refusal left: the device
691
- * tries the next address, which may hold the issuer's row.
692
- */
693
- private async seatAndRedeem(request: CadreInviteRedeemRequest, remotePeerId: string): Promise<CadreInviteRedeemReply> {
694
- const release = await this.admitDeviceIfLive(request.invite, remotePeerId);
695
- try {
696
- return await this.seatAndRedeemAdmitted(request, remotePeerId);
697
- } finally {
698
- release();
699
- }
700
- }
701
-
702
- /** {@link seatAndRedeem}'s two writes, each answered by name when it fails. */
703
- private async seatAndRedeemAdmitted(request: CadreInviteRedeemRequest, remotePeerId: string): Promise<CadreInviteRedeemReply> {
704
- const { store } = this.options;
705
- try {
706
- await store.seatCadreInvite(request.invite);
707
- } catch (error) {
708
- if (error instanceof CadreInviteIssuerUnknownError) {
709
- return rejection('issuer-unknown', 'The invitation\'s issuer is not known as an owner here');
710
- }
711
- log('seating invitation %s from %s failed: %o', request.invite.key, remotePeerId, error);
712
- return constraintRejection(error) ?? writeFailureRejection(error);
713
- }
714
- try {
715
- const result = await store.redeemCadreInvite({
716
- inviteKey: request.invite.key,
717
- peerId: remotePeerId,
718
- peerKey: request.peerKey,
719
- multiaddrs: sanitizeAddrs(request.multiaddrs),
720
- usageStampId: request.usageStampId,
721
- inviteSig: request.inviteSig,
722
- peerSig: request.peerSig
723
- });
724
- log('invitation %s redeemed by %s (alreadyMember=%s)', request.invite.key, remotePeerId, result.alreadyMember);
725
- } catch (error) {
726
- if (error instanceof InvitationExhaustedError) {
727
- return rejection('invite-spent', 'The invitation has no uses left');
728
- }
729
- log('redeeming invitation %s for %s failed: %o', request.invite.key, remotePeerId, error);
730
- return constraintRejection(error) ?? writeFailureRejection(error);
731
- }
732
- // The row as held: the seat inserted this copy verbatim, or found the same row (its key
733
- // is the primary key and every column is under the issuer's signature).
734
- return { accepted: true, partyId: this.options.partyId, invite: request.invite, peers: await this.peersBestEffort() };
735
- }
736
-
737
- /**
738
- * Admit the device to this member's control replication before the first write of the
739
- * exchange (the seat, when this member does not hold the row; otherwise the admission), and
740
- * resolve to the release that ends the admission. The device is not a member until the
741
- * admission is written, so only this puts it in this member's control write cohort, and it
742
- * must be there to hold the rows that admit it. It is pushed the control store first,
743
- * because a cohort node with no base revision of a block refuses a commit to it; a member
744
- * alone with the device would then have half its cohort refusing, and the write would tear
745
- * instead of committing.
746
- *
747
- * Only for an invitation still live here, judged on the row this member holds or, before
748
- * the seat, on the bundle's copy once its issuer signature verifies
749
- * (`ControlDatabase.isCadreInviteLive`): the holder of a withdrawn, expired or spent one,
750
- * or of a forged copy naming a real owner, is refused without being sent anything. The
751
- * retry of an admission already written can read as spent too and is sent nothing; it
752
- * needs nothing, since the device is a member and the ordinary catch-up reaches it.
753
- * Best-effort: a failed check or push is logged and the writes go ahead without the device
754
- * admitted, then commit or are answered retryably.
755
- */
756
- private async admitDeviceIfLive(invite: CadreInviteRow, remotePeerId: string): Promise<() => void> {
757
- const { admitDevice, store } = this.options;
758
- const notAdmitted = (): void => undefined;
759
- if (!admitDevice) return notAdmitted;
760
- try {
761
- if (!await store.isCadreInviteLive(invite)) {
762
- log('invitation %s is not live here; %s is not admitted before the write', invite.key, remotePeerId);
763
- return notAdmitted;
764
- }
765
- // NOTE: accepted tradeoff — when a write then fails (the seat refused, the last use
766
- // taken by a race, the row expiring between this check and the write), the device keeps
767
- // a copy of a control store it was not admitted to. It holds a live owner-signed
768
- // invitation and proved possession and consent, and a seed hands an un-enrolled machine
769
- // the same information. Revisit if a control table ever carries data a non-member must not see.
770
- return await admitDevice(remotePeerId);
771
- } catch (error) {
772
- log('admitting %s before its admission write failed (writing anyway): %o', remotePeerId, error);
773
- return notAdmitted;
774
- }
775
- }
776
-
777
- /** Dial hints are an optimization; a failed read costs the device its hints, not its membership. */
778
- private async peersBestEffort(): Promise<SeedPeer[]> {
779
- try {
780
- return await this.options.store.querySeedPeers();
781
- } catch (error) {
782
- log('peer projection for the reply failed (continuing without hints): %o', error);
783
- return [];
784
- }
785
- }
786
- }
787
-
788
- // ── Device side (dialer) ─────────────────────────────────────────────────────
789
-
790
- export interface RedeemAtMembersOptions {
791
- /** The bundle: its `members` are dialed in order. */
792
- invitation: CadreInvitation;
793
- /** The signed request ({@link signRedeemRequest}); the same one goes to every address. */
794
- request: CadreInviteRedeemRequest;
795
- /** Is this owner key one the device pinned from the bundle? The reply check's input. */
796
- isTrustedIssuer: (ownerKey: string) => boolean;
797
- /**
798
- * This machine's declared link round trip (`NetworkConfig.linkRoundTripMs`), from which
799
- * the per-address deadline derives: a dial that may need a relay plus one request
800
- * (`relayedRequestBudgetMs`, 28.5 s at the default declaration), as seed delivery's is.
801
- */
802
- linkRoundTripMs?: number;
803
- /** Per-address deadline override; default derived from `linkRoundTripMs`. */
804
- addressBudgetMs?: number;
805
- protocolId?: string;
806
- /** Cancels the dial or stream in flight and ends the pass; the call throws the signal's reason. */
807
- signal?: AbortSignal;
808
- }
809
-
810
- /** What a successful redemption yields the device. */
811
- export interface CadreInviteRedemption {
812
- reply: CadreInviteAccepted;
813
- /** The answering member's peer id, from the address's trailing `/p2p/`; null when it named none. */
814
- memberPeerId: string | null;
815
- /** The address that answered. */
816
- memberAddr: string;
817
- }
818
-
819
- /**
820
- * Dial `addr`, open one stream to it, send the request, half-close, read the one reply frame.
821
- * `signal` is the per-address deadline: it cancels the dial and resets a live stream.
822
- *
823
- * The address is dialed outside libp2p's dial queue (`dialAddr`), so another dial of the
824
- * member in flight cannot capture this attempt; an open connection is still reused.
825
- */
826
- async function exchangeRedemption(
827
- node: Libp2p,
828
- addr: Multiaddr,
829
- request: CadreInviteRedeemRequest,
830
- signal: AbortSignal,
831
- budgetMs: number,
832
- protocolId: string
833
- ): Promise<CadreInviteAccepted | CadreInviteRefusalFrame> {
834
- const connection = await dialAddr(node, addr, signal, 'Cadre invitation redemption');
835
- // `runOnLimitedConnection`: a member may be reachable only through a relay (see `register`).
836
- const rawStream = await connection.newStream(protocolId, { runOnLimitedConnection: true, signal });
837
- return await exchangeFrame(
838
- rawStream as unknown as ControlStream,
839
- signal,
840
- request,
841
- async (stream) => {
842
- const data = await readStreamToEnd(stream, { maxBytes: MAX_CADRE_INVITE_MSG_SIZE, timeoutMs: budgetMs, label: 'Cadre invite reply' });
843
- return decodeReply(JSON.parse(new TextDecoder().decode(decodeLengthPrefixedFrame(data, MAX_CADRE_INVITE_MSG_SIZE))));
844
- },
845
- 'Cadre invitation redemption aborted by its deadline'
846
- );
847
- }
848
-
849
- function asError(error: unknown): Error {
850
- if (error instanceof Error) return error;
851
- // A WebSocket transport rejects a refused dial with an `ErrorEvent`, whose text is its `message`.
852
- const message = (error as { message?: unknown } | null)?.message;
853
- return new Error(typeof message === 'string' && message.length > 0 ? message : String(error), { cause: error });
854
- }
855
-
856
- /**
857
- * Run `op` with a signal that aborts when either `deadline` or the caller's `signal` does.
858
- * Relayed by hand rather than with `AbortSignal.any`, which the phone runtimes lack.
859
- */
860
- async function underDeadlineAndCaller<T>(
861
- deadline: AbortSignal,
862
- caller: AbortSignal | undefined,
863
- op: (signal: AbortSignal) => Promise<T>
864
- ): Promise<T> {
865
- if (!caller) return await op(deadline);
866
- const either = new AbortController();
867
- const abort = (): void => either.abort();
868
- deadline.addEventListener('abort', abort, { once: true });
869
- caller.addEventListener('abort', abort, { once: true });
870
- // An abort between the caller's last check and the listener above fired no event.
871
- if (caller.aborted) abort();
872
- try {
873
- return await op(either.signal);
874
- } finally {
875
- deadline.removeEventListener('abort', abort);
876
- caller.removeEventListener('abort', abort);
877
- }
878
- }
879
-
880
- /**
881
- * Device side: try the bundle's addresses one at a time, each under its own deadline, and
882
- * stop at the first acceptance or the first final refusal. A dial failure, a malformed
883
- * reply or a retryable refusal moves to the next address.
884
- *
885
- * Every address gets the SAME request, with one `usageStampId`: `CadreInviteUsage` is keyed
886
- * by it, so a member the device gave up on that was still committing cannot admit it twice,
887
- * and a retry at the next member after a dropped reply is answered as already a member.
888
- *
889
- * @throws {CadreInviteRejectedError} on a final refusal (its `retryable` is false).
890
- * @throws {CadreInviteReplyInvalidError} on an acceptance that fails {@link verifyRedeemReply}.
891
- * @throws {CadreInviteUnreachableError} when every address was tried and none accepted.
892
- * @throws the reason of `options.signal` once it aborts.
893
- */
894
- export async function redeemAtMembers(node: Libp2p, options: RedeemAtMembersOptions): Promise<CadreInviteRedemption> {
895
- const protocolId = options.protocolId ?? CADRE_INVITE_PROTOCOL;
896
- const budgetMs = options.addressBudgetMs ?? relayedRequestBudgetMs(options.linkRoundTripMs);
897
- const outcomes: CadreInviteAddressOutcome[] = [];
898
- for (const addr of options.invitation.members) {
899
- options.signal?.throwIfAborted();
900
- // Between attempts: a relayed address's relay leg goes through libp2p's dial queue, which
901
- // needs a macrotask to drop an aborted job (see `tryAddrsInTurn`).
902
- if (outcomes.length > 0) await nextMacrotask();
903
- let parsed: Multiaddr;
904
- try {
905
- parsed = multiaddr(addr);
906
- } catch (error) {
907
- outcomes.push({ addr, error: new Error('not a multiaddr', { cause: error }) });
908
- continue;
909
- }
910
- try {
911
- const reply = await withDeadline(budgetMs, `Cadre invitation redemption via ${addr}`,
912
- (deadline) => underDeadlineAndCaller(deadline, options.signal,
913
- (signal) => exchangeRedemption(node, parsed, options.request, signal, budgetMs, protocolId)));
914
- if (!reply.accepted) {
915
- const refusal = new CadreInviteRejectedError(reply.code, reply.reason);
916
- if (!refusal.retryable) throw refusal;
917
- log('redemption via %s refused retryably (%s); trying the next address', addr, refusal.code);
918
- outcomes.push({ addr, error: refusal });
919
- continue;
920
- }
921
- verifyRedeemReply(reply, options.invitation, options.isTrustedIssuer);
922
- return { reply, memberPeerId: trailingPeerId(parsed), memberAddr: addr };
923
- } catch (error) {
924
- if (error instanceof CadreInviteRejectedError || error instanceof CadreInviteReplyInvalidError) throw error;
925
- // The caller's abort cut this attempt short; its reason, not the cut, is the answer.
926
- options.signal?.throwIfAborted();
927
- log('redemption via %s failed before an answer (%s); trying the next address', addr, asError(error).message);
928
- outcomes.push({ addr, error: asError(error) });
929
- }
930
- }
931
- throw new CadreInviteUnreachableError(outcomes);
932
- }
933
-
934
- /** Pause after the first pass in which no member accepted; doubled after each further one. */
935
- const REDEEM_PASS_FIRST_PAUSE_MS = 5_000;
936
-
937
- /** Longest pause between passes. */
938
- const REDEEM_PASS_MAX_PAUSE_MS = 60_000;
939
-
940
- export interface RedeemInPassesOptions extends RedeemAtMembersOptions {
941
- /** Keep making passes while every answer is retryable; false makes exactly one. */
942
- waitForMember: boolean;
943
- /** Called at each pass and each pause; an exception it throws is logged and ignored. */
944
- onProgress?: (progress: CadreJoinProgress) => void;
945
- }
946
-
947
- /**
948
- * Device side, in passes: one {@link redeemAtMembers} pass over the bundle's addresses, or,
949
- * with `waitForMember`, as many as it takes, pausing between them (5 s after the first,
950
- * doubling to 60 s, never past the invitation's expiry). Every pass sends the same signed
951
- * request, so a member that admitted the device on a pass whose reply was lost answers a later
952
- * pass as already a member, with no second usage row.
953
- *
954
- * A wait ends at the first acceptance, at a final refusal or an invalid reply (thrown as
955
- * {@link redeemAtMembers} throws them), when the invitation expires by this device's clock
956
- * (thrown as the `invite-spent` refusal a member would give), when `signal` aborts (its reason
957
- * thrown), and when `node` stops. A bundle that names no address has nothing to wait for and
958
- * throws at once.
959
- */
960
- export async function redeemInPasses(node: Libp2p, options: RedeemInPassesOptions): Promise<CadreInviteRedemption> {
961
- for (let pass = 1; ; pass++) {
962
- if (options.waitForMember) {
963
- // One pass leaves expiry to the member, whose clock decides; a wait has nothing else to end it.
964
- refuseIfExpired(options.invitation.invite);
965
- refuseIfStopped(node);
966
- }
967
- reportProgress(options.onProgress, { state: 'redeeming', pass });
968
- try {
969
- return await redeemAtMembers(node, options);
970
- } catch (error) {
971
- if (!options.waitForMember || !(error instanceof CadreInviteUnreachableError) || error.outcomes.length === 0) throw error;
972
- const pauseMs = pauseAfterPass(pass, options.invitation.invite);
973
- reportProgress(options.onProgress, {
974
- state: 'waiting', pass, retryAtMs: Date.now() + pauseMs, reason: waitReason(error.outcomes), outcomes: error.outcomes
975
- });
976
- log('redemption pass %d found no member ready; the next pass in %d ms', pass, pauseMs);
977
- await pause(pauseMs, options.signal, node);
978
- }
979
- }
980
- }
981
-
982
- // NOTE: judged by this device's clock, so one running ahead ends a wait before the members
983
- // would refuse it. If waits are seen ending early on skewed phones, allow a grace margin here.
984
- function refuseIfExpired(invite: CadreInviteRow): void {
985
- const expiresAtMs = parseNullableStoredDatetimeMs(invite.expiresAt);
986
- if (expiresAtMs !== null && expiresAtMs <= Date.now()) {
987
- throw new CadreInviteRejectedError('invite-spent', 'the invitation expired before a member accepted it');
988
- }
989
- }
990
-
991
- /** A stopped node's every dial fails at once, which would otherwise read as "no member answered" until expiry. */
992
- function refuseIfStopped(node: Libp2p): void {
993
- if (node.status !== 'started') {
994
- throw new Error(`Cadre invitation redemption stopped waiting: the node is ${node.status}`);
995
- }
996
- }
997
-
998
- /** The pause after pass `pass`, cut short at the invitation's expiry so the next check ends the wait then. */
999
- function pauseAfterPass(pass: number, invite: CadreInviteRow): number {
1000
- const backoffMs = Math.min(REDEEM_PASS_FIRST_PAUSE_MS * 2 ** (pass - 1), REDEEM_PASS_MAX_PAUSE_MS);
1001
- const expiresAtMs = parseNullableStoredDatetimeMs(invite.expiresAt);
1002
- return expiresAtMs === null ? backoffMs : Math.max(0, Math.min(backoffMs, expiresAtMs - Date.now()));
1003
- }
1004
-
1005
- /** Any refusal means a member answered and is not ready yet; otherwise none answered at all. */
1006
- function waitReason(outcomes: readonly CadreInviteAddressOutcome[]): 'no-member-answered' | 'member-not-ready' {
1007
- return outcomes.some((outcome) => outcome.error instanceof CadreInviteRejectedError) ? 'member-not-ready' : 'no-member-answered';
1008
- }
1009
-
1010
- /** A UI callback must not break the join. */
1011
- function reportProgress(onProgress: ((progress: CadreJoinProgress) => void) | undefined, progress: CadreJoinProgress): void {
1012
- if (!onProgress) return;
1013
- try {
1014
- onProgress(progress);
1015
- } catch (error) {
1016
- log('redemption progress callback threw (ignored): %o', error);
1017
- }
1018
- }
1019
-
1020
- /**
1021
- * Wait `ms`, or reject with `signal`'s reason as soon as it aborts. Ends early, resolving, when
1022
- * `node` stops (or is already stopped), so the next pass's stop check ends the wait without a
1023
- * timer holding the process open. Listeners and timer are cleared however it ends.
1024
- */
1025
- function pause(ms: number, signal: AbortSignal | undefined, node: Libp2p): Promise<void> {
1026
- return new Promise((resolve, reject) => {
1027
- if (signal?.aborted) {
1028
- reject(signal.reason);
1029
- return;
1030
- }
1031
- if (node.status !== 'started') {
1032
- resolve();
1033
- return;
1034
- }
1035
- const settle = (): void => {
1036
- clearTimeout(timer);
1037
- signal?.removeEventListener('abort', onAbort);
1038
- node.removeEventListener('stop', onStop);
1039
- };
1040
- const onAbort = (): void => {
1041
- settle();
1042
- reject(signal!.reason);
1043
- };
1044
- const onStop = (): void => {
1045
- settle();
1046
- resolve();
1047
- };
1048
- const timer = setTimeout(onStop, ms);
1049
- signal?.addEventListener('abort', onAbort, { once: true });
1050
- node.addEventListener('stop', onStop, { once: true });
1051
- });
1052
- }
1
+ /**
2
+ * Cadre invitation redemption transport: `/sereus/cadre-invite/1.0.0`.
3
+ *
4
+ * A device holding a cadre invitation (an owner-signed `CadreInvite` row plus the private
5
+ * half of the invitation keypair, see {@link CadreInvitation}) dials ANY member machine the
6
+ * invitation names and proves possession; the member seats the row if it does not hold it
7
+ * yet, writes the device's `CadrePeer` row (and `OwnerKey` row, when the invitation grants
8
+ * ownership) through `ControlDatabase.redeemCadreInvite`, and answers with the party id,
9
+ * the row and dial hints. The owner that minted the invitation need not be online.
10
+ *
11
+ * The exchange shape — one frame each way, the member's hardening and admission of the
12
+ * device, the device's address-by-address dialing in passes — is `member-redemption.ts`'s,
13
+ * shared with the owner-grant protocol; this module supplies the invitation's bundle, request,
14
+ * checks, writes and refusal codes. Stranger-facing by design — the device is not a member
15
+ * when it dials — so the connection gate admits a stranger while a live invitation exists
16
+ * (`membership-connection-gater.ts`), and the handler's own trust decision is the proof of
17
+ * possession: nothing touches the database for a caller that cannot prove it holds the
18
+ * invitation and is the identity it names.
19
+ *
20
+ * The seed protocol is left untouched so `SeedMessage` can grow independently.
21
+ */
22
+
23
+ import debug from 'debug';
24
+ import { multiaddr } from '@multiformats/multiaddr';
25
+ import type { Libp2p } from '@libp2p/interface';
26
+ import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
27
+ import { sign } from '@optimystic/quereus-plugin-crypto';
28
+ import {
29
+ cadreInviteRedeemMessage,
30
+ cadreInviteConsentMessage,
31
+ CadreInviteIssuerUnknownError,
32
+ InvitationExhaustedError,
33
+ type ControlDatabase
34
+ } from './control-database.js';
35
+ import { verifyCadreInviteRow, verifyCadreInviteRedemption } from './peer-authorization.js';
36
+ import { verifyPeerRecordSignature } from './peer-record.js';
37
+ import { ed25519PublicKeyB64FromPeerId } from './ed25519-key.js';
38
+ import { CADRE_INVITATION_PREFIX, CADRE_JOIN_REQUEST_PREFIX, OWNER_GRANT_PREFIX, OWNER_KEY_EXPORT_STEM } from './cadre-join-request.js';
39
+ import {
40
+ MemberRedemptionHandler,
41
+ constraintRefusal,
42
+ dialRedemption,
43
+ dialRedemptionInPasses,
44
+ isBoundedString,
45
+ isEd25519KeyB64,
46
+ isNullableBoundedString,
47
+ isNullableUseCap,
48
+ rejection,
49
+ writeFailureRefusal,
50
+ type ConstraintRefusalRow,
51
+ type MemberRedemption,
52
+ type MemberRedemptionHandlerOptions,
53
+ type MemberRedemptionOptions,
54
+ type MemberRedemptionPassOptions,
55
+ type RedemptionAddressOutcome,
56
+ type RedemptionRejection
57
+ } from './member-redemption.js';
58
+ import type { CadreInviteRow, CadreJoinProgress, PeerAddressRecord, SeedPeer } from './types.js';
59
+
60
+ const log = debug('sereus:cadre:invite-proto');
61
+
62
+ /** Protocol id for cadre invitation redemption (parallel to `/sereus/seed/1.0.0`). */
63
+ export const CADRE_INVITE_PROTOCOL = '/sereus/cadre-invite/1.0.0';
64
+
65
+ /** What the dialer calls the credential in log lines, deadline labels and error text. */
66
+ const LABEL = 'Cadre invitation';
67
+
68
+ /** Most addresses a request may carry for the device, and a reply per peer (`sanitizeAddrs`). */
69
+ const MAX_ADDRS = 16;
70
+
71
+ /** Most machines an invitation bundle may name; the issuer caps its own list far below this. */
72
+ const MAX_BUNDLE_MEMBERS = 64;
73
+
74
+ /** Most peers a reply may carry as dial hints. */
75
+ const MAX_REPLY_PEERS = 256;
76
+
77
+ // ── The invitation bundle ────────────────────────────────────────────────────
78
+
79
+ /**
80
+ * What an owner hands out (QR, paste, link) and a device redeems. An invitation is an
81
+ * ed25519 keypair: the row holds the public half, `invitePrivateKey` is the proof of
82
+ * possession, and whoever holds this bundle can redeem it — treat it as a bearer
83
+ * credential for the row's lifetime. It admits a machine and grants nothing else; an owner
84
+ * grant (`owner-grant-protocol.ts`) is the credential that makes a key an owner.
85
+ *
86
+ * Carrying the signed row is what makes redemption work at a member that has not received
87
+ * the row by replication: the member seats it from the bundle under its stored issuer
88
+ * signature. The row's own signature covers every column, so a bundle whose row was
89
+ * altered cannot be seated anywhere.
90
+ */
91
+ export interface CadreInvitation {
92
+ v: 1;
93
+ /** The cadre the invitation joins; a device refuses one for another party. */
94
+ partyId: string;
95
+ /** The invitation's ed25519 seed, base64url: the proof of possession. */
96
+ invitePrivateKey: string;
97
+ /** The owner-signed row exactly as issued (`ControlDatabase.insertCadreInvite`). */
98
+ invite: CadreInviteRow;
99
+ /**
100
+ * The issuer's anchored owner keys. The redeeming device pins them before it dials
101
+ * (`CadreNode.trustOwnerKeys`) and checks the member's reply against them. Sourced from
102
+ * the issuer's node-local anchor only, never the replicated `OwnerKey` table, for the
103
+ * reason the seed invite gives: the device anchors whatever arrives here.
104
+ */
105
+ ownerKeys: string[];
106
+ /**
107
+ * Multiaddrs ending in `/p2p/<peerId>`: the issuer's own first, then other members', at
108
+ * most `CADRE_INVITATION_MAX_ADDRS` in all (`selectCadreInvitationMembers` in
109
+ * `invitation-bootstrap.ts`). The device tries them in order.
110
+ */
111
+ members: string[];
112
+ }
113
+
114
+ /**
115
+ * Encode a bundle for out-of-band delivery: {@link CADRE_INVITATION_PREFIX}, then base64url
116
+ * over JSON. The prefix lets an app tell an invitation from a join request
117
+ * (`cadre-join-request.ts`) or an owner grant (`owner-grant-protocol.ts`) and name the mistake
118
+ * when one is pasted for the other.
119
+ */
120
+ export function encodeCadreInvitation(invitation: CadreInvitation): string {
121
+ return `${CADRE_INVITATION_PREFIX}${uint8ArrayToString(new TextEncoder().encode(JSON.stringify(invitation)), 'base64url')}`;
122
+ }
123
+
124
+ /**
125
+ * Decode a bundle, refusing anything that is not a well-formed version-1
126
+ * {@link CadreInvitation} behind {@link CADRE_INVITATION_PREFIX}. Standalone so a client
127
+ * without a node (a settings screen that wants to show which cadre an invitation joins) can
128
+ * decode one.
129
+ *
130
+ * @throws on an owner key, an owner grant or a join request, a string without the prefix or
131
+ * that is not base64url JSON after it, or one that decodes to another shape or version.
132
+ */
133
+ export function decodeCadreInvitation(encoded: string): CadreInvitation {
134
+ const trimmed = encoded.trim();
135
+ if (trimmed.startsWith(OWNER_KEY_EXPORT_STEM)) {
136
+ throw new Error('This is an owner key, not a cadre invitation; it is private, so import it only where the app asks for an owner key');
137
+ }
138
+ if (trimmed.startsWith(OWNER_GRANT_PREFIX)) {
139
+ throw new Error('This is an owner grant, not a cadre invitation; paste it where the app asks for an owner grant');
140
+ }
141
+ if (trimmed.startsWith(CADRE_JOIN_REQUEST_PREFIX)) {
142
+ throw new Error('This is a join request, not a cadre invitation; paste it into the app that manages your cadre');
143
+ }
144
+ if (!trimmed.startsWith(CADRE_INVITATION_PREFIX)) {
145
+ throw new Error(`Cadre invitation must start with "${CADRE_INVITATION_PREFIX}" (this one starts with "${trimmed.slice(0, CADRE_INVITATION_PREFIX.length)}")`);
146
+ }
147
+ let parsed: unknown;
148
+ try {
149
+ parsed = JSON.parse(new TextDecoder().decode(uint8ArrayFromString(trimmed.slice(CADRE_INVITATION_PREFIX.length), 'base64url')));
150
+ } catch (error) {
151
+ throw new Error('Cadre invitation does not decode as base64url JSON', { cause: error });
152
+ }
153
+ if (!isWellFormedCadreInvitation(parsed)) {
154
+ throw new Error('Cadre invitation is malformed or of an unsupported version');
155
+ }
156
+ return parsed;
157
+ }
158
+
159
+ function isWellFormedCadreInvitation(value: unknown): value is CadreInvitation {
160
+ if (typeof value !== 'object' || value === null) return false;
161
+ const { v, partyId, invitePrivateKey, invite, ownerKeys, members } = value as Record<string, unknown>;
162
+ return v === 1
163
+ && isBoundedString(partyId)
164
+ && isEd25519KeyB64(invitePrivateKey)
165
+ && isWellFormedCadreInviteRow(invite)
166
+ && Array.isArray(ownerKeys) && ownerKeys.length > 0 && ownerKeys.every((key) => isEd25519KeyB64(key))
167
+ && Array.isArray(members) && members.length <= MAX_BUNDLE_MEMBERS && members.every((addr) => isBoundedString(addr));
168
+ }
169
+
170
+ // ── Wire messages ────────────────────────────────────────────────────────────
171
+
172
+ /**
173
+ * The device's own address record as a request carries it: a {@link PeerAddressRecord} without
174
+ * the peer id, which the connection gives, and the key, which is the request's `peerKey`.
175
+ */
176
+ export type RedeemSelfRecord = Pick<PeerAddressRecord, 'addrs' | 'updatedAt' | 'profile' | 'sig'>;
177
+
178
+ /** Device → member: one frame, the whole redemption. */
179
+ export interface CadreInviteRedeemRequest {
180
+ v: 2;
181
+ /** The cadre the device believes it is joining; a machine serving another party refuses by name. */
182
+ partyId: string;
183
+ /** The owner-signed row from the bundle. */
184
+ invite: CadreInviteRow;
185
+ /** The device's ed25519 public key, base64url; must be the key behind the connecting peer id. */
186
+ peerKey: string;
187
+ /**
188
+ * The device's own address record for its `CadrePeer` row, self-signed for the invitation's
189
+ * party as `CadreNode.registerSelf` signs it (`signPeerRecord`); `addrs` is empty for a phone.
190
+ * The member stores `addrs` and, when the signature verifies over them, the rest of the
191
+ * record too ({@link admittedRecord}), so the device's first publish is its admission.
192
+ */
193
+ record: RedeemSelfRecord;
194
+ /** Single-use nonce the device minted (`generateStampId`); both signatures cover it. */
195
+ usageStampId: string;
196
+ /** By `invitePrivateKey` over the `'redeem'` digest. */
197
+ inviteSig: string;
198
+ /** By the device's identity key over the `'consent'` digest. */
199
+ peerSig: string;
200
+ }
201
+
202
+ /**
203
+ * Why a member refused a redemption. Retryability is {@link CADRE_INVITE_REJECTION_RETRYABLE}'s;
204
+ * a retryable code moves the device to the next address the bundle names.
205
+ */
206
+ export type CadreInviteRejectionCode =
207
+ /**
208
+ * The issuer's `OwnerKey` row is not on this member (not replicated yet), or the issuer was
209
+ * removed; the member cannot tell which, so the device tries the next address.
210
+ */
211
+ | 'issuer-unknown'
212
+ /**
213
+ * A signature does not verify, the peer key is not the connecting identity, the request or
214
+ * the row is malformed, or the invitation names another device.
215
+ */
216
+ | 'invite-invalid'
217
+ /** Expired, withdrawn, exhausted, or issued by a key that is no longer an owner here. */
218
+ | 'invite-spent'
219
+ /** This machine serves another cadre than the one the invitation names. */
220
+ | 'party-mismatch'
221
+ /** The member is at its cap on concurrent redemptions. */
222
+ | 'busy'
223
+ /** The write failed transiently (the write retry gave up), or the member has not synced the invitation row the cadre holds; nothing was recorded. */
224
+ | 'conflict'
225
+ /**
226
+ * An unexpected member-side failure, or a request frame the member could not read (timed
227
+ * out, oversized, not JSON): nothing was proved either way, so the device tries the next
228
+ * address, as the formation responder's `internal` is read.
229
+ */
230
+ | 'internal';
231
+
232
+ /** Which refusals are worth repeating at the next address — the one place that says so. */
233
+ export const CADRE_INVITE_REJECTION_RETRYABLE: Readonly<Record<CadreInviteRejectionCode, boolean>> = {
234
+ 'issuer-unknown': true,
235
+ 'invite-invalid': false,
236
+ 'invite-spent': false,
237
+ 'party-mismatch': false,
238
+ 'busy': true,
239
+ 'conflict': true,
240
+ 'internal': true
241
+ };
242
+
243
+ /** Is `value` a code this build knows? Own keys only, so a peer's `'constructor'` does not pass. */
244
+ export function isCadreInviteRejectionCode(value: unknown): value is CadreInviteRejectionCode {
245
+ return typeof value === 'string' && Object.prototype.hasOwnProperty.call(CADRE_INVITE_REJECTION_RETRYABLE, value);
246
+ }
247
+
248
+ /** Member → device. */
249
+ export interface CadreInviteRedeemReply {
250
+ accepted: boolean;
251
+ /** Present iff `!accepted`. */
252
+ code?: CadreInviteRejectionCode;
253
+ /** Human-facing text; present iff `!accepted`. */
254
+ reason?: string;
255
+ /** The member's party id; present iff `accepted`. */
256
+ partyId?: string;
257
+ /** The invitation row as this member holds it; present iff `accepted`. */
258
+ invite?: CadreInviteRow;
259
+ /**
260
+ * Dial hints for the rest of the cadre, unsigned: the member's own projection of its
261
+ * `CadrePeer` rows (`ControlDatabase.querySeedPeers`), the answering member and every
262
+ * always-on machine marked `preferred`. The device derives trust from its anchor, never
263
+ * from this.
264
+ */
265
+ peers?: SeedPeer[];
266
+ }
267
+
268
+ /** An accepting reply, every optional field present. */
269
+ export interface CadreInviteAccepted extends CadreInviteRedeemReply {
270
+ accepted: true;
271
+ partyId: string;
272
+ invite: CadreInviteRow;
273
+ peers: SeedPeer[];
274
+ }
275
+
276
+ /** A refusal as the member sends it: a code, a reason, nothing else. */
277
+ type CadreInviteRejection = RedemptionRejection<CadreInviteRejectionCode>;
278
+
279
+ // ── Errors the device sees ───────────────────────────────────────────────────
280
+
281
+ /**
282
+ * A member answered and refused. `code` is `'unrecognized'` when the frame carried none this
283
+ * build knows (the two machines may run different versions), and such a refusal counts as
284
+ * retryable: trying the next address on an unknown answer is safe, giving up is not.
285
+ */
286
+ export class CadreInviteRejectedError extends Error {
287
+ readonly code: CadreInviteRejectionCode | 'unrecognized';
288
+ readonly reason: string;
289
+ readonly retryable: boolean;
290
+
291
+ /** Both arguments come straight off the wire, so neither is trusted to have its declared type. */
292
+ constructor(code: unknown, reason: unknown) {
293
+ const text = typeof reason === 'string' ? reason : 'no reason provided';
294
+ super(`Cadre invitation refused: ${text}`);
295
+ this.name = 'CadreInviteRejectedError';
296
+ this.code = isCadreInviteRejectionCode(code) ? code : 'unrecognized';
297
+ this.reason = text;
298
+ this.retryable = this.code === 'unrecognized' || CADRE_INVITE_REJECTION_RETRYABLE[this.code];
299
+ }
300
+ }
301
+
302
+ /** What one address the bundle named came to; the owner-grant redemption reports the same shape. */
303
+ export type CadreInviteAddressOutcome = RedemptionAddressOutcome;
304
+
305
+ /**
306
+ * Every address the bundle names was tried and none accepted: each failed to dial, answered
307
+ * malformed, or refused retryably. Always retryable; `outcomes` says what each address did.
308
+ */
309
+ export class CadreInviteUnreachableError extends Error {
310
+ readonly retryable = true;
311
+
312
+ constructor(readonly outcomes: readonly CadreInviteAddressOutcome[]) {
313
+ super(outcomes.length === 0
314
+ ? 'Cadre invitation names no dialable member'
315
+ : `Cadre invitation could not be redeemed at any of its ${outcomes.length} address(es): `
316
+ + outcomes.map((outcome) => `${outcome.addr}: ${outcome.error.message}`).join('; '));
317
+ this.name = 'CadreInviteUnreachableError';
318
+ }
319
+ }
320
+
321
+ /**
322
+ * A member accepted but its reply does not check out: another party id, another invitation
323
+ * key, or a row not signed by one of the pinned owner keys. Final: a machine that answers
324
+ * like this is not a member of the cadre the invitation names, and the row it wrote (if any)
325
+ * is in a database the device will never trust.
326
+ */
327
+ export class CadreInviteReplyInvalidError extends Error {
328
+ readonly retryable = false;
329
+
330
+ constructor(message: string) {
331
+ super(`Cadre invitation reply refused: ${message}`);
332
+ this.name = 'CadreInviteReplyInvalidError';
333
+ }
334
+ }
335
+
336
+ // ── Shape checks (both directions) ───────────────────────────────────────────
337
+
338
+ /** Is `value` a {@link CadreInviteRow} with every column in its stored form and bounded? */
339
+ export function isWellFormedCadreInviteRow(value: unknown): value is CadreInviteRow {
340
+ if (typeof value !== 'object' || value === null) return false;
341
+ const row = value as Record<string, unknown>;
342
+ return isEd25519KeyB64(row.key)
343
+ && isNullableBoundedString(row.peerId)
344
+ && isNullableBoundedString(row.expiresAt)
345
+ && isNullableUseCap(row.totalUses)
346
+ && isBoundedString(row.stampId)
347
+ && isEd25519KeyB64(row.issuerKey)
348
+ && isBoundedString(row.issuerSig);
349
+ }
350
+
351
+ /**
352
+ * The record's other fields are left to {@link admittedRecord}: a bad value there costs the
353
+ * device its signature on the row, not its admission.
354
+ */
355
+ function isWellFormedRedeemRequest(value: unknown): value is CadreInviteRedeemRequest {
356
+ if (typeof value !== 'object' || value === null) return false;
357
+ const { v, partyId, invite, peerKey, record, usageStampId, inviteSig, peerSig } = value as Record<string, unknown>;
358
+ return v === 2
359
+ && isBoundedString(partyId)
360
+ && isWellFormedCadreInviteRow(invite)
361
+ && isEd25519KeyB64(peerKey)
362
+ && typeof record === 'object' && record !== null && Array.isArray((record as Record<string, unknown>).addrs)
363
+ && isBoundedString(usageStampId)
364
+ && isBoundedString(inviteSig)
365
+ && isBoundedString(peerSig);
366
+ }
367
+
368
+ /** What the member writes on the device's `CadrePeer` row from the request's record. */
369
+ interface AdmittedRecord {
370
+ /** The record's addresses, sanitized ({@link sanitizeAddrs}): stored whether or not the rest is. */
371
+ addrs: string[];
372
+ /** The record's freshness, profile and signature, or null when they cannot be stored as the device's own. */
373
+ signed: Pick<PeerAddressRecord, 'updatedAt' | 'profile' | 'sig'> | null;
374
+ /** Why `signed` is null; absent when it is not. */
375
+ unsignedBecause?: string;
376
+ }
377
+
378
+ /**
379
+ * Judge the device's record (`request.record`) for its admission row. The signature covers the
380
+ * exact address list, so it is kept only when sanitizing dropped nothing, the profile is one the
381
+ * schema's `KnownProfile` check admits, the stamp and signature are in their stored forms, and
382
+ * the signature verifies for `partyId` under the device's key. Anything less admits the device
383
+ * with its addresses and no signature, as an owner-written row is, and the device's next
384
+ * publish fills it in. No clock check: a stamp in the future or the past is one the device's own
385
+ * self-publish could write too.
386
+ */
387
+ function admittedRecord(partyId: string, remotePeerId: string, request: Pick<CadreInviteRedeemRequest, 'peerKey' | 'record'>): AdmittedRecord {
388
+ const { addrs: offered, updatedAt, profile, sig } = request.record as Partial<Record<keyof RedeemSelfRecord, unknown>>;
389
+ const addrs = sanitizeAddrs(offered);
390
+ const unsigned = (because: string): AdmittedRecord => ({ addrs, signed: null, unsignedBecause: because });
391
+ // Sanitizing only drops entries, so an unchanged length is an unchanged list.
392
+ if (!Array.isArray(offered) || addrs.length !== offered.length) {
393
+ return unsigned('sanitizing changed the signed address list');
394
+ }
395
+ if (profile !== null && profile !== 'transaction' && profile !== 'storage') {
396
+ return unsigned('unknown profile');
397
+ }
398
+ if (typeof updatedAt !== 'number' || !Number.isSafeInteger(updatedAt) || updatedAt <= 0 || !isBoundedString(sig)) {
399
+ return unsigned('malformed stamp or signature');
400
+ }
401
+ if (!verifyPeerRecordSignature(partyId, { peerId: remotePeerId, publicKey: request.peerKey, addrs, updatedAt, profile, sig })) {
402
+ return unsigned('signature does not verify');
403
+ }
404
+ return { addrs, signed: { updatedAt, profile, sig } };
405
+ }
406
+
407
+ /**
408
+ * Keep only parsable, distinct multiaddr strings, capped at {@link MAX_ADDRS}, order kept.
409
+ * Applied on both sides: the member bounds what it stores on the device's row, the device
410
+ * bounds what it merges into its peer store. A malformed entry is skipped, not fatal.
411
+ */
412
+ export function sanitizeAddrs(addrs: unknown): string[] {
413
+ if (!Array.isArray(addrs)) return [];
414
+ const out: string[] = [];
415
+ const seen = new Set<string>();
416
+ for (const entry of addrs) {
417
+ if (out.length >= MAX_ADDRS) break;
418
+ if (!isBoundedString(entry) || seen.has(entry)) continue;
419
+ try {
420
+ multiaddr(entry);
421
+ } catch (error) {
422
+ log('addrs: skipping unparsable entry: %o', error);
423
+ continue;
424
+ }
425
+ seen.add(entry);
426
+ out.push(entry);
427
+ }
428
+ return out;
429
+ }
430
+
431
+ /** The reply's peer list, bounded and each entry's addresses sanitized; anything else dropped. */
432
+ function sanitizePeers(peers: unknown): SeedPeer[] {
433
+ if (!Array.isArray(peers)) return [];
434
+ const out: SeedPeer[] = [];
435
+ for (const entry of peers) {
436
+ if (out.length >= MAX_REPLY_PEERS) break;
437
+ if (typeof entry !== 'object' || entry === null) continue;
438
+ const { peerId, multiaddrs, preferred } = entry as Record<string, unknown>;
439
+ if (!isBoundedString(peerId)) continue;
440
+ out.push({ peerId, multiaddrs: sanitizeAddrs(multiaddrs), preferred: preferred === true });
441
+ }
442
+ return out;
443
+ }
444
+
445
+ /** An acceptance frame's fields, the attacker-influenced ones sanitized. Throws on a malformed one. */
446
+ function decodeAccepted(reply: Record<string, unknown>): CadreInviteAccepted {
447
+ if (!isBoundedString(reply.partyId) || !isWellFormedCadreInviteRow(reply.invite)) {
448
+ throw new Error('Cadre invitation acceptance is missing its party id or a well-formed row');
449
+ }
450
+ return { accepted: true, partyId: reply.partyId, invite: reply.invite, peers: sanitizePeers(reply.peers) };
451
+ }
452
+
453
+ // ── Request construction (device side) ───────────────────────────────────────
454
+
455
+ /** What the device signs with: its identity keypair in the crypto plugin's base64url form. */
456
+ export interface RedeemSigner {
457
+ /** The device's ed25519 public key, base64url — the key behind its peer id. */
458
+ peerKey: string;
459
+ /** The device's ed25519 seed, base64url. */
460
+ peerPrivateKey: string;
461
+ }
462
+
463
+ /**
464
+ * Build and sign the request for `invitation`: the holder's `'redeem'` signature with the
465
+ * invitation's private key and the device's `'consent'` signature with its own, both over
466
+ * (invitation key, `usageStampId`, device key) for the invitation's party. The same bytes
467
+ * the schema verifies at the member (`cadreInviteRedeemMessage` / `cadreInviteConsentMessage`),
468
+ * whose own `party_id()` must therefore be the party the bundle names.
469
+ *
470
+ * @param record - the device's address record, already signed for the invitation's party over
471
+ * sanitized addresses ({@link sanitizeAddrs}); copied in unchanged, since the signature
472
+ * covers the exact list.
473
+ */
474
+ export function signRedeemRequest(
475
+ invitation: CadreInvitation,
476
+ signer: RedeemSigner,
477
+ usageStampId: string,
478
+ record: RedeemSelfRecord
479
+ ): CadreInviteRedeemRequest {
480
+ const fields = { inviteKey: invitation.invite.key, usageStampId, peerKey: signer.peerKey };
481
+ const signB64 = (message: Uint8Array, privateKey: string): string =>
482
+ sign(message, privateKey, 'ed25519', 'bytes', 'base64url', 'base64url') as string;
483
+ return {
484
+ v: 2,
485
+ partyId: invitation.partyId,
486
+ invite: invitation.invite,
487
+ peerKey: signer.peerKey,
488
+ record: { addrs: [...record.addrs], updatedAt: record.updatedAt, profile: record.profile, sig: record.sig },
489
+ usageStampId,
490
+ inviteSig: signB64(cadreInviteRedeemMessage(invitation.partyId, fields), invitation.invitePrivateKey),
491
+ peerSig: signB64(cadreInviteConsentMessage(invitation.partyId, fields), signer.peerPrivateKey)
492
+ };
493
+ }
494
+
495
+ /**
496
+ * The device's check on an acceptance: the same party, the same invitation key, and a row
497
+ * signed by an issuer the device pinned from the bundle. Throws
498
+ * {@link CadreInviteReplyInvalidError} naming the first failure.
499
+ *
500
+ * What it proves is bounded: the request already carried the owner-signed row, so a machine
501
+ * that is not a member can echo it. The check catches a reply for another party or
502
+ * invitation and a row the pinned owners never signed; whether the answering machine is a
503
+ * member is settled afterwards, when the device syncs the control database and judges every
504
+ * row against its anchor (`CadreNode.listAuthorizedMembers`).
505
+ */
506
+ export function verifyRedeemReply(
507
+ reply: CadreInviteAccepted,
508
+ invitation: CadreInvitation,
509
+ isTrustedIssuer: (ownerKey: string) => boolean
510
+ ): void {
511
+ if (reply.partyId !== invitation.partyId) {
512
+ throw new CadreInviteReplyInvalidError(`the member serves party ${reply.partyId}, the invitation names ${invitation.partyId}`);
513
+ }
514
+ if (reply.invite.key !== invitation.invite.key) {
515
+ throw new CadreInviteReplyInvalidError('the member answered for a different invitation');
516
+ }
517
+ if (!isTrustedIssuer(reply.invite.issuerKey)) {
518
+ throw new CadreInviteReplyInvalidError('the row the member holds is not issued by one of the invitation\'s owner keys');
519
+ }
520
+ if (!verifyCadreInviteRow(invitation.partyId, reply.invite)) {
521
+ throw new CadreInviteReplyInvalidError('the row the member holds is not signed by its issuer');
522
+ }
523
+ }
524
+
525
+ // ── Member side (handler) ────────────────────────────────────────────────────
526
+
527
+ /** The control-database surface a redemption needs; a `ControlDatabase` satisfies it. */
528
+ export type CadreInviteStore = Pick<ControlDatabase, 'queryCadreInvite' | 'seatCadreInvite' | 'isCadreInviteLive' | 'redeemCadreInvite' | 'querySeedPeers'>;
529
+
530
+ export interface CadreInviteHandlerOptions extends MemberRedemptionHandlerOptions {
531
+ /** This member's peer id: the reply's peer list marks it preferred, since the device just reached it. */
532
+ peerId: string;
533
+ store: CadreInviteStore;
534
+ }
535
+
536
+ /** Constraint names the schema refuses a redemption with, and the code each maps to. */
537
+ const REDEEM_CONSTRAINT_CODES: ReadonlyArray<ConstraintRefusalRow<CadreInviteRejectionCode>> = [
538
+ [/CHECK constraint failed: Authorized\b/, 'invite-spent', 'The invitation is expired, withdrawn, exhausted, or its issuer is no longer an owner'],
539
+ [/CHECK constraint failed: (InvitePossessed|PeerConsented|PeerExists)\b/, 'invite-invalid', 'The redemption does not satisfy the invitation'],
540
+ [/CHECK constraint failed: NotRevoked\b/, 'invite-spent', 'The invitation was withdrawn'],
541
+ ];
542
+
543
+ /**
544
+ * Member side of the invitation protocol (`MemberRedemptionHandler` has the shape): after the
545
+ * request's shape, it checks the party, the connecting identity (an ed25519 peer id is an
546
+ * identity multihash of the very key, and this is the only layer that can check the pair),
547
+ * both signatures and a targeted invitation's device, then seats the row from the bundle when
548
+ * this member holds none and redeems. An issuer this member does not know as an owner is the
549
+ * one retryable refusal left: the device tries the next address, which may hold the issuer's row.
550
+ */
551
+ export class CadreInviteHandler extends MemberRedemptionHandler<CadreInviteRedeemRequest, CadreInviteRedeemReply, CadreInviteRow> {
552
+ private readonly peerId: string;
553
+ private readonly store: CadreInviteStore;
554
+ protected readonly busyRefusal = rejection('busy', 'Too many concurrent redemptions');
555
+ protected readonly internalRefusal = rejection('internal', 'Internal redemption error');
556
+ protected readonly malformedRefusal = rejection('invite-invalid', 'Malformed redemption request');
557
+
558
+ constructor(options: CadreInviteHandlerOptions) {
559
+ super('Cadre invite', CADRE_INVITE_PROTOCOL, options);
560
+ this.peerId = options.peerId;
561
+ this.store = options.store;
562
+ }
563
+
564
+ protected isWellFormedRequest(value: unknown): value is CadreInviteRedeemRequest {
565
+ return isWellFormedRedeemRequest(value);
566
+ }
567
+
568
+ protected checkRequest(request: CadreInviteRedeemRequest, remotePeerId: string): CadreInviteRejection | null {
569
+ if (request.partyId !== this.partyId) {
570
+ return rejection('party-mismatch', 'This machine serves another cadre');
571
+ }
572
+ if (ed25519PublicKeyB64FromPeerId(remotePeerId) !== request.peerKey) {
573
+ return rejection('invite-invalid', 'The peer key is not the connecting identity');
574
+ }
575
+ const fields = { inviteKey: request.invite.key, usageStampId: request.usageStampId, peerKey: request.peerKey };
576
+ if (!verifyCadreInviteRedemption(this.partyId, fields, request.inviteSig, request.peerSig)) {
577
+ return rejection('invite-invalid', 'The redemption signatures do not verify');
578
+ }
579
+ if (request.invite.peerId !== null && request.invite.peerId !== remotePeerId) {
580
+ return rejection('invite-invalid', 'The invitation names another device');
581
+ }
582
+ return null;
583
+ }
584
+
585
+ protected readHeld(request: CadreInviteRedeemRequest): Promise<CadreInviteRow | null> {
586
+ return this.store.queryCadreInvite(request.invite.key);
587
+ }
588
+
589
+ protected isLive(request: CadreInviteRedeemRequest, held: CadreInviteRow | null): Promise<boolean> {
590
+ return this.store.isCadreInviteLive(request.invite, held);
591
+ }
592
+
593
+ /**
594
+ * The two writes — the seat (its own transaction), only when this member holds no row, and
595
+ * the redemption — each answered by name when it fails. The held row is carried into the
596
+ * redemption so it is not read again in the write lock; after a fresh seat nothing is carried
597
+ * and the redemption reads the row it seated, since the bundle's copy is the requester's.
598
+ */
599
+ protected async redeemAdmitted(request: CadreInviteRedeemRequest, remotePeerId: string, held: CadreInviteRow | null): Promise<CadreInviteRedeemReply> {
600
+ if (held === null) {
601
+ const refused = await this.seat(request, remotePeerId);
602
+ if (refused) return refused;
603
+ }
604
+ // Built before the write: the `CadrePeer` membership view is normally warm then (the
605
+ // connection gate read it when the device connected), and the redemption's commit drops it.
606
+ // The list lacks the device's own row, which the device drops from it anyway.
607
+ const peers = await this.peersBestEffort();
608
+ const record = admittedRecord(this.partyId, remotePeerId, request);
609
+ if (record.signed === null) {
610
+ log('admitting %s without its own record signature: %s', remotePeerId, record.unsignedBecause);
611
+ }
612
+ try {
613
+ const result = await this.store.redeemCadreInvite({
614
+ inviteKey: request.invite.key,
615
+ ...(held ? { held } : {}),
616
+ peerId: remotePeerId,
617
+ peerKey: request.peerKey,
618
+ multiaddrs: record.addrs,
619
+ ...(record.signed ? { record: record.signed } : {}),
620
+ usageStampId: request.usageStampId,
621
+ inviteSig: request.inviteSig,
622
+ peerSig: request.peerSig
623
+ });
624
+ log('invitation %s redeemed by %s (alreadyMember=%s)', request.invite.key, remotePeerId, result.alreadyMember);
625
+ } catch (error) {
626
+ if (error instanceof InvitationExhaustedError) {
627
+ return rejection('invite-spent', 'The invitation has no uses left');
628
+ }
629
+ log('redeeming invitation %s for %s failed: %o', request.invite.key, remotePeerId, error);
630
+ return constraintRefusal(error, REDEEM_CONSTRAINT_CODES) ?? writeFailureRefusal(error);
631
+ }
632
+ // The row as held: read before the write, or the bundle's copy the seat inserted verbatim
633
+ // or found already held (its key is the primary key and every column is under the issuer's
634
+ // signature).
635
+ return { accepted: true, partyId: this.partyId, invite: held ?? request.invite, peers };
636
+ }
637
+
638
+ /** Seat the bundle's copy of the row; a refusal answered by name, or null once it is held. */
639
+ private async seat(request: CadreInviteRedeemRequest, remotePeerId: string): Promise<CadreInviteRejection | null> {
640
+ try {
641
+ await this.store.seatCadreInvite(request.invite);
642
+ return null;
643
+ } catch (error) {
644
+ if (error instanceof CadreInviteIssuerUnknownError) {
645
+ return rejection('issuer-unknown', 'The invitation\'s issuer is not known as an owner here');
646
+ }
647
+ log('seating invitation %s from %s failed: %o', request.invite.key, remotePeerId, error);
648
+ return constraintRefusal(error, REDEEM_CONSTRAINT_CODES) ?? writeFailureRefusal(error);
649
+ }
650
+ }
651
+
652
+ /** Dial hints are an optimization; a failed read costs the device its hints, not its membership. */
653
+ private async peersBestEffort(): Promise<SeedPeer[]> {
654
+ try {
655
+ return await this.store.querySeedPeers(this.peerId);
656
+ } catch (error) {
657
+ log('peer projection for the reply failed (continuing without hints): %o', error);
658
+ return [];
659
+ }
660
+ }
661
+ }
662
+
663
+ // ── Device side (dialer) ─────────────────────────────────────────────────────
664
+
665
+ export interface RedeemAtMembersOptions {
666
+ /** The bundle: its `members` are dialed in order. */
667
+ invitation: CadreInvitation;
668
+ /** The signed request ({@link signRedeemRequest}); the same one goes to every address. */
669
+ request: CadreInviteRedeemRequest;
670
+ /** Is this owner key one the device pinned from the bundle? The reply check's input. */
671
+ isTrustedIssuer: (ownerKey: string) => boolean;
672
+ /** See `MemberRedemptionOptions.linkRoundTripMs`. */
673
+ linkRoundTripMs?: number;
674
+ /** Per-address deadline override; default derived from `linkRoundTripMs`. */
675
+ addressBudgetMs?: number;
676
+ protocolId?: string;
677
+ /** Cancels the dial or stream in flight and ends the pass; the call throws the signal's reason. */
678
+ signal?: AbortSignal;
679
+ }
680
+
681
+ /** What a successful redemption yields the device. */
682
+ export type CadreInviteRedemption = MemberRedemption<CadreInviteAccepted>;
683
+
684
+ /** The invitation's half of the shared dial loop: its label, protocol, bundle, codec, checks and errors. */
685
+ function inviteRedemption(options: RedeemAtMembersOptions): MemberRedemptionOptions<CadreInviteAccepted> {
686
+ return {
687
+ label: LABEL,
688
+ protocolId: options.protocolId ?? CADRE_INVITE_PROTOCOL,
689
+ members: options.invitation.members,
690
+ request: options.request,
691
+ decodeAccepted,
692
+ refusal: (code, reason) => new CadreInviteRejectedError(code, reason),
693
+ verifyAccepted: (reply) => verifyRedeemReply(reply, options.invitation, options.isTrustedIssuer),
694
+ unreachable: (outcomes) => new CadreInviteUnreachableError(outcomes),
695
+ ...(options.linkRoundTripMs !== undefined ? { linkRoundTripMs: options.linkRoundTripMs } : {}),
696
+ ...(options.addressBudgetMs !== undefined ? { addressBudgetMs: options.addressBudgetMs } : {}),
697
+ ...(options.signal ? { signal: options.signal } : {}),
698
+ };
699
+ }
700
+
701
+ /**
702
+ * Device side: try the bundle's addresses one at a time, each under its own deadline, and
703
+ * stop at the first acceptance or the first final refusal (`dialRedemption` in
704
+ * `member-redemption.ts`). Every address gets the SAME request, with one `usageStampId`:
705
+ * `CadreInviteUsage` is keyed by it, so a member the device gave up on that was still
706
+ * committing cannot admit it twice, and a retry at the next member after a dropped reply is
707
+ * answered as already a member.
708
+ *
709
+ * @throws {CadreInviteRejectedError} on a final refusal (its `retryable` is false).
710
+ * @throws {CadreInviteReplyInvalidError} on an acceptance that fails {@link verifyRedeemReply}.
711
+ * @throws {CadreInviteUnreachableError} when every address was tried and none accepted.
712
+ * @throws the reason of `options.signal` once it aborts.
713
+ */
714
+ export function redeemAtMembers(node: Libp2p, options: RedeemAtMembersOptions): Promise<CadreInviteRedemption> {
715
+ return dialRedemption(node, inviteRedemption(options));
716
+ }
717
+
718
+ export interface RedeemInPassesOptions extends RedeemAtMembersOptions {
719
+ /** Keep making passes while every answer is retryable; false makes exactly one. */
720
+ waitForMember: boolean;
721
+ /** Called at each pass and each pause; an exception it throws is logged and ignored. */
722
+ onProgress?: (progress: CadreJoinProgress) => void;
723
+ }
724
+
725
+ /**
726
+ * Device side, in passes (`dialRedemptionInPasses` in `member-redemption.ts`): one
727
+ * {@link redeemAtMembers} pass, or, with `waitForMember`, as many as it takes, pausing between
728
+ * them and never past the invitation's expiry, which by this device's clock is thrown as the
729
+ * `invite-spent` refusal a member would give.
730
+ */
731
+ export function redeemInPasses(node: Libp2p, options: RedeemInPassesOptions): Promise<CadreInviteRedemption> {
732
+ const passes: MemberRedemptionPassOptions<CadreInviteAccepted> = {
733
+ ...inviteRedemption(options),
734
+ waitForMember: options.waitForMember,
735
+ expiresAt: options.invitation.invite.expiresAt,
736
+ expired: () => new CadreInviteRejectedError('invite-spent', 'the invitation expired before a member accepted it'),
737
+ ...(options.onProgress ? { onProgress: options.onProgress } : {}),
738
+ };
739
+ return dialRedemptionInPasses(node, passes);
740
+ }