@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.
- package/dist/announce-addrs.d.ts +49 -0
- package/dist/announce-addrs.js +78 -0
- package/dist/announce-addrs.js.map +1 -0
- package/dist/cadre-node.d.ts +281 -30
- package/dist/cadre-node.js +513 -103
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-database.d.ts +155 -23
- package/dist/control-database.js +272 -104
- package/dist/control-database.js.map +1 -1
- package/dist/control-read-retry.d.ts +103 -0
- package/dist/control-read-retry.js +145 -0
- package/dist/control-read-retry.js.map +1 -0
- package/dist/control-retry.d.ts +101 -0
- package/dist/control-retry.js +108 -0
- package/dist/control-retry.js.map +1 -0
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +20 -4
- package/dist/control-schema.js.map +1 -1
- package/dist/control-write-retry.d.ts +59 -35
- package/dist/control-write-retry.js +21 -92
- package/dist/control-write-retry.js.map +1 -1
- package/dist/delegate-admission.d.ts +17 -15
- package/dist/delegate-admission.js +17 -15
- package/dist/delegate-admission.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.js +17 -8
- package/dist/index.js.map +1 -1
- package/dist/membership-connection-gater.d.ts +211 -25
- package/dist/membership-connection-gater.js +309 -29
- package/dist/membership-connection-gater.js.map +1 -1
- package/dist/peer-record.d.ts +26 -0
- package/dist/peer-record.js +32 -0
- package/dist/peer-record.js.map +1 -1
- package/dist/relay-addrs.d.ts +76 -26
- package/dist/relay-addrs.js +115 -27
- package/dist/relay-addrs.js.map +1 -1
- package/dist/relay-reservation.d.ts +20 -15
- package/dist/relay-reservation.js +18 -14
- package/dist/relay-reservation.js.map +1 -1
- package/dist/strand-addr-protocol.js +16 -0
- package/dist/strand-addr-protocol.js.map +1 -1
- package/dist/strand-backfill.js +13 -4
- package/dist/strand-backfill.js.map +1 -1
- package/dist/strand-formation-manager.d.ts +10 -9
- package/dist/strand-formation-manager.js +10 -9
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +7 -7
- package/dist/strand-formation-protocol.js +8 -8
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +4 -3
- package/dist/strand-instance-manager.js +31 -6
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +106 -14
- package/dist/strand-membership-writer.js +180 -14
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-solicitation.d.ts +3 -3
- package/dist/strand-solicitation.js +4 -5
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-wake-protocol.d.ts +47 -2
- package/dist/strand-wake-protocol.js +76 -6
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/types.d.ts +74 -29
- package/dist/types.js.map +1 -1
- package/package.json +8 -8
- package/src/announce-addrs.ts +93 -0
- package/src/cadre-node.ts +5603 -5158
- package/src/control-database.ts +2584 -2406
- package/src/control-read-retry.ts +167 -0
- package/src/control-retry.ts +190 -0
- package/src/control-schema.ts +20 -4
- package/src/control-write-retry.ts +30 -130
- package/src/delegate-admission.ts +17 -15
- package/src/index.ts +435 -407
- package/src/membership-connection-gater.ts +533 -204
- package/src/peer-record.ts +33 -0
- package/src/relay-addrs.ts +218 -111
- package/src/relay-reservation.ts +828 -823
- package/src/strand-addr-protocol.ts +17 -0
- package/src/strand-backfill.ts +389 -366
- package/src/strand-formation-manager.ts +492 -491
- package/src/strand-formation-protocol.ts +8 -8
- package/src/strand-instance-manager.ts +636 -611
- package/src/strand-membership-writer.ts +209 -14
- package/src/strand-solicitation.ts +6 -7
- package/src/strand-wake-protocol.ts +94 -8
- 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
|
|
818
|
-
*
|
|
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
|
|
1386
|
-
*
|
|
1387
|
-
*
|
|
1388
|
-
*
|
|
1389
|
-
*
|
|
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
|
|
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: `
|
|
1401
|
-
* nodes concurrently removing different managers can each see
|
|
1402
|
-
* converge to zero. Noted in the schema
|
|
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
|
|
1407
|
-
*
|
|
1408
|
-
*
|
|
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;
|
|
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
|
|
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
|
|
295
|
+
invitation: OpenInvitation,
|
|
296
296
|
disclosure: StrandFormationDisclosure,
|
|
297
297
|
node?: Libp2p
|
|
298
298
|
): Promise<FormStrandResult> {
|
|
299
|
-
|
|
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
|
|
314
|
-
if (
|
|
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
|
|
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-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
331
|
+
attemptMs,
|
|
270
332
|
`Wake dial ${addr.toString()}`,
|
|
271
|
-
(signal) => sendWake(node, addr, protocolId, request,
|
|
333
|
+
(signal) => sendWake(node, addr, protocolId, request, attemptMs, signal),
|
|
272
334
|
);
|
|
273
335
|
} catch (err) {
|
|
274
|
-
|
|
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
|
|
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
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
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
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
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
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
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
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
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
|
-
*
|
|
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 ⇒
|
|
372
|
-
*
|
|
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
|