@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
@@ -8,12 +8,14 @@
8
8
  * ownership) through `ControlDatabase.redeemCadreInvite`, and answers with the party id,
9
9
  * the row and dial hints. The owner that minted the invitation need not be online.
10
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.
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.
17
19
  *
18
20
  * The seed protocol is left untouched so `SeedMessage` can grow independently.
19
21
  */
@@ -21,47 +23,28 @@ import debug from 'debug';
21
23
  import { multiaddr } from '@multiformats/multiaddr';
22
24
  import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
23
25
  import { sign } from '@optimystic/quereus-plugin-crypto';
24
- import { withDeadline, exchangeFrame, readStreamToEnd, replyAndClose } from './control-stream.js';
25
- import { decodeLengthPrefixedFrame } from './seed-bootstrap.js';
26
- import { relayedRequestBudgetMs } from './link-budget.js';
27
- import { cadreInviteRedeemMessage, cadreInviteConsentMessage, CadreInviteIssuerUnknownError, InvitationExhaustedError, parseNullableStoredDatetimeMs } from './control-database.js';
26
+ import { cadreInviteRedeemMessage, cadreInviteConsentMessage, CadreInviteIssuerUnknownError, InvitationExhaustedError } from './control-database.js';
28
27
  import { verifyCadreInviteRow, verifyCadreInviteRedemption } from './peer-authorization.js';
29
- import { ed25519PublicKeyB64FromPeerId, requireEd25519PublicKeyB64 } from './ed25519-key.js';
30
- import { dialAddr, nextMacrotask, trailingPeerId } from './peer-dial.js';
31
- import { chainMessages } from './control-retry.js';
32
- import { isRetriableControlWriteFailure } from './control-write-retry.js';
33
- import { CADRE_INVITATION_PREFIX, CADRE_JOIN_REQUEST_PREFIX } from './cadre-join-request.js';
28
+ import { verifyPeerRecordSignature } from './peer-record.js';
29
+ import { ed25519PublicKeyB64FromPeerId } from './ed25519-key.js';
30
+ import { CADRE_INVITATION_PREFIX, CADRE_JOIN_REQUEST_PREFIX, OWNER_GRANT_PREFIX, OWNER_KEY_EXPORT_STEM } from './cadre-join-request.js';
31
+ import { MemberRedemptionHandler, constraintRefusal, dialRedemption, dialRedemptionInPasses, isBoundedString, isEd25519KeyB64, isNullableBoundedString, isNullableUseCap, rejection, writeFailureRefusal } from './member-redemption.js';
34
32
  const log = debug('sereus:cadre:invite-proto');
35
33
  /** Protocol id for cadre invitation redemption (parallel to `/sereus/seed/1.0.0`). */
36
34
  export const CADRE_INVITE_PROTOCOL = '/sereus/cadre-invite/1.0.0';
37
- /**
38
- * Cap on either frame. A request is one signed row, two signatures and a few addresses;
39
- * a reply adds a peer list of a cadre's size. Both fit in a few KB; 64 KiB is the
40
- * defensive bound a stranger can make the handler buffer.
41
- */
42
- const MAX_CADRE_INVITE_MSG_SIZE = 64 * 1024;
43
- /**
44
- * Default time the member waits for the request frame before aborting the read (ms). The
45
- * read only: a stranger opens the stream, so the bound is on a caller that opens one and
46
- * never sends. Same figure as the seed handler's.
47
- */
48
- // 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
49
- const DEFAULT_REDEEM_READ_TIMEOUT_MS = 10000;
50
- /** Default cap on concurrent inbound redemptions; over it the handler answers `busy`. */
51
- const DEFAULT_MAX_CONCURRENT_REDEMPTIONS = 100;
52
- /** Longest string field accepted off the wire; a stamp is 342 base64url chars, a signature 86. */
53
- const MAX_WIRE_FIELD_LENGTH = 1024;
35
+ /** What the dialer calls the credential in log lines, deadline labels and error text. */
36
+ const LABEL = 'Cadre invitation';
54
37
  /** Most addresses a request may carry for the device, and a reply per peer (`sanitizeAddrs`). */
55
38
  const MAX_ADDRS = 16;
56
39
  /** Most machines an invitation bundle may name; the issuer caps its own list far below this. */
57
40
  const MAX_BUNDLE_MEMBERS = 64;
58
41
  /** Most peers a reply may carry as dial hints. */
59
42
  const MAX_REPLY_PEERS = 256;
60
- const BASE64URL = /^[A-Za-z0-9_-]+$/;
61
43
  /**
62
44
  * Encode a bundle for out-of-band delivery: {@link CADRE_INVITATION_PREFIX}, then base64url
63
45
  * over JSON. The prefix lets an app tell an invitation from a join request
64
- * (`cadre-join-request.ts`) and name the mistake when one is pasted for the other.
46
+ * (`cadre-join-request.ts`) or an owner grant (`owner-grant-protocol.ts`) and name the mistake
47
+ * when one is pasted for the other.
65
48
  */
