@serfab/cadre-core 1.9.0 → 1.10.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 (87) hide show
  1. package/dist/cadre-node.d.ts +148 -17
  2. package/dist/cadre-node.js +361 -45
  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 +90 -3
  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/index.d.ts +5 -3
  22. package/dist/index.js +6 -2
  23. package/dist/index.js.map +1 -1
  24. package/dist/invitation-bootstrap.d.ts +32 -0
  25. package/dist/invitation-bootstrap.js +30 -0
  26. package/dist/invitation-bootstrap.js.map +1 -0
  27. package/dist/link-budget.d.ts +204 -119
  28. package/dist/link-budget.js +208 -120
  29. package/dist/link-budget.js.map +1 -1
  30. package/dist/membership-connection-gater.d.ts +3 -3
  31. package/dist/membership-connection-gater.js +3 -3
  32. package/dist/peer-dial.d.ts +18 -14
  33. package/dist/peer-dial.js +18 -14
  34. package/dist/peer-dial.js.map +1 -1
  35. package/dist/peer-join-backfill.d.ts +3 -3
  36. package/dist/pending-join-runner.d.ts +186 -0
  37. package/dist/pending-join-runner.js +649 -0
  38. package/dist/pending-join-runner.js.map +1 -0
  39. package/dist/push-fanout.d.ts +1 -1
  40. package/dist/push-fanout.js +1 -1
  41. package/dist/seed-bootstrap.d.ts +2 -2
  42. package/dist/seed-bootstrap.js +1 -1
  43. package/dist/strand-addr-protocol.js +2 -2
  44. package/dist/strand-formation-deadlines.d.ts +5 -5
  45. package/dist/strand-formation-deadlines.js +5 -5
  46. package/dist/strand-formation-manager.d.ts +18 -4
  47. package/dist/strand-formation-manager.js +48 -33
  48. package/dist/strand-formation-manager.js.map +1 -1
  49. package/dist/strand-formation-protocol.d.ts +71 -14
  50. package/dist/strand-formation-protocol.js +222 -47
  51. package/dist/strand-formation-protocol.js.map +1 -1
  52. package/dist/strand-formation-rejection.d.ts +94 -0
  53. package/dist/strand-formation-rejection.js +77 -0
  54. package/dist/strand-formation-rejection.js.map +1 -0
  55. package/dist/strand-instance-manager.js +4 -1
  56. package/dist/strand-instance-manager.js.map +1 -1
  57. package/dist/strand-solicitation.d.ts +11 -3
  58. package/dist/strand-solicitation.js +9 -0
  59. package/dist/strand-solicitation.js.map +1 -1
  60. package/dist/types.d.ts +103 -17
  61. package/dist/types.js +5 -9
  62. package/dist/types.js.map +1 -1
  63. package/package.json +22 -22
  64. package/src/cadre-node.ts +399 -46
  65. package/src/control-authorization.ts +117 -116
  66. package/src/control-cohort.ts +1 -1
  67. package/src/control-database.ts +303 -82
  68. package/src/control-formation-recorder.ts +8 -9
  69. package/src/control-schema.ts +90 -3
  70. package/src/control-write-retry.ts +21 -15
  71. package/src/index.ts +21 -1
  72. package/src/invitation-bootstrap.ts +40 -0
  73. package/src/link-budget.ts +221 -120
  74. package/src/membership-connection-gater.ts +3 -3
  75. package/src/peer-dial.ts +18 -14
  76. package/src/peer-join-backfill.ts +3 -3
  77. package/src/pending-join-runner.ts +735 -0
  78. package/src/push-fanout.ts +1 -1
  79. package/src/seed-bootstrap.ts +2 -2
  80. package/src/strand-addr-protocol.ts +2 -2
  81. package/src/strand-formation-deadlines.ts +5 -5
  82. package/src/strand-formation-manager.ts +62 -37
  83. package/src/strand-formation-protocol.ts +1036 -802
  84. package/src/strand-formation-rejection.ts +129 -0
  85. package/src/strand-instance-manager.ts +4 -1
  86. package/src/strand-solicitation.ts +17 -4
  87. package/src/types.ts +99 -17
