@serfab/cadre-core 0.11.0 → 0.13.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 (121) hide show
  1. package/README.md +3 -2
  2. package/dist/announce-addrs.d.ts +49 -0
  3. package/dist/announce-addrs.js +78 -0
  4. package/dist/announce-addrs.js.map +1 -0
  5. package/dist/cadre-node.d.ts +607 -53
  6. package/dist/cadre-node.js +1268 -157
  7. package/dist/cadre-node.js.map +1 -1
  8. package/dist/control-database.d.ts +174 -23
  9. package/dist/control-database.js +317 -107
  10. package/dist/control-database.js.map +1 -1
  11. package/dist/control-read-retry.d.ts +103 -0
  12. package/dist/control-read-retry.js +145 -0
  13. package/dist/control-read-retry.js.map +1 -0
  14. package/dist/control-retry.d.ts +101 -0
  15. package/dist/control-retry.js +108 -0
  16. package/dist/control-retry.js.map +1 -0
  17. package/dist/control-schema.d.ts +1 -1
  18. package/dist/control-schema.js +50 -6
  19. package/dist/control-schema.js.map +1 -1
  20. package/dist/control-write-retry.d.ts +59 -35
  21. package/dist/control-write-retry.js +21 -92
  22. package/dist/control-write-retry.js.map +1 -1
  23. package/dist/delegate-admission.d.ts +17 -15
  24. package/dist/delegate-admission.js +17 -15
  25. package/dist/delegate-admission.js.map +1 -1
  26. package/dist/enrolled-machine-store-file.d.ts +13 -0
  27. package/dist/enrolled-machine-store-file.js +41 -0
  28. package/dist/enrolled-machine-store-file.js.map +1 -0
  29. package/dist/enrolled-machine-store.d.ts +81 -0
  30. package/dist/enrolled-machine-store.js +225 -0
  31. package/dist/enrolled-machine-store.js.map +1 -0
  32. package/dist/file-durable-slot.js +5 -3
  33. package/dist/file-durable-slot.js.map +1 -1
  34. package/dist/index.d.ts +10 -6
  35. package/dist/index.js +39 -12
  36. package/dist/index.js.map +1 -1
  37. package/dist/membership-connection-gater.d.ts +226 -26
  38. package/dist/membership-connection-gater.js +316 -30
  39. package/dist/membership-connection-gater.js.map +1 -1
  40. package/dist/node-local-snapshot.d.ts +6 -0
  41. package/dist/node-local-snapshot.js +7 -1
  42. package/dist/node-local-snapshot.js.map +1 -1
  43. package/dist/{strand-backfill.d.ts → peer-join-backfill.d.ts} +49 -20
  44. package/dist/{strand-backfill.js → peer-join-backfill.js} +146 -56
  45. package/dist/peer-join-backfill.js.map +1 -0
  46. package/dist/peer-record.d.ts +26 -0
  47. package/dist/peer-record.js +32 -0
  48. package/dist/peer-record.js.map +1 -1
  49. package/dist/relay-addrs.d.ts +145 -22
  50. package/dist/relay-addrs.js +314 -31
  51. package/dist/relay-addrs.js.map +1 -1
  52. package/dist/relay-reservation.d.ts +20 -15
  53. package/dist/relay-reservation.js +18 -14
  54. package/dist/relay-reservation.js.map +1 -1
  55. package/dist/strand-addr-protocol.js +16 -0
  56. package/dist/strand-addr-protocol.js.map +1 -1
  57. package/dist/strand-database.d.ts +12 -0
  58. package/dist/strand-database.js +16 -0
  59. package/dist/strand-database.js.map +1 -1
  60. package/dist/strand-formation-manager.d.ts +22 -9
  61. package/dist/strand-formation-manager.js +20 -12
  62. package/dist/strand-formation-manager.js.map +1 -1
  63. package/dist/strand-formation-protocol.d.ts +83 -9
  64. package/dist/strand-formation-protocol.js +86 -10
  65. package/dist/strand-formation-protocol.js.map +1 -1
  66. package/dist/strand-instance-manager.d.ts +151 -17
  67. package/dist/strand-instance-manager.js +237 -32
  68. package/dist/strand-instance-manager.js.map +1 -1
  69. package/dist/strand-membership-writer.d.ts +118 -14
  70. package/dist/strand-membership-writer.js +203 -15
  71. package/dist/strand-membership-writer.js.map +1 -1
  72. package/dist/strand-network-config.d.ts +84 -0
  73. package/dist/strand-network-config.js +136 -0
  74. package/dist/strand-network-config.js.map +1 -0
  75. package/dist/strand-revocation-enforcer.d.ts +392 -0
  76. package/dist/strand-revocation-enforcer.js +533 -0
  77. package/dist/strand-revocation-enforcer.js.map +1 -0
  78. package/dist/strand-solicitation.d.ts +10 -3
  79. package/dist/strand-solicitation.js +12 -7
  80. package/dist/strand-solicitation.js.map +1 -1
  81. package/dist/strand-wake-protocol.d.ts +47 -2
  82. package/dist/strand-wake-protocol.js +76 -6
  83. package/dist/strand-wake-protocol.js.map +1 -1
  84. package/dist/strand-watcher.js +6 -1
  85. package/dist/strand-watcher.js.map +1 -1
  86. package/dist/types.d.ts +282 -43
  87. package/dist/types.js +7 -1
  88. package/dist/types.js.map +1 -1
  89. package/package.json +16 -8
  90. package/src/announce-addrs.ts +93 -0
  91. package/src/cadre-node.ts +1366 -171
  92. package/src/control-database.ts +2628 -2406
  93. package/src/control-read-retry.ts +167 -0
  94. package/src/control-retry.ts +190 -0
  95. package/src/control-schema.ts +50 -6
  96. package/src/control-write-retry.ts +30 -130
  97. package/src/delegate-admission.ts +17 -15
  98. package/src/enrolled-machine-store-file.ts +43 -0
  99. package/src/enrolled-machine-store.ts +280 -0
  100. package/src/file-durable-slot.ts +5 -3
  101. package/src/index.ts +477 -407
  102. package/src/membership-connection-gater.ts +539 -204
  103. package/src/node-local-snapshot.ts +13 -1
  104. package/src/peer-addr-book.ts +251 -250
  105. package/src/{strand-backfill.ts → peer-join-backfill.ts} +187 -75
  106. package/src/peer-record.ts +33 -0
  107. package/src/relay-addrs.ts +436 -111
  108. package/src/relay-reservation.ts +828 -823
  109. package/src/strand-addr-protocol.ts +17 -0
  110. package/src/strand-database.ts +17 -0
  111. package/src/strand-formation-manager.ts +511 -491
  112. package/src/strand-formation-protocol.ts +808 -692
  113. package/src/strand-instance-manager.ts +899 -611
  114. package/src/strand-membership-writer.ts +232 -15
  115. package/src/strand-network-config.ts +172 -0
  116. package/src/strand-revocation-enforcer.ts +653 -0
  117. package/src/strand-solicitation.ts +484 -472
  118. package/src/strand-wake-protocol.ts +94 -8
  119. package/src/strand-watcher.ts +319 -314
  120. package/src/types.ts +1554 -1309
  121. package/dist/strand-backfill.js.map +0 -1
