@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.
- package/dist/cadre-node.d.ts +148 -17
- package/dist/cadre-node.js +361 -45
- 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 +5 -3
- package/dist/index.js +6 -2
- 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/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 -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-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 +399 -46
- 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 +21 -1
- package/src/invitation-bootstrap.ts +40 -0
- package/src/link-budget.ts +221 -120
- package/src/membership-connection-gater.ts +3 -3
- package/src/peer-dial.ts +18 -14
- package/src/peer-join-backfill.ts +3 -3
- 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 -802
- package/src/strand-formation-rejection.ts +129 -0
- package/src/strand-instance-manager.ts +4 -1
- package/src/strand-solicitation.ts +17 -4
- package/src/types.ts +99 -17
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
* CHECK the `'reissue'`-tagged digest `ControlDatabase.reissueRevocations`
|
|
34
34
|
* signs — and `countRows` counts it.
|
|
35
35
|
*/
|
|
36
|
-
export declare const CONTROL_TABLES: readonly ["OwnerKey", "ValidationKey", "Strand", "StrandPartyKey", "JoinedStrand", "CadrePeer", "DeviceToken", "FormationInvite", "FormationUsage", "Revocation"];
|
|
36
|
+
export declare const CONTROL_TABLES: readonly ["OwnerKey", "ValidationKey", "Strand", "StrandPartyKey", "JoinedStrand", "PendingJoin", "CadrePeer", "DeviceToken", "FormationInvite", "FormationUsage", "Revocation"];
|
|
37
37
|
/**
|
|
38
38
|
* Name of a CadreControl table. Doubles as the table half of a
|
|
39
39
|
* {@link ControlDomain} tag.
|
|
@@ -51,7 +51,7 @@ export type ControlTable = typeof CONTROL_TABLES[number];
|
|
|
51
51
|
* (`control-database.ts`) so the lightweight signers — `peer-authorization.ts`'s
|
|
52
52
|
* `revocationDigest` — can type against it without pulling in the runtime.
|
|
53
53
|
*/
|
|
54
|
-
export type RevocableTable = Extract<ControlTable, 'OwnerKey' | 'CadrePeer' | 'ValidationKey' | 'Strand' | 'StrandPartyKey' | 'JoinedStrand' | 'DeviceToken'>;
|
|
54
|
+
export type RevocableTable = Extract<ControlTable, 'OwnerKey' | 'CadrePeer' | 'ValidationKey' | 'Strand' | 'StrandPartyKey' | 'JoinedStrand' | 'PendingJoin' | 'DeviceToken'>;
|
|
55
55
|
/**
|
|
56
56
|
* What a signature authorizes, scoped to one table rule — or, for
|
|
57
57
|
* `'Cadre.Enrollment'`, the offline peer vouch that `cadre enroll register`
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"control-authorization.js","sourceRoot":"","sources":["../src/control-authorization.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG;IAC5B,UAAU;IACV,eAAe;IACf,QAAQ;IACR,gBAAgB;IAChB,cAAc;IACd,WAAW;IACX,aAAa;IACb,iBAAiB;IACjB,gBAAgB;IAChB,YAAY;CACJ,CAAC;AAsDX;;;;;;;GAOG;AACH,MAAM,UAAU,0BAA0B,CACxC,MAAqB,EACrB,MAAqB,EACrB,SAAmB;IAEnB,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC,CAAC;AACxC,CAAC"}
|
|
1
|
+
{"version":3,"file":"control-authorization.js","sourceRoot":"","sources":["../src/control-authorization.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG;IAC5B,UAAU;IACV,eAAe;IACf,QAAQ;IACR,gBAAgB;IAChB,cAAc;IACd,aAAa;IACb,WAAW;IACX,aAAa;IACb,iBAAiB;IACjB,gBAAgB;IAChB,YAAY;CACJ,CAAC;AAsDX;;;;;;;GAOG;AACH,MAAM,UAAU,0BAA0B,CACxC,MAAqB,EACrB,MAAqB,EACrB,SAAmB;IAEnB,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC,CAAC;AACxC,CAAC"}
|
package/dist/control-cohort.d.ts
CHANGED
|
@@ -29,7 +29,7 @@ import type { CohortPeerRow } from './strand-cohort.js';
|
|
|
29
29
|
* An interval between pass starts, not a deadline on a pass. Passes are
|
|
30
30
|
* single-flight, so a pass that runs longer than this is joined rather than
|
|
31
31
|
* stacked, and the effective cadence becomes the pass length: one unreachable
|
|
32
|
-
* sibling's dial alone can take the whole per-peer dial budget (
|
|
32
|
+
* sibling's dial alone can take the whole per-peer dial budget (86 s at the
|
|
33
33
|
* default declared link).
|
|
34
34
|
*/
|
|
35
35
|
export declare const DEFAULT_CONTROL_COHORT_RECONCILE_MS = 15000;
|
package/dist/control-cohort.js
CHANGED
|
@@ -29,7 +29,7 @@ import { ed25519PublicKeyB64FromPeerId } from './seed-bootstrap.js';
|
|
|
29
29
|
* An interval between pass starts, not a deadline on a pass. Passes are
|
|
30
30
|
* single-flight, so a pass that runs longer than this is joined rather than
|
|
31
31
|
* stacked, and the effective cadence becomes the pass length: one unreachable
|
|
32
|
-
* sibling's dial alone can take the whole per-peer dial budget (
|
|
32
|
+
* sibling's dial alone can take the whole per-peer dial budget (86 s at the
|
|
33
33
|
* default declared link).
|
|
34
34
|
*/
|
|
35
35
|
export const DEFAULT_CONTROL_COHORT_RECONCILE_MS = 15000;
|
|
@@ -2,7 +2,7 @@ import { Database } from '@quereus/quereus';
|
|
|
2
2
|
import type { SqlParameters } from '@quereus/quereus';
|
|
3
3
|
import type { Libp2p } from '@libp2p/interface';
|
|
4
4
|
import type { IRepo } from '@optimystic/db-core';
|
|
5
|
-
import type { StrandRow, PeerAddressRecord, CadrePeerRow, RevocationRow, RevocationLedgerOpenResult, DeviceTokenRecord, DeviceTokenRow } from './types.js';
|
|
5
|
+
import type { StrandRow, PendingJoinRow, PeerAddressRecord, CadrePeerRow, RevocationRow, RevocationLedgerOpenResult, DeviceTokenRecord, DeviceTokenRow } from './types.js';
|
|
6
6
|
import type { ControlTable, RevocableTable, ControlDomain, ControlAction } from './control-authorization.js';
|
|
7
7
|
import type { ControlRetryAbandonment } from './control-retry.js';
|
|
8
8
|
export type { ControlTable, RevocableTable, ControlDomain, ControlAction } from './control-authorization.js';
|
|
@@ -175,6 +175,34 @@ type StrandIdTable = Extract<RevocableTable, 'Strand' | 'JoinedStrand'>;
|
|
|
175
175
|
* reword reddens there.
|
|
176
176
|
*/
|
|
177
177
|
export declare function isStrandIdConflict(error: unknown, table: StrandIdTable): boolean;
|
|
178
|
+
/**
|
|
179
|
+
* Did {@link ControlDatabase.insertPendingJoin} fail because the party already holds a
|
|
180
|
+
* `PendingJoin` row for this invitation? The caller adopts the existing row instead.
|
|
181
|
+
* Text-matched, failing closed, like {@link isStrandIdConflict}.
|
|
182
|
+
*
|
|
183
|
+
* NOTE: the existing row may be one {@link ControlDatabase.queryPendingJoin} hides (its stamp
|
|
184
|
+
* is retired and no reap pass has removed it yet), so an adopting re-read can come back empty
|
|
185
|
+
* until the next reap pass.
|
|
186
|
+
*/
|
|
187
|
+
export declare function isPendingJoinConflict(error: unknown): boolean;
|
|
188
|
+
/**
|
|
189
|
+
* The `PendingJoin.Id` of an invitation: the base64url sha256 `digest` of its token. One row
|
|
190
|
+
* per invitation, and the `Revocation` tombstone that names a removed row carries this, never
|
|
191
|
+
* the bearer token itself.
|
|
192
|
+
*/
|
|
193
|
+
export declare function pendingJoinId(token: string): string;
|
|
194
|
+
/**
|
|
195
|
+
* {@link ControlDatabase.replacePendingJoin} found that the live `PendingJoin` row is not the
|
|
196
|
+
* incarnation the caller read: another write replaced or removed it first (a sibling machine
|
|
197
|
+
* recorded an outcome, or the user dismissed the join). Nothing was written; the caller
|
|
198
|
+
* re-reads and decides again. `liveStampId` is null when the row is gone.
|
|
199
|
+
*/
|
|
200
|
+
export declare class PendingJoinChangedError extends Error {
|
|
201
|
+
readonly id: string;
|
|
202
|
+
readonly expectedStampId: string;
|
|
203
|
+
readonly liveStampId: string | null;
|
|
204
|
+
constructor(id: string, expectedStampId: string, liveStampId: string | null);
|
|
205
|
+
}
|
|
178
206
|
/**
|
|
179
207
|
* The one `CadreControl.Revocation` row that retires nothing, filed once by an owner
|
|
180
208
|
* ({@link ControlDatabase.openRevocationLedger}) so the table is never a never-written
|
|
@@ -194,10 +222,12 @@ export declare const REVOCATION_LEDGER_MARKER: {
|
|
|
194
222
|
* secrets stored nowhere else (tickets/backlog/debt-strand-tombstone-reap.md owns any
|
|
195
223
|
* future change). `JoinedStrand` also carries a `MemberPrivateKey` but IS reapable: that
|
|
196
224
|
* secret is the joined strand's shared read key, which its founding party and every member
|
|
197
|
-
* hold, so a reaped row is recoverable by re-forming. `
|
|
198
|
-
*
|
|
225
|
+
* hold, so a reaped row is recoverable by re-forming. `PendingJoin` is reapable for the same
|
|
226
|
+
* reason: the inviting party and the user also hold its invitation. `OwnerKey` has no
|
|
227
|
+
* production removal path and `MinOneOwner` makes an automated owner-key reap a
|
|
228
|
+
* party-bricking hazard.
|
|
199
229
|
*/
|
|
200
|
-
export declare const REAPABLE_TABLES: readonly ["CadrePeer", "DeviceToken", "ValidationKey", "JoinedStrand"];
|
|
230
|
+
export declare const REAPABLE_TABLES: readonly ["CadrePeer", "DeviceToken", "ValidationKey", "JoinedStrand", "PendingJoin"];
|
|
201
231
|
export type ReapableTable = (typeof REAPABLE_TABLES)[number];
|
|
202
232
|
/**
|
|
203
233
|
* Notified after a `CadreControl.CadrePeer` row write has COMMITTED.
|
|
@@ -445,6 +475,18 @@ export declare class ControlDatabase {
|
|
|
445
475
|
queryJoinedStrands(): Promise<StrandRow[]>;
|
|
446
476
|
/** Single-row sibling of {@link queryJoinedStrands}: the joined strand with this id, or null. */
|
|
447
477
|
queryJoinedStrand(strandId: string): Promise<StrandRow | null>;
|
|
478
|
+
/**
|
|
479
|
+
* Every join this party asked for and has not dismissed (`CadreControl.PendingJoin`),
|
|
480
|
+
* pending or finished.
|
|
481
|
+
*
|
|
482
|
+
* Rows whose `StampId` is retired in `Revocation` are dropped, as {@link queryCadrePeers}
|
|
483
|
+
* drops them: such a row is either a replaced or dismissed incarnation this node still
|
|
484
|
+
* holds until the reap sweep removes it, or one re-seated by a replayed approval on a node
|
|
485
|
+
* that had not yet seen the tombstone. Either way it must not bring a dismissed join back.
|
|
486
|
+
*/
|
|
487
|
+
queryPendingJoins(): Promise<PendingJoinRow[]>;
|
|
488
|
+
/** Single-row sibling of {@link queryPendingJoins}, with the same retired-stamp filter: the row with this id, or null. */
|
|
489
|
+
queryPendingJoin(id: string): Promise<PendingJoinRow | null>;
|
|
448
490
|
/**
|
|
449
491
|
* Count rows in a CadreControl table as seen by THIS database instance.
|
|
450
492
|
*
|
|
@@ -786,13 +828,10 @@ export declare class ControlDatabase {
|
|
|
786
828
|
* one exists — the strand's `StrandPartyKey` delete + tombstone, all in ONE
|
|
787
829
|
* transaction. A two-table sibling of {@link deleteGuardedRow} (see that method for the
|
|
788
830
|
* per-clause security rationale: stamp read inside the locked body, `'remove'`-tagged
|
|
789
|
-
* digests, mandatory same-transaction tombstones)
|
|
790
|
-
*
|
|
791
|
-
*
|
|
792
|
-
*
|
|
793
|
-
* {@link deleteGuardedRow}'s discipline (an extra precondition, a different digest shape)
|
|
794
|
-
* will NOT reach this body. Grep for both when you change either; if a second guarded
|
|
795
|
-
* table ever gains a companion row, generalize instead of copying this a third time.
|
|
831
|
+
* digests, mandatory same-transaction tombstones). Both sign through
|
|
832
|
+
* {@link signGuardedRemoval} and run {@link execGuardedRemoval}, so a change to the digest
|
|
833
|
+
* shape or the statement pair reaches this body; only the stamp reads and the companion-row
|
|
834
|
+
* branch are its own, because no other guarded table has a companion row.
|
|
796
835
|
*
|
|
797
836
|
* Not wrapped in {@link withWriteLock} — the caller holds the (non-re-entrant) lock.
|
|
798
837
|
*/
|
|
@@ -847,6 +886,46 @@ export declare class ControlDatabase {
|
|
|
847
886
|
* then, `true` when a row was actually removed.
|
|
848
887
|
*/
|
|
849
888
|
deleteJoinedStrand(strandId: string, ownerKey: string, signMessage: (message: Uint8Array) => string): Promise<boolean>;
|
|
889
|
+
/**
|
|
890
|
+
* Record, party-wide, a join this party asked for (`PendingJoin` row), using an owner
|
|
891
|
+
* signature over every column of the row (see {@link pendingJoinAddFields}). Returns the
|
|
892
|
+
* row as written, with the fresh `StampId` a later {@link replacePendingJoin} names.
|
|
893
|
+
*
|
|
894
|
+
* Fails with a `PendingJoin.Id` uniqueness violation when the party already holds a row for
|
|
895
|
+
* this invitation; {@link isPendingJoinConflict} identifies that case.
|
|
896
|
+
*/
|
|
897
|
+
insertPendingJoin(row: Omit<PendingJoinRow, 'StampId'>, ownerKey: string, signMessage: (message: Uint8Array) => string): Promise<PendingJoinRow>;
|
|
898
|
+
/**
|
|
899
|
+
* Replace the live `PendingJoin` row `next.Id` with `next`, under a fresh stamp — the only way
|
|
900
|
+
* an outcome is written, since the table refuses updates. Returns the row as written.
|
|
901
|
+
*
|
|
902
|
+
* One locked transaction deletes the row (owner-signed `'remove'` digest, with its
|
|
903
|
+
* `Revocation` tombstone) and inserts its successor, so no reader ever sees the id missing in
|
|
904
|
+
* between. The live row must still be the incarnation the caller read: when its `StampId` is
|
|
905
|
+
* not `expectedStampId`, or the row is gone, this throws {@link PendingJoinChangedError} and
|
|
906
|
+
* writes nothing. That check is local; a sibling machine's replacement or removal that lands
|
|
907
|
+
* between it and the commit fails the transaction instead (its tombstone for the same stamp,
|
|
908
|
+
* or its successor row, collides on a primary key), and the caller re-reads either way.
|
|
909
|
+
*
|
|
910
|
+
* Signatures and the new stamp are minted before the lock, so a retried attempt re-presents
|
|
911
|
+
* the same signed messages (the contract on {@link withWriteLock}). A retry after an attempt
|
|
912
|
+
* that committed but reported failure throws {@link PendingJoinChangedError} whose
|
|
913
|
+
* `liveStampId` is the returned row's own stamp, so a caller that re-reads finds its write.
|
|
914
|
+
*/
|
|
915
|
+
replacePendingJoin(expectedStampId: string, next: Omit<PendingJoinRow, 'StampId'>, ownerKey: string, signMessage: (message: Uint8Array) => string): Promise<PendingJoinRow>;
|
|
916
|
+
/**
|
|
917
|
+
* Owner-signed removal of one `PendingJoin` row, as {@link deleteJoinedStrand} removes its
|
|
918
|
+
* row: `'remove'`-tagged digest over (Id, StampId), `Revocation` tombstone in the same
|
|
919
|
+
* transaction. A no-op (no throw, no tombstone) when the row does not exist — `false` then,
|
|
920
|
+
* `true` when a row was actually removed.
|
|
921
|
+
*/
|
|
922
|
+
deletePendingJoin(id: string, ownerKey: string, signMessage: (message: Uint8Array) => string): Promise<boolean>;
|
|
923
|
+
/**
|
|
924
|
+
* Mint a fresh stamp for `row` and the owner signature `PendingJoin.AuthorizedInsert`
|
|
925
|
+
* verifies, returning the row as it will be written and the parameters of
|
|
926
|
+
* {@link PENDING_JOIN_INSERT_SQL}.
|
|
927
|
+
*/
|
|
928
|
+
private signPendingJoinInsert;
|
|
850
929
|
/**
|
|
851
930
|
* Insert a validation key into the control database using an owner signature.
|
|
852
931
|
*
|
|
@@ -1035,11 +1114,22 @@ export declare class ControlDatabase {
|
|
|
1035
1114
|
*
|
|
1036
1115
|
* Deliberately NOT wrapped in {@link withWriteLock}: every public entry point already
|
|
1037
1116
|
* holds the (non-re-entrant) lock, so taking it again here would self-deadlock.
|
|
1117
|
+
*/
|
|
1118
|
+
private deleteGuardedRow;
|
|
1119
|
+
/**
|
|
1120
|
+
* Delete the row incarnation `removal` names and file its tombstone — the statement pair
|
|
1121
|
+
* every owner removal of a guarded row runs ({@link deleteGuardedRow},
|
|
1122
|
+
* {@link deleteStrandAndPartyKey}, {@link replacePendingJoin}). The caller supplies the
|
|
1123
|
+
* transaction: the schema's `RevocationRecorded` check refuses the delete unless the
|
|
1124
|
+
* tombstone commits with it.
|
|
1038
1125
|
*
|
|
1039
|
-
*
|
|
1126
|
+
* The delete matches the stamp as well as the key, so it can only ever remove the
|
|
1127
|
+
* incarnation the signature names.
|
|
1128
|
+
*
|
|
1129
|
+
* `tableName` and its {@link GUARDED_KEY_COLUMN} column are interpolated into the SQL; both
|
|
1040
1130
|
* come from closed literal unions — no caller-supplied string reaches the statement.
|
|
1041
1131
|
*/
|
|
1042
|
-
private
|
|
1132
|
+
private execGuardedRemoval;
|
|
1043
1133
|
/**
|
|
1044
1134
|
* Fire the guarded-delete listener for one committed tombstone, swallowing (and
|
|
1045
1135
|
* logging) a listener throw — a committed delete never fails because bookkeeping did.
|
|
@@ -1130,7 +1220,8 @@ export declare class ControlDatabase {
|
|
|
1130
1220
|
* reads from this node (every other node already filters it by tombstone). A revoked
|
|
1131
1221
|
* node therefore keeps its own copy of its own row; "a node that learns it has been
|
|
1132
1222
|
* revoked should shut itself down" is a distinct behaviour and its own ticket.
|
|
1133
|
-
* `ValidationKey` and `
|
|
1223
|
+
* `ValidationKey`, `JoinedStrand` and `PendingJoin` have no self notion and are not
|
|
1224
|
+
* special-cased.
|
|
1134
1225
|
*
|
|
1135
1226
|
* A per-row failure is logged and skipped rather than aborting the sweep: an abort would
|
|
1136
1227
|
* starve every tombstone after the failing one on every subsequent pass, and each row is
|
package/dist/control-database.js
CHANGED
|
@@ -213,18 +213,26 @@ const GUARDED_KEY_COLUMN = {
|
|
|
213
213
|
Strand: 'Id',
|
|
214
214
|
StrandPartyKey: 'Id',
|
|
215
215
|
JoinedStrand: 'Id',
|
|
216
|
+
PendingJoin: 'Id',
|
|
216
217
|
DeviceToken: 'PeerId',
|
|
217
218
|
};
|
|
219
|
+
function signGuardedRemoval(tableName, rowKey, stampId, signMessage) {
|
|
220
|
+
return {
|
|
221
|
+
ref: { tableName, rowKey, stampId },
|
|
222
|
+
signature: signMessage(buildAuthorizationMessage(`CadreControl.${tableName}`, 'remove', [rowKey, stampId])),
|
|
223
|
+
revocationSignature: signMessage(buildAuthorizationMessage('CadreControl.Revocation', 'remove', [tableName, rowKey, stampId])),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
218
226
|
/**
|
|
219
|
-
* The rejection a second insert of an already-seated
|
|
220
|
-
*
|
|
221
|
-
*
|
|
227
|
+
* The rejection a second insert of an already-seated id into `table` produces, as the
|
|
228
|
+
* optimystic vtab words it (`uniqueConstraintMessage`, qualified by table name only — no
|
|
229
|
+
* schema prefix, matching `test/control-constraint-helpers.ts`'s `expectUniqueViolation`).
|
|
222
230
|
*
|
|
223
231
|
* Each table's `StampId` is unique too, but a fresh stamp is minted per insert attempt, so
|
|
224
|
-
* only the primary key can collide on a repeat
|
|
225
|
-
* keeps any OTHER uniqueness failure out of the idempotency branch.
|
|
232
|
+
* only the primary key can collide on a repeat insert. Matching the column explicitly
|
|
233
|
+
* keeps any OTHER uniqueness failure out of the caller's idempotency branch.
|
|
226
234
|
*/
|
|
227
|
-
function
|
|
235
|
+
function idConflictPattern(table) {
|
|
228
236
|
return new RegExp(`UNIQUE constraint failed: ${table}\\.Id\\b`, 'i');
|
|
229
237
|
}
|
|
230
238
|
/**
|
|
@@ -243,7 +251,42 @@ function strandIdConflictPattern(table) {
|
|
|
243
251
|
* reword reddens there.
|
|
244
252
|
*/
|
|
245
253
|
export function isStrandIdConflict(error, table) {
|
|
246
|
-
return errorChainMatches(error,
|
|
254
|
+
return errorChainMatches(error, idConflictPattern(table));
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Did {@link ControlDatabase.insertPendingJoin} fail because the party already holds a
|
|
258
|
+
* `PendingJoin` row for this invitation? The caller adopts the existing row instead.
|
|
259
|
+
* Text-matched, failing closed, like {@link isStrandIdConflict}.
|
|
260
|
+
*
|
|
261
|
+
* NOTE: the existing row may be one {@link ControlDatabase.queryPendingJoin} hides (its stamp
|
|
262
|
+
* is retired and no reap pass has removed it yet), so an adopting re-read can come back empty
|
|
263
|
+
* until the next reap pass.
|
|
264
|
+
*/
|
|
265
|
+
export function isPendingJoinConflict(error) {
|
|
266
|
+
return errorChainMatches(error, idConflictPattern('PendingJoin'));
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* The `PendingJoin.Id` of an invitation: the base64url sha256 `digest` of its token. One row
|
|
270
|
+
* per invitation, and the `Revocation` tombstone that names a removed row carries this, never
|
|
271
|
+
* the bearer token itself.
|
|
272
|
+
*/
|
|
273
|
+
export function pendingJoinId(token) {
|
|
274
|
+
return digest([token], 'sha256', 'base64url');
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* {@link ControlDatabase.replacePendingJoin} found that the live `PendingJoin` row is not the
|
|
278
|
+
* incarnation the caller read: another write replaced or removed it first (a sibling machine
|
|
279
|
+
* recorded an outcome, or the user dismissed the join). Nothing was written; the caller
|
|
280
|
+
* re-reads and decides again. `liveStampId` is null when the row is gone.
|
|
281
|
+
*/
|
|
282
|
+
export class PendingJoinChangedError extends Error {
|
|
283
|
+
constructor(id, expectedStampId, liveStampId) {
|
|
284
|
+
super(`PendingJoin ${id} changed: expected stamp ${expectedStampId}, found ${liveStampId ?? 'no row'}`);
|
|
285
|
+
this.id = id;
|
|
286
|
+
this.expectedStampId = expectedStampId;
|
|
287
|
+
this.liveStampId = liveStampId;
|
|
288
|
+
this.name = 'PendingJoinChangedError';
|
|
289
|
+
}
|
|
247
290
|
}
|
|
248
291
|
/**
|
|
249
292
|
* The one `CadreControl.Revocation` row that retires nothing, filed once by an owner
|
|
@@ -258,7 +301,7 @@ export const REVOCATION_LEDGER_MARKER = {
|
|
|
258
301
|
};
|
|
259
302
|
/**
|
|
260
303
|
* The rejection a second insert of the ledger marker produces: the optimystic vtab's
|
|
261
|
-
* primary-key wording (see {@link
|
|
304
|
+
* primary-key wording (see {@link idConflictPattern}), naming both columns of
|
|
262
305
|
* `Revocation`'s composite key. Applied only to the marker's own insert, whose key
|
|
263
306
|
* ('Revocation', 'opened') no row but the marker can hold (`RowIsGone`), and `Revocation`
|
|
264
307
|
* has no other unique constraint.
|
|
@@ -280,10 +323,12 @@ function errorChainMatches(error, pattern) {
|
|
|
280
323
|
* secrets stored nowhere else (tickets/backlog/debt-strand-tombstone-reap.md owns any
|
|
281
324
|
* future change). `JoinedStrand` also carries a `MemberPrivateKey` but IS reapable: that
|
|
282
325
|
* secret is the joined strand's shared read key, which its founding party and every member
|
|
283
|
-
* hold, so a reaped row is recoverable by re-forming. `
|
|
284
|
-
*
|
|
326
|
+
* hold, so a reaped row is recoverable by re-forming. `PendingJoin` is reapable for the same
|
|
327
|
+
* reason: the inviting party and the user also hold its invitation. `OwnerKey` has no
|
|
328
|
+
* production removal path and `MinOneOwner` makes an automated owner-key reap a
|
|
329
|
+
* party-bricking hazard.
|
|
285
330
|
*/
|
|
286
|
-
export const REAPABLE_TABLES = ['CadrePeer', 'DeviceToken', 'ValidationKey', 'JoinedStrand'];
|
|
331
|
+
export const REAPABLE_TABLES = ['CadrePeer', 'DeviceToken', 'ValidationKey', 'JoinedStrand', 'PendingJoin'];
|
|
287
332
|
/**
|
|
288
333
|
* Runtime membership test for {@link REAPABLE_TABLES}, narrowing a tombstone's
|
|
289
334
|
* `TableName` to the subset {@link ControlDatabase.reapRevokedRow} accepts. A tombstone
|
|
@@ -302,6 +347,48 @@ function joinedStrandRow(row) {
|
|
|
302
347
|
FounderOwnerKey: null,
|
|
303
348
|
};
|
|
304
349
|
}
|
|
350
|
+
/** Every `PendingJoin` column, in the schema's order. */
|
|
351
|
+
const PENDING_JOIN_COLUMNS = 'Id, Invitation, Disclosure, RequestedAt, ExpiresAt, Outcome, OutcomeAt, StrandId, MembershipInvite, FailureCode, FailureReason, StampId';
|
|
352
|
+
const PENDING_JOIN_INSERT_SQL = `
|
|
353
|
+
insert into CadreControl.PendingJoin (${PENDING_JOIN_COLUMNS})
|
|
354
|
+
with context OwnerKey = ?, Signature = ?
|
|
355
|
+
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
356
|
+
`;
|
|
357
|
+
/** A `PendingJoin` row as read; the schema's `KnownOutcome` check backs the `Outcome` cast. */
|
|
358
|
+
function pendingJoinRow(row) {
|
|
359
|
+
return {
|
|
360
|
+
Id: row.Id,
|
|
361
|
+
Invitation: row.Invitation,
|
|
362
|
+
Disclosure: row.Disclosure,
|
|
363
|
+
RequestedAt: row.RequestedAt,
|
|
364
|
+
ExpiresAt: row.ExpiresAt,
|
|
365
|
+
Outcome: row.Outcome,
|
|
366
|
+
OutcomeAt: row.OutcomeAt,
|
|
367
|
+
StrandId: row.StrandId,
|
|
368
|
+
MembershipInvite: row.MembershipInvite,
|
|
369
|
+
FailureCode: row.FailureCode,
|
|
370
|
+
FailureReason: row.FailureReason,
|
|
371
|
+
StampId: row.StampId,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* The row fields `PendingJoin.AuthorizedInsert` signs, in the schema's order: a null column
|
|
376
|
+
* signs as `''` and an integer as its text (`cast(... as text)` on the SQL side).
|
|
377
|
+
*/
|
|
378
|
+
function pendingJoinAddFields(row) {
|
|
379
|
+
return [
|
|
380
|
+
row.Id, row.Invitation, row.Disclosure, String(row.RequestedAt), String(row.ExpiresAt),
|
|
381
|
+
row.Outcome ?? '', row.OutcomeAt === null ? '' : String(row.OutcomeAt), row.StrandId ?? '',
|
|
382
|
+
row.MembershipInvite ?? '', row.FailureCode ?? '', row.FailureReason ?? '', row.StampId,
|
|
383
|
+
];
|
|
384
|
+
}
|
|
385
|
+
/** The values of {@link PENDING_JOIN_INSERT_SQL}'s columns, in {@link PENDING_JOIN_COLUMNS} order. */
|
|
386
|
+
function pendingJoinValues(row) {
|
|
387
|
+
return [
|
|
388
|
+
row.Id, row.Invitation, row.Disclosure, row.RequestedAt, row.ExpiresAt, row.Outcome, row.OutcomeAt,
|
|
389
|
+
row.StrandId, row.MembershipInvite, row.FailureCode, row.FailureReason, row.StampId,
|
|
390
|
+
];
|
|
391
|
+
}
|
|
305
392
|
/**
|
|
306
393
|
* ControlDatabase manages the CadreControl schema using Quereus with Optimystic backend.
|
|
307
394
|
* It provides typed query methods for accessing control network data.
|
|
@@ -722,6 +809,29 @@ export class ControlDatabase {
|
|
|
722
809
|
const rows = await this.readRows('select Id, Type, MemberPrivateKey from CadreControl.JoinedStrand where Id = ?', [strandId], 'joined-strand');
|
|
723
810
|
return rows.length === 0 ? null : joinedStrandRow(rows[0]);
|
|
724
811
|
}
|
|
812
|
+
/**
|
|
813
|
+
* Every join this party asked for and has not dismissed (`CadreControl.PendingJoin`),
|
|
814
|
+
* pending or finished.
|
|
815
|
+
*
|
|
816
|
+
* Rows whose `StampId` is retired in `Revocation` are dropped, as {@link queryCadrePeers}
|
|
817
|
+
* drops them: such a row is either a replaced or dismissed incarnation this node still
|
|
818
|
+
* holds until the reap sweep removes it, or one re-seated by a replayed approval on a node
|
|
819
|
+
* that had not yet seen the tombstone. Either way it must not bring a dismissed join back.
|
|
820
|
+
*/
|
|
821
|
+
async queryPendingJoins() {
|
|
822
|
+
this.ensureInitialized();
|
|
823
|
+
const revoked = await this.queryRevokedStamps('PendingJoin');
|
|
824
|
+
const rows = await this.readRows(`select ${PENDING_JOIN_COLUMNS} from CadreControl.PendingJoin`, undefined, 'pending-joins');
|
|
825
|
+
return rows.map(pendingJoinRow).filter(row => !revoked.has(row.StampId));
|
|
826
|
+
}
|
|
827
|
+
/** Single-row sibling of {@link queryPendingJoins}, with the same retired-stamp filter: the row with this id, or null. */
|
|
828
|
+
async queryPendingJoin(id) {
|
|
829
|
+
this.ensureInitialized();
|
|
830
|
+
const revoked = await this.queryRevokedStamps('PendingJoin');
|
|
831
|
+
const rows = await this.readRows(`select ${PENDING_JOIN_COLUMNS} from CadreControl.PendingJoin where Id = ?`, [id], 'pending-join');
|
|
832
|
+
const row = rows.length === 0 ? null : pendingJoinRow(rows[0]);
|
|
833
|
+
return row === null || revoked.has(row.StampId) ? null : row;
|
|
834
|
+
}
|
|
725
835
|
/**
|
|
726
836
|
* Count rows in a CadreControl table as seen by THIS database instance.
|
|
727
837
|
*
|
|
@@ -1270,13 +1380,10 @@ export class ControlDatabase {
|
|
|
1270
1380
|
* one exists — the strand's `StrandPartyKey` delete + tombstone, all in ONE
|
|
1271
1381
|
* transaction. A two-table sibling of {@link deleteGuardedRow} (see that method for the
|
|
1272
1382
|
* per-clause security rationale: stamp read inside the locked body, `'remove'`-tagged
|
|
1273
|
-
* digests, mandatory same-transaction tombstones)
|
|
1274
|
-
*
|
|
1275
|
-
*
|
|
1276
|
-
*
|
|
1277
|
-
* {@link deleteGuardedRow}'s discipline (an extra precondition, a different digest shape)
|
|
1278
|
-
* will NOT reach this body. Grep for both when you change either; if a second guarded
|
|
1279
|
-
* table ever gains a companion row, generalize instead of copying this a third time.
|
|
1383
|
+
* digests, mandatory same-transaction tombstones). Both sign through
|
|
1384
|
+
* {@link signGuardedRemoval} and run {@link execGuardedRemoval}, so a change to the digest
|
|
1385
|
+
* shape or the statement pair reaches this body; only the stamp reads and the companion-row
|
|
1386
|
+
* branch are its own, because no other guarded table has a companion row.
|
|
1280
1387
|
*
|
|
1281
1388
|
* Not wrapped in {@link withWriteLock} — the caller holds the (non-re-entrant) lock.
|
|
1282
1389
|
*/
|
|
@@ -1289,38 +1396,20 @@ export class ControlDatabase {
|
|
|
1289
1396
|
return false;
|
|
1290
1397
|
}
|
|
1291
1398
|
const partyKeyStamp = await this.queryStampId('StrandPartyKey', strandId, false);
|
|
1292
|
-
const
|
|
1293
|
-
const
|
|
1294
|
-
|
|
1295
|
-
|
|
1399
|
+
const strandRemoval = signGuardedRemoval('Strand', strandId, strandStamp, signMessage);
|
|
1400
|
+
const partyKeyRemoval = partyKeyStamp === null
|
|
1401
|
+
? null
|
|
1402
|
+
: signGuardedRemoval('StrandPartyKey', strandId, partyKeyStamp, signMessage);
|
|
1296
1403
|
await this.inTransaction('delete Strand', async () => {
|
|
1297
|
-
await this.
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
where Id = ?
|
|
1301
|
-
`, [ownerKey, strandSignature, strandId]);
|
|
1302
|
-
await this.db.exec(`
|
|
1303
|
-
insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt)
|
|
1304
|
-
with context OwnerKey = ?, Signature = ?
|
|
1305
|
-
values ('Strand', ?, ?, 0)
|
|
1306
|
-
`, [ownerKey, strandRevocationSignature, strandId, strandStamp]);
|
|
1307
|
-
if (partyKeyStamp !== null) {
|
|
1308
|
-
await this.db.exec(`
|
|
1309
|
-
delete from CadreControl.StrandPartyKey
|
|
1310
|
-
with context OwnerKey = ?, Signature = ?
|
|
1311
|
-
where Id = ?
|
|
1312
|
-
`, [ownerKey, partyKeySignature, strandId]);
|
|
1313
|
-
await this.db.exec(`
|
|
1314
|
-
insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt)
|
|
1315
|
-
with context OwnerKey = ?, Signature = ?
|
|
1316
|
-
values ('StrandPartyKey', ?, ?, 0)
|
|
1317
|
-
`, [ownerKey, partyKeyRevocationSignature, strandId, partyKeyStamp]);
|
|
1404
|
+
await this.execGuardedRemoval(strandRemoval, ownerKey);
|
|
1405
|
+
if (partyKeyRemoval !== null) {
|
|
1406
|
+
await this.execGuardedRemoval(partyKeyRemoval, ownerKey);
|
|
1318
1407
|
}
|
|
1319
1408
|
});
|
|
1320
|
-
log('Strand deleted: %s (stamp retired%s)', strandId,
|
|
1321
|
-
this.notifyGuardedDelete(
|
|
1322
|
-
if (
|
|
1323
|
-
this.notifyGuardedDelete(
|
|
1409
|
+
log('Strand deleted: %s (stamp retired%s)', strandId, partyKeyRemoval === null ? '' : '; party key deleted, stamp retired');
|
|
1410
|
+
this.notifyGuardedDelete(strandRemoval.ref);
|
|
1411
|
+
if (partyKeyRemoval !== null) {
|
|
1412
|
+
this.notifyGuardedDelete(partyKeyRemoval.ref);
|
|
1324
1413
|
}
|
|
1325
1414
|
return true;
|
|
1326
1415
|
}
|
|
@@ -1407,6 +1496,78 @@ export class ControlDatabase {
|
|
|
1407
1496
|
deleteJoinedStrand(strandId, ownerKey, signMessage) {
|
|
1408
1497
|
return this.lockedWithRetry(() => this.deleteGuardedRow('JoinedStrand', strandId, ownerKey, signMessage), {}, 'joined-strand-delete');
|
|
1409
1498
|
}
|
|
1499
|
+
/**
|
|
1500
|
+
* Record, party-wide, a join this party asked for (`PendingJoin` row), using an owner
|
|
1501
|
+
* signature over every column of the row (see {@link pendingJoinAddFields}). Returns the
|
|
1502
|
+
* row as written, with the fresh `StampId` a later {@link replacePendingJoin} names.
|
|
1503
|
+
*
|
|
1504
|
+
* Fails with a `PendingJoin.Id` uniqueness violation when the party already holds a row for
|
|
1505
|
+
* this invitation; {@link isPendingJoinConflict} identifies that case.
|
|
1506
|
+
*/
|
|
1507
|
+
async insertPendingJoin(row, ownerKey, signMessage) {
|
|
1508
|
+
this.ensureInitialized();
|
|
1509
|
+
log('Inserting pending join: %s (outcome: %s)', row.Id, row.Outcome);
|
|
1510
|
+
const { written, params } = this.signPendingJoinInsert(row, ownerKey, signMessage);
|
|
1511
|
+
await this.execWrite(PENDING_JOIN_INSERT_SQL, params, 'pending-join-insert');
|
|
1512
|
+
log('Pending join inserted: %s', row.Id);
|
|
1513
|
+
return written;
|
|
1514
|
+
}
|
|
1515
|
+
/**
|
|
1516
|
+
* Replace the live `PendingJoin` row `next.Id` with `next`, under a fresh stamp — the only way
|
|
1517
|
+
* an outcome is written, since the table refuses updates. Returns the row as written.
|
|
1518
|
+
*
|
|
1519
|
+
* One locked transaction deletes the row (owner-signed `'remove'` digest, with its
|
|
1520
|
+
* `Revocation` tombstone) and inserts its successor, so no reader ever sees the id missing in
|
|
1521
|
+
* between. The live row must still be the incarnation the caller read: when its `StampId` is
|
|
1522
|
+
* not `expectedStampId`, or the row is gone, this throws {@link PendingJoinChangedError} and
|
|
1523
|
+
* writes nothing. That check is local; a sibling machine's replacement or removal that lands
|
|
1524
|
+
* between it and the commit fails the transaction instead (its tombstone for the same stamp,
|
|
1525
|
+
* or its successor row, collides on a primary key), and the caller re-reads either way.
|
|
1526
|
+
*
|
|
1527
|
+
* Signatures and the new stamp are minted before the lock, so a retried attempt re-presents
|
|
1528
|
+
* the same signed messages (the contract on {@link withWriteLock}). A retry after an attempt
|
|
1529
|
+
* that committed but reported failure throws {@link PendingJoinChangedError} whose
|
|
1530
|
+
* `liveStampId` is the returned row's own stamp, so a caller that re-reads finds its write.
|
|
1531
|
+
*/
|
|
1532
|
+
async replacePendingJoin(expectedStampId, next, ownerKey, signMessage) {
|
|
1533
|
+
this.ensureInitialized();
|
|
1534
|
+
const id = next.Id;
|
|
1535
|
+
const removal = signGuardedRemoval('PendingJoin', id, expectedStampId, signMessage);
|
|
1536
|
+
const { written, params } = this.signPendingJoinInsert(next, ownerKey, signMessage);
|
|
1537
|
+
return this.lockedWithRetry(async () => {
|
|
1538
|
+
// retry: false — this runs inside the locked write body (see queryStampId's NOTE).
|
|
1539
|
+
const liveStampId = await this.queryStampId('PendingJoin', id, false);
|
|
1540
|
+
if (liveStampId !== expectedStampId) {
|
|
1541
|
+
throw new PendingJoinChangedError(id, expectedStampId, liveStampId);
|
|
1542
|
+
}
|
|
1543
|
+
await this.inTransaction('replace PendingJoin', async () => {
|
|
1544
|
+
await this.execGuardedRemoval(removal, ownerKey);
|
|
1545
|
+
await this.db.exec(PENDING_JOIN_INSERT_SQL, params);
|
|
1546
|
+
});
|
|
1547
|
+
log('Pending join replaced: %s (outcome: %s; stamp retired)', id, next.Outcome);
|
|
1548
|
+
this.notifyGuardedDelete(removal.ref);
|
|
1549
|
+
return written;
|
|
1550
|
+
}, {}, 'pending-join-replace');
|
|
1551
|
+
}
|
|
1552
|
+
/**
|
|
1553
|
+
* Owner-signed removal of one `PendingJoin` row, as {@link deleteJoinedStrand} removes its
|
|
1554
|
+
* row: `'remove'`-tagged digest over (Id, StampId), `Revocation` tombstone in the same
|
|
1555
|
+
* transaction. A no-op (no throw, no tombstone) when the row does not exist — `false` then,
|
|
1556
|
+
* `true` when a row was actually removed.
|
|
1557
|
+
*/
|
|
1558
|
+
deletePendingJoin(id, ownerKey, signMessage) {
|
|
1559
|
+
return this.lockedWithRetry(() => this.deleteGuardedRow('PendingJoin', id, ownerKey, signMessage), {}, 'pending-join-delete');
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* Mint a fresh stamp for `row` and the owner signature `PendingJoin.AuthorizedInsert`
|
|
1563
|
+
* verifies, returning the row as it will be written and the parameters of
|
|
1564
|
+
* {@link PENDING_JOIN_INSERT_SQL}.
|
|
1565
|
+
*/
|
|
1566
|
+
signPendingJoinInsert(row, ownerKey, signMessage) {
|
|
1567
|
+
const written = { ...row, StampId: generateStampId(this.config.libp2pNode.peerId.toString()) };
|
|
1568
|
+
const signature = signMessage(buildAuthorizationMessage('CadreControl.PendingJoin', 'add', pendingJoinAddFields(written)));
|
|
1569
|
+
return { written, params: [ownerKey, signature, ...pendingJoinValues(written)] };
|
|
1570
|
+
}
|
|
1410
1571
|
/**
|
|
1411
1572
|
* Insert a validation key into the control database using an owner signature.
|
|
1412
1573
|
*
|
|
@@ -1652,9 +1813,6 @@ export class ControlDatabase {
|
|
|
1652
1813
|
*
|
|
1653
1814
|
* Deliberately NOT wrapped in {@link withWriteLock}: every public entry point already
|
|
1654
1815
|
* holds the (non-re-entrant) lock, so taking it again here would self-deadlock.
|
|
1655
|
-
*
|
|
1656
|
-
* `table` and its {@link GUARDED_KEY_COLUMN} column are interpolated into the SQL; both
|
|
1657
|
-
* come from closed literal unions — no caller-supplied string reaches the statement.
|
|
1658
1816
|
*/
|
|
1659
1817
|
async deleteGuardedRow(table, keyValue, ownerKey, signMessage) {
|
|
1660
1818
|
this.ensureInitialized();
|
|
@@ -1664,31 +1822,41 @@ export class ControlDatabase {
|
|
|
1664
1822
|
log('delete %s: no row for %s (already absent)', table, keyValue);
|
|
1665
1823
|
return false;
|
|
1666
1824
|
}
|
|
1667
|
-
const
|
|
1668
|
-
|
|
1669
|
-
// The tombstone carries its OWN owner signature (`Revocation.Authorized`): retiring a
|
|
1670
|
-
// stamp is permanent and party-wide, so the delete's signature deliberately does not
|
|
1671
|
-
// cover it — the digests are domain-separated and neither replays as the other.
|
|
1672
|
-
const revocationSignature = signMessage(buildAuthorizationMessage('CadreControl.Revocation', 'remove', [table, keyValue, stampId]));
|
|
1673
|
-
await this.inTransaction(`delete ${table}`, async () => {
|
|
1674
|
-
await this.db.exec(`
|
|
1675
|
-
delete from CadreControl.${table}
|
|
1676
|
-
with context OwnerKey = ?, Signature = ?
|
|
1677
|
-
where ${GUARDED_KEY_COLUMN[table]} = ?
|
|
1678
|
-
`, [ownerKey, signature, keyValue]);
|
|
1679
|
-
// ReissuedAt named explicitly at 0 (the only value FreshTombstone accepts) rather
|
|
1680
|
-
// than leaning on the column default — the seat-at-zero rule is load-bearing for
|
|
1681
|
-
// reissueRevocations' monotonic bump, so state it at the write site.
|
|
1682
|
-
await this.db.exec(`
|
|
1683
|
-
insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt)
|
|
1684
|
-
with context OwnerKey = ?, Signature = ?
|
|
1685
|
-
values (?, ?, ?, 0)
|
|
1686
|
-
`, [ownerKey, revocationSignature, table, keyValue, stampId]);
|
|
1687
|
-
});
|
|
1825
|
+
const removal = signGuardedRemoval(table, keyValue, stampId, signMessage);
|
|
1826
|
+
await this.inTransaction(`delete ${table}`, () => this.execGuardedRemoval(removal, ownerKey));
|
|
1688
1827
|
log('%s deleted: %s (stamp retired)', table, keyValue);
|
|
1689
|
-
this.notifyGuardedDelete(
|
|
1828
|
+
this.notifyGuardedDelete(removal.ref);
|
|
1690
1829
|
return true;
|
|
1691
1830
|
}
|
|
1831
|
+
/**
|
|
1832
|
+
* Delete the row incarnation `removal` names and file its tombstone — the statement pair
|
|
1833
|
+
* every owner removal of a guarded row runs ({@link deleteGuardedRow},
|
|
1834
|
+
* {@link deleteStrandAndPartyKey}, {@link replacePendingJoin}). The caller supplies the
|
|
1835
|
+
* transaction: the schema's `RevocationRecorded` check refuses the delete unless the
|
|
1836
|
+
* tombstone commits with it.
|
|
1837
|
+
*
|
|
1838
|
+
* The delete matches the stamp as well as the key, so it can only ever remove the
|
|
1839
|
+
* incarnation the signature names.
|
|
1840
|
+
*
|
|
1841
|
+
* `tableName` and its {@link GUARDED_KEY_COLUMN} column are interpolated into the SQL; both
|
|
1842
|
+
* come from closed literal unions — no caller-supplied string reaches the statement.
|
|
1843
|
+
*/
|
|
1844
|
+
async execGuardedRemoval(removal, ownerKey) {
|
|
1845
|
+
const { tableName, rowKey, stampId } = removal.ref;
|
|
1846
|
+
await this.db.exec(`
|
|
1847
|
+
delete from CadreControl.${tableName}
|
|
1848
|
+
with context OwnerKey = ?, Signature = ?
|
|
1849
|
+
where ${GUARDED_KEY_COLUMN[tableName]} = ? and StampId = ?
|
|
1850
|
+
`, [ownerKey, removal.signature, rowKey, stampId]);
|
|
1851
|
+
// ReissuedAt named explicitly at 0 (the only value FreshTombstone accepts) rather
|
|
1852
|
+
// than leaning on the column default — the seat-at-zero rule is load-bearing for
|
|
1853
|
+
// reissueRevocations' monotonic bump, so state it at the write site.
|
|
1854
|
+
await this.db.exec(`
|
|
1855
|
+
insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt)
|
|
1856
|
+
with context OwnerKey = ?, Signature = ?
|
|
1857
|
+
values (?, ?, ?, 0)
|
|
1858
|
+
`, [ownerKey, removal.revocationSignature, tableName, rowKey, stampId]);
|
|
1859
|
+
}
|
|
1692
1860
|
/**
|
|
1693
1861
|
* Fire the guarded-delete listener for one committed tombstone, swallowing (and
|
|
1694
1862
|
* logging) a listener throw — a committed delete never fails because bookkeeping did.
|
|
@@ -1826,7 +1994,8 @@ export class ControlDatabase {
|
|
|
1826
1994
|
* reads from this node (every other node already filters it by tombstone). A revoked
|
|
1827
1995
|
* node therefore keeps its own copy of its own row; "a node that learns it has been
|
|
1828
1996
|
* revoked should shut itself down" is a distinct behaviour and its own ticket.
|
|
1829
|
-
* `ValidationKey` and `
|
|
1997
|
+
* `ValidationKey`, `JoinedStrand` and `PendingJoin` have no self notion and are not
|
|
1998
|
+
* special-cased.
|
|
1830
1999
|
*
|
|
1831
2000
|
* A per-row failure is logged and skipped rather than aborting the sweep: an abort would
|
|
1832
2001
|
* starve every tombstone after the failing one on every subsequent pass, and each row is
|