@serfab/cadre-core 1.9.0 → 1.11.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 (119) hide show
  1. package/dist/cadre-node.d.ts +148 -44
  2. package/dist/cadre-node.js +363 -139
  3. package/dist/cadre-node.js.map +1 -1
  4. package/dist/control-authorization.d.ts +2 -2
  5. package/dist/control-authorization.js +1 -0
  6. package/dist/control-authorization.js.map +1 -1
  7. package/dist/control-cohort.d.ts +1 -1
  8. package/dist/control-cohort.js +1 -1
  9. package/dist/control-database.d.ts +105 -14
  10. package/dist/control-database.js +242 -73
  11. package/dist/control-database.js.map +1 -1
  12. package/dist/control-formation-recorder.d.ts +6 -6
  13. package/dist/control-formation-recorder.js +7 -8
  14. package/dist/control-formation-recorder.js.map +1 -1
  15. package/dist/control-schema.d.ts +1 -1
  16. package/dist/control-schema.js +1066 -979
  17. package/dist/control-schema.js.map +1 -1
  18. package/dist/control-write-retry.d.ts +21 -15
  19. package/dist/control-write-retry.js +21 -15
  20. package/dist/control-write-retry.js.map +1 -1
  21. package/dist/diagnostics/connection-path.d.ts +1 -18
  22. package/dist/diagnostics/connection-path.js +1 -20
  23. package/dist/diagnostics/connection-path.js.map +1 -1
  24. package/dist/identity-key.d.ts +0 -30
  25. package/dist/identity-key.js +7 -40
  26. package/dist/identity-key.js.map +1 -1
  27. package/dist/index.d.ts +8 -5
  28. package/dist/index.js +12 -6
  29. package/dist/index.js.map +1 -1
  30. package/dist/invitation-bootstrap.d.ts +32 -0
  31. package/dist/invitation-bootstrap.js +30 -0
  32. package/dist/invitation-bootstrap.js.map +1 -0
  33. package/dist/link-budget.d.ts +204 -119
  34. package/dist/link-budget.js +208 -120
  35. package/dist/link-budget.js.map +1 -1
  36. package/dist/membership-connection-gater.d.ts +3 -3
  37. package/dist/membership-connection-gater.js +3 -3
  38. package/dist/peer-dial.d.ts +18 -14
  39. package/dist/peer-dial.js +18 -14
  40. package/dist/peer-dial.js.map +1 -1
  41. package/dist/peer-join-backfill.d.ts +3 -3
  42. package/dist/pending-join-runner.d.ts +186 -0
  43. package/dist/pending-join-runner.js +649 -0
  44. package/dist/pending-join-runner.js.map +1 -0
  45. package/dist/push-fanout.d.ts +1 -1
  46. package/dist/push-fanout.js +1 -1
  47. package/dist/relay-stun.d.ts +24 -0
  48. package/dist/relay-stun.js +51 -0
  49. package/dist/relay-stun.js.map +1 -0
  50. package/dist/seed-bootstrap.d.ts +2 -2
  51. package/dist/seed-bootstrap.js +5 -5
  52. package/dist/strand-addr-protocol.js +2 -2
  53. package/dist/strand-formation-deadlines.d.ts +5 -5
  54. package/dist/strand-formation-deadlines.js +5 -5
  55. package/dist/strand-formation-manager.d.ts +18 -4
  56. package/dist/strand-formation-manager.js +48 -33
  57. package/dist/strand-formation-manager.js.map +1 -1
  58. package/dist/strand-formation-protocol.d.ts +71 -14
  59. package/dist/strand-formation-protocol.js +222 -47
  60. package/dist/strand-formation-protocol.js.map +1 -1
  61. package/dist/strand-formation-rejection.d.ts +94 -0
  62. package/dist/strand-formation-rejection.js +77 -0
  63. package/dist/strand-formation-rejection.js.map +1 -0
  64. package/dist/strand-instance-manager.js +4 -1
  65. package/dist/strand-instance-manager.js.map +1 -1
  66. package/dist/strand-membership-writer.js +32 -32
  67. package/dist/strand-solicitation.d.ts +11 -3
  68. package/dist/strand-solicitation.js +9 -0
  69. package/dist/strand-solicitation.js.map +1 -1
  70. package/dist/types.d.ts +103 -17
  71. package/dist/types.js +5 -9
  72. package/dist/types.js.map +1 -1
  73. package/package.json +22 -22
  74. package/src/cadre-node.ts +8654 -8400
  75. package/src/control-authorization.ts +2 -1
  76. package/src/control-cohort.ts +128 -128
  77. package/src/control-database.ts +303 -82
  78. package/src/control-formation-recorder.ts +353 -354
  79. package/src/control-read-retry.ts +216 -216
  80. package/src/control-schema.ts +1078 -991
  81. package/src/control-write-retry.ts +567 -561
  82. package/src/diagnostics/connection-path.ts +1 -29
  83. package/src/formation-approval.ts +610 -610
  84. package/src/hibernation-manager.ts +638 -638
  85. package/src/identity-key.ts +7 -62
  86. package/src/index.ts +644 -627
  87. package/src/invitation-bootstrap.ts +40 -0
  88. package/src/joined-strand-store.ts +498 -498
  89. package/src/link-budget.ts +684 -583
  90. package/src/membership-connection-gater.ts +570 -570
  91. package/src/node-local-snapshot.ts +298 -298
  92. package/src/peer-dial.ts +315 -311
  93. package/src/peer-join-backfill.ts +710 -710
  94. package/src/pending-join-runner.ts +735 -0
  95. package/src/push-fanout.ts +1 -1
  96. package/src/relay-reservation.ts +1121 -1121
  97. package/src/relay-server.ts +98 -98
  98. package/src/relay-stun.ts +58 -0
  99. package/src/seed-bootstrap.ts +1448 -1448
  100. package/src/strand-addr-protocol.ts +547 -547
  101. package/src/strand-database.ts +287 -287
  102. package/src/strand-first-sync-gate.ts +391 -391
  103. package/src/strand-formation-deadlines.ts +227 -227
  104. package/src/strand-formation-manager.ts +725 -700
  105. package/src/strand-formation-protocol.ts +1036 -802
  106. package/src/strand-formation-rejection.ts +129 -0
  107. package/src/strand-fret-addrs.ts +145 -145
  108. package/src/strand-instance-manager.ts +1789 -1786
  109. package/src/strand-membership-writer.ts +1974 -1974
  110. package/src/strand-network-state-file.ts +40 -40
  111. package/src/strand-network-state.ts +243 -243
  112. package/src/strand-revocation-enforcer.ts +654 -654
  113. package/src/strand-solicitation.ts +532 -519
  114. package/src/strand-wake-protocol.ts +401 -401
  115. package/src/types.ts +2222 -2140
  116. package/dist/diagnostics/webrtc-turn-tracker.d.ts +0 -50
  117. package/dist/diagnostics/webrtc-turn-tracker.js +0 -196
  118. package/dist/diagnostics/webrtc-turn-tracker.js.map +0 -1
  119. package/src/diagnostics/webrtc-turn-tracker.ts +0 -222
