@serfab/cadre-core 1.16.0 → 1.18.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/README.md +4 -3
- package/dist/bootstrap-peer-store-file.js +1 -1
- package/dist/bootstrap-peer-store.d.ts +1 -1
- package/dist/bootstrap-peer-store.js +4 -4
- package/dist/cadre-invite-protocol.d.ts +81 -142
- package/dist/cadre-invite-protocol.js +168 -458
- package/dist/cadre-invite-protocol.js.map +1 -1
- package/dist/cadre-join-request.d.ts +15 -2
- package/dist/cadre-join-request.js +22 -3
- package/dist/cadre-join-request.js.map +1 -1
- package/dist/cadre-node.d.ts +1089 -475
- package/dist/cadre-node.js +2170 -1037
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-authorization.d.ts +36 -14
- package/dist/control-authorization.js +20 -3
- package/dist/control-authorization.js.map +1 -1
- package/dist/control-cohort.d.ts +88 -32
- package/dist/control-cohort.js +91 -33
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +856 -318
- package/dist/control-database.js +1422 -701
- package/dist/control-database.js.map +1 -1
- package/dist/control-formation-recorder.d.ts +46 -43
- package/dist/control-formation-recorder.js +77 -78
- package/dist/control-formation-recorder.js.map +1 -1
- package/dist/control-protocol-guard.d.ts +11 -4
- package/dist/control-protocol-guard.js +20 -5
- package/dist/control-protocol-guard.js.map +1 -1
- package/dist/control-schema-version.d.ts +97 -0
- package/dist/control-schema-version.js +225 -0
- package/dist/control-schema-version.js.map +1 -0
- package/dist/control-schema.d.ts +45 -5
- package/dist/control-schema.js +262 -78
- package/dist/control-schema.js.map +1 -1
- package/dist/control-write-retry.d.ts +45 -10
- package/dist/control-write-retry.js +92 -22
- package/dist/control-write-retry.js.map +1 -1
- package/dist/delegate-admission.d.ts +13 -0
- package/dist/delegate-admission.js +27 -0
- package/dist/delegate-admission.js.map +1 -1
- package/dist/ed25519-key.d.ts +10 -7
- package/dist/ed25519-key.js +23 -20
- package/dist/ed25519-key.js.map +1 -1
- package/dist/edge-node/config.d.ts +122 -0
- package/dist/edge-node/config.js +199 -0
- package/dist/edge-node/config.js.map +1 -0
- package/dist/edge-node/dissolve.d.ts +95 -0
- package/dist/edge-node/dissolve.js +184 -0
- package/dist/edge-node/dissolve.js.map +1 -0
- package/dist/edge-node/edge-join.d.ts +44 -0
- package/dist/edge-node/edge-join.js +227 -0
- package/dist/edge-node/edge-join.js.map +1 -0
- package/dist/edge-node/edge-node.d.ts +295 -0
- package/dist/edge-node/edge-node.js +704 -0
- package/dist/edge-node/edge-node.js.map +1 -0
- package/dist/edge-node/index.d.ts +18 -0
- package/dist/edge-node/index.js +18 -0
- package/dist/edge-node/index.js.map +1 -0
- package/dist/edge-node/join-credential.d.ts +11 -0
- package/dist/edge-node/join-credential.js +11 -0
- package/dist/edge-node/join-credential.js.map +1 -0
- package/dist/edge-node/lazy-raw-storage.d.ts +40 -0
- package/dist/edge-node/lazy-raw-storage.js +134 -0
- package/dist/edge-node/lazy-raw-storage.js.map +1 -0
- package/dist/edge-node/node-local.d.ts +60 -0
- package/dist/edge-node/node-local.js +58 -0
- package/dist/edge-node/node-local.js.map +1 -0
- package/dist/edge-node/options.d.ts +83 -0
- package/dist/edge-node/options.js +110 -0
- package/dist/edge-node/options.js.map +1 -0
- package/dist/edge-node/owner-grant.d.ts +41 -0
- package/dist/edge-node/owner-grant.js +38 -0
- package/dist/edge-node/owner-grant.js.map +1 -0
- package/dist/edge-node/platform.d.ts +140 -0
- package/dist/edge-node/platform.js +12 -0
- package/dist/edge-node/platform.js.map +1 -0
- package/dist/edge-node/strand-move.d.ts +115 -0
- package/dist/edge-node/strand-move.js +223 -0
- package/dist/edge-node/strand-move.js.map +1 -0
- package/dist/edge-node/strands.d.ts +28 -0
- package/dist/edge-node/strands.js +60 -0
- package/dist/edge-node/strands.js.map +1 -0
- package/dist/enrolled-machine-store.d.ts +3 -3
- package/dist/enrolled-machine-store.js +3 -3
- package/dist/formation-token.d.ts +48 -0
- package/dist/formation-token.js +26 -0
- package/dist/formation-token.js.map +1 -0
- package/dist/index.d.ts +27 -17
- package/dist/index.js +39 -17
- package/dist/index.js.map +1 -1
- package/dist/joined-strand-store.d.ts +29 -12
- package/dist/joined-strand-store.js +66 -27
- package/dist/joined-strand-store.js.map +1 -1
- package/dist/key-store.d.ts +9 -4
- package/dist/key-store.js +9 -4
- package/dist/key-store.js.map +1 -1
- package/dist/link-budget.d.ts +9 -7
- package/dist/link-budget.js +9 -7
- package/dist/link-budget.js.map +1 -1
- package/dist/machine-removal.d.ts +19 -39
- package/dist/machine-removal.js +24 -98
- package/dist/machine-removal.js.map +1 -1
- package/dist/member-redemption.d.ts +256 -0
- package/dist/member-redemption.js +490 -0
- package/dist/member-redemption.js.map +1 -0
- package/dist/membership-view.d.ts +91 -0
- package/dist/membership-view.js +110 -0
- package/dist/membership-view.js.map +1 -0
- package/dist/owner-anchor-sync.d.ts +4 -4
- package/dist/owner-anchor-sync.js +16 -15
- package/dist/owner-anchor-sync.js.map +1 -1
- package/dist/owner-grant-protocol.d.ts +251 -0
- package/dist/owner-grant-protocol.js +344 -0
- package/dist/owner-grant-protocol.js.map +1 -0
- package/dist/owner-key-export.d.ts +70 -0
- package/dist/owner-key-export.js +279 -0
- package/dist/owner-key-export.js.map +1 -0
- package/dist/owner-key-flows.d.ts +55 -0
- package/dist/owner-key-flows.js +60 -0
- package/dist/owner-key-flows.js.map +1 -0
- package/dist/owner-key-store.d.ts +31 -0
- package/dist/owner-key-store.js +81 -0
- package/dist/owner-key-store.js.map +1 -0
- package/dist/owner-keys.d.ts +78 -0
- package/dist/owner-keys.js +133 -0
- package/dist/owner-keys.js.map +1 -0
- package/dist/owner-signer.d.ts +54 -0
- package/dist/owner-signer.js +51 -0
- package/dist/owner-signer.js.map +1 -0
- package/dist/peer-addr-book.d.ts +10 -1
- package/dist/peer-addr-book.js +4 -3
- package/dist/peer-addr-book.js.map +1 -1
- package/dist/peer-authorization.d.ts +83 -18
- package/dist/peer-authorization.js +123 -53
- package/dist/peer-authorization.js.map +1 -1
- package/dist/peer-join-backfill.d.ts +89 -20
- package/dist/peer-join-backfill.js +265 -108
- package/dist/peer-join-backfill.js.map +1 -1
- package/dist/pending-join-runner.d.ts +48 -18
- package/dist/pending-join-runner.js +74 -56
- package/dist/pending-join-runner.js.map +1 -1
- package/dist/provider/rent-node.js +1 -1
- package/dist/provider/rent-node.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +162 -133
- package/dist/seed-bootstrap.js +300 -283
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/seed-trust-policy.js +4 -4
- package/dist/strand-addr-protocol.d.ts +4 -1
- package/dist/strand-addr-protocol.js +8 -2
- package/dist/strand-addr-protocol.js.map +1 -1
- package/dist/strand-cohort.d.ts +3 -0
- package/dist/strand-cohort.js.map +1 -1
- package/dist/strand-database.d.ts +24 -2
- package/dist/strand-database.js +29 -6
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-first-sync-gate.d.ts +176 -88
- package/dist/strand-first-sync-gate.js +288 -121
- package/dist/strand-first-sync-gate.js.map +1 -1
- package/dist/strand-formation-deadlines.d.ts +9 -9
- package/dist/strand-formation-deadlines.js +9 -9
- package/dist/strand-formation-manager.d.ts +23 -17
- package/dist/strand-formation-manager.js +46 -44
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +5 -8
- package/dist/strand-formation-protocol.js +101 -16
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +27 -18
- package/dist/strand-instance-manager.js +119 -50
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-membership-reconciler.d.ts +16 -13
- package/dist/strand-membership-reconciler.js +30 -22
- package/dist/strand-membership-reconciler.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +45 -22
- package/dist/strand-membership-writer.js +226 -131
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-move.d.ts +6 -5
- package/dist/strand-move.js +36 -32
- package/dist/strand-move.js.map +1 -1
- package/dist/strand-peer-redial.js +2 -3
- package/dist/strand-peer-redial.js.map +1 -1
- package/dist/strand-revocation-enforcer.d.ts +134 -60
- package/dist/strand-revocation-enforcer.js +144 -71
- package/dist/strand-revocation-enforcer.js.map +1 -1
- package/dist/strand-solicitation.d.ts +50 -39
- package/dist/strand-solicitation.js +10 -3
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-watcher.d.ts +80 -13
- package/dist/strand-watcher.js +152 -48
- package/dist/strand-watcher.js.map +1 -1
- package/dist/table-change-watch.d.ts +63 -0
- package/dist/table-change-watch.js +209 -0
- package/dist/table-change-watch.js.map +1 -0
- package/dist/timeout-scheduler.d.ts +4 -3
- package/dist/timeout-scheduler.js +4 -3
- package/dist/timeout-scheduler.js.map +1 -1
- package/dist/types.d.ts +374 -150
- package/dist/types.js +4 -4
- package/dist/types.js.map +1 -1
- package/package.json +17 -8
- package/src/bootstrap-peer-store-file.ts +44 -44
- package/src/bootstrap-peer-store.ts +214 -214
- package/src/cadre-invite-protocol.ts +740 -1052
- package/src/cadre-join-request.ts +24 -3
- package/src/cadre-node.ts +2402 -1079
- package/src/control-authorization.ts +47 -14
- package/src/control-cohort.ts +204 -128
- package/src/control-database.ts +1749 -820
- package/src/control-formation-recorder.ts +359 -353
- package/src/control-protocol-guard.ts +23 -6
- package/src/control-schema-version.ts +301 -0
- package/src/control-schema.ts +273 -78
- package/src/control-write-retry.ts +641 -567
- package/src/delegate-admission.ts +351 -314
- package/src/ed25519-key.ts +174 -171
- package/src/edge-node/config.ts +277 -0
- package/src/edge-node/dissolve.ts +251 -0
- package/src/edge-node/edge-join.ts +319 -0
- package/src/edge-node/edge-node.ts +1045 -0
- package/src/edge-node/index.ts +59 -0
- package/src/edge-node/join-credential.ts +16 -0
- package/src/edge-node/lazy-raw-storage.ts +178 -0
- package/src/edge-node/node-local.ts +84 -0
- package/src/edge-node/options.ts +179 -0
- package/src/edge-node/owner-grant.ts +63 -0
- package/src/edge-node/platform.ts +151 -0
- package/src/edge-node/strand-move.ts +270 -0
- package/src/edge-node/strands.ts +72 -0
- package/src/enrolled-machine-store.ts +3 -3
- package/src/formation-token.ts +63 -0
- package/src/index.ts +899 -789
- package/src/joined-strand-store.ts +73 -28
- package/src/key-store.ts +10 -4
- package/src/link-budget.ts +9 -7
- package/src/machine-removal.ts +26 -111
- package/src/member-redemption.ts +649 -0
- package/src/membership-view.ts +144 -0
- package/src/owner-anchor-sync.ts +19 -18
- package/src/owner-grant-protocol.ts +547 -0
- package/src/owner-key-export.ts +348 -0
- package/src/owner-key-flows.ts +109 -0
- package/src/owner-key-store.ts +85 -0
- package/src/owner-keys.ts +178 -0
- package/src/owner-signer.ts +94 -0
- package/src/peer-addr-book.ts +4 -3
- package/src/peer-authorization.ts +558 -488
- package/src/peer-join-backfill.ts +971 -761
- package/src/pending-join-runner.ts +76 -53
- package/src/provider/rent-node.ts +1 -1
- package/src/seed-bootstrap.ts +343 -349
- package/src/seed-trust-policy.ts +4 -4
- package/src/strand-addr-protocol.ts +547 -538
- package/src/strand-cohort.ts +4 -0
- package/src/strand-database.ts +352 -319
- package/src/strand-first-sync-gate.ts +334 -133
- package/src/strand-formation-deadlines.ts +227 -227
- package/src/strand-formation-manager.ts +731 -725
- package/src/strand-formation-protocol.ts +122 -24
- package/src/strand-instance-manager.ts +2092 -2026
- package/src/strand-membership-reconciler.ts +33 -25
- package/src/strand-membership-writer.ts +2093 -1974
- package/src/strand-move.ts +50 -38
- package/src/strand-peer-redial.ts +2 -3
- package/src/strand-revocation-enforcer.ts +740 -654
- package/src/strand-solicitation.ts +61 -34
- package/src/strand-watcher.ts +175 -56
- package/src/table-change-watch.ts +285 -0
- package/src/timeout-scheduler.ts +29 -28
- package/src/types.ts +391 -152
package/dist/control-database.js
CHANGED
|
@@ -4,14 +4,19 @@ import { Database, FunctionFlags, TEXT_RETURN, createScalarFunction, registerPlu
|
|
|
4
4
|
import cryptoPlugin from '@optimystic/quereus-plugin-crypto/plugin';
|
|
5
5
|
import optimysticPlugin from '@optimystic/quereus-plugin-optimystic/plugin';
|
|
6
6
|
import { digest, randomBytes } from '@optimystic/quereus-plugin-crypto';
|
|
7
|
-
import {
|
|
7
|
+
import { createEmptyCollections } from '@serfab/quereus-plugin-sereus';
|
|
8
|
+
import { APPLY_CONTROL_SCHEMA, CONTROL_SCHEMA_DECLARATION, CONTROL_SCHEMA_NAME, controlSchemaFileDeclaration } from './control-schema.js';
|
|
9
|
+
import { refuseIncompatibleControlStore } from './control-schema-version.js';
|
|
8
10
|
import { canonicalDatetime } from './canonical-datetime.js';
|
|
9
|
-
import { controlAuthorizationFields, unboundAuthorizationFields, cadreInviteRowFields, CONTROL_TABLES } from './control-authorization.js';
|
|
10
|
-
import {
|
|
11
|
-
import { verifyCadreInviteRow } from './peer-authorization.js';
|
|
12
|
-
import {
|
|
11
|
+
import { controlAuthorizationFields, unboundAuthorizationFields, cadreInviteRowFields, ownerGrantRowFields, CONTROL_TABLES } from './control-authorization.js';
|
|
12
|
+
import { requireEd25519PublicKeyB64 } from './ed25519-key.js';
|
|
13
|
+
import { verifyCadreInviteRow, verifyOwnerGrantRow } from './peer-authorization.js';
|
|
14
|
+
import { OWNER_KEY_MAX_LABEL } from './owner-key-export.js';
|
|
15
|
+
import { retryControlWrite, LOST_UPDATE_RETRY_POLICY, SCHEMA_INIT_RETRY_POLICY } from './control-write-retry.js';
|
|
13
16
|
import { isCohortUnreachableRead, retryControlRead } from './control-read-retry.js';
|
|
14
17
|
import { chainMessages } from './control-retry.js';
|
|
18
|
+
import { TableWatchRegistry } from './table-change-watch.js';
|
|
19
|
+
import { MembershipView } from './membership-view.js';
|
|
15
20
|
const log = debug('sereus:cadre:control-db');
|
|
16
21
|
const timing = debug('sereus:cadre:timing');
|
|
17
22
|
/**
|
|
@@ -40,12 +45,11 @@ export function generateStampId(peerId) {
|
|
|
40
45
|
* present locally.
|
|
41
46
|
*
|
|
42
47
|
* Consent is bound to the strand's one-off `StampId` (see the consent branch of
|
|
43
|
-
* `Strand.AuthorizedInsert`), so
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* CHECK at commit (which would drop the write with a far less legible error).
|
|
48
|
+
* `Strand.AuthorizedInsert`), so a writer not handed the stamp reads the live row before
|
|
49
|
+
* inserting, and throws this when the row is absent rather than leaving it to fail the
|
|
50
|
+
* deferred `StrandExists` CHECK at commit (a far less legible error). The formation
|
|
51
|
+
* responder hands the stamp over from `ControlFormationUsageRecorder.resolveStrand`, which
|
|
52
|
+
* reports an absent row as `missing`; a row removed after that read fails the CHECK instead.
|
|
49
53
|
*/
|
|
50
54
|
export class MissingHostStrandError extends Error {
|
|
51
55
|
constructor(strandId, token) {
|
|
@@ -78,22 +82,23 @@ export class FormationAbortedError extends Error {
|
|
|
78
82
|
/**
|
|
79
83
|
* The invitation's seat budget is already spent: the count of recorded usage rows for the
|
|
80
84
|
* invitation has reached its `TotalUses`, so this redemption cannot be given a seat. `token`
|
|
81
|
-
* is the `FormationInvite.Token` or the `
|
|
82
|
-
* redeemed against.
|
|
85
|
+
* is the `FormationInvite.Token`, the `CadreInvite.Key` or the `OwnerGrant.Key`, whichever
|
|
86
|
+
* table the caller redeemed against.
|
|
83
87
|
*
|
|
84
88
|
* Raised off the committed-count check ({@link ControlDatabase.assertSeatRemains}) ahead of
|
|
85
89
|
* the write, inside {@link ControlDatabase.redeemInvitation} /
|
|
86
90
|
* {@link ControlDatabase.recordFormationUsage}, off the same check for a cadre invitation
|
|
87
|
-
* ({@link ControlDatabase.redeemCadreInvite})
|
|
88
|
-
*
|
|
89
|
-
*
|
|
91
|
+
* ({@link ControlDatabase.redeemCadreInvite}) and an owner grant
|
|
92
|
+
* ({@link ControlDatabase.redeemOwnerGrant}), and off the unlocked re-check
|
|
93
|
+
* `ControlFormationUsageRecorder.authorizeUsage` runs after an outside approval, before the
|
|
94
|
+
* manager issues a membership pass — and off nothing else. It exists because
|
|
90
95
|
* without it the refusal surfaces as a generic `CHECK constraint failed: Authorized` from the
|
|
91
96
|
* schema's own count-based cap clause, which the manager reports as a retryable
|
|
92
97
|
* `Formation conflict, retry` — telling the joiner to retry something that can never succeed.
|
|
93
98
|
* Two redemptions of one token racing on the SAME node serialize behind the local write
|
|
94
|
-
* queue, so the loser reads the winner's committed row and is refused here by name —
|
|
95
|
-
* `StrandFormationManager.validateToken`
|
|
96
|
-
*
|
|
99
|
+
* queue, so the loser reads the winner's committed row and is refused here by name — the
|
|
100
|
+
* use count `StrandFormationManager.validateToken` reads cannot catch that, since it was
|
|
101
|
+
* read before either redemption wrote anything.
|
|
97
102
|
*/
|
|
98
103
|
export class InvitationExhaustedError extends Error {
|
|
99
104
|
constructor(token, usesRecorded, totalUses) {
|
|
@@ -119,8 +124,42 @@ export class CadreInviteIssuerUnknownError extends Error {
|
|
|
119
124
|
this.name = 'CadreInviteIssuerUnknownError';
|
|
120
125
|
}
|
|
121
126
|
}
|
|
122
|
-
/**
|
|
123
|
-
|
|
127
|
+
/**
|
|
128
|
+
* {@link ControlDatabase.seatOwnerGrant} was refused by `OwnerGrant.AuthorizedInsert`: the
|
|
129
|
+
* grant twin of {@link CadreInviteIssuerUnknownError}, for the same two causes (the issuer's
|
|
130
|
+
* `OwnerKey` row has not replicated here yet or has since been removed; or a tampered row).
|
|
131
|
+
* Its own class rather than a shared one so a caller that handles invitation and grant
|
|
132
|
+
* bundles can tell by `instanceof` which credential was refused.
|
|
133
|
+
*/
|
|
134
|
+
export class OwnerGrantIssuerUnknownError extends Error {
|
|
135
|
+
constructor(grantKey, issuerKey, options) {
|
|
136
|
+
super(`Owner grant ${grantKey} cannot be seated here: its issuer ${issuerKey} is not an owner on this node, or its signature does not verify`, options);
|
|
137
|
+
this.grantKey = grantKey;
|
|
138
|
+
this.issuerKey = issuerKey;
|
|
139
|
+
this.name = 'OwnerGrantIssuerUnknownError';
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* An issued row (a `CadreInvite` or an `OwnerGrant`) the cadre holds that this node cannot read
|
|
144
|
+
* yet: {@link ControlDatabase.seatCadreInvite} / {@link ControlDatabase.seatOwnerGrant} had its
|
|
145
|
+
* insert refused by a cohort member already holding the row, and a refreshed read here still
|
|
146
|
+
* finds nothing; or the redemption that follows the seat finds no row. It clears once this
|
|
147
|
+
* node's store catches up, which is why the redemption paths answer it with the retryable
|
|
148
|
+
* `conflict` code.
|
|
149
|
+
*/
|
|
150
|
+
export class IssuedRowNotHereYetError extends Error {
|
|
151
|
+
constructor(table, key, options) {
|
|
152
|
+
super(`${table} ${key} is held by the cadre but not readable on this node yet; try again once it has synced`, options);
|
|
153
|
+
this.table = table;
|
|
154
|
+
this.key = key;
|
|
155
|
+
this.name = 'IssuedRowNotHereYetError';
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The refusal {@link CadreInviteIssuerUnknownError} and {@link OwnerGrantIssuerUnknownError}
|
|
160
|
+
* wrap — the constraint named, as the engine words it; both tables name theirs `AuthorizedInsert`.
|
|
161
|
+
*/
|
|
162
|
+
const ISSUER_UNAUTHORIZED = /CHECK constraint failed: AuthorizedInsert\b/;
|
|
124
163
|
/**
|
|
125
164
|
* Parse a stored Quereus `datetime` value into epoch milliseconds.
|
|
126
165
|
*
|
|
@@ -288,6 +327,39 @@ export function cadreInviteConsentMessage(partyId, fields) {
|
|
|
288
327
|
fields.inviteKey, fields.usageStampId, fields.peerKey,
|
|
289
328
|
]);
|
|
290
329
|
}
|
|
330
|
+
/**
|
|
331
|
+
* The exact bytes an owner signs to seat an `OwnerGrant` row — the TS mirror of the `'add'`
|
|
332
|
+
* digest in `OwnerGrant.AuthorizedInsert`, over the whole row in the schema's order with
|
|
333
|
+
* nullable columns as `''` (`ownerGrantRowFields`, shared with the base64url verifier
|
|
334
|
+
* `peer-authorization.ts`'s `ownerGrantAddDigest`). `expiresAt` must already be the stored
|
|
335
|
+
* canonical `datetime` string, as {@link ControlDatabase.insertOwnerGrant} produces it, for
|
|
336
|
+
* the reason on {@link cadreInviteAddMessage}.
|
|
337
|
+
*/
|
|
338
|
+
export function ownerGrantAddMessage(partyId, row) {
|
|
339
|
+
return buildAuthorizationMessage('CadreControl.OwnerGrant', 'add', partyId, ownerGrantRowFields(row));
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* The exact bytes the HOLDER of an owner grant signs, with the grant's private key, to redeem
|
|
343
|
+
* it once — the TS mirror of the `'redeem'` digest `OwnerGrantUsage.GrantPossessed` verifies
|
|
344
|
+
* against the grant's public key. Binding the seated key and the nonce means a captured
|
|
345
|
+
* redemption can seat no other key and spend no other use.
|
|
346
|
+
*/
|
|
347
|
+
export function ownerGrantRedeemMessage(partyId, fields) {
|
|
348
|
+
return buildAuthorizationMessage('CadreControl.OwnerGrantUsage', 'redeem', partyId, [
|
|
349
|
+
fields.grantKey, fields.usageStampId, fields.seatedKey,
|
|
350
|
+
]);
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* The exact bytes the SEATED key signs, proving the holder holds it — the TS mirror of the
|
|
354
|
+
* `'consent'` digest `OwnerGrantUsage.OwnerConsented` verifies against `SeatedKey`. Same
|
|
355
|
+
* fields as {@link ownerGrantRedeemMessage} under a distinct action tag, so neither
|
|
356
|
+
* signature can stand in for the other.
|
|
357
|
+
*/
|
|
358
|
+
export function ownerGrantConsentMessage(partyId, fields) {
|
|
359
|
+
return buildAuthorizationMessage('CadreControl.OwnerGrantUsage', 'consent', partyId, [
|
|
360
|
+
fields.grantKey, fields.usageStampId, fields.seatedKey,
|
|
361
|
+
]);
|
|
362
|
+
}
|
|
291
363
|
/** Runtime guard for the dynamic-`from` count, over the one table list. */
|
|
292
364
|
const CONTROL_TABLE_SET = new Set(CONTROL_TABLES);
|
|
293
365
|
/**
|
|
@@ -306,13 +378,15 @@ const GUARDED_KEY_COLUMN = {
|
|
|
306
378
|
JoinRequest: 'Id',
|
|
307
379
|
DeviceToken: 'PeerId',
|
|
308
380
|
CadreInvite: 'Key',
|
|
381
|
+
OwnerGrant: 'Key',
|
|
309
382
|
};
|
|
383
|
+
/** The write-reason prefix each {@link IssuedTable}'s seat and withdrawal log under. */
|
|
384
|
+
const ISSUED_TABLE_REASON = { CadreInvite: 'cadre-invite', OwnerGrant: 'owner-grant' };
|
|
310
385
|
/**
|
|
311
386
|
* The exact bytes an owner signs to append one `Revocation` tombstone — `Revocation.Authorized`
|
|
312
|
-
* binds the whole (TableName, RowKey, StampId) row under its own domain tag. Shared by
|
|
313
|
-
* tombstone
|
|
314
|
-
*
|
|
315
|
-
* `revocationDigest`.
|
|
387
|
+
* binds the whole (TableName, RowKey, StampId) row under its own domain tag. Shared by both
|
|
388
|
+
* tombstone writers (a guarded removal, an issued row's withdrawal) so the field order lives
|
|
389
|
+
* in one place; the base64url verifier is `peer-authorization.ts`'s `revocationDigest`.
|
|
316
390
|
*/
|
|
317
391
|
function revocationTombstoneMessage(partyId, ref) {
|
|
318
392
|
return buildAuthorizationMessage('CadreControl.Revocation', 'remove', partyId, [ref.tableName, ref.rowKey, ref.stampId]);
|
|
@@ -321,28 +395,46 @@ function revocationTombstoneMessage(partyId, ref) {
|
|
|
321
395
|
* The exact bytes an owner signs to seat a further `OwnerKey` row (the signed-add branch of
|
|
322
396
|
* `OwnerKey.Authorized`); the base64url twin is `peer-authorization.ts`'s `ownerKeyAddDigest`.
|
|
323
397
|
*/
|
|
324
|
-
function ownerKeyAddMessage(partyId, key, stampId) {
|
|
325
|
-
return buildAuthorizationMessage('CadreControl.OwnerKey', 'add', partyId, [key, stampId]);
|
|
398
|
+
function ownerKeyAddMessage(partyId, key, label, stampId) {
|
|
399
|
+
return buildAuthorizationMessage('CadreControl.OwnerKey', 'add', partyId, [key, label ?? '', stampId]);
|
|
326
400
|
}
|
|
327
|
-
|
|
401
|
+
/**
|
|
402
|
+
* An owner key's label as its row stores it: trimmed, a blank one left out, and refused past
|
|
403
|
+
* {@link OWNER_KEY_MAX_LABEL} by name rather than by the schema's `LabelLength` check.
|
|
404
|
+
* Idempotent, so a caller that checks before opening an owner approval can pass the result on.
|
|
405
|
+
*/
|
|
406
|
+
export function ownerKeyLabel(label) {
|
|
407
|
+
const trimmed = label?.trim();
|
|
408
|
+
if (!trimmed) {
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
if (trimmed.length > OWNER_KEY_MAX_LABEL) {
|
|
412
|
+
throw new Error(`An owner key's label must be at most ${OWNER_KEY_MAX_LABEL} characters (this one has ${trimmed.length})`);
|
|
413
|
+
}
|
|
414
|
+
return trimmed;
|
|
415
|
+
}
|
|
416
|
+
async function signGuardedRemoval(partyId, tableName, rowKey, stampId, signMessage) {
|
|
328
417
|
const ref = { tableName, rowKey, stampId };
|
|
329
418
|
return {
|
|
330
419
|
ref,
|
|
331
|
-
signature: signMessage(buildAuthorizationMessage(`CadreControl.${tableName}`, 'remove', partyId, [rowKey, stampId])),
|
|
332
|
-
revocationSignature: signMessage(revocationTombstoneMessage(partyId, ref)),
|
|
420
|
+
signature: await signMessage(buildAuthorizationMessage(`CadreControl.${tableName}`, 'remove', partyId, [rowKey, stampId])),
|
|
421
|
+
revocationSignature: await signMessage(revocationTombstoneMessage(partyId, ref)),
|
|
333
422
|
};
|
|
334
423
|
}
|
|
335
424
|
/**
|
|
336
|
-
* The rejection a second insert of an already-seated
|
|
425
|
+
* The rejection a second insert of an already-seated key into `table` produces, as the
|
|
337
426
|
* optimystic vtab words it (`uniqueConstraintMessage`, qualified by table name only — no
|
|
338
|
-
* schema prefix, matching `test/control-constraint-helpers.ts`'s `expectUniqueViolation`)
|
|
427
|
+
* schema prefix, matching `test/control-constraint-helpers.ts`'s `expectUniqueViolation`),
|
|
428
|
+
* naming the table's {@link GUARDED_KEY_COLUMN}.
|
|
339
429
|
*
|
|
340
430
|
* Each table's `StampId` is unique too, but a fresh stamp is minted per insert attempt, so
|
|
341
|
-
* only the primary key can collide on a repeat insert.
|
|
342
|
-
*
|
|
431
|
+
* only the primary key can collide on a repeat insert. An issued row's bundle copy repeats its
|
|
432
|
+
* stamp as well, and the engine still names the key (`control-owner-grant.spec.ts` pins it).
|
|
433
|
+
* Matching the column explicitly keeps any OTHER uniqueness failure out of the caller's
|
|
434
|
+
* idempotency branch.
|
|
343
435
|
*/
|
|
344
|
-
function
|
|
345
|
-
return new RegExp(`UNIQUE constraint failed: ${table}
|
|
436
|
+
function keyConflictPattern(table) {
|
|
437
|
+
return new RegExp(`UNIQUE constraint failed: ${table}\\.${GUARDED_KEY_COLUMN[table]}\\b`, 'i');
|
|
346
438
|
}
|
|
347
439
|
/**
|
|
348
440
|
* Did this write to `table` fail because the strand id is already seated there?
|
|
@@ -350,7 +442,8 @@ function idConflictPattern(table) {
|
|
|
350
442
|
* {@link CadreNode.publishStrand} uses this to tell "my own earlier publish already
|
|
351
443
|
* landed" (re-read, and no-op when the row matches) apart from every other rejection —
|
|
352
444
|
* an unauthorized signer, a retired stamp — which must keep surfacing. For `JoinedStrand`
|
|
353
|
-
* the same test tells "another machine of this party already published this join" apart
|
|
445
|
+
* the same test tells "another machine of this party already published this join" apart, and
|
|
446
|
+
* for `StrandPartyKey` it tells a founding that this party's key for the id is already seated.
|
|
354
447
|
*
|
|
355
448
|
* Matched by TEXT, not by type: the typed engine error does not survive the trip out of
|
|
356
449
|
* optimystic (same constraint the retry classifiers in `control-write-retry.ts` document).
|
|
@@ -360,7 +453,16 @@ function idConflictPattern(table) {
|
|
|
360
453
|
* reword reddens there.
|
|
361
454
|
*/
|
|
362
455
|
export function isStrandIdConflict(error, table) {
|
|
363
|
-
return errorChainMatches(error,
|
|
456
|
+
return errorChainMatches(error, keyConflictPattern(table));
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Did a seat of an issued row fail because a cohort member already holds a row under its key?
|
|
460
|
+
* {@link ControlDatabase.seatCadreInvite} / {@link ControlDatabase.seatOwnerGrant} reach this
|
|
461
|
+
* on a node whose store lags the cohort: its guard read missed the row, and the member that
|
|
462
|
+
* checked the commit refused the insert. Text-matched, failing closed, like {@link isStrandIdConflict}.
|
|
463
|
+
*/
|
|
464
|
+
export function isIssuedRowKeyConflict(error, table) {
|
|
465
|
+
return errorChainMatches(error, keyConflictPattern(table));
|
|
364
466
|
}
|
|
365
467
|
/**
|
|
366
468
|
* Did {@link ControlDatabase.insertJoinRequest} fail because the party already holds a
|
|
@@ -372,7 +474,7 @@ export function isStrandIdConflict(error, table) {
|
|
|
372
474
|
* until the next reap pass.
|
|
373
475
|
*/
|
|
374
476
|
export function isPendingJoinConflict(error) {
|
|
375
|
-
return errorChainMatches(error,
|
|
477
|
+
return errorChainMatches(error, keyConflictPattern('JoinRequest'));
|
|
376
478
|
}
|
|
377
479
|
/**
|
|
378
480
|
* The `JoinRequest.Id` of an invitation: the base64url sha256 `digest` of its token. One row
|
|
@@ -396,69 +498,48 @@ export class PendingJoinChangedError extends Error {
|
|
|
396
498
|
}
|
|
397
499
|
}
|
|
398
500
|
/**
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
501
|
+
* A `CadreControl.Revocation` row that retires nothing, which parties founded before
|
|
502
|
+
* `control-collections-created-at-cadre-founding` may hold: an owner filed it so the table
|
|
503
|
+
* had a collection, which genesis now creates. Nothing files it now. `RowIsGone` still admits
|
|
504
|
+
* this exact triple, and {@link ControlDatabase.queryRevocations} skips it.
|
|
403
505
|
*/
|
|
404
|
-
|
|
506
|
+
const REVOCATION_LEDGER_MARKER = {
|
|
405
507
|
tableName: 'Revocation',
|
|
406
508
|
rowKey: 'ledger',
|
|
407
509
|
stampId: 'opened',
|
|
408
510
|
};
|
|
409
|
-
/**
|
|
410
|
-
* The rejection a second insert of the ledger marker produces: the optimystic vtab's
|
|
411
|
-
* primary-key wording (see {@link idConflictPattern}), naming both columns of
|
|
412
|
-
* `Revocation`'s composite key. Applied only to the marker's own insert, whose key
|
|
413
|
-
* ('Revocation', 'opened') no row but the marker can hold (`RowIsGone`), and `Revocation`
|
|
414
|
-
* has no other unique constraint.
|
|
415
|
-
*/
|
|
416
|
-
const REVOCATION_LEDGER_CONFLICT = /UNIQUE constraint failed: Revocation\.TableName, Revocation\.StampId\b/i;
|
|
417
|
-
/** Did the ledger marker's insert fail because the marker is already filed? Text-matched, failing closed, like {@link isStrandIdConflict}. */
|
|
418
|
-
function isRevocationLedgerConflict(error) {
|
|
419
|
-
return errorChainMatches(error, REVOCATION_LEDGER_CONFLICT);
|
|
420
|
-
}
|
|
421
511
|
/** Whether any message in `error`'s cause chain matches `pattern`; false for a non-`Error`. */
|
|
422
512
|
function errorChainMatches(error, pattern) {
|
|
423
513
|
return error instanceof Error && chainMessages(error).some(message => pattern.test(message));
|
|
424
514
|
}
|
|
425
515
|
/**
|
|
426
516
|
* Project `CadrePeer` rows into the {@link SeedPeer} list a seed, or a cadre invitation
|
|
427
|
-
* redemption reply, carries.
|
|
517
|
+
* redemption reply, carries. `minterPeerId` is the machine minting the seed or answering the
|
|
518
|
+
* redemption; it contributes an entry only through its own `CadrePeer` row.
|
|
428
519
|
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
* NOTE: this is the one owner lookup deliberately left on the REPLICATED table rather than
|
|
436
|
-
* the node-local anchor. `SeedPeer.isOwner` is a dial hint — the receiver dials owner-flagged
|
|
437
|
-
* peers first — not a trust decision, and the receiver re-derives real trust from its own
|
|
438
|
-
* anchor. So a polluted table costs at most a wasted dial, while anchoring here would
|
|
439
|
-
* silently drop legitimate co-owners this node never pinned. If `isOwner` ever gates
|
|
440
|
-
* anything the receiver TRUSTS, move it to the anchor.
|
|
520
|
+
* A peer is `preferred` when it is the minter, the one machine certain to be reachable by the
|
|
521
|
+
* receiver at that moment, since it is delivering the seed or the reply, or an always-on
|
|
522
|
+
* machine (`Profile = 'storage'`), the ones that stay dialable. Preference is a dial hint and
|
|
523
|
+
* reads no `OwnerKey` row: owner keys are not machines, and a receiver derives trust from its
|
|
524
|
+
* own anchor, never from this list. A `Profile` is self-declared and not checked here, so a
|
|
525
|
+
* machine misdeclaring one costs the receiver a wasted dial at most.
|
|
441
526
|
*/
|
|
442
|
-
export function projectSeedPeers(rows,
|
|
443
|
-
return rows.map(({ peerId, multiaddr }) => {
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
multiaddrs: multiaddr ? multiaddr.split(',') : [],
|
|
449
|
-
isOwner,
|
|
450
|
-
...(isOwner ? { publicKey: pubKeyB64 } : {}),
|
|
451
|
-
};
|
|
452
|
-
});
|
|
527
|
+
export function projectSeedPeers(rows, minterPeerId) {
|
|
528
|
+
return rows.map(({ peerId, multiaddr, profile }) => ({
|
|
529
|
+
peerId,
|
|
530
|
+
multiaddrs: multiaddr ? multiaddr.split(',') : [],
|
|
531
|
+
preferred: peerId === minterPeerId || profile === 'storage',
|
|
532
|
+
}));
|
|
453
533
|
}
|
|
454
534
|
/**
|
|
455
|
-
* The conditions a cadre invitation
|
|
456
|
-
* not withdrawn, unexpired (`expiresAt <= now` is
|
|
457
|
-
* and issued by a current owner. Shared by
|
|
458
|
-
* {@link ControlDatabase.
|
|
459
|
-
*
|
|
535
|
+
* The conditions an issued credential (a cadre invitation or an owner grant) must still meet
|
|
536
|
+
* to be redeemed, short of its use count: not withdrawn, unexpired (`expiresAt <= now` is
|
|
537
|
+
* expired, as the redemption refuses it), and issued by a current owner. Shared by
|
|
538
|
+
* {@link ControlDatabase.hasLiveCadreInvite}, {@link ControlDatabase.isCadreInviteLive},
|
|
539
|
+
* {@link ControlDatabase.listCadreInviteStatuses} and their owner-grant twins so they cannot
|
|
540
|
+
* drift from each other, or from `CadreInviteUsage.Authorized` / `OwnerGrantUsage.Authorized`.
|
|
460
541
|
*/
|
|
461
|
-
function
|
|
542
|
+
function issuedRowStillOpen(row, withdrawn, owners, nowMs) {
|
|
462
543
|
if (withdrawn.has(row.stampId) || !owners.has(row.issuerKey)) {
|
|
463
544
|
return false;
|
|
464
545
|
}
|
|
@@ -476,31 +557,97 @@ function cadreInviteStillOpen(row, withdrawn, owners, nowMs) {
|
|
|
476
557
|
* hold, so a reaped row is recoverable by re-forming. `JoinRequest` is reapable for the same
|
|
477
558
|
* reason: the inviting party and the user also hold its invitation. `OwnerKey` has no
|
|
478
559
|
* production removal path and `MinOneOwner` makes an automated owner-key reap a
|
|
479
|
-
* party-bricking hazard. `CadreInvite`
|
|
480
|
-
*
|
|
560
|
+
* party-bricking hazard. `CadreInvite` and `OwnerGrant` are never deleted at all (their
|
|
561
|
+
* tombstones are withdrawals over rows that stay), so there is nothing to reap.
|
|
481
562
|
*/
|
|
482
563
|
export const REAPABLE_TABLES = ['CadrePeer', 'DeviceToken', 'ValidationKey', 'JoinedStrand', 'JoinRequest'];
|
|
483
564
|
/**
|
|
484
565
|
* Runtime membership test for {@link REAPABLE_TABLES}, narrowing a tombstone's
|
|
485
566
|
* `TableName` to the subset {@link ControlDatabase.reapRevokedRow} accepts. A tombstone
|
|
486
|
-
* naming an excluded table (`Strand`, `OwnerKey`, `CadreInvite`) is skipped silently —
|
|
567
|
+
* naming an excluded table (`Strand`, `OwnerKey`, `CadreInvite`, `OwnerGrant`) is skipped silently —
|
|
487
568
|
* `Strand` tombstones are the common case in a party that has unpublished a strand, so a
|
|
488
569
|
* log line per skip would be per-pass noise.
|
|
489
570
|
*/
|
|
490
571
|
const REAPABLE_TABLE_SET = new Set(REAPABLE_TABLES);
|
|
491
572
|
const isReapableTable = (table) => REAPABLE_TABLE_SET.has(table);
|
|
492
|
-
/**
|
|
493
|
-
|
|
573
|
+
/**
|
|
574
|
+
* The full-table scans the `Revocation`, `CadrePeer` and `OwnerKey` membership views load
|
|
575
|
+
* ({@link ControlDatabase.readMembershipRows}); the four chain tables' scans sit beside their
|
|
576
|
+
* row mappers below. Specs rig these reads by their text: `from CadreControl.Revocation`, and
|
|
577
|
+
* the `CadrePeer` scan by its `select PeerId, Multiaddr, StampId` prefix, which no other
|
|
578
|
+
* `CadrePeer` statement shares.
|
|
579
|
+
*/
|
|
580
|
+
const REVOCATION_SELECT_SQL = 'select TableName, RowKey, StampId, ReissuedAt, SignerKey, SignerSig from CadreControl.Revocation';
|
|
581
|
+
const CADRE_PEER_SELECT_SQL = 'select PeerId, Multiaddr, StampId, VouchOwner, VouchSig, VouchUsage, PublicKey, UpdatedAt, Profile, Sig from CadreControl.CadrePeer';
|
|
582
|
+
const OWNER_KEY_SELECT_SQL = 'select Key, Label, StampId, VouchOwner, VouchSig, VouchUsage from CadreControl.OwnerKey';
|
|
583
|
+
/** A stored `CadrePeer` row as a {@link CadrePeerRow}. */
|
|
584
|
+
function cadrePeerRowOf(row) {
|
|
585
|
+
return {
|
|
586
|
+
peerId: row.PeerId,
|
|
587
|
+
publicKey: row.PublicKey ?? null,
|
|
588
|
+
multiaddr: row.Multiaddr ?? null,
|
|
589
|
+
updatedAt: row.UpdatedAt ?? 0,
|
|
590
|
+
profile: row.Profile ?? null,
|
|
591
|
+
sig: row.Sig ?? null,
|
|
592
|
+
stampId: row.StampId ?? null,
|
|
593
|
+
vouchOwner: row.VouchOwner ?? null,
|
|
594
|
+
vouchSig: row.VouchSig ?? null,
|
|
595
|
+
vouchUsage: row.VouchUsage ?? null,
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
/** A {@link CadrePeerRow} as the address record its machine signed ({@link PeerAddressRecord}); see {@link ControlDatabase.queryPeerRecord} for the coalescing. */
|
|
599
|
+
function peerAddressRecordOf(row) {
|
|
600
|
+
const multiaddr = row.multiaddr ?? '';
|
|
601
|
+
return {
|
|
602
|
+
peerId: row.peerId,
|
|
603
|
+
publicKey: row.publicKey ?? '',
|
|
604
|
+
addrs: multiaddr.length > 0 ? multiaddr.split(',') : [],
|
|
605
|
+
updatedAt: row.updatedAt,
|
|
606
|
+
profile: row.profile,
|
|
607
|
+
sig: row.sig ?? '',
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* {@link ControlDatabase.reauthorizeCadrePeer}'s decision over the row as read: the row to write,
|
|
612
|
+
* or the {@link CadrePeerRetouchOutcome} that says why nothing is written — no readable row (a
|
|
613
|
+
* stampless row counts, since the voucher digest binds the stamp), or a self-published row in
|
|
614
|
+
* `'rebroadcast'` mode. One function so the pre-check outside the lock and the re-check inside it
|
|
615
|
+
* cannot disagree.
|
|
616
|
+
*/
|
|
617
|
+
function retouchTarget(row, mode) {
|
|
618
|
+
if (row === null || row.stampId === null) {
|
|
619
|
+
return 'absent';
|
|
620
|
+
}
|
|
621
|
+
if (mode === 'rebroadcast' && row.sig !== null) {
|
|
622
|
+
return 'self-published';
|
|
623
|
+
}
|
|
624
|
+
return { stampId: row.stampId, updatedAt: row.updatedAt };
|
|
625
|
+
}
|
|
626
|
+
/** A stored `Revocation` row as a {@link RevocationRow}; the caller has already skipped {@link REVOCATION_LEDGER_MARKER}. */
|
|
627
|
+
function revocationRowOf(row) {
|
|
628
|
+
return {
|
|
629
|
+
tableName: row.TableName,
|
|
630
|
+
rowKey: row.RowKey,
|
|
631
|
+
stampId: row.StampId,
|
|
632
|
+
reissuedAt: row.ReissuedAt ?? 0,
|
|
633
|
+
signerKey: row.SignerKey,
|
|
634
|
+
signerSig: row.SignerSig,
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Every `CadreInvite` column, in the order {@link cadreInviteRowOf} reads them: the full scan the
|
|
639
|
+
* `CadreInvite` membership view loads, and the base the point reads append their `where` to.
|
|
640
|
+
*/
|
|
641
|
+
const CADRE_INVITE_SELECT_SQL = 'select Key, PeerId, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId from CadreControl.CadreInvite';
|
|
494
642
|
/**
|
|
495
643
|
* A stored `CadreInvite` row as a {@link CadreInviteRow}. `ExpiresAt` is kept as the
|
|
496
644
|
* engine-canonical string it reads back as — the form the `'add'` signature covers — never
|
|
497
|
-
* parsed to ms here
|
|
645
|
+
* parsed to ms here.
|
|
498
646
|
*/
|
|
499
647
|
function cadreInviteRowOf(row) {
|
|
500
648
|
return {
|
|
501
649
|
key: row.Key,
|
|
502
650
|
peerId: row.PeerId ?? null,
|
|
503
|
-
grantsOwner: Number(row.GrantsOwner) === 1,
|
|
504
651
|
expiresAt: row.ExpiresAt == null ? null : String(row.ExpiresAt),
|
|
505
652
|
totalUses: row.TotalUses ?? null,
|
|
506
653
|
issuerKey: row.IssuerKey,
|
|
@@ -508,6 +655,8 @@ function cadreInviteRowOf(row) {
|
|
|
508
655
|
stampId: row.StampId,
|
|
509
656
|
};
|
|
510
657
|
}
|
|
658
|
+
/** The full scan the `CadreInviteUsage` membership view loads, in the order {@link cadreInviteUsageRowOf} reads it. */
|
|
659
|
+
const CADRE_INVITE_USAGE_SELECT_SQL = 'select UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, InviteSig, PeerSig from CadreControl.CadreInviteUsage';
|
|
511
660
|
/** A stored `CadreInviteUsage` row as a {@link CadreInviteUsageRow}. */
|
|
512
661
|
function cadreInviteUsageRowOf(row) {
|
|
513
662
|
return {
|
|
@@ -516,11 +665,37 @@ function cadreInviteUsageRowOf(row) {
|
|
|
516
665
|
peerId: row.PeerId,
|
|
517
666
|
peerKey: row.PeerKey,
|
|
518
667
|
peerStampId: row.PeerStampId,
|
|
519
|
-
ownerStampId: row.OwnerStampId ?? null,
|
|
520
668
|
inviteSig: row.InviteSig,
|
|
521
669
|
peerSig: row.PeerSig,
|
|
522
670
|
};
|
|
523
671
|
}
|
|
672
|
+
/** Every `OwnerGrant` column, in the order {@link ownerGrantRowOf} reads them; as {@link CADRE_INVITE_SELECT_SQL}, the view's scan and the point read's base. */
|
|
673
|
+
const OWNER_GRANT_SELECT_SQL = 'select Key, Label, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId from CadreControl.OwnerGrant';
|
|
674
|
+
/** A stored `OwnerGrant` row as an {@link OwnerGrantRow}; `ExpiresAt` kept canonical, as {@link cadreInviteRowOf} keeps it. */
|
|
675
|
+
function ownerGrantRowOf(row) {
|
|
676
|
+
return {
|
|
677
|
+
key: row.Key,
|
|
678
|
+
label: row.Label ?? null,
|
|
679
|
+
expiresAt: row.ExpiresAt == null ? null : String(row.ExpiresAt),
|
|
680
|
+
totalUses: row.TotalUses ?? null,
|
|
681
|
+
issuerKey: row.IssuerKey,
|
|
682
|
+
issuerSig: row.IssuerSig,
|
|
683
|
+
stampId: row.StampId,
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
/** The full scan the `OwnerGrantUsage` membership view loads, in the order {@link ownerGrantUsageRowOf} reads it. */
|
|
687
|
+
const OWNER_GRANT_USAGE_SELECT_SQL = 'select UsageStampId, GrantKey, SeatedKey, SeatedStampId, GrantSig, OwnerSig from CadreControl.OwnerGrantUsage';
|
|
688
|
+
/** A stored `OwnerGrantUsage` row as an {@link OwnerGrantUsageRow}. */
|
|
689
|
+
function ownerGrantUsageRowOf(row) {
|
|
690
|
+
return {
|
|
691
|
+
usageStampId: row.UsageStampId,
|
|
692
|
+
grantKey: row.GrantKey,
|
|
693
|
+
seatedKey: row.SeatedKey,
|
|
694
|
+
seatedStampId: row.SeatedStampId,
|
|
695
|
+
grantSig: row.GrantSig,
|
|
696
|
+
ownerSig: row.OwnerSig,
|
|
697
|
+
};
|
|
698
|
+
}
|
|
524
699
|
/** A `JoinedStrand` row as a {@link StrandRow}; the schema's `KnownType` check backs the `Type` cast. */
|
|
525
700
|
function joinedStrandRow(row) {
|
|
526
701
|
return {
|
|
@@ -528,8 +703,20 @@ function joinedStrandRow(row) {
|
|
|
528
703
|
MemberPrivateKey: row.MemberPrivateKey,
|
|
529
704
|
Type: row.Type,
|
|
530
705
|
FounderOwnerKey: null,
|
|
706
|
+
FounderPeerId: null,
|
|
531
707
|
};
|
|
532
708
|
}
|
|
709
|
+
/** A party-own `Strand` row as a {@link StrandRow}; the `Type` cast as {@link joinedStrandRow}'s. */
|
|
710
|
+
function strandRow(row) {
|
|
711
|
+
return {
|
|
712
|
+
Id: row.Id,
|
|
713
|
+
MemberPrivateKey: row.MemberPrivateKey,
|
|
714
|
+
Type: row.Type,
|
|
715
|
+
FounderOwnerKey: row.FounderOwnerKey,
|
|
716
|
+
FounderPeerId: row.FounderPeerId,
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
const STRAND_COLUMNS = 'Id, MemberPrivateKey, Type, FounderOwnerKey, FounderPeerId';
|
|
533
720
|
const FORMATION_INVITE_SELECT_SQL = 'select Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId from CadreControl.FormationInvite';
|
|
534
721
|
function formationInviteRowOf(row) {
|
|
535
722
|
return {
|
|
@@ -599,7 +786,7 @@ function joinRequestAddFields(row) {
|
|
|
599
786
|
* `JoinSuccess` / `JoinFailure` row and the signature its `AuthorizedInsert` verifies, over the
|
|
600
787
|
* row's columns in the schema's order (a null column signs as `''`, an integer as its text).
|
|
601
788
|
*/
|
|
602
|
-
function signJoinOutcomeInsert(partyId, requestStampId, outcome, ownerKey, signMessage) {
|
|
789
|
+
async function signJoinOutcomeInsert(partyId, requestStampId, outcome, ownerKey, signMessage) {
|
|
603
790
|
const at = String(outcome.RecordedAt);
|
|
604
791
|
const { table, sql, fields, values } = outcome.kind === 'joined'
|
|
605
792
|
? {
|
|
@@ -612,9 +799,28 @@ function signJoinOutcomeInsert(partyId, requestStampId, outcome, ownerKey, signM
|
|
|
612
799
|
fields: [requestStampId, at, outcome.Code, outcome.Reason],
|
|
613
800
|
values: [requestStampId, outcome.RecordedAt, outcome.Code, outcome.Reason],
|
|
614
801
|
};
|
|
615
|
-
const signature = signMessage(buildAuthorizationMessage(`CadreControl.${table}`, 'add', partyId, fields));
|
|
802
|
+
const signature = await signMessage(buildAuthorizationMessage(`CadreControl.${table}`, 'add', partyId, fields));
|
|
616
803
|
return { sql, params: [ownerKey, signature, ...values] };
|
|
617
804
|
}
|
|
805
|
+
/** The text of a {@link ControlDatabaseConfig.schemaPath} file; Node.js only. */
|
|
806
|
+
async function readSchemaFile(schemaPath) {
|
|
807
|
+
if (typeof process === 'undefined' || !process.versions?.node) {
|
|
808
|
+
throw new Error('Loading schema from file is not supported in React Native. ' +
|
|
809
|
+
'Remove the schemaPath option to use the embedded schema instead.');
|
|
810
|
+
}
|
|
811
|
+
try {
|
|
812
|
+
// Use require to conditionally load fs only in Node.js.
|
|
813
|
+
// This won't be bundled by React Native's Metro bundler — a dynamic
|
|
814
|
+
// import() would be statically picked up by Metro, so require is
|
|
815
|
+
// intentional here (cross-platform constraint, not lazy CommonJS).
|
|
816
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
817
|
+
const fs = require('fs/promises');
|
|
818
|
+
return await fs.readFile(schemaPath, 'utf-8');
|
|
819
|
+
}
|
|
820
|
+
catch (error) {
|
|
821
|
+
throw new Error(`Failed to load schema from ${schemaPath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
822
|
+
}
|
|
823
|
+
}
|
|
618
824
|
/**
|
|
619
825
|
* ControlDatabase manages the CadreControl schema using Quereus with Optimystic backend.
|
|
620
826
|
* It provides typed query methods for accessing control network data.
|
|
@@ -623,12 +829,33 @@ export class ControlDatabase {
|
|
|
623
829
|
constructor(config) {
|
|
624
830
|
this.db = null;
|
|
625
831
|
this.collectionFactory = null;
|
|
832
|
+
this.txnBridge = null;
|
|
626
833
|
this.initialized = false;
|
|
627
834
|
this.membershipListener = null;
|
|
628
835
|
this.guardedDeleteListener = null;
|
|
629
836
|
this.controlWriteAbandonedListener = null;
|
|
630
837
|
/** Tail of the local-write chain — see {@link withWriteLock}. */
|
|
631
838
|
this.writeQueue = Promise.resolve();
|
|
839
|
+
/** Subscriptions handed out by {@link watchTables}. */
|
|
840
|
+
this.tableWatches = new TableWatchRegistry();
|
|
841
|
+
/** The cached membership tables; see {@link readMembershipRows}. */
|
|
842
|
+
this.revocationView = this.membershipView(REVOCATION_SELECT_SQL, 'revocations');
|
|
843
|
+
this.cadrePeerView = this.membershipView(CADRE_PEER_SELECT_SQL, 'cadre-peers');
|
|
844
|
+
this.ownerKeyView = this.membershipView(OWNER_KEY_SELECT_SQL, 'owner-keys');
|
|
845
|
+
this.cadreInviteView = this.membershipView(CADRE_INVITE_SELECT_SQL, 'cadre-invites');
|
|
846
|
+
this.cadreInviteUsageView = this.membershipView(CADRE_INVITE_USAGE_SELECT_SQL, 'cadre-invite-usages');
|
|
847
|
+
this.ownerGrantView = this.membershipView(OWNER_GRANT_SELECT_SQL, 'owner-grants');
|
|
848
|
+
this.ownerGrantUsageView = this.membershipView(OWNER_GRANT_USAGE_SELECT_SQL, 'owner-grant-usages');
|
|
849
|
+
/** Each membership view by the qualified table it copies, for {@link watchMembershipViews}, {@link watchMembershipTables} and {@link invalidateMembershipViews}. */
|
|
850
|
+
this.membershipViews = new Map([
|
|
851
|
+
['CadreControl.Revocation', this.revocationView],
|
|
852
|
+
['CadreControl.CadrePeer', this.cadrePeerView],
|
|
853
|
+
['CadreControl.OwnerKey', this.ownerKeyView],
|
|
854
|
+
['CadreControl.CadreInvite', this.cadreInviteView],
|
|
855
|
+
['CadreControl.CadreInviteUsage', this.cadreInviteUsageView],
|
|
856
|
+
['CadreControl.OwnerGrant', this.ownerGrantView],
|
|
857
|
+
['CadreControl.OwnerGrantUsage', this.ownerGrantUsageView],
|
|
858
|
+
]);
|
|
632
859
|
/**
|
|
633
860
|
* Locked write bodies currently running (see {@link runWriteBody}); what
|
|
634
861
|
* {@link readRowsOnce} consults to route an unlocked read around a write that has
|
|
@@ -664,6 +891,7 @@ export class ControlDatabase {
|
|
|
664
891
|
return;
|
|
665
892
|
}
|
|
666
893
|
log('Initializing ControlDatabase for party: %s', this.config.partyId);
|
|
894
|
+
this.invalidateMembershipViews();
|
|
667
895
|
// Create database instance
|
|
668
896
|
this.db = new Database();
|
|
669
897
|
// Register crypto plugin (provides digest, sign, verify functions)
|
|
@@ -691,6 +919,7 @@ export class ControlDatabase {
|
|
|
691
919
|
// Before hydrate: a warm restart parses the persisted CHECK text, which calls party_id().
|
|
692
920
|
registerPartyIdFunction(this.db, this.config.partyId);
|
|
693
921
|
this.collectionFactory = pluginResult.collectionFactory;
|
|
922
|
+
this.txnBridge = pluginResult.txnBridge;
|
|
694
923
|
// Inject the libp2p node into the collection factory
|
|
695
924
|
t0 = performance.now();
|
|
696
925
|
this.collectionFactory.registerLibp2pNode(networkName, this.config.libp2pNode, this.config.coordinatedRepo);
|
|
@@ -737,48 +966,77 @@ export class ControlDatabase {
|
|
|
737
966
|
t0 = performance.now();
|
|
738
967
|
await this.loadSchema();
|
|
739
968
|
timing('[controlDb] loadSchema: %dms', Math.round(performance.now() - t0));
|
|
969
|
+
this.watchMembershipViews();
|
|
740
970
|
this.initialized = true;
|
|
741
971
|
log('ControlDatabase initialized successfully');
|
|
742
972
|
}
|
|
973
|
+
/**
|
|
974
|
+
* Drop a membership view whenever its table changes: a commit made here, or a change this
|
|
975
|
+
* node's storage takes in from elsewhere. `onInvalidate` is the whole consumer, so the run
|
|
976
|
+
* does nothing; no backstop, since {@link MEMBERSHIP_VIEW_MAX_AGE_MS} bounds what the
|
|
977
|
+
* subscription cannot hear. {@link close} disposes it with the rest.
|
|
978
|
+
*/
|
|
979
|
+
watchMembershipViews() {
|
|
980
|
+
this.tableWatches.watch(this.db, [...this.membershipViews.keys()], () => Promise.resolve(), {
|
|
981
|
+
label: 'membership-view',
|
|
982
|
+
onInvalidate: (table) => this.membershipViews.get(table)?.invalidate(),
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* {@link watchTables} on the membership tables: the seven the membership views copy
|
|
987
|
+
* (`CadrePeer`, `OwnerKey`, `Revocation`, `CadreInvite`, `CadreInviteUsage`, `OwnerGrant`,
|
|
988
|
+
* `OwnerGrantUsage`), which are exactly what the membership gate and the owner-anchor sync
|
|
989
|
+
* read. `CadreNode.start` takes its gate watch here, so the gate hears every table its
|
|
990
|
+
* reads can depend on and no list of them lives outside this class.
|
|
991
|
+
*/
|
|
992
|
+
watchMembershipTables(onChange, options) {
|
|
993
|
+
return this.watchTables([...this.membershipViews.keys()], onChange, options);
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Empty every membership view, so the next read of each loads afresh and no load in
|
|
997
|
+
* flight installs. Besides {@link initialize} and {@link close}, `CadreNode` calls it when
|
|
998
|
+
* a sibling becomes reachable (its identify on a new connection): a machine that was offline
|
|
999
|
+
* while that sibling committed learns of the commit only when it next reads the block from
|
|
1000
|
+
* storage, and dropping the views makes the pass that starts then read now rather than after
|
|
1001
|
+
* `MEMBERSHIP_VIEW_MAX_AGE_MS`.
|
|
1002
|
+
*/
|
|
1003
|
+
invalidateMembershipViews() {
|
|
1004
|
+
for (const view of this.membershipViews.values()) {
|
|
1005
|
+
view.invalidate();
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
743
1008
|
async loadSchema() {
|
|
744
|
-
let
|
|
1009
|
+
let declaration;
|
|
745
1010
|
if (this.config.schemaPath) {
|
|
746
|
-
// Load from file if explicitly provided (for testing or custom schemas)
|
|
747
|
-
// This only works in Node.js environments
|
|
748
1011
|
log('Loading schema from file: %s', this.config.schemaPath);
|
|
749
|
-
|
|
750
|
-
if (typeof process !== 'undefined' && process.versions?.node) {
|
|
751
|
-
try {
|
|
752
|
-
// Use require to conditionally load fs only in Node.js.
|
|
753
|
-
// This won't be bundled by React Native's Metro bundler — a dynamic
|
|
754
|
-
// import() would be statically picked up by Metro, so require is
|
|
755
|
-
// intentional here (cross-platform constraint, not lazy CommonJS).
|
|
756
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
757
|
-
const fs = require('fs/promises');
|
|
758
|
-
schemaContent = await fs.readFile(this.config.schemaPath, 'utf-8');
|
|
759
|
-
}
|
|
760
|
-
catch (error) {
|
|
761
|
-
throw new Error(`Failed to load schema from ${this.config.schemaPath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
else {
|
|
765
|
-
throw new Error('Loading schema from file is not supported in React Native. ' +
|
|
766
|
-
'Remove the schemaPath option to use the embedded schema instead.');
|
|
767
|
-
}
|
|
1012
|
+
declaration = controlSchemaFileDeclaration(await readSchemaFile(this.config.schemaPath));
|
|
768
1013
|
}
|
|
769
1014
|
else {
|
|
770
|
-
// Use embedded schema for cross-platform compatibility
|
|
771
1015
|
log('Using embedded control schema');
|
|
772
|
-
|
|
1016
|
+
declaration = CONTROL_SCHEMA_DECLARATION;
|
|
773
1017
|
}
|
|
1018
|
+
// Declaring runs no DDL; it only records the declaration the check and the apply both read.
|
|
1019
|
+
await this.db.exec(declaration);
|
|
1020
|
+
// The apply below is also the MIGRATION of a warm store written under an older control
|
|
1021
|
+
// schema version: its diff runs each version's changes in order, every one applied to a
|
|
1022
|
+
// table's catalog record without a row rewritten, and ends by recording this build's version
|
|
1023
|
+
// on the store. A store the apply must not touch is refused HERE, before it runs anything: one
|
|
1024
|
+
// a newer release migrated (this machine is the one to update), one from before migrations,
|
|
1025
|
+
// or one whose diff would rewrite or discard rows (`control-schema-version.ts`).
|
|
1026
|
+
await refuseIncompatibleControlStore(this.db, this.config.partyId);
|
|
1027
|
+
await this.applyControlSchema();
|
|
1028
|
+
log('Schema loaded and executed');
|
|
1029
|
+
}
|
|
1030
|
+
/** Apply the declared control schema, retrying transient cohort failures. */
|
|
1031
|
+
async applyControlSchema() {
|
|
774
1032
|
// Schema init is a DISTRIBUTED write, so it gets the same bounded transient-failure
|
|
775
1033
|
// retry every other control write does ({@link lockedWithRetry}). Every CadreControl
|
|
776
|
-
// table is optimystic-backed (the default vtab
|
|
1034
|
+
// table is optimystic-backed (`initialize` routes the default vtab to optimystic),
|
|
777
1035
|
// so each `create table` needs a super-majority of the party's peers to answer. On a
|
|
778
1036
|
// node joining a party that already has two live members a single unanswered peer
|
|
779
1037
|
// during any one create otherwise kills startup outright.
|
|
780
1038
|
//
|
|
781
|
-
// Re-running the
|
|
1039
|
+
// Re-running the apply after a partial failure is safe, and this is the
|
|
782
1040
|
// non-obvious part:
|
|
783
1041
|
//
|
|
784
1042
|
// - `apply schema` is a DIFF, not a replay. Quereus collects the live catalog, diffs
|
|
@@ -786,11 +1044,6 @@ export class ControlDatabase {
|
|
|
786
1044
|
// exactly why `initialize` hydrates persisted optimystic schemas BEFORE getting here.
|
|
787
1045
|
// A table the live catalog already lists generates no statements at all, which is what
|
|
788
1046
|
// makes an apply over an already-complete schema (a warm start) a no-op.
|
|
789
|
-
// NOTE: a table the catalog lists with DIFFERENT constraint text diffs to
|
|
790
|
-
// `alter table ... drop constraint`, which the optimystic module refuses, so a store
|
|
791
|
-
// written by a build with older constraint text fails here on every start (policy:
|
|
792
|
-
// recreate, docs/architecture.md after the Control Network table). If backwards
|
|
793
|
-
// compatibility becomes a requirement, this apply is the migration site.
|
|
794
1047
|
// - a failed apply is taken back WHOLE (Quereus 4.20.0). The migration loop keeps an
|
|
795
1048
|
// undo journal; when a step fails it runs that journal in reverse, re-renders the
|
|
796
1049
|
// catalog, compares it against a fingerprint taken BEFORE the apply, and only then
|
|
@@ -812,6 +1065,20 @@ export class ControlDatabase {
|
|
|
812
1065
|
// argument is a statement about the pair, not about Quereus alone — pinned by
|
|
813
1066
|
// `test/control-schema-apply-unwind.spec.ts`.
|
|
814
1067
|
//
|
|
1068
|
+
// The same argument covers the statements a MIGRATION runs (`control-schema-version.ts`'s
|
|
1069
|
+
// `catalog-only` kinds, from Optimystic 1.16.0): the plugin's `alterTable` arms (add column,
|
|
1070
|
+
// drop column, add and drop CHECK) and the tag change write the table's catalog record under
|
|
1071
|
+
// the batch's per-statement checkpoint, so a step that throws withdraws its own write, and a
|
|
1072
|
+
// batch whose commit fails is discarded whole and the tables it wrote re-persist on their next
|
|
1073
|
+
// touch. A change the record already holds — another machine migrated first — is adopted with
|
|
1074
|
+
// nothing written, and an undo of an adopted change touches this machine's catalog only. The
|
|
1075
|
+
// one arm Quereus marks irreversible is `drop column`: once it has run, a later failure
|
|
1076
|
+
// unwinds nothing (the apply reports the schema as partially migrated), the plugin's
|
|
1077
|
+
// `endSchemaBatch` commits the steps that landed — or, when that commit fails, marks their
|
|
1078
|
+
// tables to re-persist on next touch — and the retry's diff runs only the steps still
|
|
1079
|
+
// missing. No row is lost either way: rows keep a dropped column's field until they are
|
|
1080
|
+
// rewritten, and nothing reads it.
|
|
1081
|
+
//
|
|
815
1082
|
// Only the "cohort did not answer, nothing committed" class is retried; the classifier
|
|
816
1083
|
// vetoes indeterminate commits and does not match `Missing block`, which is a durable
|
|
817
1084
|
// convergence fault a retry cannot heal (tracked separately) and must keep propagating.
|
|
@@ -823,8 +1090,7 @@ export class ControlDatabase {
|
|
|
823
1090
|
// also what lets schema init absorb optimystic's self-coordination grace refusal, which
|
|
824
1091
|
// is unsafe for control writes in general. Why, on
|
|
825
1092
|
// `RETRIABLE_SCHEMA_INIT_MATCHERS` in `control-write-retry.ts`.
|
|
826
|
-
await this.lockedWithRetry(() => this.db.exec(
|
|
827
|
-
log('Schema loaded and executed');
|
|
1093
|
+
await this.lockedWithRetry(() => this.db.exec(APPLY_CONTROL_SCHEMA), SCHEMA_INIT_RETRY_POLICY, 'schema-init');
|
|
828
1094
|
}
|
|
829
1095
|
/**
|
|
830
1096
|
* One UNRETRIED drain of a control read to rows — every read in this class bottoms out
|
|
@@ -904,8 +1170,9 @@ export class ControlDatabase {
|
|
|
904
1170
|
* statement to its transaction opening.
|
|
905
1171
|
*/
|
|
906
1172
|
async readRowsOnce(sql, params, underWriteLock) {
|
|
1173
|
+
const committed = this.writeInFlight(underWriteLock);
|
|
907
1174
|
let iterator;
|
|
908
|
-
if (
|
|
1175
|
+
if (committed) {
|
|
909
1176
|
log('read-eval: a write is in flight (%s), asking for a committed read: %s', this.db.getAutocommit() ? 'locked body running, no transaction open yet' : 'transaction open', sql);
|
|
910
1177
|
iterator = this.db.eval(sql, params, { readConcurrency: 'committed' });
|
|
911
1178
|
}
|
|
@@ -916,7 +1183,7 @@ export class ControlDatabase {
|
|
|
916
1183
|
for await (const row of iterator) {
|
|
917
1184
|
rows.push(row);
|
|
918
1185
|
}
|
|
919
|
-
return rows;
|
|
1186
|
+
return { rows, committed };
|
|
920
1187
|
}
|
|
921
1188
|
/**
|
|
922
1189
|
* Whether a control read should ask for a committed read — see {@link readRowsOnce}.
|
|
@@ -974,7 +1241,15 @@ export class ControlDatabase {
|
|
|
974
1241
|
* held, and its membership read is opted out at {@link queryCadrePeers}. If another
|
|
975
1242
|
* under-lock callback seam is ever added, audit its read graph the same way.
|
|
976
1243
|
*/
|
|
977
|
-
readRows(sql, params, label, retry = true) {
|
|
1244
|
+
async readRows(sql, params, label, retry = true) {
|
|
1245
|
+
return (await this.readRowsRouted(sql, params, label, retry)).rows;
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* {@link readRows}, also saying whether the attempt that answered took the committed-read
|
|
1249
|
+
* path — which a {@link MembershipView} load needs, since it may not keep such rows (see
|
|
1250
|
+
* {@link membershipView}).
|
|
1251
|
+
*/
|
|
1252
|
+
readRowsRouted(sql, params, label, retry) {
|
|
978
1253
|
// `retry: false` marks the reads that may run inside a locked write body, which is
|
|
979
1254
|
// also what readRowsOnce's committed-read routing must know (see writeInFlight).
|
|
980
1255
|
// NOTE: the membership-gate refresh passes it on EVERY trigger, including the
|
|
@@ -989,21 +1264,51 @@ export class ControlDatabase {
|
|
|
989
1264
|
// it changes log attribution only, never behaviour (same layering as lockedWithRetry).
|
|
990
1265
|
return retryControlRead(() => this.readRowsOnce(sql, params, false), { ...this.controlReadRetryPacing, label });
|
|
991
1266
|
}
|
|
1267
|
+
/**
|
|
1268
|
+
* A view over one membership table, loaded by its full scan `sql` through {@link readRows}'s
|
|
1269
|
+
* funnel. Rows a committed read served are never kept: that read may initialize an untouched
|
|
1270
|
+
* table provisionally, and the Optimystic plugin arms a table's replicated-change bridge only
|
|
1271
|
+
* on its full initialization (`OptimysticTable.initializeForCommittedRead`), so no later change
|
|
1272
|
+
* event would ever drop them.
|
|
1273
|
+
*/
|
|
1274
|
+
membershipView(sql, label) {
|
|
1275
|
+
return new MembershipView(async (retry) => {
|
|
1276
|
+
const { rows, committed } = await this.readRowsRouted(sql, undefined, label, retry);
|
|
1277
|
+
return { rows, installable: !committed };
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1280
|
+
/**
|
|
1281
|
+
* A membership table's raw rows, from its {@link MembershipView}: `Revocation`, `CadrePeer`
|
|
1282
|
+
* and `OwnerKey`, the tables every membership, owner and peer-record read starts from, and
|
|
1283
|
+
* `CadreInvite`, `CadreInviteUsage`, `OwnerGrant` and `OwnerGrantUsage`, the chains every
|
|
1284
|
+
* membership refresh verifies invitation-admitted machines and grant-seated owner keys through.
|
|
1285
|
+
* Callers build fresh result objects from them and never mutate them.
|
|
1286
|
+
*
|
|
1287
|
+
* Reads the table directly instead when a caller under the write lock (`retry: false`) has an
|
|
1288
|
+
* explicit transaction open: such a read sees the transaction's own uncommitted rows, which
|
|
1289
|
+
* the view does not hold, and a rollback fires no change event, so keeping them would cache
|
|
1290
|
+
* rows that never existed. No reader of these tables runs inside {@link inTransaction} today;
|
|
1291
|
+
* this keeps one added later, or a spec's raw-handle transaction, correct.
|
|
1292
|
+
*
|
|
1293
|
+
* Reads that stay plain SQL on purpose: {@link queryStampId} (sees retired rows), the
|
|
1294
|
+
* schema's CHECK constraints, which read inside the committing transaction, the redemption's
|
|
1295
|
+
* one read of the held credential row ({@link queryCadreInvite},
|
|
1296
|
+
* {@link queryOwnerGrant}), and the indexed usage counts.
|
|
1297
|
+
*/
|
|
1298
|
+
readMembershipRows(view, retry) {
|
|
1299
|
+
this.ensureInitialized();
|
|
1300
|
+
if (!retry && !this.db.getAutocommit()) {
|
|
1301
|
+
return view.readThrough(retry);
|
|
1302
|
+
}
|
|
1303
|
+
return view.read(retry);
|
|
1304
|
+
}
|
|
992
1305
|
/**
|
|
993
1306
|
* Query all strands from the control database
|
|
994
1307
|
*/
|
|
995
1308
|
async queryStrands() {
|
|
996
1309
|
this.ensureInitialized();
|
|
997
|
-
const
|
|
998
|
-
|
|
999
|
-
results.push({
|
|
1000
|
-
Id: row.Id,
|
|
1001
|
-
MemberPrivateKey: row.MemberPrivateKey,
|
|
1002
|
-
Type: row.Type,
|
|
1003
|
-
FounderOwnerKey: row.FounderOwnerKey,
|
|
1004
|
-
});
|
|
1005
|
-
}
|
|
1006
|
-
return results;
|
|
1310
|
+
const rows = await this.readRows(`select ${STRAND_COLUMNS} from CadreControl.Strand`, undefined, 'strands');
|
|
1311
|
+
return rows.map(strandRow);
|
|
1007
1312
|
}
|
|
1008
1313
|
/**
|
|
1009
1314
|
* Read a single strand row by id, or null when absent. Single-row sibling of
|
|
@@ -1013,20 +1318,23 @@ export class ControlDatabase {
|
|
|
1013
1318
|
*/
|
|
1014
1319
|
async queryStrand(strandId) {
|
|
1015
1320
|
this.ensureInitialized();
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1321
|
+
const rows = await this.readRows(`select ${STRAND_COLUMNS} from CadreControl.Strand where Id = ?`, [strandId], 'strand');
|
|
1322
|
+
return rows.length === 0 ? null : strandRow(rows[0]);
|
|
1323
|
+
}
|
|
1324
|
+
/**
|
|
1325
|
+
* {@link queryStrand} plus the row's `StampId`, in one read: the formation responder
|
|
1326
|
+
* resolves a bound invite's host strand with it and later records consent against that
|
|
1327
|
+
* stamp ({@link recordFormationUsage}'s `strandStampId`) without reading the row again.
|
|
1328
|
+
*/
|
|
1329
|
+
async queryStrandWithStamp(strandId) {
|
|
1330
|
+
this.ensureInitialized();
|
|
1331
|
+
const rows = await this.readRows(`select ${STRAND_COLUMNS}, StampId from CadreControl.Strand where Id = ?`, [strandId], 'strand-with-stamp');
|
|
1332
|
+
return rows.length === 0 ? null : { ...strandRow(rows[0]), StampId: rows[0].StampId };
|
|
1025
1333
|
}
|
|
1026
1334
|
/**
|
|
1027
1335
|
* Every strand this party joined from another party (`CadreControl.JoinedStrand`), shaped
|
|
1028
1336
|
* as a {@link StrandRow} so a reader can treat it like one of the party's own. A joiner
|
|
1029
|
-
* never founds, so `FounderOwnerKey`
|
|
1337
|
+
* never founds, so `FounderOwnerKey` and `FounderPeerId` are always null. Read raw, like {@link queryStrands}:
|
|
1030
1338
|
* no retired-stamp filter.
|
|
1031
1339
|
*/
|
|
1032
1340
|
async queryJoinedStrands() {
|
|
@@ -1101,29 +1409,72 @@ export class ControlDatabase {
|
|
|
1101
1409
|
*
|
|
1102
1410
|
* Deliberately RAW (no retired-stamp filter, unlike {@link getOwnerKeys}): the genesis
|
|
1103
1411
|
* insert is refused by the schema whenever any physical row exists, so this must see one.
|
|
1412
|
+
* Served from the `OwnerKey` membership view ({@link readMembershipRows}), which holds every
|
|
1413
|
+
* physical row, so a genesis caller's pre-check and {@link ensureOwnerKey}'s re-check share
|
|
1414
|
+
* one storage read.
|
|
1104
1415
|
*/
|
|
1105
1416
|
async hasOwnerKey() {
|
|
1106
1417
|
this.ensureInitialized();
|
|
1107
|
-
|
|
1108
|
-
return row.Count > 0;
|
|
1109
|
-
}
|
|
1110
|
-
return false;
|
|
1418
|
+
return (await this.readMembershipRows(this.ownerKeyView, true)).length > 0;
|
|
1111
1419
|
}
|
|
1112
1420
|
/**
|
|
1113
1421
|
* Idempotent genesis: insert `key` as the founding owner key only when
|
|
1114
1422
|
* the party has none yet. Returns true if it inserted, false if an owner
|
|
1115
1423
|
* key already existed (so a repeat `--owner` start is a no-op).
|
|
1424
|
+
*
|
|
1425
|
+
* Before the insert, gives every control table its empty collection
|
|
1426
|
+
* ({@link commitEmptyControlCollections}). A collection committed while a machine is alone
|
|
1427
|
+
* forks if another machine creates the same collection independently; before genesis the
|
|
1428
|
+
* party has no owner, so no other machine can have been admitted and none can hold any of
|
|
1429
|
+
* its collections. The order makes the step repeatable: a crash between it and the insert
|
|
1430
|
+
* leaves no `OwnerKey` row, so the next start runs genesis again and commits only the
|
|
1431
|
+
* collections still missing, while a founder restart finds the row and commits nothing, so
|
|
1432
|
+
* it can never write an empty second history beside a table another machine has since
|
|
1433
|
+
* written. A failure propagates with nothing inserted.
|
|
1434
|
+
*
|
|
1435
|
+
* @param label - the user's name for the key ({@link insertOwnerKey}).
|
|
1116
1436
|
*/
|
|
1117
|
-
async ensureOwnerKey(key) {
|
|
1437
|
+
async ensureOwnerKey(key, label) {
|
|
1118
1438
|
this.ensureInitialized();
|
|
1119
1439
|
const trimmed = requireEd25519PublicKeyB64(key, 'owner key');
|
|
1120
1440
|
if (await this.hasOwnerKey()) {
|
|
1121
1441
|
log('Owner key already present; skipping genesis insert');
|
|
1122
1442
|
return false;
|
|
1123
1443
|
}
|
|
1124
|
-
|
|
1444
|
+
// Two locked steps one after the other: the lock is not re-entrant, and the insert takes it itself.
|
|
1445
|
+
await this.commitEmptyControlCollections();
|
|
1446
|
+
await this.insertOwnerKey(trimmed, label);
|
|
1125
1447
|
return true;
|
|
1126
1448
|
}
|
|
1449
|
+
/**
|
|
1450
|
+
* Commit an empty collection for every `CadreControl` table, index tree and unique-enforcement
|
|
1451
|
+
* tree that has none, so a read of a table nobody has written is answered from this machine's
|
|
1452
|
+
* storage instead of asking the block's cohort to prove the collection absent — a round trip
|
|
1453
|
+
* per read on a party of several machines, and an error or a wait while they are offline.
|
|
1454
|
+
* Joining machines get these collections the ordinary way, by reading them from the cohort.
|
|
1455
|
+
*
|
|
1456
|
+
* Under the write lock: the helper commits whatever is staged in those collections outside any
|
|
1457
|
+
* SQL transaction, which is sound only while nothing else writes.
|
|
1458
|
+
*
|
|
1459
|
+
* Parties founded before this step, and tables a later control schema version adds, have no
|
|
1460
|
+
* such collections, and nothing creates them later: the start-time schema apply runs on every
|
|
1461
|
+
* machine, possibly alone, where committing one would be exactly the fork genesis avoids.
|
|
1462
|
+
*
|
|
1463
|
+
* NOTE: one `sync()` per collection, in sequence: 32 collections for today's schema (17 tables,
|
|
1464
|
+
* 3 indexes, 12 `unique` columns). Measured 2026-10-20 on a lone `CadreNode` over the network
|
|
1465
|
+
* transactor (started, then `ensureOwnerKey` alone, 3 runs each): 64–105 ms in memory and
|
|
1466
|
+
* 750–820 ms on `FileRawStorage`, about 25 ms per collection on disk, roughly 1/75 of the edge
|
|
1467
|
+
* node's 60 s `DEFAULT_OWNER_GENESIS_TIMEOUT_MS`. Under the whole cadre-core suite's load (151
|
|
1468
|
+
* geneses) it took a median of 159 ms and at most 4.6 s. If genesis latency starts to matter
|
|
1469
|
+
* (phone flash under launch contention runs 50–90 ms per storage operation, see the NOTE in
|
|
1470
|
+
* `initialize`), ask Optimystic for an API that commits them as one batch, the one strand
|
|
1471
|
+
* founding would use too; do not build a private batching path.
|
|
1472
|
+
*/
|
|
1473
|
+
async commitEmptyControlCollections() {
|
|
1474
|
+
const t0 = performance.now();
|
|
1475
|
+
const committed = await this.withWriteLock(() => createEmptyCollections(this.db, this.txnBridge, [CONTROL_SCHEMA_NAME]));
|
|
1476
|
+
log('Committed %d empty control collections in %dms', committed, Math.round(performance.now() - t0));
|
|
1477
|
+
}
|
|
1127
1478
|
/**
|
|
1128
1479
|
* Collect every owner key (`CadreControl.OwnerKey.Key`) as a set.
|
|
1129
1480
|
*
|
|
@@ -1150,8 +1501,9 @@ export class ControlDatabase {
|
|
|
1150
1501
|
return new Set((await this.queryOwnerKeyRows(retry)).map(row => row.key));
|
|
1151
1502
|
}
|
|
1152
1503
|
/**
|
|
1153
|
-
* Every live `OwnerKey` row with its persisted proof ({@link OwnerKeyRow}) — what
|
|
1154
|
-
* trusted-owner anchor sync derives from (`CadreNode.syncOwnerAnchor`)
|
|
1504
|
+
* Every live `OwnerKey` row with its label and persisted proof ({@link OwnerKeyRow}) — what
|
|
1505
|
+
* the trusted-owner anchor sync derives from (`CadreNode.syncOwnerAnchor`) and the owner-key
|
|
1506
|
+
* listing shows (`CadreNode.listOwnerKeys`). Rows whose
|
|
1155
1507
|
* `StampId` is retired in `CadreControl.Revocation` are excluded here, as
|
|
1156
1508
|
* {@link queryCadrePeers} excludes them and for the reason on {@link getOwnerKeys}, which
|
|
1157
1509
|
* reads through this. A row with no stamp (the schema permits it; no writer produces one)
|
|
@@ -1163,16 +1515,14 @@ export class ControlDatabase {
|
|
|
1163
1515
|
*
|
|
1164
1516
|
* @param retired - the retired `OwnerKey` stamps, when the caller has already read the
|
|
1165
1517
|
* tombstones (`CadreNode.syncOwnerAnchor` reads {@link queryRevocations} for their
|
|
1166
|
-
* signers); omitted, they are read here.
|
|
1167
|
-
*
|
|
1168
|
-
* membership refresh at one such read for the anchor.
|
|
1518
|
+
* signers); omitted, they are read here. Both come from the same `Revocation` view
|
|
1519
|
+
* ({@link readMembershipRows}), so passing the set saves only the filtering.
|
|
1169
1520
|
*/
|
|
1170
1521
|
async queryOwnerKeyRows(retry = true, retired) {
|
|
1171
1522
|
this.ensureInitialized();
|
|
1172
1523
|
const revoked = retired ?? await this.queryRevokedStamps('OwnerKey', retry);
|
|
1173
1524
|
const rows = [];
|
|
1174
|
-
const
|
|
1175
|
-
for (const row of await this.readRows(sql, undefined, 'owner-keys', retry)) {
|
|
1525
|
+
for (const row of await this.readMembershipRows(this.ownerKeyView, retry)) {
|
|
1176
1526
|
const stampId = row.StampId ?? null;
|
|
1177
1527
|
if (stampId === null) {
|
|
1178
1528
|
log('queryOwnerKeyRows: dropping OwnerKey row %s with no StampId', row.Key);
|
|
@@ -1183,6 +1533,7 @@ export class ControlDatabase {
|
|
|
1183
1533
|
}
|
|
1184
1534
|
rows.push({
|
|
1185
1535
|
key: row.Key,
|
|
1536
|
+
label: row.Label ?? null,
|
|
1186
1537
|
stampId,
|
|
1187
1538
|
vouchOwner: row.VouchOwner ?? null,
|
|
1188
1539
|
vouchSig: row.VouchSig ?? null,
|
|
@@ -1203,12 +1554,8 @@ export class ControlDatabase {
|
|
|
1203
1554
|
* {@link queryRevokedStamps} rather than inlining the SQL, so tests can interpose on
|
|
1204
1555
|
* that seam to model the replicated live-row-plus-tombstone merge state.
|
|
1205
1556
|
*
|
|
1206
|
-
*
|
|
1207
|
-
*
|
|
1208
|
-
* while `Revocation` has never been written every call consults the cohort about it, and
|
|
1209
|
-
* the ledger marker ({@link openRevocationLedger}) is what makes it a held block. Folding
|
|
1210
|
-
* the exclusion into one statement would not help (it reads the same block), and caching
|
|
1211
|
-
* the set would hide a tombstone arriving by replication.
|
|
1557
|
+
* Both tables come from their membership views ({@link readMembershipRows}), so a call after
|
|
1558
|
+
* the first costs no storage read until one of the two tables changes.
|
|
1212
1559
|
*
|
|
1213
1560
|
* NOTE: this filter is HAND-MIRRORED by the gate suites' fake control databases
|
|
1214
1561
|
* (`test/membership-gate-helpers.ts` and `test/cadre-node-authorized-surface.spec.ts`),
|
|
@@ -1231,42 +1578,27 @@ export class ControlDatabase {
|
|
|
1231
1578
|
this.ensureInitialized();
|
|
1232
1579
|
const revoked = await this.queryRevokedStamps('CadrePeer', retry);
|
|
1233
1580
|
const rows = [];
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
const sql = 'select PeerId, Multiaddr, StampId, VouchOwner, VouchSig, VouchUsage, PublicKey, UpdatedAt, Profile, Sig from CadreControl.CadrePeer';
|
|
1238
|
-
for (const row of await this.readRows(sql, undefined, 'cadre-peers', retry)) {
|
|
1239
|
-
const stampId = row.StampId ?? null;
|
|
1240
|
-
if (stampId !== null && revoked.has(stampId)) {
|
|
1581
|
+
for (const row of await this.readMembershipRows(this.cadrePeerView, retry)) {
|
|
1582
|
+
const peer = cadrePeerRowOf(row);
|
|
1583
|
+
if (peer.stampId !== null && revoked.has(peer.stampId)) {
|
|
1241
1584
|
continue;
|
|
1242
1585
|
}
|
|
1243
|
-
rows.push(
|
|
1244
|
-
peerId: row.PeerId,
|
|
1245
|
-
publicKey: row.PublicKey ?? null,
|
|
1246
|
-
multiaddr: row.Multiaddr ?? null,
|
|
1247
|
-
updatedAt: row.UpdatedAt ?? 0,
|
|
1248
|
-
profile: row.Profile ?? null,
|
|
1249
|
-
sig: row.Sig ?? null,
|
|
1250
|
-
stampId,
|
|
1251
|
-
vouchOwner: row.VouchOwner ?? null,
|
|
1252
|
-
vouchSig: row.VouchSig ?? null,
|
|
1253
|
-
vouchUsage: row.VouchUsage ?? null,
|
|
1254
|
-
});
|
|
1586
|
+
rows.push(peer);
|
|
1255
1587
|
}
|
|
1256
1588
|
return rows;
|
|
1257
1589
|
}
|
|
1258
1590
|
/**
|
|
1259
1591
|
* The cadre's peers as a seed or a redemption reply carries them: every live `CadrePeer`
|
|
1260
|
-
* row with its addresses,
|
|
1261
|
-
*
|
|
1592
|
+
* row with its addresses, the minter and every always-on machine marked preferred
|
|
1593
|
+
* ({@link projectSeedPeers}).
|
|
1262
1594
|
*
|
|
1263
1595
|
* Read through {@link queryCadrePeers}, never a raw `CadrePeer` select, so a removed
|
|
1264
1596
|
* member's addresses are never packed into a seed or handed to a redeeming device as a
|
|
1265
1597
|
* dial hint: a revoked peer is off the addressable surface everywhere, and this is one of
|
|
1266
1598
|
* its exits.
|
|
1267
1599
|
*/
|
|
1268
|
-
async querySeedPeers() {
|
|
1269
|
-
return projectSeedPeers(await this.queryCadrePeers(),
|
|
1600
|
+
async querySeedPeers(minterPeerId) {
|
|
1601
|
+
return projectSeedPeers(await this.queryCadrePeers(), minterPeerId);
|
|
1270
1602
|
}
|
|
1271
1603
|
/**
|
|
1272
1604
|
* Every approver public key currently enrolled in `CadreControl.ValidationKey` — the
|
|
@@ -1305,10 +1637,11 @@ export class ControlDatabase {
|
|
|
1305
1637
|
* guard. A read retrying there would sleep its backoff HOLDING the write lock
|
|
1306
1638
|
* (`retryControlWrite`'s contract is that backoff sleeps happen with no lock held), and
|
|
1307
1639
|
* the write funnel already re-runs the body's reads when it re-runs the body. The
|
|
1308
|
-
* unlocked callers — the public stamp readers
|
|
1309
|
-
*
|
|
1310
|
-
*
|
|
1311
|
-
*
|
|
1640
|
+
* unlocked callers — the public stamp readers and {@link reapRevokedRow}, whose stamp
|
|
1641
|
+
* read runs BEFORE its lock is taken — keep the default retried path. Explicit per call
|
|
1642
|
+
* rather than an ambient flag: unlocked reads run concurrently with a locked body, so
|
|
1643
|
+
* `this`-state cannot tell the two apart. The same flag threads through
|
|
1644
|
+
* {@link findCadrePeerRow} for the two writers that read the whole row inside their body.
|
|
1312
1645
|
*/
|
|
1313
1646
|
async queryStampId(table, keyValue, retry = true) {
|
|
1314
1647
|
this.ensureInitialized();
|
|
@@ -1321,8 +1654,8 @@ export class ControlDatabase {
|
|
|
1321
1654
|
/**
|
|
1322
1655
|
* `CadrePeer` stamp nonce — read by {@link SeedBootstrapService.removePeer} as its
|
|
1323
1656
|
* row-present gate before it delegates to {@link deleteCadrePeer}. The in-class
|
|
1324
|
-
* `CadrePeer` writers
|
|
1325
|
-
*
|
|
1657
|
+
* `CadrePeer` writers go straight to {@link queryStampId} ({@link insertCadrePeer}) or read
|
|
1658
|
+
* the whole row ({@link reauthorizeCadrePeer}, through {@link findCadrePeerRow}).
|
|
1326
1659
|
*/
|
|
1327
1660
|
queryCadrePeerStampId(peerId) {
|
|
1328
1661
|
return this.queryStampId('CadrePeer', peerId);
|
|
@@ -1377,18 +1710,17 @@ export class ControlDatabase {
|
|
|
1377
1710
|
* ({@link queryCadrePeers}, {@link queryPeerRecord},
|
|
1378
1711
|
* {@link CadreNode.resolveDeviceToken}) drop any row whose stamp appears here.
|
|
1379
1712
|
*
|
|
1380
|
-
*
|
|
1381
|
-
*
|
|
1382
|
-
*
|
|
1383
|
-
*
|
|
1384
|
-
*
|
|
1385
|
-
*
|
|
1386
|
-
*
|
|
1387
|
-
* row count only grows (append-only) but stays small while removals are rare; revisit if
|
|
1713
|
+
* Served from the `Revocation` membership view ({@link readMembershipRows}), dropped by
|
|
1714
|
+
* every change to the table, local or replicated into this node's storage, so a tombstone
|
|
1715
|
+
* arriving by replication is seen on the next call. The cost that matters is the storage
|
|
1716
|
+
* layer's, paid by a cold view's load: while `Revocation` has never been written, this node
|
|
1717
|
+
* does not hold its block, and Optimystic consults a block's cohort on every read of a block
|
|
1718
|
+
* it does not hold, each a round trip to every other member on a multi-machine party. The row
|
|
1719
|
+
* count only grows (append-only) but stays small while removals are rare; revisit if
|
|
1388
1720
|
* removals become routine and the scan itself shows up in a profile.
|
|
1389
1721
|
*
|
|
1390
1722
|
* An isolated node that has never received the `Revocation` block reads it as holding no
|
|
1391
|
-
* revocations instead of throwing — see {@link
|
|
1723
|
+
* revocations instead of throwing — see {@link readRevocationRowsOrNone}.
|
|
1392
1724
|
*
|
|
1393
1725
|
* `retry: false` is forwarded by {@link queryCadrePeers} for the one caller that reads
|
|
1394
1726
|
* under the write lock — see that method's note.
|
|
@@ -1396,21 +1728,25 @@ export class ControlDatabase {
|
|
|
1396
1728
|
async queryRevokedStamps(tableName, retry = true) {
|
|
1397
1729
|
this.ensureInitialized();
|
|
1398
1730
|
const stamps = new Set();
|
|
1399
|
-
for (const row of await this.
|
|
1400
|
-
|
|
1731
|
+
for (const row of await this.readRevocationRowsOrNone(retry)) {
|
|
1732
|
+
if (row.TableName === tableName) {
|
|
1733
|
+
stamps.add(row.StampId);
|
|
1734
|
+
}
|
|
1401
1735
|
}
|
|
1402
1736
|
return stamps;
|
|
1403
1737
|
}
|
|
1404
1738
|
/**
|
|
1405
|
-
* The `Revocation`
|
|
1406
|
-
* fails `cohort-unreachable` ({@link isCohortUnreachableRead}): this node does not hold
|
|
1739
|
+
* The `Revocation` rows behind {@link queryRevokedStamps}, answering NO ROWS when the view's
|
|
1740
|
+
* load fails `cohort-unreachable` ({@link isCohortUnreachableRead}): this node does not hold
|
|
1407
1741
|
* the block and could ask no other cohort member about it. Every other failure rethrows.
|
|
1408
1742
|
* The retry in {@link readRows} runs first, so a node whose connectivity returns within
|
|
1409
|
-
* the read budget still gets the real answer.
|
|
1743
|
+
* the read budget still gets the real answer. The empty answer is never installed in the
|
|
1744
|
+
* view (a failed load keeps nothing), so the next call asks again.
|
|
1410
1745
|
*
|
|
1411
1746
|
* Without this, a machine cut off from every other machine in its party before it ever
|
|
1412
|
-
* received the block —
|
|
1413
|
-
*
|
|
1747
|
+
* received the block — a joining machine gets it only by reading it from the cohort, and
|
|
1748
|
+
* on a party founded before genesis created every control collection it exists nowhere
|
|
1749
|
+
* until the first tombstone or the marker ({@link REVOCATION_LEDGER_MARKER}) — cannot
|
|
1414
1750
|
* answer ANY membership, peer-record or device-token lookup, although it holds those
|
|
1415
1751
|
* rows locally.
|
|
1416
1752
|
*
|
|
@@ -1429,20 +1765,20 @@ export class ControlDatabase {
|
|
|
1429
1765
|
*
|
|
1430
1766
|
* NOTE: an isolated node pays the whole read retry (up to
|
|
1431
1767
|
* `CONTROL_READ_RETRY_BUDGET_MS`, plus the slow attempt that exhausted it) before this
|
|
1432
|
-
* answers,
|
|
1433
|
-
* node's lookups are seen exceeding the
|
|
1434
|
-
* (`ADMISSION_DECISION_TIMEOUT_MS`), skip the retry for this
|
|
1435
|
-
* fails `cohort-unreachable`.
|
|
1768
|
+
* answers, on every call since the answer is not kept, and {@link queryPeerRecord} may then
|
|
1769
|
+
* load the `CadrePeer` view on top. If an isolated node's lookups are seen exceeding the
|
|
1770
|
+
* admission gate's 2 s deadline (`ADMISSION_DECISION_TIMEOUT_MS`), skip the retry for this
|
|
1771
|
+
* read when the first attempt fails `cohort-unreachable`.
|
|
1436
1772
|
*/
|
|
1437
|
-
async
|
|
1773
|
+
async readRevocationRowsOrNone(retry) {
|
|
1438
1774
|
try {
|
|
1439
|
-
return await this.
|
|
1775
|
+
return await this.readMembershipRows(this.revocationView, retry);
|
|
1440
1776
|
}
|
|
1441
1777
|
catch (error) {
|
|
1442
1778
|
if (!isCohortUnreachableRead(error)) {
|
|
1443
1779
|
throw error;
|
|
1444
1780
|
}
|
|
1445
|
-
log('revoked-stamps
|
|
1781
|
+
log('revoked-stamps: no cohort member reachable and the Revocation block is not held here; reading as no revocations known: %s', error);
|
|
1446
1782
|
return [];
|
|
1447
1783
|
}
|
|
1448
1784
|
}
|
|
@@ -1454,10 +1790,10 @@ export class ControlDatabase {
|
|
|
1454
1790
|
* `where`, so the composite-primary-key point-lookup hazard (see the statement comment in
|
|
1455
1791
|
* {@link reissueRevocations}) does not arise. Unlocked, like every other read.
|
|
1456
1792
|
*
|
|
1457
|
-
* Skips the ledger marker ({@link REVOCATION_LEDGER_MARKER})
|
|
1458
|
-
* neither sweep may reap or re-sign it, and skipping it keeps
|
|
1459
|
-
* a {@link RevocableTable}. Filtered on `TableName` alone, in
|
|
1460
|
-
* admits no other row under `'Revocation'`.
|
|
1793
|
+
* Skips the ledger marker ({@link REVOCATION_LEDGER_MARKER}) an older party may hold: it
|
|
1794
|
+
* retires nothing, so neither sweep may reap or re-sign it, and skipping it keeps
|
|
1795
|
+
* `RevocationRow.tableName` a {@link RevocableTable}. Filtered on `TableName` alone, in
|
|
1796
|
+
* TypeScript: `RowIsGone` admits no other row under `'Revocation'`.
|
|
1461
1797
|
*
|
|
1462
1798
|
* `retry: false` from a caller that reads under the write lock, for the reason on
|
|
1463
1799
|
* {@link queryCadrePeers}.
|
|
@@ -1465,24 +1801,16 @@ export class ControlDatabase {
|
|
|
1465
1801
|
async queryRevocations(retry = true) {
|
|
1466
1802
|
this.ensureInitialized();
|
|
1467
1803
|
const rows = [];
|
|
1468
|
-
const
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
continue;
|
|
1804
|
+
for (const row of await this.readMembershipRows(this.revocationView, retry)) {
|
|
1805
|
+
if (row.TableName !== REVOCATION_LEDGER_MARKER.tableName) {
|
|
1806
|
+
rows.push(revocationRowOf(row));
|
|
1472
1807
|
}
|
|
1473
|
-
rows.push({
|
|
1474
|
-
tableName: row.TableName,
|
|
1475
|
-
rowKey: row.RowKey,
|
|
1476
|
-
stampId: row.StampId,
|
|
1477
|
-
reissuedAt: row.ReissuedAt ?? 0,
|
|
1478
|
-
signerKey: row.SignerKey,
|
|
1479
|
-
signerSig: row.SignerSig,
|
|
1480
|
-
});
|
|
1481
1808
|
}
|
|
1482
1809
|
return rows;
|
|
1483
1810
|
}
|
|
1484
1811
|
/**
|
|
1485
|
-
* Read a single peer's address record (the full `CadrePeer` row) by PeerId
|
|
1812
|
+
* Read a single peer's address record (the full `CadrePeer` row) by PeerId, found in the
|
|
1813
|
+
* `CadrePeer` membership view ({@link readMembershipRows}).
|
|
1486
1814
|
*
|
|
1487
1815
|
* Returns null when no row exists, or when the row's `StampId` is retired in
|
|
1488
1816
|
* `CadreControl.Revocation` — a revoked peer reads as absent, so the resolver
|
|
@@ -1498,29 +1826,34 @@ export class ControlDatabase {
|
|
|
1498
1826
|
* The split `addrs` re-join to the exact stored `Multiaddr` (split-on-`,` is
|
|
1499
1827
|
* the inverse of join-on-`,`), so the resolver re-verifies over the same bytes
|
|
1500
1828
|
* the publisher signed.
|
|
1829
|
+
*
|
|
1830
|
+
* `retry: false` from the one caller that reads under the write lock,
|
|
1831
|
+
* {@link updateSelfPeerRecord}'s body, for the reason on {@link queryCadrePeers}.
|
|
1832
|
+
*/
|
|
1833
|
+
async queryPeerRecord(peerId, retry = true) {
|
|
1834
|
+
const row = await this.findCadrePeerRow(peerId, retry);
|
|
1835
|
+
return row === null ? null : peerAddressRecordOf(row);
|
|
1836
|
+
}
|
|
1837
|
+
/**
|
|
1838
|
+
* One readable `CadrePeer` row by PeerId from the membership view, or null when no row exists
|
|
1839
|
+
* or its stamp is retired in `Revocation` — the single-row read behind {@link queryPeerRecord}
|
|
1840
|
+
* and the two writers that decide over the row inside their locked body
|
|
1841
|
+
* ({@link updateSelfPeerRecord}, {@link reauthorizeCadrePeer}). Same retired-stamp rule as
|
|
1842
|
+
* {@link queryCadrePeers}, and the same `retry` contract.
|
|
1501
1843
|
*/
|
|
1502
|
-
async
|
|
1844
|
+
async findCadrePeerRow(peerId, retry) {
|
|
1503
1845
|
this.ensureInitialized();
|
|
1504
|
-
const revoked = await this.queryRevokedStamps('CadrePeer');
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
return null;
|
|
1509
|
-
}
|
|
1510
|
-
const multiaddr = row.Multiaddr ?? '';
|
|
1511
|
-
return {
|
|
1512
|
-
peerId: row.PeerId,
|
|
1513
|
-
publicKey: row.PublicKey ?? '',
|
|
1514
|
-
addrs: multiaddr.length > 0 ? multiaddr.split(',') : [],
|
|
1515
|
-
updatedAt: row.UpdatedAt ?? 0,
|
|
1516
|
-
profile: row.Profile ?? null,
|
|
1517
|
-
sig: row.Sig ?? '',
|
|
1518
|
-
};
|
|
1846
|
+
const revoked = await this.queryRevokedStamps('CadrePeer', retry);
|
|
1847
|
+
const row = (await this.readMembershipRows(this.cadrePeerView, retry)).find((candidate) => candidate.PeerId === peerId);
|
|
1848
|
+
if (row === undefined) {
|
|
1849
|
+
return null;
|
|
1519
1850
|
}
|
|
1520
|
-
|
|
1851
|
+
const peer = cadrePeerRowOf(row);
|
|
1852
|
+
return peer.stampId !== null && revoked.has(peer.stampId) ? null : peer;
|
|
1521
1853
|
}
|
|
1522
1854
|
/**
|
|
1523
|
-
* Apply a peer's own self-signed address-record update to
|
|
1855
|
+
* Apply a peer's own self-signed address-record update to its existing row: read the row
|
|
1856
|
+
* under the write lock, have `sign` build the record over it, and write that record.
|
|
1524
1857
|
*
|
|
1525
1858
|
* Authorization is carried entirely by the record: the `Sig` column (verified
|
|
1526
1859
|
* by the `AuthorizedUpdate` self-branch against the stored `PublicKey`) plus
|
|
@@ -1529,6 +1862,21 @@ export class ControlDatabase {
|
|
|
1529
1862
|
* exists. `PublicKey` is intentionally not in the SET list (it is immutable on
|
|
1530
1863
|
* self-update and the constraint enforces `new.PublicKey = old.PublicKey`).
|
|
1531
1864
|
*
|
|
1865
|
+
* The read, the decision and the signature all run INSIDE the locked body, which is what
|
|
1866
|
+
* lets it opt into {@link LOST_UPDATE_RETRY_POLICY}: the row has a second writer, the owner's
|
|
1867
|
+
* re-touch ({@link reauthorizeCadrePeer}), and the two fire together while a cadre starts.
|
|
1868
|
+
* When this write loses that race the commit is refused as a lost update and re-run, and the
|
|
1869
|
+
* re-run reads the row the rival left — including its bumped `UpdatedAt`, which a record
|
|
1870
|
+
* signed before the race could no longer exceed — and signs past it. A record signed outside
|
|
1871
|
+
* the body would be re-presented unchanged and refused again, this time by the schema's
|
|
1872
|
+
* monotonic rule, which is correctly never retried. The row's read inside is `retry: false`
|
|
1873
|
+
* (see {@link queryStampId}'s NOTE).
|
|
1874
|
+
*
|
|
1875
|
+
* Returns the record actually written, so the caller's bookkeeping describes the row rather
|
|
1876
|
+
* than the first record it signed; null, and nothing written, when no readable row exists:
|
|
1877
|
+
* the row is absent, or its stamp is retired in `Revocation` (a revoked node reads its own row
|
|
1878
|
+
* as absent every heartbeat, and must not refresh it).
|
|
1879
|
+
*
|
|
1532
1880
|
* Deliberately NOT wrapped in {@link mutateCadrePeer}, the one `CadrePeer` mutator that
|
|
1533
1881
|
* is not: it only ever touches THIS node's own row (sole caller
|
|
1534
1882
|
* `CadreNode.publishSelfRecord`), and `CadreNode.listAuthorizedMembers` filters self out
|
|
@@ -1541,17 +1889,43 @@ export class ControlDatabase {
|
|
|
1541
1889
|
* row, and a self insert happens once at startup, so the wasted refresh is a single read
|
|
1542
1890
|
* — cheaper than a conditional carve-out inside the shared writer. Only the repeating
|
|
1543
1891
|
* path is worth exempting.
|
|
1892
|
+
*
|
|
1893
|
+
* @param peerId - the row to refresh: this node's own.
|
|
1894
|
+
* @param sign - builds the signed record over the row as read; called once per attempt.
|
|
1544
1895
|
*/
|
|
1545
|
-
async updateSelfPeerRecord(
|
|
1896
|
+
async updateSelfPeerRecord(peerId, sign) {
|
|
1546
1897
|
this.ensureInitialized();
|
|
1547
|
-
const
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1898
|
+
const written = await this.lockedWithRetry(async () => {
|
|
1899
|
+
// retry: false — inside the locked write body (see queryStampId's NOTE).
|
|
1900
|
+
// NOTE: this read of the Revocation and CadrePeer views is charged to the retry budget,
|
|
1901
|
+
// so a cold view load that waits out a per-peer read deadline leaves the attempt no room
|
|
1902
|
+
// for its one retry (the 14.7 s abandonment the policy's doc cites). The caller's read
|
|
1903
|
+
// outside the funnel warms the views, so this is a cache hit except when a table change
|
|
1904
|
+
// between the two reads dropped them — the racing case itself. If that residual ever
|
|
1905
|
+
// matters, measure which phase of the losing attempt pays the deadline first; the
|
|
1906
|
+
// retired-stamp check cannot simply move outside, since `publishSelfRecord`'s insert
|
|
1907
|
+
// path reads a revoked node's row as absent and relies on this body to skip it.
|
|
1908
|
+
const current = await this.queryPeerRecord(peerId, false);
|
|
1909
|
+
if (current === null) {
|
|
1910
|
+
return null;
|
|
1911
|
+
}
|
|
1912
|
+
const record = sign(current);
|
|
1913
|
+
// Bare `exec` — inside the non-re-entrant write lock (see insertCadrePeer).
|
|
1914
|
+
await this.db.exec(`
|
|
1915
|
+
update CadreControl.CadrePeer
|
|
1916
|
+
with context OwnerKey = null, Signature = ?
|
|
1917
|
+
set Multiaddr = ?, UpdatedAt = ?, Profile = ?, Sig = ?
|
|
1918
|
+
where PeerId = ?
|
|
1919
|
+
`, [record.sig, record.addrs.join(','), record.updatedAt, record.profile, record.sig, peerId]);
|
|
1920
|
+
return record;
|
|
1921
|
+
}, LOST_UPDATE_RETRY_POLICY, 'self-record-update');
|
|
1922
|
+
if (written === null) {
|
|
1923
|
+
log('Self peer record not updated: no readable CadrePeer row for %s (absent, or its stamp retired)', peerId);
|
|
1924
|
+
}
|
|
1925
|
+
else {
|
|
1926
|
+
log('Self peer record updated: %s (updatedAt=%d)', peerId, written.updatedAt);
|
|
1927
|
+
}
|
|
1928
|
+
return written;
|
|
1555
1929
|
}
|
|
1556
1930
|
/**
|
|
1557
1931
|
* Read a single peer's device push token (the full `DeviceToken` row) by PeerId.
|
|
@@ -1603,9 +1977,13 @@ export class ControlDatabase {
|
|
|
1603
1977
|
}
|
|
1604
1978
|
/**
|
|
1605
1979
|
* Insert the initial owner key (bootstrap - no existing owners required)
|
|
1980
|
+
*
|
|
1981
|
+
* @param label - the user's name for the key, at most {@link OWNER_KEY_MAX_LABEL}
|
|
1982
|
+
* characters once trimmed; blank is stored as none.
|
|
1606
1983
|
*/
|
|
1607
|
-
async insertOwnerKey(key) {
|
|
1984
|
+
async insertOwnerKey(key, label) {
|
|
1608
1985
|
this.ensureInitialized();
|
|
1986
|
+
const storedLabel = ownerKeyLabel(label);
|
|
1609
1987
|
log('Inserting owner key: %s', key);
|
|
1610
1988
|
// Bootstrap is authorized by the schema's genesis branch — `(select count(1) from
|
|
1611
1989
|
// committed.OwnerKey) = 0`, i.e. the party had no owner before this transaction — so no
|
|
@@ -1617,60 +1995,81 @@ export class ControlDatabase {
|
|
|
1617
1995
|
// optimystic `StampId()` SQL function.
|
|
1618
1996
|
const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
|
|
1619
1997
|
await this.execWrite(`
|
|
1620
|
-
insert into CadreControl.OwnerKey (Key, StampId)
|
|
1998
|
+
insert into CadreControl.OwnerKey (Key, Label, StampId)
|
|
1621
1999
|
with context OwnerKey = null, Signature = null
|
|
1622
|
-
values (?, ?)
|
|
1623
|
-
`, [key, stampId], 'owner-key-insert');
|
|
2000
|
+
values (?, ?, ?)
|
|
2001
|
+
`, [key, storedLabel, stampId], 'owner-key-insert');
|
|
1624
2002
|
log('Owner key inserted');
|
|
1625
2003
|
}
|
|
1626
2004
|
/**
|
|
1627
2005
|
* Owner-signed INSERT of a further `OwnerKey` row, notifying the membership listener once
|
|
1628
2006
|
* it has committed. The signed-add branch of `OwnerKey.Authorized`: the signer must be an
|
|
1629
2007
|
* owner in the pre-transaction snapshot, its signature covers the `'add'` digest over
|
|
1630
|
-
* (Key, StampId), and the stored `VouchOwner`/`VouchSig` are that same pair, so a
|
|
1631
|
-
* can later judge the add against its own anchor (`verifyOwnerKeyVoucher`).
|
|
2008
|
+
* (Key, Label, StampId), and the stored `VouchOwner`/`VouchSig` are that same pair, so a
|
|
2009
|
+
* reader can later judge the add against its own anchor (`verifyOwnerKeyVoucher`).
|
|
1632
2010
|
*
|
|
1633
2011
|
* Through {@link mutateCadrePeer}, the membership hub. The liveness read, the stamp and the
|
|
1634
2012
|
* signature all sit inside the locked body: a {@link lockedWithRetry} re-run starts over a
|
|
1635
2013
|
* rolled-back attempt with a fresh stamp, as {@link redeemCadreInvite} does.
|
|
1636
2014
|
*
|
|
1637
|
-
*
|
|
1638
|
-
* is refused here by name: `OwnerKey` is not reaped, so a machine that received the
|
|
1639
|
-
* tombstone without the delete (see {@link getOwnerKeys}) keeps the row, and an insert there
|
|
1640
|
-
* would collide on the primary key. Re-add from the machine that removed the key, or any
|
|
1641
|
-
* machine the delete reached; if re-adding removed owners becomes routine, the fix is an
|
|
1642
|
-
* `OwnerKey` reap branch, gated so it can never take the table below `MinOneOwner`.
|
|
1643
|
-
*
|
|
2015
|
+
* @param label - the user's name for the key, as {@link insertOwnerKey}'s.
|
|
1644
2016
|
* @returns `true` when this call seated the row, `false` when `key` is already a live owner
|
|
1645
|
-
* here (nothing written).
|
|
1646
|
-
* @throws when this node holds `key`'s row under a retired stamp
|
|
1647
|
-
* schema refuses the signer.
|
|
2017
|
+
* here (nothing written, whatever its label).
|
|
2018
|
+
* @throws when this node holds `key`'s row under a retired stamp
|
|
2019
|
+
* ({@link liveOwnerKeyStamp}), or when the schema refuses the signer.
|
|
1648
2020
|
*/
|
|
1649
|
-
async insertOwnerKeyVouched(key, ownerKey, signMessage) {
|
|
2021
|
+
async insertOwnerKeyVouched(key, ownerKey, signMessage, label) {
|
|
1650
2022
|
this.ensureInitialized();
|
|
2023
|
+
const storedLabel = ownerKeyLabel(label);
|
|
1651
2024
|
return await this.mutateCadrePeer('owner-key-add', async () => {
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
if (!(await this.queryRevokedStamps('OwnerKey', false)).has(heldStamp)) {
|
|
1656
|
-
log('insertOwnerKeyVouched: %s is already an owner; nothing written', key);
|
|
1657
|
-
return false;
|
|
1658
|
-
}
|
|
1659
|
-
throw new Error(`Cannot re-add owner key ${key} here: this node still holds the row it was removed under ` +
|
|
1660
|
-
'(OwnerKey rows are not reaped); re-add it from the machine that removed it');
|
|
2025
|
+
if (await this.liveOwnerKeyStamp(key) !== null) {
|
|
2026
|
+
log('insertOwnerKeyVouched: %s is already an owner; nothing written', key);
|
|
2027
|
+
return false;
|
|
1661
2028
|
}
|
|
1662
2029
|
const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
|
|
1663
|
-
const signature = signMessage(ownerKeyAddMessage(this.config.partyId, key, stampId));
|
|
1664
|
-
|
|
1665
|
-
await this.db.exec(`
|
|
1666
|
-
insert into CadreControl.OwnerKey (Key, StampId, VouchOwner, VouchSig)
|
|
1667
|
-
with context OwnerKey = ?, Signature = ?
|
|
1668
|
-
values (?, ?, ?, ?)
|
|
1669
|
-
`, [ownerKey, signature, key, stampId, ownerKey, signature]);
|
|
2030
|
+
const signature = await signMessage(ownerKeyAddMessage(this.config.partyId, key, storedLabel, stampId));
|
|
2031
|
+
await this.execOwnerKeyVouchedInsert({ key, label: storedLabel, stampId }, ownerKey, signature);
|
|
1670
2032
|
log('Owner key %s added (vouched by %s)', key, ownerKey);
|
|
1671
2033
|
return true;
|
|
1672
2034
|
});
|
|
1673
2035
|
}
|
|
2036
|
+
/**
|
|
2037
|
+
* The stamp of `key`'s live `OwnerKey` row here, or null when this node holds none: the
|
|
2038
|
+
* insert-if-absent read of {@link insertOwnerKeyVouched} and {@link redeemOwnerGrant}. Inside
|
|
2039
|
+
* the locked body (both reads `retry: false`; see queryStampId's NOTE).
|
|
2040
|
+
*
|
|
2041
|
+
* NOTE: re-adding a removed owner from a machine that still holds its retired physical row
|
|
2042
|
+
* is refused here by name: `OwnerKey` is not reaped, so a machine that received the
|
|
2043
|
+
* tombstone without the delete (see {@link getOwnerKeys}) keeps the row, and an insert there
|
|
2044
|
+
* would collide on the primary key. Re-add from the machine that removed the key, or any
|
|
2045
|
+
* machine the delete reached; if re-adding removed owners becomes routine, the fix is an
|
|
2046
|
+
* `OwnerKey` reap branch, gated so it can never take the table below `MinOneOwner`.
|
|
2047
|
+
*
|
|
2048
|
+
* @throws when this node holds `key`'s row under a retired stamp (above).
|
|
2049
|
+
*/
|
|
2050
|
+
async liveOwnerKeyStamp(key) {
|
|
2051
|
+
const heldStamp = await this.queryStampId('OwnerKey', key, false);
|
|
2052
|
+
if (heldStamp === null) {
|
|
2053
|
+
return null;
|
|
2054
|
+
}
|
|
2055
|
+
if ((await this.queryRevokedStamps('OwnerKey', false)).has(heldStamp)) {
|
|
2056
|
+
throw new Error(`Cannot re-add owner key ${key} here: this node still holds the row it was removed under ` +
|
|
2057
|
+
'(OwnerKey rows are not reaped); re-add it from the machine that removed it');
|
|
2058
|
+
}
|
|
2059
|
+
return heldStamp;
|
|
2060
|
+
}
|
|
2061
|
+
/**
|
|
2062
|
+
* The signed-add statement of {@link insertOwnerKeyVouched} and {@link revouchOwnerKey}, the
|
|
2063
|
+
* stored voucher being the signing pair. Bare `exec`: callers hold the write lock, which is
|
|
2064
|
+
* NOT re-entrant.
|
|
2065
|
+
*/
|
|
2066
|
+
async execOwnerKeyVouchedInsert(row, ownerKey, signature) {
|
|
2067
|
+
await this.db.exec(`
|
|
2068
|
+
insert into CadreControl.OwnerKey (Key, Label, StampId, VouchOwner, VouchSig)
|
|
2069
|
+
with context OwnerKey = ?, Signature = ?
|
|
2070
|
+
values (?, ?, ?, ?, ?)
|
|
2071
|
+
`, [ownerKey, signature, row.key, row.label, row.stampId, ownerKey, signature]);
|
|
2072
|
+
}
|
|
1674
2073
|
/**
|
|
1675
2074
|
* Owner-signed removal of one `OwnerKey` row plus the `Revocation` tombstone retiring its
|
|
1676
2075
|
* stamp, notifying the membership listener once it has committed. The remove branch of
|
|
@@ -1708,14 +2107,14 @@ export class ControlDatabase {
|
|
|
1708
2107
|
/**
|
|
1709
2108
|
* Re-vouch a live `OwnerKey` row under this owner's signature: in ONE transaction, the
|
|
1710
2109
|
* current row's guarded delete plus tombstone ({@link deleteOwnerKey}'s body) and an insert
|
|
1711
|
-
* of the same key under a fresh stamp, signed by `ownerKey`
|
|
1712
|
-
* body), as {@link rewritePendingJoin} re-seats a request. `OwnerKey` has `NoUpdate`, so
|
|
2110
|
+
* of the same key and label under a fresh stamp, signed by `ownerKey`
|
|
2111
|
+
* ({@link insertOwnerKeyVouched}'s body), as {@link rewritePendingJoin} re-seats a request. `OwnerKey` has `NoUpdate`, so
|
|
1713
2112
|
* this is the only way to rebind a row's voucher; the anchor derivation lets the re-add
|
|
1714
2113
|
* under a fresh stamp beat the older tombstone (`deriveOwnerAnchor`). For
|
|
1715
|
-
*
|
|
1716
|
-
*
|
|
2114
|
+
* `CadreNode.removeOwner`: the owners a key being removed had added keep their anchor place
|
|
2115
|
+
* everywhere once their row stands on a remaining owner's signature.
|
|
1717
2116
|
*
|
|
1718
|
-
* Through {@link mutateCadrePeer}, the membership hub. The
|
|
2117
|
+
* Through {@link mutateCadrePeer}, the membership hub. The row read runs inside the locked
|
|
1719
2118
|
* body; a row whose stamp is already retired (held without its delete) is reported as absent
|
|
1720
2119
|
* rather than tombstoned twice.
|
|
1721
2120
|
*
|
|
@@ -1727,59 +2126,55 @@ export class ControlDatabase {
|
|
|
1727
2126
|
throw new Error(`Cannot re-vouch owner key ${key}: an owner cannot re-vouch itself`);
|
|
1728
2127
|
}
|
|
1729
2128
|
return await this.mutateCadrePeer('owner-key-revouch', async () => {
|
|
1730
|
-
// retry: false
|
|
1731
|
-
const
|
|
1732
|
-
if (
|
|
2129
|
+
// retry: false — inside the locked write body (see queryStampId's NOTE).
|
|
2130
|
+
const live = (await this.queryOwnerKeyRows(false)).find((row) => row.key === key);
|
|
2131
|
+
if (live === undefined) {
|
|
1733
2132
|
log('revouchOwnerKey: %s is not a live owner here (nothing to re-vouch)', key);
|
|
1734
2133
|
return false;
|
|
1735
2134
|
}
|
|
1736
|
-
const removal = signGuardedRemoval(this.config.partyId, 'OwnerKey', key, stampId, signMessage);
|
|
1737
|
-
const
|
|
1738
|
-
const signature = signMessage(ownerKeyAddMessage(this.config.partyId, key,
|
|
2135
|
+
const removal = await signGuardedRemoval(this.config.partyId, 'OwnerKey', key, live.stampId, signMessage);
|
|
2136
|
+
const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
|
|
2137
|
+
const signature = await signMessage(ownerKeyAddMessage(this.config.partyId, key, live.label, stampId));
|
|
1739
2138
|
await this.inTransaction('revouch OwnerKey', async () => {
|
|
1740
2139
|
await this.execGuardedRemoval(removal, ownerKey);
|
|
1741
|
-
await this.
|
|
1742
|
-
insert into CadreControl.OwnerKey (Key, StampId, VouchOwner, VouchSig)
|
|
1743
|
-
with context OwnerKey = ?, Signature = ?
|
|
1744
|
-
values (?, ?, ?, ?)
|
|
1745
|
-
`, [ownerKey, signature, key, freshStampId, ownerKey, signature]);
|
|
2140
|
+
await this.execOwnerKeyVouchedInsert({ key, label: live.label, stampId }, ownerKey, signature);
|
|
1746
2141
|
});
|
|
1747
|
-
log('Owner key %s re-vouched by %s (stamp %s retired)', key, ownerKey, stampId);
|
|
2142
|
+
log('Owner key %s re-vouched by %s (stamp %s retired)', key, ownerKey, live.stampId);
|
|
1748
2143
|
this.notifyGuardedDelete(removal.ref);
|
|
1749
2144
|
return true;
|
|
1750
2145
|
});
|
|
1751
2146
|
}
|
|
1752
2147
|
/**
|
|
1753
|
-
* Remove one machine completely, in ONE transaction: its `CadrePeer` row
|
|
1754
|
-
*
|
|
1755
|
-
*
|
|
1756
|
-
*
|
|
1757
|
-
*
|
|
1758
|
-
*
|
|
1759
|
-
*
|
|
1760
|
-
*
|
|
2148
|
+
* Remove one machine completely, in ONE transaction: its `CadrePeer` row and its
|
|
2149
|
+
* `DeviceToken` row when it has one, each with the `Revocation` tombstone retiring its stamp
|
|
2150
|
+
* ({@link deleteGuardedRow}'s statement pair, composed bare as {@link deleteStrandAndPartyKey}
|
|
2151
|
+
* composes it), plus a withdrawal tombstone for every `CadreInvite` in `target.inviteKeys`
|
|
2152
|
+
* that is still live here ({@link withdrawCadreInvite}'s body). One transaction so no reader
|
|
2153
|
+
* sees the machine half removed: a row gone but its push token resolvable, or an invitation
|
|
2154
|
+
* it could redeem again still open. Owner keys are never touched: they are the person's, and
|
|
2155
|
+
* removing one is {@link deleteOwnerKey}.
|
|
1761
2156
|
*
|
|
1762
2157
|
* Through {@link mutateCadrePeer}, the membership hub. Every stamp is read inside the locked
|
|
1763
2158
|
* body, and a row whose stamp is already retired (held without its delete) is skipped rather
|
|
1764
|
-
* than tombstoned twice.
|
|
1765
|
-
* owner-key arm. Nothing to remove writes nothing and returns all false.
|
|
2159
|
+
* than tombstoned twice. Nothing to remove writes nothing and returns all false.
|
|
1766
2160
|
*
|
|
1767
|
-
*
|
|
1768
|
-
*
|
|
1769
|
-
*
|
|
2161
|
+
* Opted into {@link LOST_UPDATE_RETRY_POLICY} for the reason on {@link deleteCadrePeer}: every
|
|
2162
|
+
* stamp and signature is minted inside the body, and the invitation keys come from the
|
|
2163
|
+
* caller's plan, which a self-publish does not touch (a withdrawal of an invitation withdrawn
|
|
2164
|
+
* since collides on `Revocation`'s primary key — a constraint failure, never retried, as
|
|
2165
|
+
* before). A lost update after a sibling tree of the transaction already synced arrives as a
|
|
2166
|
+
* partial commit and stays declined.
|
|
1770
2167
|
*
|
|
1771
|
-
*
|
|
2168
|
+
* The caller decides which invitations the machine could use, from its plan
|
|
2169
|
+
* (`CadreNode.planMachineRemoval`).
|
|
1772
2170
|
*/
|
|
1773
2171
|
async removeMachine(target, ownerKey, signMessage) {
|
|
1774
|
-
if (target.ownerKey === ownerKey) {
|
|
1775
|
-
throw new Error(`Cannot remove owner key ${ownerKey}: an owner cannot remove itself`);
|
|
1776
|
-
}
|
|
1777
2172
|
return await this.mutateCadrePeer('machine-remove', async () => {
|
|
1778
|
-
const removals = await this.signMachineRemovals(target, signMessage);
|
|
1779
|
-
const withdrawals = await this.
|
|
2173
|
+
const removals = await this.signMachineRemovals(target.peerId, signMessage);
|
|
2174
|
+
const withdrawals = await this.signWithdrawals('CadreInvite', target.inviteKeys, signMessage);
|
|
1780
2175
|
if (removals.length === 0 && withdrawals.length === 0) {
|
|
1781
2176
|
log('removeMachine: nothing live names %s here (already removed)', target.peerId);
|
|
1782
|
-
return { removedPeer: false,
|
|
2177
|
+
return { removedPeer: false, removedDeviceToken: false, withdrawnInvitations: [] };
|
|
1783
2178
|
}
|
|
1784
2179
|
await this.inTransaction('remove machine', async () => {
|
|
1785
2180
|
for (const removal of removals) {
|
|
@@ -1799,19 +2194,18 @@ export class ControlDatabase {
|
|
|
1799
2194
|
}
|
|
1800
2195
|
return {
|
|
1801
2196
|
removedPeer: removed.has('CadrePeer'),
|
|
1802
|
-
removedOwnerKey: removed.has('OwnerKey'),
|
|
1803
2197
|
removedDeviceToken: removed.has('DeviceToken'),
|
|
1804
2198
|
withdrawnInvitations: withdrawals.map((withdrawal) => withdrawal.ref.rowKey),
|
|
1805
2199
|
};
|
|
1806
|
-
});
|
|
2200
|
+
}, LOST_UPDATE_RETRY_POLICY);
|
|
1807
2201
|
}
|
|
1808
2202
|
/**
|
|
1809
2203
|
* The guarded removals {@link removeMachine} runs, signed over each row's current stamp:
|
|
1810
|
-
* the `CadrePeer` row
|
|
1811
|
-
*
|
|
1812
|
-
*
|
|
2204
|
+
* the `CadrePeer` row and the `DeviceToken` row, each when present. Inside the locked body
|
|
2205
|
+
* (every read `retry: false`); one `Revocation` scan serves the retired-stamp check for both
|
|
2206
|
+
* tables.
|
|
1813
2207
|
*/
|
|
1814
|
-
async signMachineRemovals(
|
|
2208
|
+
async signMachineRemovals(peerId, signMessage) {
|
|
1815
2209
|
const retiredByTable = new Map();
|
|
1816
2210
|
for (const tombstone of await this.queryRevocations(false)) {
|
|
1817
2211
|
const stamps = retiredByTable.get(tombstone.tableName) ?? new Set();
|
|
@@ -1824,24 +2218,12 @@ export class ControlDatabase {
|
|
|
1824
2218
|
return stampId !== null && !retired(table).has(stampId) ? stampId : null;
|
|
1825
2219
|
};
|
|
1826
2220
|
const removals = [];
|
|
1827
|
-
const
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
if (target.ownerKey !== null) {
|
|
1832
|
-
const ownerStamp = await liveStamp('OwnerKey', target.ownerKey);
|
|
1833
|
-
if (ownerStamp !== null) {
|
|
1834
|
-
const owners = await this.queryOwnerKeyRows(false, retired('OwnerKey'));
|
|
1835
|
-
if (owners.length <= 1) {
|
|
1836
|
-
throw new Error(`Cannot remove owner key ${target.ownerKey}: cannot remove the last owner`);
|
|
1837
|
-
}
|
|
1838
|
-
removals.push(signGuardedRemoval(this.config.partyId, 'OwnerKey', target.ownerKey, ownerStamp, signMessage));
|
|
2221
|
+
for (const table of ['CadrePeer', 'DeviceToken']) {
|
|
2222
|
+
const stampId = await liveStamp(table, peerId);
|
|
2223
|
+
if (stampId !== null) {
|
|
2224
|
+
removals.push(await signGuardedRemoval(this.config.partyId, table, peerId, stampId, signMessage));
|
|
1839
2225
|
}
|
|
1840
2226
|
}
|
|
1841
|
-
const tokenStamp = await liveStamp('DeviceToken', target.peerId);
|
|
1842
|
-
if (tokenStamp !== null) {
|
|
1843
|
-
removals.push(signGuardedRemoval(this.config.partyId, 'DeviceToken', target.peerId, tokenStamp, signMessage));
|
|
1844
|
-
}
|
|
1845
2227
|
return removals;
|
|
1846
2228
|
}
|
|
1847
2229
|
/**
|
|
@@ -1866,23 +2248,70 @@ export class ControlDatabase {
|
|
|
1866
2248
|
async insertStrand(strandId, type, ownerKey, signMessage, memberPrivateKey) {
|
|
1867
2249
|
this.ensureInitialized();
|
|
1868
2250
|
log('Inserting strand: %s (type: %s)', strandId, type);
|
|
2251
|
+
const insert = await this.signStrandInsert(strandId, type, ownerKey, signMessage, memberPrivateKey);
|
|
2252
|
+
await this.execWrite(insert.sql, insert.params, 'strand-insert');
|
|
2253
|
+
log('Strand inserted: %s', strandId);
|
|
2254
|
+
}
|
|
2255
|
+
/** The stamped, owner-signed `Strand` insert that {@link insertStrand} and {@link insertFoundingRows} run. */
|
|
2256
|
+
async signStrandInsert(strandId, type, ownerKey, signMessage, memberPrivateKey) {
|
|
1869
2257
|
// Generate a unique stamp ID using the peer ID for distributed uniqueness
|
|
1870
2258
|
const peerId = this.config.libp2pNode.peerId.toString();
|
|
1871
2259
|
const stampId = generateStampId(peerId);
|
|
1872
2260
|
// Field order MUST match the schema's Strand `AuthorizedInsert` verify:
|
|
1873
|
-
// Id, Type, MemberPrivateKey ('' when null), StampId. FounderOwnerKey is
|
|
2261
|
+
// Id, Type, MemberPrivateKey ('' when null), FounderPeerId, StampId. FounderOwnerKey is
|
|
1874
2262
|
// deliberately NOT in the digest — the schema binds it by equality to the
|
|
1875
2263
|
// verified context.OwnerKey instead (see the constraint's comment).
|
|
1876
|
-
const message = buildAuthorizationMessage('CadreControl.Strand', 'add', this.config.partyId, [strandId, type, memberPrivateKey ?? '', stampId]);
|
|
1877
|
-
const signature = signMessage(message);
|
|
2264
|
+
const message = buildAuthorizationMessage('CadreControl.Strand', 'add', this.config.partyId, [strandId, type, memberPrivateKey ?? '', peerId, stampId]);
|
|
2265
|
+
const signature = await signMessage(message);
|
|
1878
2266
|
// StampId is a real, unique column (single-use anti-replay), no longer a context value.
|
|
1879
2267
|
// FounderOwnerKey must equal the signing owner or the constraint rejects the row.
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
2268
|
+
return {
|
|
2269
|
+
sql: `
|
|
2270
|
+
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey, FounderPeerId)
|
|
2271
|
+
with context OwnerKey = ?, Signature = ?
|
|
2272
|
+
values (?, ?, ?, ?, ?, ?)
|
|
2273
|
+
`,
|
|
2274
|
+
params: [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId, ownerKey, peerId],
|
|
2275
|
+
};
|
|
2276
|
+
}
|
|
2277
|
+
/**
|
|
2278
|
+
* Insert one founding's control rows — the `Strand` row, a closed strand's `StrandPartyKey`
|
|
2279
|
+
* row, and a formation invitation bound to the strand — in ONE transaction: one commit, and
|
|
2280
|
+
* never a strand row without the key or invitation written beside it.
|
|
2281
|
+
*
|
|
2282
|
+
* Each row is stamped and signed exactly as its single-row writer ({@link insertStrand},
|
|
2283
|
+
* {@link insertStrandPartyKey}, {@link insertFormationInvite}) does it, and all of that — the
|
|
2284
|
+
* invitation's {@link canonicalDatetime} read included — happens BEFORE the write lock is
|
|
2285
|
+
* taken: signing may prompt the user, and the lock held across a prompt would stall every
|
|
2286
|
+
* other control write. A {@link lockedWithRetry} re-run re-presents the same signed rows.
|
|
2287
|
+
*
|
|
2288
|
+
* No row's `AuthorizedInsert` reads another of these rows (`StrandPartyKey` and
|
|
2289
|
+
* `FormationInvite` check only `OwnerKey` and `Revocation`), so their order is free; the
|
|
2290
|
+
* `Strand` row goes first, so a founding repeated over a stored row is refused on `Strand.Id`.
|
|
2291
|
+
*
|
|
2292
|
+
* A uniqueness rejection rolls the whole transaction back. The caller resolves it and writes
|
|
2293
|
+
* again: a `Strand.Id` conflict is another founder's row, a `StrandPartyKey.Id` conflict a key
|
|
2294
|
+
* stored with no strand row ({@link isStrandIdConflict}; see `CadreNode.publishStrand`).
|
|
2295
|
+
*/
|
|
2296
|
+
async insertFoundingRows(rows, ownerKey, signMessage) {
|
|
2297
|
+
this.ensureInitialized();
|
|
2298
|
+
const { strand, partyKey, formationInvite } = rows;
|
|
2299
|
+
log('Inserting founding rows: strand %s (type: %s, party key: %s, invitation: %s)', strand.id, strand.type, partyKey !== undefined, formationInvite !== undefined);
|
|
2300
|
+
const inserts = [await this.signStrandInsert(strand.id, strand.type, ownerKey, signMessage, strand.memberPrivateKey)];
|
|
2301
|
+
if (partyKey !== undefined) {
|
|
2302
|
+
inserts.push(await this.signStrandPartyKeyInsert(strand.id, partyKey, ownerKey, signMessage));
|
|
2303
|
+
}
|
|
2304
|
+
if (formationInvite) {
|
|
2305
|
+
const { token, sAppId, ...options } = formationInvite;
|
|
2306
|
+
inserts.push(await this.signFormationInviteInsert(token, sAppId, ownerKey, signMessage, { ...options, strandId: strand.id }));
|
|
2307
|
+
}
|
|
2308
|
+
// Bare `exec`: the lock is held, and `execWrite` would take it again and self-deadlock.
|
|
2309
|
+
await this.lockedWithRetry(() => this.inTransaction('found strand', async () => {
|
|
2310
|
+
for (const insert of inserts) {
|
|
2311
|
+
await this.db.exec(insert.sql, insert.params);
|
|
2312
|
+
}
|
|
2313
|
+
}), {}, 'strand-found');
|
|
2314
|
+
log('Founding rows inserted: strand %s', strand.id);
|
|
1886
2315
|
}
|
|
1887
2316
|
/**
|
|
1888
2317
|
* Delete a strand from the control database using an owner signature.
|
|
@@ -1935,10 +2364,10 @@ export class ControlDatabase {
|
|
|
1935
2364
|
return false;
|
|
1936
2365
|
}
|
|
1937
2366
|
const partyKeyStamp = await this.queryStampId('StrandPartyKey', strandId, false);
|
|
1938
|
-
const strandRemoval = signGuardedRemoval(this.config.partyId, 'Strand', strandId, strandStamp, signMessage);
|
|
2367
|
+
const strandRemoval = await signGuardedRemoval(this.config.partyId, 'Strand', strandId, strandStamp, signMessage);
|
|
1939
2368
|
const partyKeyRemoval = partyKeyStamp === null
|
|
1940
2369
|
? null
|
|
1941
|
-
: signGuardedRemoval(this.config.partyId, 'StrandPartyKey', strandId, partyKeyStamp, signMessage);
|
|
2370
|
+
: await signGuardedRemoval(this.config.partyId, 'StrandPartyKey', strandId, partyKeyStamp, signMessage);
|
|
1942
2371
|
await this.inTransaction('delete Strand', async () => {
|
|
1943
2372
|
await this.execGuardedRemoval(strandRemoval, ownerKey);
|
|
1944
2373
|
if (partyKeyRemoval !== null) {
|
|
@@ -1971,18 +2400,25 @@ export class ControlDatabase {
|
|
|
1971
2400
|
async insertStrandPartyKey(strandId, privateKey, ownerKey, signMessage) {
|
|
1972
2401
|
this.ensureInitialized();
|
|
1973
2402
|
log('Inserting strand party key for strand: %s', strandId);
|
|
1974
|
-
const
|
|
1975
|
-
|
|
2403
|
+
const insert = await this.signStrandPartyKeyInsert(strandId, privateKey, ownerKey, signMessage);
|
|
2404
|
+
await this.execWrite(insert.sql, insert.params, 'strand-party-key-insert');
|
|
2405
|
+
log('Strand party key inserted for strand: %s', strandId);
|
|
2406
|
+
}
|
|
2407
|
+
/** The stamped, owner-signed `StrandPartyKey` insert that {@link insertStrandPartyKey} and {@link insertFoundingRows} run. */
|
|
2408
|
+
async signStrandPartyKeyInsert(strandId, privateKey, ownerKey, signMessage) {
|
|
2409
|
+
const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
|
|
1976
2410
|
// Field order MUST match the schema's StrandPartyKey `AuthorizedInsert` verify:
|
|
1977
2411
|
// Id, PrivateKey, StampId.
|
|
1978
2412
|
const message = buildAuthorizationMessage('CadreControl.StrandPartyKey', 'add', this.config.partyId, [strandId, privateKey, stampId]);
|
|
1979
|
-
const signature = signMessage(message);
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
2413
|
+
const signature = await signMessage(message);
|
|
2414
|
+
return {
|
|
2415
|
+
sql: `
|
|
2416
|
+
insert into CadreControl.StrandPartyKey (Id, PrivateKey, StampId)
|
|
2417
|
+
with context OwnerKey = ?, Signature = ?
|
|
2418
|
+
values (?, ?, ?)
|
|
2419
|
+
`,
|
|
2420
|
+
params: [ownerKey, signature, strandId, privateKey, stampId],
|
|
2421
|
+
};
|
|
1986
2422
|
}
|
|
1987
2423
|
/**
|
|
1988
2424
|
* Owner-signed removal of one `StrandPartyKey` row — the party's own membership
|
|
@@ -2018,7 +2454,7 @@ export class ControlDatabase {
|
|
|
2018
2454
|
// Field order MUST match the schema's JoinedStrand `AuthorizedInsert` verify:
|
|
2019
2455
|
// Id, Type, MemberPrivateKey ('' when null), StampId.
|
|
2020
2456
|
const message = buildAuthorizationMessage('CadreControl.JoinedStrand', 'add', this.config.partyId, [row.Id, row.Type, row.MemberPrivateKey ?? '', stampId]);
|
|
2021
|
-
const signature = signMessage(message);
|
|
2457
|
+
const signature = await signMessage(message);
|
|
2022
2458
|
await this.execWrite(`
|
|
2023
2459
|
insert into CadreControl.JoinedStrand (Id, Type, MemberPrivateKey, StampId)
|
|
2024
2460
|
with context OwnerKey = ?, Signature = ?
|
|
@@ -2044,7 +2480,7 @@ export class ControlDatabase {
|
|
|
2044
2480
|
*/
|
|
2045
2481
|
async insertJoinRequest(request, ownerKey, signMessage) {
|
|
2046
2482
|
this.ensureInitialized();
|
|
2047
|
-
const { written, insert } = this.signJoinRequestInsert(request, ownerKey, signMessage);
|
|
2483
|
+
const { written, insert } = await this.signJoinRequestInsert(request, ownerKey, signMessage);
|
|
2048
2484
|
await this.execWrite(insert.sql, insert.params, 'join-request-insert');
|
|
2049
2485
|
log('Join request inserted: %s', request.Id);
|
|
2050
2486
|
return { ...written, outcome: null };
|
|
@@ -2064,7 +2500,7 @@ export class ControlDatabase {
|
|
|
2064
2500
|
*/
|
|
2065
2501
|
async recordJoinOutcome(expected, outcome, ownerKey, signMessage) {
|
|
2066
2502
|
this.ensureInitialized();
|
|
2067
|
-
const insert = signJoinOutcomeInsert(this.config.partyId, expected.StampId, outcome, ownerKey, signMessage);
|
|
2503
|
+
const insert = await signJoinOutcomeInsert(this.config.partyId, expected.StampId, outcome, ownerKey, signMessage);
|
|
2068
2504
|
return this.lockedWithRetry(async () => {
|
|
2069
2505
|
const live = await this.readPendingJoin(expected.Id, false);
|
|
2070
2506
|
if (live === null || live.StampId !== expected.StampId || live.outcome?.kind !== expected.outcome?.kind) {
|
|
@@ -2097,9 +2533,9 @@ export class ControlDatabase {
|
|
|
2097
2533
|
async rewritePendingJoin(expectedStampId, next, ownerKey, signMessage) {
|
|
2098
2534
|
this.ensureInitialized();
|
|
2099
2535
|
const { outcome, ...request } = next;
|
|
2100
|
-
const removal = signGuardedRemoval(this.config.partyId, 'JoinRequest', next.Id, expectedStampId, signMessage);
|
|
2101
|
-
const { written, insert } = this.signJoinRequestInsert(request, ownerKey, signMessage);
|
|
2102
|
-
const outcomeInsert = outcome === null ? null : signJoinOutcomeInsert(this.config.partyId, written.StampId, outcome, ownerKey, signMessage);
|
|
2536
|
+
const removal = await signGuardedRemoval(this.config.partyId, 'JoinRequest', next.Id, expectedStampId, signMessage);
|
|
2537
|
+
const { written, insert } = await this.signJoinRequestInsert(request, ownerKey, signMessage);
|
|
2538
|
+
const outcomeInsert = outcome === null ? null : await signJoinOutcomeInsert(this.config.partyId, written.StampId, outcome, ownerKey, signMessage);
|
|
2103
2539
|
return this.lockedWithRetry(async () => {
|
|
2104
2540
|
// retry: false — this runs inside the locked write body (see queryStampId's NOTE).
|
|
2105
2541
|
const liveStampId = await this.queryStampId('JoinRequest', next.Id, false);
|
|
@@ -2132,9 +2568,9 @@ export class ControlDatabase {
|
|
|
2132
2568
|
* Mint a fresh stamp for `request` and the owner signature `JoinRequest.AuthorizedInsert`
|
|
2133
2569
|
* verifies, returning the row as it will be written and its insert.
|
|
2134
2570
|
*/
|
|
2135
|
-
signJoinRequestInsert(request, ownerKey, signMessage) {
|
|
2571
|
+
async signJoinRequestInsert(request, ownerKey, signMessage) {
|
|
2136
2572
|
const written = { ...request, StampId: generateStampId(this.config.libp2pNode.peerId.toString()) };
|
|
2137
|
-
const signature = signMessage(buildAuthorizationMessage('CadreControl.JoinRequest', 'add', this.config.partyId, joinRequestAddFields(written)));
|
|
2573
|
+
const signature = await signMessage(buildAuthorizationMessage('CadreControl.JoinRequest', 'add', this.config.partyId, joinRequestAddFields(written)));
|
|
2138
2574
|
const params = [ownerKey, signature, written.Id, written.Invitation, written.Disclosure, written.RequestedAt, written.ExpiresAt, written.StampId];
|
|
2139
2575
|
return { written, insert: { sql: JOIN_REQUEST_INSERT_SQL, params } };
|
|
2140
2576
|
}
|
|
@@ -2175,7 +2611,7 @@ export class ControlDatabase {
|
|
|
2175
2611
|
const stampId = generateStampId(peerId);
|
|
2176
2612
|
// Field order MUST match the schema's ValidationKey `AuthorizedInsert` verify: Key, StampId.
|
|
2177
2613
|
const message = buildAuthorizationMessage('CadreControl.ValidationKey', 'add', this.config.partyId, [key, stampId]);
|
|
2178
|
-
const signature = signMessage(message);
|
|
2614
|
+
const signature = await signMessage(message);
|
|
2179
2615
|
await this.execWrite(`
|
|
2180
2616
|
insert into CadreControl.ValidationKey (Key, StampId)
|
|
2181
2617
|
with context OwnerKey = ?, Signature = ?
|
|
@@ -2244,7 +2680,7 @@ export class ControlDatabase {
|
|
|
2244
2680
|
// Stamp is minted from the ADMITTED peer's id, not this node's — the nonce names the
|
|
2245
2681
|
// row it vouches.
|
|
2246
2682
|
const stampId = generateStampId(row.peerId);
|
|
2247
|
-
const signature = signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [row.peerId, stampId]));
|
|
2683
|
+
const signature = await signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [row.peerId, stampId]));
|
|
2248
2684
|
return await this.mutateCadrePeer('peer-insert', async () => {
|
|
2249
2685
|
// retry: false — this guard runs inside the locked write body (see queryStampId's NOTE).
|
|
2250
2686
|
if (await this.queryStampId('CadrePeer', row.peerId, false) !== null) {
|
|
@@ -2266,32 +2702,44 @@ export class ControlDatabase {
|
|
|
2266
2702
|
});
|
|
2267
2703
|
}
|
|
2268
2704
|
/**
|
|
2269
|
-
* Owner "re-touch" of an existing `CadrePeer` membership row:
|
|
2270
|
-
*
|
|
2271
|
-
*
|
|
2272
|
-
* the row is re-emitted as a fresh, broadcasting
|
|
2273
|
-
*
|
|
2274
|
-
*
|
|
2275
|
-
*
|
|
2276
|
-
*
|
|
2277
|
-
*
|
|
2278
|
-
*
|
|
2279
|
-
*
|
|
2280
|
-
*
|
|
2281
|
-
*
|
|
2282
|
-
* `
|
|
2283
|
-
*
|
|
2284
|
-
*
|
|
2285
|
-
*
|
|
2286
|
-
*
|
|
2287
|
-
*
|
|
2288
|
-
*
|
|
2289
|
-
*
|
|
2290
|
-
*
|
|
2291
|
-
*
|
|
2292
|
-
*
|
|
2293
|
-
*
|
|
2294
|
-
*
|
|
2705
|
+
* Owner "re-touch" of an existing `CadrePeer` membership row: rewrite `VouchOwner`/`VouchSig`
|
|
2706
|
+
* (and, re-broadcasting, bump `UpdatedAt`) under the owner branch of
|
|
2707
|
+
* `CadrePeer.AuthorizedUpdate` — a signature over the SAME `'vouch'`-tagged digest
|
|
2708
|
+
* {@link insertCadrePeer} builds — so the row is re-emitted as a fresh, broadcasting
|
|
2709
|
+
* transaction, or stands on a different owner's voucher.
|
|
2710
|
+
*
|
|
2711
|
+
* Re-broadcasting is the write-while-alone re-replication primitive: a membership row that
|
|
2712
|
+
* committed local-only (its block's cluster ≤1 at insert) is pushed to the cohort once it
|
|
2713
|
+
* grows, by re-issuing this monotonic bump. It is an UPDATE (not the original INSERT) because
|
|
2714
|
+
* the row already exists locally; a re-INSERT would hit the `PeerId` PK. Only the freshness
|
|
2715
|
+
* stamp and voucher change — `PublicKey` / `Multiaddr` / `Sig` / `Profile` are left intact
|
|
2716
|
+
* (the owner branch requires them kept: they are the machine's own claim) — so it is safe
|
|
2717
|
+
* over a row whose peer has not self-published (`Sig` null), and this method itself skips a
|
|
2718
|
+
* row that already carries a self-`Sig` (that row is the owning peer's to refresh, and
|
|
2719
|
+
* bumping `UpdatedAt` without re-signing would invalidate its self-signature).
|
|
2720
|
+
*
|
|
2721
|
+
* The two modes ({@link CadrePeerRetouchMode}) differ in what they write: `'rebroadcast'`
|
|
2722
|
+
* bumps `UpdatedAt` to `max(now, current + 1)` from the row read inside the body and writes
|
|
2723
|
+
* only while the row's `Sig` is null; `'revouch'` leaves `UpdatedAt` out of the `set` list and
|
|
2724
|
+
* writes whether or not the machine has self-published (the owner branch has no monotonic
|
|
2725
|
+
* rule, so the column simply keeps its value).
|
|
2726
|
+
*
|
|
2727
|
+
* The row read (stamp, `Sig`, `UpdatedAt`), the mode's decision and the signature over the
|
|
2728
|
+
* row's CURRENT `StampId` (unchanged by the re-touch) all run INSIDE the locked body, which is
|
|
2729
|
+
* what lets both modes opt into {@link LOST_UPDATE_RETRY_POLICY}: the row's other writer is
|
|
2730
|
+
* the machine's own publish ({@link updateSelfPeerRecord}), and the drain's re-broadcast fires
|
|
2731
|
+
* at the moment that publish does. A re-run after losing to it reads the self-signed row and
|
|
2732
|
+
* reports `'self-published'` instead of writing the owner branch over it — which would rewrite
|
|
2733
|
+
* `UpdatedAt` under a `Sig` signed over the old value and make the record unverifiable to
|
|
2734
|
+
* resolvers until the machine's next heartbeat. The decision has to be the body's for that;
|
|
2735
|
+
* a caller's pre-check outside the lock is only a fast path (see below).
|
|
2736
|
+
*
|
|
2737
|
+
* An absent row — none, or its stamp retired, or a stampless row no writer produces — is
|
|
2738
|
+
* `'absent'`. Checked once outside the lock too, as `'self-published'` is: the common case
|
|
2739
|
+
* writes nothing, takes no lock and does NOT notify, so an absent target stays quiet
|
|
2740
|
+
* ({@link SeedBootstrapService.removePeer} keeps the same gate for the same reason). The
|
|
2741
|
+
* in-body re-check covers the race; a no-write outcome reached there notifies spuriously,
|
|
2742
|
+
* which only re-reads the member set.
|
|
2295
2743
|
*
|
|
2296
2744
|
* Notifies like the insert/remove paths even though this is "only" a re-touch: it rewrites
|
|
2297
2745
|
* VouchOwner/VouchSig, which the authorized-membership predicate judges on, so it CAN
|
|
@@ -2305,38 +2753,55 @@ export class ControlDatabase {
|
|
|
2305
2753
|
*
|
|
2306
2754
|
* NOTE: this rebinds VouchOwner to the CALLING owner's key, and the authorized-membership
|
|
2307
2755
|
* predicate (`CadreNode.listAuthorizedMembers`) now judges rows by that column against each
|
|
2308
|
-
* reader's node-local anchor.
|
|
2309
|
-
*
|
|
2310
|
-
*
|
|
2311
|
-
*
|
|
2312
|
-
* anchor the original owner but not this one would
|
|
2313
|
-
* must re-vouch deliberately (or preserve the existing
|
|
2314
|
-
* inherit this rebinding.
|
|
2756
|
+
* reader's node-local anchor. The two callers rebind on purpose: the write-while-alone drain
|
|
2757
|
+
* re-touches solely rows this node itself authored (`pendingPeerWrites`), so the voucher is
|
|
2758
|
+
* rewritten to the key that already signed it, and `CadreNode.removeOwner` moves rows off a
|
|
2759
|
+
* key it is about to remove. A new caller that re-touches a row a DIFFERENT owner vouched
|
|
2760
|
+
* flips the voucher silently: readers that anchor the original owner but not this one would
|
|
2761
|
+
* drop a legitimate member. It must re-vouch deliberately (or preserve the existing
|
|
2762
|
+
* VouchOwner/VouchSig) rather than inherit this rebinding.
|
|
2315
2763
|
*
|
|
2316
2764
|
* @param peerId - the membership row to re-touch.
|
|
2317
|
-
* @param
|
|
2765
|
+
* @param mode - re-broadcast or re-vouch ({@link CadrePeerRetouchMode}).
|
|
2318
2766
|
* @param ownerKey - public key of the re-vouching owner, rewritten into `VouchOwner`.
|
|
2319
|
-
* @param signMessage - ed25519-signs the raw message bytes with that owner's private key
|
|
2320
|
-
*
|
|
2767
|
+
* @param signMessage - ed25519-signs the raw message bytes with that owner's private key;
|
|
2768
|
+
* called inside the lock, once per attempt (the NOTE on {@link ControlSign}).
|
|
2769
|
+
* @returns what was found and done ({@link CadrePeerRetouchOutcome}).
|
|
2321
2770
|
*/
|
|
2322
|
-
async reauthorizeCadrePeer(peerId,
|
|
2771
|
+
async reauthorizeCadrePeer(peerId, mode, ownerKey, signMessage) {
|
|
2323
2772
|
this.ensureInitialized();
|
|
2324
|
-
const
|
|
2325
|
-
if (
|
|
2326
|
-
log('reauthorizeCadrePeer:
|
|
2327
|
-
return
|
|
2773
|
+
const outside = retouchTarget(await this.findCadrePeerRow(peerId, true), mode);
|
|
2774
|
+
if (typeof outside === 'string') {
|
|
2775
|
+
log('reauthorizeCadrePeer(%s): %s for %s (nothing to write)', mode, outside, peerId);
|
|
2776
|
+
return outside;
|
|
2328
2777
|
}
|
|
2329
|
-
|
|
2330
|
-
|
|
2778
|
+
return await this.mutateCadrePeer('peer-reauthorize', async () => {
|
|
2779
|
+
// retry: false — inside the locked write body (see queryStampId's NOTE).
|
|
2780
|
+
const target = retouchTarget(await this.findCadrePeerRow(peerId, false), mode);
|
|
2781
|
+
if (typeof target === 'string') {
|
|
2782
|
+
log('reauthorizeCadrePeer(%s): %s for %s since the pre-check (nothing to write)', mode, target, peerId);
|
|
2783
|
+
return target;
|
|
2784
|
+
}
|
|
2785
|
+
const signature = await signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [peerId, target.stampId]));
|
|
2331
2786
|
// Bare `exec` — inside the non-re-entrant write lock (see insertCadrePeer).
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2787
|
+
if (mode === 'rebroadcast') {
|
|
2788
|
+
await this.db.exec(`
|
|
2789
|
+
update CadreControl.CadrePeer
|
|
2790
|
+
with context OwnerKey = ?, Signature = ?
|
|
2791
|
+
set UpdatedAt = ?, VouchOwner = ?, VouchSig = ?, VouchUsage = null
|
|
2792
|
+
where PeerId = ?
|
|
2793
|
+
`, [ownerKey, signature, Math.max(Date.now(), target.updatedAt + 1), ownerKey, signature, peerId]);
|
|
2794
|
+
}
|
|
2795
|
+
else {
|
|
2796
|
+
await this.db.exec(`
|
|
2797
|
+
update CadreControl.CadrePeer
|
|
2798
|
+
with context OwnerKey = ?, Signature = ?
|
|
2799
|
+
set VouchOwner = ?, VouchSig = ?, VouchUsage = null
|
|
2800
|
+
where PeerId = ?
|
|
2801
|
+
`, [ownerKey, signature, ownerKey, signature, peerId]);
|
|
2802
|
+
}
|
|
2803
|
+
return 'retouched';
|
|
2804
|
+
}, LOST_UPDATE_RETRY_POLICY);
|
|
2340
2805
|
}
|
|
2341
2806
|
/**
|
|
2342
2807
|
* Owner-signed removal of one `CadrePeer` membership row, notifying the membership
|
|
@@ -2357,9 +2822,15 @@ export class ControlDatabase {
|
|
|
2357
2822
|
*
|
|
2358
2823
|
* A no-op (no throw, no tombstone) when the row does not exist — `false` then, `true`
|
|
2359
2824
|
* when a row was actually removed.
|
|
2825
|
+
*
|
|
2826
|
+
* Opted into {@link LOST_UPDATE_RETRY_POLICY}: the row's machine may publish its own record
|
|
2827
|
+
* at the moment of the removal (its first publish lands about a second after it starts), and
|
|
2828
|
+
* the removal that loses is refused as a lost update. {@link deleteGuardedRow} reads the stamp
|
|
2829
|
+
* and signs inside the body, and a self-publish never changes the stamp the `'remove'` digest
|
|
2830
|
+
* binds, so the re-run reads the published row and removes it.
|
|
2360
2831
|
*/
|
|
2361
2832
|
deleteCadrePeer(peerId, ownerKey, signMessage) {
|
|
2362
|
-
return this.mutateCadrePeer('peer-remove', () => this.deleteGuardedRow('CadrePeer', peerId, ownerKey, signMessage));
|
|
2833
|
+
return this.mutateCadrePeer('peer-remove', () => this.deleteGuardedRow('CadrePeer', peerId, ownerKey, signMessage), LOST_UPDATE_RETRY_POLICY);
|
|
2363
2834
|
}
|
|
2364
2835
|
/**
|
|
2365
2836
|
* Owner-signed removal of one peer's `DeviceToken` row (logout / token invalidation).
|
|
@@ -2412,7 +2883,7 @@ export class ControlDatabase {
|
|
|
2412
2883
|
log('delete %s: no row for %s (already absent)', table, keyValue);
|
|
2413
2884
|
return false;
|
|
2414
2885
|
}
|
|
2415
|
-
const removal = signGuardedRemoval(this.config.partyId, table, keyValue, stampId, signMessage);
|
|
2886
|
+
const removal = await signGuardedRemoval(this.config.partyId, table, keyValue, stampId, signMessage);
|
|
2416
2887
|
await this.inTransaction(`delete ${table}`, async () => {
|
|
2417
2888
|
await alongside?.(stampId);
|
|
2418
2889
|
await this.execGuardedRemoval(removal, ownerKey);
|
|
@@ -2445,10 +2916,9 @@ export class ControlDatabase {
|
|
|
2445
2916
|
}
|
|
2446
2917
|
/**
|
|
2447
2918
|
* File the owner-signed `Revocation` tombstone retiring one row incarnation — the second
|
|
2448
|
-
* statement of every guarded removal ({@link execGuardedRemoval}) and the whole of
|
|
2449
|
-
*
|
|
2450
|
-
*
|
|
2451
|
-
* lock and, where one is needed, the transaction.
|
|
2919
|
+
* statement of every guarded removal ({@link execGuardedRemoval}) and the whole of an
|
|
2920
|
+
* issued row's withdrawal ({@link withdrawIssuedRow}), whose row stays. The caller
|
|
2921
|
+
* supplies the lock and, where one is needed, the transaction.
|
|
2452
2922
|
*
|
|
2453
2923
|
* `ReissuedAt` is named explicitly at 0 (the only value `FreshTombstone` accepts) rather
|
|
2454
2924
|
* than leaning on the column default — the seat-at-zero rule is load-bearing for
|
|
@@ -2502,9 +2972,10 @@ export class ControlDatabase {
|
|
|
2502
2972
|
* statement, and without the clause the reap would delete the owner's brand-new row.
|
|
2503
2973
|
* With it the statement matches nothing and the reap is a silent no-op (the schema
|
|
2504
2974
|
* branch would also refuse that delete, but as a constraint error thrown into a
|
|
2505
|
-
* background sweep — matched-nothing is the better failure mode).
|
|
2506
|
-
* {@link reauthorizeCadrePeer},
|
|
2507
|
-
* harmless — nothing was deleted, and the next sweep
|
|
2975
|
+
* background sweep — matched-nothing is the better failure mode). A race lost AFTER the
|
|
2976
|
+
* guard still returns `true` here (unlike {@link reauthorizeCadrePeer}, which reads inside
|
|
2977
|
+
* its lock and reports what it found); harmless — nothing was deleted, and the next sweep
|
|
2978
|
+
* re-reads.
|
|
2508
2979
|
*
|
|
2509
2980
|
* Writes NOTHING to `Revocation`: `RevocationRecorded` is satisfied by the same
|
|
2510
2981
|
* committed tombstone that authorizes the reap, so a reap files no second tombstone.
|
|
@@ -2531,7 +3002,7 @@ export class ControlDatabase {
|
|
|
2531
3002
|
* present clause are the shape {@link redeemInvitation}'s consent-branch insert ships).
|
|
2532
3003
|
*
|
|
2533
3004
|
* Driven automatically by {@link reapRevokedRows}, which the periodic control-cohort
|
|
2534
|
-
* reconcile pass runs while
|
|
3005
|
+
* reconcile pass runs while a sibling is reachable (`CadreNode.hasReachableControlSibling`).
|
|
2535
3006
|
*/
|
|
2536
3007
|
async reapRevokedRow(table, rowKey, stampId) {
|
|
2537
3008
|
this.ensureInitialized();
|
|
@@ -2589,8 +3060,8 @@ export class ControlDatabase {
|
|
|
2589
3060
|
* carry the delete (replaying a delete of an already-absent row is a no-op), so a node
|
|
2590
3061
|
* that converged on a removal while holding the removed row keeps that row as inert
|
|
2591
3062
|
* garbage until something walks the tombstones and drops it. `CadreNode`'s periodic
|
|
2592
|
-
* control-cohort reconcile pass is that something, gated on
|
|
2593
|
-
*
|
|
3063
|
+
* control-cohort reconcile pass is that something, gated on a sibling being reachable
|
|
3064
|
+
* (a reap is a write, and a write committed alone is local-only).
|
|
2594
3065
|
*
|
|
2595
3066
|
* Enumerated UNLOCKED, deleted per-row LOCKED: {@link reapRevokedRow} takes the write
|
|
2596
3067
|
* lock itself (via {@link mutateCadrePeer} / {@link execWrite}) and the lock is not
|
|
@@ -2623,12 +3094,11 @@ export class ControlDatabase {
|
|
|
2623
3094
|
*
|
|
2624
3095
|
* NOTE: cost is O(tombstones), not O(live rows) — one {@link queryRevocations} scan plus
|
|
2625
3096
|
* one {@link queryStampId} point lookup per tombstone, and the empty early return makes
|
|
2626
|
-
* the common case (a party that has never revoked anyone
|
|
2627
|
-
*
|
|
2628
|
-
*
|
|
2629
|
-
* ({@link openRevocationLedger}), filed by the same connected pass, makes it a held block.
|
|
3097
|
+
* the common case (a party that has never revoked anyone) a single scan per pass, served
|
|
3098
|
+
* from the `Revocation` membership view
|
|
3099
|
+
* ({@link readMembershipRows}) unless the table changed since the last read.
|
|
2630
3100
|
* But `Revocation` is append-only and unbounded, so this is O(all tombstones ever) point
|
|
2631
|
-
* lookups on every reconcile
|
|
3101
|
+
* lookups on every connected reconcile pass. Fine while revocations stay rare for a cadre-sized
|
|
2632
3102
|
* party; if the table ever grows, persist a node-local high-water mark of what has
|
|
2633
3103
|
* already been reaped instead of re-scanning everything (the same bound the sweep in
|
|
2634
3104
|
* `CadreNode.drainPendingRevocations` wants).
|
|
@@ -2692,17 +3162,20 @@ export class ControlDatabase {
|
|
|
2692
3162
|
*
|
|
2693
3163
|
* Returns how many UPDATE statements ran (`rows.length` on success).
|
|
2694
3164
|
*/
|
|
2695
|
-
reissueRevocations(rows, reissuedAt, ownerKey, signMessage) {
|
|
3165
|
+
async reissueRevocations(rows, reissuedAt, ownerKey, signMessage) {
|
|
2696
3166
|
this.ensureInitialized();
|
|
2697
3167
|
// Signatures minted OUTSIDE the locked body: a retried attempt must re-present the
|
|
2698
3168
|
// exact signed message, never re-mint (the contract on withWriteLock). ReissuedAt
|
|
2699
3169
|
// rides in the digest as a string — SQL-side it is cast(new.ReissuedAt as text),
|
|
2700
3170
|
// and every digest field is TEXT on both sides (see buildAuthorizationMessage).
|
|
2701
|
-
const signed =
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
3171
|
+
const signed = [];
|
|
3172
|
+
for (const row of rows) {
|
|
3173
|
+
signed.push({
|
|
3174
|
+
row,
|
|
3175
|
+
signature: await signMessage(buildAuthorizationMessage('CadreControl.Revocation', 'reissue', this.config.partyId, [row.tableName, row.rowKey, row.stampId, String(reissuedAt)])),
|
|
3176
|
+
});
|
|
3177
|
+
}
|
|
3178
|
+
return await this.lockedWithRetry(async () => {
|
|
2706
3179
|
// Counter lives INSIDE the locked body so a lockedWithRetry re-run (which
|
|
2707
3180
|
// re-executes the whole body) starts from zero instead of double-counting.
|
|
2708
3181
|
let executed = 0;
|
|
@@ -2733,69 +3206,6 @@ export class ControlDatabase {
|
|
|
2733
3206
|
return executed;
|
|
2734
3207
|
}, {}, 'revocation-reissue');
|
|
2735
3208
|
}
|
|
2736
|
-
/**
|
|
2737
|
-
* Owner-signed filing of the singleton `Revocation` ledger marker
|
|
2738
|
-
* ({@link REVOCATION_LEDGER_MARKER}), the one row in that table that retires nothing.
|
|
2739
|
-
*
|
|
2740
|
-
* Why it exists: while `Revocation` has never been written, this node does not hold its
|
|
2741
|
-
* block, and Optimystic consults a block's cohort on EVERY read of a block it does not
|
|
2742
|
-
* hold — and every membership lookup, and every guarded insert's `NotRevoked` check,
|
|
2743
|
-
* reads this table. Once any row exists the block is held and re-checked at most once per
|
|
2744
|
-
* read-repair window, like every other populated table. The schema's table comment says
|
|
2745
|
-
* why the row can never read as a retirement.
|
|
2746
|
-
*
|
|
2747
|
-
* Insert-if-absent, shaped like {@link reissueRevocations}: the signature is minted
|
|
2748
|
-
* OUTSIDE the locked body so a retried attempt re-presents the same one, and the guard
|
|
2749
|
-
* runs INSIDE it so it sees a concurrent local writer's committed row. The guard scans
|
|
2750
|
-
* the `'Revocation'` rows and compares the stamp in TypeScript rather than seeking the
|
|
2751
|
-
* full composite primary key, which is served as a point lookup that can miss an existing
|
|
2752
|
-
* row on a networked database (tickets/backlog/debt-composite-pk-point-lookup-unreliable-untracked).
|
|
2753
|
-
*
|
|
2754
|
-
* Two owners filing at once: the loser either sees the marker in its guard or is refused
|
|
2755
|
-
* on the primary key, and both answer `'already-open'`.
|
|
2756
|
-
*
|
|
2757
|
-
* The caller decides WHEN. A marker committed while the node is alone is local-only and
|
|
2758
|
-
* can fork the collection, so {@link CadreNode} files it only while connected; this
|
|
2759
|
-
* method does not look at connectivity.
|
|
2760
|
-
*
|
|
2761
|
-
* @param ownerKey - owner public key for the write context (`Revocation.Authorized`).
|
|
2762
|
-
* @param signMessage - ed25519-signs the raw message bytes (no pre-hash) with that owner's
|
|
2763
|
-
* private key, returning a base64url signature.
|
|
2764
|
-
* @returns `'opened'` when this call filed the marker, `'already-open'` when it was
|
|
2765
|
-
* already there.
|
|
2766
|
-
*/
|
|
2767
|
-
async openRevocationLedger(ownerKey, signMessage) {
|
|
2768
|
-
this.ensureInitialized();
|
|
2769
|
-
const signature = signMessage(revocationTombstoneMessage(this.config.partyId, REVOCATION_LEDGER_MARKER));
|
|
2770
|
-
try {
|
|
2771
|
-
return await this.lockedWithRetry(async () => {
|
|
2772
|
-
if (await this.revocationLedgerFiled()) {
|
|
2773
|
-
return 'already-open';
|
|
2774
|
-
}
|
|
2775
|
-
// execTombstone is a bare `exec`: already inside the write lock, which is NOT re-entrant.
|
|
2776
|
-
await this.execTombstone(REVOCATION_LEDGER_MARKER, signature, ownerKey);
|
|
2777
|
-
log('Revocation ledger marker filed');
|
|
2778
|
-
return 'opened';
|
|
2779
|
-
}, {}, 'revocation-ledger-open');
|
|
2780
|
-
}
|
|
2781
|
-
catch (error) {
|
|
2782
|
-
if (!isRevocationLedgerConflict(error)) {
|
|
2783
|
-
throw error;
|
|
2784
|
-
}
|
|
2785
|
-
log('Revocation ledger marker already filed (refused on the primary key, most likely by another owner filing first): %s', error);
|
|
2786
|
-
return 'already-open';
|
|
2787
|
-
}
|
|
2788
|
-
}
|
|
2789
|
-
/**
|
|
2790
|
-
* Whether the ledger marker is present locally: a scan of the `'Revocation'` rows with the
|
|
2791
|
-
* stamp compared in TypeScript (see {@link openRevocationLedger} for why not a seek).
|
|
2792
|
-
* Called only inside the locked write body, so never retried (see queryStampId's NOTE).
|
|
2793
|
-
*/
|
|
2794
|
-
async revocationLedgerFiled() {
|
|
2795
|
-
const { tableName, stampId } = REVOCATION_LEDGER_MARKER;
|
|
2796
|
-
const rows = await this.readRows('select StampId from CadreControl.Revocation where TableName = ?', [tableName], 'revocation-ledger', false);
|
|
2797
|
-
return rows.some(row => row.StampId === stampId);
|
|
2798
|
-
}
|
|
2799
3209
|
/**
|
|
2800
3210
|
* Run `body` between `beginTransaction` and `commit`, rolling back on failure.
|
|
2801
3211
|
*
|
|
@@ -2843,10 +3253,26 @@ export class ControlDatabase {
|
|
|
2843
3253
|
* {@link CadreNode}, which wires this in `start()` and clears it on teardown. A
|
|
2844
3254
|
* second call replaces the first rather than fanning out, so a stale node can
|
|
2845
3255
|
* never keep receiving notifications for a database it no longer owns.
|
|
3256
|
+
*
|
|
3257
|
+
* Only this database's own writes notify here, awaited inside the write so the gate is
|
|
3258
|
+
* current before the write returns. A membership change replicated in from another
|
|
3259
|
+
* machine reaches the gate through the subscription `CadreNode.start` takes on the
|
|
3260
|
+
* membership tables ({@link watchTables}), a coalesced run up to 50 ms after the change
|
|
3261
|
+
* lands in storage; one storage never hears of waits for the reconcile pass's backstop.
|
|
2846
3262
|
*/
|
|
2847
3263
|
setMembershipChangeListener(listener) {
|
|
2848
3264
|
this.membershipListener = listener;
|
|
2849
3265
|
}
|
|
3266
|
+
/**
|
|
3267
|
+
* Subscribe `onChange` to changes in control tables (`'CadreControl.Strand'`, …): commits
|
|
3268
|
+
* made here and changes replicated into this node's storage alike, on the single-flight
|
|
3269
|
+
* schedule `table-change-watch.ts` describes. {@link close} disposes every subscription
|
|
3270
|
+
* still live before it drains the write queue.
|
|
3271
|
+
*/
|
|
3272
|
+
watchTables(tables, onChange, options) {
|
|
3273
|
+
this.ensureInitialized();
|
|
3274
|
+
return this.tableWatches.watch(this.db, tables, onChange, options);
|
|
3275
|
+
}
|
|
2850
3276
|
/**
|
|
2851
3277
|
* Wire (or clear, with null) the single listener notified after every committed
|
|
2852
3278
|
* guarded-table delete. Same ownership contract as
|
|
@@ -2876,8 +3302,9 @@ export class ControlDatabase {
|
|
|
2876
3302
|
* Covers every write that can change who is a member or an owner: EVERY `CadrePeer`
|
|
2877
3303
|
* writer, with one documented exception ({@link updateSelfPeerRecord}, which cannot
|
|
2878
3304
|
* change the snapshot), the `OwnerKey` writers ({@link insertOwnerKeyVouched},
|
|
2879
|
-
* {@link deleteOwnerKey})
|
|
2880
|
-
*
|
|
3305
|
+
* {@link deleteOwnerKey}), the invitation redemption ({@link redeemCadreInvite}), which
|
|
3306
|
+
* seats both, and the owner-grant redemption ({@link redeemOwnerGrant}). That is what
|
|
3307
|
+
* makes the party-membership snapshot refresh automatic
|
|
2881
3308
|
* rather than a caller obligation. The name predates the `OwnerKey` writers. A
|
|
2882
3309
|
* writer necessarily holds the target node's ControlDatabase (the `SeedBootstrapService`'s
|
|
2883
3310
|
* event callbacks are NOT a viable seam: the temp service `CadreNode.applySeed`
|
|
@@ -2904,8 +3331,11 @@ export class ControlDatabase {
|
|
|
2904
3331
|
* exhaustion.
|
|
2905
3332
|
*
|
|
2906
3333
|
* @param reason - Label for the log line only (e.g. `'peer-insert'`).
|
|
3334
|
+
* @param policy - The retry policy, default unless the body has earned a wider one: the three
|
|
3335
|
+
* `CadrePeer` writers whose bodies re-read the row and decide inside pass
|
|
3336
|
+
* `LOST_UPDATE_RETRY_POLICY` (its `NOTE:` names them and the rule).
|
|
2907
3337
|
*/
|
|
2908
|
-
async mutateCadrePeer(reason, body) {
|
|
3338
|
+
async mutateCadrePeer(reason, body, policy = {}) {
|
|
2909
3339
|
this.ensureInitialized();
|
|
2910
3340
|
return this.lockedWithRetry(async () => {
|
|
2911
3341
|
this.assertCommitBoundary(reason, 'on entry to');
|
|
@@ -2913,7 +3343,7 @@ export class ControlDatabase {
|
|
|
2913
3343
|
this.assertCommitBoundary(reason, 'on return from');
|
|
2914
3344
|
await this.notifyMembershipChanged(reason);
|
|
2915
3345
|
return result;
|
|
2916
|
-
},
|
|
3346
|
+
}, policy, reason);
|
|
2917
3347
|
}
|
|
2918
3348
|
/**
|
|
2919
3349
|
* Run one local write under the database-wide write lock, serializing it against
|
|
@@ -2927,8 +3357,8 @@ export class ControlDatabase {
|
|
|
2927
3357
|
* class: every public write method of this class runs its statement(s) under it, and
|
|
2928
3358
|
* `SeedBootstrapService` reaches it through {@link execWrite} rather than touching the
|
|
2929
3359
|
* `Database` directly, so it is covered too. The race is
|
|
2930
|
-
* real, not theoretical: the background self-record publish
|
|
2931
|
-
*
|
|
3360
|
+
* real, not theoretical: the background self-record publish when a sibling becomes
|
|
3361
|
+
* reachable (`CadreNode.drainPendingControlReplication` → `registerSelf`) collided with
|
|
2932
3362
|
* a foreground `authorizePeer` — both `CadrePeer` inserts — and tripped the boundary
|
|
2933
3363
|
* assert.
|
|
2934
3364
|
*
|
|
@@ -2952,6 +3382,11 @@ export class ControlDatabase {
|
|
|
2952
3382
|
* re-runnable means a retry re-runs the body's reads too, which is what makes it safe.
|
|
2953
3383
|
* Signatures and stamp ids minted OUTSIDE the body are deliberately NOT re-minted per
|
|
2954
3384
|
* attempt: a retry re-presents the exact signed message the first attempt presented.
|
|
3385
|
+
* A body opted into `LOST_UPDATE_RETRY_POLICY` is the exception, and must mint INSIDE:
|
|
3386
|
+
* its re-run is a re-decision over a row another writer changed, not a re-presentation of
|
|
3387
|
+
* the same write, so its read of the row, its decision and its signature all run in the
|
|
3388
|
+
* body ({@link updateSelfPeerRecord}, {@link reauthorizeCadrePeer}, {@link deleteGuardedRow}
|
|
3389
|
+
* under {@link deleteCadrePeer}, {@link removeMachine}).
|
|
2955
3390
|
*/
|
|
2956
3391
|
async withWriteLock(fn) {
|
|
2957
3392
|
// Chain behind the current tail regardless of how it settled, then park a
|
|
@@ -2988,13 +3423,18 @@ export class ControlDatabase {
|
|
|
2988
3423
|
* backoff never stalls the other local writers queued behind it. Safe because every
|
|
2989
3424
|
* locked body is atomic and re-runnable (the contract on {@link withWriteLock}).
|
|
2990
3425
|
*
|
|
2991
|
-
* Also carries {@link
|
|
3426
|
+
* Also carries {@link applyControlSchema}'s distributed DDL, the one caller that runs BEFORE
|
|
2992
3427
|
* `initialized` is set — so it reaches this method directly rather than through
|
|
2993
3428
|
* {@link execWrite}, whose {@link ensureInitialized} would reject it. The write queue is
|
|
2994
3429
|
* necessarily empty at that point, so the lock is uncontended there; it is taken anyway
|
|
2995
|
-
* rather than carving out a lockless path.
|
|
2996
|
-
*
|
|
2997
|
-
*
|
|
3430
|
+
* rather than carving out a lockless path.
|
|
3431
|
+
*
|
|
3432
|
+
* Two policies reach here besides the default: `SCHEMA_INIT_RETRY_POLICY` from that one
|
|
3433
|
+
* call site, and `LOST_UPDATE_RETRY_POLICY` from the `CadrePeer` writers that re-read the
|
|
3434
|
+
* row inside their body ({@link updateSelfPeerRecord} directly; {@link reauthorizeCadrePeer},
|
|
3435
|
+
* {@link deleteCadrePeer} and {@link removeMachine} through {@link mutateCadrePeer}'s
|
|
3436
|
+
* policy parameter). Everything else takes the default, whose classifier and attempt count
|
|
3437
|
+
* are unchanged by those call sites existing.
|
|
2998
3438
|
*
|
|
2999
3439
|
* `label` names the operation in this loop's debug lines and nothing else
|
|
3000
3440
|
* ({@link ControlWriteRetryOptions.label}). Every call site in this class supplies one:
|
|
@@ -3125,6 +3565,15 @@ export class ControlDatabase {
|
|
|
3125
3565
|
async insertFormationInvite(token, sAppId, ownerKey, signMessage, options = {}) {
|
|
3126
3566
|
this.ensureInitialized();
|
|
3127
3567
|
log('Inserting formation invite: %s', token);
|
|
3568
|
+
const insert = await this.signFormationInviteInsert(token, sAppId, ownerKey, signMessage, options);
|
|
3569
|
+
await this.execWrite(insert.sql, insert.params, 'formation-invite-insert');
|
|
3570
|
+
log('Formation invite inserted: %s', token);
|
|
3571
|
+
}
|
|
3572
|
+
/**
|
|
3573
|
+
* The stamped, owner-signed `FormationInvite` insert that {@link insertFormationInvite} and
|
|
3574
|
+
* {@link insertFoundingRows} run. Reads the engine once, for the canonical `ExpiresAt`.
|
|
3575
|
+
*/
|
|
3576
|
+
async signFormationInviteInsert(token, sAppId, ownerKey, signMessage, options) {
|
|
3128
3577
|
const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
|
|
3129
3578
|
// Build each bound field exactly as the deferred CHECK sees it post-coercion:
|
|
3130
3579
|
// - ExpiresAt: engine-canonical datetime string (or '' when absent), sourced from
|
|
@@ -3144,24 +3593,26 @@ export class ControlDatabase {
|
|
|
3144
3593
|
const message = buildAuthorizationMessage('CadreControl.FormationInvite', 'add', this.config.partyId, [
|
|
3145
3594
|
token, sAppId, expiresAtField, totalUsesField, validationUrlField, strandIdField, stampId,
|
|
3146
3595
|
]);
|
|
3147
|
-
const signature = signMessage(message);
|
|
3596
|
+
const signature = await signMessage(message);
|
|
3148
3597
|
// Persist the canonical ExpiresAt string (datetime parse is idempotent on it) so the
|
|
3149
3598
|
// signed source-of-truth and the stored value are produced once. StampId is a real,
|
|
3150
3599
|
// unique column (single-use anti-replay), no longer a context value.
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3600
|
+
return {
|
|
3601
|
+
sql: `
|
|
3602
|
+
insert into CadreControl.FormationInvite (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId)
|
|
3603
|
+
with context OwnerKey = ?, Signature = ?
|
|
3604
|
+
values (?, ?, ?, ?, ?, ?, ?)
|
|
3605
|
+
`,
|
|
3606
|
+
params: [
|
|
3607
|
+
ownerKey, signature,
|
|
3608
|
+
token, sAppId,
|
|
3609
|
+
expiresAtCanonical,
|
|
3610
|
+
options.totalUses ?? null,
|
|
3611
|
+
options.validationUrl ?? null,
|
|
3612
|
+
options.strandId ?? null,
|
|
3613
|
+
stampId,
|
|
3614
|
+
],
|
|
3615
|
+
};
|
|
3165
3616
|
}
|
|
3166
3617
|
/**
|
|
3167
3618
|
* Redeem a `FormationInvite` by inserting the `Strand` row and a matching
|
|
@@ -3217,13 +3668,13 @@ export class ControlDatabase {
|
|
|
3217
3668
|
await this.inTransaction('redemption', async () => {
|
|
3218
3669
|
// 1. Strand row — authorised by the FormationUsage branch (no owner sig),
|
|
3219
3670
|
// still carrying a fresh unique StampId for the anti-replay column.
|
|
3220
|
-
// Hard-coded open + keyless + explicit null
|
|
3671
|
+
// Hard-coded open + keyless + explicit null founder columns: the
|
|
3221
3672
|
// consent branch admits no other shape (no signature means no
|
|
3222
|
-
// trustworthy founder machine to record — see the schema's
|
|
3673
|
+
// trustworthy founder machine to record — see the schema's comment).
|
|
3223
3674
|
await this.db.exec(`
|
|
3224
|
-
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey)
|
|
3675
|
+
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey, FounderPeerId)
|
|
3225
3676
|
with context OwnerKey = null, Signature = null
|
|
3226
|
-
values (?, 'o', null, ?, null)
|
|
3677
|
+
values (?, 'o', null, ?, null, null)
|
|
3227
3678
|
`, [strandId, strandStampId]);
|
|
3228
3679
|
// 2. FormationUsage row — authorised by the matching FormationInvite, and
|
|
3229
3680
|
// carrying the strand's stamp so it authorizes THIS row and no other.
|
|
@@ -3248,14 +3699,13 @@ export class ControlDatabase {
|
|
|
3248
3699
|
* Use {@link redeemInvitation} instead when the strand must be created by
|
|
3249
3700
|
* consent atomically with the usage.
|
|
3250
3701
|
*
|
|
3251
|
-
* The strand's live `StampId` is
|
|
3702
|
+
* The strand's live `StampId` is written onto the usage row:
|
|
3252
3703
|
* `FormationUsage.StrandExists` matches the (id, stamp) PAIR, and
|
|
3253
3704
|
* `Strand.AuthorizedInsert`'s consent branch reads the same pair back, so a consent
|
|
3254
|
-
* record authorizes exactly the strand ROW it was recorded against. A
|
|
3255
|
-
*
|
|
3256
|
-
*
|
|
3257
|
-
*
|
|
3258
|
-
* point is a genuine race and deserves a named error, not a silent rollback.
|
|
3705
|
+
* record authorizes exactly the strand ROW it was recorded against. A caller that already
|
|
3706
|
+
* read the stamp passes it (`strandStampId`); otherwise it is read here first, and a
|
|
3707
|
+
* missing strand THROWS {@link MissingHostStrandError} rather than being left to the
|
|
3708
|
+
* deferred `StrandExists` CHECK.
|
|
3259
3709
|
*
|
|
3260
3710
|
* The row is keyed by the joiner's own nonce (`UsageStampId` is the primary key), so this
|
|
3261
3711
|
* path — the one production actually races on (a bound invite published by `cadre-web` /
|
|
@@ -3269,7 +3719,7 @@ export class ControlDatabase {
|
|
|
3269
3719
|
const { token, strandId, disclosure = '', peerKey, peerSignature, usageStampId, nowMs, validationKey, validationSignature, signal, totalUses, } = params;
|
|
3270
3720
|
// The host strand is pre-existing and owner-signed; its stamp names the strand ROW the
|
|
3271
3721
|
// consent record authorizes.
|
|
3272
|
-
const strandStampId = await this.queryStrandStampId(strandId);
|
|
3722
|
+
const strandStampId = params.strandStampId ?? await this.queryStrandStampId(strandId);
|
|
3273
3723
|
if (strandStampId === null) {
|
|
3274
3724
|
throw new MissingHostStrandError(strandId, token);
|
|
3275
3725
|
}
|
|
@@ -3390,7 +3840,7 @@ export class ControlDatabase {
|
|
|
3390
3840
|
* permissive PRE-check that runs ahead of it, and a transiently short read costs each of
|
|
3391
3841
|
* them only a worse outcome for the ATTEMPT, never a seat the invitation did not pay for:
|
|
3392
3842
|
* {@link assertSeatRemains} loses its named exhaustion error and falls back to the CHECK's
|
|
3393
|
-
* generic refusal; `ControlFormationUsageRecorder.
|
|
3843
|
+
* generic refusal; `ControlFormationUsageRecorder.readToken` reports a seat free and lets
|
|
3394
3844
|
* the redemption proceed to the CHECK, which decides; {@link hasOutstandingFormationInvite}
|
|
3395
3845
|
* holds the stranger-admission door open slightly longer.
|
|
3396
3846
|
*
|
|
@@ -3423,12 +3873,12 @@ export class ControlDatabase {
|
|
|
3423
3873
|
/**
|
|
3424
3874
|
* Is any `FormationInvite` row still redeemable — unexpired AND with usage
|
|
3425
3875
|
* below its `TotalUses`? A null `ExpiresAt` never expires and a null
|
|
3426
|
-
* `TotalUses` is unlimited, matching
|
|
3427
|
-
*
|
|
3876
|
+
* `TotalUses` is unlimited, matching the per-token semantics of `classifyTokenRead`
|
|
3877
|
+
* (`formation-token.ts`).
|
|
3428
3878
|
*
|
|
3429
3879
|
* Answers the control-network connection gate's coarse "does this node expect
|
|
3430
3880
|
* a stranger?" question, which has no token to ask about. The expiry
|
|
3431
|
-
* comparison is `expiresAtMs <= now` — identical to `
|
|
3881
|
+
* comparison is `expiresAtMs <= now` — identical to `isInviteExpired`'s — so an
|
|
3432
3882
|
* invite the formation handler would reject can never hold the gate open.
|
|
3433
3883
|
*
|
|
3434
3884
|
* The scan is deliberately not pushed into SQL: nothing else here compares a
|
|
@@ -3492,73 +3942,116 @@ export class ControlDatabase {
|
|
|
3492
3942
|
* private half to the invitee out of band. It is never stored here (`Strand.Invite` has the
|
|
3493
3943
|
* same shape).
|
|
3494
3944
|
*
|
|
3495
|
-
* @param invite.key - the invitation public key (base64url ed25519), the row's primary key
|
|
3496
|
-
* @param invite.peerId - the device the invitation is for, or null for whoever redeems first
|
|
3497
|
-
* @param invite.grantsOwner - whether redemption also seats an `OwnerKey` row for the device
|
|
3498
|
-
* @param invite.expiresAtMs - epoch ms after which it cannot be redeemed; omit for never
|
|
3499
|
-
* @param invite.totalUses - how many redemptions it allows; omit for unlimited
|
|
3500
3945
|
* @returns the row as stored, `expiresAt` in its canonical form
|
|
3501
3946
|
*/
|
|
3502
3947
|
async insertCadreInvite(invite, ownerKey, signMessage) {
|
|
3948
|
+
const row = await this.signCadreInvite(invite, ownerKey, signMessage);
|
|
3949
|
+
await this.lockedWithRetry(() => this.execCadreInviteInsert(row), {}, 'cadre-invite-insert');
|
|
3950
|
+
log('Cadre invitation inserted: %s (peer=%s)', row.key, row.peerId ?? 'any');
|
|
3951
|
+
return row;
|
|
3952
|
+
}
|
|
3953
|
+
/**
|
|
3954
|
+
* Build and owner-sign a `CadreInvite` row WITHOUT inserting it: the signing half of
|
|
3955
|
+
* {@link insertCadreInvite}, which `CadreNode.joinWithOwnerGrant` uses alone for the
|
|
3956
|
+
* invitation a node issues to itself, since a store that holds no `OwnerKey` row for the
|
|
3957
|
+
* issuer would refuse the local insert and the row is seated at a member from the bundle
|
|
3958
|
+
* instead. `ExpiresAt` is canonicalized by this database's engine, which is a format, not a
|
|
3959
|
+
* store, so the row reads the same wherever it is seated.
|
|
3960
|
+
*/
|
|
3961
|
+
async signCadreInvite(invite, ownerKey, signMessage) {
|
|
3503
3962
|
this.ensureInitialized();
|
|
3504
3963
|
const key = requireEd25519PublicKeyB64(invite.key, 'cadre invitation key');
|
|
3505
3964
|
const signed = {
|
|
3506
3965
|
key,
|
|
3507
3966
|
peerId: invite.peerId ?? null,
|
|
3508
|
-
grantsOwner: invite.grantsOwner,
|
|
3509
3967
|
expiresAt: invite.expiresAtMs == null ? null : await canonicalDatetime(this.db, invite.expiresAtMs),
|
|
3510
3968
|
totalUses: invite.totalUses ?? null,
|
|
3511
3969
|
stampId: generateStampId(this.config.libp2pNode.peerId.toString()),
|
|
3512
3970
|
};
|
|
3513
|
-
|
|
3971
|
+
return {
|
|
3514
3972
|
...signed,
|
|
3515
3973
|
issuerKey: ownerKey,
|
|
3516
|
-
issuerSig: signMessage(cadreInviteAddMessage(this.config.partyId, signed)),
|
|
3974
|
+
issuerSig: await signMessage(cadreInviteAddMessage(this.config.partyId, signed)),
|
|
3517
3975
|
};
|
|
3518
|
-
await this.lockedWithRetry(() => this.execCadreInviteInsert(row), {}, 'cadre-invite-insert');
|
|
3519
|
-
log('Cadre invitation inserted: %s (peer=%s owner=%s)', key, row.peerId ?? 'any', row.grantsOwner);
|
|
3520
|
-
return row;
|
|
3521
3976
|
}
|
|
3522
3977
|
/**
|
|
3523
3978
|
* Seat an owner-signed `CadreInvite` row received inside an invitation bundle, under its own
|
|
3524
3979
|
* stored `IssuerKey`/`IssuerSig` as the write context — insert-if-absent, so a member that
|
|
3525
|
-
* already holds the row (by replication, or from an earlier redemption attempt) is a no-op
|
|
3980
|
+
* already holds the row (by replication, or from an earlier redemption attempt) is a no-op,
|
|
3981
|
+
* and so is one whose insert the cohort refuses because a member there holds it.
|
|
3526
3982
|
*
|
|
3527
3983
|
* @returns `true` when this call inserted the row, `false` when it was already present.
|
|
3528
3984
|
* @throws {CadreInviteIssuerUnknownError} when `CadreInvite.AuthorizedInsert` refuses it: the
|
|
3529
3985
|
* issuer is not an owner on this node (not replicated yet, or removed), or the signature does
|
|
3530
|
-
* not verify.
|
|
3986
|
+
* not verify. {@link IssuedRowNotHereYetError} when the cohort holds the row and this node
|
|
3987
|
+
* cannot read it yet. Every other refusal (a withdrawn stamp, `NotRevoked`) propagates as is.
|
|
3531
3988
|
*/
|
|
3532
3989
|
async seatCadreInvite(row) {
|
|
3990
|
+
return this.seatIssuedRow('CadreInvite', row.key, () => this.execCadreInviteInsert(row), cause => new CadreInviteIssuerUnknownError(row.key, row.issuerKey, { cause }));
|
|
3991
|
+
}
|
|
3992
|
+
/**
|
|
3993
|
+
* Insert-if-absent of an issued row from a bundle's copy, under the write lock: the body of
|
|
3994
|
+
* {@link seatCadreInvite} and {@link seatOwnerGrant}. `exec` runs the bare insert under the
|
|
3995
|
+
* row's own stored issuer pair.
|
|
3996
|
+
*
|
|
3997
|
+
* @returns `true` when this call inserted the row, `false` when it was already present —
|
|
3998
|
+
* here, or at the cohort member that refused the insert for holding it
|
|
3999
|
+
* ({@link adoptCohortHeldRow}).
|
|
4000
|
+
* @throws `refuse(cause)` when the table's `AuthorizedInsert` refuses the row;
|
|
4001
|
+
* {@link IssuedRowNotHereYetError} when the cohort holds the row and this node still cannot
|
|
4002
|
+
* read it; every other refusal (a withdrawn stamp, `NotRevoked`) propagates as is.
|
|
4003
|
+
*/
|
|
4004
|
+
async seatIssuedRow(table, key, exec, refuse) {
|
|
3533
4005
|
this.ensureInitialized();
|
|
3534
4006
|
try {
|
|
3535
4007
|
return await this.lockedWithRetry(async () => {
|
|
3536
4008
|
// retry: false — inside the locked write body (see queryStampId's NOTE).
|
|
3537
|
-
if (await this.queryStampId(
|
|
3538
|
-
log('
|
|
4009
|
+
if (await this.queryStampId(table, key, false) !== null) {
|
|
4010
|
+
log('%s %s already seated; seat skipped', table, key);
|
|
3539
4011
|
return false;
|
|
3540
4012
|
}
|
|
3541
|
-
await
|
|
3542
|
-
log('
|
|
4013
|
+
await exec();
|
|
4014
|
+
log('%s seated from its bundle: %s', table, key);
|
|
3543
4015
|
return true;
|
|
3544
|
-
}, {},
|
|
4016
|
+
}, {}, `${ISSUED_TABLE_REASON[table]}-seat`);
|
|
3545
4017
|
}
|
|
3546
4018
|
catch (error) {
|
|
3547
|
-
if (errorChainMatches(error,
|
|
3548
|
-
throw
|
|
4019
|
+
if (errorChainMatches(error, ISSUER_UNAUTHORIZED)) {
|
|
4020
|
+
throw refuse(error);
|
|
4021
|
+
}
|
|
4022
|
+
if (isIssuedRowKeyConflict(error, table)) {
|
|
4023
|
+
return await this.adoptCohortHeldRow(table, key, error);
|
|
3549
4024
|
}
|
|
3550
4025
|
throw error;
|
|
3551
4026
|
}
|
|
3552
4027
|
}
|
|
4028
|
+
/**
|
|
4029
|
+
* The seat's insert was refused because a cohort member already holds a row under `key`, which
|
|
4030
|
+
* this node's guard read missed because its store lags the cohort. A bundle's copy is verbatim
|
|
4031
|
+
* and every column is under the issuer's signature, so the held row is this one: it counts as
|
|
4032
|
+
* seated once a refreshing read (unlocked, so retried as any read) finds it here.
|
|
4033
|
+
*
|
|
4034
|
+
* @throws {IssuedRowNotHereYetError} when the read still does not find it.
|
|
4035
|
+
*/
|
|
4036
|
+
async adoptCohortHeldRow(table, key, conflict) {
|
|
4037
|
+
// NOTE: while another locked body runs here, this read takes readRowsOnce's committed path,
|
|
4038
|
+
// which does not refresh from the cohort, so it can miss the row and refuse a retryable
|
|
4039
|
+
// `conflict`; if that refusal shows up in practice, wait for the running body before reading.
|
|
4040
|
+
if (await this.queryStampId(table, key) === null) {
|
|
4041
|
+
throw new IssuedRowNotHereYetError(table, key, { cause: conflict });
|
|
4042
|
+
}
|
|
4043
|
+
log('%s %s is held by the cohort; seat skipped', table, key);
|
|
4044
|
+
return false;
|
|
4045
|
+
}
|
|
3553
4046
|
/** Bare `CadreInvite` insert shared by {@link insertCadreInvite} and {@link seatCadreInvite}; the caller holds the lock. */
|
|
3554
4047
|
async execCadreInviteInsert(row) {
|
|
3555
4048
|
await this.db.exec(`
|
|
3556
|
-
insert into CadreControl.CadreInvite (Key, PeerId,
|
|
4049
|
+
insert into CadreControl.CadreInvite (Key, PeerId, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId)
|
|
3557
4050
|
with context OwnerKey = ?, Signature = ?
|
|
3558
|
-
values (?, ?, ?, ?, ?, ?,
|
|
4051
|
+
values (?, ?, ?, ?, ?, ?, ?)
|
|
3559
4052
|
`, [
|
|
3560
4053
|
row.issuerKey, row.issuerSig,
|
|
3561
|
-
row.key, row.peerId, row.
|
|
4054
|
+
row.key, row.peerId, row.expiresAt, row.totalUses,
|
|
3562
4055
|
row.issuerKey, row.issuerSig, row.stampId,
|
|
3563
4056
|
]);
|
|
3564
4057
|
}
|
|
@@ -3567,8 +4060,10 @@ export class ControlDatabase {
|
|
|
3567
4060
|
* the row is the holder's proof and never goes away; liveness is {@link hasLiveCadreInvite}'s
|
|
3568
4061
|
* question, and `CadreInviteUsage.Authorized`'s at redemption.
|
|
3569
4062
|
*
|
|
3570
|
-
*
|
|
3571
|
-
*
|
|
4063
|
+
* A redemption reads the row once, before its write, and carries it into
|
|
4064
|
+
* {@link isCadreInviteLive} and {@link redeemCadreInvite}. `retry: false` is passed by
|
|
4065
|
+
* {@link redeemCadreInvite} when it was handed none and reads INSIDE its locked write body —
|
|
4066
|
+
* the same per-call opt-out, for the same reason, as {@link queryStampId}'s.
|
|
3572
4067
|
*/
|
|
3573
4068
|
async queryCadreInvite(key, retry = true) {
|
|
3574
4069
|
this.ensureInitialized();
|
|
@@ -3585,19 +4080,15 @@ export class ControlDatabase {
|
|
|
3585
4080
|
* {@link queryCadrePeers}' reason.
|
|
3586
4081
|
*/
|
|
3587
4082
|
async queryCadreInvites(retry = true) {
|
|
3588
|
-
this.
|
|
3589
|
-
const rows = await this.readRows(CADRE_INVITE_SELECT_SQL, undefined, 'cadre-invites', retry);
|
|
3590
|
-
return rows.map(cadreInviteRowOf);
|
|
4083
|
+
return (await this.readMembershipRows(this.cadreInviteView, retry)).map(cadreInviteRowOf);
|
|
3591
4084
|
}
|
|
3592
4085
|
/**
|
|
3593
4086
|
* Every `CadreInviteUsage` row this node holds — the redemption records the membership
|
|
3594
4087
|
* predicate verifies invitation-admitted rows against. Same `retry` contract as
|
|
3595
|
-
* {@link queryCadreInvites}.
|
|
4088
|
+
* {@link queryCadreInvites}; both come from their membership views ({@link readMembershipRows}).
|
|
3596
4089
|
*/
|
|
3597
4090
|
async queryCadreInviteUsages(retry = true) {
|
|
3598
|
-
this.
|
|
3599
|
-
const rows = await this.readRows('select UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, OwnerStampId, InviteSig, PeerSig from CadreControl.CadreInviteUsage', undefined, 'cadre-invite-usages', retry);
|
|
3600
|
-
return rows.map(cadreInviteUsageRowOf);
|
|
4091
|
+
return (await this.readMembershipRows(this.cadreInviteUsageView, retry)).map(cadreInviteUsageRowOf);
|
|
3601
4092
|
}
|
|
3602
4093
|
/**
|
|
3603
4094
|
* How many times a cadre invitation has been redeemed (its `CadreInviteUsage` rows), served
|
|
@@ -3607,9 +4098,12 @@ export class ControlDatabase {
|
|
|
3607
4098
|
* `retry: false` is passed by {@link assertCadreInviteSeatRemains}, inside the locked body.
|
|
3608
4099
|
*/
|
|
3609
4100
|
async countCadreInviteUsage(key, retry = true) {
|
|
4101
|
+
return this.countUsageRows('select count(1) as Count from CadreControl.CadreInviteUsage where InviteKey = ?', key, 'cadre-invite-usage-count', retry);
|
|
4102
|
+
}
|
|
4103
|
+
/** The one-row count read behind {@link countCadreInviteUsage} and {@link countOwnerGrantUsage}. */
|
|
4104
|
+
async countUsageRows(sql, key, label, retry) {
|
|
3610
4105
|
this.ensureInitialized();
|
|
3611
|
-
|
|
3612
|
-
for (const row of await this.readRows(sql, [key], 'cadre-invite-usage-count', retry)) {
|
|
4106
|
+
for (const row of await this.readRows(sql, [key], label, retry)) {
|
|
3613
4107
|
return row.Count ?? 0;
|
|
3614
4108
|
}
|
|
3615
4109
|
return 0;
|
|
@@ -3637,10 +4131,13 @@ export class ControlDatabase {
|
|
|
3637
4131
|
// counts make that free today; if a long-lived cadre accumulates thousands and inbound
|
|
3638
4132
|
// upgrades slow down, add an expiry-ordered index or prune withdrawn/expired rows (which
|
|
3639
4133
|
// first needs every device they admitted re-vouched, so the chain no longer needs them).
|
|
4134
|
+
// NOTE: this scan reaches storage while queryCadreInvites serves the same rows from the
|
|
4135
|
+
// membership view; if the stranger path's reads ever near the gate's 2 s deadline, walk
|
|
4136
|
+
// queryCadreInvites() here instead.
|
|
3640
4137
|
const metered = [];
|
|
3641
4138
|
let unlimitedLive = false;
|
|
3642
4139
|
for (const row of await this.readRows('select Key, ExpiresAt, TotalUses, IssuerKey, StampId from CadreControl.CadreInvite', undefined, 'live-cadre-invites')) {
|
|
3643
|
-
const open =
|
|
4140
|
+
const open = issuedRowStillOpen({ stampId: row.StampId, issuerKey: row.IssuerKey, expiresAt: row.ExpiresAt ?? null }, withdrawn, owners, nowMs);
|
|
3644
4141
|
if (!open) {
|
|
3645
4142
|
continue;
|
|
3646
4143
|
}
|
|
@@ -3667,24 +4164,36 @@ export class ControlDatabase {
|
|
|
3667
4164
|
}
|
|
3668
4165
|
/**
|
|
3669
4166
|
* Is this one cadre invitation still redeemable here — the conditions
|
|
3670
|
-
* {@link hasLiveCadreInvite} applies, for one invitation? The row judged is the one this
|
|
3671
|
-
* holds under `invite.key
|
|
3672
|
-
* yet), but only if its issuer signature
|
|
3673
|
-
* judged by whichever owner it names as
|
|
3674
|
-
* {@link countCadreInviteUsage}: {@link seatCadreInvite}
|
|
3675
|
-
* what enforce every condition.
|
|
4167
|
+
* {@link hasLiveCadreInvite} applies, for one invitation? The row judged is `held`, the one this
|
|
4168
|
+
* node holds under `invite.key` ({@link queryCadreInvite}, read by the caller); when it holds
|
|
4169
|
+
* none, `invite` itself (a bundle's copy, not seated yet), but only if its issuer signature
|
|
4170
|
+
* verifies, because an unsigned copy would otherwise be judged by whichever owner it names as
|
|
4171
|
+
* issuer. A permissive pre-check like {@link countCadreInviteUsage}: {@link seatCadreInvite}
|
|
4172
|
+
* and the redemption's own write are what enforce every condition.
|
|
4173
|
+
*
|
|
4174
|
+
* The use count read here is read again inside the redemption's write lock
|
|
4175
|
+
* ({@link assertCadreInviteSeatRemains}), on purpose: this one decides whether the device is
|
|
4176
|
+
* pushed the control store before the write, so the holder of a spent invitation is refused
|
|
4177
|
+
* without being sent anything; the in-lock one decides the seat, against a concurrent redemption.
|
|
3676
4178
|
*/
|
|
3677
|
-
async isCadreInviteLive(invite, nowMs = Date.now()) {
|
|
4179
|
+
async isCadreInviteLive(invite, held, nowMs = Date.now()) {
|
|
3678
4180
|
this.ensureInitialized();
|
|
3679
|
-
const row =
|
|
4181
|
+
const row = held ?? (verifyCadreInviteRow(this.config.partyId, invite) ? invite : null);
|
|
4182
|
+
return this.issuedRowLive('CadreInvite', row, nowMs, key => this.countCadreInviteUsage(key));
|
|
4183
|
+
}
|
|
4184
|
+
/**
|
|
4185
|
+
* The body of {@link isCadreInviteLive} and {@link isOwnerGrantLive}: is `row` (the one held,
|
|
4186
|
+
* or a verified bundle copy; null for neither) still open and under its use cap?
|
|
4187
|
+
*/
|
|
4188
|
+
async issuedRowLive(table, row, nowMs, countUsage) {
|
|
3680
4189
|
if (row === null) {
|
|
3681
4190
|
return false;
|
|
3682
4191
|
}
|
|
3683
|
-
const [withdrawn, owners] = await Promise.all([this.queryRevokedStamps(
|
|
3684
|
-
if (!
|
|
4192
|
+
const [withdrawn, owners] = await Promise.all([this.queryRevokedStamps(table), this.getOwnerKeys()]);
|
|
4193
|
+
if (!issuedRowStillOpen(row, withdrawn, owners, nowMs)) {
|
|
3685
4194
|
return false;
|
|
3686
4195
|
}
|
|
3687
|
-
return row.totalUses === null || await
|
|
4196
|
+
return row.totalUses === null || await countUsage(row.key) < row.totalUses;
|
|
3688
4197
|
}
|
|
3689
4198
|
/**
|
|
3690
4199
|
* Every `CadreInvite` row this node holds with its standing: how many redemptions are
|
|
@@ -3694,22 +4203,28 @@ export class ControlDatabase {
|
|
|
3694
4203
|
* occasional read, not a gate's.
|
|
3695
4204
|
*/
|
|
3696
4205
|
async listCadreInviteStatuses(nowMs = Date.now()) {
|
|
4206
|
+
const standings = await this.issuedRowStandings('CadreInvite', await this.queryCadreInvites(), nowMs, key => this.countCadreInviteUsage(key));
|
|
4207
|
+
return standings.map(({ row, ...standing }) => ({ invite: row, ...standing }));
|
|
4208
|
+
}
|
|
4209
|
+
/**
|
|
4210
|
+
* The body of {@link listCadreInviteStatuses} and {@link listOwnerGrantStatuses}: each of
|
|
4211
|
+
* `rows` with its withdrawal, its recorded uses and whether it is still redeemable.
|
|
4212
|
+
*/
|
|
4213
|
+
async issuedRowStandings(table, rows, nowMs, countUsage) {
|
|
3697
4214
|
this.ensureInitialized();
|
|
3698
|
-
const [
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
//
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
const
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
&& (invite.totalUses === null || usesRecorded < invite.totalUses);
|
|
3710
|
-
statuses.push({ invite, live, withdrawn, usesRecorded });
|
|
4215
|
+
const [withdrawnStamps, owners] = await Promise.all([this.queryRevokedStamps(table), this.getOwnerKeys()]);
|
|
4216
|
+
const standings = [];
|
|
4217
|
+
// NOTE: one retried count read per row, withdrawn and expired rows included, since rows
|
|
4218
|
+
// are never deleted; if a long-lived cadre's listing ever shows as slow, group the usage
|
|
4219
|
+
// counts in one statement or skip the count for rows that are no longer open.
|
|
4220
|
+
for (const row of rows) {
|
|
4221
|
+
const usesRecorded = await countUsage(row.key);
|
|
4222
|
+
const withdrawn = withdrawnStamps.has(row.stampId);
|
|
4223
|
+
const live = issuedRowStillOpen(row, withdrawnStamps, owners, nowMs)
|
|
4224
|
+
&& (row.totalUses === null || usesRecorded < row.totalUses);
|
|
4225
|
+
standings.push({ row, live, withdrawn, usesRecorded });
|
|
3711
4226
|
}
|
|
3712
|
-
return
|
|
4227
|
+
return standings;
|
|
3713
4228
|
}
|
|
3714
4229
|
/**
|
|
3715
4230
|
* Owner-signed WITHDRAWAL of a cadre invitation: file the `Revocation` tombstone retiring its
|
|
@@ -3729,59 +4244,66 @@ export class ControlDatabase {
|
|
|
3729
4244
|
* node was alone is queued for re-issue on cohort growth ({@link CadreNode.noteGuardedDelete}).
|
|
3730
4245
|
*/
|
|
3731
4246
|
async withdrawCadreInvite(key, ownerKey, signMessage) {
|
|
4247
|
+
return this.withdrawIssuedRow('CadreInvite', key, ownerKey, signMessage);
|
|
4248
|
+
}
|
|
4249
|
+
/**
|
|
4250
|
+
* The body of {@link withdrawCadreInvite} and {@link withdrawOwnerGrant}: under the write
|
|
4251
|
+
* lock, file the owner-signed tombstone retiring the row's stamp with the row kept, and fire
|
|
4252
|
+
* the guarded-delete listener for it (so a withdrawal made while alone is re-issued).
|
|
4253
|
+
*/
|
|
4254
|
+
async withdrawIssuedRow(table, key, ownerKey, signMessage) {
|
|
3732
4255
|
this.ensureInitialized();
|
|
3733
4256
|
const filed = await this.lockedWithRetry(async () => {
|
|
3734
|
-
const [withdrawal] = await this.
|
|
4257
|
+
const [withdrawal] = await this.signWithdrawals(table, [key], signMessage);
|
|
3735
4258
|
if (withdrawal === undefined) {
|
|
3736
4259
|
return null;
|
|
3737
4260
|
}
|
|
3738
4261
|
await this.execTombstone(withdrawal.ref, withdrawal.signature, ownerKey);
|
|
3739
4262
|
return withdrawal.ref;
|
|
3740
|
-
}, {},
|
|
4263
|
+
}, {}, `${ISSUED_TABLE_REASON[table]}-withdraw`);
|
|
3741
4264
|
if (filed === null) {
|
|
3742
4265
|
return false;
|
|
3743
4266
|
}
|
|
3744
|
-
log('
|
|
4267
|
+
log('%s withdrawn: %s (stamp %s retired, row kept)', table, key, filed.stampId);
|
|
3745
4268
|
this.notifyGuardedDelete(filed);
|
|
3746
4269
|
return true;
|
|
3747
4270
|
}
|
|
3748
4271
|
/**
|
|
3749
|
-
* The withdrawal tombstones for the `
|
|
3750
|
-
*
|
|
3751
|
-
* {@link
|
|
4272
|
+
* The withdrawal tombstones for the `table` rows among `keys` that are held here and not
|
|
4273
|
+
* yet withdrawn, signed and ready for {@link execTombstone}: the body
|
|
4274
|
+
* {@link withdrawIssuedRow} files one of and {@link removeMachine} files several of in one
|
|
3752
4275
|
* transaction. Inside the locked body (every read `retry: false`); an absent or already
|
|
3753
4276
|
* withdrawn key is logged and left out.
|
|
3754
4277
|
*/
|
|
3755
|
-
async
|
|
4278
|
+
async signWithdrawals(table, keys, signMessage) {
|
|
3756
4279
|
if (keys.length === 0) {
|
|
3757
4280
|
return [];
|
|
3758
4281
|
}
|
|
3759
|
-
const withdrawn = await this.queryRevokedStamps(
|
|
4282
|
+
const withdrawn = await this.queryRevokedStamps(table, false);
|
|
3760
4283
|
const withdrawals = [];
|
|
3761
4284
|
for (const key of keys) {
|
|
3762
|
-
const stampId = await this.queryStampId(
|
|
4285
|
+
const stampId = await this.queryStampId(table, key, false);
|
|
3763
4286
|
if (stampId === null) {
|
|
3764
|
-
log('
|
|
4287
|
+
log('withdraw %s: no row for %s (nothing to withdraw)', table, key);
|
|
3765
4288
|
continue;
|
|
3766
4289
|
}
|
|
3767
4290
|
if (withdrawn.has(stampId)) {
|
|
3768
|
-
log('
|
|
4291
|
+
log('withdraw %s: %s already withdrawn (stamp %s retired)', table, key, stampId);
|
|
3769
4292
|
continue;
|
|
3770
4293
|
}
|
|
3771
|
-
const ref = { tableName:
|
|
3772
|
-
withdrawals.push({ ref, signature: signMessage(revocationTombstoneMessage(this.config.partyId, ref)) });
|
|
4294
|
+
const ref = { tableName: table, rowKey: key, stampId };
|
|
4295
|
+
withdrawals.push({ ref, signature: await signMessage(revocationTombstoneMessage(this.config.partyId, ref)) });
|
|
3773
4296
|
}
|
|
3774
4297
|
return withdrawals;
|
|
3775
4298
|
}
|
|
3776
4299
|
/**
|
|
3777
|
-
* Redeem a seated `CadreInvite` on the owner's behalf: admit `peerId` as a cadre member
|
|
3778
|
-
*
|
|
3779
|
-
* transaction writes the `CadrePeer` row (consent branch of `AuthorizedInsert`)
|
|
3780
|
-
* `
|
|
3781
|
-
*
|
|
3782
|
-
*
|
|
3783
|
-
*
|
|
3784
|
-
* ({@link seatCadreInvite}): every consent branch reads `committed.CadreInvite`.
|
|
4300
|
+
* Redeem a seated `CadreInvite` on the owner's behalf: admit `peerId` as a cadre member —
|
|
4301
|
+
* with NO owner signature, and as a machine only (ownership is {@link redeemOwnerGrant}'s).
|
|
4302
|
+
* One transaction writes the `CadrePeer` row (consent branch of `AuthorizedInsert`) and then
|
|
4303
|
+
* the `CadreInviteUsage` row that authorizes it; the usage row's own check (`PeerExists`)
|
|
4304
|
+
* reads the peer row back, so the two are mutually bound to this exact redemption and
|
|
4305
|
+
* neither can be held in reserve. The invitation must already be committed
|
|
4306
|
+
* ({@link seatCadreInvite}): the consent branch reads `committed.CadreInvite`.
|
|
3785
4307
|
*
|
|
3786
4308
|
* The two signatures are the holder's and the device's, minted on the device over
|
|
3787
4309
|
* {@link cadreInviteRedeemMessage} / {@link cadreInviteConsentMessage}; this machine only
|
|
@@ -3790,79 +4312,59 @@ export class ControlDatabase {
|
|
|
3790
4312
|
* (`verifyInvitationAdmission`) — a mismatched pair is written and then never trusted.
|
|
3791
4313
|
*
|
|
3792
4314
|
* Idempotent for a retry after a dropped reply: when the device's row is already present
|
|
3793
|
-
*
|
|
3794
|
-
*
|
|
3795
|
-
*
|
|
3796
|
-
* The seat check ({@link assertCadreInviteSeatRemains}) runs after that, so the retry of a
|
|
3797
|
-
* single-use redemption is answered `alreadyMember` rather than refused as exhausted.
|
|
4315
|
+
* nothing is written and no seat is spent — `alreadyMember: true`. The seat check
|
|
4316
|
+
* ({@link assertCadreInviteSeatRemains}) runs after that, so the retry of a single-use
|
|
4317
|
+
* redemption is answered `alreadyMember` rather than refused as exhausted.
|
|
3798
4318
|
*
|
|
3799
4319
|
* Through {@link mutateCadrePeer}, so the membership hub is notified; inside the locked body
|
|
3800
4320
|
* every read passes `retry: false` and every stamp is minted per attempt, so a
|
|
3801
4321
|
* {@link lockedWithRetry} re-run starts clean.
|
|
3802
4322
|
*
|
|
3803
4323
|
* @throws {InvitationExhaustedError} when the invitation's seats are all spent (by name,
|
|
3804
|
-
* ahead of the schema's generic `Authorized` refusal — see {@link assertSeatRemains})
|
|
4324
|
+
* ahead of the schema's generic `Authorized` refusal — see {@link assertSeatRemains});
|
|
4325
|
+
* {@link IssuedRowNotHereYetError} when no `CadreInvite` row is readable here.
|
|
3805
4326
|
*/
|
|
3806
4327
|
async redeemCadreInvite(params) {
|
|
3807
4328
|
this.ensureInitialized();
|
|
3808
|
-
const { inviteKey, peerId, peerKey, usageStampId, inviteSig, peerSig } = params;
|
|
4329
|
+
const { inviteKey, peerId, peerKey, record, usageStampId, inviteSig, peerSig } = params;
|
|
3809
4330
|
const nowMs = params.nowMs ?? Date.now();
|
|
3810
4331
|
const multiaddr = params.multiaddrs?.length ? params.multiaddrs.join(',') : '';
|
|
3811
4332
|
log('Redeeming cadre invitation %s for %s', inviteKey, peerId);
|
|
3812
4333
|
return this.mutateCadrePeer('cadre-invite-redeem', async () => {
|
|
3813
|
-
const invite = await this.queryCadreInvite(inviteKey, false);
|
|
4334
|
+
const invite = params.held ?? await this.queryCadreInvite(inviteKey, false);
|
|
3814
4335
|
if (invite === null) {
|
|
3815
|
-
throw new
|
|
4336
|
+
throw new IssuedRowNotHereYetError('CadreInvite', inviteKey);
|
|
3816
4337
|
}
|
|
3817
|
-
|
|
3818
|
-
|
|
4338
|
+
// A raw stamp read (queryStampId), so a physically present row counts; inside the locked
|
|
4339
|
+
// body, so never retried.
|
|
4340
|
+
const seatedPeerStampId = await this.queryStampId('CadrePeer', peerId, false);
|
|
4341
|
+
if (seatedPeerStampId !== null) {
|
|
3819
4342
|
log('redeemCadreInvite: %s already admitted; nothing written', peerId);
|
|
3820
|
-
return { alreadyMember: true, peerStampId:
|
|
4343
|
+
return { alreadyMember: true, peerStampId: seatedPeerStampId };
|
|
3821
4344
|
}
|
|
3822
4345
|
await this.assertCadreInviteSeatRemains(invite);
|
|
3823
|
-
//
|
|
3824
|
-
// the row it seats. Minted inside the body, so a re-run mints afresh over a
|
|
3825
|
-
// attempt rather than re-presenting a stamp the usage row may already name.
|
|
3826
|
-
const peerStampId =
|
|
3827
|
-
const ownerStampId = invite.grantsOwner ? (seated.ownerStampId ?? generateStampId(peerId)) : null;
|
|
4346
|
+
// The stamp is minted from the ADMITTED device's id, as insertCadrePeer does: the nonce
|
|
4347
|
+
// names the row it seats. Minted inside the body, so a re-run mints afresh over a
|
|
4348
|
+
// rolled-back attempt rather than re-presenting a stamp the usage row may already name.
|
|
4349
|
+
const peerStampId = generateStampId(peerId);
|
|
3828
4350
|
const nowCanonical = await canonicalDatetime(this.db, nowMs);
|
|
3829
4351
|
await this.inTransaction('cadre-invite-redeem', async () => {
|
|
3830
|
-
if (seated.peerStampId === null) {
|
|
3831
|
-
await this.db.exec(`
|
|
3832
|
-
insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId, VouchOwner, VouchSig, VouchUsage)
|
|
3833
|
-
with context OwnerKey = null, Signature = null
|
|
3834
|
-
values (?, ?, ?, ?, null, ?, ?, null, ?)
|
|
3835
|
-
`, [peerId, peerKey, multiaddr, nowMs, peerStampId, invite.issuerKey, usageStampId]);
|
|
3836
|
-
}
|
|
3837
|
-
if (ownerStampId !== null && seated.ownerStampId === null) {
|
|
3838
|
-
await this.db.exec(`
|
|
3839
|
-
insert into CadreControl.OwnerKey (Key, StampId, VouchOwner, VouchSig, VouchUsage)
|
|
3840
|
-
with context OwnerKey = null, Signature = null
|
|
3841
|
-
values (?, ?, ?, null, ?)
|
|
3842
|
-
`, [peerKey, ownerStampId, invite.issuerKey, usageStampId]);
|
|
3843
|
-
}
|
|
3844
4352
|
await this.db.exec(`
|
|
3845
|
-
insert into CadreControl.
|
|
4353
|
+
insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, Profile, StampId, VouchOwner, VouchSig, VouchUsage)
|
|
4354
|
+
with context OwnerKey = null, Signature = null
|
|
4355
|
+
values (?, ?, ?, ?, ?, ?, ?, ?, null, ?)
|
|
4356
|
+
`, [peerId, peerKey, multiaddr, record?.updatedAt ?? nowMs, record?.sig ?? null, record?.profile ?? null,
|
|
4357
|
+
peerStampId, invite.issuerKey, usageStampId]);
|
|
4358
|
+
await this.db.exec(`
|
|
4359
|
+
insert into CadreControl.CadreInviteUsage (UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, InviteSig, PeerSig)
|
|
3846
4360
|
with context Now = ?
|
|
3847
|
-
values (?, ?, ?, ?, ?, ?,
|
|
3848
|
-
`, [nowCanonical, usageStampId, inviteKey, peerId, peerKey, peerStampId,
|
|
4361
|
+
values (?, ?, ?, ?, ?, ?, ?)
|
|
4362
|
+
`, [nowCanonical, usageStampId, inviteKey, peerId, peerKey, peerStampId, inviteSig, peerSig]);
|
|
3849
4363
|
});
|
|
3850
|
-
log('Redeemed cadre invitation %s: %s admitted (
|
|
3851
|
-
return { alreadyMember: false, peerStampId
|
|
4364
|
+
log('Redeemed cadre invitation %s: %s admitted (usage %s)', inviteKey, peerId, usageStampId);
|
|
4365
|
+
return { alreadyMember: false, peerStampId };
|
|
3852
4366
|
});
|
|
3853
4367
|
}
|
|
3854
|
-
/**
|
|
3855
|
-
* The rows a redemption would seat that are already present: the device's `CadrePeer`
|
|
3856
|
-
* stamp, and — only when the invitation grants ownership — its `OwnerKey` stamp. Raw stamp
|
|
3857
|
-
* reads ({@link queryStampId}), so a physically present row counts; inside the locked
|
|
3858
|
-
* body, so never retried.
|
|
3859
|
-
*/
|
|
3860
|
-
async readSeatedAdmission(peerId, peerKey, grantsOwner) {
|
|
3861
|
-
return {
|
|
3862
|
-
peerStampId: await this.queryStampId('CadrePeer', peerId, false),
|
|
3863
|
-
ownerStampId: grantsOwner ? await this.queryStampId('OwnerKey', peerKey, false) : null,
|
|
3864
|
-
};
|
|
3865
|
-
}
|
|
3866
4368
|
/**
|
|
3867
4369
|
* Refuse a redemption that would consume a seat the cadre invitation does not have, by
|
|
3868
4370
|
* name ({@link InvitationExhaustedError}) — the cadre-invitation twin of
|
|
@@ -3870,19 +4372,232 @@ export class ControlDatabase {
|
|
|
3870
4372
|
* generic `Authorized` failure a caller would otherwise report as retryable. Reads the
|
|
3871
4373
|
* committed count inside the locked body, so on a same-node race the loser is refused here.
|
|
3872
4374
|
*/
|
|
3873
|
-
|
|
3874
|
-
|
|
4375
|
+
assertCadreInviteSeatRemains(invite) {
|
|
4376
|
+
return this.assertIssuedSeatRemains(invite, key => this.countCadreInviteUsage(key, false));
|
|
4377
|
+
}
|
|
4378
|
+
/** The body of {@link assertCadreInviteSeatRemains} and {@link assertOwnerGrantSeatRemains}; `countUsage` reads inside the locked body. */
|
|
4379
|
+
async assertIssuedSeatRemains(row, countUsage) {
|
|
4380
|
+
if (row.totalUses === null) {
|
|
3875
4381
|
return;
|
|
3876
4382
|
}
|
|
3877
|
-
const used = await
|
|
3878
|
-
if (used >=
|
|
3879
|
-
throw new InvitationExhaustedError(
|
|
4383
|
+
const used = await countUsage(row.key);
|
|
4384
|
+
if (used >= row.totalUses) {
|
|
4385
|
+
throw new InvitationExhaustedError(row.key, used, row.totalUses);
|
|
3880
4386
|
}
|
|
3881
4387
|
}
|
|
4388
|
+
// ── Owner grants (OwnerGrant / OwnerGrantUsage) ─────────────────────────────────────────
|
|
4389
|
+
/**
|
|
4390
|
+
* Mint and insert an owner-signed `OwnerGrant`: a grant of ownership to whoever holds the
|
|
4391
|
+
* grant's private half, redeemable through any member machine ({@link redeemOwnerGrant})
|
|
4392
|
+
* to seat an owner key the holder generates or already holds. No machine is admitted and no
|
|
4393
|
+
* peer id is named. The grant twin of {@link insertCadreInvite}: the owner signs the whole
|
|
4394
|
+
* row ({@link ownerGrantAddMessage}), `ExpiresAt` through {@link canonicalDatetime}, and
|
|
4395
|
+
* the returned row is what the grant bundle carries, re-inserted verbatim by
|
|
4396
|
+
* {@link seatOwnerGrant} on a member that has not received it by replication.
|
|
4397
|
+
*
|
|
4398
|
+
* @param grant.key - the grant's ed25519 PUBLIC key (base64url), the row's primary key; the
|
|
4399
|
+
* caller minted the pair and hands the private half to the holder out of band
|
|
4400
|
+
* @param grant.label - the label every key this grant seats will carry
|
|
4401
|
+
* ({@link ownerKeyLabel}'s rule: trimmed, at most 64 characters, blank meaning none); the
|
|
4402
|
+
* issuer's choice, which the holder cannot change
|
|
4403
|
+
* @param grant.expiresAtMs - epoch ms after which it cannot be redeemed; omit or null for
|
|
4404
|
+
* never. The short default lifetime is the node API's to apply, not this writer's.
|
|
4405
|
+
* @param grant.totalUses - how many redemptions it allows; omit for unlimited
|
|
4406
|
+
* @returns the row as stored, `expiresAt` in its canonical form
|
|
4407
|
+
*/
|
|
4408
|
+
async insertOwnerGrant(grant, ownerKey, signMessage) {
|
|
4409
|
+
this.ensureInitialized();
|
|
4410
|
+
const key = requireEd25519PublicKeyB64(grant.key, 'owner grant key');
|
|
4411
|
+
const signed = {
|
|
4412
|
+
key,
|
|
4413
|
+
label: ownerKeyLabel(grant.label),
|
|
4414
|
+
expiresAt: grant.expiresAtMs == null ? null : await canonicalDatetime(this.db, grant.expiresAtMs),
|
|
4415
|
+
totalUses: grant.totalUses ?? null,
|
|
4416
|
+
stampId: generateStampId(this.config.libp2pNode.peerId.toString()),
|
|
4417
|
+
};
|
|
4418
|
+
const row = {
|
|
4419
|
+
...signed,
|
|
4420
|
+
issuerKey: ownerKey,
|
|
4421
|
+
issuerSig: await signMessage(ownerGrantAddMessage(this.config.partyId, signed)),
|
|
4422
|
+
};
|
|
4423
|
+
await this.lockedWithRetry(() => this.execOwnerGrantInsert(row), {}, 'owner-grant-insert');
|
|
4424
|
+
log('Owner grant inserted: %s (label=%s)', key, row.label ?? 'none');
|
|
4425
|
+
return row;
|
|
4426
|
+
}
|
|
4427
|
+
/**
|
|
4428
|
+
* Seat an owner-signed `OwnerGrant` row received inside a grant bundle, under its own stored
|
|
4429
|
+
* `IssuerKey`/`IssuerSig` as the write context — insert-if-absent, as {@link seatCadreInvite}.
|
|
4430
|
+
*
|
|
4431
|
+
* @returns `true` when this call inserted the row, `false` when it was already present.
|
|
4432
|
+
* @throws {OwnerGrantIssuerUnknownError} when `OwnerGrant.AuthorizedInsert` refuses it: the
|
|
4433
|
+
* issuer is not an owner on this node (not replicated yet, or removed), or the signature does
|
|
4434
|
+
* not verify. {@link IssuedRowNotHereYetError} when the cohort holds the row and this node
|
|
4435
|
+
* cannot read it yet. Every other refusal (a withdrawn stamp, `NotRevoked`) propagates as is.
|
|
4436
|
+
*/
|
|
4437
|
+
async seatOwnerGrant(row) {
|
|
4438
|
+
return this.seatIssuedRow('OwnerGrant', row.key, () => this.execOwnerGrantInsert(row), cause => new OwnerGrantIssuerUnknownError(row.key, row.issuerKey, { cause }));
|
|
4439
|
+
}
|
|
4440
|
+
/** Bare `OwnerGrant` insert shared by {@link insertOwnerGrant} and {@link seatOwnerGrant}; the caller holds the lock. */
|
|
4441
|
+
async execOwnerGrantInsert(row) {
|
|
4442
|
+
await this.db.exec(`
|
|
4443
|
+
insert into CadreControl.OwnerGrant (Key, Label, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId)
|
|
4444
|
+
with context OwnerKey = ?, Signature = ?
|
|
4445
|
+
values (?, ?, ?, ?, ?, ?, ?)
|
|
4446
|
+
`, [
|
|
4447
|
+
row.issuerKey, row.issuerSig,
|
|
4448
|
+
row.key, row.label, row.expiresAt, row.totalUses,
|
|
4449
|
+
row.issuerKey, row.issuerSig, row.stampId,
|
|
4450
|
+
]);
|
|
4451
|
+
}
|
|
4452
|
+
/**
|
|
4453
|
+
* Read one `OwnerGrant` row by key, or null when absent. Withdrawn rows are returned too,
|
|
4454
|
+
* as {@link queryCadreInvite} returns them: the row is the holder's proof and never goes
|
|
4455
|
+
* away. Read once per redemption and carried into {@link isOwnerGrantLive} and
|
|
4456
|
+
* {@link redeemOwnerGrant}; `retry: false` is passed by {@link redeemOwnerGrant} when it was
|
|
4457
|
+
* handed none and reads inside its locked body.
|
|
4458
|
+
*/
|
|
4459
|
+
async queryOwnerGrant(key, retry = true) {
|
|
4460
|
+
this.ensureInitialized();
|
|
4461
|
+
for (const row of await this.readRows(`${OWNER_GRANT_SELECT_SQL} where Key = ?`, [key], 'owner-grant', retry)) {
|
|
4462
|
+
return ownerGrantRowOf(row);
|
|
4463
|
+
}
|
|
4464
|
+
return null;
|
|
4465
|
+
}
|
|
4466
|
+
/**
|
|
4467
|
+
* Every `OwnerGrant` row this node holds, withdrawn and expired ones included. Read by the
|
|
4468
|
+
* anchor sync when at least one `OwnerKey` row is consent-seated (`CadreNode.loadOwnerGrantChain`);
|
|
4469
|
+
* `retry: false` is forwarded from the membership-gate refresh for {@link queryCadrePeers}' reason.
|
|
4470
|
+
*/
|
|
4471
|
+
async queryOwnerGrants(retry = true) {
|
|
4472
|
+
return (await this.readMembershipRows(this.ownerGrantView, retry)).map(ownerGrantRowOf);
|
|
4473
|
+
}
|
|
4474
|
+
/** Every `OwnerGrantUsage` row this node holds — the redemption records a grant-seated `OwnerKey` row is verified against. Same `retry` contract as {@link queryOwnerGrants}; both come from their membership views ({@link readMembershipRows}). */
|
|
4475
|
+
async queryOwnerGrantUsages(retry = true) {
|
|
4476
|
+
return (await this.readMembershipRows(this.ownerGrantUsageView, retry)).map(ownerGrantUsageRowOf);
|
|
4477
|
+
}
|
|
4478
|
+
/**
|
|
4479
|
+
* How many times an owner grant has been redeemed (its `OwnerGrantUsage` rows), served
|
|
4480
|
+
* through the `OwnerGrantUsageByGrant` index. A permissive PRE-check like
|
|
4481
|
+
* {@link countCadreInviteUsage}: the cap is `OwnerGrantUsage.Authorized`'s own committed count.
|
|
4482
|
+
*/
|
|
4483
|
+
async countOwnerGrantUsage(key, retry = true) {
|
|
4484
|
+
return this.countUsageRows('select count(1) as Count from CadreControl.OwnerGrantUsage where GrantKey = ?', key, 'owner-grant-usage-count', retry);
|
|
4485
|
+
}
|
|
4486
|
+
/**
|
|
4487
|
+
* Is this one owner grant still redeemable here — not withdrawn, unexpired, uses left, issuer
|
|
4488
|
+
* still an owner — judged as {@link isCadreInviteLive} judges an invitation: by `held`, the row
|
|
4489
|
+
* this node holds under `grant.key` ({@link queryOwnerGrant}, read by the caller), else by
|
|
4490
|
+
* `grant` itself (a bundle's copy, not seated yet) when its issuer signature verifies. A
|
|
4491
|
+
* permissive pre-check; {@link seatOwnerGrant} and the redemption's own write enforce every
|
|
4492
|
+
* condition. Its use count is read again inside the write lock, for
|
|
4493
|
+
* {@link isCadreInviteLive}'s reason.
|
|
4494
|
+
*/
|
|
4495
|
+
async isOwnerGrantLive(grant, held, nowMs = Date.now()) {
|
|
4496
|
+
this.ensureInitialized();
|
|
4497
|
+
const row = held ?? (verifyOwnerGrantRow(this.config.partyId, grant) ? grant : null);
|
|
4498
|
+
return this.issuedRowLive('OwnerGrant', row, nowMs, key => this.countOwnerGrantUsage(key));
|
|
4499
|
+
}
|
|
4500
|
+
/**
|
|
4501
|
+
* Every `OwnerGrant` row this node holds with its standing — recorded redemptions, withdrawal,
|
|
4502
|
+
* and whether it is still redeemable — for an owner's listing, as {@link listCadreInviteStatuses}.
|
|
4503
|
+
*/
|
|
4504
|
+
async listOwnerGrantStatuses(nowMs = Date.now()) {
|
|
4505
|
+
const standings = await this.issuedRowStandings('OwnerGrant', await this.queryOwnerGrants(), nowMs, key => this.countOwnerGrantUsage(key));
|
|
4506
|
+
return standings.map(({ row, ...standing }) => ({ grant: row, ...standing }));
|
|
4507
|
+
}
|
|
4508
|
+
/**
|
|
4509
|
+
* Owner-signed WITHDRAWAL of an owner grant: file the `Revocation` tombstone retiring its
|
|
4510
|
+
* stamp, with the row kept, exactly as {@link withdrawCadreInvite} withdraws an invitation
|
|
4511
|
+
* and for the same reason (every key the grant seated proves itself through the row;
|
|
4512
|
+
* `OwnerGrant.NoDelete`, `Revocation.RowIsGone`). Afterwards `OwnerGrantUsage.Authorized`
|
|
4513
|
+
* refuses every further redemption and `OwnerGrant.NotRevoked` refuses re-seating the
|
|
4514
|
+
* holder's copy on any node holding the tombstone. Keys already seated stay owners;
|
|
4515
|
+
* removing one is {@link deleteOwnerKey}.
|
|
4516
|
+
*
|
|
4517
|
+
* Idempotent: `false` (nothing written) when the row is absent here or already withdrawn,
|
|
4518
|
+
* `true` when this call filed the tombstone. Fires the guarded-delete listener, so a
|
|
4519
|
+
* withdrawal committed while alone is re-issued on cohort growth.
|
|
4520
|
+
*/
|
|
4521
|
+
async withdrawOwnerGrant(key, ownerKey, signMessage) {
|
|
4522
|
+
return this.withdrawIssuedRow('OwnerGrant', key, ownerKey, signMessage);
|
|
4523
|
+
}
|
|
4524
|
+
/**
|
|
4525
|
+
* Redeem a seated `OwnerGrant` on the issuer's behalf: seat `seatedKey` as an owner with NO
|
|
4526
|
+
* owner signature. One transaction writes the `OwnerKey` row (the grant branch of
|
|
4527
|
+
* `Authorized`: `VouchOwner` the issuer, `VouchSig` null, `VouchUsage` the usage stamp,
|
|
4528
|
+
* `Label` the grant's) and then the `OwnerGrantUsage` row that authorizes it; the usage
|
|
4529
|
+
* row's `SeatedExists` reads the owner row back, so the two are bound to this exact
|
|
4530
|
+
* redemption and neither can be held in reserve. The grant must already be committed
|
|
4531
|
+
* ({@link seatOwnerGrant}): both consent reads are of `committed.OwnerGrant`. No machine is
|
|
4532
|
+
* admitted and no peer id is named.
|
|
4533
|
+
*
|
|
4534
|
+
* The two signatures are minted where the private keys are (the grant's and the seated
|
|
4535
|
+
* key's), over {@link ownerGrantRedeemMessage} / {@link ownerGrantConsentMessage}; this
|
|
4536
|
+
* machine only verifies them, by writing the row.
|
|
4537
|
+
*
|
|
4538
|
+
* Idempotent, and a no-op for a key that is already an owner: when `seatedKey` has a live
|
|
4539
|
+
* `OwnerKey` row here nothing is written and no use is spent — `alreadyOwner: true` with
|
|
4540
|
+
* that row's stamp. The seat check ({@link assertOwnerGrantSeatRemains}) runs after that, so
|
|
4541
|
+
* the retry of a single-use redemption is answered `alreadyOwner` rather than refused as
|
|
4542
|
+
* exhausted. A key removed earlier is seated afresh under a new stamp (`NotRevoked` passes),
|
|
4543
|
+
* except on a machine that still holds its retired row, which refuses by name
|
|
4544
|
+
* ({@link liveOwnerKeyStamp}).
|
|
4545
|
+
*
|
|
4546
|
+
* Through {@link mutateCadrePeer}, so the membership hub is notified and the anchors follow;
|
|
4547
|
+
* inside the locked body every read passes `retry: false` and the stamp is minted per
|
|
4548
|
+
* attempt, so a {@link lockedWithRetry} re-run starts clean.
|
|
4549
|
+
*
|
|
4550
|
+
* @throws {InvitationExhaustedError} when the grant's uses are all spent (by name, ahead of
|
|
4551
|
+
* the schema's generic `Authorized` refusal); its `token` is the grant key.
|
|
4552
|
+
* {@link IssuedRowNotHereYetError} when no `OwnerGrant` row is readable here.
|
|
4553
|
+
*/
|
|
4554
|
+
async redeemOwnerGrant(params) {
|
|
4555
|
+
this.ensureInitialized();
|
|
4556
|
+
const { grantKey, seatedKey, usageStampId, grantSig, ownerSig } = params;
|
|
4557
|
+
const nowMs = params.nowMs ?? Date.now();
|
|
4558
|
+
log('Redeeming owner grant %s for %s', grantKey, seatedKey);
|
|
4559
|
+
return this.mutateCadrePeer('owner-grant-redeem', async () => {
|
|
4560
|
+
const grant = params.held ?? await this.queryOwnerGrant(grantKey, false);
|
|
4561
|
+
if (grant === null) {
|
|
4562
|
+
throw new IssuedRowNotHereYetError('OwnerGrant', grantKey);
|
|
4563
|
+
}
|
|
4564
|
+
const heldStamp = await this.liveOwnerKeyStamp(seatedKey);
|
|
4565
|
+
if (heldStamp !== null) {
|
|
4566
|
+
log('redeemOwnerGrant: %s is already an owner; nothing written', seatedKey);
|
|
4567
|
+
return { alreadyOwner: true, seatedStampId: heldStamp };
|
|
4568
|
+
}
|
|
4569
|
+
await this.assertOwnerGrantSeatRemains(grant);
|
|
4570
|
+
// Minted inside the body, so a re-run mints afresh over a rolled-back attempt rather
|
|
4571
|
+
// than re-presenting a stamp the usage row may already name.
|
|
4572
|
+
const seatedStampId = generateStampId(this.config.libp2pNode.peerId.toString());
|
|
4573
|
+
const nowCanonical = await canonicalDatetime(this.db, nowMs);
|
|
4574
|
+
await this.inTransaction('owner-grant-redeem', async () => {
|
|
4575
|
+
await this.db.exec(`
|
|
4576
|
+
insert into CadreControl.OwnerKey (Key, Label, StampId, VouchOwner, VouchSig, VouchUsage)
|
|
4577
|
+
with context OwnerKey = null, Signature = null
|
|
4578
|
+
values (?, ?, ?, ?, null, ?)
|
|
4579
|
+
`, [seatedKey, grant.label, seatedStampId, grant.issuerKey, usageStampId]);
|
|
4580
|
+
await this.db.exec(`
|
|
4581
|
+
insert into CadreControl.OwnerGrantUsage (UsageStampId, GrantKey, SeatedKey, SeatedStampId, GrantSig, OwnerSig)
|
|
4582
|
+
with context Now = ?
|
|
4583
|
+
values (?, ?, ?, ?, ?, ?)
|
|
4584
|
+
`, [nowCanonical, usageStampId, grantKey, seatedKey, seatedStampId, grantSig, ownerSig]);
|
|
4585
|
+
});
|
|
4586
|
+
log('Redeemed owner grant %s: %s seated as an owner (usage %s)', grantKey, seatedKey, usageStampId);
|
|
4587
|
+
return { alreadyOwner: false, seatedStampId };
|
|
4588
|
+
});
|
|
4589
|
+
}
|
|
4590
|
+
/** {@link assertCadreInviteSeatRemains} for an owner grant: refuse an exhausted grant by name, ahead of the write. */
|
|
4591
|
+
assertOwnerGrantSeatRemains(grant) {
|
|
4592
|
+
return this.assertIssuedSeatRemains(grant, key => this.countOwnerGrantUsage(key, false));
|
|
4593
|
+
}
|
|
3882
4594
|
/**
|
|
3883
4595
|
* Close the database and cleanup resources.
|
|
3884
4596
|
*
|
|
3885
|
-
*
|
|
4597
|
+
* Disposes every {@link watchTables} subscription before anything else, so a commit that
|
|
4598
|
+
* lands while the write chain drains schedules no run, and again before dropping the handle.
|
|
4599
|
+
*
|
|
4600
|
+
* Then drains the local-write chain: {@link withWriteLock} can park a write behind
|
|
3886
4601
|
* others across an await, and a queued closure evaluates `this.db!` only when it
|
|
3887
4602
|
* finally runs — so nulling the handle out from under it would throw a TypeError on a
|
|
3888
4603
|
* null handle instead of committing. The tail never rejects (it swallows both
|
|
@@ -3893,11 +4608,17 @@ export class ControlDatabase {
|
|
|
3893
4608
|
* deadline) if a write can ever hang.
|
|
3894
4609
|
*/
|
|
3895
4610
|
async close() {
|
|
4611
|
+
this.tableWatches.disposeAll();
|
|
3896
4612
|
await this.writeQueue;
|
|
3897
4613
|
if (this.collectionFactory) {
|
|
3898
4614
|
await this.collectionFactory.shutdown();
|
|
3899
4615
|
this.collectionFactory = null;
|
|
3900
4616
|
}
|
|
4617
|
+
this.txnBridge = null;
|
|
4618
|
+
// Again: a subscription taken during the awaits above would keep its timers past close.
|
|
4619
|
+
this.tableWatches.disposeAll();
|
|
4620
|
+
// Here, not first: a read started during the awaits above must not install either.
|
|
4621
|
+
this.invalidateMembershipViews();
|
|
3901
4622
|
if (this.db) {
|
|
3902
4623
|
void this.db.close();
|
|
3903
4624
|
this.db = null;
|