@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.
- package/dist/cadre-node.d.ts +148 -44
- package/dist/cadre-node.js +363 -139
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-authorization.d.ts +2 -2
- package/dist/control-authorization.js +1 -0
- package/dist/control-authorization.js.map +1 -1
- package/dist/control-cohort.d.ts +1 -1
- package/dist/control-cohort.js +1 -1
- package/dist/control-database.d.ts +105 -14
- package/dist/control-database.js +242 -73
- package/dist/control-database.js.map +1 -1
- package/dist/control-formation-recorder.d.ts +6 -6
- package/dist/control-formation-recorder.js +7 -8
- package/dist/control-formation-recorder.js.map +1 -1
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +1066 -979
- package/dist/control-schema.js.map +1 -1
- package/dist/control-write-retry.d.ts +21 -15
- package/dist/control-write-retry.js +21 -15
- package/dist/control-write-retry.js.map +1 -1
- package/dist/diagnostics/connection-path.d.ts +1 -18
- package/dist/diagnostics/connection-path.js +1 -20
- package/dist/diagnostics/connection-path.js.map +1 -1
- package/dist/identity-key.d.ts +0 -30
- package/dist/identity-key.js +7 -40
- package/dist/identity-key.js.map +1 -1
- package/dist/index.d.ts +8 -5
- package/dist/index.js +12 -6
- package/dist/index.js.map +1 -1
- package/dist/invitation-bootstrap.d.ts +32 -0
- package/dist/invitation-bootstrap.js +30 -0
- package/dist/invitation-bootstrap.js.map +1 -0
- package/dist/link-budget.d.ts +204 -119
- package/dist/link-budget.js +208 -120
- package/dist/link-budget.js.map +1 -1
- package/dist/membership-connection-gater.d.ts +3 -3
- package/dist/membership-connection-gater.js +3 -3
- package/dist/peer-dial.d.ts +18 -14
- package/dist/peer-dial.js +18 -14
- package/dist/peer-dial.js.map +1 -1
- package/dist/peer-join-backfill.d.ts +3 -3
- package/dist/pending-join-runner.d.ts +186 -0
- package/dist/pending-join-runner.js +649 -0
- package/dist/pending-join-runner.js.map +1 -0
- package/dist/push-fanout.d.ts +1 -1
- package/dist/push-fanout.js +1 -1
- package/dist/relay-stun.d.ts +24 -0
- package/dist/relay-stun.js +51 -0
- package/dist/relay-stun.js.map +1 -0
- package/dist/seed-bootstrap.d.ts +2 -2
- package/dist/seed-bootstrap.js +5 -5
- package/dist/strand-addr-protocol.js +2 -2
- package/dist/strand-formation-deadlines.d.ts +5 -5
- package/dist/strand-formation-deadlines.js +5 -5
- package/dist/strand-formation-manager.d.ts +18 -4
- package/dist/strand-formation-manager.js +48 -33
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +71 -14
- package/dist/strand-formation-protocol.js +222 -47
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-formation-rejection.d.ts +94 -0
- package/dist/strand-formation-rejection.js +77 -0
- package/dist/strand-formation-rejection.js.map +1 -0
- package/dist/strand-instance-manager.js +4 -1
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-membership-writer.js +32 -32
- package/dist/strand-solicitation.d.ts +11 -3
- package/dist/strand-solicitation.js +9 -0
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/types.d.ts +103 -17
- package/dist/types.js +5 -9
- package/dist/types.js.map +1 -1
- package/package.json +22 -22
- package/src/cadre-node.ts +8654 -8400
- package/src/control-authorization.ts +2 -1
- package/src/control-cohort.ts +128 -128
- package/src/control-database.ts +303 -82
- package/src/control-formation-recorder.ts +353 -354
- package/src/control-read-retry.ts +216 -216
- package/src/control-schema.ts +1078 -991
- package/src/control-write-retry.ts +567 -561
- package/src/diagnostics/connection-path.ts +1 -29
- package/src/formation-approval.ts +610 -610
- package/src/hibernation-manager.ts +638 -638
- package/src/identity-key.ts +7 -62
- package/src/index.ts +644 -627
- package/src/invitation-bootstrap.ts +40 -0
- package/src/joined-strand-store.ts +498 -498
- package/src/link-budget.ts +684 -583
- package/src/membership-connection-gater.ts +570 -570
- package/src/node-local-snapshot.ts +298 -298
- package/src/peer-dial.ts +315 -311
- package/src/peer-join-backfill.ts +710 -710
- package/src/pending-join-runner.ts +735 -0
- package/src/push-fanout.ts +1 -1
- package/src/relay-reservation.ts +1121 -1121
- package/src/relay-server.ts +98 -98
- package/src/relay-stun.ts +58 -0
- package/src/seed-bootstrap.ts +1448 -1448
- package/src/strand-addr-protocol.ts +547 -547
- package/src/strand-database.ts +287 -287
- package/src/strand-first-sync-gate.ts +391 -391
- package/src/strand-formation-deadlines.ts +227 -227
- package/src/strand-formation-manager.ts +725 -700
- package/src/strand-formation-protocol.ts +1036 -802
- package/src/strand-formation-rejection.ts +129 -0
- package/src/strand-fret-addrs.ts +145 -145
- package/src/strand-instance-manager.ts +1789 -1786
- package/src/strand-membership-writer.ts +1974 -1974
- package/src/strand-network-state-file.ts +40 -40
- package/src/strand-network-state.ts +243 -243
- package/src/strand-revocation-enforcer.ts +654 -654
- package/src/strand-solicitation.ts +532 -519
- package/src/strand-wake-protocol.ts +401 -401
- package/src/types.ts +2222 -2140
- package/dist/diagnostics/webrtc-turn-tracker.d.ts +0 -50
- package/dist/diagnostics/webrtc-turn-tracker.js +0 -196
- package/dist/diagnostics/webrtc-turn-tracker.js.map +0 -1
- package/src/diagnostics/webrtc-turn-tracker.ts +0 -222
package/src/control-database.ts
CHANGED
|
@@ -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
|
|
307
|
-
*
|
|
308
|
-
*
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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. `
|
|
377
|
-
*
|
|
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)
|
|
1535
|
-
*
|
|
1536
|
-
*
|
|
1537
|
-
*
|
|
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
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
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.
|
|
1569
|
-
|
|
1570
|
-
|
|
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
|
-
|
|
1594
|
-
this.notifyGuardedDelete(
|
|
1595
|
-
if (
|
|
1596
|
-
this.notifyGuardedDelete(
|
|
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:
|
|
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
|
|
2023
|
-
|
|
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(
|
|
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 `
|
|
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
|