@serfab/cadre-core 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/dist/bootstrap-peer-store-file.d.ts +13 -0
- package/dist/bootstrap-peer-store-file.js +42 -0
- package/dist/bootstrap-peer-store-file.js.map +1 -0
- package/dist/bootstrap-peer-store.d.ts +73 -0
- package/dist/bootstrap-peer-store.js +128 -0
- package/dist/bootstrap-peer-store.js.map +1 -0
- package/dist/cadre-node.d.ts +918 -53
- package/dist/cadre-node.js +1843 -190
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-authorization.d.ts +93 -0
- package/dist/control-authorization.js +57 -0
- package/dist/control-authorization.js.map +1 -0
- package/dist/control-database.d.ts +990 -50
- package/dist/control-database.js +1459 -108
- package/dist/control-database.js.map +1 -1
- package/dist/control-formation-recorder.d.ts +89 -10
- package/dist/control-formation-recorder.js +129 -14
- package/dist/control-formation-recorder.js.map +1 -1
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +774 -210
- package/dist/control-schema.js.map +1 -1
- package/dist/control-stream.d.ts +38 -4
- package/dist/control-stream.js +70 -4
- package/dist/control-stream.js.map +1 -1
- package/dist/control-write-retry.d.ts +151 -0
- package/dist/control-write-retry.js +411 -0
- package/dist/control-write-retry.js.map +1 -0
- package/dist/delegate-admission.d.ts +134 -0
- package/dist/delegate-admission.js +244 -0
- package/dist/delegate-admission.js.map +1 -0
- package/dist/device-token.d.ts +11 -11
- package/dist/device-token.js +14 -13
- package/dist/device-token.js.map +1 -1
- package/dist/ed25519-key.d.ts +26 -0
- package/dist/ed25519-key.js +56 -1
- package/dist/ed25519-key.js.map +1 -1
- package/dist/file-durable-slot.d.ts +20 -0
- package/dist/file-durable-slot.js +53 -0
- package/dist/file-durable-slot.js.map +1 -0
- package/dist/formation-approval.d.ts +157 -0
- package/dist/formation-approval.js +375 -0
- package/dist/formation-approval.js.map +1 -0
- package/dist/fs-atomic.d.ts +38 -0
- package/dist/fs-atomic.js +97 -0
- package/dist/fs-atomic.js.map +1 -0
- package/dist/identity-key.d.ts +49 -0
- package/dist/identity-key.js +81 -0
- package/dist/identity-key.js.map +1 -0
- package/dist/index.d.ts +16 -6
- package/dist/index.js +39 -5
- package/dist/index.js.map +1 -1
- package/dist/key-store-file.d.ts +2 -24
- package/dist/key-store-file.js +10 -77
- package/dist/key-store-file.js.map +1 -1
- package/dist/membership-connection-gater.d.ts +146 -0
- package/dist/membership-connection-gater.js +180 -0
- package/dist/membership-connection-gater.js.map +1 -0
- package/dist/node-local-snapshot.d.ts +109 -0
- package/dist/node-local-snapshot.js +182 -0
- package/dist/node-local-snapshot.js.map +1 -0
- package/dist/peer-authorization.d.ts +135 -18
- package/dist/peer-authorization.js +152 -21
- package/dist/peer-authorization.js.map +1 -1
- package/dist/peer-record.d.ts +21 -8
- package/dist/peer-record.js +26 -10
- package/dist/peer-record.js.map +1 -1
- package/dist/relay-addrs.d.ts +45 -0
- package/dist/relay-addrs.js +103 -0
- package/dist/relay-addrs.js.map +1 -0
- package/dist/relay-reservation.d.ts +266 -0
- package/dist/relay-reservation.js +613 -0
- package/dist/relay-reservation.js.map +1 -0
- package/dist/seed-bootstrap.d.ts +221 -56
- package/dist/seed-bootstrap.js +328 -188
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/seed-trust-policy.d.ts +47 -21
- package/dist/seed-trust-policy.js +24 -15
- package/dist/seed-trust-policy.js.map +1 -1
- package/dist/strand-addr-protocol.d.ts +42 -13
- package/dist/strand-addr-protocol.js +58 -52
- package/dist/strand-addr-protocol.js.map +1 -1
- package/dist/strand-backfill.d.ts +109 -0
- package/dist/strand-backfill.js +279 -0
- package/dist/strand-backfill.js.map +1 -0
- package/dist/strand-formation-manager.d.ts +38 -9
- package/dist/strand-formation-manager.js +120 -17
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +103 -4
- package/dist/strand-formation-protocol.js +232 -4
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +39 -1
- package/dist/strand-instance-manager.js +92 -14
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-member-registry.d.ts +5 -5
- package/dist/strand-member-registry.js +18 -10
- package/dist/strand-member-registry.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +482 -88
- package/dist/strand-membership-writer.js +757 -136
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-solicitation.d.ts +99 -33
- package/dist/strand-solicitation.js +100 -52
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-transport-key.d.ts +40 -0
- package/dist/strand-transport-key.js +56 -0
- package/dist/strand-transport-key.js.map +1 -0
- package/dist/strand-wake-protocol.d.ts +9 -7
- package/dist/strand-wake-protocol.js +22 -46
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/strand-watcher.d.ts +17 -2
- package/dist/strand-watcher.js +47 -3
- package/dist/strand-watcher.js.map +1 -1
- package/dist/trusted-owner-store-file.d.ts +12 -0
- package/dist/trusted-owner-store-file.js +39 -0
- package/dist/trusted-owner-store-file.js.map +1 -0
- package/dist/trusted-owner-store.d.ts +64 -0
- package/dist/trusted-owner-store.js +126 -0
- package/dist/trusted-owner-store.js.map +1 -0
- package/dist/types.d.ts +275 -20
- package/dist/types.js +13 -0
- package/dist/types.js.map +1 -1
- package/package.json +19 -8
- package/src/bootstrap-peer-store-file.ts +44 -0
- package/src/bootstrap-peer-store.ts +172 -0
- package/src/cadre-node.ts +1992 -202
- package/src/control-authorization.ts +114 -0
- package/src/control-database.ts +1704 -146
- package/src/control-formation-recorder.ts +172 -19
- package/src/control-schema.ts +786 -222
- package/src/control-stream.ts +81 -4
- package/src/control-write-retry.ts +473 -0
- package/src/delegate-admission.ts +302 -0
- package/src/device-token.ts +18 -13
- package/src/ed25519-key.ts +134 -69
- package/src/file-durable-slot.ts +61 -0
- package/src/formation-approval.ts +604 -0
- package/src/fs-atomic.ts +107 -0
- package/src/identity-key.ts +109 -0
- package/src/index.ts +136 -5
- package/src/key-store-file.ts +10 -81
- package/src/membership-connection-gater.ts +204 -0
- package/src/node-local-snapshot.ts +260 -0
- package/src/peer-authorization.ts +199 -21
- package/src/peer-record.ts +28 -10
- package/src/relay-addrs.ts +111 -0
- package/src/relay-reservation.ts +823 -0
- package/src/seed-bootstrap.ts +397 -197
- package/src/seed-trust-policy.ts +58 -25
- package/src/strand-addr-protocol.ts +84 -53
- package/src/strand-backfill.ts +366 -0
- package/src/strand-formation-manager.ts +145 -19
- package/src/strand-formation-protocol.ts +309 -8
- package/src/strand-instance-manager.ts +111 -14
- package/src/strand-member-registry.ts +18 -13
- package/src/strand-membership-writer.ts +1430 -640
- package/src/strand-solicitation.ts +155 -90
- package/src/strand-transport-key.ts +58 -0
- package/src/strand-wake-protocol.ts +29 -46
- package/src/strand-watcher.ts +66 -3
- package/src/trusted-owner-store-file.ts +41 -0
- package/src/trusted-owner-store.ts +171 -0
- package/src/types.ts +292 -21
package/dist/control-database.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import debug from 'debug';
|
|
2
2
|
import { toString as uint8ArrayToString } from 'uint8arrays';
|
|
3
|
-
import { Database, registerPlugin } from '@quereus/quereus';
|
|
3
|
+
import { Database, registerPlugin, unwrapError } from '@quereus/quereus';
|
|
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
7
|
import { CONTROL_SCHEMA } from './control-schema.js';
|
|
8
8
|
import { canonicalDatetime } from './canonical-datetime.js';
|
|
9
|
+
import { controlAuthorizationFields, CONTROL_TABLES } from './control-authorization.js';
|
|
10
|
+
import { requireEd25519PublicKeyB64 } from './ed25519-key.js';
|
|
11
|
+
import { retryControlWrite, SCHEMA_INIT_RETRY_POLICY } from './control-write-retry.js';
|
|
9
12
|
const log = debug('sereus:cadre:control-db');
|
|
10
13
|
const timing = debug('sereus:cadre:timing');
|
|
11
14
|
/**
|
|
@@ -29,6 +32,135 @@ export function generateStampId(peerId) {
|
|
|
29
32
|
// Convert to base64url
|
|
30
33
|
return uint8ArrayToString(combined, 'base64url');
|
|
31
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* A `FormationUsage` was to be recorded against a host `Strand` row that is not
|
|
37
|
+
* present locally.
|
|
38
|
+
*
|
|
39
|
+
* Consent is bound to the strand's one-off `StampId` (see the consent branch of
|
|
40
|
+
* `Strand.AuthorizedInsert`), so the writer must read the live row before inserting.
|
|
41
|
+
* The ordinary "host strand has not converged on this responder yet" case is caught
|
|
42
|
+
* earlier and reported as `missing` by `ControlFormationUsageRecorder.resolveStrand`;
|
|
43
|
+
* reaching here means the row vanished between that check and the write, which is a
|
|
44
|
+
* genuine race and is surfaced rather than left to fail the deferred `StrandExists`
|
|
45
|
+
* CHECK at commit (which would drop the write with a far less legible error).
|
|
46
|
+
*/
|
|
47
|
+
export class MissingHostStrandError extends Error {
|
|
48
|
+
constructor(strandId, token) {
|
|
49
|
+
super(`Cannot record formation usage for token ${token}: host strand ${strandId} is not present`);
|
|
50
|
+
this.strandId = strandId;
|
|
51
|
+
this.token = token;
|
|
52
|
+
this.name = 'MissingHostStrandError';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* A formation write was abandoned because the caller's `AbortSignal` fired BEFORE the
|
|
57
|
+
* `FormationUsage` insert was issued — the invite's single use is NOT spent.
|
|
58
|
+
*
|
|
59
|
+
* Thrown by the formation write paths ({@link ControlDatabase.recordFormationUsage},
|
|
60
|
+
* {@link ControlDatabase.redeemInvitation}) and by `ControlFormationUsageRecorder`'s
|
|
61
|
+
* pre-approval checks when the responder's provisioning budget expires while the work is
|
|
62
|
+
* still queued (e.g. behind the write lock) or still asking the approval hook. Never
|
|
63
|
+
* thrown once the insert has been issued: a half-issued write must be allowed to land,
|
|
64
|
+
* and the formation listener's settle grace then adopts it as a successful join. The
|
|
65
|
+
* manager rethrows this error instead of mapping it to a retryable conflict — the
|
|
66
|
+
* listener's timeout path owns the reply.
|
|
67
|
+
*/
|
|
68
|
+
export class FormationAbortedError extends Error {
|
|
69
|
+
constructor(token, operation, options) {
|
|
70
|
+
super(`Formation ${operation} for token ${token} was aborted before its write was issued`, options);
|
|
71
|
+
this.token = token;
|
|
72
|
+
this.name = 'FormationAbortedError';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* A redemption could not be given a `UseNumber` within the invite's seat budget: the use
|
|
77
|
+
* number the writer would have to take is past `FormationInvite.TotalUses`, so the
|
|
78
|
+
* invitation is spent.
|
|
79
|
+
*
|
|
80
|
+
* Raised inside {@link ControlDatabase.redeemInvitation} / {@link ControlDatabase.recordFormationUsage}
|
|
81
|
+
* on EVERY attempt, including the first: two redemptions of one token racing on the SAME node
|
|
82
|
+
* serialize behind the local write queue, so the loser's first attempt already takes a use
|
|
83
|
+
* number past the seat budget — `StrandFormationManager.validateToken`'s `isTokenUsed` check
|
|
84
|
+
* cannot catch this, since it ran before either redemption wrote anything. Without this the
|
|
85
|
+
* write would fail as a generic `CHECK constraint failed: Authorized`, which the manager
|
|
86
|
+
* reports as a retryable `Formation conflict, retry` — telling the joiner to retry something
|
|
87
|
+
* that can never succeed.
|
|
88
|
+
*/
|
|
89
|
+
export class InvitationExhaustedError extends Error {
|
|
90
|
+
constructor(token, useNumber, totalUses) {
|
|
91
|
+
super(`Invitation ${token} is exhausted: use #${useNumber} is past its limit of ${totalUses} use(s)`);
|
|
92
|
+
this.token = token;
|
|
93
|
+
this.useNumber = useNumber;
|
|
94
|
+
this.totalUses = totalUses;
|
|
95
|
+
this.name = 'InvitationExhaustedError';
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Attempts allowed per redemption: the first, plus two retries of a use number another
|
|
100
|
+
* writer took. Bounded so sustained contention terminates in a clean rejection rather than
|
|
101
|
+
* spinning; two retries is enough for the realistic case (a handful of nodes redeeming one
|
|
102
|
+
* multi-use token at once) without holding the write lock hostage.
|
|
103
|
+
*
|
|
104
|
+
* NOTE: the attempts fire back-to-back, with no backoff and no jitter — each one only takes
|
|
105
|
+
* and releases the write lock. Fine while cross-node contention on a single token is a
|
|
106
|
+
* handful of writers; if one hot multi-use token ever draws sustained concurrent redemption
|
|
107
|
+
* (a broadcast invite, a bulk onboarding run), all three attempts can burn against the same
|
|
108
|
+
* competitor and the caller gets the engine's error. Add a short randomised delay between
|
|
109
|
+
* attempts then — NOT a longer attempt budget, which would hold the write queue.
|
|
110
|
+
*/
|
|
111
|
+
const USE_NUMBER_ATTEMPTS = 3;
|
|
112
|
+
/**
|
|
113
|
+
* The two error messages that mean "another writer took the `UseNumber` we picked", and
|
|
114
|
+
* nothing else.
|
|
115
|
+
*
|
|
116
|
+
* - the `(Token, UseNumber)` primary-key collision, which the optimystic vtab reports on the
|
|
117
|
+
* insert itself (`uniqueConstraintMessage` renders the columns table-qualified, no schema
|
|
118
|
+
* prefix);
|
|
119
|
+
* - `CHECK constraint failed: Monotonic` — `FormationUsage.Monotonic` contains a subquery, so
|
|
120
|
+
* Quereus defers it to commit where it reads `committed.FormationUsage`. A concurrent row
|
|
121
|
+
* that our insert's key probe did not see still trips it there. Same meaning, different
|
|
122
|
+
* surface.
|
|
123
|
+
*
|
|
124
|
+
* Deliberately NOT matched: `FormationUsage.UsageStampId`'s `unique` (a repeated nonce is
|
|
125
|
+
* replay, never a race — retrying it would re-present a spent approval), and every named
|
|
126
|
+
* CHECK other than `Monotonic`. `Authorized` in particular renders with the same
|
|
127
|
+
* `CHECK constraint failed:` prefix, so the constraint NAME is anchored with `\b` exactly as
|
|
128
|
+
* `expectConstraintFailure` does rather than matched by prefix.
|
|
129
|
+
*
|
|
130
|
+
* NOTE: a CHECK failure names only the CONSTRAINT, never its table, and `Monotonic` is a
|
|
131
|
+
* generic enough name that a second table could plausibly claim it. Today it is unique across
|
|
132
|
+
* `schemas/control.qsql`, so the pattern cannot mis-fire; if another table ever declares a
|
|
133
|
+
* `Monotonic` constraint, a failure of THAT constraint would be read here as a lost use-number
|
|
134
|
+
* race and pointlessly retried up to {@link USE_NUMBER_ATTEMPTS} times. Rename the other
|
|
135
|
+
* constraint (cheapest) rather than trying to recover the table from the message.
|
|
136
|
+
*/
|
|
137
|
+
const LOST_USE_NUMBER_PATTERNS = [
|
|
138
|
+
/UNIQUE constraint failed: FormationUsage\.Token, FormationUsage\.UseNumber/i,
|
|
139
|
+
/CHECK constraint failed: Monotonic\b/,
|
|
140
|
+
];
|
|
141
|
+
/**
|
|
142
|
+
* Did this write fail purely because another writer took the `UseNumber` it picked — i.e. is
|
|
143
|
+
* re-presenting the SAME approval under the next free use number the right response?
|
|
144
|
+
*
|
|
145
|
+
* Classifies by MESSAGE, walking the `cause` chain with Quereus' own {@link unwrapError}:
|
|
146
|
+
* neither surface can be recognised by type. The deferred `Monotonic` failure is thrown as a
|
|
147
|
+
* bare `QuereusError`, not a `ConstraintError` (see
|
|
148
|
+
* `quereus/src/runtime/deferred-constraint-queue.ts`), so an `instanceof ConstraintError` gate
|
|
149
|
+
* would silently drop the branch this retry exists for. Messages also carry an
|
|
150
|
+
* ` (at line N, column M)` suffix and arrive wrapped when the write ran inside a transaction,
|
|
151
|
+
* so the match is a substring test rather than an equality one.
|
|
152
|
+
*
|
|
153
|
+
* NOTE: this is the one piece of the retry that depends on engine error TEXT. Its own test
|
|
154
|
+
* (`control-formation-use-number-retry.spec.ts`) produces all three messages from the real
|
|
155
|
+
* engine rather than from string literals, so a reworded storage-layer error fails that spec
|
|
156
|
+
* instead of silently disabling the retry.
|
|
157
|
+
*/
|
|
158
|
+
export function isLostUseNumberRace(error) {
|
|
159
|
+
if (!(error instanceof Error)) {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
return unwrapError(error).some(({ message }) => LOST_USE_NUMBER_PATTERNS.some(pattern => pattern.test(message)));
|
|
163
|
+
}
|
|
32
164
|
/**
|
|
33
165
|
* Parse a stored Quereus `datetime` value into epoch milliseconds.
|
|
34
166
|
*
|
|
@@ -43,40 +175,123 @@ function parseStoredDatetimeMs(value) {
|
|
|
43
175
|
const hasZone = /[zZ]$|[+-]\d\d:?\d\d$/.test(value);
|
|
44
176
|
return new Date(hasZone ? value : `${value}Z`).getTime();
|
|
45
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* Parse a nullable stored `datetime` column into epoch ms, mapping both "absent"
|
|
180
|
+
* and "unparseable" to null — i.e. "no expiry". Shared by every `ExpiresAt`
|
|
181
|
+
* reader so the NaN guard cannot drift between them.
|
|
182
|
+
*/
|
|
183
|
+
function parseNullableStoredDatetimeMs(value) {
|
|
184
|
+
if (value === null || value === undefined)
|
|
185
|
+
return null;
|
|
186
|
+
const parsed = parseStoredDatetimeMs(value);
|
|
187
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
188
|
+
}
|
|
46
189
|
/**
|
|
47
190
|
* Build the canonical authorization message that owner signatures are bound to.
|
|
48
191
|
*
|
|
49
|
-
* The message is a SINGLE framed SHA-256 digest over the ordered
|
|
50
|
-
* crypto plugin's injective multi-field
|
|
51
|
-
*
|
|
192
|
+
* The message is a SINGLE framed SHA-256 digest over the ordered field vector from
|
|
193
|
+
* {@link controlAuthorizationFields} (the crypto plugin's injective multi-field
|
|
194
|
+
* encoding): two fixed literals — the domain tag naming the table rule and the action
|
|
195
|
+
* tag — followed by the row fields in the schema's fixed order, with the single-use
|
|
196
|
+
* StampId as the final field where the table has one:
|
|
52
197
|
*
|
|
53
|
-
* message = sha256(encodeFields([
|
|
198
|
+
* message = sha256(encodeFields([domain, action, field_1, ..., StampId])) // raw digest bytes
|
|
54
199
|
*
|
|
55
200
|
* ed25519 signs these raw digest bytes DIRECTLY (no second hash). The SQL constraints
|
|
56
201
|
* verify the identical bytes with one variadic call
|
|
57
|
-
* (`verify(digest(field_1,
|
|
58
|
-
* `digest(...)` returns the base64url string of the same digest, which `verify`'s default
|
|
202
|
+
* (`verify(digest('CadreControl.X', 'add', field_1, ...), context.Signature, A.Key, 'ed25519')`):
|
|
203
|
+
* SQL `digest(...)` returns the base64url string of the same digest, which `verify`'s default
|
|
59
204
|
* base64url input encoding decodes back to those raw bytes — so signer and verifier
|
|
60
205
|
* operate on the same bytes. Every field is TEXT on both sides (the SQL columns are
|
|
61
206
|
* `cast(... as text)` / `coalesce(...,'')`; the TS args are strings), so the per-field
|
|
62
|
-
* type tags agree.
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
207
|
+
* type tags agree. Binding the row contents closes captured-stamp replay; the leading
|
|
208
|
+
* domain/action tags scope the signature to ONE table rule, so an approval minted for
|
|
209
|
+
* one constraint can never satisfy another (e.g. a ValidationKey enrollment can no
|
|
210
|
+
* longer double as an OwnerKey enrollment). Single source of truth: every signed writer
|
|
211
|
+
* (and every test/harness signer) MUST build the message through this function with the
|
|
212
|
+
* schema's tags and field order, or `verify` will reject the row.
|
|
213
|
+
*/
|
|
214
|
+
export function buildAuthorizationMessage(domain, action, rowFields) {
|
|
215
|
+
return digest(controlAuthorizationFields(domain, action, rowFields), 'sha256', 'bytes');
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* The exact bytes an outside approver signs to authorize ONE redemption of a
|
|
219
|
+
* `ValidationUrl`-bearing `FormationInvite` — the TS mirror of the `'vouch'` digest in
|
|
220
|
+
* `FormationUsage.Authorized`, in the schema's field order.
|
|
221
|
+
*
|
|
222
|
+
* The approval covers the whole redemption: the invitation (`token`), the single-use nonce
|
|
223
|
+
* the JOINING peer minted for THIS redemption (`usageStampId`), the network being joined
|
|
224
|
+
* (`strandId`), the joining peer's own ed25519 public key (`peerKey`), and the disclosure
|
|
225
|
+
* text. That makes it non-transferable — an approval cannot be re-presented for another use
|
|
226
|
+
* of the same invitation, another network, or another joiner — and the nonce's `unique`
|
|
227
|
+
* column makes a verbatim re-presentation a duplicate-row rejection. The joiner mints the
|
|
228
|
+
* nonce ({@link generateStampId}) and sends it in its contact message; sign these bytes over
|
|
229
|
+
* that same nonce, then pass BOTH the nonce and the signature to
|
|
230
|
+
* {@link ControlDatabase.redeemInvitation} / {@link ControlDatabase.recordFormationUsage}:
|
|
231
|
+
* signing one nonce and inserting another fails the CHECK.
|
|
66
232
|
*/
|
|
67
|
-
export function
|
|
68
|
-
return
|
|
233
|
+
export function formationVouchMessage(fields) {
|
|
234
|
+
return buildAuthorizationMessage('CadreControl.FormationUsage', 'vouch', [
|
|
235
|
+
fields.token, fields.usageStampId, fields.strandId, fields.peerKey, fields.disclosure,
|
|
236
|
+
]);
|
|
69
237
|
}
|
|
70
|
-
/**
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
238
|
+
/**
|
|
239
|
+
* The exact bytes the JOINING peer signs to consent to ONE redemption of a
|
|
240
|
+
* `FormationInvite` — the TS mirror of the `'consent'` digest in the schema's
|
|
241
|
+
* `FormationUsage.PeerConsented` constraint, in its field order.
|
|
242
|
+
*
|
|
243
|
+
* A sibling of {@link formationVouchMessage}, not a replacement: the approver's vouch
|
|
244
|
+
* says the redemption may proceed, this says the joiner itself agreed to it, and the
|
|
245
|
+
* two are signed by different keys over the same nonce so neither can stand in for
|
|
246
|
+
* the other. Verify the result with `peer-authorization.ts`'s
|
|
247
|
+
* `verifyFormationConsent`, whose `formationConsentDigest` is the base64url twin of
|
|
248
|
+
* this vector — the two must stay in lockstep.
|
|
249
|
+
*
|
|
250
|
+
* `strandId` is deliberately absent, unlike its vouch sibling: the joiner cannot know
|
|
251
|
+
* the strand when it signs (a bound invite's host strand arrives only in the result
|
|
252
|
+
* frame; an unbound strand is minted by the responder). The responder cannot
|
|
253
|
+
* substitute one anyway — a bound invite is pinned to its own strand by `Authorized`,
|
|
254
|
+
* and an unbound redemption mints a fresh strand, so there is no victim to name.
|
|
255
|
+
*/
|
|
256
|
+
export function formationConsentMessage(fields) {
|
|
257
|
+
return buildAuthorizationMessage('CadreControl.FormationUsage', 'consent', [
|
|
258
|
+
fields.token, fields.usageStampId, fields.peerKey, fields.disclosure,
|
|
259
|
+
]);
|
|
260
|
+
}
|
|
261
|
+
/** Runtime guard for the dynamic-`from` count, over the one table list. */
|
|
262
|
+
const CONTROL_TABLE_SET = new Set(CONTROL_TABLES);
|
|
263
|
+
/**
|
|
264
|
+
* Which column identifies one row of each guarded table — the ONLY place that mapping
|
|
265
|
+
* lives, so a stamp read and the delete that binds to it can never disagree about it.
|
|
266
|
+
* Derived rather than passed: every guarded table has exactly one primary key, so a
|
|
267
|
+
* `(table, keyColumn)` pair on a signature is a mismatch waiting to happen.
|
|
268
|
+
*/
|
|
269
|
+
const GUARDED_KEY_COLUMN = {
|
|
270
|
+
OwnerKey: 'Key',
|
|
271
|
+
CadrePeer: 'PeerId',
|
|
272
|
+
ValidationKey: 'Key',
|
|
273
|
+
Strand: 'Id',
|
|
274
|
+
DeviceToken: 'PeerId',
|
|
275
|
+
};
|
|
276
|
+
/**
|
|
277
|
+
* Guarded tables a node may reap locally once their tombstone has committed — the
|
|
278
|
+
* tables whose `AuthorizedDelete` carries the REAP branch (see the constraint comment on
|
|
279
|
+
* `CadrePeer.AuthorizedDelete` in the schema). `Strand` is deliberately absent: its row
|
|
280
|
+
* carries `MemberPrivateKey`, the party's own membership secret for that network, stored
|
|
281
|
+
* nowhere else (tickets/backlog/debt-strand-tombstone-reap.md owns any future change).
|
|
282
|
+
* `OwnerKey` has no production removal path and `MinOneOwner` makes an automated
|
|
283
|
+
* owner-key reap a party-bricking hazard.
|
|
284
|
+
*/
|
|
285
|
+
export const REAPABLE_TABLES = ['CadrePeer', 'DeviceToken', 'ValidationKey'];
|
|
286
|
+
/**
|
|
287
|
+
* Runtime membership test for {@link REAPABLE_TABLES}, narrowing a tombstone's
|
|
288
|
+
* `TableName` to the subset {@link ControlDatabase.reapRevokedRow} accepts. A tombstone
|
|
289
|
+
* naming an excluded table (`Strand`, `OwnerKey`) is skipped silently — `Strand`
|
|
290
|
+
* tombstones are the common case in a party that has unpublished a strand, so a log line
|
|
291
|
+
* per skip would be per-pass noise.
|
|
292
|
+
*/
|
|
293
|
+
const REAPABLE_TABLE_SET = new Set(REAPABLE_TABLES);
|
|
294
|
+
const isReapableTable = (table) => REAPABLE_TABLE_SET.has(table);
|
|
80
295
|
/**
|
|
81
296
|
* ControlDatabase manages the CadreControl schema using Quereus with Optimystic backend.
|
|
82
297
|
* It provides typed query methods for accessing control network data.
|
|
@@ -86,6 +301,17 @@ export class ControlDatabase {
|
|
|
86
301
|
this.db = null;
|
|
87
302
|
this.collectionFactory = null;
|
|
88
303
|
this.initialized = false;
|
|
304
|
+
this.membershipListener = null;
|
|
305
|
+
this.guardedDeleteListener = null;
|
|
306
|
+
/** Tail of the local-write chain — see {@link withWriteLock}. */
|
|
307
|
+
this.writeQueue = Promise.resolve();
|
|
308
|
+
/**
|
|
309
|
+
* Pacing seams for {@link lockedWithRetry}. Production leaves this empty (real backoff,
|
|
310
|
+
* real clock); specs inject a recorded `sleep` / fake `now` so no test waits out a real
|
|
311
|
+
* backoff. Applied OVER the call site's policy, so it overrides pacing without a spec
|
|
312
|
+
* having to restate the policy's attempts or classifier.
|
|
313
|
+
*/
|
|
314
|
+
this.controlWriteRetryPacing = {};
|
|
89
315
|
this.config = config;
|
|
90
316
|
}
|
|
91
317
|
/**
|
|
@@ -188,7 +414,36 @@ export class ControlDatabase {
|
|
|
188
414
|
log('Using embedded control schema');
|
|
189
415
|
schemaContent = CONTROL_SCHEMA;
|
|
190
416
|
}
|
|
191
|
-
|
|
417
|
+
// Schema init is a DISTRIBUTED write, so it gets the same bounded transient-failure
|
|
418
|
+
// retry every other control write does ({@link lockedWithRetry}). Every CadreControl
|
|
419
|
+
// table is optimystic-backed (the default vtab was routed to optimystic just above),
|
|
420
|
+
// so each `create table` needs a super-majority of the party's peers to answer. On a
|
|
421
|
+
// node joining a party that already has two live members a single unanswered peer
|
|
422
|
+
// during any one create otherwise kills startup outright.
|
|
423
|
+
//
|
|
424
|
+
// Re-running the WHOLE `exec` after a partial failure is safe, and this is the
|
|
425
|
+
// non-obvious part:
|
|
426
|
+
//
|
|
427
|
+
// - `apply schema` is a DIFF, not a replay. Quereus collects the live catalog, diffs
|
|
428
|
+
// the declared schema against it, and only emits DDL for what is missing — which is
|
|
429
|
+
// exactly why `initialize` hydrates persisted optimystic schemas BEFORE getting here.
|
|
430
|
+
// Tables that already landed generate no statements on the second pass.
|
|
431
|
+
// - a failed `create table` leaves the catalog CLEAN. `SchemaManager.createTable` calls
|
|
432
|
+
// the module's `create` first and only registers the table on success, so the table
|
|
433
|
+
// that failed is not in the catalog, and neither are the ones after it.
|
|
434
|
+
//
|
|
435
|
+
// Together: attempt 2 re-emits exactly the failed table and its successors. No
|
|
436
|
+
// `if not exists` juggling, no per-table loop, no statement splitting.
|
|
437
|
+
//
|
|
438
|
+
// Only the "cohort did not answer, nothing committed" class is retried; the classifier
|
|
439
|
+
// vetoes indeterminate commits and does not match `Missing block`, which is a durable
|
|
440
|
+
// convergence fault a retry cannot heal (tracked separately) and must keep propagating.
|
|
441
|
+
//
|
|
442
|
+
// This is the ONE call site on a non-default policy: the re-run safety argued above is
|
|
443
|
+
// also what lets schema init absorb optimystic's self-coordination grace refusal, which
|
|
444
|
+
// is unsafe for control writes in general. Why, on
|
|
445
|
+
// `RETRIABLE_SCHEMA_INIT_MATCHERS` in `control-write-retry.ts`.
|
|
446
|
+
await this.lockedWithRetry(() => this.db.exec(schemaContent), SCHEMA_INIT_RETRY_POLICY);
|
|
192
447
|
log('Schema loaded and executed');
|
|
193
448
|
}
|
|
194
449
|
/**
|
|
@@ -226,7 +481,7 @@ export class ControlDatabase {
|
|
|
226
481
|
/**
|
|
227
482
|
* Count rows in a CadreControl table as seen by THIS database instance.
|
|
228
483
|
*
|
|
229
|
-
* `table` is validated against {@link
|
|
484
|
+
* `table` is validated against {@link CONTROL_TABLE_SET} before it is interpolated
|
|
230
485
|
* into the `from` clause: the names are not user input, but the check keeps the
|
|
231
486
|
* dynamic query off the injection surface and fails loudly on a typo instead of
|
|
232
487
|
* emitting a malformed statement. The count reflects only the rows this node's
|
|
@@ -236,7 +491,7 @@ export class ControlDatabase {
|
|
|
236
491
|
*/
|
|
237
492
|
async countRows(table) {
|
|
238
493
|
this.ensureInitialized();
|
|
239
|
-
if (!
|
|
494
|
+
if (!CONTROL_TABLE_SET.has(table)) {
|
|
240
495
|
throw new Error(`Unknown CadreControl table: ${table}`);
|
|
241
496
|
}
|
|
242
497
|
for await (const row of this.db.eval(`select count(1) as Count from CadreControl.${table}`)) {
|
|
@@ -269,11 +524,12 @@ export class ControlDatabase {
|
|
|
269
524
|
*/
|
|
270
525
|
async ensureOwnerKey(key) {
|
|
271
526
|
this.ensureInitialized();
|
|
527
|
+
const trimmed = requireEd25519PublicKeyB64(key, 'owner key');
|
|
272
528
|
if (await this.hasOwnerKey()) {
|
|
273
529
|
log('Owner key already present; skipping genesis insert');
|
|
274
530
|
return false;
|
|
275
531
|
}
|
|
276
|
-
await this.insertOwnerKey(
|
|
532
|
+
await this.insertOwnerKey(trimmed);
|
|
277
533
|
return true;
|
|
278
534
|
}
|
|
279
535
|
/**
|
|
@@ -294,15 +550,34 @@ export class ControlDatabase {
|
|
|
294
550
|
}
|
|
295
551
|
/**
|
|
296
552
|
* Enumerate the CadrePeer rows (cadre membership) for admin/membership reads.
|
|
553
|
+
* Includes the persisted voucher columns, which
|
|
554
|
+
* {@link CadreNode.listAuthorizedMembers} re-checks against its node-local anchor.
|
|
555
|
+
*
|
|
556
|
+
* Rows whose `StampId` is retired in `CadreControl.Revocation` are excluded HERE, so
|
|
557
|
+
* every membership reader inherits the exclusion — a revoked peer is neither
|
|
558
|
+
* authorizable nor addressable (a replayed row planted at a retired stamp must not
|
|
559
|
+
* be RPC'd or handed out as an address). Reads the retired set through
|
|
560
|
+
* {@link queryRevokedStamps} rather than inlining the SQL, so tests can interpose on
|
|
561
|
+
* that seam to model the replicated live-row-plus-tombstone merge state.
|
|
562
|
+
*
|
|
563
|
+
* NOTE: runs a second query (the retired-stamp set) per call, and this sits on the
|
|
564
|
+
* membership-gate refresh path; control tables are a handful of rows today, so this
|
|
565
|
+
* is cheap — if they ever grow, fold the exclusion into one statement or cache the
|
|
566
|
+
* retired-stamp set.
|
|
297
567
|
*/
|
|
298
568
|
async queryCadrePeers() {
|
|
299
569
|
this.ensureInitialized();
|
|
570
|
+
const revoked = await this.queryRevokedStamps('CadrePeer');
|
|
300
571
|
const rows = [];
|
|
301
572
|
for await (const row of this.db.eval('select PeerId, Multiaddr, StampId, VouchOwner, VouchSig from CadreControl.CadrePeer')) {
|
|
573
|
+
const stampId = row.StampId ?? null;
|
|
574
|
+
if (stampId !== null && revoked.has(stampId)) {
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
302
577
|
rows.push({
|
|
303
578
|
peerId: row.PeerId,
|
|
304
579
|
multiaddr: row.Multiaddr ?? null,
|
|
305
|
-
stampId
|
|
580
|
+
stampId,
|
|
306
581
|
vouchOwner: row.VouchOwner ?? null,
|
|
307
582
|
vouchSig: row.VouchSig ?? null,
|
|
308
583
|
});
|
|
@@ -310,22 +585,127 @@ export class ControlDatabase {
|
|
|
310
585
|
return rows;
|
|
311
586
|
}
|
|
312
587
|
/**
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
* {@link
|
|
588
|
+
* Every approver public key currently enrolled in `CadreControl.ValidationKey` — the
|
|
589
|
+
* set a `ValidationUrl` redemption's approval signature must be signed by (see
|
|
590
|
+
* {@link ControlFormationUsageRecorder}, which checks one key at a time via
|
|
591
|
+
* {@link queryValidationKeyStampId}).
|
|
592
|
+
*
|
|
593
|
+
* Sorted in TypeScript rather than SQL so the order is stable regardless of storage
|
|
594
|
+
* order; the enrolled set is a handful of keys, so the sort is free.
|
|
595
|
+
*/
|
|
596
|
+
async queryValidationKeys() {
|
|
597
|
+
this.ensureInitialized();
|
|
598
|
+
const keys = [];
|
|
599
|
+
for await (const row of this.db.eval('select Key from CadreControl.ValidationKey')) {
|
|
600
|
+
keys.push(row.Key);
|
|
601
|
+
}
|
|
602
|
+
return keys.sort();
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Read one guarded row's single-use `StampId` nonce (null when the row does not
|
|
606
|
+
* exist). Every owner-signed delete / re-touch path must bind its signature to the
|
|
607
|
+
* row's CURRENT nonce, so they all read through here first.
|
|
608
|
+
*
|
|
609
|
+
* Deliberately RAW — no `Revocation` filtering (unlike {@link queryCadrePeers} /
|
|
610
|
+
* {@link queryPeerRecord}): {@link deleteGuardedRow} and the insert-if-absent guard
|
|
611
|
+
* in the `CadrePeer` upsert need to see a physically present row. Filtering here
|
|
612
|
+
* would make an insert-if-absent guard read "absent" for a row that exists and
|
|
613
|
+
* collide on the primary key.
|
|
614
|
+
*
|
|
615
|
+
* `table` and its {@link GUARDED_KEY_COLUMN} column are interpolated into the SQL; both
|
|
616
|
+
* come from closed literal unions — no caller-supplied string can reach the statement
|
|
617
|
+
* (same injection-surface discipline as {@link countRows}'s `CONTROL_TABLE_SET` guard).
|
|
317
618
|
*/
|
|
318
|
-
async
|
|
619
|
+
async queryStampId(table, keyValue) {
|
|
319
620
|
this.ensureInitialized();
|
|
320
|
-
|
|
621
|
+
const sql = `select StampId from CadreControl.${table} where ${GUARDED_KEY_COLUMN[table]} = ?`;
|
|
622
|
+
for await (const row of this.db.eval(sql, [keyValue])) {
|
|
321
623
|
return row.StampId ?? null;
|
|
322
624
|
}
|
|
323
625
|
return null;
|
|
324
626
|
}
|
|
627
|
+
/**
|
|
628
|
+
* `CadrePeer` stamp nonce — read by {@link SeedBootstrapService.removePeer} as its
|
|
629
|
+
* row-present gate before it delegates to {@link deleteCadrePeer}. The in-class
|
|
630
|
+
* `CadrePeer` writers ({@link insertCadrePeer}, {@link reauthorizeCadrePeer}) go
|
|
631
|
+
* straight to {@link queryStampId}.
|
|
632
|
+
*/
|
|
633
|
+
queryCadrePeerStampId(peerId) {
|
|
634
|
+
return this.queryStampId('CadrePeer', peerId);
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* `DeviceToken` stamp nonce. {@link deleteDeviceToken} reads the stamp itself, so this
|
|
638
|
+
* is the reader for callers that need to observe a live row's nonce — asserting a token
|
|
639
|
+
* was seated, or that a removal retired the stamp it named.
|
|
640
|
+
*/
|
|
641
|
+
queryDeviceTokenStampId(peerId) {
|
|
642
|
+
return this.queryStampId('DeviceToken', peerId);
|
|
643
|
+
}
|
|
644
|
+
/** `Strand` stamp nonce — bound into {@link deleteStrand}'s remove digest. */
|
|
645
|
+
queryStrandStampId(strandId) {
|
|
646
|
+
return this.queryStampId('Strand', strandId);
|
|
647
|
+
}
|
|
648
|
+
/** `ValidationKey` stamp nonce — bound into {@link deleteValidationKey}'s remove digest. */
|
|
649
|
+
queryValidationKeyStampId(key) {
|
|
650
|
+
return this.queryStampId('ValidationKey', key);
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Collect the retired `StampId` nonces recorded in `CadreControl.Revocation` for one
|
|
654
|
+
* {@link RevocableTable}. A stamp
|
|
655
|
+
* lands here when its row is removed ({@link SeedBootstrapService.removePeer},
|
|
656
|
+
* {@link deleteValidationKey}, {@link deleteStrand}), and retirement is permanent —
|
|
657
|
+
* rows are never deleted (only their `ReissuedAt` counter moves, via
|
|
658
|
+
* {@link reissueRevocations}). Read-side mitigation for the write-time race: the
|
|
659
|
+
* schema's `NotRevoked` CHECK only sees locally visible tombstones, so a node that
|
|
660
|
+
* converged on a resurrected row before its tombstone can hold both; readers
|
|
661
|
+
* ({@link queryCadrePeers}, {@link queryPeerRecord},
|
|
662
|
+
* {@link CadreNode.resolveDeviceToken}) drop any row whose stamp appears here.
|
|
663
|
+
*
|
|
664
|
+
* NOTE: re-reads the whole retired set on every call, and the caller runs per inbound
|
|
665
|
+
* gate request while the table only ever grows. Cheap today (a cadre removes peers
|
|
666
|
+
* rarely); if removals ever become routine, cache the set and invalidate it on write.
|
|
667
|
+
*/
|
|
668
|
+
async queryRevokedStamps(tableName) {
|
|
669
|
+
this.ensureInitialized();
|
|
670
|
+
const stamps = new Set();
|
|
671
|
+
for await (const row of this.db.eval('select StampId from CadreControl.Revocation where TableName = ?', [tableName])) {
|
|
672
|
+
stamps.add(row.StampId);
|
|
673
|
+
}
|
|
674
|
+
return stamps;
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Every locally-held `CadreControl.Revocation` tombstone — identity triple plus its
|
|
678
|
+
* `ReissuedAt` counter. Consumed by the cohort-growth re-issue sweep, which
|
|
679
|
+
* enumerates what this node holds before {@link reissueRevocations} re-broadcasts
|
|
680
|
+
* it. Plain scan with no `where`, so the composite-primary-key point-lookup hazard
|
|
681
|
+
* (see the statement comment in {@link reissueRevocations}) does not arise. Unlocked,
|
|
682
|
+
* like every other read.
|
|
683
|
+
*/
|
|
684
|
+
async queryRevocations() {
|
|
685
|
+
this.ensureInitialized();
|
|
686
|
+
const rows = [];
|
|
687
|
+
for await (const row of this.db.eval('select TableName, RowKey, StampId, ReissuedAt from CadreControl.Revocation')) {
|
|
688
|
+
rows.push({
|
|
689
|
+
tableName: row.TableName,
|
|
690
|
+
rowKey: row.RowKey,
|
|
691
|
+
stampId: row.StampId,
|
|
692
|
+
reissuedAt: row.ReissuedAt ?? 0,
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
return rows;
|
|
696
|
+
}
|
|
325
697
|
/**
|
|
326
698
|
* Read a single peer's address record (the full `CadrePeer` row) by PeerId.
|
|
327
699
|
*
|
|
328
|
-
* Returns null when no row exists
|
|
700
|
+
* Returns null when no row exists, or when the row's `StampId` is retired in
|
|
701
|
+
* `CadreControl.Revocation` — a revoked peer reads as absent, so the resolver
|
|
702
|
+
* ({@link CadreNode.resolvePeerAddrs}) never hands out a revoked peer's addresses.
|
|
703
|
+
* The stamp is read for that check only and then dropped; the returned
|
|
704
|
+
* {@link PeerAddressRecord} shape is unchanged. Reads the retired set through
|
|
705
|
+
* {@link queryRevokedStamps} rather than inlining the SQL, so tests can interpose on
|
|
706
|
+
* that seam (see {@link queryCadrePeers}).
|
|
707
|
+
*
|
|
708
|
+
* Missing/legacy column values are coalesced
|
|
329
709
|
* to their empty form (`''` key/sig, `[]` addrs, `0` stamp) so the caller's
|
|
330
710
|
* verify/freshness gates uniformly reject an unpublished or malformed row.
|
|
331
711
|
* The split `addrs` re-join to the exact stored `Multiaddr` (split-on-`,` is
|
|
@@ -334,7 +714,12 @@ export class ControlDatabase {
|
|
|
334
714
|
*/
|
|
335
715
|
async queryPeerRecord(peerId) {
|
|
336
716
|
this.ensureInitialized();
|
|
337
|
-
|
|
717
|
+
const revoked = await this.queryRevokedStamps('CadrePeer');
|
|
718
|
+
for await (const row of this.db.eval('select PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId from CadreControl.CadrePeer where PeerId = ?', [peerId])) {
|
|
719
|
+
const stampId = row.StampId ?? null;
|
|
720
|
+
if (stampId !== null && revoked.has(stampId)) {
|
|
721
|
+
return null;
|
|
722
|
+
}
|
|
338
723
|
const multiaddr = row.Multiaddr ?? '';
|
|
339
724
|
return {
|
|
340
725
|
peerId: row.PeerId,
|
|
@@ -355,11 +740,24 @@ export class ControlDatabase {
|
|
|
355
740
|
* is the refresh path for any member — owner or drone — once its row
|
|
356
741
|
* exists. `PublicKey` is intentionally not in the SET list (it is immutable on
|
|
357
742
|
* self-update and the constraint enforces `new.PublicKey = old.PublicKey`).
|
|
743
|
+
*
|
|
744
|
+
* Deliberately NOT wrapped in {@link mutateCadrePeer}, the one `CadrePeer` mutator that
|
|
745
|
+
* is not: it only ever touches THIS node's own row (sole caller
|
|
746
|
+
* `CadreNode.publishSelfRecord`), and `CadreNode.listAuthorizedMembers` filters self out
|
|
747
|
+
* of the membership snapshot, so it cannot change that snapshot. It also runs on the
|
|
748
|
+
* periodic self-registration refresh, where a notify would add a recurring membership
|
|
749
|
+
* read for nothing.
|
|
750
|
+
*
|
|
751
|
+
* Its INSERT counterpart (`SeedBootstrapService.insertSelfPeerRecord`) does notify. Not an
|
|
752
|
+
* inconsistency: it shares the one owner-signed insert path with every other member's
|
|
753
|
+
* row, and a self insert happens once at startup, so the wasted refresh is a single read
|
|
754
|
+
* — cheaper than a conditional carve-out inside the shared writer. Only the repeating
|
|
755
|
+
* path is worth exempting.
|
|
358
756
|
*/
|
|
359
757
|
async updateSelfPeerRecord(record) {
|
|
360
758
|
this.ensureInitialized();
|
|
361
759
|
const multiaddr = record.addrs.join(',');
|
|
362
|
-
await this.
|
|
760
|
+
await this.execWrite(`
|
|
363
761
|
update CadreControl.CadrePeer
|
|
364
762
|
with context OwnerKey = null, Signature = ?
|
|
365
763
|
set Multiaddr = ?, UpdatedAt = ?, Sig = ?
|
|
@@ -375,16 +773,21 @@ export class ControlDatabase {
|
|
|
375
773
|
* gates uniformly reject an unpublished or malformed row. `platform` is returned
|
|
376
774
|
* verbatim (the resolver validates it against {@link PushPlatform} and re-verifies
|
|
377
775
|
* the self-signature, which covers the platform field).
|
|
776
|
+
*
|
|
777
|
+
* `stampId` rides along ({@link DeviceTokenRow}) because the resolver must drop a
|
|
778
|
+
* row whose stamp is retired in `CadreControl.Revocation`; it is NOT part of
|
|
779
|
+
* {@link DeviceTokenRecord}, which is the self-signed shape the peer's `Sig` covers.
|
|
378
780
|
*/
|
|
379
781
|
async queryDeviceToken(peerId) {
|
|
380
782
|
this.ensureInitialized();
|
|
381
|
-
for await (const row of this.db.eval('select PeerId, Platform, Token, UpdatedAt, Sig from CadreControl.DeviceToken where PeerId = ?', [peerId])) {
|
|
783
|
+
for await (const row of this.db.eval('select PeerId, Platform, Token, UpdatedAt, Sig, StampId from CadreControl.DeviceToken where PeerId = ?', [peerId])) {
|
|
382
784
|
return {
|
|
383
785
|
peerId: row.PeerId,
|
|
384
786
|
platform: (row.Platform ?? ''),
|
|
385
787
|
token: row.Token ?? '',
|
|
386
788
|
updatedAt: row.UpdatedAt ?? 0,
|
|
387
789
|
sig: row.Sig ?? '',
|
|
790
|
+
stampId: row.StampId ?? '',
|
|
388
791
|
};
|
|
389
792
|
}
|
|
390
793
|
return null;
|
|
@@ -402,7 +805,7 @@ export class ControlDatabase {
|
|
|
402
805
|
*/
|
|
403
806
|
async updateSelfDeviceToken(record) {
|
|
404
807
|
this.ensureInitialized();
|
|
405
|
-
await this.
|
|
808
|
+
await this.execWrite(`
|
|
406
809
|
update CadreControl.DeviceToken
|
|
407
810
|
with context OwnerKey = null, Signature = ?
|
|
408
811
|
set Platform = ?, Token = ?, UpdatedAt = ?, Sig = ?
|
|
@@ -416,12 +819,16 @@ export class ControlDatabase {
|
|
|
416
819
|
async insertOwnerKey(key) {
|
|
417
820
|
this.ensureInitialized();
|
|
418
821
|
log('Inserting owner key: %s', key);
|
|
419
|
-
// Bootstrap is authorized by the schema's `(select count(1) from
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
//
|
|
822
|
+
// Bootstrap is authorized by the schema's genesis branch — `(select count(1) from
|
|
823
|
+
// committed.OwnerKey) = 0`, i.e. the party had no owner before this transaction — so no
|
|
824
|
+
// signature is needed. Every other branch of `OwnerKey.Authorized` requires a signature
|
|
825
|
+
// from a PRE-EXISTING owner, so this method only ever succeeds on a fresh party; seating
|
|
826
|
+
// a second owner (or removing one) has no writer here and must sign the digests
|
|
827
|
+
// documented on the schema's `Authorized` constraint. We still persist a fresh, unique
|
|
828
|
+
// StampId in the row's own column to satisfy the not-null/unique anti-replay constraint —
|
|
829
|
+
// the StampId is a real column value, not the optimystic `StampId()` SQL function.
|
|
423
830
|
const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
|
|
424
|
-
await this.
|
|
831
|
+
await this.execWrite(`
|
|
425
832
|
insert into CadreControl.OwnerKey (Key, StampId)
|
|
426
833
|
with context OwnerKey = null, Signature = null
|
|
427
834
|
values (?, ?)
|
|
@@ -449,18 +856,39 @@ export class ControlDatabase {
|
|
|
449
856
|
// Generate a unique stamp ID using the peer ID for distributed uniqueness
|
|
450
857
|
const peerId = this.config.libp2pNode.peerId.toString();
|
|
451
858
|
const stampId = generateStampId(peerId);
|
|
452
|
-
// Field order MUST match the schema's Strand `
|
|
859
|
+
// Field order MUST match the schema's Strand `AuthorizedInsert` verify:
|
|
453
860
|
// Id, Type, MemberPrivateKey ('' when null), StampId.
|
|
454
|
-
const message = buildAuthorizationMessage([strandId, type, memberPrivateKey ?? '', stampId]);
|
|
861
|
+
const message = buildAuthorizationMessage('CadreControl.Strand', 'add', [strandId, type, memberPrivateKey ?? '', stampId]);
|
|
455
862
|
const signature = signMessage(message);
|
|
456
863
|
// StampId is a real, unique column (single-use anti-replay), no longer a context value.
|
|
457
|
-
await this.
|
|
864
|
+
await this.execWrite(`
|
|
458
865
|
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId)
|
|
459
866
|
with context OwnerKey = ?, Signature = ?
|
|
460
867
|
values (?, ?, ?, ?)
|
|
461
868
|
`, [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId]);
|
|
462
869
|
log('Strand inserted: %s', strandId);
|
|
463
870
|
}
|
|
871
|
+
/**
|
|
872
|
+
* Delete a strand from the control database using an owner signature.
|
|
873
|
+
*
|
|
874
|
+
* Mirrors {@link insertStrand}'s row-bound approach for the delete half: the owner
|
|
875
|
+
* signs the canonical `'remove'`-tagged authorization message over (Id, StampId) — the
|
|
876
|
+
* schema's `Strand.AuthorizedDelete` verifies this DISTINCT digest, so the insert
|
|
877
|
+
* approval (which never expires) can never be replayed as a removal. The delete and
|
|
878
|
+
* the `Revocation` tombstone retiring the row's StampId commit in ONE transaction —
|
|
879
|
+
* `Strand.RevocationRecorded` refuses a bare delete, and without the tombstone the
|
|
880
|
+
* stamp would free up and the original formation approval could re-seat the strand.
|
|
881
|
+
* Body shared with every other guarded delete via {@link deleteGuardedRow}.
|
|
882
|
+
*
|
|
883
|
+
* The remove digest binds only (Id, StampId) — not Type/MemberPrivateKey — so this
|
|
884
|
+
* works identically for open and closed strands.
|
|
885
|
+
*
|
|
886
|
+
* A no-op (no throw, no tombstone) when the row does not exist — `false` then, `true`
|
|
887
|
+
* when a row was actually removed.
|
|
888
|
+
*/
|
|
889
|
+
deleteStrand(strandId, ownerKey, signMessage) {
|
|
890
|
+
return this.lockedWithRetry(() => this.deleteGuardedRow('Strand', strandId, ownerKey, signMessage));
|
|
891
|
+
}
|
|
464
892
|
/**
|
|
465
893
|
* Insert a validation key into the control database using an owner signature.
|
|
466
894
|
*
|
|
@@ -479,32 +907,761 @@ export class ControlDatabase {
|
|
|
479
907
|
log('Inserting validation key: %s', key);
|
|
480
908
|
const peerId = this.config.libp2pNode.peerId.toString();
|
|
481
909
|
const stampId = generateStampId(peerId);
|
|
482
|
-
// Field order MUST match the schema's ValidationKey `
|
|
483
|
-
const message = buildAuthorizationMessage([key, stampId]);
|
|
910
|
+
// Field order MUST match the schema's ValidationKey `AuthorizedInsert` verify: Key, StampId.
|
|
911
|
+
const message = buildAuthorizationMessage('CadreControl.ValidationKey', 'add', [key, stampId]);
|
|
484
912
|
const signature = signMessage(message);
|
|
485
|
-
await this.
|
|
913
|
+
await this.execWrite(`
|
|
486
914
|
insert into CadreControl.ValidationKey (Key, StampId)
|
|
487
915
|
with context OwnerKey = ?, Signature = ?
|
|
488
916
|
values (?, ?)
|
|
489
917
|
`, [ownerKey, signature, key, stampId]);
|
|
490
918
|
log('Validation key inserted: %s', key);
|
|
491
919
|
}
|
|
920
|
+
/**
|
|
921
|
+
* Delete a validation key from the control database using an owner signature.
|
|
922
|
+
*
|
|
923
|
+
* Mirrors {@link insertValidationKey}'s row-bound approach for the delete half: the
|
|
924
|
+
* owner signs the canonical `'remove'`-tagged authorization message over (Key,
|
|
925
|
+
* StampId) — the schema's `ValidationKey.AuthorizedDelete` verifies this DISTINCT
|
|
926
|
+
* digest, so the enrollment approval (which never expires) can never be replayed as a
|
|
927
|
+
* removal. The delete and the `Revocation` tombstone retiring the row's StampId commit
|
|
928
|
+
* in ONE transaction — `ValidationKey.RevocationRecorded` refuses a bare delete, and
|
|
929
|
+
* without the tombstone the stamp would free up and the original enrollment approval
|
|
930
|
+
* could re-seat the key. Body shared with every other guarded delete via
|
|
931
|
+
* {@link deleteGuardedRow}.
|
|
932
|
+
*
|
|
933
|
+
* A no-op (no throw, no tombstone) when the row does not exist — `false` then, `true`
|
|
934
|
+
* when a row was actually removed.
|
|
935
|
+
*/
|
|
936
|
+
deleteValidationKey(key, ownerKey, signMessage) {
|
|
937
|
+
return this.lockedWithRetry(() => this.deleteGuardedRow('ValidationKey', key, ownerKey, signMessage));
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* Owner-vouched INSERT of one `CadrePeer` membership row, notifying the membership
|
|
941
|
+
* listener once it has committed.
|
|
942
|
+
*
|
|
943
|
+
* Mints a fresh single-use `StampId` and signs the `'vouch'`-tagged authorization
|
|
944
|
+
* message over (PeerId, StampId) — see {@link buildAuthorizationMessage} — satisfying
|
|
945
|
+
* the schema's `CadrePeer.AuthorizedInsert`. Binding the peer id to the nonce means a
|
|
946
|
+
* captured insert approval cannot be replayed (live rows are blocked by the unique
|
|
947
|
+
* `StampId` column, removed rows by `Revocation` retirement via `CadrePeer.NotRevoked`)
|
|
948
|
+
* and cannot be repurposed as a delete (which signs a distinct `'remove'`-tagged digest).
|
|
949
|
+
*
|
|
950
|
+
* The owner signature does NOT cover the address columns — those are vouched only as far
|
|
951
|
+
* as the owner asserts them, and a peer's own `Sig` (when present) is what makes the row
|
|
952
|
+
* resolvable.
|
|
953
|
+
*
|
|
954
|
+
* The {@link mutateCadrePeer} wrapper lives here rather than in the caller so no
|
|
955
|
+
* `CadrePeer` inserter can forget it — the write itself is what lets the admitted peer's
|
|
956
|
+
* traffic in.
|
|
957
|
+
*
|
|
958
|
+
* Idempotent on an already-present row: two writers can legitimately race the SAME peer's
|
|
959
|
+
* first row — the node's own background self-publish ({@link CadreNode.registerSelf})
|
|
960
|
+
* against a foreground {@link SeedBootstrapService.authorizePeer} of that node's id — and
|
|
961
|
+
* the write lock only serializes them; the loser would hit the `CadrePeer.PeerId` UNIQUE
|
|
962
|
+
* constraint. The existence check runs INSIDE the locked body, so it sees the winner's
|
|
963
|
+
* committed row (a pre-lock check would re-open the read-then-insert window). The existing
|
|
964
|
+
* row — voucher, addresses, self-`Sig` — is left untouched; re-touching a live row is
|
|
965
|
+
* {@link reauthorizeCadrePeer}'s job.
|
|
966
|
+
*
|
|
967
|
+
* @param row - the membership row's columns; `multiaddr` is the comma-joined address list.
|
|
968
|
+
* @param ownerKey - public key of the vouching owner, persisted into `VouchOwner`.
|
|
969
|
+
* @param signMessage - ed25519-signs the raw message bytes (no pre-hash) with that owner's
|
|
970
|
+
* private key, returning a base64url signature.
|
|
971
|
+
* @returns `true` when this call performed the INSERT, `false` when the in-lock existence
|
|
972
|
+
* check found the row already seated. The loser needs to know: an authorize seats a row
|
|
973
|
+
* with a null `Sig`, so a self-publish that lost the race must fall through to a
|
|
974
|
+
* self-update or its record never lands.
|
|
975
|
+
*/
|
|
976
|
+
async insertCadrePeer(row, ownerKey, signMessage) {
|
|
977
|
+
this.ensureInitialized();
|
|
978
|
+
// Stamp is minted from the ADMITTED peer's id, not this node's — the nonce names the
|
|
979
|
+
// row it vouches.
|
|
980
|
+
const stampId = generateStampId(row.peerId);
|
|
981
|
+
const signature = signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', [row.peerId, stampId]));
|
|
982
|
+
return await this.mutateCadrePeer('peer-insert', async () => {
|
|
983
|
+
if (await this.queryStampId('CadrePeer', row.peerId) !== null) {
|
|
984
|
+
log('CadrePeer row already present for %s; insert skipped (already a member)', row.peerId);
|
|
985
|
+
return false;
|
|
986
|
+
}
|
|
987
|
+
// Bare `exec`: already inside the write lock, which is NOT re-entrant (see the NOTE
|
|
988
|
+
// on withWriteLock — re-entry via execWrite hangs silently and permanently).
|
|
989
|
+
//
|
|
990
|
+
// Persist the vouching (owner, signature) onto the row (VouchOwner/VouchSig) —
|
|
991
|
+
// identical to the context pair, which the AuthorizedInsert constraint binds — so a
|
|
992
|
+
// reader can later re-check the voucher against its node-local trusted-owner anchor.
|
|
993
|
+
await this.db.exec(`
|
|
994
|
+
insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId, VouchOwner, VouchSig)
|
|
995
|
+
with context OwnerKey = ?, Signature = ?
|
|
996
|
+
values (?, ?, ?, ?, ?, ?, ?, ?)
|
|
997
|
+
`, [ownerKey, signature, row.peerId, row.publicKey, row.multiaddr, row.updatedAt, row.sig, stampId, ownerKey, signature]);
|
|
998
|
+
return true;
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* Owner "re-touch" of an existing `CadrePeer` membership row: bump `UpdatedAt` and
|
|
1003
|
+
* rewrite `VouchOwner`/`VouchSig` under the owner branch of `CadrePeer.AuthorizedUpdate`
|
|
1004
|
+
* (a signature over the SAME `'vouch'`-tagged digest {@link insertCadrePeer} builds), so
|
|
1005
|
+
* the row is re-emitted as a fresh, broadcasting transaction.
|
|
1006
|
+
*
|
|
1007
|
+
* This is the write-while-alone re-replication primitive: a membership row that committed
|
|
1008
|
+
* local-only (its block's cluster ≤1 at insert) is pushed to the cohort once it grows, by
|
|
1009
|
+
* re-issuing this monotonic bump. It is an UPDATE (not the original INSERT) because the row
|
|
1010
|
+
* already exists locally; a re-INSERT would hit the `PeerId` PK. Only the freshness stamp
|
|
1011
|
+
* and voucher change — `PublicKey` / `Multiaddr` / `Sig` are left intact — so it is safe
|
|
1012
|
+
* over a row whose peer has not self-published (`Sig` null); the caller must skip a row
|
|
1013
|
+
* that already carries a self-`Sig` (that row is the owning peer's to refresh, and bumping
|
|
1014
|
+
* `UpdatedAt` without re-signing would invalidate its self-signature).
|
|
1015
|
+
*
|
|
1016
|
+
* Signs over the row's CURRENT `StampId` (unchanged by the re-touch), read BEFORE the lock
|
|
1017
|
+
* is taken. An absent row returns `false` WITHOUT notifying: nothing was written, so the
|
|
1018
|
+
* membership snapshot cannot have changed.
|
|
1019
|
+
*
|
|
1020
|
+
* NOTE: that stamp read is outside the lock, same as {@link deleteGuardedRow}'s. A writer
|
|
1021
|
+
* that removes the row in between makes the `update` match nothing, and this still returns
|
|
1022
|
+
* `true` and notifies — harmless today (the sole caller, the write-while-alone drain, only
|
|
1023
|
+
* logs the result, and a spurious notify just re-reads the member set), and a remove-then-
|
|
1024
|
+
* re-add in the same window fails loudly instead, since the signature binds the retired
|
|
1025
|
+
* stamp. If a caller ever acts on `true` as proof the row was written, fold the stamp read
|
|
1026
|
+
* into the locked body.
|
|
1027
|
+
*
|
|
1028
|
+
* Notifies like the insert/remove paths even though this is "only" a re-touch: it rewrites
|
|
1029
|
+
* VouchOwner/VouchSig, which the authorized-membership predicate judges on, so it CAN
|
|
1030
|
+
* change the member set. Keeping the rule uniform ("every CadrePeer mutator notifies")
|
|
1031
|
+
* beats a per-method exception the next reader has to relearn.
|
|
1032
|
+
*
|
|
1033
|
+
* NOTE: this rebinds VouchOwner to the CALLING owner's key, and the authorized-membership
|
|
1034
|
+
* predicate (`CadreNode.listAuthorizedMembers`) now judges rows by that column against each
|
|
1035
|
+
* reader's node-local anchor. Benign today because the only caller — the write-while-alone
|
|
1036
|
+
* drain — re-touches solely rows this node itself authored (`pendingPeerWrites`), so the
|
|
1037
|
+
* voucher is rewritten to the key that already signed it. If a future path ever lets one
|
|
1038
|
+
* owner re-touch a row a DIFFERENT owner vouched, the voucher silently flips: readers that
|
|
1039
|
+
* anchor the original owner but not this one would drop a legitimate member. Such a path
|
|
1040
|
+
* must re-vouch deliberately (or preserve the existing VouchOwner/VouchSig) rather than
|
|
1041
|
+
* inherit this rebinding.
|
|
1042
|
+
*
|
|
1043
|
+
* @param peerId - the membership row to re-touch.
|
|
1044
|
+
* @param updatedAt - the strictly-increasing freshness stamp to write.
|
|
1045
|
+
* @param ownerKey - public key of the re-vouching owner, rewritten into `VouchOwner`.
|
|
1046
|
+
* @param signMessage - ed25519-signs the raw message bytes with that owner's private key.
|
|
1047
|
+
* @returns `true` when the row was re-touched, `false` when no row exists (no notify).
|
|
1048
|
+
*/
|
|
1049
|
+
async reauthorizeCadrePeer(peerId, updatedAt, ownerKey, signMessage) {
|
|
1050
|
+
this.ensureInitialized();
|
|
1051
|
+
const stampId = await this.queryStampId('CadrePeer', peerId);
|
|
1052
|
+
if (stampId === null) {
|
|
1053
|
+
log('reauthorizeCadrePeer: no CadrePeer row for %s (nothing to re-touch)', peerId);
|
|
1054
|
+
return false;
|
|
1055
|
+
}
|
|
1056
|
+
const signature = signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', [peerId, stampId]));
|
|
1057
|
+
await this.mutateCadrePeer('peer-reauthorize', async () => {
|
|
1058
|
+
// Bare `exec` — inside the non-re-entrant write lock (see insertCadrePeer).
|
|
1059
|
+
await this.db.exec(`
|
|
1060
|
+
update CadreControl.CadrePeer
|
|
1061
|
+
with context OwnerKey = ?, Signature = ?
|
|
1062
|
+
set UpdatedAt = ?, VouchOwner = ?, VouchSig = ?
|
|
1063
|
+
where PeerId = ?
|
|
1064
|
+
`, [ownerKey, signature, updatedAt, ownerKey, signature, peerId]);
|
|
1065
|
+
});
|
|
1066
|
+
return true;
|
|
1067
|
+
}
|
|
1068
|
+
/**
|
|
1069
|
+
* Owner-signed removal of one `CadrePeer` membership row, notifying the membership
|
|
1070
|
+
* listener once it has committed.
|
|
1071
|
+
*
|
|
1072
|
+
* Mirrors {@link deleteStrand} for the membership table: the owner signs the DISTINCT
|
|
1073
|
+
* `'remove'`-tagged digest over (PeerId, StampId), so the row's stored `VouchSig` can
|
|
1074
|
+
* never be replayed to delete it, and the `Revocation` tombstone retiring the stamp
|
|
1075
|
+
* lands in the SAME transaction — `CadrePeer.RevocationRecorded` refuses a bare delete,
|
|
1076
|
+
* and without the tombstone the never-expiring admission approval (which the removed
|
|
1077
|
+
* peer holds a copy of) could re-seat the row.
|
|
1078
|
+
*
|
|
1079
|
+
* The {@link mutateCadrePeer} wrapper lives here rather than in the caller so no
|
|
1080
|
+
* `CadrePeer` remover can forget it. It notifies whenever the body resolves, including
|
|
1081
|
+
* the absent-row no-op below — a caller that must not notify for an already-absent peer
|
|
1082
|
+
* gates on {@link queryCadrePeerStampId} first (see
|
|
1083
|
+
* {@link SeedBootstrapService.removePeer}).
|
|
1084
|
+
*
|
|
1085
|
+
* A no-op (no throw, no tombstone) when the row does not exist — `false` then, `true`
|
|
1086
|
+
* when a row was actually removed.
|
|
1087
|
+
*/
|
|
1088
|
+
deleteCadrePeer(peerId, ownerKey, signMessage) {
|
|
1089
|
+
return this.mutateCadrePeer('peer-remove', () => this.deleteGuardedRow('CadrePeer', peerId, ownerKey, signMessage));
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* Owner-signed removal of one peer's `DeviceToken` row (logout / token invalidation).
|
|
1093
|
+
*
|
|
1094
|
+
* Mirrors {@link deleteStrand}: the owner signs the DISTINCT `'remove'`-tagged digest
|
|
1095
|
+
* over (PeerId, StampId), so the insert approval can never be replayed to clear a token,
|
|
1096
|
+
* and the `Revocation` tombstone retiring the stamp lands in the SAME transaction —
|
|
1097
|
+
* `DeviceToken.RevocationRecorded` refuses a bare delete, and without the tombstone the
|
|
1098
|
+
* never-expiring insert approval (which the cleared device holds a copy of) could
|
|
1099
|
+
* re-seat the token.
|
|
1100
|
+
*
|
|
1101
|
+
* A no-op (no throw, no tombstone) when the row does not exist — `false` then, `true`
|
|
1102
|
+
* when a row was actually removed.
|
|
1103
|
+
*/
|
|
1104
|
+
deleteDeviceToken(peerId, ownerKey, signMessage) {
|
|
1105
|
+
return this.lockedWithRetry(() => this.deleteGuardedRow('DeviceToken', peerId, ownerKey, signMessage));
|
|
1106
|
+
}
|
|
1107
|
+
/**
|
|
1108
|
+
* Owner-signed delete of one guarded row plus the `Revocation` tombstone retiring its
|
|
1109
|
+
* stamp, in ONE transaction. The single body behind EVERY guarded delete —
|
|
1110
|
+
* {@link deleteStrand}, {@link deleteValidationKey}, {@link deleteCadrePeer},
|
|
1111
|
+
* {@link deleteDeviceToken}; see any of them for the per-table security rationale. Each
|
|
1112
|
+
* of those is a thin named wrapper, so callers never pass a table name and this
|
|
1113
|
+
* generic shape stays off the public surface.
|
|
1114
|
+
*
|
|
1115
|
+
* `OwnerKey` is excluded from `table` only because no owner-key removal path exists in
|
|
1116
|
+
* production yet — the schema has the `'remove'` branch (`OwnerKey.Authorized`, which
|
|
1117
|
+
* requires a DIFFERENT owner as signer) and `control-revocation-replay.spec.ts` drives
|
|
1118
|
+
* it with hand-rolled SQL. Widen this and add a wrapper when that path lands; the body
|
|
1119
|
+
* needs no change.
|
|
1120
|
+
*
|
|
1121
|
+
* The row's CURRENT stamp is read first and signed over, so the remove digest binds to
|
|
1122
|
+
* this exact row instance. A no-op (no throw, no tombstone) when the row is absent —
|
|
1123
|
+
* reported as `false` so a caller can tell "removed" from "was never there" without a
|
|
1124
|
+
* second read (e.g. {@link CadreNode.unpublishStrand}'s committed-while-alone warning,
|
|
1125
|
+
* which must not fire for a no-op).
|
|
1126
|
+
*
|
|
1127
|
+
* NOTE: the stamp read is outside the transaction. A concurrent writer that removes
|
|
1128
|
+
* the row in between makes the signature bind a stamp that is no longer live; the
|
|
1129
|
+
* delete then matches nothing and the tombstone insert collides with the other
|
|
1130
|
+
* writer's on `Revocation`'s (TableName, StampId) primary key, so the transaction
|
|
1131
|
+
* fails rather than silently half-applying. If concurrent owner-device removals ever
|
|
1132
|
+
* become routine, fold the stamp read into the transaction instead.
|
|
1133
|
+
*
|
|
1134
|
+
* Deliberately NOT wrapped in {@link withWriteLock}: every public entry point already
|
|
1135
|
+
* holds the (non-re-entrant) lock, so taking it again here would self-deadlock.
|
|
1136
|
+
*
|
|
1137
|
+
* `table` and its {@link GUARDED_KEY_COLUMN} column are interpolated into the SQL; both
|
|
1138
|
+
* come from closed literal unions — no caller-supplied string reaches the statement.
|
|
1139
|
+
*/
|
|
1140
|
+
async deleteGuardedRow(table, keyValue, ownerKey, signMessage) {
|
|
1141
|
+
this.ensureInitialized();
|
|
1142
|
+
const stampId = await this.queryStampId(table, keyValue);
|
|
1143
|
+
if (stampId === null) {
|
|
1144
|
+
log('delete %s: no row for %s (already absent)', table, keyValue);
|
|
1145
|
+
return false;
|
|
1146
|
+
}
|
|
1147
|
+
const message = buildAuthorizationMessage(`CadreControl.${table}`, 'remove', [keyValue, stampId]);
|
|
1148
|
+
const signature = signMessage(message);
|
|
1149
|
+
// The tombstone carries its OWN owner signature (`Revocation.Authorized`): retiring a
|
|
1150
|
+
// stamp is permanent and party-wide, so the delete's signature deliberately does not
|
|
1151
|
+
// cover it — the digests are domain-separated and neither replays as the other.
|
|
1152
|
+
const revocationSignature = signMessage(buildAuthorizationMessage('CadreControl.Revocation', 'remove', [table, keyValue, stampId]));
|
|
1153
|
+
await this.inTransaction(`delete ${table}`, async () => {
|
|
1154
|
+
await this.db.exec(`
|
|
1155
|
+
delete from CadreControl.${table}
|
|
1156
|
+
with context OwnerKey = ?, Signature = ?
|
|
1157
|
+
where ${GUARDED_KEY_COLUMN[table]} = ?
|
|
1158
|
+
`, [ownerKey, signature, keyValue]);
|
|
1159
|
+
// ReissuedAt named explicitly at 0 (the only value FreshTombstone accepts) rather
|
|
1160
|
+
// than leaning on the column default — the seat-at-zero rule is load-bearing for
|
|
1161
|
+
// reissueRevocations' monotonic bump, so state it at the write site.
|
|
1162
|
+
await this.db.exec(`
|
|
1163
|
+
insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt)
|
|
1164
|
+
with context OwnerKey = ?, Signature = ?
|
|
1165
|
+
values (?, ?, ?, 0)
|
|
1166
|
+
`, [ownerKey, revocationSignature, table, keyValue, stampId]);
|
|
1167
|
+
});
|
|
1168
|
+
log('%s deleted: %s (stamp retired)', table, keyValue);
|
|
1169
|
+
if (this.guardedDeleteListener) {
|
|
1170
|
+
try {
|
|
1171
|
+
this.guardedDeleteListener({ tableName: table, rowKey: keyValue, stampId });
|
|
1172
|
+
}
|
|
1173
|
+
catch (error) {
|
|
1174
|
+
log('guarded-delete listener threw (committed delete unaffected): %o', error);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
return true;
|
|
1178
|
+
}
|
|
1179
|
+
/**
|
|
1180
|
+
* Delete one guarded row that an ALREADY-COMMITTED `Revocation` tombstone retires —
|
|
1181
|
+
* the local catch-up for a node that converged on a removal's tombstone while still
|
|
1182
|
+
* holding the removed row. That live-row-plus-tombstone state is reachable only by
|
|
1183
|
+
* replication merge (local writes cannot build it: `Revocation.RowIsGone` refuses a
|
|
1184
|
+
* tombstone while the row is live), and replication cannot carry the delete itself —
|
|
1185
|
+
* replaying a delete of a row that is already gone locally is a no-op — so without
|
|
1186
|
+
* this the stale row is permanent garbage on every node that held it at revocation
|
|
1187
|
+
* time. Authorized by the REAP branch of the table's `AuthorizedDelete`: the
|
|
1188
|
+
* tombstone is itself owner-signed (`Revocation.Authorized`), so no owner key is
|
|
1189
|
+
* needed HERE — any node holding row + tombstone may reap, drones included.
|
|
1190
|
+
*
|
|
1191
|
+
* Returns whether a row was removed: `false` without writing when the row is absent
|
|
1192
|
+
* locally (the common case on most nodes), or when its live stamp is not `stampId`
|
|
1193
|
+
* (a fresh incarnation the owner re-seated, which the old tombstone must not touch).
|
|
1194
|
+
*
|
|
1195
|
+
* The delete's WHERE clause binds `StampId` too — required, not belt-and-braces: an
|
|
1196
|
+
* owner may re-seat the row (fresh stamp) between the stamp read below and the
|
|
1197
|
+
* statement, and without the clause the reap would delete the owner's brand-new row.
|
|
1198
|
+
* With it the statement matches nothing and the reap is a silent no-op (the schema
|
|
1199
|
+
* branch would also refuse that delete, but as a constraint error thrown into a
|
|
1200
|
+
* background sweep — matched-nothing is the better failure mode). Like
|
|
1201
|
+
* {@link reauthorizeCadrePeer}, a race lost AFTER the guard still returns `true`;
|
|
1202
|
+
* harmless — nothing was deleted, and the next sweep re-reads.
|
|
1203
|
+
*
|
|
1204
|
+
* Writes NOTHING to `Revocation`: `RevocationRecorded` is satisfied by the same
|
|
1205
|
+
* committed tombstone that authorizes the reap, so a reap files no second tombstone.
|
|
1206
|
+
* It therefore does NOT fire the guarded-delete listener either — that seam exists to
|
|
1207
|
+
* queue a tombstone for re-issue when it committed while alone, and a reap has no
|
|
1208
|
+
* tombstone of its own to re-issue. A reap that commits while alone needs no
|
|
1209
|
+
* re-replication at all: every OTHER node either already lacks the row, or holds the
|
|
1210
|
+
* same committed tombstone and reaps its own copy.
|
|
1211
|
+
*
|
|
1212
|
+
* NOTE: the reap delete is keyed on the primary key; the `StampId` predicate is applied
|
|
1213
|
+
* where the statement runs. If a reap on one node ever has to be reconciled against an
|
|
1214
|
+
* owner re-seat of the same key that landed on another node, whether the delete or the
|
|
1215
|
+
* re-seat wins is decided by the collection's merge order, not by this clause — the same
|
|
1216
|
+
* ordering question the owner-signed delete path already carries
|
|
1217
|
+
* (`tickets/blocked/forked-control-collection-sync-livelocks.md`). Only matters once a
|
|
1218
|
+
* sweep drives this automatically on connected nodes; if that reconcile ever drops a
|
|
1219
|
+
* freshly re-seated row, this is the site.
|
|
1220
|
+
*
|
|
1221
|
+
* `table` and its {@link GUARDED_KEY_COLUMN} column come from closed literal unions —
|
|
1222
|
+
* no caller-supplied string reaches the statement (same injection-surface discipline
|
|
1223
|
+
* as {@link deleteGuardedRow}). The `with context` clause is PRESENT and bound to
|
|
1224
|
+
* nulls, never omitted: Quereus cannot resolve `context.OwnerKey` at plan time when
|
|
1225
|
+
* the clause is absent while a constraint references `context.*` (nulls through a
|
|
1226
|
+
* present clause are the shape {@link redeemInvitation}'s consent-branch insert ships).
|
|
1227
|
+
*
|
|
1228
|
+
* Driven automatically by {@link reapRevokedRows}, which the periodic control-cohort
|
|
1229
|
+
* reconcile pass runs while the node has at least one control connection.
|
|
1230
|
+
*/
|
|
1231
|
+
async reapRevokedRow(table, rowKey, stampId) {
|
|
1232
|
+
this.ensureInitialized();
|
|
1233
|
+
const liveStamp = await this.queryStampId(table, rowKey);
|
|
1234
|
+
if (liveStamp === null) {
|
|
1235
|
+
log('reap %s: no row for %s (nothing to reap)', table, rowKey);
|
|
1236
|
+
return false;
|
|
1237
|
+
}
|
|
1238
|
+
if (liveStamp !== stampId) {
|
|
1239
|
+
log('reap %s: %s holds a fresh incarnation (live stamp differs from the tombstoned one); leaving it', table, rowKey);
|
|
1240
|
+
return false;
|
|
1241
|
+
}
|
|
1242
|
+
const sql = `
|
|
1243
|
+
delete from CadreControl.${table}
|
|
1244
|
+
with context OwnerKey = null, Signature = null
|
|
1245
|
+
where ${GUARDED_KEY_COLUMN[table]} = ? and StampId = ?
|
|
1246
|
+
`;
|
|
1247
|
+
if (table === 'CadrePeer') {
|
|
1248
|
+
// Through mutateCadrePeer so the "EVERY CadrePeer writer goes through here"
|
|
1249
|
+
// invariant keeps holding with no new documented exception. The membership
|
|
1250
|
+
// snapshot cannot actually change — queryCadrePeers already dropped this row via
|
|
1251
|
+
// the retired-stamp gate — so the notify is a redundant refresh. Bare exec:
|
|
1252
|
+
// already inside the non-re-entrant write lock (see insertCadrePeer).
|
|
1253
|
+
//
|
|
1254
|
+
// NOTE: reapRevokedRows calls this in a loop, so a sweep that clears K CadrePeer
|
|
1255
|
+
// rows fires K redundant gate refreshes (two table scans each), awaited serially.
|
|
1256
|
+
// Harmless today — K is the backlog of unreaped rows, which a party burns down to
|
|
1257
|
+
// 0 on its first connected tick and holds there. If a party ever reaps many rows
|
|
1258
|
+
// in one pass, hoist the notify to one refresh after the sweep instead.
|
|
1259
|
+
await this.mutateCadrePeer('peer-reap', () => this.db.exec(sql, [rowKey, stampId]));
|
|
1260
|
+
}
|
|
1261
|
+
else {
|
|
1262
|
+
await this.execWrite(sql, [rowKey, stampId]);
|
|
1263
|
+
}
|
|
1264
|
+
// A row leaving the control plane without an owner signature at the write site
|
|
1265
|
+
// should be visible in a log.
|
|
1266
|
+
log('REAPED %s row %s (stamp %s): committed tombstone authorized local removal, no owner signature', table, rowKey, stampId);
|
|
1267
|
+
return true;
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1270
|
+
* Sweep every locally-held `Revocation` tombstone and {@link reapRevokedRow} the guarded
|
|
1271
|
+
* row each one retires, returning how many rows were actually removed.
|
|
1272
|
+
*
|
|
1273
|
+
* This is the enumeration half of the reap: replication carries the tombstone but cannot
|
|
1274
|
+
* carry the delete (replaying a delete of an already-absent row is a no-op), so a node
|
|
1275
|
+
* that converged on a removal while holding the removed row keeps that row as inert
|
|
1276
|
+
* garbage until something walks the tombstones and drops it. `CadreNode`'s periodic
|
|
1277
|
+
* control-cohort reconcile pass is that something, gated on the node having at least one
|
|
1278
|
+
* control connection (a reap is a write, and a write committed alone is local-only).
|
|
1279
|
+
*
|
|
1280
|
+
* Enumerated UNLOCKED, deleted per-row LOCKED: {@link reapRevokedRow} takes the write
|
|
1281
|
+
* lock itself (via {@link mutateCadrePeer} / {@link execWrite}) and the lock is not
|
|
1282
|
+
* re-entrant, so this loop must not hold it. Same structure as
|
|
1283
|
+
* `CadreNode.reissueAuthoredMembershipRows`.
|
|
1284
|
+
*
|
|
1285
|
+
* Two rows are deliberately left alone:
|
|
1286
|
+
*
|
|
1287
|
+
* - **Tables outside {@link REAPABLE_TABLES}** (`Strand`, `OwnerKey`) — no reap branch
|
|
1288
|
+
* exists on their `AuthorizedDelete`, so the delete would throw rather than no-op.
|
|
1289
|
+
* - **This node's OWN `CadrePeer` / `DeviceToken` row** (`selfPeerId`). Reaping it would
|
|
1290
|
+
* fight this node's own re-registration: `registerSelf` / `retouchSelfDeviceToken` run
|
|
1291
|
+
* on the heartbeat and the growth drain, and after a self-reap their insert-if-absent
|
|
1292
|
+
* guard takes the INSERT path — which needs an owner signature `NotRevoked` refuses for
|
|
1293
|
+
* a retired stamp, and which a revoked drone cannot produce at all. The trade is
|
|
1294
|
+
* recurring failure noise every cycle against removing the one stale row nobody else
|
|
1295
|
+
* reads from this node (every other node already filters it by tombstone). A revoked
|
|
1296
|
+
* node therefore keeps its own copy of its own row; "a node that learns it has been
|
|
1297
|
+
* revoked should shut itself down" is a distinct behaviour and its own ticket.
|
|
1298
|
+
* `ValidationKey` has no self notion and is not special-cased.
|
|
1299
|
+
*
|
|
1300
|
+
* A per-row failure is logged and skipped rather than aborting the sweep: an abort would
|
|
1301
|
+
* starve every tombstone after the failing one on every subsequent pass, and each row is
|
|
1302
|
+
* independent. Returns the count so far if the database is closed mid-sweep.
|
|
1303
|
+
*
|
|
1304
|
+
* `selfPeerId` is a parameter rather than read from the injected libp2p node so the
|
|
1305
|
+
* skip-self rule is exercisable without one.
|
|
1306
|
+
*
|
|
1307
|
+
* NOTE: cost is O(tombstones), not O(live rows) — one {@link queryRevocations} scan plus
|
|
1308
|
+
* one {@link queryStampId} point lookup per tombstone, and the empty-table early return
|
|
1309
|
+
* makes the common case (a party that has never revoked anyone) a single scan per pass.
|
|
1310
|
+
* But `Revocation` is append-only and unbounded, so this is O(all tombstones ever) point
|
|
1311
|
+
* lookups on every reconcile tick. Fine while revocations stay rare for a cadre-sized
|
|
1312
|
+
* party; if the table ever grows, persist a node-local high-water mark of what has
|
|
1313
|
+
* already been reaped instead of re-scanning everything (the same bound the sweep in
|
|
1314
|
+
* `CadreNode.drainPendingRevocations` wants).
|
|
1315
|
+
*/
|
|
1316
|
+
async reapRevokedRows(selfPeerId) {
|
|
1317
|
+
this.ensureInitialized();
|
|
1318
|
+
const held = await this.queryRevocations();
|
|
1319
|
+
if (held.length === 0) {
|
|
1320
|
+
return 0;
|
|
1321
|
+
}
|
|
1322
|
+
let reaped = 0;
|
|
1323
|
+
for (const tombstone of held) {
|
|
1324
|
+
// Teardown guard, per row: close() can land mid-sweep (the reconcile pass that
|
|
1325
|
+
// drives this outlives no node, but it does not hold the node's lifecycle either).
|
|
1326
|
+
if (!this.initialized || !this.db) {
|
|
1327
|
+
return reaped;
|
|
1328
|
+
}
|
|
1329
|
+
if (!isReapableTable(tombstone.tableName)) {
|
|
1330
|
+
continue;
|
|
1331
|
+
}
|
|
1332
|
+
if (tombstone.rowKey === selfPeerId
|
|
1333
|
+
&& (tombstone.tableName === 'CadrePeer' || tombstone.tableName === 'DeviceToken')) {
|
|
1334
|
+
continue;
|
|
1335
|
+
}
|
|
1336
|
+
try {
|
|
1337
|
+
if (await this.reapRevokedRow(tombstone.tableName, tombstone.rowKey, tombstone.stampId)) {
|
|
1338
|
+
reaped++;
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
catch (error) {
|
|
1342
|
+
log('reap sweep: %s row %s (stamp %s) failed (continuing): %o', tombstone.tableName, tombstone.rowKey, tombstone.stampId, error);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
return reaped;
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* Owner-signed re-issue of a batch of `Revocation` tombstones: bump each row's
|
|
1349
|
+
* `ReissuedAt` to `reissuedAt` in ONE transaction. Re-writing the row is what makes
|
|
1350
|
+
* the storage layer re-broadcast it — a tombstone that committed while the node was
|
|
1351
|
+
* alone is local-only, and unlike an insert a delete cannot be replayed (the guarded
|
|
1352
|
+
* row is already gone locally), so the tombstone is the half of a removal that can
|
|
1353
|
+
* still carry it to the rest of the party.
|
|
1354
|
+
*
|
|
1355
|
+
* One transaction, not one per row: a sweep may cover every tombstone in the table,
|
|
1356
|
+
* and each separate commit is a separate round of network work. One owner signature
|
|
1357
|
+
* per row: `AuthorizedReissue`'s digest binds (TableName, RowKey, StampId,
|
|
1358
|
+
* ReissuedAt). `reissuedAt` must be STRICTLY above every affected row's current
|
|
1359
|
+
* value (`ReissueOnly`); callers derive it as `Math.max(Date.now(), max(existing) + 1)`,
|
|
1360
|
+
* mirroring {@link CadreNode.reissuePeerAuthorize}'s monotonic bump. Re-issuing a
|
|
1361
|
+
* tombstone whose guarded row was never present locally is the normal case (a node
|
|
1362
|
+
* that converged on the tombstone but never held the row) — a re-issue files no
|
|
1363
|
+
* delete, so `RowIsGone` / `RevocationRecorded` are not involved.
|
|
1364
|
+
*
|
|
1365
|
+
* A constraint failure on any row rolls the WHOLE batch back and propagates —
|
|
1366
|
+
* {@link lockedWithRetry}'s classifier only re-presents transient cluster failures,
|
|
1367
|
+
* so a real refusal (non-owner signer, or a stale counter when two owner devices
|
|
1368
|
+
* sweep concurrently and the loser's `ReissueOnly` CHECK fails) surfaces to the
|
|
1369
|
+
* caller, which retries with a fresh counter on its next sweep. A row whose
|
|
1370
|
+
* `StampId` matches nothing locally is a silent no-op statement; callers enumerate
|
|
1371
|
+
* via {@link queryRevocations} first, so they only name stamps they hold.
|
|
1372
|
+
*
|
|
1373
|
+
* Returns how many UPDATE statements ran (`rows.length` on success).
|
|
1374
|
+
*/
|
|
1375
|
+
reissueRevocations(rows, reissuedAt, ownerKey, signMessage) {
|
|
1376
|
+
this.ensureInitialized();
|
|
1377
|
+
// Signatures minted OUTSIDE the locked body: a retried attempt must re-present the
|
|
1378
|
+
// exact signed message, never re-mint (the contract on withWriteLock). ReissuedAt
|
|
1379
|
+
// rides in the digest as a string — SQL-side it is cast(new.ReissuedAt as text),
|
|
1380
|
+
// and every digest field is TEXT on both sides (see buildAuthorizationMessage).
|
|
1381
|
+
const signed = rows.map((row) => ({
|
|
1382
|
+
row,
|
|
1383
|
+
signature: signMessage(buildAuthorizationMessage('CadreControl.Revocation', 'reissue', [row.tableName, row.rowKey, row.stampId, String(reissuedAt)])),
|
|
1384
|
+
}));
|
|
1385
|
+
return this.lockedWithRetry(async () => {
|
|
1386
|
+
// Counter lives INSIDE the locked body so a lockedWithRetry re-run (which
|
|
1387
|
+
// re-executes the whole body) starts from zero instead of double-counting.
|
|
1388
|
+
let executed = 0;
|
|
1389
|
+
await this.inTransaction('reissue revocations', async () => {
|
|
1390
|
+
for (const { row, signature } of signed) {
|
|
1391
|
+
// Keyed on StampId ALONE, deliberately. Equality on every column of the
|
|
1392
|
+
// composite primary key (TableName, StampId) makes the optimystic vtab claim
|
|
1393
|
+
// the predicate as a point lookup with no engine-side re-check, and that
|
|
1394
|
+
// descent has been observed returning zero rows for a row that provably
|
|
1395
|
+
// exists (tickets/backlog/debt-composite-pk-point-lookup-unreliable-untracked).
|
|
1396
|
+
// A re-issue that silently matched nothing would report success and
|
|
1397
|
+
// replicate nothing — the exact failure this path exists to remove. StampId
|
|
1398
|
+
// alone is a non-leading subset of the key, which the module declines, so
|
|
1399
|
+
// the read is a scan the engine filters; a StampId is 128 bits of CSPRNG
|
|
1400
|
+
// output minted per row incarnation (generateStampId), so it identifies one
|
|
1401
|
+
// row across all TableName values. Do NOT "fix" this by adding TableName to
|
|
1402
|
+
// the where clause.
|
|
1403
|
+
await this.db.exec(`
|
|
1404
|
+
update CadreControl.Revocation
|
|
1405
|
+
with context OwnerKey = ?, Signature = ?
|
|
1406
|
+
set ReissuedAt = ?
|
|
1407
|
+
where StampId = ?
|
|
1408
|
+
`, [ownerKey, signature, reissuedAt, row.stampId]);
|
|
1409
|
+
executed++;
|
|
1410
|
+
}
|
|
1411
|
+
});
|
|
1412
|
+
log('Reissued %d revocation tombstone(s) at %d', executed, reissuedAt);
|
|
1413
|
+
return executed;
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* Run `body` between `beginTransaction` and `commit`, rolling back on failure.
|
|
1418
|
+
*
|
|
1419
|
+
* A failed `commit()` has already torn the transaction down, so the `rollback()` in
|
|
1420
|
+
* the failure path would itself throw "No transaction active" and mask the real
|
|
1421
|
+
* cause — that secondary throw is logged and swallowed, and the original error always
|
|
1422
|
+
* propagates.
|
|
1423
|
+
*
|
|
1424
|
+
* It opens a transaction unconditionally (Quereus hard-throws on a nested
|
|
1425
|
+
* `beginTransaction`), so a caller already inside one must not call it. A `CadrePeer`
|
|
1426
|
+
* write nests this INSIDE {@link mutateCadrePeer}, never the reverse — see
|
|
1427
|
+
* {@link assertCommitBoundary}.
|
|
1428
|
+
*
|
|
1429
|
+
* Private: every multi-statement control write lives in this class (the owner-signed
|
|
1430
|
+
* delete/tombstone pairs all go through {@link deleteGuardedRow}), so needing to widen
|
|
1431
|
+
* this is a sign the writer that wants it belongs in here too.
|
|
1432
|
+
*
|
|
1433
|
+
* Deliberately NOT wrapped in {@link withWriteLock}: callers already hold the
|
|
1434
|
+
* (non-re-entrant) lock, so taking it again here would self-deadlock.
|
|
1435
|
+
*
|
|
1436
|
+
* @param label - What the transaction was doing, for the rollback log line.
|
|
1437
|
+
*/
|
|
1438
|
+
async inTransaction(label, body) {
|
|
1439
|
+
this.ensureInitialized();
|
|
1440
|
+
await this.db.beginTransaction();
|
|
1441
|
+
try {
|
|
1442
|
+
await body();
|
|
1443
|
+
await this.db.commit();
|
|
1444
|
+
}
|
|
1445
|
+
catch (error) {
|
|
1446
|
+
try {
|
|
1447
|
+
await this.db.rollback();
|
|
1448
|
+
}
|
|
1449
|
+
catch (rollbackError) {
|
|
1450
|
+
log('Rollback after %s failure was a no-op: %s', label, rollbackError);
|
|
1451
|
+
}
|
|
1452
|
+
throw error;
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
/**
|
|
1456
|
+
* Wire (or clear, with null) the single listener notified after every committed
|
|
1457
|
+
* `CadreControl.CadrePeer` row write.
|
|
1458
|
+
*
|
|
1459
|
+
* At most one listener: a ControlDatabase instance belongs to exactly one
|
|
1460
|
+
* {@link CadreNode}, which wires this in `start()` and clears it on teardown. A
|
|
1461
|
+
* second call replaces the first rather than fanning out, so a stale node can
|
|
1462
|
+
* never keep receiving notifications for a database it no longer owns.
|
|
1463
|
+
*/
|
|
1464
|
+
setMembershipChangeListener(listener) {
|
|
1465
|
+
this.membershipListener = listener;
|
|
1466
|
+
}
|
|
1467
|
+
/**
|
|
1468
|
+
* Wire (or clear, with null) the single listener notified after every committed
|
|
1469
|
+
* guarded-table delete. Same ownership contract as
|
|
1470
|
+
* {@link setMembershipChangeListener}: one CadreNode, one listener, a second
|
|
1471
|
+
* call replaces the first.
|
|
1472
|
+
*/
|
|
1473
|
+
setGuardedDeleteListener(listener) {
|
|
1474
|
+
this.guardedDeleteListener = listener;
|
|
1475
|
+
}
|
|
1476
|
+
/**
|
|
1477
|
+
* Run a `CadrePeer` row mutation and notify the membership listener once it has
|
|
1478
|
+
* COMMITTED.
|
|
1479
|
+
*
|
|
1480
|
+
* EVERY `CadrePeer` writer goes through here, with one documented exception
|
|
1481
|
+
* ({@link updateSelfPeerRecord}, which cannot change the snapshot) — that is what makes
|
|
1482
|
+
* the party-membership snapshot refresh automatic rather than a caller obligation. A
|
|
1483
|
+
* writer necessarily holds the target node's ControlDatabase (the `SeedBootstrapService`'s
|
|
1484
|
+
* event callbacks are NOT a viable seam: the temp services `CadreNode.applySeed`/
|
|
1485
|
+
* `dialInvite` build, and services constructed outside `CadreNode` entirely, never get
|
|
1486
|
+
* callbacks wired), so this is the one point on the write path that cannot be bypassed.
|
|
1487
|
+
*
|
|
1488
|
+
* `body` owns its own transaction if it needs one and must COMMIT before returning, so
|
|
1489
|
+
* the notification never makes the listener read uncommitted state. A throwing `body`
|
|
1490
|
+
* propagates and does NOT notify: nothing changed.
|
|
1491
|
+
*
|
|
1492
|
+
* That contract is enforced, not merely documented — see {@link assertCommitBoundary}.
|
|
1493
|
+
*
|
|
1494
|
+
* Runs under {@link withWriteLock}, notification included: with the lock held through
|
|
1495
|
+
* the notify, no other local writer can have a transaction open while the listener
|
|
1496
|
+
* reads, so the listener always sees exactly the committed state it was told about.
|
|
1497
|
+
* The listener itself only READS (it re-materializes the membership snapshot) — a
|
|
1498
|
+
* listener that wrote through a locked method would deadlock.
|
|
1499
|
+
*
|
|
1500
|
+
* Reaches the lock through {@link lockedWithRetry}, so after a transient cluster
|
|
1501
|
+
* failure the WHOLE locked body re-runs — `body` must be atomic and re-runnable (the
|
|
1502
|
+
* contract on {@link withWriteLock}). The notify sits after a successful `body` inside
|
|
1503
|
+
* the same attempt and a throwing attempt never reaches it, so a retried mutation
|
|
1504
|
+
* still notifies exactly once: on the attempt that commits, and not at all on
|
|
1505
|
+
* exhaustion.
|
|
1506
|
+
*
|
|
1507
|
+
* @param reason - Label for the log line only (e.g. `'peer-insert'`).
|
|
1508
|
+
*/
|
|
1509
|
+
async mutateCadrePeer(reason, body) {
|
|
1510
|
+
this.ensureInitialized();
|
|
1511
|
+
return this.lockedWithRetry(async () => {
|
|
1512
|
+
this.assertCommitBoundary(reason, 'on entry to');
|
|
1513
|
+
const result = await body();
|
|
1514
|
+
this.assertCommitBoundary(reason, 'on return from');
|
|
1515
|
+
await this.notifyMembershipChanged(reason);
|
|
1516
|
+
return result;
|
|
1517
|
+
});
|
|
1518
|
+
}
|
|
1519
|
+
/**
|
|
1520
|
+
* Run one local write under the database-wide write lock, serializing it against
|
|
1521
|
+
* every other local writer on this ControlDatabase.
|
|
1522
|
+
*
|
|
1523
|
+
* Quereus tracks transaction state per `Database` (`getAutocommit()`), and a write
|
|
1524
|
+
* statement's implicit transaction stays open across the awaits inside `exec`. Two
|
|
1525
|
+
* local writers interleaving mid-statement therefore either trip
|
|
1526
|
+
* {@link assertCommitBoundary} (any `CadrePeer` path) or silently join each other's
|
|
1527
|
+
* open transaction — a torn commit if either side rolls back. This lock closes that
|
|
1528
|
+
* class: every public write method of this class runs its statement(s) under it, and
|
|
1529
|
+
* `SeedBootstrapService` wraps its direct `db.exec` writes in it too. The race is
|
|
1530
|
+
* real, not theoretical: the background self-record publish on a control connection
|
|
1531
|
+
* opening (`CadreNode.drainPendingControlReplication` → `registerSelf`) collided with
|
|
1532
|
+
* a foreground `authorizePeer` — both `CadrePeer` inserts — and tripped the boundary
|
|
1533
|
+
* assert.
|
|
1534
|
+
*
|
|
1535
|
+
* Reads are deliberately not locked: they take no transaction of their own, and
|
|
1536
|
+
* serializing them here would let a listener that reads during a notify (see
|
|
1537
|
+
* {@link mutateCadrePeer}) deadlock. The lock is NOT re-entrant — a locked writer's
|
|
1538
|
+
* body must never call another locked public method; the private bodies it composes
|
|
1539
|
+
* ({@link deleteGuardedRow}, {@link inTransaction}) stay bare for exactly that reason.
|
|
1540
|
+
*
|
|
1541
|
+
* NOTE: re-entry fails SILENTLY and PERMANENTLY. A locked body that calls another
|
|
1542
|
+
* locked public method (including a {@link mutateCadrePeer} body, which runs an
|
|
1543
|
+
* arbitrary caller-supplied callback) queues behind its own tail and never resolves —
|
|
1544
|
+
* no error, and it strands the whole write queue, not only that call. There is no
|
|
1545
|
+
* cheap fail-fast: a "held" flag cannot tell re-entry from a legitimately queued
|
|
1546
|
+
* concurrent writer. Compose bare private bodies instead.
|
|
1547
|
+
*
|
|
1548
|
+
* A locked body must also be ATOMIC and RE-RUNNABLE: the public write surface reaches
|
|
1549
|
+
* this lock through {@link lockedWithRetry}, which re-runs the WHOLE body after a
|
|
1550
|
+
* transient cluster failure. Atomic holds for every body today — one statement, or
|
|
1551
|
+
* wrapped in {@link inTransaction} — so a failed attempt leaves nothing half-applied;
|
|
1552
|
+
* re-runnable means a retry re-runs the body's reads too, which is what makes it safe.
|
|
1553
|
+
* Signatures and stamp ids minted OUTSIDE the body are deliberately NOT re-minted per
|
|
1554
|
+
* attempt: a retry re-presents the exact signed message the first attempt presented.
|
|
1555
|
+
*/
|
|
1556
|
+
async withWriteLock(fn) {
|
|
1557
|
+
// Chain behind the current tail regardless of how it settled, then park a
|
|
1558
|
+
// swallowed copy as the new tail so one failed write never poisons the queue.
|
|
1559
|
+
const run = this.writeQueue.then(fn, fn);
|
|
1560
|
+
this.writeQueue = run.then(() => undefined, () => undefined);
|
|
1561
|
+
return run;
|
|
1562
|
+
}
|
|
1563
|
+
/**
|
|
1564
|
+
* {@link withWriteLock} plus the bounded transient-failure retry
|
|
1565
|
+
* ({@link retryControlWrite}) — the wrapper the whole public write surface goes
|
|
1566
|
+
* through, so a control write that failed because the cluster cohort did not answer is
|
|
1567
|
+
* re-presented a moment later instead of surfacing to ~19 callers that are not
|
|
1568
|
+
* uniformly written to retry.
|
|
1569
|
+
*
|
|
1570
|
+
* The retry wraps the LOCK, it does not live inside it: each attempt takes and
|
|
1571
|
+
* releases the lock, and the backoff sleeps with NO lock held, so a write parked in
|
|
1572
|
+
* backoff never stalls the other local writers queued behind it. Same reason
|
|
1573
|
+
* {@link withUseNumberRetry} re-takes the lock per attempt — see the note on
|
|
1574
|
+
* `USE_NUMBER_ATTEMPTS`. Safe because every locked body is atomic and re-runnable
|
|
1575
|
+
* (the contract on {@link withWriteLock}).
|
|
1576
|
+
*
|
|
1577
|
+
* Also carries {@link loadSchema}'s distributed DDL, the one caller that runs BEFORE
|
|
1578
|
+
* `initialized` is set — so it reaches this method directly rather than through
|
|
1579
|
+
* {@link execWrite}, whose {@link ensureInitialized} would reject it. The write queue is
|
|
1580
|
+
* necessarily empty at that point, so the lock is uncontended there; it is taken anyway
|
|
1581
|
+
* rather than carving out a lockless path. It is also the ONLY caller that passes a
|
|
1582
|
+
* `policy` — everything else takes the default, whose classifier and attempt count are
|
|
1583
|
+
* unchanged by that call site existing.
|
|
1584
|
+
*/
|
|
1585
|
+
lockedWithRetry(fn, policy = {}) {
|
|
1586
|
+
return retryControlWrite(() => this.withWriteLock(fn), { ...policy, ...this.controlWriteRetryPacing });
|
|
1587
|
+
}
|
|
1588
|
+
/**
|
|
1589
|
+
* One-statement local write, serialized by {@link withWriteLock} and retried on a
|
|
1590
|
+
* transient cluster failure via {@link lockedWithRetry}. Prefer this over a bare
|
|
1591
|
+
* `getDatabase().exec` so a new writer cannot forget the lock — the hazard
|
|
1592
|
+
* {@link assertCommitBoundary} can only catch after the fact.
|
|
1593
|
+
*
|
|
1594
|
+
* Public so `SeedBootstrapService`'s direct `CadrePeer`/`DeviceToken` SQL writes go
|
|
1595
|
+
* through the same seam. Must NOT be called from inside an already-locked body (the
|
|
1596
|
+
* lock is not re-entrant); such a caller uses a bare `getDatabase().exec` instead.
|
|
1597
|
+
*/
|
|
1598
|
+
execWrite(sql, params) {
|
|
1599
|
+
this.ensureInitialized();
|
|
1600
|
+
return this.lockedWithRetry(() => this.db.exec(sql, params));
|
|
1601
|
+
}
|
|
1602
|
+
/**
|
|
1603
|
+
* Throw unless the database sits at a committed boundary — `getAutocommit()` is false
|
|
1604
|
+
* exactly while a transaction is open.
|
|
1605
|
+
*
|
|
1606
|
+
* Both ends of a {@link mutateCadrePeer} body are checked, because either open
|
|
1607
|
+
* transaction would notify a listener that then reads PRE-commit state and materializes
|
|
1608
|
+
* a membership snapshot silently missing the write it was told about:
|
|
1609
|
+
*
|
|
1610
|
+
* - open on entry: the wrapper sits INSIDE an enclosing transaction and must be moved
|
|
1611
|
+
* out to enclose that outer commit.
|
|
1612
|
+
* - open on return: the body opened a transaction and never committed it.
|
|
1613
|
+
*
|
|
1614
|
+
* Neither is reachable from today's callers, so this is a fail-fast guard on a future
|
|
1615
|
+
* mistake rather than a live code path.
|
|
1616
|
+
*
|
|
1617
|
+
* NOTE: `getAutocommit()` reports the whole `Database`, not this call. Local writers
|
|
1618
|
+
* serialize through {@link withWriteLock}, so with every writer locked this can only
|
|
1619
|
+
* trip on the two misuses above. A writer that bypasses the lock (a new method that
|
|
1620
|
+
* forgets it, or raw `getDatabase().exec` outside a locked wrapper) re-opens the
|
|
1621
|
+
* concurrent-writer window, and this assert is what catches it — it throws instead of
|
|
1622
|
+
* silently joining the other write's transaction.
|
|
1623
|
+
*/
|
|
1624
|
+
assertCommitBoundary(reason, where) {
|
|
1625
|
+
if (this.db.getAutocommit()) {
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
throw new Error(`mutateCadrePeer(${reason}): a transaction is open ${where} the mutation body — a ` +
|
|
1629
|
+
'CadrePeer write must commit before the membership listener runs; move the ' +
|
|
1630
|
+
'mutateCadrePeer wrapper out to enclose the commit');
|
|
1631
|
+
}
|
|
1632
|
+
/**
|
|
1633
|
+
* Best-effort notify. A listener that throws is logged and swallowed: the write has
|
|
1634
|
+
* already committed, and it must never be reported as failed because a downstream
|
|
1635
|
+
* snapshot refresh did. A null listener (no `CadreNode` attached — e.g. the service
|
|
1636
|
+
* unit tests drive a bare control DB) is a silent no-op.
|
|
1637
|
+
*/
|
|
1638
|
+
async notifyMembershipChanged(reason) {
|
|
1639
|
+
if (!this.membershipListener) {
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1642
|
+
try {
|
|
1643
|
+
await this.membershipListener(reason);
|
|
1644
|
+
}
|
|
1645
|
+
catch (error) {
|
|
1646
|
+
log('Membership change listener failed (%s): %s', reason, error);
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
492
1649
|
/**
|
|
493
1650
|
* Insert an owner-signed `FormationInvite` (open invitation token).
|
|
494
1651
|
*
|
|
495
1652
|
* The invite is the on-network record that later authorizes an
|
|
496
1653
|
* owner-signature-FREE `Strand` creation: an invited cadre peer redeems it
|
|
497
1654
|
* by inserting a matching `FormationUsage` row (see {@link redeemInvitation}),
|
|
498
|
-
* which satisfies the consent branch of `Strand.
|
|
1655
|
+
* which satisfies the consent branch of `Strand.AuthorizedInsert`.
|
|
499
1656
|
*
|
|
500
1657
|
* Like {@link insertStrand}/{@link insertValidationKey}, the owner signs the
|
|
501
1658
|
* canonical row-bound authorization message (see {@link buildAuthorizationMessage})
|
|
502
1659
|
* over (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId) — NOT a
|
|
503
1660
|
* bare stamp — so the signature is bound to this invite's contents and cannot be
|
|
504
1661
|
* transplanted onto an attacker-chosen row. The StampId is persisted as a unique
|
|
505
|
-
* column for single-use anti-replay. `FormationInvite.
|
|
506
|
-
*
|
|
507
|
-
* insert
|
|
1662
|
+
* column for single-use anti-replay. `FormationInvite.AuthorizedInsert` gates insert
|
|
1663
|
+
* over an `'add'`-tagged digest; deletes verify a DISTINCT `'remove'`-tagged digest
|
|
1664
|
+
* (`AuthorizedDelete`), so this insert approval can never be replayed as a revocation.
|
|
508
1665
|
*
|
|
509
1666
|
* `StrandId` binds the invite to a pre-existing host strand (provision-then-record):
|
|
510
1667
|
* when set, a responder redeeming this token records a `FormationUsage` against that
|
|
@@ -545,16 +1702,16 @@ export class ControlDatabase {
|
|
|
545
1702
|
const totalUsesField = options.totalUses == null ? '' : String(options.totalUses);
|
|
546
1703
|
const validationUrlField = options.validationUrl ?? '';
|
|
547
1704
|
const strandIdField = options.strandId ?? '';
|
|
548
|
-
// Field order MUST match the schema's FormationInvite `
|
|
1705
|
+
// Field order MUST match the schema's FormationInvite `AuthorizedInsert` verify:
|
|
549
1706
|
// Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId.
|
|
550
|
-
const message = buildAuthorizationMessage([
|
|
1707
|
+
const message = buildAuthorizationMessage('CadreControl.FormationInvite', 'add', [
|
|
551
1708
|
token, sAppId, expiresAtField, totalUsesField, validationUrlField, strandIdField, stampId,
|
|
552
1709
|
]);
|
|
553
1710
|
const signature = signMessage(message);
|
|
554
1711
|
// Persist the canonical ExpiresAt string (datetime parse is idempotent on it) so the
|
|
555
1712
|
// signed source-of-truth and the stored value are produced once. StampId is a real,
|
|
556
1713
|
// unique column (single-use anti-replay), no longer a context value.
|
|
557
|
-
await this.
|
|
1714
|
+
await this.execWrite(`
|
|
558
1715
|
insert into CadreControl.FormationInvite (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId)
|
|
559
1716
|
with context OwnerKey = ?, Signature = ?
|
|
560
1717
|
values (?, ?, ?, ?, ?, ?, ?)
|
|
@@ -574,54 +1731,62 @@ export class ControlDatabase {
|
|
|
574
1731
|
* `FormationUsage` row **atomically, in one transaction**.
|
|
575
1732
|
*
|
|
576
1733
|
* The two CHECK constraints are mutually circular under immediate evaluation:
|
|
577
|
-
* `Strand.
|
|
578
|
-
* `FormationUsage.StrandExists` requires
|
|
1734
|
+
* `Strand.AuthorizedInsert`'s consent branch requires a `FormationUsage` row naming
|
|
1735
|
+
* this strand's `(Id, StampId)`, while `FormationUsage.StrandExists` requires a
|
|
1736
|
+
* `Strand` row matching that same pair — the ONE freshly-minted `strandStampId` below
|
|
1737
|
+
* satisfies both, which is what binds the consent record to this specific strand ROW
|
|
1738
|
+
* (so a later owner-signed, tombstoned removal cannot be undone by re-inserting the
|
|
1739
|
+
* id with a fresh stamp). Both CHECKs contain
|
|
579
1740
|
* subqueries, so Quereus auto-defers them to transaction commit — wrapping both
|
|
580
1741
|
* inserts in a single explicit `begin … commit` lets both deferred CHECKs see
|
|
581
1742
|
* both rows at commit. The strand is authorised WITHOUT an owner signature
|
|
582
|
-
* (the `FormationUsage` branch of `Strand.
|
|
1743
|
+
* (the `FormationUsage` branch of `Strand.AuthorizedInsert`) but still gets a fresh,
|
|
583
1744
|
* unique `StampId` column to satisfy the not-null/unique anti-replay column.
|
|
584
1745
|
*
|
|
1746
|
+
* The seated strand is always open (`'o'`) and keyless — the consent branch of
|
|
1747
|
+
* `Strand.AuthorizedInsert` accepts nothing else, the invite must be UNBOUND
|
|
1748
|
+
* (`FormationInvite.StrandId` null; a bound invite's host strand is owner-provisioned
|
|
1749
|
+
* and only ever record-only, see {@link recordFormationUsage}), and a given strand id
|
|
1750
|
+
* may be consent-seated once, EVER: after an owner-signed removal, re-joining that id
|
|
1751
|
+
* takes an owner re-seat ({@link insertStrand}) plus a bound invite, never another
|
|
1752
|
+
* redemption.
|
|
1753
|
+
*
|
|
585
1754
|
* `UseNumber` is computed as `max(UseNumber)+1` for the token (the `Monotonic`
|
|
586
|
-
* constraint)
|
|
587
|
-
*
|
|
1755
|
+
* constraint), inside the write lock and re-read per attempt by
|
|
1756
|
+
* {@link withUseNumberRetry} — so concurrent redemptions of one token no longer need
|
|
1757
|
+
* the caller to serialise them, and a use number lost to another writer is retried here
|
|
1758
|
+
* with the caller's parameters passed through UNCHANGED. That is what makes the retry
|
|
1759
|
+
* safe: the approval is bound to the nonce, not to the use number, so re-presenting it
|
|
1760
|
+
* cannot drift, and no second trip through the approval hook is possible from in here.
|
|
588
1761
|
*/
|
|
589
1762
|
async redeemInvitation(params) {
|
|
590
1763
|
this.ensureInitialized();
|
|
591
|
-
const { token, strandId,
|
|
1764
|
+
const { token, strandId, disclosure = '', peerKey, peerSignature, usageStampId, nowMs, validationKey, validationSignature, signal, totalUses, } = params;
|
|
592
1765
|
log('Redeeming invitation %s -> strand %s', token, strandId);
|
|
593
1766
|
const localPeerId = this.config.libp2pNode.peerId.toString();
|
|
1767
|
+
// Minted ONCE, outside the loop: a failed attempt rolls its whole transaction back, so
|
|
1768
|
+
// nothing was ever persisted under this stamp, and it is deliberately not part of the
|
|
1769
|
+
// approver's signed digest. Re-minting per attempt would only churn.
|
|
594
1770
|
const strandStampId = generateStampId(localPeerId);
|
|
595
|
-
const useNumber = await this.
|
|
596
|
-
await this.db.beginTransaction();
|
|
597
|
-
try {
|
|
1771
|
+
const useNumber = await this.withUseNumberRetry(token, 'redemption', signal, totalUses, attemptUseNumber => this.inTransaction('redemption', async () => {
|
|
598
1772
|
// 1. Strand row — authorised by the FormationUsage branch (no owner sig),
|
|
599
1773
|
// still carrying a fresh unique StampId for the anti-replay column.
|
|
1774
|
+
// Hard-coded open + keyless: the consent branch admits no other shape.
|
|
600
1775
|
await this.db.exec(`
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
// 2. FormationUsage row — authorised by the matching FormationInvite
|
|
1776
|
+
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId)
|
|
1777
|
+
with context OwnerKey = null, Signature = null
|
|
1778
|
+
values (?, 'o', null, ?)
|
|
1779
|
+
`, [strandId, strandStampId]);
|
|
1780
|
+
// 2. FormationUsage row — authorised by the matching FormationInvite, and
|
|
1781
|
+
// carrying the strand's stamp so it authorizes THIS row and no other.
|
|
606
1782
|
await this.execFormationUsageInsert({
|
|
607
|
-
token, useNumber, disclosure, strandId,
|
|
608
|
-
|
|
1783
|
+
token, useNumber: attemptUseNumber, usageStampId, disclosure, strandId, strandStampId,
|
|
1784
|
+
peerKey, peerSignature, nowMs: nowMs ?? Date.now(),
|
|
609
1785
|
validationKey: validationKey ?? null, validationSignature: validationSignature ?? null,
|
|
610
1786
|
});
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
}
|
|
614
|
-
catch (error) {
|
|
615
|
-
// A failed commit() already tears down the transaction, so rollback() would
|
|
616
|
-
// throw "No transaction active" and mask the real cause — swallow only that.
|
|
617
|
-
try {
|
|
618
|
-
await this.db.rollback();
|
|
619
|
-
}
|
|
620
|
-
catch (rollbackError) {
|
|
621
|
-
log('Rollback after redemption failure was a no-op: %s', rollbackError);
|
|
622
|
-
}
|
|
623
|
-
throw error;
|
|
624
|
-
}
|
|
1787
|
+
}));
|
|
1788
|
+
log('Redeemed invitation %s -> strand %s (use #%d)', token, strandId, useNumber);
|
|
1789
|
+
return { useNumber, usageStampId };
|
|
625
1790
|
}
|
|
626
1791
|
/**
|
|
627
1792
|
* Record a `FormationUsage` against an **already-existing** `Strand` (no strand
|
|
@@ -633,19 +1798,143 @@ export class ControlDatabase {
|
|
|
633
1798
|
*
|
|
634
1799
|
* Use {@link redeemInvitation} instead when the strand must be created by
|
|
635
1800
|
* consent atomically with the usage.
|
|
1801
|
+
*
|
|
1802
|
+
* The strand's live `StampId` is read first and written onto the usage row:
|
|
1803
|
+
* `FormationUsage.StrandExists` matches the (id, stamp) PAIR, and
|
|
1804
|
+
* `Strand.AuthorizedInsert`'s consent branch reads the same pair back, so a consent
|
|
1805
|
+
* record authorizes exactly the strand ROW it was recorded against. A missing strand
|
|
1806
|
+
* THROWS here rather than being left to the deferred `StrandExists` CHECK — the
|
|
1807
|
+
* ordinary "host strand has not converged yet" case is already reported as `missing`
|
|
1808
|
+
* by {@link ControlFormationUsageRecorder.resolveStrand}, so an absent row at this
|
|
1809
|
+
* point is a genuine race and deserves a named error, not a silent rollback.
|
|
1810
|
+
*
|
|
1811
|
+
* `UseNumber` is read inside the write lock and re-read per attempt by
|
|
1812
|
+
* {@link withUseNumberRetry}: this is the path production actually races on (a bound
|
|
1813
|
+
* invite published by `cadre-web` / `cadre-phone` is always record-only), and a use number
|
|
1814
|
+
* lost to another writer is retried here with the caller's parameters — nonce, approver
|
|
1815
|
+
* sign-off, strand, peer key, disclosure — passed through UNCHANGED, so a granted approval
|
|
1816
|
+
* is never thrown away for a second trip through the approval hook.
|
|
636
1817
|
*/
|
|
637
1818
|
async recordFormationUsage(params) {
|
|
638
1819
|
this.ensureInitialized();
|
|
639
|
-
const { token, strandId, disclosure = '',
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
await this.
|
|
643
|
-
|
|
644
|
-
|
|
1820
|
+
const { token, strandId, disclosure = '', peerKey, peerSignature, usageStampId, nowMs, validationKey, validationSignature, signal, totalUses, } = params;
|
|
1821
|
+
// Read ONCE, outside the loop: the host strand is pre-existing and owner-signed, and a
|
|
1822
|
+
// retry changes which use number we take, never which strand row we name.
|
|
1823
|
+
const strandStampId = await this.queryStrandStampId(strandId);
|
|
1824
|
+
if (strandStampId === null) {
|
|
1825
|
+
throw new MissingHostStrandError(strandId, token);
|
|
1826
|
+
}
|
|
1827
|
+
const useNumber = await this.withUseNumberRetry(token, 'usage recording', signal, totalUses, attemptUseNumber => this.execFormationUsageInsert({
|
|
1828
|
+
token, useNumber: attemptUseNumber, usageStampId, disclosure, strandId, strandStampId,
|
|
1829
|
+
peerKey, peerSignature, nowMs: nowMs ?? Date.now(),
|
|
645
1830
|
validationKey: validationKey ?? null, validationSignature: validationSignature ?? null,
|
|
646
|
-
});
|
|
1831
|
+
}));
|
|
647
1832
|
log('Recorded formation usage: token=%s strand=%s (use #%d)', token, strandId, useNumber);
|
|
648
|
-
return useNumber;
|
|
1833
|
+
return { useNumber, usageStampId };
|
|
1834
|
+
}
|
|
1835
|
+
/**
|
|
1836
|
+
* Assign a `UseNumber` and run `write` under it, re-picking and retrying when another
|
|
1837
|
+
* writer took that number first.
|
|
1838
|
+
*
|
|
1839
|
+
* Each attempt takes {@link withWriteLock} and, inside it, reads {@link nextUseNumber}
|
|
1840
|
+
* afresh. Reading under the lock is the whole fix for the common case: two redemptions of
|
|
1841
|
+
* one token on the SAME node are serialized by the local write queue, so each computes a
|
|
1842
|
+
* number the other has already committed and neither collides. The retry covers the writers
|
|
1843
|
+
* this lock does not reach — another node of the cadre, another `Database` handle over the
|
|
1844
|
+
* same store — which can still take the number between our read and our commit.
|
|
1845
|
+
*
|
|
1846
|
+
* `write` receives ONLY the use number; every other parameter of the redemption (the nonce,
|
|
1847
|
+
* the approver's signature, the strand, the joining peer key, the disclosure) is closed over
|
|
1848
|
+
* by the caller and passed through untouched. So by construction a retry re-presents the
|
|
1849
|
+
* IDENTICAL approval and the four non-nonce fields inside the approver's signed digest
|
|
1850
|
+
* cannot drift. No second call to the approval hook is reachable from in here either:
|
|
1851
|
+
* `obtainApproval` lives a layer up in `ControlFormationUsageRecorder` and is never
|
|
1852
|
+
* re-entered.
|
|
1853
|
+
*
|
|
1854
|
+
* `write` must be a BARE private body ({@link inTransaction} /
|
|
1855
|
+
* {@link execFormationUsageInsert}) — {@link withWriteLock} is not re-entrant and a `write`
|
|
1856
|
+
* that called another locked public method would hang silently and strand the write queue.
|
|
1857
|
+
*
|
|
1858
|
+
* A failed attempt has written nothing (a bare insert never landed; a transactional one
|
|
1859
|
+
* rolled back), so the abort check at the top of each attempt stays inside
|
|
1860
|
+
* {@link FormationAbortedError}'s contract — it is only ever thrown before this attempt's
|
|
1861
|
+
* write is issued.
|
|
1862
|
+
*
|
|
1863
|
+
* @param operation - `'redemption'` | `'usage recording'`, for the abort error's text.
|
|
1864
|
+
* @param knownTotalUses - The invite's seat budget when the caller already has it in hand
|
|
1865
|
+
* (`undefined` to fall back to a fresh {@link queryFormationInvite} read; `null` for "no
|
|
1866
|
+
* invite row" / unlimited). Passed through to {@link assertSeatRemains} on every attempt.
|
|
1867
|
+
* @returns The `UseNumber` the successful attempt wrote under.
|
|
1868
|
+
*/
|
|
1869
|
+
async withUseNumberRetry(token, operation, signal, knownTotalUses, write) {
|
|
1870
|
+
let lastError;
|
|
1871
|
+
for (let attempt = 1; attempt <= USE_NUMBER_ATTEMPTS; attempt++) {
|
|
1872
|
+
try {
|
|
1873
|
+
// lockedWithRetry carries the transient-cluster retry INSIDE this use-number
|
|
1874
|
+
// retry. The loops cannot multiply: a lost use number is a constraint failure,
|
|
1875
|
+
// which `isRetriableControlWriteFailure` never matches, and a transient cluster
|
|
1876
|
+
// failure is not a lost use number, so exactly one loop claims any given error.
|
|
1877
|
+
return await this.lockedWithRetry(async () => {
|
|
1878
|
+
// Inside the lock: a write still parked behind another writer when the caller's
|
|
1879
|
+
// budget expired is abandoned rather than executed.
|
|
1880
|
+
if (signal?.aborted) {
|
|
1881
|
+
throw new FormationAbortedError(token, operation);
|
|
1882
|
+
}
|
|
1883
|
+
const useNumber = await this.nextUseNumber(token);
|
|
1884
|
+
await this.assertSeatRemains(token, useNumber, knownTotalUses);
|
|
1885
|
+
await write(useNumber);
|
|
1886
|
+
return useNumber;
|
|
1887
|
+
});
|
|
1888
|
+
}
|
|
1889
|
+
catch (error) {
|
|
1890
|
+
if (!isLostUseNumberRace(error)) {
|
|
1891
|
+
throw error;
|
|
1892
|
+
}
|
|
1893
|
+
lastError = error;
|
|
1894
|
+
log('Use number for token %s was taken by another writer (attempt %d/%d): %s', token, attempt, USE_NUMBER_ATTEMPTS, error);
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
// Out of attempts under sustained contention. Rethrowing the engine's own error keeps
|
|
1898
|
+
// today's behaviour: `StrandFormationManager.provisionAsResponder`'s catch-all maps it to
|
|
1899
|
+
// `Formation conflict, retry` and the joiner starts over with a fresh nonce.
|
|
1900
|
+
throw lastError;
|
|
1901
|
+
}
|
|
1902
|
+
/**
|
|
1903
|
+
* Refuse an attempt that would consume a seat the invite does not have.
|
|
1904
|
+
*
|
|
1905
|
+
* `FormationUsage.Authorized` already enforces `FI.TotalUses is null or FI.TotalUses >=
|
|
1906
|
+
* new.UseNumber`, so an over-limit write fails at the database anyway — but it fails as a
|
|
1907
|
+
* generic `CHECK constraint failed: Authorized`, which the manager reports as a retryable
|
|
1908
|
+
* conflict. Catching it here as {@link InvitationExhaustedError} lets the joiner be told the
|
|
1909
|
+
* invitation is spent instead of being sent around a loop that can never close. Run on EVERY
|
|
1910
|
+
* attempt, including the first: two redemptions racing on the SAME node serialize behind the
|
|
1911
|
+
* local write queue, so the loser's first attempt is already over budget, with no retry
|
|
1912
|
+
* involved.
|
|
1913
|
+
*
|
|
1914
|
+
* `knownTotalUses` lets a caller that already read the `FormationInvite` row (both
|
|
1915
|
+
* production callers in `ControlFormationUsageRecorder` do) skip a second read on the common,
|
|
1916
|
+
* non-racing path. `undefined` falls back to a fresh read here.
|
|
1917
|
+
*
|
|
1918
|
+
* NOTE: a passed-in budget is a value read BEFORE the write lock (and, for a validating
|
|
1919
|
+
* invite, before an outbound approval call). Safe today because `FormationInvite` is
|
|
1920
|
+
* insert/delete only (its `Immutable` constraint), so the only way to stale it is an owner
|
|
1921
|
+
* revoking the token and re-issuing it with MORE seats mid-redemption; if invites ever gain
|
|
1922
|
+
* an update path, drop the parameter and read here on every attempt instead.
|
|
1923
|
+
*/
|
|
1924
|
+
async assertSeatRemains(token, useNumber, knownTotalUses) {
|
|
1925
|
+
// A missing invite is left to `Authorized` — it is not an exhaustion, and the CHECK's
|
|
1926
|
+
// rejection is already the right, non-retryable answer for it. That fallback is also what
|
|
1927
|
+
// makes this read's failure mode safe: it is a full-primary-key point lookup
|
|
1928
|
+
// (`FormationInvite where Token = ?`), the shape whose reliability on a networked strand
|
|
1929
|
+
// is still open (tracked by `debt-composite-pk-point-lookup-unreliable-untracked`). A
|
|
1930
|
+
// spurious empty result only costs the attempt its NAMED exhaustion error, reverting it to
|
|
1931
|
+
// today's generic `Authorized` refusal — never a seat the invite does not have.
|
|
1932
|
+
const totalUses = knownTotalUses !== undefined
|
|
1933
|
+
? knownTotalUses
|
|
1934
|
+
: (await this.queryFormationInvite(token))?.totalUses ?? null;
|
|
1935
|
+
if (totalUses != null && useNumber > totalUses) {
|
|
1936
|
+
throw new InvitationExhaustedError(token, useNumber, totalUses);
|
|
1937
|
+
}
|
|
649
1938
|
}
|
|
650
1939
|
/** Parameterised `FormationUsage` insert shared by redeem + record paths. */
|
|
651
1940
|
async execFormationUsageInsert(opts) {
|
|
@@ -659,13 +1948,13 @@ export class ControlDatabase {
|
|
|
659
1948
|
// not a fix for an observable mis-ordering.
|
|
660
1949
|
const nowCanonical = await canonicalDatetime(this.db, opts.nowMs);
|
|
661
1950
|
await this.db.exec(`
|
|
662
|
-
insert into CadreControl.FormationUsage (Token, UseNumber, Disclosure, StrandId)
|
|
663
|
-
with context
|
|
664
|
-
values (?, ?, ?, ?)
|
|
1951
|
+
insert into CadreControl.FormationUsage (Token, UseNumber, UsageStampId, PeerKey, PeerSig, Disclosure, StrandId, StrandStampId)
|
|
1952
|
+
with context Now = ?, ValidationKey = ?, ValidationSignature = ?
|
|
1953
|
+
values (?, ?, ?, ?, ?, ?, ?, ?)
|
|
665
1954
|
`, [
|
|
666
|
-
opts.
|
|
667
|
-
opts.
|
|
668
|
-
opts.
|
|
1955
|
+
nowCanonical, opts.validationKey, opts.validationSignature,
|
|
1956
|
+
opts.token, opts.useNumber, opts.usageStampId, opts.peerKey,
|
|
1957
|
+
opts.peerSignature, opts.disclosure, opts.strandId, opts.strandStampId,
|
|
669
1958
|
]);
|
|
670
1959
|
}
|
|
671
1960
|
/**
|
|
@@ -676,14 +1965,10 @@ export class ControlDatabase {
|
|
|
676
1965
|
async queryFormationInvite(token) {
|
|
677
1966
|
this.ensureInitialized();
|
|
678
1967
|
for await (const row of this.db.eval('select Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId from CadreControl.FormationInvite where Token = ?', [token])) {
|
|
679
|
-
const expiresAt = row.ExpiresAt;
|
|
680
|
-
const expiresAtMs = expiresAt === null || expiresAt === undefined
|
|
681
|
-
? null
|
|
682
|
-
: parseStoredDatetimeMs(expiresAt);
|
|
683
1968
|
return {
|
|
684
1969
|
token: row.Token,
|
|
685
1970
|
sAppId: row.sAppId,
|
|
686
|
-
expiresAtMs:
|
|
1971
|
+
expiresAtMs: parseNullableStoredDatetimeMs(row.ExpiresAt),
|
|
687
1972
|
totalUses: row.TotalUses ?? null,
|
|
688
1973
|
validationUrl: row.ValidationUrl ?? null,
|
|
689
1974
|
strandId: row.StrandId ?? null,
|
|
@@ -701,7 +1986,62 @@ export class ControlDatabase {
|
|
|
701
1986
|
}
|
|
702
1987
|
return 0;
|
|
703
1988
|
}
|
|
704
|
-
/**
|
|
1989
|
+
/**
|
|
1990
|
+
* Is any `FormationInvite` row still redeemable — unexpired AND with usage
|
|
1991
|
+
* below its `TotalUses`? A null `ExpiresAt` never expires and a null
|
|
1992
|
+
* `TotalUses` is unlimited, matching {@link ControlFormationUsageRecorder}'s
|
|
1993
|
+
* per-token semantics (`isTokenValid` / `isTokenUsed`).
|
|
1994
|
+
*
|
|
1995
|
+
* Answers the control-network connection gate's coarse "does this node expect
|
|
1996
|
+
* a stranger?" question, which has no token to ask about. The expiry
|
|
1997
|
+
* comparison is `expiresAtMs <= now` — identical to `isTokenValid`'s — so an
|
|
1998
|
+
* invite the formation handler would reject can never hold the gate open.
|
|
1999
|
+
*
|
|
2000
|
+
* The scan is deliberately not pushed into SQL: nothing else here compares a
|
|
2001
|
+
* stored `datetime` with an inequality, so the parse stays in JS via the
|
|
2002
|
+
* shared {@link parseNullableStoredDatetimeMs}. Only invites that are
|
|
2003
|
+
* unexpired AND use-metered cost a {@link countFormationUsage} read, and an
|
|
2004
|
+
* unlimited-use invite anywhere in the scan short-circuits all of them.
|
|
2005
|
+
*/
|
|
2006
|
+
async hasOutstandingFormationInvite(nowMs = Date.now()) {
|
|
2007
|
+
this.ensureInitialized();
|
|
2008
|
+
// NOTE: scans every FormationInvite row (expired ones included) on the
|
|
2009
|
+
// stranger path of an inbound connection. Cadre-scale invite counts make
|
|
2010
|
+
// that free today; if a long-lived cadre accumulates thousands of expired
|
|
2011
|
+
// invites and inbound upgrades slow down, add an expiry-ordered index or
|
|
2012
|
+
// prune redeemed/expired rows.
|
|
2013
|
+
const metered = [];
|
|
2014
|
+
let unlimitedOutstanding = false;
|
|
2015
|
+
for await (const row of this.db.eval('select Token, ExpiresAt, TotalUses from CadreControl.FormationInvite')) {
|
|
2016
|
+
const expiresAtMs = parseNullableStoredDatetimeMs(row.ExpiresAt);
|
|
2017
|
+
if (expiresAtMs !== null && expiresAtMs <= nowMs) {
|
|
2018
|
+
continue;
|
|
2019
|
+
}
|
|
2020
|
+
const totalUses = row.TotalUses ?? null;
|
|
2021
|
+
if (totalUses === null) {
|
|
2022
|
+
unlimitedOutstanding = true;
|
|
2023
|
+
}
|
|
2024
|
+
else {
|
|
2025
|
+
metered.push({ token: row.Token, totalUses });
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
if (unlimitedOutstanding) {
|
|
2029
|
+
return true;
|
|
2030
|
+
}
|
|
2031
|
+
for (const invite of metered) {
|
|
2032
|
+
if (await this.countFormationUsage(invite.token) < invite.totalUses) {
|
|
2033
|
+
return true;
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
return false;
|
|
2037
|
+
}
|
|
2038
|
+
/**
|
|
2039
|
+
* Next `UseNumber` for a token = max(existing)+1, per the `Monotonic` constraint.
|
|
2040
|
+
*
|
|
2041
|
+
* Called only from inside {@link withUseNumberRetry}'s locked body — a read takes no
|
|
2042
|
+
* transaction of its own, so it is safe there, and reading it under the lock is what stops
|
|
2043
|
+
* two local redemptions of one token from picking the same number.
|
|
2044
|
+
*/
|
|
705
2045
|
async nextUseNumber(token) {
|
|
706
2046
|
for await (const row of this.db.eval('select coalesce(max(UseNumber), 0) as MaxUse from CadreControl.FormationUsage where Token = ?', [token])) {
|
|
707
2047
|
return row.MaxUse + 1;
|
|
@@ -709,9 +2049,20 @@ export class ControlDatabase {
|
|
|
709
2049
|
return 1;
|
|
710
2050
|
}
|
|
711
2051
|
/**
|
|
712
|
-
* Close the database and cleanup resources
|
|
2052
|
+
* Close the database and cleanup resources.
|
|
2053
|
+
*
|
|
2054
|
+
* Drains the local-write chain first: {@link withWriteLock} can park a write behind
|
|
2055
|
+
* others across an await, and a queued closure evaluates `this.db!` only when it
|
|
2056
|
+
* finally runs — so nulling the handle out from under it would throw a TypeError on a
|
|
2057
|
+
* null handle instead of committing. The tail never rejects (it swallows both
|
|
2058
|
+
* outcomes), so the bare await is safe.
|
|
2059
|
+
*
|
|
2060
|
+
* NOTE: this makes `close()` wait on a stuck write. Acceptable today — every locked
|
|
2061
|
+
* body is a bounded local `exec` — but revisit (bounded drain, or abandon after a
|
|
2062
|
+
* deadline) if a write can ever hang.
|
|
713
2063
|
*/
|
|
714
2064
|
async close() {
|
|
2065
|
+
await this.writeQueue;
|
|
715
2066
|
if (this.collectionFactory) {
|
|
716
2067
|
await this.collectionFactory.shutdown();
|
|
717
2068
|
this.collectionFactory = null;
|