@serfab/cadre-core 1.14.0 → 1.15.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 (171) hide show
  1. package/README.md +293 -274
  2. package/dist/cadre-invite-protocol.d.ts +64 -29
  3. package/dist/cadre-invite-protocol.js +207 -38
  4. package/dist/cadre-invite-protocol.js.map +1 -1
  5. package/dist/cadre-join-request.d.ts +42 -0
  6. package/dist/cadre-join-request.js +144 -0
  7. package/dist/cadre-join-request.js.map +1 -0
  8. package/dist/cadre-node.d.ts +375 -79
  9. package/dist/cadre-node.js +758 -186
  10. package/dist/cadre-node.js.map +1 -1
  11. package/dist/control-database.d.ts +92 -12
  12. package/dist/control-database.js +323 -142
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-schema.d.ts +1 -1
  15. package/dist/control-schema.js +19 -3
  16. package/dist/control-schema.js.map +1 -1
  17. package/dist/ed25519-key.d.ts +7 -9
  18. package/dist/ed25519-key.js +20 -10
  19. package/dist/ed25519-key.js.map +1 -1
  20. package/dist/fetch-impl.d.ts +12 -0
  21. package/dist/fetch-impl.js +23 -0
  22. package/dist/fetch-impl.js.map +1 -0
  23. package/dist/file-durable-slot.js +2 -1
  24. package/dist/file-durable-slot.js.map +1 -1
  25. package/dist/file-safe-name.d.ts +25 -0
  26. package/dist/file-safe-name.js +29 -0
  27. package/dist/file-safe-name.js.map +1 -0
  28. package/dist/formation-approval.js +5 -11
  29. package/dist/formation-approval.js.map +1 -1
  30. package/dist/fs-atomic.d.ts +0 -19
  31. package/dist/fs-atomic.js +3 -24
  32. package/dist/fs-atomic.js.map +1 -1
  33. package/dist/index.d.ts +12 -5
  34. package/dist/index.js +31 -6
  35. package/dist/index.js.map +1 -1
  36. package/dist/invitation-bootstrap.d.ts +50 -12
  37. package/dist/invitation-bootstrap.js +76 -13
  38. package/dist/invitation-bootstrap.js.map +1 -1
  39. package/dist/joined-strand-store.d.ts +7 -0
  40. package/dist/joined-strand-store.js +9 -0
  41. package/dist/joined-strand-store.js.map +1 -1
  42. package/dist/key-store-file.js +2 -1
  43. package/dist/key-store-file.js.map +1 -1
  44. package/dist/link-budget.d.ts +9 -4
  45. package/dist/link-budget.js +9 -4
  46. package/dist/link-budget.js.map +1 -1
  47. package/dist/machine-removal.d.ts +78 -0
  48. package/dist/machine-removal.js +166 -0
  49. package/dist/machine-removal.js.map +1 -0
  50. package/dist/membership-connection-gater.d.ts +6 -5
  51. package/dist/membership-connection-gater.js +6 -5
  52. package/dist/membership-connection-gater.js.map +1 -1
  53. package/dist/node-claim-payload.d.ts +55 -0
  54. package/dist/node-claim-payload.js +108 -4
  55. package/dist/node-claim-payload.js.map +1 -1
  56. package/dist/node-default-transports.d.ts +27 -0
  57. package/dist/node-default-transports.js +35 -0
  58. package/dist/node-default-transports.js.map +1 -0
  59. package/dist/node-transports.d.ts +30 -0
  60. package/dist/node-transports.js +40 -0
  61. package/dist/node-transports.js.map +1 -0
  62. package/dist/peer-dial.d.ts +124 -27
  63. package/dist/peer-dial.js +254 -30
  64. package/dist/peer-dial.js.map +1 -1
  65. package/dist/peer-join-backfill.js +6 -0
  66. package/dist/peer-join-backfill.js.map +1 -1
  67. package/dist/peer-record.d.ts +6 -45
  68. package/dist/peer-record.js +8 -50
  69. package/dist/peer-record.js.map +1 -1
  70. package/dist/primary-lan-address.d.ts +8 -0
  71. package/dist/primary-lan-address.js +46 -0
  72. package/dist/primary-lan-address.js.map +1 -0
  73. package/dist/provider/client.d.ts +131 -0
  74. package/dist/provider/client.js +226 -0
  75. package/dist/provider/client.js.map +1 -0
  76. package/dist/provider/descriptor.d.ts +41 -0
  77. package/dist/provider/descriptor.js +103 -0
  78. package/dist/provider/descriptor.js.map +1 -0
  79. package/dist/provider/index.d.ts +9 -0
  80. package/dist/provider/index.js +10 -0
  81. package/dist/provider/index.js.map +1 -0
  82. package/dist/provider/oauth.d.ts +125 -0
  83. package/dist/provider/oauth.js +199 -0
  84. package/dist/provider/oauth.js.map +1 -0
  85. package/dist/provider/rent-node.d.ts +101 -0
  86. package/dist/provider/rent-node.js +220 -0
  87. package/dist/provider/rent-node.js.map +1 -0
  88. package/dist/relay-addrs.d.ts +9 -4
  89. package/dist/relay-addrs.js +9 -4
  90. package/dist/relay-addrs.js.map +1 -1
  91. package/dist/relay-reservation.d.ts +51 -16
  92. package/dist/relay-reservation.js +107 -24
  93. package/dist/relay-reservation.js.map +1 -1
  94. package/dist/seed-bootstrap.d.ts +22 -5
  95. package/dist/seed-bootstrap.js +48 -25
  96. package/dist/seed-bootstrap.js.map +1 -1
  97. package/dist/strand-commit-durability.d.ts +54 -0
  98. package/dist/strand-commit-durability.js +74 -0
  99. package/dist/strand-commit-durability.js.map +1 -0
  100. package/dist/strand-database.d.ts +23 -8
  101. package/dist/strand-database.js +41 -13
  102. package/dist/strand-database.js.map +1 -1
  103. package/dist/strand-first-sync-gate.d.ts +7 -1
  104. package/dist/strand-first-sync-gate.js +7 -1
  105. package/dist/strand-first-sync-gate.js.map +1 -1
  106. package/dist/strand-formation-protocol.js +4 -1
  107. package/dist/strand-formation-protocol.js.map +1 -1
  108. package/dist/strand-fret-addrs.d.ts +19 -1
  109. package/dist/strand-fret-addrs.js +27 -9
  110. package/dist/strand-fret-addrs.js.map +1 -1
  111. package/dist/strand-instance-manager.d.ts +81 -19
  112. package/dist/strand-instance-manager.js +216 -105
  113. package/dist/strand-instance-manager.js.map +1 -1
  114. package/dist/strand-move.d.ts +124 -0
  115. package/dist/strand-move.js +438 -0
  116. package/dist/strand-move.js.map +1 -0
  117. package/dist/strand-network-config.js +7 -6
  118. package/dist/strand-network-config.js.map +1 -1
  119. package/dist/strand-peer-redial.d.ts +113 -0
  120. package/dist/strand-peer-redial.js +236 -0
  121. package/dist/strand-peer-redial.js.map +1 -0
  122. package/dist/strand-wake-protocol.js +10 -7
  123. package/dist/strand-wake-protocol.js.map +1 -1
  124. package/dist/types.d.ts +212 -4
  125. package/dist/types.js +10 -0
  126. package/dist/types.js.map +1 -1
  127. package/package.json +18 -10
  128. package/src/cadre-invite-protocol.ts +233 -45
  129. package/src/cadre-join-request.ts +167 -0
  130. package/src/cadre-node.ts +10003 -9381
  131. package/src/control-database.ts +4663 -4451
  132. package/src/control-schema.ts +19 -3
  133. package/src/ed25519-key.ts +20 -10
  134. package/src/fetch-impl.ts +23 -0
  135. package/src/file-durable-slot.ts +64 -63
  136. package/src/file-safe-name.ts +32 -0
  137. package/src/formation-approval.ts +604 -610
  138. package/src/fs-atomic.ts +92 -117
  139. package/src/index.ts +789 -722
  140. package/src/invitation-bootstrap.ts +91 -13
  141. package/src/joined-strand-store.ts +508 -498
  142. package/src/key-store-file.ts +142 -141
  143. package/src/link-budget.ts +9 -4
  144. package/src/machine-removal.ts +216 -0
  145. package/src/membership-connection-gater.ts +638 -637
  146. package/src/node-claim-payload.ts +138 -4
  147. package/src/node-default-transports.ts +38 -0
  148. package/src/node-transports.ts +49 -0
  149. package/src/peer-dial.ts +604 -336
  150. package/src/peer-join-backfill.ts +6 -0
  151. package/src/peer-record.ts +159 -202
  152. package/src/primary-lan-address.ts +47 -0
  153. package/src/provider/client.ts +326 -0
  154. package/src/provider/descriptor.ts +129 -0
  155. package/src/provider/index.ts +59 -0
  156. package/src/provider/oauth.ts +285 -0
  157. package/src/provider/rent-node.ts +275 -0
  158. package/src/relay-addrs.ts +9 -4
  159. package/src/relay-reservation.ts +1211 -1121
  160. package/src/seed-bootstrap.ts +1502 -1471
  161. package/src/strand-commit-durability.ts +116 -0
  162. package/src/strand-database.ts +46 -14
  163. package/src/strand-first-sync-gate.ts +7 -1
  164. package/src/strand-formation-protocol.ts +1039 -1036
  165. package/src/strand-fret-addrs.ts +179 -145
  166. package/src/strand-instance-manager.ts +2026 -1883
  167. package/src/strand-move.ts +585 -0
  168. package/src/strand-network-config.ts +7 -6
  169. package/src/strand-peer-redial.ts +307 -0
  170. package/src/strand-wake-protocol.ts +10 -7
  171. package/src/types.ts +220 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serfab/cadre-core",