@@ -7,7 +7,7 @@ import optimysticPlugin from '@optimystic/quereus-plugin-optimystic/plugin';
7
7
  import { digest, randomBytes } from '@optimystic/quereus-plugin-crypto';
8
8
  import type { Libp2p } from '@libp2p/interface';
9
9
  import type { IRepo } from '@optimystic/db-core';
10
- import type { StrandRow, PeerAddressRecord, CadrePeerRow, RevocationRow, RevocationLedgerOpenResult, DeviceTokenRecord, DeviceTokenRow, PushPlatform } from './types.js';
10
+ import type { StrandRow, PendingJoinRow, PeerAddressRecord, CadrePeerRow, RevocationRow, RevocationLedgerOpenResult, DeviceTokenRecord, DeviceTokenRow, PushPlatform } from './types.js';
11
11
  import { CONTROL_SCHEMA } from './control-schema.js';
12
12
  import { canonicalDatetime } from './canonical-datetime.js';
13
13
  import { controlAuthorizationFields, CONTROL_TABLES } from './control-authorization.js';
@@ -296,22 +296,55 @@ const GUARDED_KEY_COLUMN: Readonly<Record<RevocableTable, GuardedKeyColumn>> = {
296
296
  Strand: 'Id',
297
297
  StrandPartyKey: 'Id',
298
298
  JoinedStrand: 'Id',
299
+ PendingJoin: 'Id',
299
300
  DeviceToken: 'PeerId',
300
301
  };
301
302
 
303
+ /** A guarded table an owner may remove a row from (no production `OwnerKey` removal path exists yet). */
304
+ type RemovableTable = Exclude<RevocableTable, 'OwnerKey'>;
305
+
306
+ /**
307
+ * One row incarnation's owner-signed removal, ready for {@link ControlDatabase.execGuardedRemoval}:
308
+ * the row's own `'remove'` signature over (key, stamp), and the tombstone's separate
309
+ * `Revocation` signature over (table, key, stamp). Retiring a stamp is permanent and
310
+ * party-wide, so the delete's signature deliberately does not cover it; the digests are
311
+ * domain-separated and neither replays as the other.
312
+ */
313
+ interface GuardedRemoval {
314
+ readonly ref: RevokedRowRef & { readonly tableName: RemovableTable };
315
+ readonly signature: string;
316
+ readonly revocationSignature: string;
317
+ }
318
+
319
+ function signGuardedRemoval(
320
+ tableName: RemovableTable,
321
+ rowKey: string,
322
+ stampId: string,
323
+ signMessage: (message: Uint8Array) => string
324
+ ): GuardedRemoval {
325
+ return {
326
+ ref: { tableName, rowKey, stampId },
327
+ signature: signMessage(buildAuthorizationMessage(`CadreControl.${tableName}`, 'remove', [rowKey, stampId])),
328
+ revocationSignature: signMessage(buildAuthorizationMessage('CadreControl.Revocation', 'remove', [tableName, rowKey, stampId])),
329
+ };
330
+ }
331
+
302
332
  /** The control tables keyed by a strand id whose repeat insert a caller may treat as idempotent. */
