@serfab/cadre-core 0.11.0 → 0.13.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 +3 -2
- package/dist/announce-addrs.d.ts +49 -0
- package/dist/announce-addrs.js +78 -0
- package/dist/announce-addrs.js.map +1 -0
- package/dist/cadre-node.d.ts +607 -53
- package/dist/cadre-node.js +1268 -157
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-database.d.ts +174 -23
- package/dist/control-database.js +317 -107
- package/dist/control-database.js.map +1 -1
- package/dist/control-read-retry.d.ts +103 -0
- package/dist/control-read-retry.js +145 -0
- package/dist/control-read-retry.js.map +1 -0
- package/dist/control-retry.d.ts +101 -0
- package/dist/control-retry.js +108 -0
- package/dist/control-retry.js.map +1 -0
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +50 -6
- package/dist/control-schema.js.map +1 -1
- package/dist/control-write-retry.d.ts +59 -35
- package/dist/control-write-retry.js +21 -92
- package/dist/control-write-retry.js.map +1 -1
- package/dist/delegate-admission.d.ts +17 -15
- package/dist/delegate-admission.js +17 -15
- package/dist/delegate-admission.js.map +1 -1
- package/dist/enrolled-machine-store-file.d.ts +13 -0
- package/dist/enrolled-machine-store-file.js +41 -0
- package/dist/enrolled-machine-store-file.js.map +1 -0
- package/dist/enrolled-machine-store.d.ts +81 -0
- package/dist/enrolled-machine-store.js +225 -0
- package/dist/enrolled-machine-store.js.map +1 -0
- package/dist/file-durable-slot.js +5 -3
- package/dist/file-durable-slot.js.map +1 -1
- package/dist/index.d.ts +10 -6
- package/dist/index.js +39 -12
- package/dist/index.js.map +1 -1
- package/dist/membership-connection-gater.d.ts +226 -26
- package/dist/membership-connection-gater.js +316 -30
- package/dist/membership-connection-gater.js.map +1 -1
- package/dist/node-local-snapshot.d.ts +6 -0
- package/dist/node-local-snapshot.js +7 -1
- package/dist/node-local-snapshot.js.map +1 -1
- package/dist/{strand-backfill.d.ts → peer-join-backfill.d.ts} +49 -20
- package/dist/{strand-backfill.js → peer-join-backfill.js} +146 -56
- package/dist/peer-join-backfill.js.map +1 -0
- package/dist/peer-record.d.ts +26 -0
- package/dist/peer-record.js +32 -0
- package/dist/peer-record.js.map +1 -1
- package/dist/relay-addrs.d.ts +145 -22
- package/dist/relay-addrs.js +314 -31
- package/dist/relay-addrs.js.map +1 -1
- package/dist/relay-reservation.d.ts +20 -15
- package/dist/relay-reservation.js +18 -14
- package/dist/relay-reservation.js.map +1 -1
- package/dist/strand-addr-protocol.js +16 -0
- package/dist/strand-addr-protocol.js.map +1 -1
- package/dist/strand-database.d.ts +12 -0
- package/dist/strand-database.js +16 -0
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-formation-manager.d.ts +22 -9
- package/dist/strand-formation-manager.js +20 -12
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +83 -9
- package/dist/strand-formation-protocol.js +86 -10
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +151 -17
- package/dist/strand-instance-manager.js +237 -32
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +118 -14
- package/dist/strand-membership-writer.js +203 -15
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-network-config.d.ts +84 -0
- package/dist/strand-network-config.js +136 -0
- package/dist/strand-network-config.js.map +1 -0
- package/dist/strand-revocation-enforcer.d.ts +392 -0
- package/dist/strand-revocation-enforcer.js +533 -0
- package/dist/strand-revocation-enforcer.js.map +1 -0
- package/dist/strand-solicitation.d.ts +10 -3
- package/dist/strand-solicitation.js +12 -7
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-wake-protocol.d.ts +47 -2
- package/dist/strand-wake-protocol.js +76 -6
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/strand-watcher.js +6 -1
- package/dist/strand-watcher.js.map +1 -1
- package/dist/types.d.ts +282 -43
- package/dist/types.js +7 -1
- package/dist/types.js.map +1 -1
- package/package.json +16 -8
- package/src/announce-addrs.ts +93 -0
- package/src/cadre-node.ts +1366 -171
- package/src/control-database.ts +2628 -2406
- package/src/control-read-retry.ts +167 -0
- package/src/control-retry.ts +190 -0
- package/src/control-schema.ts +50 -6
- package/src/control-write-retry.ts +30 -130
- package/src/delegate-admission.ts +17 -15
- package/src/enrolled-machine-store-file.ts +43 -0
- package/src/enrolled-machine-store.ts +280 -0
- package/src/file-durable-slot.ts +5 -3
- package/src/index.ts +477 -407
- package/src/membership-connection-gater.ts +539 -204
- package/src/node-local-snapshot.ts +13 -1
- package/src/peer-addr-book.ts +251 -250
- package/src/{strand-backfill.ts → peer-join-backfill.ts} +187 -75
- package/src/peer-record.ts +33 -0
- package/src/relay-addrs.ts +436 -111
- package/src/relay-reservation.ts +828 -823
- package/src/strand-addr-protocol.ts +17 -0
- package/src/strand-database.ts +17 -0
- package/src/strand-formation-manager.ts +511 -491
- package/src/strand-formation-protocol.ts +808 -692
- package/src/strand-instance-manager.ts +899 -611
- package/src/strand-membership-writer.ts +232 -15
- package/src/strand-network-config.ts +172 -0
- package/src/strand-revocation-enforcer.ts +653 -0
- package/src/strand-solicitation.ts +484 -472
- package/src/strand-wake-protocol.ts +94 -8
- package/src/strand-watcher.ts +319 -314
- package/src/types.ts +1554 -1309
- package/dist/strand-backfill.js.map +0 -1
package/dist/control-database.js
CHANGED
|
@@ -9,6 +9,8 @@ import { canonicalDatetime } from './canonical-datetime.js';
|
|
|
9
9
|
import { controlAuthorizationFields, CONTROL_TABLES } from './control-authorization.js';
|
|
10
10
|
import { requireEd25519PublicKeyB64 } from './ed25519-key.js';
|
|
11
11
|
import { retryControlWrite, SCHEMA_INIT_RETRY_POLICY } from './control-write-retry.js';
|
|
12
|
+
import { retryControlRead } from './control-read-retry.js';
|
|
13
|
+
import { chainMessages } from './control-retry.js';
|
|
12
14
|
const log = debug('sereus:cadre:control-db');
|
|
13
15
|
const timing = debug('sereus:cadre:timing');
|
|
14
16
|
/**
|
|
@@ -209,6 +211,36 @@ const GUARDED_KEY_COLUMN = {
|
|
|
209
211
|
Strand: 'Id',
|
|
210
212
|
DeviceToken: 'PeerId',
|
|
211
213
|
};
|
|
214
|
+
/**
|
|
215
|
+
* The rejection a second insert of an already-seated strand id produces, as the optimystic
|
|
216
|
+
* vtab words it (`uniqueConstraintMessage`, qualified by table name only — no schema
|
|
217
|
+
* prefix, matching `test/control-constraint-helpers.ts`'s `expectUniqueViolation`).
|
|
218
|
+
*
|
|
219
|
+
* `Strand.StampId` is unique too, but a fresh stamp is minted per insert attempt, so only
|
|
220
|
+
* the primary key can collide on a repeat publish. Matching the column explicitly keeps
|
|
221
|
+
* any OTHER uniqueness failure out of the idempotency branch.
|
|
222
|
+
*/
|
|
223
|
+
const STRAND_ID_CONFLICT = /UNIQUE constraint failed: Strand\.Id\b/i;
|
|
224
|
+
/**
|
|
225
|
+
* Did this write fail because the strand id is already seated?
|
|
226
|
+
*
|
|
227
|
+
* {@link CadreNode.publishStrand} uses this to tell "my own earlier publish already
|
|
228
|
+
* landed" (re-read, and no-op when the row matches) apart from every other rejection —
|
|
229
|
+
* an unauthorized signer, a retired stamp — which must keep surfacing.
|
|
230
|
+
*
|
|
231
|
+
* Matched by TEXT, not by type: the typed engine error does not survive the trip out of
|
|
232
|
+
* optimystic (same constraint the retry classifiers in `control-write-retry.ts` document).
|
|
233
|
+
* Fails CLOSED — a rewording upstream turns the idempotent branch back into the raw
|
|
234
|
+
* uniqueness error the caller saw before, never into a silent overwrite. The
|
|
235
|
+
* `publish-strand.spec.ts` repeat-publish cases assert against the live engine error, so a
|
|
236
|
+
* reword reddens there.
|
|
237
|
+
*/
|
|
238
|
+
export function isStrandIdConflict(error) {
|
|
239
|
+
if (!(error instanceof Error)) {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
return chainMessages(error).some(message => STRAND_ID_CONFLICT.test(message));
|
|
243
|
+
}
|
|
212
244
|
/**
|
|
213
245
|
* Guarded tables a node may reap locally once their tombstone has committed — the
|
|
214
246
|
* tables whose `AuthorizedDelete` carries the REAP branch (see the constraint comment on
|
|
@@ -248,6 +280,13 @@ export class ControlDatabase {
|
|
|
248
280
|
* having to restate the policy's attempts or classifier.
|
|
249
281
|
*/
|
|
250
282
|
this.controlWriteRetryPacing = {};
|
|
283
|
+
/**
|
|
284
|
+
* Pacing seams for {@link readRows}'s retry — the read twin of
|
|
285
|
+
* {@link controlWriteRetryPacing}, with the same contract: production leaves this
|
|
286
|
+
* empty, specs inject a recorded `sleep` / fake `now` so no test waits out a real
|
|
287
|
+
* backoff. Applied OVER the read policy's defaults but UNDER the call site's `label`.
|
|
288
|
+
*/
|
|
289
|
+
this.controlReadRetryPacing = {};
|
|
251
290
|
this.config = config;
|
|
252
291
|
}
|
|
253
292
|
/**
|
|
@@ -396,17 +435,131 @@ export class ControlDatabase {
|
|
|
396
435
|
await this.lockedWithRetry(() => this.db.exec(schemaContent), SCHEMA_INIT_RETRY_POLICY, 'schema-init');
|
|
397
436
|
log('Schema loaded and executed');
|
|
398
437
|
}
|
|
438
|
+
/**
|
|
439
|
+
* One UNRETRIED drain of a control read to rows — every read in this class bottoms out
|
|
440
|
+
* here (almost all via {@link readRows}, which adds the transient-failure retry), and
|
|
441
|
+
* this is the ONE place the committed-read opt-in is spelled.
|
|
442
|
+
*
|
|
443
|
+
* Reached only through {@link readRows}, which drops straight here for the call sites
|
|
444
|
+
* that pass `retry: false` — reads issued from INSIDE a locked write body, where a
|
|
445
|
+
* backoff sleep would hold the write lock and the write funnel re-runs the read anyway
|
|
446
|
+
* (see the NOTE on {@link readRows}).
|
|
447
|
+
*
|
|
448
|
+
* A control read normally queues on the database's exec mutex, so it does not answer
|
|
449
|
+
* until whatever statement holds that mutex finishes. When a control WRITE is parked
|
|
450
|
+
* against an unresponsive cohort member that is minutes, and a read that only wants
|
|
451
|
+
* the last committed state waits the whole time for no reason
|
|
452
|
+
* (`complete/control-reads-blocked-by-stalled-write`).
|
|
453
|
+
*
|
|
454
|
+
* Quereus's remedy is `readConcurrency: 'committed'` — a PER-CALL opt-in
|
|
455
|
+
* (`Statement.tryRouteConcurrent` refuses anything that did not ask for it by name)
|
|
456
|
+
* that runs an eligible read off the mutex against each table's last committed state.
|
|
457
|
+
* Routing is best-effort and never an error: an ineligible statement falls back to
|
|
458
|
+
* the serialized path silently — any side-effecting node, any table-valued function
|
|
459
|
+
* call (pure ones included), any table whose module does not declare
|
|
460
|
+
* `readCommittedSnapshot`, or an EXPLICIT transaction being open on this database.
|
|
461
|
+
*
|
|
462
|
+
* **It is asked for only while a write is in flight, and that condition is the whole
|
|
463
|
+
* point.** The opt-in does not merely move the read off the mutex: it connects the
|
|
464
|
+
* table with `_readCommitted`, and the optimystic vtab serves such a connection from
|
|
465
|
+
* a pinned pre-transaction snapshot that NEVER refreshes from the network, where an
|
|
466
|
+
* ordinary read calls `update()` on the collection first. Taking that path
|
|
467
|
+
* unconditionally therefore trades "reads block behind a stalled write" for "reads
|
|
468
|
+
* stop seeing what other machines wrote" — measured, not theorised: with every read
|
|
469
|
+
* opted in, `control-write-degraded-cohort-member` could no longer observe a sibling
|
|
470
|
+
* publishing its own `CadrePeer` row and failed at suite setup on two runs out of two.
|
|
471
|
+
*
|
|
472
|
+
* `getAutocommit()` reports the whole `Database` rather than this call (the same
|
|
473
|
+
* property {@link assertCommitBoundary} leans on), which is exactly what is wanted:
|
|
474
|
+
* false means some writer's transaction is open, which is the only situation where a
|
|
475
|
+
* read would have blocked. The check races that writer harmlessly in both directions
|
|
476
|
+
* — losing it either reads normally (today's behaviour) or reads committed state.
|
|
477
|
+
*
|
|
478
|
+
* What a committed read gives up is observing a write that has not finished
|
|
479
|
+
* committing, and that is safe for every caller here: an AWAITED write is committed by
|
|
480
|
+
* the time it returns, and a read inside {@link inTransaction} is disqualified by the
|
|
481
|
+
* open explicit transaction and stays serialized, so it still sees its own
|
|
482
|
+
* transaction's rows. A read after an UNAWAITED write on this database would be wrong
|
|
483
|
+
* — no such caller exists, and {@link withWriteLock} is what keeps it that way.
|
|
484
|
+
*/
|
|
485
|
+
async readRowsOnce(sql, params) {
|
|
486
|
+
let iterator;
|
|
487
|
+
if (this.db.getAutocommit()) {
|
|
488
|
+
iterator = this.db.eval(sql, params);
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
log('read-eval: a write is in flight, asking for a committed read: %s', sql);
|
|
492
|
+
iterator = this.db.eval(sql, params, { readConcurrency: 'committed' });
|
|
493
|
+
}
|
|
494
|
+
const rows = [];
|
|
495
|
+
for await (const row of iterator) {
|
|
496
|
+
rows.push(row);
|
|
497
|
+
}
|
|
498
|
+
return rows;
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* {@link readRowsOnce} plus the bounded transient-failure retry
|
|
502
|
+
* ({@link retryControlRead}) — the funnel every UNLOCKED control read goes through, so a
|
|
503
|
+
* read that failed because the cluster could not be asked properly (a stream reset
|
|
504
|
+
* mid-scan, a partially unreachable cohort) is re-presented a moment later instead of
|
|
505
|
+
* surfacing to every reader.
|
|
506
|
+
*
|
|
507
|
+
* MATERIALIZES the read rather than returning a lazy iterator, and that shape is what
|
|
508
|
+
* makes the retry possible at all: the failure happens during ITERATION, not at call
|
|
509
|
+
* time, and a half-consumed iterator cannot be retried without re-yielding rows the
|
|
510
|
+
* caller already saw. Materializing is safe here — every caller either drains to an
|
|
511
|
+
* array anyway or takes the first row of a statement that yields at most one (a
|
|
512
|
+
* primary-key lookup or a `count(1)`); nothing streams.
|
|
513
|
+
*
|
|
514
|
+
* Each ATTEMPT is a fresh {@link readRowsOnce} call, so the committed-read opt-in
|
|
515
|
+
* (`getAutocommit()`) is re-evaluated per attempt — a write can finish between attempts
|
|
516
|
+
* and change the right answer.
|
|
517
|
+
*
|
|
518
|
+
* The retry's budget ({@link CONTROL_READ_RETRY_BUDGET_MS}, 1.5 s) is deliberately far
|
|
519
|
+
* under the write funnel's: the tightest caller deadline over a control read is the
|
|
520
|
+
* inbound admission gate's 2 s FAIL-OPEN timeout, and a read that outlives it spends its
|
|
521
|
+
* retries after the gate has already admitted (see `control-read-retry.ts`).
|
|
522
|
+
*
|
|
523
|
+
* `label` names the read in the retry loop's `Control read [<label>] …` debug lines and
|
|
524
|
+
* nothing else; several reads are in flight concurrently in a real party, so an
|
|
525
|
+
* unlabelled line cannot be attributed — keep new call sites labelled.
|
|
526
|
+
*
|
|
527
|
+
* `retry: false` drops straight to {@link readRowsOnce}: reads issued from INSIDE a
|
|
528
|
+
* locked write body must not retry on their own. `retryControlWrite`'s contract is that
|
|
529
|
+
* backoff sleeps happen with NO lock held; a read retrying its own backoff inside a
|
|
530
|
+
* locked body would sleep holding the write lock (stalling every other local writer),
|
|
531
|
+
* and the write funnel already re-runs the body's reads when it re-runs the body. See
|
|
532
|
+
* {@link queryStampId} / {@link assertSeatRemains} / {@link queryCadrePeers} for the
|
|
533
|
+
* per-call opt-outs.
|
|
534
|
+
*
|
|
535
|
+
* NOTE: nothing structural enforces that opt-out — it is per-call because an unlocked
|
|
536
|
+
* read runs CONCURRENTLY with a locked body, so `this`-state cannot tell the two apart,
|
|
537
|
+
* and there is no async-context primitive available on every target platform (browser,
|
|
538
|
+
* React Native) to carry the answer. Reads reached through a CALLBACK invoked under the
|
|
539
|
+
* lock are the easy miss: `notifyMembershipChanged` runs its listener with the lock
|
|
540
|
+
* held, and its membership read is opted out at {@link queryCadrePeers}. If another
|
|
541
|
+
* under-lock callback seam is ever added, audit its read graph the same way.
|
|
542
|
+
*/
|
|
543
|
+
readRows(sql, params, label, retry = true) {
|
|
544
|
+
if (!retry) {
|
|
545
|
+
return this.readRowsOnce(sql, params);
|
|
546
|
+
}
|
|
547
|
+
// The label lands LAST so the call site's name survives the spec-injected pacing —
|
|
548
|
+
// it changes log attribution only, never behaviour (same layering as lockedWithRetry).
|
|
549
|
+
return retryControlRead(() => this.readRowsOnce(sql, params), { ...this.controlReadRetryPacing, label });
|
|
550
|
+
}
|
|
399
551
|
/**
|
|
400
552
|
* Query all strands from the control database
|
|
401
553
|
*/
|
|
402
554
|
async queryStrands() {
|
|
403
555
|
this.ensureInitialized();
|
|
404
556
|
const results = [];
|
|
405
|
-
for
|
|
557
|
+
for (const row of await this.readRows('select Id, MemberPrivateKey, Type, FounderOwnerKey from CadreControl.Strand', undefined, 'strands')) {
|
|
406
558
|
results.push({
|
|
407
559
|
Id: row.Id,
|
|
408
560
|
MemberPrivateKey: row.MemberPrivateKey,
|
|
409
561
|
Type: row.Type,
|
|
562
|
+
FounderOwnerKey: row.FounderOwnerKey,
|
|
410
563
|
});
|
|
411
564
|
}
|
|
412
565
|
return results;
|
|
@@ -419,11 +572,12 @@ export class ControlDatabase {
|
|
|
419
572
|
*/
|
|
420
573
|
async queryStrand(strandId) {
|
|
421
574
|
this.ensureInitialized();
|
|
422
|
-
for
|
|
575
|
+
for (const row of await this.readRows('select Id, MemberPrivateKey, Type, FounderOwnerKey from CadreControl.Strand where Id = ?', [strandId], 'strand')) {
|
|
423
576
|
return {
|
|
424
577
|
Id: row.Id,
|
|
425
578
|
MemberPrivateKey: row.MemberPrivateKey,
|
|
426
579
|
Type: row.Type,
|
|
580
|
+
FounderOwnerKey: row.FounderOwnerKey,
|
|
427
581
|
};
|
|
428
582
|
}
|
|
429
583
|
return null;
|
|
@@ -444,7 +598,7 @@ export class ControlDatabase {
|
|
|
444
598
|
if (!CONTROL_TABLE_SET.has(table)) {
|
|
445
599
|
throw new Error(`Unknown CadreControl table: ${table}`);
|
|
446
600
|
}
|
|
447
|
-
for
|
|
601
|
+
for (const row of await this.readRows(`select count(1) as Count from CadreControl.${table}`, undefined, 'count-rows')) {
|
|
448
602
|
return row.Count ?? 0;
|
|
449
603
|
}
|
|
450
604
|
return 0;
|
|
@@ -462,7 +616,7 @@ export class ControlDatabase {
|
|
|
462
616
|
*/
|
|
463
617
|
async hasOwnerKey() {
|
|
464
618
|
this.ensureInitialized();
|
|
465
|
-
for
|
|
619
|
+
for (const row of await this.readRows('select count(1) as Count from CadreControl.OwnerKey', undefined, 'owner-key-exists')) {
|
|
466
620
|
return row.Count > 0;
|
|
467
621
|
}
|
|
468
622
|
return false;
|
|
@@ -493,7 +647,7 @@ export class ControlDatabase {
|
|
|
493
647
|
async getOwnerKeys() {
|
|
494
648
|
this.ensureInitialized();
|
|
495
649
|
const keys = new Set();
|
|
496
|
-
for
|
|
650
|
+
for (const row of await this.readRows('select Key from CadreControl.OwnerKey', undefined, 'owner-keys')) {
|
|
497
651
|
keys.add(row.Key);
|
|
498
652
|
}
|
|
499
653
|
return keys;
|
|
@@ -522,12 +676,21 @@ export class ControlDatabase {
|
|
|
522
676
|
* carried into both, or those suites keep passing against a contract the database no
|
|
523
677
|
* longer honours. Fine while the filter is one `Set.has` on `StampId`; if it grows a
|
|
524
678
|
* second dimension, give the fakes a shared fixture or a contract test instead.
|
|
679
|
+
*
|
|
680
|
+
* `retry: false` is passed by exactly one caller —
|
|
681
|
+
* `CadreNode.refreshAuthorizedControlPeers`, the membership-gate snapshot refresh —
|
|
682
|
+
* because {@link notifyMembershipChanged} invokes that listener with the write lock
|
|
683
|
+
* HELD, and a retrying read there would sleep its backoff holding the lock (see the
|
|
684
|
+
* NOTE on {@link readRows}). It costs nothing: that refresh is best-effort, keeps the
|
|
685
|
+
* previous snapshot on failure, and is re-driven by the next write and by the timed
|
|
686
|
+
* reconcile. BOTH of this method's reads take the flag — retrying either one under the
|
|
687
|
+
* lock is the same defect.
|
|
525
688
|
*/
|
|
526
|
-
async queryCadrePeers() {
|
|
689
|
+
async queryCadrePeers(retry = true) {
|
|
527
690
|
this.ensureInitialized();
|
|
528
|
-
const revoked = await this.queryRevokedStamps('CadrePeer');
|
|
691
|
+
const revoked = await this.queryRevokedStamps('CadrePeer', retry);
|
|
529
692
|
const rows = [];
|
|
530
|
-
for
|
|
693
|
+
for (const row of await this.readRows('select PeerId, Multiaddr, StampId, VouchOwner, VouchSig from CadreControl.CadrePeer', undefined, 'cadre-peers', retry)) {
|
|
531
694
|
const stampId = row.StampId ?? null;
|
|
532
695
|
if (stampId !== null && revoked.has(stampId)) {
|
|
533
696
|
continue;
|
|
@@ -554,7 +717,7 @@ export class ControlDatabase {
|
|
|
554
717
|
async queryValidationKeys() {
|
|
555
718
|
this.ensureInitialized();
|
|
556
719
|
const keys = [];
|
|
557
|
-
for
|
|
720
|
+
for (const row of await this.readRows('select Key from CadreControl.ValidationKey', undefined, 'validation-keys')) {
|
|
558
721
|
keys.push(row.Key);
|
|
559
722
|
}
|
|
560
723
|
return keys.sort();
|
|
@@ -573,11 +736,21 @@ export class ControlDatabase {
|
|
|
573
736
|
* `table` and its {@link GUARDED_KEY_COLUMN} column are interpolated into the SQL; both
|
|
574
737
|
* come from closed literal unions — no caller-supplied string can reach the statement
|
|
575
738
|
* (same injection-surface discipline as {@link countRows}'s `CONTROL_TABLE_SET` guard).
|
|
739
|
+
*
|
|
740
|
+
* NOTE: `retry: false` is passed by exactly the call sites that run INSIDE a locked
|
|
741
|
+
* write body — {@link deleteGuardedRow} and {@link insertCadrePeer}'s insert-if-absent
|
|
742
|
+
* guard. A read retrying there would sleep its backoff HOLDING the write lock
|
|
743
|
+
* (`retryControlWrite`'s contract is that backoff sleeps happen with no lock held), and
|
|
744
|
+
* the write funnel already re-runs the body's reads when it re-runs the body. The
|
|
745
|
+
* unlocked callers — the public stamp readers, {@link reauthorizeCadrePeer} and
|
|
746
|
+
* {@link reapRevokedRow}, whose stamp reads run BEFORE their locks are taken — keep the
|
|
747
|
+
* default retried path. Explicit per call rather than an ambient flag: unlocked reads
|
|
748
|
+
* run concurrently with a locked body, so `this`-state cannot tell the two apart.
|
|
576
749
|
*/
|
|
577
|
-
async queryStampId(table, keyValue) {
|
|
750
|
+
async queryStampId(table, keyValue, retry = true) {
|
|
578
751
|
this.ensureInitialized();
|
|
579
752
|
const sql = `select StampId from CadreControl.${table} where ${GUARDED_KEY_COLUMN[table]} = ?`;
|
|
580
|
-
for
|
|
753
|
+
for (const row of await this.readRows(sql, [keyValue], `stamp-${table}`, retry)) {
|
|
581
754
|
return row.StampId ?? null;
|
|
582
755
|
}
|
|
583
756
|
return null;
|
|
@@ -622,11 +795,14 @@ export class ControlDatabase {
|
|
|
622
795
|
* NOTE: re-reads the whole retired set on every call, and the caller runs per inbound
|
|
623
796
|
* gate request while the table only ever grows. Cheap today (a cadre removes peers
|
|
624
797
|
* rarely); if removals ever become routine, cache the set and invalidate it on write.
|
|
798
|
+
*
|
|
799
|
+
* `retry: false` is forwarded by {@link queryCadrePeers} for the one caller that reads
|
|
800
|
+
* under the write lock — see that method's note.
|
|
625
801
|
*/
|
|
626
|
-
async queryRevokedStamps(tableName) {
|
|
802
|
+
async queryRevokedStamps(tableName, retry = true) {
|
|
627
803
|
this.ensureInitialized();
|
|
628
804
|
const stamps = new Set();
|
|
629
|
-
for
|
|
805
|
+
for (const row of await this.readRows('select StampId from CadreControl.Revocation where TableName = ?', [tableName], 'revoked-stamps', retry)) {
|
|
630
806
|
stamps.add(row.StampId);
|
|
631
807
|
}
|
|
632
808
|
return stamps;
|
|
@@ -642,7 +818,7 @@ export class ControlDatabase {
|
|
|
642
818
|
async queryRevocations() {
|
|
643
819
|
this.ensureInitialized();
|
|
644
820
|
const rows = [];
|
|
645
|
-
for
|
|
821
|
+
for (const row of await this.readRows('select TableName, RowKey, StampId, ReissuedAt from CadreControl.Revocation', undefined, 'revocations')) {
|
|
646
822
|
rows.push({
|
|
647
823
|
tableName: row.TableName,
|
|
648
824
|
rowKey: row.RowKey,
|
|
@@ -663,7 +839,7 @@ export class ControlDatabase {
|
|
|
663
839
|
* {@link queryRevokedStamps} rather than inlining the SQL, so tests can interpose on
|
|
664
840
|
* that seam (see {@link queryCadrePeers}).
|
|
665
841
|
*
|
|
666
|
-
* Missing/
|
|
842
|
+
* Missing/null column values are coalesced
|
|
667
843
|
* to their empty form (`''` key/sig, `[]` addrs, `0` stamp) so the caller's
|
|
668
844
|
* verify/freshness gates uniformly reject an unpublished or malformed row.
|
|
669
845
|
* The split `addrs` re-join to the exact stored `Multiaddr` (split-on-`,` is
|
|
@@ -673,7 +849,7 @@ export class ControlDatabase {
|
|
|
673
849
|
async queryPeerRecord(peerId) {
|
|
674
850
|
this.ensureInitialized();
|
|
675
851
|
const revoked = await this.queryRevokedStamps('CadrePeer');
|
|
676
|
-
for
|
|
852
|
+
for (const row of await this.readRows('select PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId from CadreControl.CadrePeer where PeerId = ?', [peerId], 'peer-record')) {
|
|
677
853
|
const stampId = row.StampId ?? null;
|
|
678
854
|
if (stampId !== null && revoked.has(stampId)) {
|
|
679
855
|
return null;
|
|
@@ -715,18 +891,18 @@ export class ControlDatabase {
|
|
|
715
891
|
async updateSelfPeerRecord(record) {
|
|
716
892
|
this.ensureInitialized();
|
|
717
893
|
const multiaddr = record.addrs.join(',');
|
|
718
|
-
await this.execWrite(`
|
|
719
|
-
update CadreControl.CadrePeer
|
|
720
|
-
with context OwnerKey = null, Signature = ?
|
|
721
|
-
set Multiaddr = ?, UpdatedAt = ?, Sig = ?
|
|
722
|
-
where PeerId = ?
|
|
894
|
+
await this.execWrite(`
|
|
895
|
+
update CadreControl.CadrePeer
|
|
896
|
+
with context OwnerKey = null, Signature = ?
|
|
897
|
+
set Multiaddr = ?, UpdatedAt = ?, Sig = ?
|
|
898
|
+
where PeerId = ?
|
|
723
899
|
`, [record.sig, multiaddr, record.updatedAt, record.sig, record.peerId], 'self-record-update');
|
|
724
900
|
log('Self peer record updated: %s (updatedAt=%d)', record.peerId, record.updatedAt);
|
|
725
901
|
}
|
|
726
902
|
/**
|
|
727
903
|
* Read a single peer's device push token (the full `DeviceToken` row) by PeerId.
|
|
728
904
|
*
|
|
729
|
-
* Returns null when no row exists. Missing/
|
|
905
|
+
* Returns null when no row exists. Missing/null column values are coalesced to
|
|
730
906
|
* their empty form (`''` token/sig, `0` stamp) so the caller's verify/freshness
|
|
731
907
|
* gates uniformly reject an unpublished or malformed row. `platform` is returned
|
|
732
908
|
* verbatim (the resolver validates it against {@link PushPlatform} and re-verifies
|
|
@@ -738,7 +914,7 @@ export class ControlDatabase {
|
|
|
738
914
|
*/
|
|
739
915
|
async queryDeviceToken(peerId) {
|
|
740
916
|
this.ensureInitialized();
|
|
741
|
-
for
|
|
917
|
+
for (const row of await this.readRows('select PeerId, Platform, Token, UpdatedAt, Sig, StampId from CadreControl.DeviceToken where PeerId = ?', [peerId], 'device-token')) {
|
|
742
918
|
return {
|
|
743
919
|
peerId: row.PeerId,
|
|
744
920
|
platform: (row.Platform ?? ''),
|
|
@@ -763,11 +939,11 @@ export class ControlDatabase {
|
|
|
763
939
|
*/
|
|
764
940
|
async updateSelfDeviceToken(record) {
|
|
765
941
|
this.ensureInitialized();
|
|
766
|
-
await this.execWrite(`
|
|
767
|
-
update CadreControl.DeviceToken
|
|
768
|
-
with context OwnerKey = null, Signature = ?
|
|
769
|
-
set Platform = ?, Token = ?, UpdatedAt = ?, Sig = ?
|
|
770
|
-
where PeerId = ?
|
|
942
|
+
await this.execWrite(`
|
|
943
|
+
update CadreControl.DeviceToken
|
|
944
|
+
with context OwnerKey = null, Signature = ?
|
|
945
|
+
set Platform = ?, Token = ?, UpdatedAt = ?, Sig = ?
|
|
946
|
+
where PeerId = ?
|
|
771
947
|
`, [record.sig, record.platform, record.token, record.updatedAt, record.sig, record.peerId], 'device-token-update');
|
|
772
948
|
log('Self device token updated: %s (platform=%s, updatedAt=%d)', record.peerId, record.platform, record.updatedAt);
|
|
773
949
|
}
|
|
@@ -786,16 +962,20 @@ export class ControlDatabase {
|
|
|
786
962
|
// StampId in the row's own column to satisfy the not-null/unique anti-replay constraint —
|
|
787
963
|
// the StampId is a real column value, not the optimystic `StampId()` SQL function.
|
|
788
964
|
const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
|
|
789
|
-
await this.execWrite(`
|
|
790
|
-
insert into CadreControl.OwnerKey (Key, StampId)
|
|
791
|
-
with context OwnerKey = null, Signature = null
|
|
792
|
-
values (?, ?)
|
|
965
|
+
await this.execWrite(`
|
|
966
|
+
insert into CadreControl.OwnerKey (Key, StampId)
|
|
967
|
+
with context OwnerKey = null, Signature = null
|
|
968
|
+
values (?, ?)
|
|
793
969
|
`, [key, stampId], 'owner-key-insert');
|
|
794
970
|
log('Owner key inserted');
|
|
795
971
|
}
|
|
796
972
|
/**
|
|
797
973
|
* Insert a strand into the control database using an owner signature.
|
|
798
974
|
*
|
|
975
|
+
* Fails with a `Strand.Id` uniqueness violation when the id is already seated — the
|
|
976
|
+
* caller decides whether that is a duplicate of its own earlier write (idempotent) or a
|
|
977
|
+
* genuine conflict; see {@link isStrandIdConflict} and `CadreNode.publishStrand`.
|
|
978
|
+
*
|
|
799
979
|
* The owner signs the canonical row-bound authorization message (see
|
|
800
980
|
* {@link buildAuthorizationMessage}) — NOT a bare stamp — so the signature is bound to
|
|
801
981
|
* this strand's contents and cannot be transplanted onto an attacker-chosen row. The
|
|
@@ -815,15 +995,18 @@ export class ControlDatabase {
|
|
|
815
995
|
const peerId = this.config.libp2pNode.peerId.toString();
|
|
816
996
|
const stampId = generateStampId(peerId);
|
|
817
997
|
// Field order MUST match the schema's Strand `AuthorizedInsert` verify:
|
|
818
|
-
// Id, Type, MemberPrivateKey ('' when null), StampId.
|
|
998
|
+
// Id, Type, MemberPrivateKey ('' when null), StampId. FounderOwnerKey is
|
|
999
|
+
// deliberately NOT in the digest — the schema binds it by equality to the
|
|
1000
|
+
// verified context.OwnerKey instead (see the constraint's comment).
|
|
819
1001
|
const message = buildAuthorizationMessage('CadreControl.Strand', 'add', [strandId, type, memberPrivateKey ?? '', stampId]);
|
|
820
1002
|
const signature = signMessage(message);
|
|
821
1003
|
// StampId is a real, unique column (single-use anti-replay), no longer a context value.
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
1004
|
+
// FounderOwnerKey must equal the signing owner or the constraint rejects the row.
|
|
1005
|
+
await this.execWrite(`
|
|
1006
|
+
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey)
|
|
1007
|
+
with context OwnerKey = ?, Signature = ?
|
|
1008
|
+
values (?, ?, ?, ?, ?)
|
|
1009
|
+
`, [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId, ownerKey], 'strand-insert');
|
|
827
1010
|
log('Strand inserted: %s', strandId);
|
|
828
1011
|
}
|
|
829
1012
|
/**
|
|
@@ -868,10 +1051,10 @@ export class ControlDatabase {
|
|
|
868
1051
|
// Field order MUST match the schema's ValidationKey `AuthorizedInsert` verify: Key, StampId.
|
|
869
1052
|
const message = buildAuthorizationMessage('CadreControl.ValidationKey', 'add', [key, stampId]);
|
|
870
1053
|
const signature = signMessage(message);
|
|
871
|
-
await this.execWrite(`
|
|
872
|
-
insert into CadreControl.ValidationKey (Key, StampId)
|
|
873
|
-
with context OwnerKey = ?, Signature = ?
|
|
874
|
-
values (?, ?)
|
|
1054
|
+
await this.execWrite(`
|
|
1055
|
+
insert into CadreControl.ValidationKey (Key, StampId)
|
|
1056
|
+
with context OwnerKey = ?, Signature = ?
|
|
1057
|
+
values (?, ?)
|
|
875
1058
|
`, [ownerKey, signature, key, stampId], 'validation-key-insert');
|
|
876
1059
|
log('Validation key inserted: %s', key);
|
|
877
1060
|
}
|
|
@@ -938,7 +1121,8 @@ export class ControlDatabase {
|
|
|
938
1121
|
const stampId = generateStampId(row.peerId);
|
|
939
1122
|
const signature = signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', [row.peerId, stampId]));
|
|
940
1123
|
return await this.mutateCadrePeer('peer-insert', async () => {
|
|
941
|
-
|
|
1124
|
+
// retry: false — this guard runs inside the locked write body (see queryStampId's NOTE).
|
|
1125
|
+
if (await this.queryStampId('CadrePeer', row.peerId, false) !== null) {
|
|
942
1126
|
log('CadrePeer row already present for %s; insert skipped (already a member)', row.peerId);
|
|
943
1127
|
return false;
|
|
944
1128
|
}
|
|
@@ -948,10 +1132,10 @@ export class ControlDatabase {
|
|
|
948
1132
|
// Persist the vouching (owner, signature) onto the row (VouchOwner/VouchSig) —
|
|
949
1133
|
// identical to the context pair, which the AuthorizedInsert constraint binds — so a
|
|
950
1134
|
// reader can later re-check the voucher against its node-local trusted-owner anchor.
|
|
951
|
-
await this.db.exec(`
|
|
952
|
-
insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId, VouchOwner, VouchSig)
|
|
953
|
-
with context OwnerKey = ?, Signature = ?
|
|
954
|
-
values (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1135
|
+
await this.db.exec(`
|
|
1136
|
+
insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId, VouchOwner, VouchSig)
|
|
1137
|
+
with context OwnerKey = ?, Signature = ?
|
|
1138
|
+
values (?, ?, ?, ?, ?, ?, ?, ?)
|
|
955
1139
|
`, [ownerKey, signature, row.peerId, row.publicKey, row.multiaddr, row.updatedAt, row.sig, stampId, ownerKey, signature]);
|
|
956
1140
|
return true;
|
|
957
1141
|
});
|
|
@@ -1014,11 +1198,11 @@ export class ControlDatabase {
|
|
|
1014
1198
|
const signature = signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', [peerId, stampId]));
|
|
1015
1199
|
await this.mutateCadrePeer('peer-reauthorize', async () => {
|
|
1016
1200
|
// Bare `exec` — inside the non-re-entrant write lock (see insertCadrePeer).
|
|
1017
|
-
await this.db.exec(`
|
|
1018
|
-
update CadreControl.CadrePeer
|
|
1019
|
-
with context OwnerKey = ?, Signature = ?
|
|
1020
|
-
set UpdatedAt = ?, VouchOwner = ?, VouchSig = ?
|
|
1021
|
-
where PeerId = ?
|
|
1201
|
+
await this.db.exec(`
|
|
1202
|
+
update CadreControl.CadrePeer
|
|
1203
|
+
with context OwnerKey = ?, Signature = ?
|
|
1204
|
+
set UpdatedAt = ?, VouchOwner = ?, VouchSig = ?
|
|
1205
|
+
where PeerId = ?
|
|
1022
1206
|
`, [ownerKey, signature, updatedAt, ownerKey, signature, peerId]);
|
|
1023
1207
|
});
|
|
1024
1208
|
return true;
|
|
@@ -1097,7 +1281,8 @@ export class ControlDatabase {
|
|
|
1097
1281
|
*/
|
|
1098
1282
|
async deleteGuardedRow(table, keyValue, ownerKey, signMessage) {
|
|
1099
1283
|
this.ensureInitialized();
|
|
1100
|
-
|
|
1284
|
+
// retry: false — this runs inside the locked write body (see queryStampId's NOTE).
|
|
1285
|
+
const stampId = await this.queryStampId(table, keyValue, false);
|
|
1101
1286
|
if (stampId === null) {
|
|
1102
1287
|
log('delete %s: no row for %s (already absent)', table, keyValue);
|
|
1103
1288
|
return false;
|
|
@@ -1109,18 +1294,18 @@ export class ControlDatabase {
|
|
|
1109
1294
|
// cover it — the digests are domain-separated and neither replays as the other.
|
|
1110
1295
|
const revocationSignature = signMessage(buildAuthorizationMessage('CadreControl.Revocation', 'remove', [table, keyValue, stampId]));
|
|
1111
1296
|
await this.inTransaction(`delete ${table}`, async () => {
|
|
1112
|
-
await this.db.exec(`
|
|
1113
|
-
delete from CadreControl.${table}
|
|
1114
|
-
with context OwnerKey = ?, Signature = ?
|
|
1115
|
-
where ${GUARDED_KEY_COLUMN[table]} = ?
|
|
1297
|
+
await this.db.exec(`
|
|
1298
|
+
delete from CadreControl.${table}
|
|
1299
|
+
with context OwnerKey = ?, Signature = ?
|
|
1300
|
+
where ${GUARDED_KEY_COLUMN[table]} = ?
|
|
1116
1301
|
`, [ownerKey, signature, keyValue]);
|
|
1117
1302
|
// ReissuedAt named explicitly at 0 (the only value FreshTombstone accepts) rather
|
|
1118
1303
|
// than leaning on the column default — the seat-at-zero rule is load-bearing for
|
|
1119
1304
|
// reissueRevocations' monotonic bump, so state it at the write site.
|
|
1120
|
-
await this.db.exec(`
|
|
1121
|
-
insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt)
|
|
1122
|
-
with context OwnerKey = ?, Signature = ?
|
|
1123
|
-
values (?, ?, ?, 0)
|
|
1305
|
+
await this.db.exec(`
|
|
1306
|
+
insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt)
|
|
1307
|
+
with context OwnerKey = ?, Signature = ?
|
|
1308
|
+
values (?, ?, ?, 0)
|
|
1124
1309
|
`, [ownerKey, revocationSignature, table, keyValue, stampId]);
|
|
1125
1310
|
});
|
|
1126
1311
|
log('%s deleted: %s (stamp retired)', table, keyValue);
|
|
@@ -1197,10 +1382,10 @@ export class ControlDatabase {
|
|
|
1197
1382
|
log('reap %s: %s holds a fresh incarnation (live stamp differs from the tombstoned one); leaving it', table, rowKey);
|
|
1198
1383
|
return false;
|
|
1199
1384
|
}
|
|
1200
|
-
const sql = `
|
|
1201
|
-
delete from CadreControl.${table}
|
|
1202
|
-
with context OwnerKey = null, Signature = null
|
|
1203
|
-
where ${GUARDED_KEY_COLUMN[table]} = ? and StampId = ?
|
|
1385
|
+
const sql = `
|
|
1386
|
+
delete from CadreControl.${table}
|
|
1387
|
+
with context OwnerKey = null, Signature = null
|
|
1388
|
+
where ${GUARDED_KEY_COLUMN[table]} = ? and StampId = ?
|
|
1204
1389
|
`;
|
|
1205
1390
|
if (table === 'CadrePeer') {
|
|
1206
1391
|
// Through mutateCadrePeer so the "EVERY CadrePeer writer goes through here"
|
|
@@ -1358,11 +1543,11 @@ export class ControlDatabase {
|
|
|
1358
1543
|
// output minted per row incarnation (generateStampId), so it identifies one
|
|
1359
1544
|
// row across all TableName values. Do NOT "fix" this by adding TableName to
|
|
1360
1545
|
// the where clause.
|
|
1361
|
-
await this.db.exec(`
|
|
1362
|
-
update CadreControl.Revocation
|
|
1363
|
-
with context OwnerKey = ?, Signature = ?
|
|
1364
|
-
set ReissuedAt = ?
|
|
1365
|
-
where StampId = ?
|
|
1546
|
+
await this.db.exec(`
|
|
1547
|
+
update CadreControl.Revocation
|
|
1548
|
+
with context OwnerKey = ?, Signature = ?
|
|
1549
|
+
set ReissuedAt = ?
|
|
1550
|
+
where StampId = ?
|
|
1366
1551
|
`, [ownerKey, signature, reissuedAt, row.stampId]);
|
|
1367
1552
|
executed++;
|
|
1368
1553
|
}
|
|
@@ -1484,7 +1669,8 @@ export class ControlDatabase {
|
|
|
1484
1669
|
* {@link assertCommitBoundary} (any `CadrePeer` path) or silently join each other's
|
|
1485
1670
|
* open transaction — a torn commit if either side rolls back. This lock closes that
|
|
1486
1671
|
* class: every public write method of this class runs its statement(s) under it, and
|
|
1487
|
-
* `SeedBootstrapService`
|
|
1672
|
+
* `SeedBootstrapService` reaches it through {@link execWrite} rather than touching the
|
|
1673
|
+
* `Database` directly, so it is covered too. The race is
|
|
1488
1674
|
* real, not theoretical: the background self-record publish on a control connection
|
|
1489
1675
|
* opening (`CadreNode.drainPendingControlReplication` → `registerSelf`) collided with
|
|
1490
1676
|
* a foreground `authorizePeer` — both `CadrePeer` inserts — and tripped the boundary
|
|
@@ -1632,7 +1818,8 @@ export class ControlDatabase {
|
|
|
1632
1818
|
* `StrandId` binds the invite to a pre-existing host strand (provision-then-record):
|
|
1633
1819
|
* when set, a responder redeeming this token records a `FormationUsage` against that
|
|
1634
1820
|
* strand and returns it (see {@link ControlFormationUsageRecorder.resolveStrand}); a
|
|
1635
|
-
* null `StrandId` (the default)
|
|
1821
|
+
* null `StrandId` (the default) takes the unbound responder-provisions path: the
|
|
1822
|
+
* responder provisions a fresh open strand and atomically records its one consent row.
|
|
1636
1823
|
* Like ValidationUrl it is a nullable bound field, signed as `''` when absent.
|
|
1637
1824
|
*
|
|
1638
1825
|
* The ExpiresAt and TotalUses message fields must byte-match what the (auto-deferred,
|
|
@@ -1677,10 +1864,10 @@ export class ControlDatabase {
|
|
|
1677
1864
|
// Persist the canonical ExpiresAt string (datetime parse is idempotent on it) so the
|
|
1678
1865
|
// signed source-of-truth and the stored value are produced once. StampId is a real,
|
|
1679
1866
|
// unique column (single-use anti-replay), no longer a context value.
|
|
1680
|
-
await this.execWrite(`
|
|
1681
|
-
insert into CadreControl.FormationInvite (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId)
|
|
1682
|
-
with context OwnerKey = ?, Signature = ?
|
|
1683
|
-
values (?, ?, ?, ?, ?, ?, ?)
|
|
1867
|
+
await this.execWrite(`
|
|
1868
|
+
insert into CadreControl.FormationInvite (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId)
|
|
1869
|
+
with context OwnerKey = ?, Signature = ?
|
|
1870
|
+
values (?, ?, ?, ?, ?, ?, ?)
|
|
1684
1871
|
`, [
|
|
1685
1872
|
ownerKey, signature,
|
|
1686
1873
|
token, sAppId,
|
|
@@ -1746,11 +1933,13 @@ export class ControlDatabase {
|
|
|
1746
1933
|
await this.inTransaction('redemption', async () => {
|
|
1747
1934
|
// 1. Strand row — authorised by the FormationUsage branch (no owner sig),
|
|
1748
1935
|
// still carrying a fresh unique StampId for the anti-replay column.
|
|
1749
|
-
// Hard-coded open + keyless
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1936
|
+
// Hard-coded open + keyless + explicit null FounderOwnerKey: the
|
|
1937
|
+
// consent branch admits no other shape (no signature means no
|
|
1938
|
+
// trustworthy founder machine to record — see the schema's NOTE).
|
|
1939
|
+
await this.db.exec(`
|
|
1940
|
+
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey)
|
|
1941
|
+
with context OwnerKey = null, Signature = null
|
|
1942
|
+
values (?, 'o', null, ?, null)
|
|
1754
1943
|
`, [strandId, strandStampId]);
|
|
1755
1944
|
// 2. FormationUsage row — authorised by the matching FormationInvite, and
|
|
1756
1945
|
// carrying the strand's stamp so it authorizes THIS row and no other.
|
|
@@ -1844,13 +2033,17 @@ export class ControlDatabase {
|
|
|
1844
2033
|
// is still open (tracked by `debt-composite-pk-point-lookup-unreliable-untracked`). A
|
|
1845
2034
|
// spurious empty result only costs the attempt its NAMED exhaustion error, reverting it to
|
|
1846
2035
|
// today's generic `Authorized` refusal — never a seat the invite does not have.
|
|
2036
|
+
// Both reads pass retry: false — assertSeatRemains only ever runs inside the locked
|
|
2037
|
+
// write bodies of redeemInvitation / recordFormationUsage, where a read retrying its
|
|
2038
|
+
// own backoff would sleep holding the write lock and the write funnel re-runs these
|
|
2039
|
+
// reads anyway (see the NOTE on readRows).
|
|
1847
2040
|
const totalUses = knownTotalUses !== undefined
|
|
1848
2041
|
? knownTotalUses
|
|
1849
|
-
: (await this.queryFormationInvite(token))?.totalUses ?? null;
|
|
2042
|
+
: (await this.queryFormationInvite(token, false))?.totalUses ?? null;
|
|
1850
2043
|
if (totalUses == null) {
|
|
1851
2044
|
return;
|
|
1852
2045
|
}
|
|
1853
|
-
const used = await this.countFormationUsage(token);
|
|
2046
|
+
const used = await this.countFormationUsage(token, false);
|
|
1854
2047
|
if (used >= totalUses) {
|
|
1855
2048
|
throw new InvitationExhaustedError(token, used, totalUses);
|
|
1856
2049
|
}
|
|
@@ -1866,10 +2059,10 @@ export class ControlDatabase {
|
|
|
1866
2059
|
// robustness/consistency change, matching the strand layer's `consumeInvite`,
|
|
1867
2060
|
// not a fix for an observable mis-ordering.
|
|
1868
2061
|
const nowCanonical = await canonicalDatetime(this.db, opts.nowMs);
|
|
1869
|
-
await this.db.exec(`
|
|
1870
|
-
insert into CadreControl.FormationUsage (Token, UsageStampId, PeerKey, PeerSig, Disclosure, StrandId, StrandStampId)
|
|
1871
|
-
with context Now = ?, ValidationKey = ?, ValidationSignature = ?
|
|
1872
|
-
values (?, ?, ?, ?, ?, ?, ?)
|
|
2062
|
+
await this.db.exec(`
|
|
2063
|
+
insert into CadreControl.FormationUsage (Token, UsageStampId, PeerKey, PeerSig, Disclosure, StrandId, StrandStampId)
|
|
2064
|
+
with context Now = ?, ValidationKey = ?, ValidationSignature = ?
|
|
2065
|
+
values (?, ?, ?, ?, ?, ?, ?)
|
|
1873
2066
|
`, [
|
|
1874
2067
|
nowCanonical, opts.validationKey, opts.validationSignature,
|
|
1875
2068
|
opts.token, opts.usageStampId, opts.peerKey,
|
|
@@ -1880,10 +2073,15 @@ export class ControlDatabase {
|
|
|
1880
2073
|
* Read a `FormationInvite` row by token, or null when absent. `expiresAtMs` is
|
|
1881
2074
|
* the parsed epoch-ms of the stored `datetime` (null when the invite never
|
|
1882
2075
|
* expires); the caller compares it against the wall clock for freshness.
|
|
2076
|
+
*
|
|
2077
|
+
* `retry: false` is passed only by {@link assertSeatRemains}, which runs INSIDE a
|
|
2078
|
+
* locked write body — same per-call opt-out, and for the same reason, as
|
|
2079
|
+
* {@link queryStampId}'s. Every unlocked caller keeps the default retried path.
|
|
1883
2080
|
*/
|
|
1884
|
-
async queryFormationInvite(token) {
|
|
2081
|
+
async queryFormationInvite(token, retry = true) {
|
|
1885
2082
|
this.ensureInitialized();
|
|
1886
|
-
|
|
2083
|
+
const sql = 'select Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId from CadreControl.FormationInvite where Token = ?';
|
|
2084
|
+
for (const row of await this.readRows(sql, [token], 'formation-invite', retry)) {
|
|
1887
2085
|
return {
|
|
1888
2086
|
token: row.Token,
|
|
1889
2087
|
sAppId: row.sAppId,
|
|
@@ -1898,25 +2096,31 @@ export class ControlDatabase {
|
|
|
1898
2096
|
/**
|
|
1899
2097
|
* Count `FormationUsage` rows recorded against a token (uses consumed so far).
|
|
1900
2098
|
*
|
|
1901
|
-
*
|
|
1902
|
-
*
|
|
1903
|
-
*
|
|
1904
|
-
*
|
|
1905
|
-
*
|
|
1906
|
-
*
|
|
1907
|
-
*
|
|
1908
|
-
*
|
|
1909
|
-
*
|
|
1910
|
-
*
|
|
1911
|
-
*
|
|
1912
|
-
*
|
|
1913
|
-
*
|
|
1914
|
-
*
|
|
1915
|
-
*
|
|
2099
|
+
* This count IS the seat cap (`enforceFormationUseCap`), so an under-report admits a seat
|
|
2100
|
+
* the invitation never paid for. It is served by a table SCAN, deliberately: a bare
|
|
2101
|
+
* equality on a non-key column is a shape the optimystic vtab declines to claim, so the
|
|
2102
|
+
* engine applies the filter itself over a full read of the table.
|
|
2103
|
+
*
|
|
2104
|
+
* NOTE: it was briefly an index seek, and that is what the scan is protecting against.
|
|
2105
|
+
* `formation-unique-token-redesign` took `Token` out of the primary key and declared
|
|
2106
|
+
* `FormationUsageByToken` purely to spare this scan, recording as a tripwire that an
|
|
2107
|
+
* under-reporting descent would admit an unpaid-for seat — "a failure mode a scan could
|
|
2108
|
+
* not produce". The tripwire fired. Across machines a secondary-index descent returns only
|
|
2109
|
+
* the rows the READING machine wrote, so this count under-reported without bound on a
|
|
2110
|
+
* multi-machine party and the cap over-admitted. The index was removed 2026-08-25; the
|
|
2111
|
+
* measurement is recorded in the block comment at the `FormationUsage` declaration in
|
|
2112
|
+
* `schemas/control.qsql`, and the engine defect in
|
|
2113
|
+
* `blocked/secondary-index-seek-blind-to-sibling-rows`. Do NOT re-declare the index to
|
|
2114
|
+
* speed this up while that ticket is open.
|
|
2115
|
+
*
|
|
2116
|
+
* `retry: false` is passed only by {@link assertSeatRemains}, which runs INSIDE a
|
|
2117
|
+
* locked write body — same per-call opt-out, and for the same reason, as
|
|
2118
|
+
* {@link queryStampId}'s. Every unlocked caller keeps the default retried path.
|
|
1916
2119
|
*/
|
|
1917
|
-
async countFormationUsage(token) {
|
|
2120
|
+
async countFormationUsage(token, retry = true) {
|
|
1918
2121
|
this.ensureInitialized();
|
|
1919
|
-
|
|
2122
|
+
const sql = 'select count(1) as Count from CadreControl.FormationUsage where Token = ?';
|
|
2123
|
+
for (const row of await this.readRows(sql, [token], 'formation-usage-count', retry)) {
|
|
1920
2124
|
return row.Count ?? 0;
|
|
1921
2125
|
}
|
|
1922
2126
|
return 0;
|
|
@@ -1947,7 +2151,7 @@ export class ControlDatabase {
|
|
|
1947
2151
|
// prune redeemed/expired rows.
|
|
1948
2152
|
const metered = [];
|
|
1949
2153
|
let unlimitedOutstanding = false;
|
|
1950
|
-
for
|
|
2154
|
+
for (const row of await this.readRows('select Token, ExpiresAt, TotalUses from CadreControl.FormationInvite', undefined, 'outstanding-invites')) {
|
|
1951
2155
|
const expiresAtMs = parseNullableStoredDatetimeMs(row.ExpiresAt);
|
|
1952
2156
|
if (expiresAtMs !== null && expiresAtMs <= nowMs) {
|
|
1953
2157
|
continue;
|
|
@@ -1963,6 +2167,12 @@ export class ControlDatabase {
|
|
|
1963
2167
|
if (unlimitedOutstanding) {
|
|
1964
2168
|
return true;
|
|
1965
2169
|
}
|
|
2170
|
+
// NOTE: this loop issues one retried read per metered invite, each carrying its own
|
|
2171
|
+
// CONTROL_READ_RETRY_BUDGET_MS, on a path the inbound admission gate awaits under a
|
|
2172
|
+
// 2 s FAIL-OPEN deadline — so with N unexpired metered invites the worst case is
|
|
2173
|
+
// (1 + N) budgets, not one. Fine while a cadre holds a couple of live invites and
|
|
2174
|
+
// transient read failures are rare; if a party ever keeps many metered invites open,
|
|
2175
|
+
// give this whole method one shared deadline rather than raising the per-read budget.
|
|
1966
2176
|
for (const invite of metered) {
|
|
1967
2177
|
if (await this.countFormationUsage(invite.token) < invite.totalUses) {
|
|
1968
2178
|
return true;
|