66
49
  export function encodeCadreInvitation(invitation) {
67
50
  return `${CADRE_INVITATION_PREFIX}${uint8ArrayToString(new TextEncoder().encode(JSON.stringify(invitation)), 'base64url')}`;
@@ -72,11 +55,17 @@ export function encodeCadreInvitation(invitation) {
72
55
  * without a node (a settings screen that wants to show which cadre an invitation joins) can
73
56
  * decode one.
74
57
  *
75
- * @throws on a join request, a string without the prefix or that is not base64url JSON after
76
- * it, or one that decodes to another shape or version.
58
+ * @throws on an owner key, an owner grant or a join request, a string without the prefix or
59
+ * that is not base64url JSON after it, or one that decodes to another shape or version.
77
60
  */
78
61
  export function decodeCadreInvitation(encoded) {
79
62
  const trimmed = encoded.trim();
63
+ if (trimmed.startsWith(OWNER_KEY_EXPORT_STEM)) {
64
+ 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');
65
+ }
66
+ if (trimmed.startsWith(OWNER_GRANT_PREFIX)) {
67
+ throw new Error('This is an owner grant, not a cadre invitation; paste it where the app asks for an owner grant');
68
+ }
80
69
  if (trimmed.startsWith(CADRE_JOIN_REQUEST_PREFIX)) {
81
70
  throw new Error('This is a join request, not a cadre invitation; paste it into the app that manages your cadre');
82
71
  }
@@ -120,9 +109,6 @@ export const CADRE_INVITE_REJECTION_RETRYABLE = {
120
109
  export function isCadreInviteRejectionCode(value) {
121
110
  return typeof value === 'string' && Object.prototype.hasOwnProperty.call(CADRE_INVITE_REJECTION_RETRYABLE, value);
122
111
  }
123
- function rejection(code, reason) {
124
- return { accepted: false, code, reason };
125
- }
126
112
  // ── Errors the device sees ───────────────────────────────────────────────────
127
113
  /**
128
114
  * A member answered and refused. `code` is `'unrecognized'` when the frame carried none this
@@ -169,47 +155,64 @@ export class CadreInviteReplyInvalidError extends Error {
169
155
  }
170
156
  }
171
157
  // ── Shape checks (both directions) ───────────────────────────────────────────
172
- function isBoundedString(value, max = MAX_WIRE_FIELD_LENGTH) {
173
- return typeof value === 'string' && value.length > 0 && value.length <= max;
174
- }
175
- function isEd25519KeyB64(value) {
176
- if (!isBoundedString(value) || !BASE64URL.test(value))
177
- return false;
178
- try {
179
- requireEd25519PublicKeyB64(value, 'key');
180
- return true;
181
- }
182
- catch {
183
- return false;
184
- }
185
- }
186
158
  /** Is `value` a {@link CadreInviteRow} with every column in its stored form and bounded? */
187
159
  export function isWellFormedCadreInviteRow(value) {
188
160
  if (typeof value !== 'object' || value === null)
189
161
  return false;
190
162
  const row = value;
191
163
  return isEd25519KeyB64(row.key)
192
- && (row.peerId === null || isBoundedString(row.peerId))
193
- && typeof row.grantsOwner === 'boolean'
194
- && (row.expiresAt === null || isBoundedString(row.expiresAt))
195
- && (row.totalUses === null || (Number.isInteger(row.totalUses) && row.totalUses >= 1))
164
+ && isNullableBoundedString(row.peerId)
165
+ && isNullableBoundedString(row.expiresAt)
166
+ && isNullableUseCap(row.totalUses)
196
167
  && isBoundedString(row.stampId)
197
168
  && isEd25519KeyB64(row.issuerKey)
198
169
  && isBoundedString(row.issuerSig);
199
170
  }
171
+ /**
172
+ * The record's other fields are left to {@link admittedRecord}: a bad value there costs the
173
+ * device its signature on the row, not its admission.
174
+ */
200
175
  function isWellFormedRedeemRequest(value) {
201
176
  if (typeof value !== 'object' || value === null)
202
177
  return false;
203
- const { v, partyId, invite, peerKey, multiaddrs, usageStampId, inviteSig, peerSig } = value;
204
- return v === 1
178
+ const { v, partyId, invite, peerKey, record, usageStampId, inviteSig, peerSig } = value;
179
+ return v === 2
205
180
  && isBoundedString(partyId)
206
181
  && isWellFormedCadreInviteRow(invite)
207
182
  && isEd25519KeyB64(peerKey)
208
- && Array.isArray(multiaddrs)
183
+ && typeof record === 'object' && record !== null && Array.isArray(record.addrs)
209
184
  && isBoundedString(usageStampId)
210
185
  && isBoundedString(inviteSig)
211
186
  && isBoundedString(peerSig);
212
187
  }
188
+ /**
189
+ * Judge the device's record (`request.record`) for its admission row. The signature covers the
190
+ * exact address list, so it is kept only when sanitizing dropped nothing, the profile is one the
191
+ * schema's `KnownProfile` check admits, the stamp and signature are in their stored forms, and
192
+ * the signature verifies for `partyId` under the device's key. Anything less admits the device
193
+ * with its addresses and no signature, as an owner-written row is, and the device's next
194
+ * publish fills it in. No clock check: a stamp in the future or the past is one the device's own
195
+ * self-publish could write too.
196
+ */
197
+ function admittedRecord(partyId, remotePeerId, request) {
198
+ const { addrs: offered, updatedAt, profile, sig } = request.record;
199
+ const addrs = sanitizeAddrs(offered);
200
+ const unsigned = (because) => ({ addrs, signed: null, unsignedBecause: because });
201
+ // Sanitizing only drops entries, so an unchanged length is an unchanged list.
202
+ if (!Array.isArray(offered) || addrs.length !== offered.length) {
203
+ return unsigned('sanitizing changed the signed address list');
204
+ }
205
+ if (profile !== null && profile !== 'transaction' && profile !== 'storage') {
206
+ return unsigned('unknown profile');
207
+ }
208
+ if (typeof updatedAt !== 'number' || !Number.isSafeInteger(updatedAt) || updatedAt <= 0 || !isBoundedString(sig)) {
209
+ return unsigned('malformed stamp or signature');
210
+ }
211
+ if (!verifyPeerRecordSignature(partyId, { peerId: remotePeerId, publicKey: request.peerKey, addrs, updatedAt, profile, sig })) {
212
+ return unsigned('signature does not verify');
213
+ }
214
+ return { addrs, signed: { updatedAt, profile, sig } };
215
+ }
213
216
  /**
214
217
  * Keep only parsable, distinct multiaddr strings, capped at {@link MAX_ADDRS}, order kept.
215
218
  * Applied on both sides: the member bounds what it stores on the device's row, the device
@@ -247,31 +250,15 @@ function sanitizePeers(peers) {
247
250
  break;
248
251
  if (typeof entry !== 'object' || entry === null)
249
252
  continue;
250
- const { peerId, multiaddrs, isOwner, publicKey } = entry;
253
+ const { peerId, multiaddrs, preferred } = entry;
251
254
  if (!isBoundedString(peerId))
252
255
  continue;
253
- out.push({
254
- peerId,
255
- multiaddrs: sanitizeAddrs(multiaddrs),
256
- isOwner: isOwner === true,
257
- ...(isEd25519KeyB64(publicKey) ? { publicKey } : {})
258
- });
256
+ out.push({ peerId, multiaddrs: sanitizeAddrs(multiaddrs), preferred: preferred === true });
259
257
  }
260
258
  return out;
261
259
  }
262
- /**
263
- * Read a reply frame into a typed reply, sanitizing the attacker-influenced fields of an
264
- * acceptance. A refusal keeps only its code and reason, as sent. Throws on a frame that is
265
- * neither.
266
- */
267
- function decodeReply(value) {
268
- if (typeof value !== 'object' || value === null) {
269
- throw new Error('Cadre invitation reply is not an object');
270
- }
271
- const reply = value;
272
- if (reply.accepted !== true) {
273
- return { accepted: false, code: reply.code, reason: reply.reason };
274
- }
260
+ /** An acceptance frame's fields, the attacker-influenced ones sanitized. Throws on a malformed one. */
261
+ function decodeAccepted(reply) {
275
262
  if (!isBoundedString(reply.partyId) || !isWellFormedCadreInviteRow(reply.invite)) {
276
263
  throw new Error('Cadre invitation acceptance is missing its party id or a well-formed row');
277
264
  }
@@ -283,16 +270,20 @@ function decodeReply(value) {
283
270
  * (invitation key, `usageStampId`, device key) for the invitation's party. The same bytes
284
271
  * the schema verifies at the member (`cadreInviteRedeemMessage` / `cadreInviteConsentMessage`),
285
272
  * whose own `party_id()` must therefore be the party the bundle names.
273
+ *
274
+ * @param record - the device's address record, already signed for the invitation's party over
275
+ * sanitized addresses ({@link sanitizeAddrs}); copied in unchanged, since the signature
276
+ * covers the exact list.
286
277
  */
287
- export function signRedeemRequest(invitation, signer, usageStampId, multiaddrs) {
278
+ export function signRedeemRequest(invitation, signer, usageStampId, record) {
288
279
  const fields = { inviteKey: invitation.invite.key, usageStampId, peerKey: signer.peerKey };
289
280
  const signB64 = (message, privateKey) => sign(message, privateKey, 'ed25519', 'bytes', 'base64url', 'base64url');
290
281
  return {
291
- v: 1,
282
+ v: 2,
292
283
  partyId: invitation.partyId,
293
284
  invite: invitation.invite,
294
285
  peerKey: signer.peerKey,
295
- multiaddrs: sanitizeAddrs(multiaddrs),
286
+ record: { addrs: [...record.addrs], updatedAt: record.updatedAt, profile: record.profile, sig: record.sig },
296
287
  usageStampId,
297
288
  inviteSig: signB64(cadreInviteRedeemMessage(invitation.partyId, fields), invitation.invitePrivateKey),
298
289
  peerSig: signB64(cadreInviteConsentMessage(invitation.partyId, fields), signer.peerPrivateKey)
@@ -326,131 +317,38 @@ export function verifyRedeemReply(reply, invitation, isTrustedIssuer) {
326
317
  /** Constraint names the schema refuses a redemption with, and the code each maps to. */
327
318
  const REDEEM_CONSTRAINT_CODES = [
328
319
  [/CHECK constraint failed: Authorized\b/, 'invite-spent', 'The invitation is expired, withdrawn, exhausted, or its issuer is no longer an owner'],
329
- [/CHECK constraint failed: (InvitePossessed|PeerConsented|PeerExists|OwnerExists)\b/, 'invite-invalid', 'The redemption does not satisfy the invitation'],
320
+ [/CHECK constraint failed: (InvitePossessed|PeerConsented|PeerExists)\b/, 'invite-invalid', 'The redemption does not satisfy the invitation'],
330
321
  [/CHECK constraint failed: NotRevoked\b/, 'invite-spent', 'The invitation was withdrawn'],
331
322
  ];
332
- /** Which named constraint, if any, refused the write — read off the error's cause chain. */
333
- function constraintRejection(error) {
334
- if (!(error instanceof Error))
335
- return null;
336
- const messages = chainMessages(error);
337
- for (const [pattern, code, reason] of REDEEM_CONSTRAINT_CODES) {
338
- if (messages.some((message) => pattern.test(message)))
339
- return rejection(code, reason);
340
- }
341
- return null;
342
- }
343
- /** A write failure that is not a refusal: the retry gave up (`conflict`), or something else (`internal`). */
344
- function writeFailureRejection(error) {
345
- // NOTE: a seat torn on a member alone with an uncaught-up device was measured answering
346
- // `internal`, not `conflict` (cadre-invite-redeemed-at-a-member-without-the-row). Both are
347
- // retryable, so the device is unaffected; if the two codes ever diverge in retryability,
348
- // find which tear `isRetriableControlWriteFailure` does not recognise.
349
- return isRetriableControlWriteFailure(error)
350
- ? rejection('conflict', 'The redemption write failed transiently; nothing was recorded')
351
- : rejection('internal', 'Internal redemption error');
352
- }
353
323
  /**
354
- * Member side: registers the handler and drives each inbound stream through the checks in
355
- * cost order — shape, connecting identity, both signatures, target device — so no database
356
- * work happens for a stranger that cannot prove anything, then admits the device to this
357
- * member's control replication (`admitDevice`), seats the row and redeems.
358
- * Hardened as the seed handler is: a concurrency cap (over it, `busy`) and a read timeout.
359
- *
360
- * Registered only by a started `CadreNode`, after its control database is up, like the
361
- * formation responder; nothing here is reachable on a node without one.
324
+ * Member side of the invitation protocol (`MemberRedemptionHandler` has the shape): after the
325
+ * request's shape, it checks the party, the connecting identity (an ed25519 peer id is an
326
+ * identity multihash of the very key, and this is the only layer that can check the pair),
327
+ * both signatures and a targeted invitation's device, then seats the row from the bundle when
328
+ * this member holds none and redeems. An issuer this member does not know as an owner is the
329
+ * one retryable refusal left: the device tries the next address, which may hold the issuer's row.
362
330
  */
363
- export class CadreInviteHandler {
331
+ export class CadreInviteHandler extends MemberRedemptionHandler {
364
332
  constructor(options) {
365
- this.registered = new Set();
366
- this.active = 0;
367
- this.options = options;
368
- this.readTimeoutMs = options.redeemReadTimeoutMs ?? DEFAULT_REDEEM_READ_TIMEOUT_MS;
369
- this.maxConcurrent = options.maxConcurrentRedemptions ?? DEFAULT_MAX_CONCURRENT_REDEMPTIONS;
370
- }
371
- /** In-flight inbound redemptions. */
372
- get activeCount() {
373
- return this.active;
374
- }
375
- /**
376
- * Register on `node`; rejects when libp2p refuses the registration. `runOnLimitedConnection`
377
- * because a phone redeems through a relay, whose connection libp2p marks limited; the
378
- * exchange is two small frames, well inside any relay's cap.
379
- */
380
- async register(node, protocolId = CADRE_INVITE_PROTOCOL) {
381
- if (this.registered.has(node))
382
- return;
383
- await node.handle(protocolId, async (rawStream, rawConnection) => {
384
- await this.handleStream(rawStream, rawConnection.remotePeer.toString());
385
- }, { runOnLimitedConnection: true });
386
- this.registered.add(node);
387
- log('cadre invitation handler registered (%s)', protocolId);
388
- }
389
- async unregister(node, protocolId = CADRE_INVITE_PROTOCOL) {
390
- if (!this.registered.has(node))
391
- return;
392
- await node.unhandle(protocolId);
393
- this.registered.delete(node);
394
- log('cadre invitation handler unregistered (%s)', protocolId);
395
- }
396
- /**
397
- * One inbound redemption: read the frame, decide, reply, close. Public as the seam the
398
- * protocol spec drives directly; `remotePeerId` is the connection's authenticated peer.
399
- */
400
- async handleStream(stream, remotePeerId) {
401
- if (this.active >= this.maxConcurrent) {
402
- log('refusing redemption from %s: %d in flight at cap %d', remotePeerId, this.active, this.maxConcurrent);
403
- await replyAndClose(stream, rejection('busy', 'Too many concurrent redemptions'), 'Cadre invite');
404
- return;
405
- }
406
- this.active++;
407
- try {
408
- const reply = await this.decide(stream, remotePeerId);
409
- log('redemption from %s: %s', remotePeerId, reply.accepted ? 'accepted' : `refused (${reply.code})`);
410
- await replyAndClose(stream, reply, 'Cadre invite');
411
- }
412
- catch (error) {
413
- log('redemption from %s failed: %o', remotePeerId, error);
414
- await replyAndClose(stream, rejection('internal', 'Internal redemption error'), 'Cadre invite');
415
- }
416
- finally {
417
- this.active--;
418
- }
419
- }
420
- /**
421
- * Read the request frame to EOF, bounded and size-capped; JSON only, shape is `decide`'s.
422
- * Throws on a frame that cannot be read, which {@link handleStream} answers `internal`:
423
- * a read timeout or a cut frame proves nothing about the invitation, so the device must
424
- * stay free to try the next address.
425
- */
426
- async readRequest(stream) {
427
- const data = await readStreamToEnd(stream, { maxBytes: MAX_CADRE_INVITE_MSG_SIZE, timeoutMs: this.readTimeoutMs, label: 'Cadre invite' });
428
- return JSON.parse(new TextDecoder().decode(decodeLengthPrefixedFrame(data, MAX_CADRE_INVITE_MSG_SIZE)));
333
+ super('Cadre invite', CADRE_INVITE_PROTOCOL, options);
334
+ this.busyRefusal = rejection('busy', 'Too many concurrent redemptions');
335
+ this.internalRefusal = rejection('internal', 'Internal redemption error');
336
+ this.malformedRefusal = rejection('invite-invalid', 'Malformed redemption request');
337
+ this.peerId = options.peerId;
338
+ this.store = options.store;
429
339
  }
430
- async decide(stream, remotePeerId) {
431
- const request = await this.readRequest(stream);
432
- const refused = this.checkRequest(request, remotePeerId);
433
- if (refused)
434
- return refused;
435
- return await this.seatAndRedeem(request, remotePeerId);
340
+ isWellFormedRequest(value) {
341
+ return isWellFormedRedeemRequest(value);
436
342
  }
437
- /**
438
- * The checks that cost no database work, in order: shape, party, connecting identity (an
439
- * ed25519 peer id is an identity multihash of the very key, and this is the only layer that
440
- * can check the pair), both signatures, and a targeted invitation's device.
441
- */
442
343
  checkRequest(request, remotePeerId) {
443
- if (!isWellFormedRedeemRequest(request)) {
444
- return rejection('invite-invalid', 'Malformed redemption request');
445
- }
446
- if (request.partyId !== this.options.partyId) {
344
+ if (request.partyId !== this.partyId) {
447
345
  return rejection('party-mismatch', 'This machine serves another cadre');
448
346
  }
449
347
  if (ed25519PublicKeyB64FromPeerId(remotePeerId) !== request.peerKey) {
450
348
  return rejection('invite-invalid', 'The peer key is not the connecting identity');
451
349
  }
452
350
  const fields = { inviteKey: request.invite.key, usageStampId: request.usageStampId, peerKey: request.peerKey };
453
- if (!verifyCadreInviteRedemption(this.options.partyId, fields, request.inviteSig, request.peerSig)) {
351
+ if (!verifyCadreInviteRedemption(this.partyId, fields, request.inviteSig, request.peerSig)) {
454
352
  return rejection('invite-invalid', 'The redemption signatures do not verify');
455
353
  }
456
354
  if (request.invite.peerId !== null && request.invite.peerId !== remotePeerId) {
@@ -458,41 +356,40 @@ export class CadreInviteHandler {
458
356
  }
459
357
  return null;
460
358
  }
359
+ readHeld(request) {
360
+ return this.store.queryCadreInvite(request.invite.key);
361
+ }
362
+ isLive(request, held) {
363
+ return this.store.isCadreInviteLive(request.invite, held);
364
+ }
461
365
  /**
462
- * Admit the device to this member's control replication (see {@link admitDeviceIfLive}),
463
- * seat the row from the bundle (its own transaction; a no-op when held), then redeem. The
464
- * admission comes first because the seat is already a write the device takes part in. An
465
- * issuer this member does not know as an owner is the one retryable refusal left: the device
466
- * tries the next address, which may hold the issuer's row.
366
+ * The two writes — the seat (its own transaction), only when this member holds no row, and
367
+ * the redemption — each answered by name when it fails. The held row is carried into the
368
+ * redemption so it is not read again in the write lock; after a fresh seat nothing is carried
369
+ * and the redemption reads the row it seated, since the bundle's copy is the requester's.
467
370
  */
468
- async seatAndRedeem(request, remotePeerId) {
469
- const release = await this.admitDeviceIfLive(request.invite, remotePeerId);
470
- try {
471
- return await this.seatAndRedeemAdmitted(request, remotePeerId);
472
- }
473
- finally {
474
- release();
475
- }
476
- }
477
- /** {@link seatAndRedeem}'s two writes, each answered by name when it fails. */
478
- async seatAndRedeemAdmitted(request, remotePeerId) {
479
- const { store } = this.options;
480
- try {
481
- await store.seatCadreInvite(request.invite);
371
+ async redeemAdmitted(request, remotePeerId, held) {
372
+ if (held === null) {
373
+ const refused = await this.seat(request, remotePeerId);
374
+ if (refused)
375
+ return refused;
482
376
  }
483
- catch (error) {
484
- if (error instanceof CadreInviteIssuerUnknownError) {
485
- return rejection('issuer-unknown', 'The invitation\'s issuer is not known as an owner here');
486
- }
487
- log('seating invitation %s from %s failed: %o', request.invite.key, remotePeerId, error);
488
- return constraintRejection(error) ?? writeFailureRejection(error);
377
+ // Built before the write: the `CadrePeer` membership view is normally warm then (the
378
+ // connection gate read it when the device connected), and the redemption's commit drops it.
379
+ // The list lacks the device's own row, which the device drops from it anyway.
380
+ const peers = await this.peersBestEffort();
381
+ const record = admittedRecord(this.partyId, remotePeerId, request);
382
+ if (record.signed === null) {
383
+ log('admitting %s without its own record signature: %s', remotePeerId, record.unsignedBecause);
489
384
  }
490
385
  try {
491
- const result = await store.redeemCadreInvite({
386
+ const result = await this.store.redeemCadreInvite({
492
387
  inviteKey: request.invite.key,
388
+ ...(held ? { held } : {}),
493
389
  peerId: remotePeerId,
494
390
  peerKey: request.peerKey,
495
- multiaddrs: sanitizeAddrs(request.multiaddrs),
391
+ multiaddrs: record.addrs,
392
+ ...(record.signed ? { record: record.signed } : {}),
496
393
  usageStampId: request.usageStampId,
497
394
  inviteSig: request.inviteSig,
498
395
  peerSig: request.peerSig
@@ -504,57 +401,31 @@ export class CadreInviteHandler {
504
401
  return rejection('invite-spent', 'The invitation has no uses left');
505
402
  }
506
403
  log('redeeming invitation %s for %s failed: %o', request.invite.key, remotePeerId, error);
507
- return constraintRejection(error) ?? writeFailureRejection(error);
404
+ return constraintRefusal(error, REDEEM_CONSTRAINT_CODES) ?? writeFailureRefusal(error);
508
405
  }
509
- // The row as held: the seat inserted this copy verbatim, or found the same row (its key
510
- // is the primary key and every column is under the issuer's signature).
511
- return { accepted: true, partyId: this.options.partyId, invite: request.invite, peers: await this.peersBestEffort() };
406
+ // The row as held: read before the write, or the bundle's copy the seat inserted verbatim
407
+ // or found already held (its key is the primary key and every column is under the issuer's
408
+ // signature).
409
+ return { accepted: true, partyId: this.partyId, invite: held ?? request.invite, peers };
512
410
  }
513
- /**
514
- * Admit the device to this member's control replication before the first write of the
515
- * exchange (the seat, when this member does not hold the row; otherwise the admission), and
516
- * resolve to the release that ends the admission. The device is not a member until the
517
- * admission is written, so only this puts it in this member's control write cohort, and it
518
- * must be there to hold the rows that admit it. It is pushed the control store first,
519
- * because a cohort node with no base revision of a block refuses a commit to it; a member
520
- * alone with the device would then have half its cohort refusing, and the write would tear
521
- * instead of committing.
522
- *
523
- * Only for an invitation still live here, judged on the row this member holds or, before
524
- * the seat, on the bundle's copy once its issuer signature verifies
525
- * (`ControlDatabase.isCadreInviteLive`): the holder of a withdrawn, expired or spent one,
526
- * or of a forged copy naming a real owner, is refused without being sent anything. The
527
- * retry of an admission already written can read as spent too and is sent nothing; it
528
- * needs nothing, since the device is a member and the ordinary catch-up reaches it.
529
- * Best-effort: a failed check or push is logged and the writes go ahead without the device
530
- * admitted, then commit or are answered retryably.
531
- */
532
- async admitDeviceIfLive(invite, remotePeerId) {
533
- const { admitDevice, store } = this.options;
534
- const notAdmitted = () => undefined;
535
- if (!admitDevice)
536
- return notAdmitted;
411
+ /** Seat the bundle's copy of the row; a refusal answered by name, or null once it is held. */
412
+ async seat(request, remotePeerId) {
537
413
  try {
538
- if (!await store.isCadreInviteLive(invite)) {
539
- log('invitation %s is not live here; %s is not admitted before the write', invite.key, remotePeerId);
540
- return notAdmitted;
541
- }
542
- // NOTE: accepted tradeoff — when a write then fails (the seat refused, the last use
543
- // taken by a race, the row expiring between this check and the write), the device keeps
544
- // a copy of a control store it was not admitted to. It holds a live owner-signed
545
- // invitation and proved possession and consent, and a seed hands an un-enrolled machine
546
- // the same information. Revisit if a control table ever carries data a non-member must not see.
547
- return await admitDevice(remotePeerId);
414
+ await this.store.seatCadreInvite(request.invite);
415
+ return null;
548
416
  }
549
417
  catch (error) {
550
- log('admitting %s before its admission write failed (writing anyway): %o', remotePeerId, error);
551
- return notAdmitted;
418
+ if (error instanceof CadreInviteIssuerUnknownError) {
419
+ return rejection('issuer-unknown', 'The invitation\'s issuer is not known as an owner here');
420
+ }
421
+ log('seating invitation %s from %s failed: %o', request.invite.key, remotePeerId, error);
422
+ return constraintRefusal(error, REDEEM_CONSTRAINT_CODES) ?? writeFailureRefusal(error);
552
423
  }
553
424
  }
554
425
  /** Dial hints are an optimization; a failed read costs the device its hints, not its membership. */
555
426
  async peersBestEffort() {
556
427
  try {
557
- return await this.options.store.querySeedPeers();
428
+ return await this.store.querySeedPeers(this.peerId);
558
429
  }
559
430
  catch (error) {
560
431
  log('peer projection for the reply failed (continuing without hints): %o', error);
@@ -562,213 +433,52 @@ export class CadreInviteHandler {
562
433
  }
563
434
  }
564
435
  }
565
- /**
566
- * Dial `addr`, open one stream to it, send the request, half-close, read the one reply frame.
567
- * `signal` is the per-address deadline: it cancels the dial and resets a live stream.
568
- *
569
- * The address is dialed outside libp2p's dial queue (`dialAddr`), so another dial of the
570
- * member in flight cannot capture this attempt; an open connection is still reused.
571
- */
572
- async function exchangeRedemption(node, addr, request, signal, budgetMs, protocolId) {
573
- const connection = await dialAddr(node, addr, signal, 'Cadre invitation redemption');
574
- // `runOnLimitedConnection`: a member may be reachable only through a relay (see `register`).
575
- const rawStream = await connection.newStream(protocolId, { runOnLimitedConnection: true, signal });
576
- return await exchangeFrame(rawStream, signal, request, async (stream) => {
577
- const data = await readStreamToEnd(stream, { maxBytes: MAX_CADRE_INVITE_MSG_SIZE, timeoutMs: budgetMs, label: 'Cadre invite reply' });
578
- return decodeReply(JSON.parse(new TextDecoder().decode(decodeLengthPrefixedFrame(data, MAX_CADRE_INVITE_MSG_SIZE))));
579
- }, 'Cadre invitation redemption aborted by its deadline');
580
- }
581
- function asError(error) {
582
- if (error instanceof Error)
583
- return error;
584
- // A WebSocket transport rejects a refused dial with an `ErrorEvent`, whose text is its `message`.
585
- const message = error?.message;
586
- return new Error(typeof message === 'string' && message.length > 0 ? message : String(error), { cause: error });
587
- }
588
- /**
589
- * Run `op` with a signal that aborts when either `deadline` or the caller's `signal` does.
590
- * Relayed by hand rather than with `AbortSignal.any`, which the phone runtimes lack.
591
- */
592
- async function underDeadlineAndCaller(deadline, caller, op) {
593
- if (!caller)
594
- return await op(deadline);
595
- const either = new AbortController();
596
- const abort = () => either.abort();
597
- deadline.addEventListener('abort', abort, { once: true });
598
- caller.addEventListener('abort', abort, { once: true });
599
- // An abort between the caller's last check and the listener above fired no event.
600
- if (caller.aborted)
601
- abort();
602
- try {
603
- return await op(either.signal);
604
- }
605
- finally {
606
- deadline.removeEventListener('abort', abort);
607
- caller.removeEventListener('abort', abort);
608
- }
436
+ /** The invitation's half of the shared dial loop: its label, protocol, bundle, codec, checks and errors. */
437
+ function inviteRedemption(options) {
438
+ return {
439
+ label: LABEL,
440
+ protocolId: options.protocolId ?? CADRE_INVITE_PROTOCOL,
441
+ members: options.invitation.members,
442
+ request: options.request,
443
+ decodeAccepted,
444
+ refusal: (code, reason) => new CadreInviteRejectedError(code, reason),
445
+ verifyAccepted: (reply) => verifyRedeemReply(reply, options.invitation, options.isTrustedIssuer),
446
+ unreachable: (outcomes) => new CadreInviteUnreachableError(outcomes),
447
+ ...(options.linkRoundTripMs !== undefined ? { linkRoundTripMs: options.linkRoundTripMs } : {}),
448
+ ...(options.addressBudgetMs !== undefined ? { addressBudgetMs: options.addressBudgetMs } : {}),
449
+ ...(options.signal ? { signal: options.signal } : {}),
450
+ };
609
451
  }
610
452
  /**
611
453
  * Device side: try the bundle's addresses one at a time, each under its own deadline, and
612
- * stop at the first acceptance or the first final refusal. A dial failure, a malformed
613
- * reply or a retryable refusal moves to the next address.
614
- *
615
- * Every address gets the SAME request, with one `usageStampId`: `CadreInviteUsage` is keyed
616
- * by it, so a member the device gave up on that was still committing cannot admit it twice,
617
- * and a retry at the next member after a dropped reply is answered as already a member.
454
+ * stop at the first acceptance or the first final refusal (`dialRedemption` in
455
+ * `member-redemption.ts`). Every address gets the SAME request, with one `usageStampId`:
456
+ * `CadreInviteUsage` is keyed by it, so a member the device gave up on that was still
457
+ * committing cannot admit it twice, and a retry at the next member after a dropped reply is
458
+ * answered as already a member.
618
459
  *
619
460
  * @throws {CadreInviteRejectedError} on a final refusal (its `retryable` is false).
620
461
  * @throws {CadreInviteReplyInvalidError} on an acceptance that fails {@link verifyRedeemReply}.
621
462
  * @throws {CadreInviteUnreachableError} when every address was tried and none accepted.
622
463
  * @throws the reason of `options.signal` once it aborts.
623
464
  */
624
- export async function redeemAtMembers(node, options) {
625
- const protocolId = options.protocolId ?? CADRE_INVITE_PROTOCOL;
626
- const budgetMs = options.addressBudgetMs ?? relayedRequestBudgetMs(options.linkRoundTripMs);
627
- const outcomes = [];
628
- for (const addr of options.invitation.members) {
629
- options.signal?.throwIfAborted();
630
- // Between attempts: a relayed address's relay leg goes through libp2p's dial queue, which
631
- // needs a macrotask to drop an aborted job (see `tryAddrsInTurn`).
632
- if (outcomes.length > 0)
633
- await nextMacrotask();
634
- let parsed;
635
- try {
636
- parsed = multiaddr(addr);
637
- }
638
- catch (error) {
639
- outcomes.push({ addr, error: new Error('not a multiaddr', { cause: error }) });
640
- continue;
641
- }
642
- try {
643
- const reply = await withDeadline(budgetMs, `Cadre invitation redemption via ${addr}`, (deadline) => underDeadlineAndCaller(deadline, options.signal, (signal) => exchangeRedemption(node, parsed, options.request, signal, budgetMs, protocolId)));
644
- if (!reply.accepted) {
645
- const refusal = new CadreInviteRejectedError(reply.code, reply.reason);
646
- if (!refusal.retryable)
647
- throw refusal;
648
- log('redemption via %s refused retryably (%s); trying the next address', addr, refusal.code);
649
- outcomes.push({ addr, error: refusal });
650
- continue;
651
- }
652
- verifyRedeemReply(reply, options.invitation, options.isTrustedIssuer);
653
- return { reply, memberPeerId: trailingPeerId(parsed), memberAddr: addr };
654
- }
655
- catch (error) {
656
- if (error instanceof CadreInviteRejectedError || error instanceof CadreInviteReplyInvalidError)
657
- throw error;
658
- // The caller's abort cut this attempt short; its reason, not the cut, is the answer.
659
- options.signal?.throwIfAborted();
660
- log('redemption via %s failed before an answer (%s); trying the next address', addr, asError(error).message);
661
- outcomes.push({ addr, error: asError(error) });
662
- }
663
- }
664
- throw new CadreInviteUnreachableError(outcomes);
665
- }
666
- /** Pause after the first pass in which no member accepted; doubled after each further one. */
667
- const REDEEM_PASS_FIRST_PAUSE_MS = 5000;
668
- /** Longest pause between passes. */
669
- const REDEEM_PASS_MAX_PAUSE_MS = 60000;
670
- /**
671
- * Device side, in passes: one {@link redeemAtMembers} pass over the bundle's addresses, or,
672
- * with `waitForMember`, as many as it takes, pausing between them (5 s after the first,
673
- * doubling to 60 s, never past the invitation's expiry). Every pass sends the same signed
674
- * request, so a member that admitted the device on a pass whose reply was lost answers a later
675
- * pass as already a member, with no second usage row.
676
- *
677
- * A wait ends at the first acceptance, at a final refusal or an invalid reply (thrown as
678
- * {@link redeemAtMembers} throws them), when the invitation expires by this device's clock
679
- * (thrown as the `invite-spent` refusal a member would give), when `signal` aborts (its reason
680
- * thrown), and when `node` stops. A bundle that names no address has nothing to wait for and
681
- * throws at once.
682
- */
683
- export async function redeemInPasses(node, options) {
684
- for (let pass = 1;; pass++) {
685
- if (options.waitForMember) {
686
- // One pass leaves expiry to the member, whose clock decides; a wait has nothing else to end it.
687
- refuseIfExpired(options.invitation.invite);
688
- refuseIfStopped(node);
689
- }
690
- reportProgress(options.onProgress, { state: 'redeeming', pass });
691
- try {
692
- return await redeemAtMembers(node, options);
693
- }
694
- catch (error) {
695
- if (!options.waitForMember || !(error instanceof CadreInviteUnreachableError) || error.outcomes.length === 0)
696
- throw error;
697
- const pauseMs = pauseAfterPass(pass, options.invitation.invite);
698
- reportProgress(options.onProgress, {
699
- state: 'waiting', pass, retryAtMs: Date.now() + pauseMs, reason: waitReason(error.outcomes), outcomes: error.outcomes
700
- });
701
- log('redemption pass %d found no member ready; the next pass in %d ms', pass, pauseMs);
702
- await pause(pauseMs, options.signal, node);
703
- }
704
- }
705
- }
706
- // NOTE: judged by this device's clock, so one running ahead ends a wait before the members
707
- // would refuse it. If waits are seen ending early on skewed phones, allow a grace margin here.
708
- function refuseIfExpired(invite) {
709
- const expiresAtMs = parseNullableStoredDatetimeMs(invite.expiresAt);
710
- if (expiresAtMs !== null && expiresAtMs <= Date.now()) {
711
- throw new CadreInviteRejectedError('invite-spent', 'the invitation expired before a member accepted it');
712
- }
713
- }
714
- /** A stopped node's every dial fails at once, which would otherwise read as "no member answered" until expiry. */
715
- function refuseIfStopped(node) {
716
- if (node.status !== 'started') {
717
- throw new Error(`Cadre invitation redemption stopped waiting: the node is ${node.status}`);
718
- }
719
- }
720
- /** The pause after pass `pass`, cut short at the invitation's expiry so the next check ends the wait then. */
721
- function pauseAfterPass(pass, invite) {
722
- const backoffMs = Math.min(REDEEM_PASS_FIRST_PAUSE_MS * 2 ** (pass - 1), REDEEM_PASS_MAX_PAUSE_MS);
723
- const expiresAtMs = parseNullableStoredDatetimeMs(invite.expiresAt);
724
- return expiresAtMs === null ? backoffMs : Math.max(0, Math.min(backoffMs, expiresAtMs - Date.now()));
725
- }
726
- /** Any refusal means a member answered and is not ready yet; otherwise none answered at all. */
727
- function waitReason(outcomes) {
728
- return outcomes.some((outcome) => outcome.error instanceof CadreInviteRejectedError) ? 'member-not-ready' : 'no-member-answered';
729
- }
730
- /** A UI callback must not break the join. */
731
- function reportProgress(onProgress, progress) {
732
- if (!onProgress)
733
- return;
734
- try {
735
- onProgress(progress);
736
- }
737
- catch (error) {
738
- log('redemption progress callback threw (ignored): %o', error);
739
- }
465
+ export function redeemAtMembers(node, options) {
466
+ return dialRedemption(node, inviteRedemption(options));
740
467
  }
741
468
  /**
742
- * Wait `ms`, or reject with `signal`'s reason as soon as it aborts. Ends early, resolving, when
743
- * `node` stops (or is already stopped), so the next pass's stop check ends the wait without a
744
- * timer holding the process open. Listeners and timer are cleared however it ends.
469
+ * Device side, in passes (`dialRedemptionInPasses` in `member-redemption.ts`): one
470
+ * {@link redeemAtMembers} pass, or, with `waitForMember`, as many as it takes, pausing between
471
+ * them and never past the invitation's expiry, which by this device's clock is thrown as the
472
+ * `invite-spent` refusal a member would give.
745
473
  */
746
- function pause(ms, signal, node) {
747
- return new Promise((resolve, reject) => {
748
- if (signal?.aborted) {
749
- reject(signal.reason);
750
- return;
751
- }
752
- if (node.status !== 'started') {
753
- resolve();
754
- return;
755
- }
756
- const settle = () => {
757
- clearTimeout(timer);
758
- signal?.removeEventListener('abort', onAbort);
759
- node.removeEventListener('stop', onStop);
760
- };
761
- const onAbort = () => {
762
- settle();
763
- reject(signal.reason);
764
- };
765
- const onStop = () => {
766
- settle();
767
- resolve();
768
- };
769
- const timer = setTimeout(onStop, ms);
770
- signal?.addEventListener('abort', onAbort, { once: true });
771
- node.addEventListener('stop', onStop, { once: true });
772
- });
474
+ export function redeemInPasses(node, options) {
475
+ const passes = {
476
+ ...inviteRedemption(options),
477
+ waitForMember: options.waitForMember,
478
+ expiresAt: options.invitation.invite.expiresAt,
479
+ expired: () => new CadreInviteRejectedError('invite-spent', 'the invitation expired before a member accepted it'),
480
+ ...(options.onProgress ? { onProgress: options.onProgress } : {}),
481
+ };
482
+ return dialRedemptionInPasses(node, passes);
773
483
  }
774
484
  //# sourceMappingURL=cadre-invite-protocol.js.map