303
333
  type StrandIdTable = Extract<RevocableTable, 'Strand' | 'JoinedStrand'>;
304
334
 
335
+ /** The control tables keyed by an `Id` column whose repeat insert a caller may classify. */
336
+ type IdKeyedTable = StrandIdTable | Extract<RevocableTable, 'PendingJoin'>;
337
+
305
338
  /**
306
- * The rejection a second insert of an already-seated strand id into `table` produces, as
307
- * the optimystic vtab words it (`uniqueConstraintMessage`, qualified by table name only —
308
- * no schema prefix, matching `test/control-constraint-helpers.ts`'s `expectUniqueViolation`).
339
+ * The rejection a second insert of an already-seated id into `table` produces, as the
340
+ * optimystic vtab words it (`uniqueConstraintMessage`, qualified by table name only — no
341
+ * schema prefix, matching `test/control-constraint-helpers.ts`'s `expectUniqueViolation`).
309
342
  *
310
343
  * Each table's `StampId` is unique too, but a fresh stamp is minted per insert attempt, so
311
- * only the primary key can collide on a repeat publish. Matching the column explicitly
312
- * keeps any OTHER uniqueness failure out of the idempotency branch.
344
+ * only the primary key can collide on a repeat insert. Matching the column explicitly
345
+ * keeps any OTHER uniqueness failure out of the caller's idempotency branch.
313
346
  */
