@serfab/cadre-core 0.11.0 → 0.12.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 (86) hide show
  1. package/dist/announce-addrs.d.ts +49 -0
  2. package/dist/announce-addrs.js +78 -0
  3. package/dist/announce-addrs.js.map +1 -0
  4. package/dist/cadre-node.d.ts +281 -30
  5. package/dist/cadre-node.js +513 -103
  6. package/dist/cadre-node.js.map +1 -1
  7. package/dist/control-database.d.ts +155 -23
  8. package/dist/control-database.js +272 -104
  9. package/dist/control-database.js.map +1 -1
  10. package/dist/control-read-retry.d.ts +103 -0
  11. package/dist/control-read-retry.js +145 -0
  12. package/dist/control-read-retry.js.map +1 -0
  13. package/dist/control-retry.d.ts +101 -0
  14. package/dist/control-retry.js +108 -0
  15. package/dist/control-retry.js.map +1 -0
  16. package/dist/control-schema.d.ts +1 -1
  17. package/dist/control-schema.js +20 -4
  18. package/dist/control-schema.js.map +1 -1
  19. package/dist/control-write-retry.d.ts +59 -35
  20. package/dist/control-write-retry.js +21 -92
  21. package/dist/control-write-retry.js.map +1 -1
  22. package/dist/delegate-admission.d.ts +17 -15
  23. package/dist/delegate-admission.js +17 -15
  24. package/dist/delegate-admission.js.map +1 -1
  25. package/dist/index.d.ts +6 -4
  26. package/dist/index.js +17 -8
  27. package/dist/index.js.map +1 -1
  28. package/dist/membership-connection-gater.d.ts +211 -25
  29. package/dist/membership-connection-gater.js +309 -29
  30. package/dist/membership-connection-gater.js.map +1 -1
  31. package/dist/peer-record.d.ts +26 -0
  32. package/dist/peer-record.js +32 -0
  33. package/dist/peer-record.js.map +1 -1
  34. package/dist/relay-addrs.d.ts +76 -26
  35. package/dist/relay-addrs.js +115 -27
  36. package/dist/relay-addrs.js.map +1 -1
  37. package/dist/relay-reservation.d.ts +20 -15
  38. package/dist/relay-reservation.js +18 -14
  39. package/dist/relay-reservation.js.map +1 -1
  40. package/dist/strand-addr-protocol.js +16 -0
  41. package/dist/strand-addr-protocol.js.map +1 -1
  42. package/dist/strand-backfill.js +13 -4
  43. package/dist/strand-backfill.js.map +1 -1
  44. package/dist/strand-formation-manager.d.ts +10 -9
  45. package/dist/strand-formation-manager.js +10 -9
  46. package/dist/strand-formation-manager.js.map +1 -1
  47. package/dist/strand-formation-protocol.d.ts +7 -7
  48. package/dist/strand-formation-protocol.js +8 -8
  49. package/dist/strand-formation-protocol.js.map +1 -1
  50. package/dist/strand-instance-manager.d.ts +4 -3
  51. package/dist/strand-instance-manager.js +31 -6
  52. package/dist/strand-instance-manager.js.map +1 -1
  53. package/dist/strand-membership-writer.d.ts +106 -14
  54. package/dist/strand-membership-writer.js +180 -14
  55. package/dist/strand-membership-writer.js.map +1 -1
  56. package/dist/strand-solicitation.d.ts +3 -3
  57. package/dist/strand-solicitation.js +4 -5
  58. package/dist/strand-solicitation.js.map +1 -1
  59. package/dist/strand-wake-protocol.d.ts +47 -2
  60. package/dist/strand-wake-protocol.js +76 -6
  61. package/dist/strand-wake-protocol.js.map +1 -1
  62. package/dist/types.d.ts +74 -29
  63. package/dist/types.js.map +1 -1
  64. package/package.json +8 -8
  65. package/src/announce-addrs.ts +93 -0
  66. package/src/cadre-node.ts +5603 -5158
  67. package/src/control-database.ts +2584 -2406
  68. package/src/control-read-retry.ts +167 -0
  69. package/src/control-retry.ts +190 -0
  70. package/src/control-schema.ts +20 -4
  71. package/src/control-write-retry.ts +30 -130
  72. package/src/delegate-admission.ts +17 -15
  73. package/src/index.ts +435 -407
  74. package/src/membership-connection-gater.ts +533 -204
  75. package/src/peer-record.ts +33 -0
  76. package/src/relay-addrs.ts +218 -111
  77. package/src/relay-reservation.ts +828 -823
  78. package/src/strand-addr-protocol.ts +17 -0
  79. package/src/strand-backfill.ts +389 -366
  80. package/src/strand-formation-manager.ts +492 -491
  81. package/src/strand-formation-protocol.ts +8 -8
  82. package/src/strand-instance-manager.ts +636 -611
  83. package/src/strand-membership-writer.ts +209 -14
  84. package/src/strand-solicitation.ts +6 -7
  85. package/src/strand-wake-protocol.ts +94 -8
  86. package/src/types.ts +74 -29