@@ -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
@@ -39,8 +39,8 @@ const NEVER_EXPIRES = new Date(8640000000000000);
39
39
  * ATOMICALLY (one `FormationUsage` row), closing the single-use hole the older
40
40
  * never-record fallback left open.
41
41
  *
42
- * This replaces the in-memory stubs used by the formation tests so the consent path
43
- * is exercised against the persisted control network.
42
+ * Every `CadreNode`'s formation responder uses it unless the embedder passes another
43
+ * recorder to `initializeStrandSolicitation`.
44
44
  *
45
45
  * Usage accounting follows the schema's `FormationUsage.Authorized` semantics:
46
46
  * a null `TotalUses` means unlimited uses; otherwise the invite is "used up"
@@ -59,10 +59,9 @@ export class ControlFormationUsageRecorder implements FormationUsageRecorder {
59
59
  private readonly approver: FormationApprover;
60
60
 
61
61
  /**
62
- * `approver` defaults to the real HTTP hook client — deliberately ON: this recorder is
63
- * constructed by the reference apps and the integration harness, not by `CadreNode`, so an
64
- * opt-in approver would leave every real deployment unable to redeem a
65
- * `ValidationUrl`-bearing invite. Tests inject a fake.
62
+ * `approver` defaults to the real HTTP hook client — deliberately ON: `CadreNode` builds this
63
+ * recorder for every node's responder with no options, so an opt-in approver would leave
64
+ * every real deployment unable to redeem a `ValidationUrl`-bearing invite. Tests inject a fake.
66
65
  */
67
66
  constructor(
68
67
  private readonly controlDatabase: ControlDatabase,
@@ -72,14 +71,14 @@ export class ControlFormationUsageRecorder implements FormationUsageRecorder {
72
71
  }
73
72
 
74
73
  /** A token is valid when a matching, unexpired `FormationInvite` exists. */
75
- async isTokenValid(token: string): Promise<{ valid: boolean; invitation?: OpenInvitation }> {
74
+ async isTokenValid(token: string): Promise<{ valid: boolean; reason?: 'unknown' | 'expired'; invitation?: OpenInvitation }> {
76
75
  const invite = await this.controlDatabase.queryFormationInvite(token);
77
76
  if (!invite) {
78
- return { valid: false };
77
+ return { valid: false, reason: 'unknown' };
79
78
  }
80
79
  if (invite.expiresAtMs !== null && invite.expiresAtMs <= Date.now()) {
81
80
  log('Token expired: %s', token);
82
- return { valid: false };
81
+ return { valid: false, reason: 'expired' };
83
82
  }
84
83
  return {
85
84
  valid: true,
@@ -424,6 +424,92 @@ declare schema CadreControl {
424
424
  )
425
425
  ) with context (OwnerKey text, Signature text);
426
426
 
427
+ -- A join THIS party asked for, by redeeming another party's invitation, that it has not
428
+ -- finished yet, or has finished and not yet dismissed. Party-wide rather than kept on the
429
+ -- device that asked, so the attempt outlives that device and any owner machine of the party
430
+ -- can carry it on and record how it ended. Invitation is a bearer credential and
431
+ -- MembershipInvite a single-use one; both are replicated in plaintext to every machine the
432
+ -- party owns, under the same accepted risk as JoinedStrand.MemberPrivateKey (docs/strands.md
433
+ -- → "Closed-Strand Member Key Handling").
434
+ -- Every write is a whole-row insert or delete: recording an outcome deletes the row (with its
435
+ -- tombstone) and inserts its successor under a fresh stamp in one transaction
436
+ -- (ControlDatabase.replacePendingJoin). Which outcome may follow which is CadreNode policy,
437
+ -- not a rule here, because every writer is an owner.
438
+ -- Carries a REAP branch, like JoinedStrand, because nothing here is stored only here: the
439
+ -- inviting party holds the FormationInvite row, the user holds the shared link, and the
440
+ -- membership invitation is a copy of what the inviter issued.
441
+ table PendingJoin (
442
+ Id text primary key, -- base64url sha256 of the invitation token (cadre-core's
443
+ -- pendingJoinId): one row per invitation, and the Revocation
444
+ -- tombstone that names a removed row never holds the token.
445
+ Invitation text not null, -- CadreNode.encodeInvitation: token, sAppId, expiration and
446
+ -- bootstrap addresses. A few kilobytes at most, since
447
+ -- createOpenInvitation caps the addresses an invitation carries.
448
+ Disclosure text not null, -- canonicalJson of the disclosure the requester gave; the
449
+ -- formation sets partyId itself on each attempt
450
+ RequestedAt integer not null, -- epoch ms
451
+ ExpiresAt integer not null, -- epoch ms; no attempt starts at or after it
452
+ Outcome text null, -- null while pending | 'joined' | 'failed'
453
+ OutcomeAt integer null, -- epoch ms the outcome was recorded
454
+ StrandId text null, -- 'joined': the strand the formation returned
455
+ MembershipInvite text null, -- 'joined', closed strand only: JSON {inviteKey, invitePrivateKey},
456
+ -- the StrandMembershipInvite the formation delivered
457
+ FailureCode text null, -- 'failed': a FormationRejectionCode, 'expired' or 'local'
458
+ FailureReason text null, -- 'failed': human-readable text
459
+ StampId text not null unique, -- single-use authorization nonce (anti-replay).
460
+ -- \`unique\` holds over LIVE rows only; a removed row's stamp is
461
+ -- retired permanently into Revocation (NotRevoked below).
462
+ -- The approval that seated a removed row can never re-seat it, so a dismissed or replaced
463
+ -- row cannot be brought back. Same rationale as OwnerKey.NotRevoked, stated in full there.
464
+ constraint NotRevoked check on insert (
465
+ not exists (select 1 from Revocation R where R.TableName = 'PendingJoin' and R.StampId = new.StampId)
466
+ ),
467
+ -- A delete must file its correctly named tombstone in the same transaction. Same
468
+ -- rationale as StrandPartyKey.RevocationRecorded.
469
+ constraint RevocationRecorded check on delete (
470
+ exists (select 1 from Revocation R where R.TableName = 'PendingJoin' and R.StampId = old.StampId and R.RowKey = old.Id)
471
+ ),
472
+ -- Insert + delete only: neither Authorized rule below covers update, so an in-place
473
+ -- rewrite would change the outcome unsigned. Mirrors StrandPartyKey.NoUpdate.
474
+ constraint NoUpdate check on update (false),
475
+ -- Readers cast Outcome to null | 'joined' | 'failed' (ControlDatabase.queryPendingJoins).
476
+ constraint KnownOutcome check (new.Outcome is null or new.Outcome = 'joined' or new.Outcome = 'failed'),
477
+ -- Each outcome carries exactly its own columns, so a reader can trust the shape it reads.
478
+ constraint OutcomeShape check (
479
+ (new.Outcome is null
480
+ and new.OutcomeAt is null and new.StrandId is null and new.MembershipInvite is null
481
+ and new.FailureCode is null and new.FailureReason is null)
482
+ or (new.Outcome = 'joined'
483
+ and new.OutcomeAt is not null and new.StrandId is not null
484
+ and new.FailureCode is null and new.FailureReason is null)
485
+ or (new.Outcome = 'failed'
486
+ and new.OutcomeAt is not null and new.FailureCode is not null
487
+ and new.StrandId is null and new.MembershipInvite is null)
488
+ ),
489
+ constraint AuthorizedInsert check on insert (
490
+ -- Owners authorize by signing over THIS row, every column ending in StampId; nullable
491
+ -- columns sign as '' and integers as their text, so a captured approval can only
492
+ -- reproduce the exact row it approved. Owner-signed only: whether a machine that is
493
+ -- not an owner may record an outcome is an open decision
494
+ -- (tickets/blocked/decide-non-owner-machine-completes-a-pending-join.md).
495
+ exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest('CadreControl.PendingJoin', 'add',
496
+ new.Id, new.Invitation, new.Disclosure, cast(new.RequestedAt as text), cast(new.ExpiresAt as text),
497
+ coalesce(new.Outcome, ''), coalesce(cast(new.OutcomeAt as text), ''), coalesce(new.StrandId, ''),
498
+ coalesce(new.MembershipInvite, ''), coalesce(new.FailureCode, ''), coalesce(new.FailureReason, ''),
499
+ new.StampId), context.Signature, A.Key, 'ed25519'))
500
+ ),
501
+ constraint AuthorizedDelete check on delete (
502
+ -- 'remove'-tagged digest over the STORED row (Id, StampId), so the add approval can
503
+ -- never be replayed as a removal.
504
+ exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest('CadreControl.PendingJoin', 'remove', old.Id, old.StampId), context.Signature, A.Key, 'ed25519'))
505
+
506
+ -- or REAP: a COMMITTED tombstone already retires this exact row incarnation.
507
+ -- Why committed.* and why the stamp is bound: stated in full on
508
+ -- CadrePeer.AuthorizedDelete. Allowed for the reason the table comment gives.
509
+ or exists (select 1 from committed.Revocation R where R.TableName = 'PendingJoin' and R.RowKey = old.Id and R.StampId = old.StampId)
510
+ )
511
+ ) with context (OwnerKey text, Signature text);
512
+
427
513
  -- A peer (node) that is part of the cadre, carrying a self-published,
428
514
  -- freshness-stamped, self-signed address record (see PeerAddressRecord in cadre-core).
429
515
  -- The row IS the peer-address record: a resolver reads it, re-verifies Sig against
@@ -875,11 +961,11 @@ declare schema CadreControl {
875
961
  -- skips the marker, so nothing reaps or re-issues it.
876
962
  table Revocation (
877
963
  TableName text, -- 'OwnerKey' | 'CadrePeer' | 'ValidationKey' | 'Strand' | 'StrandPartyKey' |
878
- -- 'JoinedStrand' | 'DeviceToken', or 'Revocation' for the ledger marker only
879
- -- (all confined by RowIsGone below)
964
+ -- 'JoinedStrand' | 'PendingJoin' | 'DeviceToken', or 'Revocation' for the
965
+ -- ledger marker only (all confined by RowIsGone below)
880
966
  RowKey text not null, -- primary key of the removed row: OwnerKey.Key / ValidationKey.Key /
881
967
  -- CadrePeer.PeerId / DeviceToken.PeerId / Strand.Id / StrandPartyKey.Id /
882
- -- JoinedStrand.Id
968
+ -- JoinedStrand.Id / PendingJoin.Id
883
969
  -- Every guarded table's RevocationRecorded CHECK requires the
884
970
  -- accompanying tombstone to carry the removed row's key here, so
885
971
  -- a REMOVAL cannot file a misnamed tombstone. A tombstone with no
@@ -931,6 +1017,7 @@ declare schema CadreControl {
931
1017
  or (new.TableName = 'Strand' and not exists (select 1 from Strand S where S.StampId = new.StampId))
932
1018
  or (new.TableName = 'StrandPartyKey' and not exists (select 1 from StrandPartyKey K where K.StampId = new.StampId))
933
1019
  or (new.TableName = 'JoinedStrand' and not exists (select 1 from JoinedStrand J where J.StampId = new.StampId))
1020
+ or (new.TableName = 'PendingJoin' and not exists (select 1 from PendingJoin P where P.StampId = new.StampId))
934
1021
  or (new.TableName = 'DeviceToken' and not exists (select 1 from DeviceToken D where D.StampId = new.StampId))
935
1022
  or (new.TableName = 'Revocation' and new.RowKey = 'ledger' and new.StampId = 'opened')
936
1023
  ),