314
- function strandIdConflictPattern(table: StrandIdTable): RegExp {
347
+ function idConflictPattern(table: IdKeyedTable): RegExp {
315
348
  return new RegExp(`UNIQUE constraint failed: ${table}\\.Id\\b`, 'i');
316
349
  }
317
350
 
@@ -331,7 +364,46 @@ function strandIdConflictPattern(table: StrandIdTable): RegExp {
331
364
  * reword reddens there.
332
365
  */
333
366
  export function isStrandIdConflict(error: unknown, table: StrandIdTable): boolean {
334
- return errorChainMatches(error, strandIdConflictPattern(table));
367
+ return errorChainMatches(error, idConflictPattern(table));
368
+ }
369
+
370
+ /**
371
+ * Did {@link ControlDatabase.insertPendingJoin} fail because the party already holds a
372
+ * `PendingJoin` row for this invitation? The caller adopts the existing row instead.
373
+ * Text-matched, failing closed, like {@link isStrandIdConflict}.
374
+ *
375
+ * NOTE: the existing row may be one {@link ControlDatabase.queryPendingJoin} hides (its stamp
376
+ * is retired and no reap pass has removed it yet), so an adopting re-read can come back empty
377
+ * until the next reap pass.
378
+ */
379
+ export function isPendingJoinConflict(error: unknown): boolean {
380
+ return errorChainMatches(error, idConflictPattern('PendingJoin'));
381
+ }
382
+
383
+ /**
384
+ * The `PendingJoin.Id` of an invitation: the base64url sha256 `digest` of its token. One row
385
+ * per invitation, and the `Revocation` tombstone that names a removed row carries this, never
386
+ * the bearer token itself.
387
+ */
388
+ export function pendingJoinId(token: string): string {
389
+ return digest([token], 'sha256', 'base64url') as string;
390
+ }
391
+
392
+ /**
393
+ * {@link ControlDatabase.replacePendingJoin} found that the live `PendingJoin` row is not the
394
+ * incarnation the caller read: another write replaced or removed it first (a sibling machine
395
+ * recorded an outcome, or the user dismissed the join). Nothing was written; the caller
396
+ * re-reads and decides again. `liveStampId` is null when the row is gone.
397
+ */
398
+ export class PendingJoinChangedError extends Error {
399
+ constructor(
400
+ readonly id: string,
401
+ readonly expectedStampId: string,
402
+ readonly liveStampId: string | null,
403
+ ) {
404
+ super(`PendingJoin ${id} changed: expected stamp ${expectedStampId}, found ${liveStampId ?? 'no row'}`);
405
+ this.name = 'PendingJoinChangedError';
406
+ }
335
407
  }
336
408
 
337
409
  /**
@@ -348,7 +420,7 @@ export const REVOCATION_LEDGER_MARKER = {
348
420
 
349
421
  /**
350
422
  * The rejection a second insert of the ledger marker produces: the optimystic vtab's
351
- * primary-key wording (see {@link strandIdConflictPattern}), naming both columns of
423
+ * primary-key wording (see {@link idConflictPattern}), naming both columns of
352
424
  * `Revocation`'s composite key. Applied only to the marker's own insert, whose key
353
425
  * ('Revocation', 'opened') no row but the marker can hold (`RowIsGone`), and `Revocation`
354
426
  * has no other unique constraint.
@@ -373,10 +445,12 @@ function errorChainMatches(error: unknown, pattern: RegExp): boolean {
373
445
  * secrets stored nowhere else (tickets/backlog/debt-strand-tombstone-reap.md owns any
374
446
  * future change). `JoinedStrand` also carries a `MemberPrivateKey` but IS reapable: that
375
447
  * secret is the joined strand's shared read key, which its founding party and every member
376
- * hold, so a reaped row is recoverable by re-forming. `OwnerKey` has no production removal
377
- * path and `MinOneOwner` makes an automated owner-key reap a party-bricking hazard.
448
+ * hold, so a reaped row is recoverable by re-forming. `PendingJoin` is reapable for the same
449
+ * reason: the inviting party and the user also hold its invitation. `OwnerKey` has no
450
+ * production removal path and `MinOneOwner` makes an automated owner-key reap a
451
+ * party-bricking hazard.
378
452
  */
379
- export const REAPABLE_TABLES = ['CadrePeer', 'DeviceToken', 'ValidationKey', 'JoinedStrand'] as const satisfies readonly RevocableTable[];
453
+ export const REAPABLE_TABLES = ['CadrePeer', 'DeviceToken', 'ValidationKey', 'JoinedStrand', 'PendingJoin'] as const satisfies readonly RevocableTable[];
380
454
  export type ReapableTable = (typeof REAPABLE_TABLES)[number];
381
455
 
382
456
  /**
@@ -399,6 +473,53 @@ function joinedStrandRow(row: Record<string, SqlValue>): StrandRow {
399
473
  };
400
474
  }
401
475
 
476
+ /** Every `PendingJoin` column, in the schema's order. */
477
+ const PENDING_JOIN_COLUMNS = 'Id, Invitation, Disclosure, RequestedAt, ExpiresAt, Outcome, OutcomeAt, StrandId, MembershipInvite, FailureCode, FailureReason, StampId';
478
+
479
+ const PENDING_JOIN_INSERT_SQL = `
480
+ insert into CadreControl.PendingJoin (${PENDING_JOIN_COLUMNS})
481
+ with context OwnerKey = ?, Signature = ?
482
+ values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
483
+ `;
484
+
485
+ /** A `PendingJoin` row as read; the schema's `KnownOutcome` check backs the `Outcome` cast. */
486
+ function pendingJoinRow(row: Record<string, SqlValue>): PendingJoinRow {
487
+ return {
488
+ Id: row.Id as string,
489
+ Invitation: row.Invitation as string,
490
+ Disclosure: row.Disclosure as string,
491
+ RequestedAt: row.RequestedAt as number,
492
+ ExpiresAt: row.ExpiresAt as number,
493
+ Outcome: row.Outcome as PendingJoinRow['Outcome'],
494
+ OutcomeAt: row.OutcomeAt as number | null,
495
+ StrandId: row.StrandId as string | null,
496
+ MembershipInvite: row.MembershipInvite as string | null,
497
+ FailureCode: row.FailureCode as string | null,
498
+ FailureReason: row.FailureReason as string | null,
499
+ StampId: row.StampId as string,
500
+ };
501
+ }
502
+
503
+ /**
504
+ * The row fields `PendingJoin.AuthorizedInsert` signs, in the schema's order: a null column
505
+ * signs as `''` and an integer as its text (`cast(... as text)` on the SQL side).
506
+ */
507
+ function pendingJoinAddFields(row: PendingJoinRow): string[] {
508
+ return [
509
+ row.Id, row.Invitation, row.Disclosure, String(row.RequestedAt), String(row.ExpiresAt),
510
+ row.Outcome ?? '', row.OutcomeAt === null ? '' : String(row.OutcomeAt), row.StrandId ?? '',
511
+ row.MembershipInvite ?? '', row.FailureCode ?? '', row.FailureReason ?? '', row.StampId,
512
+ ];
513
+ }
514
+
515
+ /** The values of {@link PENDING_JOIN_INSERT_SQL}'s columns, in {@link PENDING_JOIN_COLUMNS} order. */
516
+ function pendingJoinValues(row: PendingJoinRow): SqlValue[] {
517
+ return [
518
+ row.Id, row.Invitation, row.Disclosure, row.RequestedAt, row.ExpiresAt, row.Outcome, row.OutcomeAt,
519
+ row.StrandId, row.MembershipInvite, row.FailureCode, row.FailureReason, row.StampId,
520
+ ];
521
+ }
522
+
402
523
  /**
403
524
  * Notified after a `CadreControl.CadrePeer` row write has COMMITTED.
404
525
  *
@@ -928,6 +1049,31 @@ export class ControlDatabase {
928
1049
  return rows.length === 0 ? null : joinedStrandRow(rows[0]!);
929
1050
  }
930
1051
 
1052
+ /**
1053
+ * Every join this party asked for and has not dismissed (`CadreControl.PendingJoin`),
1054
+ * pending or finished.
1055
+ *
1056
+ * Rows whose `StampId` is retired in `Revocation` are dropped, as {@link queryCadrePeers}
1057
+ * drops them: such a row is either a replaced or dismissed incarnation this node still
1058
+ * holds until the reap sweep removes it, or one re-seated by a replayed approval on a node
1059
+ * that had not yet seen the tombstone. Either way it must not bring a dismissed join back.
1060
+ */
1061
+ async queryPendingJoins(): Promise<PendingJoinRow[]> {
1062
+ this.ensureInitialized();
1063
+ const revoked = await this.queryRevokedStamps('PendingJoin');
1064
+ const rows = await this.readRows(`select ${PENDING_JOIN_COLUMNS} from CadreControl.PendingJoin`, undefined, 'pending-joins');
1065
+ return rows.map(pendingJoinRow).filter(row => !revoked.has(row.StampId));
1066
+ }
1067
+
1068
+ /** Single-row sibling of {@link queryPendingJoins}, with the same retired-stamp filter: the row with this id, or null. */
1069
+ async queryPendingJoin(id: string): Promise<PendingJoinRow | null> {
1070
+ this.ensureInitialized();
1071
+ const revoked = await this.queryRevokedStamps('PendingJoin');
1072
+ const rows = await this.readRows(`select ${PENDING_JOIN_COLUMNS} from CadreControl.PendingJoin where Id = ?`, [id], 'pending-join');
1073
+ const row = rows.length === 0 ? null : pendingJoinRow(rows[0]!);
1074
+ return row === null || revoked.has(row.StampId) ? null : row;
1075
+ }
1076
+
931
1077
  /**
932
1078
  * Count rows in a CadreControl table as seen by THIS database instance.
933
1079
  *
@@ -1531,13 +1677,10 @@ export class ControlDatabase {
1531
1677
  * one exists — the strand's `StrandPartyKey` delete + tombstone, all in ONE
1532
1678
  * transaction. A two-table sibling of {@link deleteGuardedRow} (see that method for the
1533
1679
  * per-clause security rationale: stamp read inside the locked body, `'remove'`-tagged
1534
- * digests, mandatory same-transaction tombstones); kept separate rather than
1535
- * generalizing the shared body because no other guarded table has a companion row.
1536
- *
1537
- * NOTE: that duplication is a drift risk, not a correctness one — a future tightening of
1538
- * {@link deleteGuardedRow}'s discipline (an extra precondition, a different digest shape)
1539
- * will NOT reach this body. Grep for both when you change either; if a second guarded
1540
- * table ever gains a companion row, generalize instead of copying this a third time.
1680
+ * digests, mandatory same-transaction tombstones). Both sign through
1681
+ * {@link signGuardedRemoval} and run {@link execGuardedRemoval}, so a change to the digest
1682
+ * shape or the statement pair reaches this body; only the stamp reads and the companion-row
1683
+ * branch are its own, because no other guarded table has a companion row.
1541
1684
  *
1542
1685
  * Not wrapped in {@link withWriteLock} — the caller holds the (non-re-entrant) lock.
1543
1686
  */
@@ -1555,45 +1698,23 @@ export class ControlDatabase {
1555
1698
  }
1556
1699
  const partyKeyStamp = await this.queryStampId('StrandPartyKey', strandId, false);
1557
1700
 
1558
- const strandSignature = signMessage(
1559
- buildAuthorizationMessage('CadreControl.Strand', 'remove', [strandId, strandStamp]));
1560
- const strandRevocationSignature = signMessage(
1561
- buildAuthorizationMessage('CadreControl.Revocation', 'remove', ['Strand', strandId, strandStamp]));
1562
- const partyKeySignature = partyKeyStamp === null ? null : signMessage(
1563
- buildAuthorizationMessage('CadreControl.StrandPartyKey', 'remove', [strandId, partyKeyStamp]));
1564
- const partyKeyRevocationSignature = partyKeyStamp === null ? null : signMessage(
1565
- buildAuthorizationMessage('CadreControl.Revocation', 'remove', ['StrandPartyKey', strandId, partyKeyStamp]));
1701
+ const strandRemoval = signGuardedRemoval('Strand', strandId, strandStamp, signMessage);
1702
+ const partyKeyRemoval = partyKeyStamp === null
1703
+ ? null
1704
+ : signGuardedRemoval('StrandPartyKey', strandId, partyKeyStamp, signMessage);
1566
1705
 
1567
1706
  await this.inTransaction('delete Strand', async () => {
1568
- await this.db!.exec(`
1569
- delete from CadreControl.Strand
1570
- with context OwnerKey = ?, Signature = ?
1571
- where Id = ?
1572
- `, [ownerKey, strandSignature, strandId]);
1573
- await this.db!.exec(`
1574
- insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt)
1575
- with context OwnerKey = ?, Signature = ?
1576
- values ('Strand', ?, ?, 0)
1577
- `, [ownerKey, strandRevocationSignature, strandId, strandStamp]);
1578
- if (partyKeyStamp !== null) {
1579
- await this.db!.exec(`
1580
- delete from CadreControl.StrandPartyKey
1581
- with context OwnerKey = ?, Signature = ?
1582
- where Id = ?
1583
- `, [ownerKey, partyKeySignature, strandId]);
1584
- await this.db!.exec(`
1585
- insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt)
1586
- with context OwnerKey = ?, Signature = ?
1587
- values ('StrandPartyKey', ?, ?, 0)
1588
- `, [ownerKey, partyKeyRevocationSignature, strandId, partyKeyStamp]);
1707
+ await this.execGuardedRemoval(strandRemoval, ownerKey);
1708
+ if (partyKeyRemoval !== null) {
1709
+ await this.execGuardedRemoval(partyKeyRemoval, ownerKey);
1589
1710
  }
1590
1711
  });
1591
1712
 
1592
1713
  log('Strand deleted: %s (stamp retired%s)', strandId,
1593
- partyKeyStamp === null ? '' : '; party key deleted, stamp retired');
1594
- this.notifyGuardedDelete({ tableName: 'Strand', rowKey: strandId, stampId: strandStamp });
1595
- if (partyKeyStamp !== null) {
1596
- this.notifyGuardedDelete({ tableName: 'StrandPartyKey', rowKey: strandId, stampId: partyKeyStamp });
1714
+ partyKeyRemoval === null ? '' : '; party key deleted, stamp retired');
1715
+ this.notifyGuardedDelete(strandRemoval.ref);
1716
+ if (partyKeyRemoval !== null) {
1717
+ this.notifyGuardedDelete(partyKeyRemoval.ref);
1597
1718
  }
1598
1719
  return true;
1599
1720
  }
@@ -1710,6 +1831,100 @@ export class ControlDatabase {
1710
1831
  return this.lockedWithRetry(() => this.deleteGuardedRow('JoinedStrand', strandId, ownerKey, signMessage), {}, 'joined-strand-delete');
1711
1832
  }
1712
1833
 
1834
+ /**
1835
+ * Record, party-wide, a join this party asked for (`PendingJoin` row), using an owner
1836
+ * signature over every column of the row (see {@link pendingJoinAddFields}). Returns the
1837
+ * row as written, with the fresh `StampId` a later {@link replacePendingJoin} names.
1838
+ *
1839
+ * Fails with a `PendingJoin.Id` uniqueness violation when the party already holds a row for
1840
+ * this invitation; {@link isPendingJoinConflict} identifies that case.
1841
+ */
1842
+ async insertPendingJoin(
1843
+ row: Omit<PendingJoinRow, 'StampId'>,
1844
+ ownerKey: string,
1845
+ signMessage: (message: Uint8Array) => string
1846
+ ): Promise<PendingJoinRow> {
1847
+ this.ensureInitialized();
1848
+ log('Inserting pending join: %s (outcome: %s)', row.Id, row.Outcome);
1849
+ const { written, params } = this.signPendingJoinInsert(row, ownerKey, signMessage);
1850
+ await this.execWrite(PENDING_JOIN_INSERT_SQL, params, 'pending-join-insert');
1851
+ log('Pending join inserted: %s', row.Id);
1852
+ return written;
1853
+ }
1854
+
1855
+ /**
1856
+ * Replace the live `PendingJoin` row `next.Id` with `next`, under a fresh stamp — the only way
1857
+ * an outcome is written, since the table refuses updates. Returns the row as written.
1858
+ *
1859
+ * One locked transaction deletes the row (owner-signed `'remove'` digest, with its
1860
+ * `Revocation` tombstone) and inserts its successor, so no reader ever sees the id missing in
1861
+ * between. The live row must still be the incarnation the caller read: when its `StampId` is
1862
+ * not `expectedStampId`, or the row is gone, this throws {@link PendingJoinChangedError} and
1863
+ * writes nothing. That check is local; a sibling machine's replacement or removal that lands
1864
+ * between it and the commit fails the transaction instead (its tombstone for the same stamp,
1865
+ * or its successor row, collides on a primary key), and the caller re-reads either way.
1866
+ *
1867
+ * Signatures and the new stamp are minted before the lock, so a retried attempt re-presents
1868
+ * the same signed messages (the contract on {@link withWriteLock}). A retry after an attempt
1869
+ * that committed but reported failure throws {@link PendingJoinChangedError} whose
1870
+ * `liveStampId` is the returned row's own stamp, so a caller that re-reads finds its write.
1871
+ */
1872
+ async replacePendingJoin(
1873
+ expectedStampId: string,
1874
+ next: Omit<PendingJoinRow, 'StampId'>,
1875
+ ownerKey: string,
1876
+ signMessage: (message: Uint8Array) => string
1877
+ ): Promise<PendingJoinRow> {
1878
+ this.ensureInitialized();
1879
+ const id = next.Id;
1880
+ const removal = signGuardedRemoval('PendingJoin', id, expectedStampId, signMessage);
1881
+ const { written, params } = this.signPendingJoinInsert(next, ownerKey, signMessage);
1882
+
1883
+ return this.lockedWithRetry(async () => {
1884
+ // retry: false — this runs inside the locked write body (see queryStampId's NOTE).
1885
+ const liveStampId = await this.queryStampId('PendingJoin', id, false);
1886
+ if (liveStampId !== expectedStampId) {
1887
+ throw new PendingJoinChangedError(id, expectedStampId, liveStampId);
1888
+ }
1889
+ await this.inTransaction('replace PendingJoin', async () => {
1890
+ await this.execGuardedRemoval(removal, ownerKey);
1891
+ await this.db!.exec(PENDING_JOIN_INSERT_SQL, params);
1892
+ });
1893
+ log('Pending join replaced: %s (outcome: %s; stamp retired)', id, next.Outcome);
1894
+ this.notifyGuardedDelete(removal.ref);
1895
+ return written;
1896
+ }, {}, 'pending-join-replace');
1897
+ }
1898
+
1899
+ /**
1900
+ * Owner-signed removal of one `PendingJoin` row, as {@link deleteJoinedStrand} removes its
1901
+ * row: `'remove'`-tagged digest over (Id, StampId), `Revocation` tombstone in the same
1902
+ * transaction. A no-op (no throw, no tombstone) when the row does not exist — `false` then,
1903
+ * `true` when a row was actually removed.
1904
+ */
1905
+ deletePendingJoin(
1906
+ id: string,
1907
+ ownerKey: string,
1908
+ signMessage: (message: Uint8Array) => string
1909
+ ): Promise<boolean> {
1910
+ return this.lockedWithRetry(() => this.deleteGuardedRow('PendingJoin', id, ownerKey, signMessage), {}, 'pending-join-delete');
1911
+ }
1912
+
1913
+ /**
1914
+ * Mint a fresh stamp for `row` and the owner signature `PendingJoin.AuthorizedInsert`
1915
+ * verifies, returning the row as it will be written and the parameters of
1916
+ * {@link PENDING_JOIN_INSERT_SQL}.
1917
+ */
1918
+ private signPendingJoinInsert(
1919
+ row: Omit<PendingJoinRow, 'StampId'>,
1920
+ ownerKey: string,
1921
+ signMessage: (message: Uint8Array) => string
1922
+ ): { written: PendingJoinRow; params: SqlValue[] } {
1923
+ const written: PendingJoinRow = { ...row, StampId: generateStampId(this.config.libp2pNode.peerId.toString()) };
1924
+ const signature = signMessage(buildAuthorizationMessage('CadreControl.PendingJoin', 'add', pendingJoinAddFields(written)));
1925
+ return { written, params: [ownerKey, signature, ...pendingJoinValues(written)] };
1926
+ }
1927
+
1713
1928
  /**
1714
1929
  * Insert a validation key into the control database using an owner signature.
1715
1930
  *
@@ -2001,12 +2216,9 @@ export class ControlDatabase {
2001
2216
  *
2002
2217
  * Deliberately NOT wrapped in {@link withWriteLock}: every public entry point already
2003
2218
  * holds the (non-re-entrant) lock, so taking it again here would self-deadlock.
2004
- *
2005
- * `table` and its {@link GUARDED_KEY_COLUMN} column are interpolated into the SQL; both
2006
- * come from closed literal unions — no caller-supplied string reaches the statement.
2007
2219
  */
2008
2220
  private async deleteGuardedRow(
2009
- table: Exclude<RevocableTable, 'OwnerKey'>,
2221
+ table: RemovableTable,
2010
2222
  keyValue: string,
2011
2223
  ownerKey: string,
2012
2224
  signMessage: (message: Uint8Array) => string
@@ -2019,36 +2231,44 @@ export class ControlDatabase {
2019
2231
  return false;
2020
2232
  }
2021
2233
 
2022
- const message = buildAuthorizationMessage(`CadreControl.${table}`, 'remove', [keyValue, stampId]);
2023
- const signature = signMessage(message);
2024
- // The tombstone carries its OWN owner signature (`Revocation.Authorized`): retiring a
2025
- // stamp is permanent and party-wide, so the delete's signature deliberately does not
2026
- // cover it — the digests are domain-separated and neither replays as the other.
2027
- const revocationSignature = signMessage(
2028
- buildAuthorizationMessage('CadreControl.Revocation', 'remove', [table, keyValue, stampId]),
2029
- );
2030
-
2031
- await this.inTransaction(`delete ${table}`, async () => {
2032
- await this.db!.exec(`
2033
- delete from CadreControl.${table}
2034
- with context OwnerKey = ?, Signature = ?
2035
- where ${GUARDED_KEY_COLUMN[table]} = ?
2036
- `, [ownerKey, signature, keyValue]);
2037
- // ReissuedAt named explicitly at 0 (the only value FreshTombstone accepts) rather
2038
- // than leaning on the column default — the seat-at-zero rule is load-bearing for
2039
- // reissueRevocations' monotonic bump, so state it at the write site.
2040
- await this.db!.exec(`
2041
- insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt)
2042
- with context OwnerKey = ?, Signature = ?
2043
- values (?, ?, ?, 0)
2044
- `, [ownerKey, revocationSignature, table, keyValue, stampId]);
2045
- });
2234
+ const removal = signGuardedRemoval(table, keyValue, stampId, signMessage);
2235
+ await this.inTransaction(`delete ${table}`, () => this.execGuardedRemoval(removal, ownerKey));
2046
2236
 
2047
2237
  log('%s deleted: %s (stamp retired)', table, keyValue);
2048
- this.notifyGuardedDelete({ tableName: table, rowKey: keyValue, stampId });
2238
+ this.notifyGuardedDelete(removal.ref);
2049
2239
  return true;
2050
2240
  }
2051
2241
 
2242
+ /**
2243
+ * Delete the row incarnation `removal` names and file its tombstone — the statement pair
2244
+ * every owner removal of a guarded row runs ({@link deleteGuardedRow},
2245
+ * {@link deleteStrandAndPartyKey}, {@link replacePendingJoin}). The caller supplies the
2246
+ * transaction: the schema's `RevocationRecorded` check refuses the delete unless the
2247
+ * tombstone commits with it.
2248
+ *
2249
+ * The delete matches the stamp as well as the key, so it can only ever remove the
2250
+ * incarnation the signature names.
2251
+ *
2252
+ * `tableName` and its {@link GUARDED_KEY_COLUMN} column are interpolated into the SQL; both
2253
+ * come from closed literal unions — no caller-supplied string reaches the statement.
2254
+ */
2255
+ private async execGuardedRemoval(removal: GuardedRemoval, ownerKey: string): Promise<void> {
2256
+ const { tableName, rowKey, stampId } = removal.ref;
2257
+ await this.db!.exec(`
2258
+ delete from CadreControl.${tableName}
2259
+ with context OwnerKey = ?, Signature = ?
2260
+ where ${GUARDED_KEY_COLUMN[tableName]} = ? and StampId = ?
2261
+ `, [ownerKey, removal.signature, rowKey, stampId]);
2262
+ // ReissuedAt named explicitly at 0 (the only value FreshTombstone accepts) rather
2263
+ // than leaning on the column default — the seat-at-zero rule is load-bearing for
2264
+ // reissueRevocations' monotonic bump, so state it at the write site.
2265
+ await this.db!.exec(`
2266
+ insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt)
2267
+ with context OwnerKey = ?, Signature = ?
2268
+ values (?, ?, ?, 0)
2269
+ `, [ownerKey, removal.revocationSignature, tableName, rowKey, stampId]);
2270
+ }
2271
+
2052
2272
  /**
2053
2273
  * Fire the guarded-delete listener for one committed tombstone, swallowing (and
2054
2274
  * logging) a listener throw — a committed delete never fails because bookkeeping did.
@@ -2186,7 +2406,8 @@ export class ControlDatabase {
2186
2406
  * reads from this node (every other node already filters it by tombstone). A revoked
2187
2407
  * node therefore keeps its own copy of its own row; "a node that learns it has been
2188
2408
  * revoked should shut itself down" is a distinct behaviour and its own ticket.
2189
- * `ValidationKey` and `JoinedStrand` have no self notion and are not special-cased.
2409
+ * `ValidationKey`, `JoinedStrand` and `PendingJoin` have no self notion and are not
2410
+ * special-cased.
2190
2411
  *
2191
2412
  * A per-row failure is logged and skipped rather than aborting the sweep: an abort would
2192
2413
  * starve every tombstone after the failing one on every subsequent pass, and each row is