@serfab/cadre-core 1.8.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.
- package/dist/cadre-node.d.ts +301 -121
- package/dist/cadre-node.js +588 -244
- 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 +90 -3
- 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/index.d.ts +7 -7
- package/dist/index.js +16 -13
- 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/node-local-snapshot.d.ts +6 -4
- package/dist/node-local-snapshot.js +7 -3
- package/dist/node-local-snapshot.js.map +1 -1
- 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 -12
- package/dist/peer-join-backfill.js +2 -3
- package/dist/peer-join-backfill.js.map +1 -1
- 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/seed-bootstrap.d.ts +2 -2
- package/dist/seed-bootstrap.js +1 -1
- 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 -25
- package/dist/strand-formation-protocol.js +225 -51
- 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-fret-addrs.d.ts +54 -0
- package/dist/strand-fret-addrs.js +108 -0
- package/dist/strand-fret-addrs.js.map +1 -0
- package/dist/strand-instance-manager.d.ts +9 -38
- package/dist/strand-instance-manager.js +17 -74
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-network-state-file.d.ts +12 -0
- package/dist/strand-network-state-file.js +38 -0
- package/dist/strand-network-state-file.js.map +1 -0
- package/dist/strand-network-state.d.ts +111 -0
- package/dist/strand-network-state.js +191 -0
- package/dist/strand-network-state.js.map +1 -0
- 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 +123 -40
- package/dist/types.js +5 -9
- package/dist/types.js.map +1 -1
- package/package.json +25 -24
- package/src/cadre-node.ts +637 -251
- package/src/control-authorization.ts +117 -116
- package/src/control-cohort.ts +1 -1
- package/src/control-database.ts +303 -82
- package/src/control-formation-recorder.ts +8 -9
- package/src/control-schema.ts +90 -3
- package/src/control-write-retry.ts +21 -15
- package/src/index.ts +36 -56
- package/src/invitation-bootstrap.ts +40 -0
- package/src/link-budget.ts +221 -120
- package/src/membership-connection-gater.ts +3 -3
- package/src/node-local-snapshot.ts +12 -7
- package/src/peer-dial.ts +18 -14
- package/src/peer-join-backfill.ts +5 -6
- package/src/pending-join-runner.ts +735 -0
- package/src/push-fanout.ts +1 -1
- package/src/seed-bootstrap.ts +2 -2
- package/src/strand-addr-protocol.ts +2 -2
- package/src/strand-formation-deadlines.ts +5 -5
- package/src/strand-formation-manager.ts +62 -37
- package/src/strand-formation-protocol.ts +1036 -803
- package/src/strand-formation-rejection.ts +129 -0
- package/src/strand-fret-addrs.ts +145 -0
- package/src/strand-instance-manager.ts +24 -97
- package/src/strand-network-state-file.ts +40 -0
- package/src/strand-network-state.ts +243 -0
- package/src/strand-solicitation.ts +17 -4
- package/src/types.ts +119 -40
- package/dist/strand-peer-book-file.d.ts +0 -12
- package/dist/strand-peer-book-file.js +0 -37
- package/dist/strand-peer-book-file.js.map +0 -1
- package/dist/strand-peer-book-protocol.d.ts +0 -168
- package/dist/strand-peer-book-protocol.js +0 -356
- package/dist/strand-peer-book-protocol.js.map +0 -1
- package/dist/strand-peer-book-swap.d.ts +0 -104
- package/dist/strand-peer-book-swap.js +0 -284
- package/dist/strand-peer-book-swap.js.map +0 -1
- package/dist/strand-peer-book.d.ts +0 -159
- package/dist/strand-peer-book.js +0 -361
- package/dist/strand-peer-book.js.map +0 -1
- package/dist/strand-peer-observer.d.ts +0 -88
- package/dist/strand-peer-observer.js +0 -180
- package/dist/strand-peer-observer.js.map +0 -1
- package/src/strand-peer-book-file.ts +0 -39
- package/src/strand-peer-book-protocol.ts +0 -466
- package/src/strand-peer-book-swap.ts +0 -338
- package/src/strand-peer-book.ts +0 -475
- package/src/strand-peer-observer.ts +0 -235
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
|
|
@@ -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
|
-
*
|
|
43
|
-
*
|
|
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:
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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,
|
package/src/control-schema.ts
CHANGED
|
@@ -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
|
|
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
|
),
|