@serfab/cadre-core 0.12.0 → 1.0.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 (155) hide show
  1. package/README.md +23 -8
  2. package/dist/bootstrap-peer-store-file.js +2 -2
  3. package/dist/bootstrap-peer-store.d.ts +32 -12
  4. package/dist/bootstrap-peer-store.js +41 -15
  5. package/dist/bootstrap-peer-store.js.map +1 -1
  6. package/dist/cadre-node.d.ts +883 -102
  7. package/dist/cadre-node.js +1800 -209
  8. package/dist/cadre-node.js.map +1 -1
  9. package/dist/control-authorization.d.ts +2 -2
  10. package/dist/control-authorization.js +1 -0
  11. package/dist/control-authorization.js.map +1 -1
  12. package/dist/control-cohort.d.ts +13 -26
  13. package/dist/control-cohort.js +0 -26
  14. package/dist/control-cohort.js.map +1 -1
  15. package/dist/control-database.d.ts +318 -44
  16. package/dist/control-database.js +516 -74
  17. package/dist/control-database.js.map +1 -1
  18. package/dist/control-read-retry.d.ts +40 -11
  19. package/dist/control-read-retry.js +48 -15
  20. package/dist/control-read-retry.js.map +1 -1
  21. package/dist/control-retry.d.ts +58 -3
  22. package/dist/control-retry.js +58 -3
  23. package/dist/control-retry.js.map +1 -1
  24. package/dist/control-schema.d.ts +1 -1
  25. package/dist/control-schema.js +133 -21
  26. package/dist/control-schema.js.map +1 -1
  27. package/dist/control-write-retry.d.ts +80 -11
  28. package/dist/control-write-retry.js +168 -29
  29. package/dist/control-write-retry.js.map +1 -1
  30. package/dist/enrolled-machine-store-file.d.ts +13 -0
  31. package/dist/enrolled-machine-store-file.js +41 -0
  32. package/dist/enrolled-machine-store-file.js.map +1 -0
  33. package/dist/enrolled-machine-store.d.ts +81 -0
  34. package/dist/enrolled-machine-store.js +225 -0
  35. package/dist/enrolled-machine-store.js.map +1 -0
  36. package/dist/file-durable-slot.js +5 -3
  37. package/dist/file-durable-slot.js.map +1 -1
  38. package/dist/index.d.ts +17 -8
  39. package/dist/index.js +54 -9
  40. package/dist/index.js.map +1 -1
  41. package/dist/membership-connection-gater.d.ts +15 -1
  42. package/dist/membership-connection-gater.js +9 -3
  43. package/dist/membership-connection-gater.js.map +1 -1
  44. package/dist/node-local-snapshot.d.ts +21 -7
  45. package/dist/node-local-snapshot.js +30 -9
  46. package/dist/node-local-snapshot.js.map +1 -1
  47. package/dist/peer-dial.d.ts +143 -0
  48. package/dist/peer-dial.js +176 -0
  49. package/dist/peer-dial.js.map +1 -0
  50. package/dist/{strand-backfill.d.ts → peer-join-backfill.d.ts} +49 -20
  51. package/dist/{strand-backfill.js → peer-join-backfill.js} +133 -52
  52. package/dist/peer-join-backfill.js.map +1 -0
  53. package/dist/relay-addrs.d.ts +160 -49
  54. package/dist/relay-addrs.js +287 -60
  55. package/dist/relay-addrs.js.map +1 -1
  56. package/dist/relay-reservation.d.ts +71 -3
  57. package/dist/relay-reservation.js +116 -8
  58. package/dist/relay-reservation.js.map +1 -1
  59. package/dist/seed-bootstrap.d.ts +30 -1
  60. package/dist/seed-bootstrap.js +62 -17
  61. package/dist/seed-bootstrap.js.map +1 -1
  62. package/dist/storage-scope.d.ts +62 -0
  63. package/dist/storage-scope.js +75 -0
  64. package/dist/storage-scope.js.map +1 -0
  65. package/dist/strand-database.d.ts +41 -11
  66. package/dist/strand-database.js +48 -18
  67. package/dist/strand-database.js.map +1 -1
  68. package/dist/strand-first-sync-gate.d.ts +152 -0
  69. package/dist/strand-first-sync-gate.js +208 -0
  70. package/dist/strand-first-sync-gate.js.map +1 -0
  71. package/dist/strand-formation-manager.d.ts +99 -5
  72. package/dist/strand-formation-manager.js +119 -8
  73. package/dist/strand-formation-manager.js.map +1 -1
  74. package/dist/strand-formation-protocol.d.ts +97 -3
  75. package/dist/strand-formation-protocol.js +108 -2
  76. package/dist/strand-formation-protocol.js.map +1 -1
  77. package/dist/strand-instance-manager.d.ts +389 -19
  78. package/dist/strand-instance-manager.js +678 -65
  79. package/dist/strand-instance-manager.js.map +1 -1
  80. package/dist/strand-member-key.d.ts +20 -12
  81. package/dist/strand-member-key.js +20 -12
  82. package/dist/strand-member-key.js.map +1 -1
  83. package/dist/strand-member-registry.js +3 -0
  84. package/dist/strand-member-registry.js.map +1 -1
  85. package/dist/strand-membership-reconciler.d.ts +361 -0
  86. package/dist/strand-membership-reconciler.js +548 -0
  87. package/dist/strand-membership-reconciler.js.map +1 -0
  88. package/dist/strand-membership-writer.d.ts +143 -11
  89. package/dist/strand-membership-writer.js +419 -135
  90. package/dist/strand-membership-writer.js.map +1 -1
  91. package/dist/strand-network-config.d.ts +116 -0
  92. package/dist/strand-network-config.js +192 -0
  93. package/dist/strand-network-config.js.map +1 -0
  94. package/dist/strand-revocation-enforcer.d.ts +392 -0
  95. package/dist/strand-revocation-enforcer.js +533 -0
  96. package/dist/strand-revocation-enforcer.js.map +1 -0
  97. package/dist/strand-solicitation.d.ts +17 -1
  98. package/dist/strand-solicitation.js +13 -2
  99. package/dist/strand-solicitation.js.map +1 -1
  100. package/dist/strand-wake-protocol.d.ts +11 -7
  101. package/dist/strand-wake-protocol.js +20 -52
  102. package/dist/strand-wake-protocol.js.map +1 -1
  103. package/dist/strand-watcher.js +12 -1
  104. package/dist/strand-watcher.js.map +1 -1
  105. package/dist/timed-step.d.ts +12 -0
  106. package/dist/timed-step.js +28 -0
  107. package/dist/timed-step.js.map +1 -0
  108. package/dist/timeout-scheduler.d.ts +19 -0
  109. package/dist/timeout-scheduler.js +22 -0
  110. package/dist/timeout-scheduler.js.map +1 -0
  111. package/dist/types.d.ts +472 -46
  112. package/dist/types.js +7 -1
  113. package/dist/types.js.map +1 -1
  114. package/package.json +16 -8
  115. package/src/bootstrap-peer-store-file.ts +2 -2
  116. package/src/bootstrap-peer-store.ts +69 -27
  117. package/src/cadre-node.ts +1926 -228
  118. package/src/control-authorization.ts +2 -1
  119. package/src/control-cohort.ts +122 -130
  120. package/src/control-database.ts +586 -74
  121. package/src/control-read-retry.ts +202 -167
  122. package/src/control-retry.ts +297 -190
  123. package/src/control-schema.ts +133 -21
  124. package/src/control-write-retry.ts +525 -383
  125. package/src/enrolled-machine-store-file.ts +43 -0
  126. package/src/enrolled-machine-store.ts +280 -0
  127. package/src/file-durable-slot.ts +5 -3
  128. package/src/index.ts +134 -15
  129. package/src/membership-connection-gater.ts +9 -3
  130. package/src/node-local-snapshot.ts +38 -9
  131. package/src/peer-addr-book.ts +251 -250
  132. package/src/peer-dial.ts +215 -0
  133. package/src/{strand-backfill.ts → peer-join-backfill.ts} +478 -389
  134. package/src/relay-addrs.ts +458 -218
  135. package/src/relay-reservation.ts +951 -828
  136. package/src/seed-bootstrap.ts +76 -18
  137. package/src/storage-scope.ts +78 -0
  138. package/src/strand-database.ts +61 -21
  139. package/src/strand-first-sync-gate.ts +258 -0
  140. package/src/strand-formation-manager.ts +162 -8
  141. package/src/strand-formation-protocol.ts +850 -692
  142. package/src/strand-instance-manager.ts +877 -74
  143. package/src/strand-member-key.ts +20 -12
  144. package/src/strand-member-registry.ts +3 -0
  145. package/src/strand-membership-reconciler.ts +680 -0
  146. package/src/strand-membership-writer.ts +550 -190
  147. package/src/strand-network-config.ts +250 -0
  148. package/src/strand-revocation-enforcer.ts +653 -0
  149. package/src/strand-solicitation.ts +499 -471
  150. package/src/strand-wake-protocol.ts +21 -66
  151. package/src/strand-watcher.ts +325 -314
  152. package/src/timed-step.ts +28 -0
  153. package/src/timeout-scheduler.ts +28 -0
  154. package/src/types.ts +487 -52
  155. package/dist/strand-backfill.js.map +0 -1