@@ -228,6 +228,28 @@ async function strandTableCount(db: Database, table: 'Header' | 'Member' | 'Mana
228
228
  return 0;
229
229
  }
230
230
 
231
+ /**
232
+ * Whether any `Strand.Revocation` row has ever retired a `Manager` seat — the
233
+ * exact condition that permanently closes the founding branch of
234
+ * `Manager.Authorized` (see the schema's bootstrap-branch comment). Unfiltered
235
+ * scan + JavaScript comparison, the module's scan-not-seek idiom (rationale on
236
+ * {@link scanMemberPeers}).
237
+ *
238
+ * NOTE: walks the whole append-only `Strand.Revocation` table when no `Manager`
239
+ * tombstone is present (it returns on the first one it finds). Cheap at strand
240
+ * scale; if membership churn ever makes that table large — the growth the
241
+ * `Revocation` schema comment already flags, tracked by `debt-strand-tombstone-reap`
242
+ * — this wants a stored "founding closed" marker rather than a scan per call.
243
+ */
244
+ async function strandHasManagerRevocation(db: Database): Promise<boolean> {
245
+ for await (const row of db.eval('select TableName from Strand.Revocation')) {
246
+ if (row.TableName === 'Manager') {
247
+ return true;
248
+ }
249
+ }
250
+ return false;
251
+ }
252
+
231
253
  /**
232
254
  * Insert the singleton `Strand.Header` if absent.
233
255
  *
@@ -304,12 +326,25 @@ async function insertFounderMemberIfAbsent(db: Database, memberKey: string, stra
304
326
  * `Manager.OnlyClosed` is a deferred (subquery) check evaluated at commit. The
305
327
  * sequential auto-commit Header→Member→Manager order in
306
328
  * {@link bootstrapFounderMembership} satisfies both.
329
+ *
330
+ * SEALED strands are skipped, not re-founded: a strand whose sole manager has
331
+ * {@link sealStrand}ed also has zero `Manager` rows, but its founding branch is
332
+ * closed for good (the schema's bootstrap branch requires no `Manager`-table
333
+ * `Revocation` row to exist). Attempting the founding insert here would be
334
+ * schema-rejected and turn every founder restart of a sealed strand into a
335
+ * loud failure — so this mirrors the schema's own gate and returns quietly.
336
+ * A crash BETWEEN the founding Member and Manager inserts leaves no Revocation
337
+ * rows at all, so that restart still completes the founding.
307
338
  */
