@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.
- package/README.md +3 -2
- 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 +607 -53
- package/dist/cadre-node.js +1268 -157
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-database.d.ts +174 -23
- package/dist/control-database.js +317 -107
- 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 +50 -6
- 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/enrolled-machine-store-file.d.ts +13 -0
- package/dist/enrolled-machine-store-file.js +41 -0
- package/dist/enrolled-machine-store-file.js.map +1 -0
- package/dist/enrolled-machine-store.d.ts +81 -0
- package/dist/enrolled-machine-store.js +225 -0
- package/dist/enrolled-machine-store.js.map +1 -0
- package/dist/file-durable-slot.js +5 -3
- package/dist/file-durable-slot.js.map +1 -1
- package/dist/index.d.ts +10 -6
- package/dist/index.js +39 -12
- package/dist/index.js.map +1 -1
- package/dist/membership-connection-gater.d.ts +226 -26
- package/dist/membership-connection-gater.js +316 -30
- package/dist/membership-connection-gater.js.map +1 -1
- package/dist/node-local-snapshot.d.ts +6 -0
- package/dist/node-local-snapshot.js +7 -1
- package/dist/node-local-snapshot.js.map +1 -1
- package/dist/{strand-backfill.d.ts → peer-join-backfill.d.ts} +49 -20
- package/dist/{strand-backfill.js → peer-join-backfill.js} +146 -56
- package/dist/peer-join-backfill.js.map +1 -0
- 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 +145 -22
- package/dist/relay-addrs.js +314 -31
- 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-database.d.ts +12 -0
- package/dist/strand-database.js +16 -0
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-formation-manager.d.ts +22 -9
- package/dist/strand-formation-manager.js +20 -12
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +83 -9
- package/dist/strand-formation-protocol.js +86 -10
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +151 -17
- package/dist/strand-instance-manager.js +237 -32
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +118 -14
- package/dist/strand-membership-writer.js +203 -15
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-network-config.d.ts +84 -0
- package/dist/strand-network-config.js +136 -0
- package/dist/strand-network-config.js.map +1 -0
- package/dist/strand-revocation-enforcer.d.ts +392 -0
- package/dist/strand-revocation-enforcer.js +533 -0
- package/dist/strand-revocation-enforcer.js.map +1 -0
- package/dist/strand-solicitation.d.ts +10 -3
- package/dist/strand-solicitation.js +12 -7
- 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/strand-watcher.js +6 -1
- package/dist/strand-watcher.js.map +1 -1
- package/dist/types.d.ts +282 -43
- package/dist/types.js +7 -1
- package/dist/types.js.map +1 -1
- package/package.json +16 -8
- package/src/announce-addrs.ts +93 -0
- package/src/cadre-node.ts +1366 -171
- package/src/control-database.ts +2628 -2406
- package/src/control-read-retry.ts +167 -0
- package/src/control-retry.ts +190 -0
- package/src/control-schema.ts +50 -6
- package/src/control-write-retry.ts +30 -130
- package/src/delegate-admission.ts +17 -15
- package/src/enrolled-machine-store-file.ts +43 -0
- package/src/enrolled-machine-store.ts +280 -0
- package/src/file-durable-slot.ts +5 -3
- package/src/index.ts +477 -407
- package/src/membership-connection-gater.ts +539 -204
- package/src/node-local-snapshot.ts +13 -1
- package/src/peer-addr-book.ts +251 -250
- package/src/{strand-backfill.ts → peer-join-backfill.ts} +187 -75
- package/src/peer-record.ts +33 -0
- package/src/relay-addrs.ts +436 -111
- package/src/relay-reservation.ts +828 -823
- package/src/strand-addr-protocol.ts +17 -0
- package/src/strand-database.ts +17 -0
- package/src/strand-formation-manager.ts +511 -491
- package/src/strand-formation-protocol.ts +808 -692
- package/src/strand-instance-manager.ts +899 -611
- package/src/strand-membership-writer.ts +232 -15
- package/src/strand-network-config.ts +172 -0
- package/src/strand-revocation-enforcer.ts +653 -0
- package/src/strand-solicitation.ts +484 -472
- package/src/strand-wake-protocol.ts +94 -8
- package/src/strand-watcher.ts +319 -314
- package/src/types.ts +1554 -1309
- 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
|
|
818
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
1386
|
-
*
|
|
1387
|
-
*
|
|
1388
|
-
*
|
|
1389
|
-
*
|
|
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
|
|
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: `
|
|
1401
|
-
* nodes concurrently removing different managers can each see
|
|
1402
|
-
* converge to zero. Noted in the schema
|
|
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
|
|
1407
|
-
*
|
|
1408
|
-
*
|
|
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
|
+
}
|