3
- "version": "1.14.0",
3
+ "version": "1.15.0",
4
4
  "type": "module",
5
5
  "description": "Core library for Sereus cadre nodes - manages control networks and strand participation",
6
6
  "main": "dist/index.js",
@@ -24,6 +24,10 @@
24
24
  "types": "./dist/key-store-file.d.ts",
25
25
  "import": "./dist/key-store-file.js"
26
26
  },
27
+ "./primary-lan-address": {
28
+ "types": "./dist/primary-lan-address.d.ts",
29
+ "import": "./dist/primary-lan-address.js"
30
+ },
27
31
  "./trusted-owner-store-file": {
28
32
  "types": "./dist/trusted-owner-store-file.d.ts",
29
33
  "import": "./dist/trusted-owner-store-file.js"
@@ -43,6 +47,10 @@
43
47
  "./push-node": {
44
48
  "types": "./dist/push-node.d.ts",
45
49
  "import": "./dist/push-node.js"
50
+ },
51
+ "./node-default-transports": {
52
+ "types": "./dist/node-default-transports.d.ts",
53
+ "import": "./dist/node-default-transports.js"
46
54
  }
47
55
  },
48
56
  "repository": {
@@ -70,14 +78,11 @@
70
78
  "devDependencies": {
71
79
  "@chainsafe/libp2p-noise": "^17.0.0",
72
80
  "@chainsafe/libp2p-yamux": "^8.0.1",
73
- "@libp2p/circuit-relay-v2": "^4.2.13",
74
81
  "@libp2p/identify": "4.1.14",
75
82
  "@libp2p/logger": "^6.2.13",
76
83
  "@libp2p/peer-store": "^12.0.28",
77
- "@libp2p/tcp": "^11.0.28",
78
84
  "@libp2p/webrtc": "6.0.33",
79
- "@libp2p/websockets": "^10.1.21",
80
- "@optimystic/db-p2p-storage-fs": "^1.12.0",
85
+ "@optimystic/db-p2p-storage-fs": "^1.13.0",
81
86
  "@types/debug": "^4.1.12",
82
87
  "@types/node": "^25.0.10",
83
88
  "@vitest/coverage-v8": "^4.0.17",
@@ -88,18 +93,21 @@
88
93
  "vitest": "^4.0.17"
89
94
  },
90
95
  "dependencies": {
96
+ "@libp2p/circuit-relay-v2": "^4.2.13",
91
97
  "@libp2p/crypto": "^5.1.23",
92
98
  "@libp2p/interface": "^3.3.0",
93
99
  "@libp2p/peer-id": "^6.0.15",
94
100
  "@libp2p/peer-record": "^9.0.16",
101
+ "@libp2p/tcp": "^11.0.28",
102
+ "@libp2p/websockets": "^10.1.21",
95
103
  "@multiformats/multiaddr": "^13.0.3",
96
104
  "@noble/hashes": "^2.0.0",
97
- "@optimystic/db-core": "^1.12.0",
98
- "@optimystic/db-p2p": "^1.12.0",
99
- "@optimystic/quereus-plugin-crypto": "^1.12.0",
100
- "@optimystic/quereus-plugin-optimystic": "^1.12.0",
105
+ "@optimystic/db-core": "^1.13.0",
106
+ "@optimystic/db-p2p": "^1.13.0",
107
+ "@optimystic/quereus-plugin-crypto": "^1.13.0",
108
+ "@optimystic/quereus-plugin-optimystic": "^1.13.0",
101
109
  "@quereus/quereus": "^4.20.1",
102
- "@serfab/quereus-plugin-sereus": "^1.14.0",
110
+ "@serfab/quereus-plugin-sereus": "^1.15.0",
103
111
  "debug": "^4.4.3",
104
112
  "libp2p": "^3.3.11",
105
113
  "uint8arrays": "^6.1.1"
@@ -31,15 +31,16 @@ import {
31
31
  cadreInviteConsentMessage,
32
32
  CadreInviteIssuerUnknownError,
33
33
  InvitationExhaustedError,
34
+ parseNullableStoredDatetimeMs,
34
35
  type ControlDatabase
35
36
  } from './control-database.js';
36
37
  import { verifyCadreInviteRow, verifyCadreInviteRedemption } from './peer-authorization.js';
37
38
  import { ed25519PublicKeyB64FromPeerId, requireEd25519PublicKeyB64 } from './ed25519-key.js';
38
- import { trailingPeerId } from './peer-record.js';
39
- import { nextMacrotask } from './peer-dial.js';
39
+ import { dialAddr, nextMacrotask, trailingPeerId } from './peer-dial.js';
40
40
  import { chainMessages } from './control-retry.js';
41
41
  import { isRetriableControlWriteFailure } from './control-write-retry.js';
42
- import type { CadreInviteRow, SeedPeer } from './types.js';
42
+ import { CADRE_INVITATION_PREFIX, CADRE_JOIN_REQUEST_PREFIX } from './cadre-join-request.js';
43
+ import type { CadreInviteRow, CadreJoinProgress, SeedPeer } from './types.js';
43
44
 
44
45
  const log = debug('sereus:cadre:invite-proto');
45
46
 
@@ -108,29 +109,42 @@ export interface CadreInvitation {
108
109
  */
109
110
  ownerKeys: string[];
110
111
  /**
111
- * Multiaddrs ending in `/p2p/<peerId>`: the issuer's own first, then up to
112
- * `INVITATION_SIBLING_MACHINES` other members (`invitation-bootstrap.ts`). The device
113
- * tries them in order.
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.
114
115
  */
115
116
  members: string[];
116
117
  }
117
118
 
118
- /** Encode a bundle for out-of-band delivery: base64url over JSON, as seeds and open invitations are. */
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
+ */
119
124
  export function encodeCadreInvitation(invitation: CadreInvitation): string {
120
- return uint8ArrayToString(new TextEncoder().encode(JSON.stringify(invitation)), 'base64url');
125
+ return `${CADRE_INVITATION_PREFIX}${uint8ArrayToString(new TextEncoder().encode(JSON.stringify(invitation)), 'base64url')}`;
121
126
  }
122
127
 
123
128
  /**
124
129
  * Decode a bundle, refusing anything that is not a well-formed version-1
125
- * {@link CadreInvitation}. Standalone so a client without a node (a settings screen that
126
- * wants to show which cadre an invitation joins) can decode one.
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.
127
133
  *
128
- * @throws on a string that is not base64url JSON, or decodes to another shape or version.
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.
129
136
  */
130
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
+ }
131
145
  let parsed: unknown;
132
146
  try {
133
- parsed = JSON.parse(new TextDecoder().decode(uint8ArrayFromString(encoded.trim(), 'base64url')));
147
+ parsed = JSON.parse(new TextDecoder().decode(uint8ArrayFromString(trimmed.slice(CADRE_INVITATION_PREFIX.length), 'base64url')));
134
148
  } catch (error) {
135
149
  throw new Error('Cadre invitation does not decode as base64url JSON', { cause: error });
136
150
  }
@@ -512,12 +526,14 @@ export interface CadreInviteHandlerOptions {
512
526
  partyId: string;
513
527
  store: CadreInviteStore;
514
528
  /**
515
- * Push this member's control store to the device and resolve once the push has finished,
516
- * called after the request verified and before the row is seated and the admission is
517
- * written. Absent, the writes go ahead with the device holding nothing; see
518
- * `catchUpDeviceIfLive` for why that tears on a member alone with the device.
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`.
519
535
  */
520
- catchUpDevice?: (peerId: string) => Promise<void>;
536
+ admitDevice?: (peerId: string) => Promise<() => void>;
521
537
  /** Time to wait for the request frame; default {@link DEFAULT_REDEEM_READ_TIMEOUT_MS}. */
522
538
  redeemReadTimeoutMs?: number;
523
539
  /** Cap on concurrent inbound redemptions; default {@link DEFAULT_MAX_CONCURRENT_REDEMPTIONS}. */
@@ -555,8 +571,8 @@ function writeFailureRejection(error: unknown): CadreInviteRejection {
555
571
  /**
556
572
  * Member side: registers the handler and drives each inbound stream through the checks in
557
573
  * cost order — shape, connecting identity, both signatures, target device — so no database
558
- * work happens for a stranger that cannot prove anything, then hands the device the control
559
- * store (`catchUpDevice`), seats the row and redeems.
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.
560
576
  * Hardened as the seed handler is: a concurrency cap (over it, `busy`) and a read timeout.
561
577
  *
562
578
  * Registered only by a started `CadreNode`, after its control database is up, like the
@@ -668,15 +684,24 @@ export class CadreInviteHandler {
668
684
  }
669
685
 
670
686
  /**
671
- * Hand the device this member's control store, seat the row from the bundle (its own
672
- * transaction; a no-op when held), then redeem. The push comes first because the seat is
673
- * already a write the device takes part in (see {@link catchUpDeviceIfLive}). An issuer this
674
- * member does not know as an owner is the one retryable refusal left: the device tries the
675
- * next address, which may hold the issuer's row.
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.
676
692
  */
677
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> {
678
704
  const { store } = this.options;
679
- await this.catchUpDeviceIfLive(request.invite, remotePeerId);
680
705
  try {
681
706
  await store.seatCadreInvite(request.invite);
682
707
  } catch (error) {
@@ -710,12 +735,14 @@ export class CadreInviteHandler {
710
735
  }
711
736
 
712
737
  /**
713
- * Push this member's control store to the device before the first write of the exchange
714
- * (the seat, when this member does not hold the row; otherwise the admission). The device
715
- * joined this member's control write cohort when it connected, and a cohort node with no
716
- * base revision of a block refuses a commit to it; a member alone with the device then has
717
- * half its cohort refusing, and the write tears instead of committing. With the store in
718
- * hand the device holds the write too.
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.
719
746
  *
720
747
  * Only for an invitation still live here, judged on the row this member holds or, before
721
748
  * the seat, on the bundle's copy once its issuer signature verifies
@@ -723,25 +750,27 @@ export class CadreInviteHandler {
723
750
  * or of a forged copy naming a real owner, is refused without being sent anything. The
724
751
  * retry of an admission already written can read as spent too and is sent nothing; it
725
752
  * needs nothing, since the device is a member and the ordinary catch-up reaches it.
726
- * Best-effort: a failed check or push is logged and the writes go ahead, then commit or are
727
- * answered retryably.
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.
728
755
  */
729
- private async catchUpDeviceIfLive(invite: CadreInviteRow, remotePeerId: string): Promise<void> {
730
- const { catchUpDevice, store } = this.options;
731
- if (!catchUpDevice) return;
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;
732
760
  try {
733
761
  if (!await store.isCadreInviteLive(invite)) {
734
- log('invitation %s is not live here; %s is not caught up before the write', invite.key, remotePeerId);
735
- return;
762
+ log('invitation %s is not live here; %s is not admitted before the write', invite.key, remotePeerId);
763
+ return notAdmitted;
736
764
  }
737
765
  // NOTE: accepted tradeoff — when a write then fails (the seat refused, the last use
738
766
  // taken by a race, the row expiring between this check and the write), the device keeps
739
767
  // a copy of a control store it was not admitted to. It holds a live owner-signed
740
768
  // invitation and proved possession and consent, and a seed hands an un-enrolled machine
741
769
  // the same information. Revisit if a control table ever carries data a non-member must not see.
742
- await catchUpDevice(remotePeerId);
770
+ return await admitDevice(remotePeerId);
743
771
  } catch (error) {
744
- log('catching up %s before its admission failed (writing anyway): %o', remotePeerId, error);
772
+ log('admitting %s before its admission write failed (writing anyway): %o', remotePeerId, error);
773
+ return notAdmitted;
745
774
  }
746
775
  }
747
776
 
@@ -774,6 +803,8 @@ export interface RedeemAtMembersOptions {
774
803
  /** Per-address deadline override; default derived from `linkRoundTripMs`. */
775
804
  addressBudgetMs?: number;
776
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;
777
808
  }
778
809
 
779
810
  /** What a successful redemption yields the device. */
@@ -786,8 +817,11 @@ export interface CadreInviteRedemption {
786
817
  }
787
818
 
788
819
  /**
789
- * Open one stream to `addr`, send the request, half-close, read the one reply frame.
820
+ * Dial `addr`, open one stream to it, send the request, half-close, read the one reply frame.
790
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.
791
825
  */
792
826
  async function exchangeRedemption(
793
827
  node: Libp2p,
@@ -797,8 +831,9 @@ async function exchangeRedemption(
797
831
  budgetMs: number,
798
832
  protocolId: string
799
833
  ): Promise<CadreInviteAccepted | CadreInviteRefusalFrame> {
834
+ const connection = await dialAddr(node, addr, signal, 'Cadre invitation redemption');
800
835
  // `runOnLimitedConnection`: a member may be reachable only through a relay (see `register`).
801
- const rawStream = await node.dialProtocol(addr, protocolId, { runOnLimitedConnection: true, signal });
836
+ const rawStream = await connection.newStream(protocolId, { runOnLimitedConnection: true, signal });
802
837
  return await exchangeFrame(
803
838
  rawStream as unknown as ControlStream,
804
839
  signal,
@@ -812,7 +847,34 @@ async function exchangeRedemption(
812
847
  }
813
848
 
814
849
  function asError(error: unknown): Error {
815
- return error instanceof Error ? error : new Error(String(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
+ }
816
878
  }
817
879
 
818
880
  /**
@@ -827,13 +889,16 @@ function asError(error: unknown): Error {
827
889
  * @throws {CadreInviteRejectedError} on a final refusal (its `retryable` is false).
828
890
  * @throws {CadreInviteReplyInvalidError} on an acceptance that fails {@link verifyRedeemReply}.
829
891
  * @throws {CadreInviteUnreachableError} when every address was tried and none accepted.
892
+ * @throws the reason of `options.signal` once it aborts.
830
893
  */
831
894
  export async function redeemAtMembers(node: Libp2p, options: RedeemAtMembersOptions): Promise<CadreInviteRedemption> {
832
895
  const protocolId = options.protocolId ?? CADRE_INVITE_PROTOCOL;
833
896
  const budgetMs = options.addressBudgetMs ?? relayedRequestBudgetMs(options.linkRoundTripMs);
834
897
  const outcomes: CadreInviteAddressOutcome[] = [];
835
898
  for (const addr of options.invitation.members) {
836
- // Between attempts: libp2p's dial queue needs a macrotask to drop an aborted job.
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`).
837
902
  if (outcomes.length > 0) await nextMacrotask();
838
903
  let parsed: Multiaddr;
839
904
  try {
@@ -844,7 +909,8 @@ export async function redeemAtMembers(node: Libp2p, options: RedeemAtMembersOpti
844
909
  }
845
910
  try {
846
911
  const reply = await withDeadline(budgetMs, `Cadre invitation redemption via ${addr}`,
847
- (signal) => exchangeRedemption(node, parsed, options.request, signal, budgetMs, protocolId));
912
+ (deadline) => underDeadlineAndCaller(deadline, options.signal,
913
+ (signal) => exchangeRedemption(node, parsed, options.request, signal, budgetMs, protocolId)));
848
914
  if (!reply.accepted) {
849
915
  const refusal = new CadreInviteRejectedError(reply.code, reply.reason);
850
916
  if (!refusal.retryable) throw refusal;
@@ -856,9 +922,131 @@ export async function redeemAtMembers(node: Libp2p, options: RedeemAtMembersOpti
856
922
  return { reply, memberPeerId: trailingPeerId(parsed), memberAddr: addr };
857
923
  } catch (error) {
858
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();
859
927
  log('redemption via %s failed before an answer (%s); trying the next address', addr, asError(error).message);
860
928
  outcomes.push({ addr, error: asError(error) });
861
929
  }
862
930
  }
863
931
  throw new CadreInviteUnreachableError(outcomes);
864
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
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * The join request: the text an app that wants to join a cadre copies (or shows as a QR code)
3
+ * so the app that manages the cadre can mint an invitation for it alone
4
+ * (`CadreNode.createCadreInvitation({ peerId: request.peerId, grantsOwner })`).
5
+ *
6
+ * `sereus-join-request:1.<base64url of canonical JSON>` where the JSON is
7
+ * `{ appName, peerId, reply?, v }`. Nothing in it is secret or signed: the peer id is public, and
8
+ * the name is only what the requester calls itself, shown in the owner's approval prompt. The
9
+ * safety is in the response: an invitation targeted at that peer id can be redeemed only by
10
+ * the key behind it.
11
+ *
12
+ * `reply` is present only on a request opened as a link from another app on the same phone
13
+ * (`@serfab/cadre-rn/app-link`): the owner app answers by opening `<reply.scheme>://…`, a link it
14
+ * builds itself, so the scheme is restricted to an app's own custom scheme and never a web or
15
+ * system one, which would send the invitation's machine addresses off the phone.
16
+ *
17
+ * Imports only `@libp2p/peer-id` and `uint8arrays`, so it loads on React Native and in the
18
+ * browser.
19
+ */
20
+ import { peerIdFromString } from '@libp2p/peer-id';
21
+ import { fromString as uint8ArrayFromString, toString as uint8ArrayToString } from 'uint8arrays';
22
+ import { canonicalJson } from './canonical-json.js';
23
+
24
+ /** Version 1 of the join request; the only one either side reads. */
25
+ export const CADRE_JOIN_REQUEST_PREFIX = 'sereus-join-request:1.';
26
+
27
+ /**
28
+ * Version 1 of an encoded cadre invitation (`encodeCadreInvitation`). Declared here, beside
29
+ * the request's prefix, so each decoder can name the other kind of text when it is pasted in
30
+ * the wrong place without this codec importing the redemption protocol.
31
+ */
32
+ export const CADRE_INVITATION_PREFIX = 'sereus-invite:1.';
33
+
34
+ /** Longest `appName` a request carries: one line in an approval prompt. */
35
+ export const CADRE_JOIN_REQUEST_MAX_APP_NAME = 64;
36
+
37
+ /**
38
+ * Schemes a reply may not name, though they match {@link CADRE_JOIN_REPLY_SCHEME}: each opens a
39
+ * web page, a file, a system action or another app's content rather than a Sereus app.
40
+ */
41
+ const REFUSED_REPLY_SCHEMES: ReadonlySet<string> = new Set([
42
+ 'http', 'https', 'ws', 'wss', 'ftp', 'file', 'content', 'data', 'blob',
43
+ 'javascript', 'about', 'intent', 'mailto', 'tel', 'sms',
44
+ ]);
45
+
46
+ /**
47
+ * The form of a reply's scheme: lowercase, as an app's custom URL scheme is declared.
48
+ * NOTE: `@serfab/cadre-rn`'s CommonJS config plugin keeps a copy (`APP_SCHEME`) to check extra
49
+ * apps' schemes; change it with this one.
50
+ */
51
+ const CADRE_JOIN_REPLY_SCHEME = /^[a-z][a-z0-9+.-]{0,63}$/;
52
+
53
+ /** The form of a reply's nonce: base64url, long enough that it cannot be guessed. */
54
+ const CADRE_JOIN_REPLY_NONCE = /^[A-Za-z0-9_-]{16,64}$/;
55
+
56
+ /** Where and how the owner app answers a request that arrived as a link. */
57
+ export interface CadreJoinReply {
58
+ /** The requesting app's own custom URL scheme; the owner app opens `<scheme>://cadre-join-response/<response>`. */
59
+ scheme: string;
60
+ /** Echoed in the response, so the requesting app takes only an answer to a request it has open. */
61
+ nonce: string;
62
+ }
63
+
64
+ export interface CadreJoinRequest {
65
+ v: 1;
66
+ /** The requesting node's peer id: an Ed25519 peer id, since the invitation targets that key. */
67
+ peerId: string;
68
+ /** What the requester calls itself, for the owner's approval prompt. Unverified; at most 64 characters, trimmed, non-empty. */
69
+ appName: string;
70
+ /** Present only on a request opened as a link: where and how the owner app answers. */
71
+ reply?: CadreJoinReply;
72
+ }
73
+
74
+ /** Encode a request as the text the requesting app copies. Refuses what {@link decodeCadreJoinRequest} would. */
75
+ export function encodeCadreJoinRequest(request: CadreJoinRequest): string {
76
+ const checked = requireCadreJoinRequest(request);
77
+ // canonicalJson drops an undefined `reply`, so a request without one encodes as it always has.
78
+ const json = canonicalJson({ appName: checked.appName, peerId: checked.peerId, reply: checked.reply, v: checked.v });
79
+ return `${CADRE_JOIN_REQUEST_PREFIX}${uint8ArrayToString(new TextEncoder().encode(json), 'base64url')}`;
80
+ }
81
+
82
+ /**
83
+ * Decode the text the owner's app was given. Refuses, naming the problem: an invitation
84
+ * pasted here, any prefix but version 1's, a body that is not base64url JSON of the request's
85
+ * shape, a peer id that does not parse or is not Ed25519, an `appName` that is empty or
86
+ * longer than {@link CADRE_JOIN_REQUEST_MAX_APP_NAME} characters once trimmed, and a `reply`
87
+ * that {@link requireCadreJoinReply} refuses.
88
+ */
89
+ export function decodeCadreJoinRequest(text: string): CadreJoinRequest {
90
+ const trimmed = text.trim();
91
+ if (trimmed.startsWith(CADRE_INVITATION_PREFIX)) {
92
+ throw new Error('This is a cadre invitation, not a join request; paste it where the app asks for an invitation');
93
+ }
94
+ if (!trimmed.startsWith(CADRE_JOIN_REQUEST_PREFIX)) {
95
+ throw new Error(`A join request must start with "${CADRE_JOIN_REQUEST_PREFIX}" (this one starts with "${trimmed.slice(0, CADRE_JOIN_REQUEST_PREFIX.length)}")`);
96
+ }
97
+ let parsed: unknown;
98
+ try {
99
+ parsed = JSON.parse(new TextDecoder().decode(uint8ArrayFromString(trimmed.slice(CADRE_JOIN_REQUEST_PREFIX.length), 'base64url')));
100
+ } catch (error) {
101
+ throw new Error('A join request must carry base64url-encoded JSON after its prefix', { cause: error });
102
+ }
103
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
104
+ throw new Error('A join request must decode to a JSON object');
105
+ }
106
+ const { v, peerId, appName, reply } = parsed as Record<string, unknown>;
107
+ if (v !== 1) throw new Error(`A join request must be version 1 (this one says ${JSON.stringify(v)})`);
108
+ if (typeof peerId !== 'string') throw new Error('A join request must name the requesting node\'s peer id');
109
+ if (typeof appName !== 'string') throw new Error('A join request must carry the requesting app\'s name');
110
+ return requireCadreJoinRequest({ v, peerId, appName, ...(reply === undefined ? {} : { reply: replyShape(reply) }) });
111
+ }
112
+
113
+ /**
114
+ * Refuses, naming the problem, a reply whose scheme is not a lowercase URL scheme of at most 64
115
+ * characters or is a web or system scheme (`https`, `file`, `intent`, …), and a nonce that is
116
+ * not 16 to 64 base64url characters. The owner app checks it again before it opens a reply.
117
+ */
118
+ export function requireCadreJoinReply(reply: CadreJoinReply): CadreJoinReply {
119
+ const { scheme, nonce } = reply;
120
+ if (!CADRE_JOIN_REPLY_SCHEME.test(scheme)) {
121
+ throw new Error(`A join request's reply scheme must be a lowercase URL scheme of at most 64 characters (this one is ${JSON.stringify(scheme)})`);
122
+ }
123
+ if (REFUSED_REPLY_SCHEMES.has(scheme)) {
124
+ throw new Error(`A join request's reply scheme must be an app's own scheme, not "${scheme}"`);
125
+ }
126
+ if (!CADRE_JOIN_REPLY_NONCE.test(nonce)) {
127
+ throw new Error('A join request\'s reply nonce must be 16 to 64 base64url characters');
128
+ }
129
+ return { scheme, nonce };
130
+ }
131
+
132
+ /** The decoded `reply`'s types, before {@link requireCadreJoinReply} judges its values. */
133
+ function replyShape(reply: unknown): CadreJoinReply {
134
+ if (typeof reply !== 'object' || reply === null || Array.isArray(reply)) {
135
+ throw new Error('A join request\'s reply must be an object');
136
+ }
137
+ const { scheme, nonce } = reply as Record<string, unknown>;
138
+ if (typeof scheme !== 'string' || typeof nonce !== 'string') {
139
+ throw new Error('A join request\'s reply must carry a scheme and a nonce');
140
+ }
141
+ return { scheme, nonce };
142
+ }
143
+
144
+ /** The shape rules both directions apply. Returns the request with its fields trimmed. */
145
+ function requireCadreJoinRequest(request: CadreJoinRequest): CadreJoinRequest {
146
+ const peerId = requireEd25519PeerId(request.peerId);
147
+ const appName = request.appName.trim();
148
+ if (appName.length === 0) throw new Error('A join request must name the requesting app');
149
+ if (appName.length > CADRE_JOIN_REQUEST_MAX_APP_NAME) {
150
+ throw new Error(`A join request's app name must be at most ${CADRE_JOIN_REQUEST_MAX_APP_NAME} characters (this one has ${appName.length})`);
151
+ }
152
+ return { v: 1, peerId, appName, ...(request.reply ? { reply: requireCadreJoinReply(request.reply) } : {}) };
153
+ }
154
+
155
+ /** An invitation targeted at the peer id names its key, so only an Ed25519 id could ever redeem one. */
156
+ function requireEd25519PeerId(text: string): string {
157
+ let parsed: ReturnType<typeof peerIdFromString>;
158
+ try {
159
+ parsed = peerIdFromString(text.trim());
160
+ } catch (error) {
161
+ throw new Error(`A join request's peer id does not parse ("${text}")`, { cause: error });
162
+ }
163
+ if (parsed.type !== 'Ed25519') {
164
+ throw new Error(`A join request's peer id must be an Ed25519 peer id (this one is ${parsed.type})`);
165
+ }
166
+ return parsed.toString();
167
+ }