@@ -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,8 @@ 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).
819
854
  *
820
855
  * @param db - The closed strand's database.
821
856
  * @param params - The revoking manager keypair and the target member key.
@@ -1382,13 +1417,19 @@ export interface RemoveManagerParams {
1382
1417
  * — deferred — IS enforced here: a signer that is neither an existing manager nor
1383
1418
  * the target itself is rejected at commit.
1384
1419
  *
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
1420
+ * THE SOLE MANAGER CANNOT ORDINARILY RESIGN — it SEALS instead. The `'resign'`
1421
+ * branch of `Manager.Authorized` requires at least one manager in the POST-delete
1422
+ * image, so a resignation that would empty the `Manager` table is schema-rejected;
1423
+ * emptying it deliberately is a distinct, self-signed act — {@link sealStrand} —
1424
+ * that permanently freezes admission. This writer refuses the sole-manager
1425
+ * self-resignation up front with an error naming `sealStrand`; that TS guard is UX,
1426
+ * not the trust boundary — the schema rejects a mis-counted `'resign'` approval
1427
+ * regardless. Reading the count inside a caller-joined transaction is correct: an
1428
+ * add-then-resign hand-off composed in one transaction sees 2 and passes, while
1429
+ * the already-rejected delete-then-add swap sees 1 and is refused here as well as
1430
+ * by the schema. The bootstrap branch of `Manager.Authorized` is gated to INSERTs
1390
1431
  * (`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
1432
+ * that drops the count toward zero: a second-to-last removal is authorized
1392
1433
  * exactly like any other.
1393
1434
  *
1394
1435
  * HAND-OFF ORDER: a sole manager transferring control must ADD the successor FIRST
@@ -1397,15 +1438,19 @@ export interface RemoveManagerParams {
1397
1438
  * successor's insert has no other manager to authorize it once the sole manager's
1398
1439
  * row is gone.
1399
1440
  *
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.
1441
+ * Cross-node caveat: the `'resign'`/`'seal'` branches count rows visible to THIS
1442
+ * transaction, so two nodes concurrently removing different managers can each see
1443
+ * a survivor and still converge to zero — an unintended seal. Noted in the schema
1444
+ * (the `Manager` table's seal-propagation NOTE); a cross-node floor is not
1445
+ * attempted here.
1403
1446
  *
1404
1447
  * @param db - The closed strand's database.
1405
1448
  * @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.
1449
+ * @throws If the signer IS the target and it is the sole manager (before writing —
1450
+ * the operation the caller wants is {@link sealStrand}), or if `Manager.Authorized`
1451
+ * rejects (a signer that is neither another existing manager nor the target
1452
+ * itself, or a raw sole-manager `'resign'` whose post-image count is zero); the
1453
+ * whole delete+tombstone transaction rolls back.
1409
1454
  */
1410
1455
  export async function removeManager(db: Database, params: RemoveManagerParams): Promise<void> {
1411
1456
  const { byManagerKeyPair, targetManagerKey } = params;
@@ -1415,6 +1460,12 @@ export async function removeManager(db: Database, params: RemoveManagerParams):
1415
1460
  return;
1416
1461
  }
1417
1462
  const tag = byManagerKeyPair.publicKeyB64 === targetManagerKey ? 'resign' : 'remove';
1463
+ if (tag === 'resign' && await strandTableCount(db, 'Manager') === 1) {
1464
+ throw new Error(
1465
+ 'Cannot resign as the SOLE manager: an ordinary resignation never empties the '
1466
+ + 'Manager table. To permanently freeze admission instead, call sealStrand.',
1467
+ );
1468
+ }
1418
1469
  const signature = signStrandApproval(
1419
1470
  ['Strand.Manager', tag, targetManagerKey, target.stampId],
1420
1471
  byManagerKeyPair.privateKeyB64,
@@ -1430,3 +1481,147 @@ export async function removeManager(db: Database, params: RemoveManagerParams):
1430
1481
  });
1431
1482
  log('Removed manager %s by %s', targetManagerKey, byManagerKeyPair.publicKeyB64);
1432
1483
  }
1484
+
1485
+ // ── Sealing (the sole manager permanently freezes admission) ──────────────────
1486
+
1487
+ /** Parameters for {@link sealStrand}. */
1488
+ export interface SealStrandParams {
1489
+ /**
1490
+ * The SOLE manager's strand keypair. Its `publicKeyB64` must be the one live
1491
+ * `Strand.Manager` row; it signs the seal-tagged digest over its own key and
1492
+ * live stamp, and files the accompanying tombstone (it is still a committed
1493
+ * member, which is what `Revocation.Authorized` checks).
1494
+ */
1495
+ managerKeyPair: Ed25519KeyPair;
1496
+ }
1497
+
1498
+ /**
1499
+ * Seal the strand: the SOLE manager deliberately steps down, permanently freezing
1500
+ * membership.
1501
+ *
1502
+ * A sealed strand has zero `Manager` rows, and every admission path — issuing or
1503
+ * cancelling an invitation, consuming one issued BEFORE the seal
1504
+ * (`ConsumedInvite.NotSealed`), admitting a member, promoting a manager — requires
1505
+ * one, so nobody can ever be let in again. That freeze is a privacy guarantee to
1506
+ * everyone who contributed: no key holds the power to admit a party who would then
1507
+ * read the strand's whole history. Sealing is irreversible — the founding branch
1508
+ * of `Manager.Authorized` closes for good once any manager stamp is retired into
1509
+ * `Strand.Revocation`. What remains possible: members may {@link leaveStrand}
1510
+ * (floored by `MinOneMember`), manage their own `MemberPeer` rows, and file
1511
+ * `Revocation` tombstones.
1512
+ *
1513
+ * The `'seal'` branch of `Manager.Authorized` accepts a self-signed delete only
1514
+ * when the POST-image manager count is ZERO — a distinct action tag from
1515
+ * `'resign'`, so a captured resignation approval can never seal and a raw writer
1516
+ * cannot seal by accident. Like {@link removeManager}, the delete pairs with its
1517
+ * `Strand.Revocation` tombstone in one transaction.
1518
+ *
1519
+ * The TS count/identity checks below are UX guards, not the trust boundary — the
1520
+ * schema rejects a mis-tagged or mis-counted approval regardless of what this
1521
+ * writer (or any raw writer) presents.
1522
+ *
1523
+ * NOTE: a seal approval is a bearer token for its row incarnation, like every other
1524
+ * self-signed approval here — but the `'seal'` branch's post-image count of 0 is
1525
+ * satisfied for EVERY row deleted in the same transaction, so N managers each
1526
+ * presenting a valid `'seal'` approval seal jointly (pinned as an accepted case in
1527
+ * `strand-seal.spec.ts`). Unreachable today: this writer mints a seal signature only
1528
+ * while its caller is the SOLE manager, and spends it immediately, so a second
1529
+ * manager never has one to combine. If seal-approval MINTING is ever split from
1530
+ * spending — offline signing, a two-phase seal, a queued approval — one manager
1531
+ * holding another's unspent seal approval could freeze the strand irreversibly
1532
+ * without its current consent; re-gate here (or narrow the schema branch) then.
1533
+ *
1534
+ * @param db - The closed strand's database.
1535
+ * @param params - The sole manager's own keypair.
1536
+ * @throws If more than one manager exists (the caller wants {@link removeManager}),
1537
+ * or the supplied keypair holds no `Manager` row while one exists, or the strand
1538
+ * holds no `Manager` row and has never retired one (not founded yet — see
1539
+ * {@link isStrandSealed}), or any schema constraint rejects; the whole
1540
+ * delete+tombstone transaction rolls back. A strand that is ALREADY sealed logs
1541
+ * and returns quietly (restart-safe, matching {@link bootstrapFounderMembership}
1542
+ * and {@link removeManager}'s absent-row no-op).
1543
+ */
1544
+ export async function sealStrand(db: Database, params: SealStrandParams): Promise<void> {
1545
+ const { managerKeyPair } = params;
1546
+ const managerCount = await strandTableCount(db, 'Manager');
1547
+ if (managerCount === 0) {
1548
+ // Zero managers is NOT by itself a seal — a closed strand between its Header
1549
+ // and Manager founding inserts looks identical. The retired `Manager` stamp is
1550
+ // what separates "frozen forever" from "still foundable"; returning quietly on
1551
+ // the latter would report a seal that never happened.
1552
+ if (await strandHasManagerRevocation(db)) {
1553
+ log('Strand already sealed (no Manager rows, manager stamp retired); skipping seal');
1554
+ return;
1555
+ }
1556
+ throw new Error(
1557
+ 'Cannot seal: the strand holds no Manager row and has never retired one — it is '
1558
+ + 'not founded yet, not sealed. Bootstrap the founding manager first.',
1559
+ );
1560
+ }
1561
+ if (managerCount > 1) {
1562
+ throw new Error(
1563
+ `Cannot seal: ${managerCount} managers exist and sealing is the SOLE manager's act. `
1564
+ + 'The other managers must step down first (removeManager).',
1565
+ );
1566
+ }
1567
+ const self = await managerRow(db, managerKeyPair.publicKeyB64);
1568
+ if (self == null) {
1569
+ throw new Error('Cannot seal: the supplied keypair does not hold the sole Manager row.');
1570
+ }
1571
+ const signature = signStrandApproval(
1572
+ ['Strand.Manager', 'seal', managerKeyPair.publicKeyB64, self.stampId],
1573
+ managerKeyPair.privateKeyB64,
1574
+ );
1575
+ await inStrandTransaction(db, async () => {
1576
+ await db.exec(
1577
+ `delete from Strand.Manager
1578
+ with context ManagerKey = ?, Signature = ?
1579
+ where MemberKey = ?`,
1580
+ [managerKeyPair.publicKeyB64, signature, managerKeyPair.publicKeyB64],
1581
+ );
1582
+ await insertRevocation(db, 'Manager', self.stampId, managerKeyPair);
1583
+ });
1584
+ log('Sealed strand: sole manager %s stepped down', managerKeyPair.publicKeyB64);
1585
+ }
1586
+
1587
+ /**
1588
+ * Whether the strand is `Type = 'c'` (closed). Reads the singleton `Header` via
1589
+ * the same `db.eval` scan idiom as {@link strandTableCount}; a strand with no
1590
+ * `Header` row yet reports `false`.
1591
+ */
1592
+ async function strandIsClosed(db: Database): Promise<boolean> {
1593
+ for await (const row of db.eval('select Type from Strand.Header')) {
1594
+ return row.Type === 'c';
1595
+ }
1596
+ return false;
1597
+ }
1598
+
1599
+ /**
1600
+ * Whether the strand is sealed: a CLOSED strand that holds no `Manager` rows AND
1601
+ * has retired at least one `Manager` stamp into `Strand.Revocation`.
1602
+ *
1603
+ * All three conjuncts are load-bearing:
1604
+ * - `Header.Type` — an OPEN strand never holds `Manager` rows at all
1605
+ * (`Manager.OnlyClosed`), so a bare manager-count test would report every open
1606
+ * strand as sealed.
1607
+ * - the manager count — sealing IS the empty `Manager` table.
1608
+ * - the retired `Manager` stamp — "sealed" means admission is frozen FOREVER, and
1609
+ * what makes it permanent is the founding branch of `Manager.Authorized`
1610
+ * refusing to re-seat a generation-0 manager once any `Manager` stamp has been
1611
+ * retired. Without this conjunct a closed strand that is merely NOT FOUNDED YET
1612
+ * reads as sealed: {@link bootstrapFounderMembership} commits `Header`,
1613
+ * `Member` and `Manager` as three sequential statements, so the window between
1614
+ * the first and the last is exactly "closed, zero managers, still foundable".
1615
+ *
1616
+ * Reads locally visible rows — a node that has not yet converged on the seal
1617
+ * still reports `false` (the schema's seal-propagation NOTE).
1618
+ *
1619
+ * @param db - The strand's database.
1620
+ * @returns `true` iff the strand is closed, holds zero `Manager` rows, and has
1621
+ * permanently closed its founding branch.
1622
+ */
1623
+ export async function isStrandSealed(db: Database): Promise<boolean> {
1624
+ return await strandIsClosed(db)
1625
+ && await strandTableCount(db, 'Manager') === 0
1626
+ && await strandHasManagerRevocation(db);
1627
+ }
@@ -41,7 +41,7 @@ export interface DisclosureValidator {
41
41
  * Discriminated result of resolving the host strand an invite binds to (responder side):
42
42
  *
43
43
  * - `unbound`: the invite carries no `StrandId` → the responder-provisions path (provision
44
- * a NEW strand; legacy/open).
44
+ * a NEW strand; unbound/open).
45
45
  * - `bound`: the invite names a host strand AND that strand row is present on this responder