@@ -5,6 +5,7 @@ import type { Libp2p, Connection } from '@libp2p/interface';
5
5
  import { multiaddr, type Multiaddr } from '@multiformats/multiaddr';
6
6
  import { peerIdFromString } from '@libp2p/peer-id';
7
7
  import { type ControlStream, writeFrame, withDeadline, exchangeFrame, readStreamToEnd } from './control-stream.js';
8
+ import { dialPeerAddrs, DEFAULT_PEER_DIAL_BUDGET, type PeerDialBudget } from './peer-dial.js';
8
9
  import type {
9
10
  ControlNetworkSeed,
10
11
  SeedPeer,
@@ -19,7 +20,8 @@ import type {
19
20
  CadreInvite,
20
21
  PeerAddressRecord,
21
22
  DeviceTokenRecord,
22
- RevocationRow
23
+ RevocationRow,
24
+ RevocationLedgerOpenResult
23
25
  } from './types.js';
24
26
  import type { ControlDatabase } from './control-database.js';
25
27
  import { generateStampId } from './control-database.js';
@@ -120,6 +122,22 @@ function seedRejected(error: string): ApplySeedResult {
120
122
  return { success: false, peersAdded: 0, error, ownerDialsAttempted: 0, ownerDialsFailed: 0 };
121
123
  }
122
124
 
125
+ /**
126
+ * Parse each address, dropping (and logging) any that is malformed, so one bad
127
+ * entry cannot keep a peer's valid addresses from being dialed.
128
+ */
129
+ function parseDialAddrs(addrs: readonly string[]): Multiaddr[] {
130
+ const parsed: Multiaddr[] = [];
131
+ for (const addr of addrs) {
132
+ try {
133
+ parsed.push(multiaddr(addr));
134
+ } catch (error) {
135
+ log('Skipping malformed dial address %s: %o', addr, error);
136
+ }
137
+ }
138
+ return parsed;
139
+ }
140
+
123
141
  /**
124
142
  * Configuration for the SeedBootstrapService
125
143
  */
@@ -183,6 +201,14 @@ export interface SeedBootstrapConfig {
183
201
  * direction than the receiver knobs above.
184
202
  */
185
203
  seedDeliverTimeoutMs?: number;
204
+ /**
205
+ * Time limits for each peer this service dials from a list of addresses —
206
+ * {@link SeedBootstrapService.applySeed}'s owner dials and
207
+ * {@link SeedBootstrapService.dialInvite} — per address and per peer (see
208
+ * `peer-dial.ts`). Defaults to {@link DEFAULT_PEER_DIAL_BUDGET}; a `CadreNode`
209
+ * passes its `network.controlCohort` limits.
210
+ */
211
+ dialBudget?: PeerDialBudget;
186
212
  }
187
213
 
188
214
  /**
@@ -220,6 +246,7 @@ export class SeedBootstrapService {
220
246
  private readonly seedReadTimeoutMs: number;
221
247
  private readonly maxConcurrentSeeds: number;
222
248
  private readonly seedDeliverTimeoutMs: number;
249
+ private readonly dialBudget: PeerDialBudget;
223
250
  /** In-flight inbound seed streams, used to enforce {@link maxConcurrentSeeds}. */
224
251
  private activeStreams = 0;
225
252
  private eventCallbacks: SeedEventCallbacks = {};
@@ -230,6 +257,7 @@ export class SeedBootstrapService {
230
257
  this.seedReadTimeoutMs = config.seedReadTimeoutMs ?? DEFAULT_SEED_READ_TIMEOUT_MS;
231
258
  this.maxConcurrentSeeds = config.maxConcurrentSeeds ?? DEFAULT_MAX_CONCURRENT_SEEDS;
232
259
  this.seedDeliverTimeoutMs = config.seedDeliverTimeoutMs ?? DEFAULT_SEED_DELIVER_TIMEOUT_MS;
260
+ this.dialBudget = config.dialBudget ?? DEFAULT_PEER_DIAL_BUDGET;
233
261
 
234
262
  // Derive public key from private key if not provided
235
263
  if (config.ownerPrivateKey && !config.ownerPublicKey) {
@@ -618,6 +646,29 @@ export class SeedBootstrapService {
618
646
  );
619
647
  }
620
648
 
649
+ /**
650
+ * Owner filing of the singleton `Revocation` ledger marker, so the table is never a
651
+ * never-written block that the storage layer re-consults on every read.
652
+ *
653
+ * The signature, the insert-if-absent guard and the `'already-open'` mapping are
654
+ * {@link ControlDatabase.openRevocationLedger}'s. What stays here is the owner-key
655
+ * precondition.
656
+ *
657
+ * @throws if no owner private key is configured or the control database is not
658
+ * initialized.
659
+ */
660
+ async openRevocationLedger(): Promise<RevocationLedgerOpenResult> {
661
+ // Fail fast on a keyless service before any DB work (see removePeer): a
662
+ // non-owner cannot sign the marker's append digest.
663
+ const ownerKey = this.requireOwnerPublicKey();
664
+ if (!this.controlDatabase) {
665
+ throw new Error('Control database not initialized');
666
+ }
667
+ return this.controlDatabase.openRevocationLedger(
668
+ ownerKey, message => this.signMessageBytes(message),
669
+ );
670
+ }
671
+
621
672
  /**
622
673
  * Create a seed from the current control network state.
623
674
  * The seed contains peer information and is signed by an owner.
@@ -756,6 +807,13 @@ export class SeedBootstrapService {
756
807
  // throws, which would report a healthy owner as "seeded but stranded".
757
808
  // Optional-chained: partial libp2p handles (unit-test doubles) omit `peerId`,
758
809
  // and an undefined self simply matches nothing.
810
+ // Every one of an owner's addresses is a candidate, each on its own time limit
811
+ // (`dialPeerAddrs`), so an owner whose first address never answers neither
812
+ // stalls seed application nor goes undialed at its other addresses.
813
+ // NOTE: `handleSeedStream` acks only after this loop, so an unreachable owner
814
+ // can hold the ack for up to `dialBudget.totalMs` (30 s) each, past a sender's
815
+ // 10 s `seedDeliverTimeoutMs`; the seed is still applied. If senders start
816
+ // reporting accepted seeds as timed out, ack before dialing.
759
817
  const selfPeerId = this.libp2pNode.peerId?.toString();
760
818
  let ownerDialsAttempted = 0;
761
819
  let ownerDialsFailed = 0;
@@ -765,10 +823,10 @@ export class SeedBootstrapService {
765
823
  }
766
824
  ownerDialsAttempted++;
767
825
  try {
768
- const addr = multiaddr(peer.multiaddrs[0]);
826
+ const addrs = parseDialAddrs(peer.multiaddrs);
769
827
 
770
- log('Dialing owner peer: %s', peer.peerId);
771
- await this.libp2pNode.dial(addr);
828
+ log('Dialing owner peer: %s (%d addr(s))', peer.peerId, addrs.length);
829
+ await dialPeerAddrs(this.libp2pNode, addrs, this.dialBudget, `Owner dial of ${peer.peerId}`);
772
830
  } catch (error) {
773
831
  ownerDialsFailed++;
774
832
  log('Failed to dial peer %s: %o', peer.peerId, error);
@@ -1133,6 +1191,13 @@ export class SeedBootstrapService {
1133
1191
  * 2. Creates a seed including all current peers
1134
1192
  * 3. Returns the seed for sending to provider API
1135
1193
  *
1194
+ * Nothing here dials the drone or remembers its addresses beyond the unsigned
1195
+ * `CadrePeer` row, which no resolver accepts. `CadreNode.addDrone` is the entry
1196
+ * point that also retains them as a durable dial target, so the adder's
1197
+ * reconcile pass can open the connection — the drone cannot dial an owner that
1198
+ * does not listen. Call `CadreNode.reconcileControlCohort()` after delivering
1199
+ * the seed to dial at once.
1200
+ *
1136
1201
  * @param options - Drone peer info from provider API
1137
1202
  * @returns Seed and encoded seed for drone initialization
1138
1203
  */
@@ -1327,21 +1392,14 @@ export class SeedBootstrapService {
1327
1392
 
1328
1393
  log('Dialing invite owner with %d addresses', invite.ownerAddrs.length);
1329
1394
 
1330
- // Try each owner address until one succeeds
1331
- let lastError: Error | null = null;
1332
- for (const addrStr of invite.ownerAddrs) {
1333
- try {
1334
- const addr = multiaddr(addrStr);
1335
- await this.libp2pNode.dial(addr);
1336
- log('Connected to owner at: %s', addrStr);
1337
- return;
1338
- } catch (error) {
1339
- lastError = error instanceof Error ? error : new Error(String(error));
1340
- log('Failed to dial %s: %o', addrStr, error);
1341
- }
1395
+ const addrs = parseDialAddrs(invite.ownerAddrs);
1396
+ if (addrs.length === 0) {
1397
+ throw new Error('No owner addresses available');
1342
1398
  }
1343
-
1344
- throw lastError ?? new Error('No owner addresses available');
1399
+ // Each address on its own time limit, so one that never answers cannot hold
1400
+ // the invitee back from the rest (`dialPeerAddrs`).
1401
+ const connection = await dialPeerAddrs(this.libp2pNode, addrs, this.dialBudget, 'Invite owner dial');
1402
+ log('Connected to owner at: %s', connection.remoteAddr.toString());
1345
1403
  }
1346
1404
  }
1347
1405
 
@@ -0,0 +1,78 @@
1
+ /**
2
+ * storage-scope.ts — the scope keys `CadreNodeConfig.storage.provider` is called with.
3
+ *
4
+ * A provider is a factory cadre-core invokes once per **scope**: once for the control
5
+ * database and once for each strand. A strand's scope key is its strand id; the control
6
+ * database's scope key is what this module mints.
7
+ *
8
+ * WHY THE CONTROL SCOPE CARRIES THE PARTY ID. The control database holds the party's
9
+ * own records — its strands, owner keys, peers, invitations, revocations. Two parties
10
+ * on one device must therefore not share one control store, or a node started for
11
+ * party B reads party A's rows as if party A had written them for B. Every other
12
+ * party-scoped store in this codebase already knows this: `PersistentTrustedOwnerStore`,
13
+ * `PersistentBootstrapPeerStore` and `PersistentEnrolledMachineStore` each take a
14
+ * `partyId` and fail closed on a mismatch. Putting the party id in the key here means
15
+ * no embedder has to know it belongs there, and none can get it wrong.
16
+ *
17
+ * WHY IT IS ENCODED. A party id is arbitrary text — nothing in cadre-core validates
18
+ * its shape, and the React Native reference app lets a user type one into Settings.
19
+ * Scope keys reach real namespaces unescaped: cadre-cli builds `${config.path}/${scope}`,
20
+ * React Native a LevelDB filename, the browser an IndexedDB database name. A party id
21
+ * containing `/` or `..` would escape the CLI's storage directory. Base64url keeps every
22
+ * key inside `[A-Za-z0-9._-]` — the charset embedders already rely on, and the same
23
+ * encoding the React Native app's `secureStoreKeySegment` uses on a party id for the
24
+ * same reason.
25
+ *
26
+ * THE STRAND ARM OF THAT CHARSET RULE IS NOT YET ENFORCED. The strand scope key is
27
+ * `StrandRow.Id`, and the ids cadre-core itself mints are `strand-<digits>-<base36>`
28
+ * / `strand-<hex>`, which satisfy the charset. But a strand row replicated into the
29
+ * control database by another node in the party carries whatever id THAT node wrote,
30
+ * and nothing validates its shape before `resolveStrandStorage` hands it to the
31
+ * provider. See `tickets/backlog/bug-strand-scope-key-charset-unenforced`.
32
+ */
33
+
34
+ import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
35
+
36
+ /**
37
+ * Prefix marking a scope key as a control database rather than a strand.
38
+ *
39
+ * Not exported: `controlStorageScope` mints these keys and `isControlStorageScope`
40
+ * recognizes them, which is the whole surface a caller needs.
41
+ */
42
+ const CONTROL_SCOPE_PREFIX = 'control-';
43
+
44
+ /**
45
+ * The storage scope key for a party's control database.
46
+ *
47
+ * Returns `control-` followed by the base64url encoding of `partyId`'s UTF-8 bytes,
48
+ * so the whole key stays within `[A-Za-z0-9._-]` and is safe to use directly as a
49
+ * file name, directory name or database name. See the module comment for why the
50
+ * encoding is load-bearing rather than decorative.
51
+ *
52
+ * To read a party id back off a device — from, say, a LevelDB file named
53
+ * `sereus-control-MTExMTExMTEtMjIyMi00MzMzLTg0NDQtNTU1NTU1NTU1NTU1` — in a browser or
54
+ * Node console, with no dependency on this package:
55
+ * ```js
56
+ * const b64 = key.slice('control-'.length).replace(/-/g, '+').replace(/_/g, '/');
57
+ * new TextDecoder().decode(Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)))
58
+ * ```
59
+ * The `TextDecoder` step is not optional: `atob` alone yields one character per BYTE,
60
+ * so a party id with any non-ASCII character in it decodes to mojibake.
61
+ */
62
+ export function controlStorageScope(partyId: string): string {
63
+ return CONTROL_SCOPE_PREFIX + uint8ArrayToString(uint8ArrayFromString(partyId, 'utf8'), 'base64url');
64
+ }
65
+
66
+ /**
67
+ * Whether a scope key names a control database rather than a strand.
68
+ *
69
+ * A prefix test. Safe as one against the strand ids cadre-core mints, which are
70
+ * `strand-`-prefixed (`strand-formation-manager.ts`, `strand-solicitation.ts`,
71
+ * `control-formation-recorder.ts`) and so can never collide. It is NOT a security
72
+ * boundary: a strand id replicated in from another node is unvalidated text that
73
+ * could begin `control-`, which is one more reason to close the gap the module
74
+ * comment names.
75
+ */
76
+ export function isControlStorageScope(scope: string): boolean {
77
+ return scope.startsWith(CONTROL_SCOPE_PREFIX);
78
+ }
@@ -27,11 +27,21 @@ export interface StrandDatabaseConfig {
27
27
  */
28
28
  strandType: 'o' | 'c';
29
29
  /**
30
- * The closed-strand `MemberPrivateKey` (base64 protobuf) from the strand row.
31
- * Required when `founder` is true and `strandType` is `'c'` — it derives the
32
- * founding `Member.Key`/`Manager.MemberKey`. Absent for open strands.
30
+ * The closed-strand `MemberPrivateKey` (base64 protobuf) from the strand row —
31
+ * the strand-wide read secret every joining party receives. Carried for the
32
+ * strand's read-gating story only; it derives NOBODY's identity (that used to be
33
+ * this key's second job, which let any joiner sign as the founding manager —
34
+ * gotchoices/sereus#4). Absent for open strands.
33
35
  */
34
36
  memberPrivateKey?: string;
37
+ /**
38
+ * THIS party's own strand membership private key (base64 protobuf), from the
39
+ * control-layer `StrandPartyKey` row (or supplied explicitly at attach). Required
40
+ * when `founder` is true and `strandType` is `'c'` — it derives the founding
41
+ * `Member.Key`/`Manager.MemberKey`. Never shared outside the party; absent for
42
+ * open strands and for joiners that have not yet persisted one.
43
+ */
44
+ partyMemberPrivateKey?: string;
35
45
  /**
36
46
  * Whether this node founds the strand. When true, {@link initialize} runs the
37
47
  * one-time founder membership bootstrap after the schema is applied. Joiners
@@ -120,38 +130,68 @@ export class StrandDatabase {
120
130
  /**
121
131
  * Run the founder membership bootstrap against the freshly-composed strand DB.
122
132
  *
123
- * Derives the founding keypair from the closed-strand `memberPrivateKey` (the
124
- * `Member.Key`/`Manager.MemberKey` are its public key); a closed strand with no
125
- * `memberPrivateKey` throws, because it could never seat a founding manager.
126
- * Open strands derive no keypair (Header only). Idempotent — see
127
- * {@link bootstrapFounderMembership}.
133
+ * Derives the founding keypair from this PARTY's own `partyMemberPrivateKey` (the
134
+ * `Member.Key`/`Manager.MemberKey` are its public key) — deliberately NOT from the
135
+ * strand row's shared `MemberPrivateKey`, which every joining party receives and could
136
+ * therefore use to forge the founder's identity. A closed strand with no
137
+ * `partyMemberPrivateKey` throws, because it could never seat a founding manager.
138
+ * The shared key's public half IS passed, but only so the bootstrap can refuse a strand
139
+ * founded under it before the split (`PreSplitStrandIdentityError`). Open strands derive
140
+ * no keypair (Header only). Idempotent — see {@link bootstrapFounderMembership}.
128
141
  */
129
142
  private async bootstrapFounder(): Promise<void> {
130
- const { strandId, strandType, memberPrivateKey, sAppConfig } = this.config;
131
- const founderKeyPair = strandType === 'c'
132
- ? this.deriveFounderKeyPair(strandId, memberPrivateKey)
133
- : undefined;
143
+ const { strandId, strandType, memberPrivateKey, partyMemberPrivateKey, sAppConfig } = this.config;
144
+ const closed = strandType === 'c';
134
145
  await bootstrapFounderMembership(this.db!, {
135
146
  strandId,
136
147
  type: strandType,
137
148
  sApp: sAppConfig,
138
- founderKeyPair,
149
+ founderKeyPair: closed ? this.deriveFounderKeyPair(strandId, partyMemberPrivateKey) : undefined,
150
+ sharedMemberPublicKey: closed && memberPrivateKey
151
+ ? strandMemberKeyPair(memberPrivateKey).publicKeyB64
152
+ : undefined,
139
153
  });
140
154
  }
141
155
 
142
156
  /**
143
- * Derive the founding keypair for a closed strand from its `memberPrivateKey`,
144
- * failing loudly when the key is absent (a closed strand with no founding
145
- * Manager can never admit anyone).
157
+ * Derive the founding keypair for a closed strand from the party's own
158
+ * `partyMemberPrivateKey`, failing loudly when the key is absent (a closed strand
159
+ * with no founding Manager can never admit anyone).
146
160
  */
147
- private deriveFounderKeyPair(strandId: string, memberPrivateKey: string | undefined) {
148
- if (!memberPrivateKey) {
161
+ private deriveFounderKeyPair(strandId: string, partyMemberPrivateKey: string | undefined) {
162
+ if (!partyMemberPrivateKey) {
149
163
  throw new Error(
150
- `Cannot found closed strand ${strandId}: the strand row has no MemberPrivateKey. ` +
151
- 'A closed strand needs a founding Member/Manager derived from that key.',
164
+ `Cannot found closed strand ${strandId}: this party has no StrandPartyKey for it. ` +
165
+ 'A closed strand needs a founding Member/Manager derived from the party\'s own ' +
166
+ 'membership key (minted at publishStrand, or at the founder launch that follows a ' +
167
+ 'publish interrupted before its mint) — the shared MemberPrivateKey deliberately ' +
168
+ 'no longer derives anyone\'s identity.',
152
169
  );
153
170
  }
154
- return strandMemberKeyPair(memberPrivateKey);
171
+ return strandMemberKeyPair(partyMemberPrivateKey);
172
+ }
173
+
174
+ /**
175
+ * Run the founder membership bootstrap against an ALREADY-LIVE database — the
176
+ * seam {@link StrandInstanceManager.foundExistingStrand} uses when a founder
177
+ * request arrives for an instance that was first launched as a joiner.
178
+ * Idempotent: every bootstrap write is insert-if-absent
179
+ * ({@link bootstrapFounderMembership}), so calling it on an instance that
180
+ * already founded writes nothing. On success also flips the captured config's
181
+ * `founder`, so this object's own record of how it was launched stays coherent
182
+ * with what actually ran (a construction-time `founder: false` is a statement
183
+ * about the launch, not a permanent identity); a refused bootstrap leaves it a joiner.
184
+ *
185
+ * @param partyMemberPrivateKey - The party's own membership key, for a closed strand
186
+ * whose original (joiner) launch resolved none — e.g. the `StrandPartyKey` row had
187
+ * not been written or replicated yet. The captured config's key wins when both
188
+ * exist: it is the identity this instance launched under.
189
+ */
190
+ async ensureFounderBootstrap(partyMemberPrivateKey?: string): Promise<void> {
191
+ this.ensureInitialized();
192
+ this.config.partyMemberPrivateKey ??= partyMemberPrivateKey;
193
+ await this.bootstrapFounder();
194
+ this.config.founder = true;
155
195
  }
156
196
 
157
197
  /**
@@ -0,0 +1,258 @@
1
+ /**
2
+ * The first-sync write gate for a JOINING machine.
3
+ *
4
+ * A machine that has never received anything from another member of a strand must not
5
+ * commit to it. With no strand peer connected, Optimystic's cohort for every block is the
6
+ * machine itself, so the first write to a table finds no collection locally and INVENTS
7
+ * one — exactly what a founder writing alone legitimately does. When the connection comes
8
+ * up, two independently created histories share one collection id: Optimystic keeps one and
9
+ * drops the other's commits, or never reconciles them at all. The joiner's own rows silently
10
+ * vanish (`joining-machine-writes-before-first-sync-fork-tables`). Optimystic cannot tell a
11
+ * founder from a joiner; sereus can, because the launch knows whether it is founding.
12
+ *
13
+ * The invariant this module enforces: **a machine that has never held this strand's
14
+ * `Strand.Header` row, and has not read every `App` table once, must not commit to it.**
15
+ * The founder writes the Header in its bootstrap; every other machine can only receive it
16
+ * from a peer, so "no local Header" means "never synced". The gate therefore holds the
17
+ * freshly initialized `StrandDatabase` back from the app — `StrandInstance.database` stays
18
+ * unset and the instance reports `'syncing'` — and probes on a cadence until the Header is
19
+ * readable AND a read of each app table settles, at which point the database is published
20
+ * and the instance goes `'active'`. A machine that already holds the Header (a restart, a
21
+ * hibernation resume, a founder) passes the probe on the first try and is never gated, so
22
+ * offline-first writes on a machine that has synced before keep working.
23
+ *
24
+ * Why the app tables too: the Header is ONE collection. It reaches a joiner ahead of the
25
+ * founder's app-table collections (peer-join backfill and pull-on-read deliver those
26
+ * separately), and a write to a table whose collection this machine has not yet fetched
27
+ * invents one exactly as a lone joiner did. Measured on the reference chat schema
28
+ * (2026-09-16, direct connections, Header-only probe): the joiner's participant + message
29
+ * written the instant `addStrand` resolved diverged in 3 of 4 runs. Reading each table once
30
+ * pulls its collection while the founder is reachable, so the write that follows appends.
31
+ *
32
+ * Every probe is a read (`Strand.Header`, then `select count(1)` on each `App` table). Reads
33
+ * never invent a collection (Optimystic's `Collection.open` resolves undefined on an
34
+ * authoritatively absent header, and throws on an unreachable one), so probing is safe to
35
+ * repeat and a throw is just "not yet". A table nobody has written yet reads as absent on
36
+ * every machine; the first write to it still creates its collection — see `docs/strands.md`
37
+ * ("Joining") for that residual.
38
+ *
39
+ * Owned by `StrandInstanceManager`: created in `buildStrandRuntime` for a non-founder launch
40
+ * whose first probe finds no Header, stopped and dropped in `releaseRuntime` (so a quiesce →
41
+ * resume rebuild re-probes over the same store), and force-opened when a founder request
42
+ * runs the bootstrap against the still-gated database (`foundExistingStrand`).
43
+ *
44
+ * NOTE: the gate covers app and membership tables, not the schema catalog. Every launch —
45
+ * joiner or founder — writes the catalog collection (`optimystic/schema` plus two
46
+ * hash-named blocks, measured 2026-09-16) alone, during `connectToStrand`'s schema apply,
47
+ * before any peer contact. That is the same "two histories under one id" shape this gate
48
+ * exists to prevent, and it is only safe because both sides derive byte-identical content
49
+ * from the same schema text. If the catalog ever carries per-machine or ordering-dependent
50
+ * content (optimystic's own `0.5-same-named-tables-in-two-schemas-share-storage` re-keys
51
+ * it), a joiner's catalog will fork against the founder's — gate the schema apply on the
52
+ * Header too, or have optimystic open the catalog read-only when a peer already holds it.
53
+ */
54
+
55
+ import debug from 'debug';
56
+ import type { Database } from '@quereus/quereus';
57
+ import type { StrandDatabase } from './strand-database.js';
58
+ import { defaultTimeoutScheduler, type TimeoutScheduler } from './timeout-scheduler.js';
59
+
60
+ const log = debug('sereus:cadre:strand-first-sync');
61
+
62
+ /** How often a gated launch re-probes (`Strand.Header`, then every `App` table) while waiting for its first sync. */
63
+ export const DEFAULT_STRAND_FIRST_SYNC_POLL_MS = 500;
64
+
65
+ /**
66
+ * How long `CadreNode.addStrand` waits for a joining machine's first sync before rejecting
67
+ * with {@link StrandAwaitingFirstSyncError}. Measured: over a direct connection the
68
+ * joiner reads the founder's rows about 1.3 s after `addStrand` would previously have
69
+ * resolved; a relayed path is slower, and a phone that just redeemed an invitation has the
70
+ * host reachable moments ago, so 30 s is generous without leaving an app hanging.
71
+ */
72
+ export const DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS = 30_000;
73
+
74
+ /** Embedder-facing tuning for the gate, threaded from `CadreNodeConfig.strandFirstSync`. */
75
+ export interface StrandFirstSyncConfig {
76
+ /**
77
+ * Default wait for `CadreNode.addStrand` (and `whenStrandWritable`) before a joining
78
+ * machine that has reached no other member is reported as not yet writable. Default
79
+ * {@link DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS}.
80
+ */
81
+ timeoutMs?: number;
82
+ /** Probe cadence while gated. Default {@link DEFAULT_STRAND_FIRST_SYNC_POLL_MS}. */
83
+ pollIntervalMs?: number;
84
+ }
85
+
86
+ /**
87
+ * Thrown by `CadreNode.addStrand` / `whenStrandWritable` when a joining machine has not
88
+ * received the strand's data from any other member within the wait budget. RETRYABLE: the
89
+ * strand stays launched and keeps probing, so a later `addStrand` (or `whenStrandWritable`)
90
+ * for the same strand completes the attach once another member is reachable; the
91
+ * `strand:writable` event fires at that moment too.
92
+ */
93
+ export class StrandAwaitingFirstSyncError extends Error {
94
+ readonly strandId: string;
95
+ readonly waitedMs: number;
96
+
97
+ constructor(strandId: string, waitedMs: number) {
98
+ super(
99
+ `Strand ${strandId} is not writable yet: no member of this strand has been reachable ` +
100
+ `since this machine joined, so it has not received the strand's data (waited ${waitedMs} ms). ` +
101
+ 'The strand stays launched and keeps trying — call addStrand again, or wait for the ' +
102
+ "'strand:writable' event, once another member is reachable."
103
+ );
104
+ this.name = 'StrandAwaitingFirstSyncError';
105
+ this.strandId = strandId;
106
+ this.waitedMs = waitedMs;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Whether this machine holds the strand's `Strand.Header` row — the "has synced at least
112
+ * once (or founded)" signal. A read, never a write: it cannot invent the collection. A
113
+ * throwing read (an unreachable cohort mid-sync, a store still hydrating) reports `false`
114
+ * with a log, since the caller's only response to either answer is "probe again".
115
+ */
116
+ export async function strandHeaderHeld(db: Database, label: string): Promise<boolean> {
117
+ try {
118
+ for await (const row of db.eval('select count(1) as Count from Strand.Header')) {
119
+ return ((row.Count as number) ?? 0) > 0;
120
+ }
121
+ return false;
122
+ } catch (error) {
123
+ log('[%s] Header probe failed (treated as not yet held): %s', label,
124
+ error instanceof Error ? error.message : String(error));
125
+ return false;
126
+ }
127
+ }
128
+
129
+ /** The tables the sApp declared in the `App` schema, by name; views excluded. */
130
+ function appTableNames(db: Database): string[] {
131
+ const app = db.schemaManager.getSchema('App');
132
+ return app ? Array.from(app.getAllTables()).filter((table) => !table.isView).map((table) => table.name) : [];
133
+ }
134
+
135
+ /**
136
+ * Whether a read of every `App` table settles on this machine — the "each app collection
137
+ * has been fetched from the cohort, or is authoritatively absent" signal. Only a throwing
138
+ * read (an unreachable cohort) reports `false`; an empty table is a settled read.
139
+ */
140
+ export async function appTablesReadable(db: Database, label: string): Promise<boolean> {
141
+ for (const table of appTableNames(db)) {
142
+ try {
143
+ for await (const _row of db.eval(`select count(1) as Count from App."${table}"`)) {
144
+ break;
145
+ }
146
+ } catch (error) {
147
+ log('[%s] App.%s probe failed (treated as not yet synced): %s', label, table,
148
+ error instanceof Error ? error.message : String(error));
149
+ return false;
150
+ }
151
+ }
152
+ return true;
153
+ }
154
+
155
+ /**
156
+ * The gate's whole probe: the Header is held AND every app table has been read once. The
157
+ * app tables are read only once the Header is — a joiner with no peer would otherwise pay
158
+ * one failing network read per table per probe for nothing.
159
+ */
160
+ export async function strandFirstSyncComplete(db: Database, label: string): Promise<boolean> {
161
+ return await strandHeaderHeld(db, label) && await appTablesReadable(db, label);
162
+ }
163
+
164
+ export interface StrandFirstSyncGateDeps {
165
+ /** Log tag naming which strand this gate holds (the strand id). */
166
+ label: string;
167
+ /** The initialized database being held back from the app until the Header is held. */
168
+ database: StrandDatabase;
169
+ /**
170
+ * Called exactly once, on the gate's own probe loop, when the first sync completes.
171
+ * NOT called by {@link StrandFirstSyncGate.open} — a caller that force-opens the gate
172
+ * (a founder bootstrap it just ran) publishes the database itself.
173
+ */
174
+ onHeaderHeld: () => void;
175
+ /** Timer seam for the probe loop; omit for real (unref'd) timeouts. */
176
+ scheduler?: TimeoutScheduler;
177
+ }
178
+
179
+ /**
180
+ * Holds one launch's `StrandDatabase` until {@link strandFirstSyncComplete}. Probes are
181
+ * sequential (the next is scheduled only after the previous read settles), so a slow
182
+ * network read never stacks probes.
183
+ */
184
+ export class StrandFirstSyncGate {
185
+ private readonly scheduler: TimeoutScheduler;
186
+ private readonly pollIntervalMs: number;
187
+ private timer: unknown;
188
+ private stopped = false;
189
+ private opened = false;
190
+ private probing = false;
191
+
192
+ constructor(private readonly deps: StrandFirstSyncGateDeps, config?: StrandFirstSyncConfig) {
193
+ this.scheduler = deps.scheduler ?? defaultTimeoutScheduler;
194
+ this.pollIntervalMs = config?.pollIntervalMs ?? DEFAULT_STRAND_FIRST_SYNC_POLL_MS;
195
+ }
196
+
197
+ /** The database this gate is holding back. */
198
+ get database(): StrandDatabase {
199
+ return this.deps.database;
200
+ }
201
+
202
+ /** True once the Header was seen (or the gate was force-opened); the loop is then done. */
203
+ get isOpen(): boolean {
204
+ return this.opened;
205
+ }
206
+
207
+ /** Arm the probe loop. The first probe runs after one interval — the caller already probed once. */
208
+ start(): void {
209
+ if (this.stopped || this.opened || this.timer !== undefined) return;
210
+ this.schedule();
211
+ log('[%s] first-sync gate armed: no Strand.Header held yet; probing every %dms',
212
+ this.deps.label, this.pollIntervalMs);
213
+ }
214
+
215
+ /**
216
+ * Force-open: the caller has just written the Header itself (a founder bootstrap run
217
+ * against the gated database). Stops the loop; `onHeaderHeld` is NOT invoked.
218
+ */
219
+ open(): void {
220
+ if (this.opened) return;
221
+ this.opened = true;
222
+ this.stop();
223
+ log('[%s] first-sync gate opened by the caller (Header written locally)', this.deps.label);
224
+ }
225
+
226
+ /** Disarm the loop; a probe already in flight completes and is then ignored. */
227
+ stop(): void {
228
+ this.stopped = true;
229
+ if (this.timer !== undefined) {
230
+ this.scheduler.clearTimeout(this.timer);
231
+ this.timer = undefined;
232
+ }
233
+ }
234
+
235
+ private schedule(): void {
236
+ this.timer = this.scheduler.setTimeout(() => {
237
+ this.timer = undefined;
238
+ void this.probe();
239
+ }, this.pollIntervalMs);
240
+ }
241
+
242
+ private async probe(): Promise<void> {
243
+ if (this.stopped || this.opened || this.probing) return;
244
+ this.probing = true;
245
+ const held = await strandFirstSyncComplete(this.deps.database.getDatabase(), this.deps.label)
246
+ .finally(() => { this.probing = false; });
247
+ // Re-check after the await: a release or a force-open may have landed mid-read.
248
+ if (this.stopped || this.opened) return;
249
+ if (!held) {
250
+ this.schedule();
251
+ return;
252
+ }
253
+ this.opened = true;
254
+ this.stop();
255
+ log('[%s] first-sync gate opened: Strand.Header and every App table read from a peer', this.deps.label);
256
+ this.deps.onHeaderHeld();
257
+ }
258
+ }