308
339
  async function insertFounderManagerIfAbsent(db: Database, memberKey: string, strandId: string): Promise<void> {
309
340
  if (await strandTableCount(db, 'Manager') > 0) {
310
341
  log('Manager already present for strand %s; skipping founder Manager', strandId);
311
342
  return;
312
343
  }
344
+ if (await strandHasManagerRevocation(db)) {
345
+ log('Strand %s has retired a manager seat (sealed); skipping founder Manager', strandId);
346
+ return;
347
+ }
313
348
  await db.exec(
314
349
  `insert into Strand.Manager (MemberKey, Generation, StampId)
315
350
  with context ManagerKey = null, Signature = null
@@ -814,8 +849,15 @@ export interface RevokeMemberParams {
814
849
  * A manager must resign its `Manager` row before (or in the same transaction
815
850
  * as) losing membership — `Member.NotAManager` rejects un-membering a key that
816
851
  * still holds a Manager row. And the strand never drops to zero members
817
- * (`Member.MinOneMember`, a local-count floor with the same cross-node caveat
818
- * as `MinOneManager`).
852
+ * (`Member.MinOneMember`, a local-count floor with the cross-node caveat its
853
+ * schema NOTE states).
854
+ *
855
+ * Callers running inside a cadre runtime should follow this write with
856
+ * `CadreNode.refreshRevocationEnforcement(strandId)`. The removed party's
857
+ * machines are refused at the network layer by the strand's revoked-peer gate
858
+ * (`strand-revocation-enforcer.ts`), which polls for membership changes because
859
+ * this bare `Database` handle raises no event the runtime can hook — so without
860
+ * that call the cut lands up to one poll interval (default 30 s) later.
819
861
  *
820
862
  * @param db - The closed strand's database.
821
863
  * @param params - The revoking manager keypair and the target member key.
@@ -872,6 +914,11 @@ export interface LeaveStrandParams {
872
914
  * The same floors as {@link revokeMember} apply: a manager must resign first
873
915
  * (`NotAManager`) and the last member cannot leave (`MinOneMember`).
874
916
  *
917
+ * Like {@link revokeMember}, a caller inside a cadre runtime should follow this
918
+ * with `CadreNode.refreshRevocationEnforcement(strandId)` — here it is what
919
+ * makes the departing node notice its own departure promptly (the gate emits
920
+ * `strand:revoked` when it finds this node's own peer id revoked).
921
+ *
875
922
  * @param db - The closed strand's database.
876
923
  * @param params - The departing member's own keypair.
877
924
  * @throws If `Member.Authorized`, `Member.NotAManager`, or `Member.MinOneMember`
@@ -1172,7 +1219,17 @@ async function deleteOwnMemberPeer(db: Database, params: RemoveOwnPeerParams, st
1172
1219
  await insertRevocation(db, 'MemberPeer', stampId, memberKeyPair);
1173
1220
  }
1174
1221
 
1175
- /** The manager branch: a manager signs the manager-remove-tagged digest + tombstone. */
1222
+ /**
1223
+ * The manager branch: a manager signs the manager-remove-tagged digest + tombstone.
1224
+ *
1225
+ * NOTE: clearing an ORPHANED binding (one whose member was already removed) also
1226
+ * forgets the NETWORK DENIAL of that peer id. The orphaned `MemberPeer` row IS
1227
+ * the record the strand revoked-peer gate derives its deny set from
1228
+ * (`strand-revocation-enforcer.ts`) — the `Strand.Revocation` tombstone keeps
1229
+ * only the stamp, not the peer id — so once the orphan is cleared, remaining
1230
+ * members' nodes resume answering and dialing that peer. Only clear a binding
1231
+ * that should never have existed, or a peer that is truly gone.
1232
+ */
1176
1233
  async function deleteMemberPeerByManager(
1177
1234
  db: Database,
1178
1235
  params: RemoveMemberPeerByManagerParams,
@@ -1382,13 +1439,19 @@ export interface RemoveManagerParams {
1382
1439
  * — deferred — IS enforced here: a signer that is neither an existing manager nor
1383
1440
  * the target itself is rejected at commit.
1384
1441
  *
1385
- * THE LAST MANAGER CANNOT BE REMOVED. `Manager.MinOneManager` (`check on delete`,
1386
- * deferred, so it sees the POST-delete count) rejects any delete that would leave the
1387
- * strand with zero managers — an admin-less closed strand could never admit anyone
1388
- * again, since `Invite`, `addMemberByManager`, and `addManager` all require a
1389
- * `Manager` row. The bootstrap branch of `Manager.Authorized` is gated to INSERTs
1442
+ * THE SOLE MANAGER CANNOT ORDINARILY RESIGN — it SEALS instead. The `'resign'`
1443
+ * branch of `Manager.Authorized` requires at least one manager in the POST-delete
1444
+ * image, so a resignation that would empty the `Manager` table is schema-rejected;
1445
+ * emptying it deliberately is a distinct, self-signed act — {@link sealStrand} —
1446
+ * that permanently freezes admission. This writer refuses the sole-manager
1447
+ * self-resignation up front with an error naming `sealStrand`; that TS guard is UX,
1448
+ * not the trust boundary — the schema rejects a mis-counted `'resign'` approval
1449
+ * regardless. Reading the count inside a caller-joined transaction is correct: an
1450
+ * add-then-resign hand-off composed in one transaction sees 2 and passes, while
1451
+ * the already-rejected delete-then-add swap sees 1 and is refused here as well as
1452
+ * by the schema. The bootstrap branch of `Manager.Authorized` is gated to INSERTs
1390
1453
  * (`old.MemberKey is null`), so it does not waive the signature check on a delete
1391
- * that drops the count toward the floor: a second-to-last removal is authorized
1454
+ * that drops the count toward zero: a second-to-last removal is authorized
1392
1455
  * exactly like any other.
1393
1456
  *
1394
1457
  * HAND-OFF ORDER: a sole manager transferring control must ADD the successor FIRST
@@ -1397,15 +1460,19 @@ export interface RemoveManagerParams {
1397
1460
  * successor's insert has no other manager to authorize it once the sole manager's
1398
1461
  * row is gone.
1399
1462
  *
1400
- * Cross-node caveat: `MinOneManager` counts rows visible to THIS transaction, so two
1401
- * nodes concurrently removing different managers can each see a survivor and still
1402
- * converge to zero. Noted in the schema; a cross-node floor is not attempted here.
1463
+ * Cross-node caveat: the `'resign'`/`'seal'` branches count rows visible to THIS
1464
+ * transaction, so two nodes concurrently removing different managers can each see
1465
+ * a survivor and still converge to zero — an unintended seal. Noted in the schema
1466
+ * (the `Manager` table's seal-propagation NOTE); a cross-node floor is not
1467
+ * attempted here.
1403
1468
  *
1404
1469
  * @param db - The closed strand's database.
1405
1470
  * @param params - The authorizing keypair and the target manager key.
1406
- * @throws If `Manager.Authorized` rejects (a signer that is neither another existing
1407
- * manager nor the target itself) or `Manager.MinOneManager` rejects (the removal
1408
- * would leave no managers); the whole delete+tombstone transaction rolls back.
1471
+ * @throws If the signer IS the target and it is the sole manager (before writing —
1472
+ * the operation the caller wants is {@link sealStrand}), or if `Manager.Authorized`
1473
+ * rejects (a signer that is neither another existing manager nor the target
1474
+ * itself, or a raw sole-manager `'resign'` whose post-image count is zero); the
1475
+ * whole delete+tombstone transaction rolls back.
1409
1476
  */
1410
1477
  export async function removeManager(db: Database, params: RemoveManagerParams): Promise<void> {
1411
1478
  const { byManagerKeyPair, targetManagerKey } = params;
@@ -1415,6 +1482,12 @@ export async function removeManager(db: Database, params: RemoveManagerParams):
1415
1482
  return;
1416
1483
  }
1417
1484
  const tag = byManagerKeyPair.publicKeyB64 === targetManagerKey ? 'resign' : 'remove';
1485
+ if (tag === 'resign' && await strandTableCount(db, 'Manager') === 1) {
1486
+ throw new Error(
1487
+ 'Cannot resign as the SOLE manager: an ordinary resignation never empties the '
1488
+ + 'Manager table. To permanently freeze admission instead, call sealStrand.',
1489
+ );
1490
+ }
1418
1491
  const signature = signStrandApproval(
1419
1492
  ['Strand.Manager', tag, targetManagerKey, target.stampId],
1420
1493
  byManagerKeyPair.privateKeyB64,
@@ -1430,3 +1503,147 @@ export async function removeManager(db: Database, params: RemoveManagerParams):
1430
1503
  });
1431
1504
  log('Removed manager %s by %s', targetManagerKey, byManagerKeyPair.publicKeyB64);
1432
1505
  }
1506
+
1507
+ // ── Sealing (the sole manager permanently freezes admission) ──────────────────
1508
+
1509
+ /** Parameters for {@link sealStrand}. */
1510
+ export interface SealStrandParams {
1511
+ /**
1512
+ * The SOLE manager's strand keypair. Its `publicKeyB64` must be the one live
1513
+ * `Strand.Manager` row; it signs the seal-tagged digest over its own key and
1514
+ * live stamp, and files the accompanying tombstone (it is still a committed
1515
+ * member, which is what `Revocation.Authorized` checks).
1516
+ */
1517
+ managerKeyPair: Ed25519KeyPair;
1518
+ }
1519
+
1520
+ /**
1521
+ * Seal the strand: the SOLE manager deliberately steps down, permanently freezing
1522
+ * membership.
1523
+ *
1524
+ * A sealed strand has zero `Manager` rows, and every admission path — issuing or
1525
+ * cancelling an invitation, consuming one issued BEFORE the seal
1526
+ * (`ConsumedInvite.NotSealed`), admitting a member, promoting a manager — requires
1527
+ * one, so nobody can ever be let in again. That freeze is a privacy guarantee to
1528
+ * everyone who contributed: no key holds the power to admit a party who would then
1529
+ * read the strand's whole history. Sealing is irreversible — the founding branch
1530
+ * of `Manager.Authorized` closes for good once any manager stamp is retired into
1531
+ * `Strand.Revocation`. What remains possible: members may {@link leaveStrand}
1532
+ * (floored by `MinOneMember`), manage their own `MemberPeer` rows, and file
1533
+ * `Revocation` tombstones.
1534
+ *
1535
+ * The `'seal'` branch of `Manager.Authorized` accepts a self-signed delete only
1536
+ * when the POST-image manager count is ZERO — a distinct action tag from
1537
+ * `'resign'`, so a captured resignation approval can never seal and a raw writer
1538
+ * cannot seal by accident. Like {@link removeManager}, the delete pairs with its
1539
+ * `Strand.Revocation` tombstone in one transaction.
1540
+ *
1541
+ * The TS count/identity checks below are UX guards, not the trust boundary — the
1542
+ * schema rejects a mis-tagged or mis-counted approval regardless of what this
1543
+ * writer (or any raw writer) presents.
1544
+ *
1545
+ * NOTE: a seal approval is a bearer token for its row incarnation, like every other
1546
+ * self-signed approval here — but the `'seal'` branch's post-image count of 0 is
1547
+ * satisfied for EVERY row deleted in the same transaction, so N managers each
1548
+ * presenting a valid `'seal'` approval seal jointly (pinned as an accepted case in
1549
+ * `strand-seal.spec.ts`). Unreachable today: this writer mints a seal signature only
1550
+ * while its caller is the SOLE manager, and spends it immediately, so a second
1551
+ * manager never has one to combine. If seal-approval MINTING is ever split from
1552
+ * spending — offline signing, a two-phase seal, a queued approval — one manager
1553
+ * holding another's unspent seal approval could freeze the strand irreversibly
1554
+ * without its current consent; re-gate here (or narrow the schema branch) then.
1555
+ *
1556
+ * @param db - The closed strand's database.
1557
+ * @param params - The sole manager's own keypair.
1558
+ * @throws If more than one manager exists (the caller wants {@link removeManager}),
1559
+ * or the supplied keypair holds no `Manager` row while one exists, or the strand
1560
+ * holds no `Manager` row and has never retired one (not founded yet — see
1561
+ * {@link isStrandSealed}), or any schema constraint rejects; the whole
1562
+ * delete+tombstone transaction rolls back. A strand that is ALREADY sealed logs
1563
+ * and returns quietly (restart-safe, matching {@link bootstrapFounderMembership}
1564
+ * and {@link removeManager}'s absent-row no-op).
1565
+ */
1566
+ export async function sealStrand(db: Database, params: SealStrandParams): Promise<void> {
1567
+ const { managerKeyPair } = params;
1568
+ const managerCount = await strandTableCount(db, 'Manager');
1569
+ if (managerCount === 0) {
1570
+ // Zero managers is NOT by itself a seal — a closed strand between its Header
1571
+ // and Manager founding inserts looks identical. The retired `Manager` stamp is
1572
+ // what separates "frozen forever" from "still foundable"; returning quietly on
1573
+ // the latter would report a seal that never happened.
1574
+ if (await strandHasManagerRevocation(db)) {
1575
+ log('Strand already sealed (no Manager rows, manager stamp retired); skipping seal');
1576
+ return;
1577
+ }
1578
+ throw new Error(
1579
+ 'Cannot seal: the strand holds no Manager row and has never retired one — it is '
1580
+ + 'not founded yet, not sealed. Bootstrap the founding manager first.',
1581
+ );
1582
+ }
1583
+ if (managerCount > 1) {
1584
+ throw new Error(
1585
+ `Cannot seal: ${managerCount} managers exist and sealing is the SOLE manager's act. `
1586
+ + 'The other managers must step down first (removeManager).',
1587
+ );
1588
+ }
1589
+ const self = await managerRow(db, managerKeyPair.publicKeyB64);
1590
+ if (self == null) {
1591
+ throw new Error('Cannot seal: the supplied keypair does not hold the sole Manager row.');
1592
+ }
1593
+ const signature = signStrandApproval(
1594
+ ['Strand.Manager', 'seal', managerKeyPair.publicKeyB64, self.stampId],
1595
+ managerKeyPair.privateKeyB64,
1596
+ );
1597
+ await inStrandTransaction(db, async () => {
1598
+ await db.exec(
1599
+ `delete from Strand.Manager
1600
+ with context ManagerKey = ?, Signature = ?
1601
+ where MemberKey = ?`,
1602
+ [managerKeyPair.publicKeyB64, signature, managerKeyPair.publicKeyB64],
1603
+ );
1604
+ await insertRevocation(db, 'Manager', self.stampId, managerKeyPair);
1605
+ });
1606
+ log('Sealed strand: sole manager %s stepped down', managerKeyPair.publicKeyB64);
1607
+ }
1608
+
1609
+ /**
1610
+ * Whether the strand is `Type = 'c'` (closed). Reads the singleton `Header` via
1611
+ * the same `db.eval` scan idiom as {@link strandTableCount}; a strand with no
1612
+ * `Header` row yet reports `false`.
1613
+ */
1614
+ async function strandIsClosed(db: Database): Promise<boolean> {
1615
+ for await (const row of db.eval('select Type from Strand.Header')) {
1616
+ return row.Type === 'c';
1617
+ }
1618
+ return false;
1619
+ }
1620
+
1621
+ /**
1622
+ * Whether the strand is sealed: a CLOSED strand that holds no `Manager` rows AND
1623
+ * has retired at least one `Manager` stamp into `Strand.Revocation`.
1624
+ *
1625
+ * All three conjuncts are load-bearing:
1626
+ * - `Header.Type` — an OPEN strand never holds `Manager` rows at all
1627
+ * (`Manager.OnlyClosed`), so a bare manager-count test would report every open
1628
+ * strand as sealed.
1629
+ * - the manager count — sealing IS the empty `Manager` table.
1630
+ * - the retired `Manager` stamp — "sealed" means admission is frozen FOREVER, and
1631
+ * what makes it permanent is the founding branch of `Manager.Authorized`
1632
+ * refusing to re-seat a generation-0 manager once any `Manager` stamp has been
1633
+ * retired. Without this conjunct a closed strand that is merely NOT FOUNDED YET
1634
+ * reads as sealed: {@link bootstrapFounderMembership} commits `Header`,
1635
+ * `Member` and `Manager` as three sequential statements, so the window between
1636
+ * the first and the last is exactly "closed, zero managers, still foundable".
1637
+ *
1638
+ * Reads locally visible rows — a node that has not yet converged on the seal
1639
+ * still reports `false` (the schema's seal-propagation NOTE).
1640
+ *
1641
+ * @param db - The strand's database.
1642
+ * @returns `true` iff the strand is closed, holds zero `Manager` rows, and has
1643
+ * permanently closed its founding branch.
1644
+ */
1645
+ export async function isStrandSealed(db: Database): Promise<boolean> {
1646
+ return await strandIsClosed(db)
1647
+ && await strandTableCount(db, 'Manager') === 0
1648
+ && await strandHasManagerRevocation(db);
1649
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Derive the per-STRAND-node view of the machine's one `NetworkConfig`.
3
+ *
4
+ * A cadre machine runs one **control** libp2p node plus one more libp2p node per
5
+ * strand, and every one of them is built from the SAME operator-written
6
+ * `NetworkConfig` block. Two of its fields describe a single endpoint on the host
7
+ * and therefore cannot be inherited literally by a second node on that host:
8
+ *
9
+ * - **`listenAddrs` with a fixed port.** `cadre-cli`'s example config ships
10
+ * `/ip4/0.0.0.0/tcp/4001`. The control node binds it first; every strand node
11
+ * then tries to bind the same port and fails with `EADDRINUSE`, so a machine
12
+ * configured with a fixed port could not start any strand at all.
13
+ * - **`announceAddrs` / `appendAnnounceAddrs`.** Any concrete announce entry names
14
+ * a port, and that port is the CONTROL node's. A strand node advertising it
15
+ * sends peers to the control node — and `announceAddrs` REPLACES the advertised
16
+ * set (see `announce-addrs.ts`), so that wrong address would be the ONLY thing
17
+ * the strand node publishes.
18
+ *
19
+ * The reachability model this rests on: **a strand node is not separately dialable
20
+ * at a published fixed address.** It is reached through (a) its ephemeral direct
21
+ * listener plus the addresses peers observe for it, and (b) circuit relay —
22
+ * `network.relayAddrs` resolves to per-relay `<relay>/p2p-circuit` listen entries
23
+ * that strand nodes DO inherit correctly, because the port inside such an entry is
24
+ * the relay's, not a local bind (`relay-addrs.ts`).
25
+ *
26
+ * Everything else in `NetworkConfig` — `relayAddrs`, `transports`,
27
+ * `connectionGater`, `enableRelay` — is inherited by the caller; this module
28
+ * only owns the two host-endpoint fields above. One caveat on `connectionGater`:
29
+ * an OPEN strand's node gets it unchanged, while a CLOSED strand's node composes
30
+ * revoked-peer denial onto it in `strand-instance-manager.ts` (every supplied
31
+ * hook still honored — see `strand-revocation-enforcer.ts`).
32
+ *
33
+ * It does carry one non-address option out with them: the WebSocket transport switch
34
+ * a `/ws` listen entry implies (`relay-addrs.ts` → `resolveTransportOptions`). That is
35
+ * DERIVED from the listen entries rather than inherited, so it has to be computed
36
+ * wherever they are, and a strand node whose listen entries reached libp2p without it
37
+ * would bind no WebSocket listener and report nothing.
38
+ */
39
+
40
+ import { multiaddr, type Component } from '@multiformats/multiaddr';
41
+ import { resolveListenAddrs, resolveTransportOptions } from './relay-addrs.js';
42
+ import type { NetworkConfig } from './types.js';
43
+
44
+ /**
45
+ * The address-shaped `createLibp2pNode` options a strand node gets, ready to spread
46
+ * into the rest of its options.
47
+ *
48
+ * There is deliberately no announce field here: a strand node announces NOTHING the
49
+ * operator configured, only what libp2p derives for it (its own listeners, observed
50
+ * addresses, and any `/p2p-circuit` address a relay reservation earns).
51
+ *
52
+ * NOTE: accepted tradeoff — a hosted deployment that wants a strand node reachable
53
+ * at its own published public port has no way to say so; the announce config is
54
+ * dropped for strand nodes wholesale rather than per-strand. Nothing needs that
55
+ * today, and the alternative is a per-strand network-config surface. Revisit if a
56
+ * hosted or reverse-proxy deployment ever needs direct-dialable strand nodes.
57
+ */
58
+ export interface StrandNodeAddrs {
59
+ /**
60
+ * Listen entries for this strand node, or `undefined` when the operator
61
+ * configured neither `listenAddrs` nor `relayAddrs` — in which case the caller
62
+ * omits the option and inherits `@optimystic/db-p2p`'s own default.
63
+ */
64
+ listenAddrs?: string[];
65
+ /**
66
+ * The WebSocket transport switch those listen entries imply, present only when one
67
+ * of them names WebSocket (`relay-addrs.ts` → `resolveTransportOptions`). Not an
68
+ * address — it is deliberately `0` and nothing binds it — but it lives here because
69
+ * it is derived from, and must travel with, the listen entries above. Without it a
70
+ * `/ws` strand listen entry binds nothing and libp2p reports nothing.
71
+ */
72
+ wsPort?: number;
73
+ }
74
+
75
+ /**
76
+ * The strand-node view of `network`: the control node's resolved listen entries with
77
+ * every fixed direct port rewritten to an ephemeral one, and no announce fields.
78
+ *
79
+ * Validation is unchanged and still fail-fast — `resolveListenAddrs` throws here on a
80
+ * malformed `relayAddrs` entry exactly as it does for the control node.
81
+ *
82
+ * NOTE: announce entries are no longer validated on this path at all; the CONTROL
83
+ * node's own build (`cadre-node.ts`) is the only thing that parses them, and it runs
84
+ * before any strand starts, so a typo still refuses node start today. That is an
85
+ * ORDERING guarantee, not a structural one. Revisit if a strand node is ever built
86
+ * before the control node's libp2p options are resolved — a malformed announce entry
87
+ * would then go unreported until the control node's own build.
88
+ */
89
+ export function strandNodeAddrs(network: NetworkConfig | undefined): StrandNodeAddrs {
90
+ // The default `'configured'` relay route, deliberately NOT the control node's
91
+ // `'search'` route: nothing drives an explicit reservation for a strand node, so a
92
+ // bare `/p2p-circuit` search entry would register a pending reservation nobody fills
93
+ // and leave every NAT'd strand node undialable. See `relay-addrs.ts`.
94
+ const listenAddrs = resolveListenAddrs(network);
95
+ if (!listenAddrs) {
96
+ return {};
97
+ }
98
+ // An explicitly empty `listenAddrs` (the React Native "cannot listen" case) stays
99
+ // empty — rewriting must never resurrect a direct listener that was opted out of.
100
+ const rewritten = dedupe(listenAddrs.map(ephemeralPortListenAddr));
101
+ // Classified AFTER the ephemeral rewrite, so the check reads what this strand node
102
+ // will actually bind. Zeroing a port cannot change an entry's transport — only the
103
+ // `tcp`/`udp` component's value moves — so `/ip4/0.0.0.0/tcp/4002/ws` still resolves
104
+ // to WebSocket as `/ip4/0.0.0.0/tcp/0/ws`.
105
+ return { listenAddrs: rewritten, ...resolveTransportOptions(network, rewritten) };
106
+ }
107
+
108
+ /**
109
+ * One listen entry with its fixed direct port rewritten to `0`, so the OS assigns a
110
+ * free one per node. Interface and transport choices are preserved — `/ws`, a
111
+ * specific-interface bind, a `/udp/…/quic-v1` stack — since only the port is
112
+ * contended.
113
+ *
114
+ * Passed through UNCHANGED when there is nothing to rewrite, so the operator's exact
115
+ * string survives rather than being round-tripped through the multiaddr normalizer:
116
+ *
117
+ * - a circuit entry (`…/p2p-circuit`), whose embedded port belongs to the RELAY and
118
+ * is not a local bind at all;
119
+ * - a port that is already `0`, or an entry that names no port;
120
+ * - an unparsable entry — libp2p reports a bad listen addr itself, and this helper
121
+ * only ever narrows a port.
122
+ */
123
+ function ephemeralPortListenAddr(listenAddr: string): string {
124
+ let components: Component[];
125
+ try {
126
+ components = multiaddr(listenAddr).getComponents();
127
+ } catch {
128
+ return listenAddr;
129
+ }
130
+ if (components.some((c) => c.name === 'p2p-circuit')) {
131
+ return listenAddr;
132
+ }
133
+ if (!components.some(isFixedPort)) {
134
+ return listenAddr;
135
+ }
136
+ return multiaddr(components.map(withEphemeralPort)).toString();
137
+ }
138
+
139
+ /** A `tcp`/`udp` component naming a port the OS did not choose. */
140
+ function isFixedPort(component: Component): boolean {
141
+ return (component.name === 'tcp' || component.name === 'udp')
142
+ && component.value !== undefined
143
+ && component.value !== '0';
144
+ }
145
+
146
+ /**
147
+ * `component` with its port zeroed, or unchanged when it is not a fixed port.
148
+ *
149
+ * Rebuilt field-by-field rather than spread: a parsed component may carry a `bytes`
150
+ * cache, and `componentsToBytes` PREFERS that cache over `value` — a spread would
151
+ * re-encode the old port.
152
+ */
153
+ function withEphemeralPort(component: Component): Component {
154
+ return isFixedPort(component)
155
+ ? { code: component.code, name: component.name, value: '0' }
156
+ : component;
157
+ }
158
+
159
+ /**
160
+ * Exact-string dedupe, first occurrence wins — matching `relay-addrs.ts`. Entries
161
+ * that differed only by port collapse into one after the rewrite, and libp2p would
162
+ * otherwise try to bind the same ephemeral-port entry twice.
163
+ *
164
+ * NOTE: an operator who writes two fixed ports on the same interface and transport
165
+ * (`/ip4/0.0.0.0/tcp/4001` and `…/tcp/4002`) gets ONE strand listener, not two, since
166
+ * the two entries are identical once zeroed. Intended — the ports were the only thing
167
+ * distinguishing them and neither survives. Revisit if a deployment ever needs a
168
+ * strand node to hold a fixed count of direct listeners.
169
+ */
170
+ function dedupe(addrs: readonly string[]): string[] {
171
+ return [...new Set(addrs)];
172
+ }