46
46
  * → provision-then-record (record consent against it, return it + its membership key).
47
47
  * - `missing`: the invite names a host strand but the row is ABSENT on this responder (e.g.
@@ -286,18 +286,17 @@ export class StrandSolicitationService {
286
286
  * This generates a member key, contacts the responder's cadre, and negotiates
287
287
  * strand formation.
288
288
  *
289
- * @param invitation The open invitation (or just token for legacy API)
289
+ * @param invitation The open invitation
290
290
  * @param disclosure Identity/context information to share with the responder
291
291
  * @param node Optional libp2p node for real protocol handling
292
292
  * @returns The member key and strand info if successful
293
293
  */
294
294
  async formStrand(
295
- invitation: OpenInvitation | string,
295
+ invitation: OpenInvitation,
296
296
  disclosure: StrandFormationDisclosure,
297
297
  node?: Libp2p
298
298
  ): Promise<FormStrandResult> {
299
- // Handle legacy API where just token was passed
300
- const token = typeof invitation === 'string' ? invitation : invitation.token;
299
+ const token = invitation.token;
301
300
  log('Forming strand with token: %s', token);
302
301
 
303
302
  // Generate a new keypair for this strand membership
@@ -310,8 +309,8 @@ export class StrandSolicitationService {
310
309
 
311
310
  log('Generated member key: %s', memberKey);
312
311
 
313
- // If we have a full invitation and a node, use the real protocol
314
- if (typeof invitation !== 'string' && node) {
312
+ // If we have a node, use the real protocol
313
+ if (node) {
315
314
  log('Using native cadre-core formation transport');
316
315
  const { privateKeyB64, publicKeyB64 } = ed25519KeyPairFromLibp2p(privateKey);
317
316
  const usageStampId = generateStampId(memberKey);
@@ -44,9 +44,35 @@ export const WAKE_PROTOCOL = '/sereus/strand-wake/1.0.0';
44
44
  */
45
45
  const MAX_WAKE_SIZE = 64 * 1024;
46
46
 
47
- /** Default time to wait for the ack before abandoning a wake dial (ms). */
47
+ /** Default time to wait for the ack before abandoning ONE wake dial attempt (ms). */
48
48
  const DEFAULT_WAKE_TIMEOUT_MS = 10_000;
49
49
 
50
+ /**
51
+ * Default budget for a WHOLE {@link dialWake} call, in ms — every candidate
52
+ * address together, not each one.
53
+ *
54
+ * Without it the cost of a wake is (candidate count × {@link
55
+ * DEFAULT_WAKE_TIMEOUT_MS}), a number nothing chooses or bounds. That is not
56
+ * hypothetical: an address behind a dropped NAT mapping, or any host that
57
+ * blackholes rather than sending a RST, burns its full attempt timeout instead
58
+ * of failing in milliseconds, so a two-address peer costs 20 s and a
59
+ * five-address one costs 50 s.
60
+ *
61
+ * This makes the TARGET PEER the unit rather than the address — the same
62
+ * decision, for the same reason, as
63
+ * `DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS` in `control-cohort.ts`, and the same
64
+ * chosen number. 20 s is deliberately wider than one attempt timeout so a
65
+ * reachable peer whose signaling address is stale still gets a genuine try at
66
+ * its direct one; a peer needing longer than that is not "asleep and reachable",
67
+ * which is the only case a wake is for. The last attempt inside the budget gets
68
+ * whatever remains of it, so the call returns at the budget, not past it.
69
+ *
70
+ * Override per call with {@link DialWakeOptions.budgetMs} — tests that drive
71
+ * dead addresses on purpose set it low so a dial's duration is a chosen number
72
+ * rather than a transitive libp2p default stretched by machine load.
73
+ */
74
+ export const DEFAULT_WAKE_DIAL_BUDGET_MS = 20_000;
75
+
50
76
  /** Default time the receiver waits for an inbound wake frame before aborting (ms). */
51
77
  const DEFAULT_WAKE_READ_TIMEOUT_MS = 10_000;
52
78
 
@@ -234,19 +260,45 @@ export class StrandWakeService {
234
260
 
235
261
  /** Options for {@link dialWake}. */
236
262
  export interface DialWakeOptions {
237
- /** Per-dial timeout in ms (default {@link DEFAULT_WAKE_TIMEOUT_MS}). */
263
+ /** Per-ATTEMPT timeout in ms (default {@link DEFAULT_WAKE_TIMEOUT_MS}). */
238
264
  timeoutMs?: number;
265
+ /**
266
+ * Budget for the whole call — every candidate together (default
267
+ * {@link DEFAULT_WAKE_DIAL_BUDGET_MS}).
268
+ */
269
+ budgetMs?: number;
239
270
  /** Override the protocol id (defaults to {@link WAKE_PROTOCOL}). */
240
271
  protocolId?: string;
241
272
  }
242
273
 
274
+ /** One candidate's outcome, kept so the thrown error can name every attempt. */
275
+ interface WakeDialFailure {
276
+ addr: Multiaddr;
277
+ error: Error;
278
+ }
279
+
243
280
  /**
244
281
  * Sender side: dial a target's control-network address(es), send a
245
282
  * {@link WakeRequest} over `WAKE_PROTOCOL`, and return the peer's {@link WakeAck}.
246
283
  *
247
284
  * Tries each candidate address in order (signaling/relay first, as produced by
248
285
  * `CadreNode.resolvePeerAddrs`) until one dials, so a NAT'd peer is reachable via
249
- * its circuit-relay address. Throws if no address is dialable.
286
+ * its circuit-relay address. Each attempt is bounded by `timeoutMs` and the call
287
+ * as a whole by `budgetMs` (see {@link DEFAULT_WAKE_DIAL_BUDGET_MS}); a candidate
288
+ * the budget leaves no room for is reported as untried rather than silently
289
+ * dropped.
290
+ *
291
+ * Throws if no address is dialable — with an error naming EVERY candidate and
292
+ * why it failed, not merely the last one. That distinction is not cosmetic:
293
+ * with only the last candidate's message the failure that mattered (usually the
294
+ * signaling address, tried first) is invisible outside a debug log, and the
295
+ * surfaced message points at whichever address happened to be tried last.
296
+ *
297
+ * The candidate loop is deliberately explicit rather than one
298
+ * `dialProtocol(addrs)` call. libp2p sorts any multi-address dial with
299
+ * `defaultAddressSorter`, whose `circuitRelayAddressesLast` pass would demote
300
+ * exactly the signaling address this ordering puts first — silently inverting
301
+ * it, with no per-dial sorter override to opt out of.
250
302
  */
251
303
  export async function dialWake(
252
304
  node: Libp2p,
@@ -259,23 +311,57 @@ export async function dialWake(
259
311
  }
260
312
  const protocolId = options.protocolId ?? WAKE_PROTOCOL;
261
313
  const timeoutMs = options.timeoutMs ?? DEFAULT_WAKE_TIMEOUT_MS;
314
+ const budgetMs = options.budgetMs ?? DEFAULT_WAKE_DIAL_BUDGET_MS;
315
+ const deadline = Date.now() + budgetMs;
262
316
 
263
- let lastError: Error | null = null;
317
+ const failures: WakeDialFailure[] = [];
264
318
  for (const addr of addrs) {
319
+ // Whatever is left of the whole-call budget caps this attempt, so the last
320
+ // candidate inside the budget still gets a real (if shortened) try.
321
+ const remaining = deadline - Date.now();
322
+ if (remaining <= 0) {
323
+ failures.push({ addr, error: new Error(`not tried — the ${budgetMs}ms wake dial budget was spent on earlier addresses`) });
324
+ continue;
325
+ }
326
+ const attemptMs = Math.min(timeoutMs, remaining);
265
327
  try {
266
328
  // One deadline per attempt: its signal aborts the in-flight dialProtocol and
267
329
  // resets the live stream, so neither the connect nor the ack-read leaks.
268
330
  return await withDeadline(
269
- timeoutMs,
331
+ attemptMs,
270
332
  `Wake dial ${addr.toString()}`,
271
- (signal) => sendWake(node, addr, protocolId, request, timeoutMs, signal),
333
+ (signal) => sendWake(node, addr, protocolId, request, attemptMs, signal),
272
334
  );
273
335
  } catch (err) {
274
- lastError = err instanceof Error ? err : new Error(String(err));
336
+ failures.push({ addr, error: err instanceof Error ? err : new Error(String(err)) });
275
337
  log('Wake dial to %s failed: %o', addr.toString(), err);
276
338
  }
277
339
  }
278
- throw lastError ?? new Error('Wake dial failed');
340
+ throw wakeDialError(failures);
341
+ }
342
+
343
+ /**
344
+ * Fold every candidate's failure into the one error {@link dialWake} throws.
345
+ *
346
+ * A single candidate throws its own error unchanged, so a one-address wake reads
347
+ * exactly as it always has (and `rejects.toThrow(/timed out/)` still means what
348
+ * it says). Several candidates produce one message naming each address and its
349
+ * cause, with the FIRST failure as `cause` — the first candidate is the
350
+ * signaling address, which is the one that matters when a relayed peer goes
351
+ * unreachable.
352
+ */
353
+ function wakeDialError(failures: WakeDialFailure[]): Error {
354
+ if (failures.length === 0) {
355
+ return new Error('Wake dial failed');
356
+ }
357
+ if (failures.length === 1) {
358
+ return failures[0].error;
359
+ }
360
+ const detail = failures.map((f) => `${f.addr.toString()} — ${f.error.message}`).join('; ');
361
+ return new Error(
362
+ `Wake dial failed for all ${failures.length} candidate addresses: ${detail}`,
363
+ { cause: failures[0].error },
364
+ );
279
365
  }
280
366
 
281
367
  /**
package/src/types.ts CHANGED
@@ -128,12 +128,12 @@ export const HIBERNATION_TIMEOUTS: Record<LatencyHint, HibernationTimeouts> = {
128
128
  * cadre-core disposes only its own cache wrapper — it never closes the store you
129
129
  * returned. Closing the underlying handle stays the embedder's job.
130
130
  *
131
- * **Prefer the factory form for any persistent backend.** One instance handed to
132
- * every scope also shares ONE cache wrapper (`wrapStorageWithCache` memoizes per
133
- * inner instance), and no scope solely owns it — so the first scope to stop retires
134
- * the wrapper the still-running scopes are reading through. See
135
- * `tickets/backlog/debt-storage-cache-wrap-unrefcounted.md`; an in-memory store is
136
- * unaffected, since it is never wrapped.
131
+ * Either form is safe with a persistent backend. One instance handed to every scope
132
+ * also shares ONE cache wrapper (`wrapStorageWithCache` memoizes per inner instance),
133
+ * which counts its holders: a scope stopping releases only its own claim, and the
134
+ * wrapper is retired when the last scope releases it. The factory form is still the
135
+ * better default for a different reason — it partitions each strand's data, which
136
+ * simplifies cleanup.
137
137
  */
138
138
  export type RawStorageProvider = IRawStorage | ((strandId: string) => IRawStorage);
139
139
 
@@ -174,26 +174,48 @@ export interface StorageConfig {
174
174
  export interface NetworkConfig {
175
175
  listenAddrs?: string[];
176
176
  /**
177
- * Addresses to advertise to peers instead of `listenAddrs` — for a node behind
177
+ * Addresses to advertise to peers **instead of** `listenAddrs` — for a node behind
178
178
  * NAT or a reverse proxy that is reachable at a different address than it binds
179
- * (e.g. `mynode.example.com:4001` in front of a `0.0.0.0:4001` listener).
179
+ * (e.g. `mynode.example.com:4001` in front of a `0.0.0.0:4001` listener). Reaches
180
+ * libp2p's `addresses.announce` via `@optimystic/db-p2p`'s `NodeOptions.announceAddrs`,
181
+ * on this node's control node and on every strand node it runs.
180
182
  *
181
- * Accepted (settable in `cadre.yaml`, via `CADRE_ANNOUNCE_ADDRS`, and through the
182
- * Docker entrypoint) but **not yet applied**: libp2p only takes announce addresses
183
- * at construction (`addresses.announce` / `addresses.appendAnnounce`), and
184
- * `@optimystic/db-p2p`'s `NodeOptions` has no field for either — there is nothing
185
- * in this repo to wire this into. `CadreNode.start()` warns when this is set,
186
- * rather than pretending it works. Unblocked by the `announce-addrs-option`
187
- * request filed against `@optimystic/db-p2p`; wiring lands here once this repo's
188
- * dependency range moves to a version carrying it.
183
+ * **A non-empty value REPLACES the advertised set entirely.** Observed addresses,
184
+ * and the `/p2p-circuit` address earned from a {@link relayAddrs} reservation, are
185
+ * all dropped from what peers are told — so a node configured with both this and
186
+ * `relayAddrs` stops being reachable through its relay. `CadreNode.start()` warns
187
+ * when it sees that combination; it does not refuse it, since a node whose relay
188
+ * slot is decorative may legitimately want only the announced address.
189
189
  *
190
- * Use {@link relayAddrs} for reachability behind NAT today (a reserved relay slot
191
- * gives the node a real dialable circuit address), or `inviteAddressResolver` to
192
- * substitute a reachable address into invite payloads specifically — neither
193
- * changes what this node advertises on the wire, but both cover the operator need
194
- * this field cannot yet.
190
+ * Reach for {@link appendAnnounceAddrs} instead in the common case — one publicly
191
+ * reachable address ADDED to everything else the node advertises. Or for
192
+ * {@link relayAddrs} alone, when the node has no stable public address to name and
193
+ * a relay reservation is what makes it dialable. (`inviteAddressResolver` is a
194
+ * narrower tool again: it substitutes a reachable address into invite payloads
195
+ * only, and changes nothing about what this node advertises on the wire.)
196
+ *
197
+ * An empty array means "unset" — it is dropped rather than forwarded, so it cannot
198
+ * land as an explicit empty announce set. An empty *entry* is a different thing and
199
+ * is rejected: a list of one blank string is non-empty, so it would replace every
200
+ * advertised address with nothing. Malformed entries are rejected on the same terms,
201
+ * both at node start — libp2p itself would not notice either until its first address
202
+ * lookup (see `announce-addrs.ts`), so this repo checks each entry up front.
195
203
  */
196
204
  announceAddrs?: string[];
205
+ /**
206
+ * Addresses to advertise **in addition to** `listenAddrs` — the field most
207
+ * deployments actually want, since it makes a node reachable at a public address
208
+ * without discarding the observed and `/p2p-circuit` addresses it would otherwise
209
+ * advertise. Reaches libp2p's `addresses.appendAnnounce` via
210
+ * `@optimystic/db-p2p`'s `NodeOptions.appendAnnounceAddrs`.
211
+ *
212
+ * **Ignored while {@link announceAddrs} is non-empty** — that is libp2p's own
213
+ * precedence, applied upstream; this repo does not merge the two locally. Set one
214
+ * or the other, not both.
215
+ *
216
+ * Empty-array, blank-entry and malformed-entry handling all match {@link announceAddrs}.
217
+ */
218
+ appendAnnounceAddrs?: string[];
197
219
  /**
198
220
  * Circuit-relay servers this node reserves a slot on, so peers can reach it
199
221
  * from behind NAT. Each entry is the relay's DIRECT dial multiaddr ending in
@@ -201,18 +223,26 @@ export interface NetworkConfig {
201
223
  * `/p2p-circuit` suffix (one is appended for you; an entry that already has
202
224
  * one is passed through unchanged).
203
225
  *
204
- * This is sugar for a circuit entry in {@link listenAddrs}: `relay-addrs.ts`
205
- * resolves the two into one listen list, and listening on a relay's circuit
206
- * addr is what makes libp2p dial it and hold the reservation. A relay named
207
- * here therefore also becomes a delegate-announce target, so this node's
208
- * strand nodes may reserve on it too (see `delegate-admission.ts`).
226
+ * On the CONTROL node this adds libp2p's bare `/p2p-circuit` SEARCH listener to
227
+ * {@link listenAddrs} (`relay-addrs.ts`) and `CadreNode.start()` drives the
228
+ * reservation itself once the control database is up — the ordering matters,
229
+ * because a listener that dials its relay from inside `libp2p.start()` put a
230
+ * sibling in this node's cohort before its own database existed, and a sibling
231
+ * that had not yet replicated this node's membership row refused the bring-up.
232
+ * STRAND nodes inherit the older per-relay `<relay>/p2p-circuit` listener, which
233
+ * has no such ordering hazard. A relay named here also becomes a
234
+ * delegate-announce target, so this node's strand nodes may reserve on it too
235
+ * (see `delegate-admission.ts`).
209
236
  *
210
237
  * Setting this gives the node a listener even when `listenAddrs` is `[]` —
211
238
  * that is the point of naming a relay, but note that React Native hosts
212
239
  * (`reference-app-rn`'s `cadre-phone.ts`) deliberately do NOT listen, and
213
240
  * must not acquire a listener by accident.
214
241
  *
215
- * A malformed entry throws at node start rather than being silently dropped.
242
+ * FAIL-FAST both ways: a malformed entry throws at node start rather than being
243
+ * silently dropped, and a first reservation attempt that lands no
244
+ * `/p2p-circuit` address throws `RelayReservationFailedError` out of `start()`.
245
+ * The fail-soft posture over the same machinery is `CadreNode.reserveRelays()`.
216
246
  */
217
247
  relayAddrs?: string[];
218
248
  /**
@@ -221,6 +251,16 @@ export interface NetworkConfig {
221
251
  * better connectivity and uptime), false for transaction profile nodes.
222
252
  */
223
253
  enableRelay?: boolean;
254
+ /**
255
+ * Cap on concurrent circuit-relay reservations this node's relay server grants to
256
+ * peers it cannot (yet) recognize as authorized members — the boot-ordering window
257
+ * where a genuine member reserves its relay slot before its membership row has
258
+ * replicated here (see `membership-connection-gater.ts` → "The relay-reservation
259
+ * seam"). Authorized members and announced delegate peers are never counted against
260
+ * it. Default `MAX_UNAUTHORIZED_RELAY_RESERVATIONS` (8); 0 refuses every
261
+ * unauthorized reservation. Only meaningful while {@link enableRelay} is on.
262
+ */
263
+ unauthorizedRelayReservationCap?: number;
224
264
  /**
225
265
  * Custom libp2p transports. When omitted, the default transports from
226
266
  * `@optimystic/db-p2p` are used (TCP + circuit relay for Node.js).
@@ -243,6 +283,11 @@ export interface NetworkConfig {
243
283
  * (and other owner-address contexts). When unset, `libp2pNode.getMultiaddrs()`
244
284
  * is used. Hosts behind NAT supply this to substitute their DDNS hostname
245
285
  * and externally-mapped port — see `@serfab/cadre-host`'s NatService.
286
+ *
287
+ * Returned addresses need NOT carry a `/p2p/<peerId>` suffix: `CadreNode`
288
+ * appends its own before publishing or dialing any of them, so a resolver
289
+ * cannot leave a sibling with the mixed suffixed/unsuffixed list that
290
+ * `libp2p.dial` rejects. Returning them already suffixed is equally fine.
246
291
  */
247
292
  inviteAddressResolver?: () => Promise<string[]>;
248
293
  /**
@@ -368,8 +413,8 @@ export interface CadreNodeConfig {
368
413
  * Pluggable secure store for node key material. When set, the node loads its
369
414
  * identity from `keyStore` under {@link identityKeyId}, generating + persisting
370
415
  * a fresh Ed25519 key on first run (protobuf bytes are the canonical stored
371
- * form). Mutually exclusive with {@link privateKey}. Absent ⇒ legacy behavior
372
- * (use `privateKey`, else libp2p generates an ephemeral key).
416
+ * form). Mutually exclusive with {@link privateKey}. Absent ⇒ use `privateKey`
417
+ * when set, else libp2p generates an ephemeral key.
373
418
  *
374
419
  * In the single-key reference model the owner signing key is *derived from*
375
420
  * the node identity (see {@link CadreNode.getIdentityOwnerKey}), so