@serfab/cadre-core 1.16.0 → 1.17.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 +1 -1
- package/dist/bootstrap-peer-store-file.js +1 -1
- package/dist/bootstrap-peer-store.d.ts +1 -1
- package/dist/bootstrap-peer-store.js +4 -4
- package/dist/cadre-invite-protocol.d.ts +55 -138
- package/dist/cadre-invite-protocol.js +94 -449
- package/dist/cadre-invite-protocol.js.map +1 -1
- package/dist/cadre-join-request.d.ts +15 -2
- package/dist/cadre-join-request.js +22 -3
- package/dist/cadre-join-request.js.map +1 -1
- package/dist/cadre-node.d.ts +530 -247
- package/dist/cadre-node.js +1046 -525
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-authorization.d.ts +36 -14
- package/dist/control-authorization.js +20 -3
- package/dist/control-authorization.js.map +1 -1
- package/dist/control-cohort.d.ts +22 -23
- package/dist/control-cohort.js +24 -24
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +416 -136
- package/dist/control-database.js +757 -378
- package/dist/control-database.js.map +1 -1
- package/dist/control-protocol-guard.d.ts +5 -4
- package/dist/control-protocol-guard.js +6 -3
- package/dist/control-protocol-guard.js.map +1 -1
- package/dist/control-schema-version.d.ts +80 -0
- package/dist/control-schema-version.js +203 -0
- package/dist/control-schema-version.js.map +1 -0
- package/dist/control-schema.d.ts +45 -5
- package/dist/control-schema.js +254 -70
- package/dist/control-schema.js.map +1 -1
- package/dist/control-write-retry.d.ts +5 -5
- package/dist/control-write-retry.js +10 -8
- package/dist/control-write-retry.js.map +1 -1
- package/dist/delegate-admission.d.ts +13 -0
- package/dist/delegate-admission.js +27 -0
- package/dist/delegate-admission.js.map +1 -1
- package/dist/ed25519-key.d.ts +10 -7
- package/dist/ed25519-key.js +23 -20
- package/dist/ed25519-key.js.map +1 -1
- package/dist/edge-node/config.d.ts +122 -0
- package/dist/edge-node/config.js +199 -0
- package/dist/edge-node/config.js.map +1 -0
- package/dist/edge-node/dissolve.d.ts +95 -0
- package/dist/edge-node/dissolve.js +184 -0
- package/dist/edge-node/dissolve.js.map +1 -0
- package/dist/edge-node/edge-join.d.ts +44 -0
- package/dist/edge-node/edge-join.js +227 -0
- package/dist/edge-node/edge-join.js.map +1 -0
- package/dist/edge-node/edge-node.d.ts +295 -0
- package/dist/edge-node/edge-node.js +704 -0
- package/dist/edge-node/edge-node.js.map +1 -0
- package/dist/edge-node/index.d.ts +18 -0
- package/dist/edge-node/index.js +18 -0
- package/dist/edge-node/index.js.map +1 -0
- package/dist/edge-node/join-credential.d.ts +11 -0
- package/dist/edge-node/join-credential.js +11 -0
- package/dist/edge-node/join-credential.js.map +1 -0
- package/dist/edge-node/lazy-raw-storage.d.ts +40 -0
- package/dist/edge-node/lazy-raw-storage.js +134 -0
- package/dist/edge-node/lazy-raw-storage.js.map +1 -0
- package/dist/edge-node/node-local.d.ts +60 -0
- package/dist/edge-node/node-local.js +58 -0
- package/dist/edge-node/node-local.js.map +1 -0
- package/dist/edge-node/options.d.ts +83 -0
- package/dist/edge-node/options.js +110 -0
- package/dist/edge-node/options.js.map +1 -0
- package/dist/edge-node/owner-grant.d.ts +41 -0
- package/dist/edge-node/owner-grant.js +38 -0
- package/dist/edge-node/owner-grant.js.map +1 -0
- package/dist/edge-node/platform.d.ts +140 -0
- package/dist/edge-node/platform.js +12 -0
- package/dist/edge-node/platform.js.map +1 -0
- package/dist/edge-node/strand-move.d.ts +115 -0
- package/dist/edge-node/strand-move.js +223 -0
- package/dist/edge-node/strand-move.js.map +1 -0
- package/dist/edge-node/strands.d.ts +28 -0
- package/dist/edge-node/strands.js +60 -0
- package/dist/edge-node/strands.js.map +1 -0
- package/dist/index.d.ts +16 -7
- package/dist/index.js +26 -7
- package/dist/index.js.map +1 -1
- package/dist/joined-strand-store.d.ts +3 -3
- package/dist/joined-strand-store.js +3 -3
- package/dist/joined-strand-store.js.map +1 -1
- package/dist/key-store.d.ts +9 -4
- package/dist/key-store.js +9 -4
- package/dist/key-store.js.map +1 -1
- package/dist/machine-removal.d.ts +19 -39
- package/dist/machine-removal.js +24 -98
- package/dist/machine-removal.js.map +1 -1
- package/dist/member-redemption.d.ts +250 -0
- package/dist/member-redemption.js +485 -0
- package/dist/member-redemption.js.map +1 -0
- package/dist/owner-anchor-sync.d.ts +4 -4
- package/dist/owner-anchor-sync.js +16 -15
- package/dist/owner-anchor-sync.js.map +1 -1
- package/dist/owner-grant-protocol.d.ts +247 -0
- package/dist/owner-grant-protocol.js +330 -0
- package/dist/owner-grant-protocol.js.map +1 -0
- package/dist/owner-key-export.d.ts +70 -0
- package/dist/owner-key-export.js +279 -0
- package/dist/owner-key-export.js.map +1 -0
- package/dist/owner-key-flows.d.ts +55 -0
- package/dist/owner-key-flows.js +60 -0
- package/dist/owner-key-flows.js.map +1 -0
- package/dist/owner-key-store.d.ts +31 -0
- package/dist/owner-key-store.js +81 -0
- package/dist/owner-key-store.js.map +1 -0
- package/dist/owner-keys.d.ts +78 -0
- package/dist/owner-keys.js +133 -0
- package/dist/owner-keys.js.map +1 -0
- package/dist/owner-signer.d.ts +54 -0
- package/dist/owner-signer.js +51 -0
- package/dist/owner-signer.js.map +1 -0
- package/dist/peer-authorization.d.ts +83 -18
- package/dist/peer-authorization.js +123 -53
- package/dist/peer-authorization.js.map +1 -1
- package/dist/provider/rent-node.js +1 -1
- package/dist/provider/rent-node.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +131 -109
- package/dist/seed-bootstrap.js +260 -248
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/seed-trust-policy.js +4 -4
- package/dist/strand-addr-protocol.d.ts +4 -1
- package/dist/strand-addr-protocol.js +8 -2
- package/dist/strand-addr-protocol.js.map +1 -1
- package/dist/strand-cohort.d.ts +3 -0
- package/dist/strand-cohort.js.map +1 -1
- package/dist/strand-database.d.ts +1 -0
- package/dist/strand-database.js +3 -0
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-first-sync-gate.d.ts +4 -3
- package/dist/strand-first-sync-gate.js +4 -3
- package/dist/strand-first-sync-gate.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +1 -1
- package/dist/strand-membership-writer.d.ts +13 -2
- package/dist/strand-membership-writer.js +52 -16
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-move.d.ts +6 -5
- package/dist/strand-move.js +31 -30
- package/dist/strand-move.js.map +1 -1
- package/dist/strand-peer-redial.js +2 -3
- package/dist/strand-peer-redial.js.map +1 -1
- package/dist/types.d.ts +286 -103
- package/dist/types.js.map +1 -1
- package/package.json +11 -2
- package/src/bootstrap-peer-store-file.ts +44 -44
- package/src/bootstrap-peer-store.ts +214 -214
- package/src/cadre-invite-protocol.ts +652 -1052
- package/src/cadre-join-request.ts +24 -3
- package/src/cadre-node.ts +1166 -541
- package/src/control-authorization.ts +47 -14
- package/src/control-cohort.ts +127 -128
- package/src/control-database.ts +940 -456
- package/src/control-protocol-guard.ts +7 -4
- package/src/control-schema-version.ts +251 -0
- package/src/control-schema.ts +265 -70
- package/src/control-write-retry.ts +569 -567
- package/src/delegate-admission.ts +37 -0
- package/src/ed25519-key.ts +174 -171
- package/src/edge-node/config.ts +277 -0
- package/src/edge-node/dissolve.ts +251 -0
- package/src/edge-node/edge-join.ts +319 -0
- package/src/edge-node/edge-node.ts +1045 -0
- package/src/edge-node/index.ts +59 -0
- package/src/edge-node/join-credential.ts +16 -0
- package/src/edge-node/lazy-raw-storage.ts +178 -0
- package/src/edge-node/node-local.ts +84 -0
- package/src/edge-node/options.ts +179 -0
- package/src/edge-node/owner-grant.ts +63 -0
- package/src/edge-node/platform.ts +151 -0
- package/src/edge-node/strand-move.ts +270 -0
- package/src/edge-node/strands.ts +72 -0
- package/src/index.ts +886 -789
- package/src/joined-strand-store.ts +4 -4
- package/src/key-store.ts +10 -4
- package/src/machine-removal.ts +26 -111
- package/src/member-redemption.ts +642 -0
- package/src/owner-anchor-sync.ts +19 -18
- package/src/owner-grant-protocol.ts +532 -0
- package/src/owner-key-export.ts +348 -0
- package/src/owner-key-flows.ts +109 -0
- package/src/owner-key-store.ts +85 -0
- package/src/owner-keys.ts +178 -0
- package/src/owner-signer.ts +94 -0
- package/src/peer-authorization.ts +558 -488
- package/src/provider/rent-node.ts +1 -1
- package/src/seed-bootstrap.ts +301 -312
- package/src/seed-trust-policy.ts +4 -4
- package/src/strand-addr-protocol.ts +547 -538
- package/src/strand-cohort.ts +4 -0
- package/src/strand-database.ts +3 -0
- package/src/strand-first-sync-gate.ts +4 -3
- package/src/strand-instance-manager.ts +1 -1
- package/src/strand-membership-writer.ts +61 -16
- package/src/strand-move.ts +45 -36
- package/src/strand-peer-redial.ts +2 -3
- package/src/types.ts +304 -103
package/dist/control-database.js
CHANGED
|
@@ -4,11 +4,13 @@ import { Database, FunctionFlags, TEXT_RETURN, createScalarFunction, registerPlu
|
|
|
4
4
|
import cryptoPlugin from '@optimystic/quereus-plugin-crypto/plugin';
|
|
5
5
|
import optimysticPlugin from '@optimystic/quereus-plugin-optimystic/plugin';
|
|
6
6
|
import { digest, randomBytes } from '@optimystic/quereus-plugin-crypto';
|
|
7
|
-
import {
|
|
7
|
+
import { APPLY_CONTROL_SCHEMA, CONTROL_SCHEMA_DECLARATION, controlSchemaFileDeclaration } from './control-schema.js';
|
|
8
|
+
import { refuseIncompatibleControlStore } from './control-schema-version.js';
|
|
8
9
|
import { canonicalDatetime } from './canonical-datetime.js';
|
|
9
|
-
import { controlAuthorizationFields, unboundAuthorizationFields, cadreInviteRowFields, CONTROL_TABLES } from './control-authorization.js';
|
|
10
|
-
import {
|
|
11
|
-
import { verifyCadreInviteRow } from './peer-authorization.js';
|
|
10
|
+
import { controlAuthorizationFields, unboundAuthorizationFields, cadreInviteRowFields, ownerGrantRowFields, CONTROL_TABLES } from './control-authorization.js';
|
|
11
|
+
import { requireEd25519PublicKeyB64 } from './ed25519-key.js';
|
|
12
|
+
import { verifyCadreInviteRow, verifyOwnerGrantRow } from './peer-authorization.js';
|
|
13
|
+
import { OWNER_KEY_MAX_LABEL } from './owner-key-export.js';
|
|
12
14
|
import { retryControlWrite, SCHEMA_INIT_RETRY_POLICY } from './control-write-retry.js';
|
|
13
15
|
import { isCohortUnreachableRead, retryControlRead } from './control-read-retry.js';
|
|
14
16
|
import { chainMessages } from './control-retry.js';
|
|
@@ -78,13 +80,14 @@ export class FormationAbortedError extends Error {
|
|
|
78
80
|
/**
|
|
79
81
|
* The invitation's seat budget is already spent: the count of recorded usage rows for the
|
|
80
82
|
* invitation has reached its `TotalUses`, so this redemption cannot be given a seat. `token`
|
|
81
|
-
* is the `FormationInvite.Token` or the `
|
|
82
|
-
* redeemed against.
|
|
83
|
+
* is the `FormationInvite.Token`, the `CadreInvite.Key` or the `OwnerGrant.Key`, whichever
|
|
84
|
+
* table the caller redeemed against.
|
|
83
85
|
*
|
|
84
86
|
* Raised off the committed-count check ({@link ControlDatabase.assertSeatRemains}) ahead of
|
|
85
87
|
* the write, inside {@link ControlDatabase.redeemInvitation} /
|
|
86
88
|
* {@link ControlDatabase.recordFormationUsage}, off the same check for a cadre invitation
|
|
87
|
-
* ({@link ControlDatabase.redeemCadreInvite})
|
|
89
|
+
* ({@link ControlDatabase.redeemCadreInvite}) and an owner grant
|
|
90
|
+
* ({@link ControlDatabase.redeemOwnerGrant}), and off the unlocked pre-check
|
|
88
91
|
* `ControlFormationUsageRecorder.authorizeUsage` runs before the manager issues a membership
|
|
89
92
|
* pass — and off nothing else. It exists because
|
|
90
93
|
* without it the refusal surfaces as a generic `CHECK constraint failed: Authorized` from the
|
|
@@ -119,8 +122,42 @@ export class CadreInviteIssuerUnknownError extends Error {
|
|
|
119
122
|
this.name = 'CadreInviteIssuerUnknownError';
|
|
120
123
|
}
|
|
121
124
|
}
|
|
122
|
-
/**
|
|
123
|
-
|
|
125
|
+
/**
|
|
126
|
+
* {@link ControlDatabase.seatOwnerGrant} was refused by `OwnerGrant.AuthorizedInsert`: the
|
|
127
|
+
* grant twin of {@link CadreInviteIssuerUnknownError}, for the same two causes (the issuer's
|
|
128
|
+
* `OwnerKey` row has not replicated here yet or has since been removed; or a tampered row).
|
|
129
|
+
* Its own class rather than a shared one so a caller that handles invitation and grant
|
|
130
|
+
* bundles can tell by `instanceof` which credential was refused.
|
|
131
|
+
*/
|
|
132
|
+
export class OwnerGrantIssuerUnknownError extends Error {
|
|
133
|
+
constructor(grantKey, issuerKey, options) {
|
|
134
|
+
super(`Owner grant ${grantKey} cannot be seated here: its issuer ${issuerKey} is not an owner on this node, or its signature does not verify`, options);
|
|
135
|
+
this.grantKey = grantKey;
|
|
136
|
+
this.issuerKey = issuerKey;
|
|
137
|
+
this.name = 'OwnerGrantIssuerUnknownError';
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* An issued row (a `CadreInvite` or an `OwnerGrant`) the cadre holds that this node cannot read
|
|
142
|
+
* yet: {@link ControlDatabase.seatCadreInvite} / {@link ControlDatabase.seatOwnerGrant} had its
|
|
143
|
+
* insert refused by a cohort member already holding the row, and a refreshed read here still
|
|
144
|
+
* finds nothing; or the redemption that follows the seat finds no row. It clears once this
|
|
145
|
+
* node's store catches up, which is why the redemption paths answer it with the retryable
|
|
146
|
+
* `conflict` code.
|
|
147
|
+
*/
|
|
148
|
+
export class IssuedRowNotHereYetError extends Error {
|
|
149
|
+
constructor(table, key, options) {
|
|
150
|
+
super(`${table} ${key} is held by the cadre but not readable on this node yet; try again once it has synced`, options);
|
|
151
|
+
this.table = table;
|
|
152
|
+
this.key = key;
|
|
153
|
+
this.name = 'IssuedRowNotHereYetError';
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* The refusal {@link CadreInviteIssuerUnknownError} and {@link OwnerGrantIssuerUnknownError}
|
|
158
|
+
* wrap — the constraint named, as the engine words it; both tables name theirs `AuthorizedInsert`.
|
|
159
|
+
*/
|
|
160
|
+
const ISSUER_UNAUTHORIZED = /CHECK constraint failed: AuthorizedInsert\b/;
|
|
124
161
|
/**
|
|
125
162
|
* Parse a stored Quereus `datetime` value into epoch milliseconds.
|
|
126
163
|
*
|
|
@@ -288,6 +325,39 @@ export function cadreInviteConsentMessage(partyId, fields) {
|
|
|
288
325
|
fields.inviteKey, fields.usageStampId, fields.peerKey,
|
|
289
326
|
]);
|
|
290
327
|
}
|
|
328
|
+
/**
|
|
329
|
+
* The exact bytes an owner signs to seat an `OwnerGrant` row — the TS mirror of the `'add'`
|
|
330
|
+
* digest in `OwnerGrant.AuthorizedInsert`, over the whole row in the schema's order with
|
|
331
|
+
* nullable columns as `''` (`ownerGrantRowFields`, shared with the base64url verifier
|
|
332
|
+
* `peer-authorization.ts`'s `ownerGrantAddDigest`). `expiresAt` must already be the stored
|
|
333
|
+
* canonical `datetime` string, as {@link ControlDatabase.insertOwnerGrant} produces it, for
|
|
334
|
+
* the reason on {@link cadreInviteAddMessage}.
|
|
335
|
+
*/
|
|
336
|
+
export function ownerGrantAddMessage(partyId, row) {
|
|
337
|
+
return buildAuthorizationMessage('CadreControl.OwnerGrant', 'add', partyId, ownerGrantRowFields(row));
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* The exact bytes the HOLDER of an owner grant signs, with the grant's private key, to redeem
|
|
341
|
+
* it once — the TS mirror of the `'redeem'` digest `OwnerGrantUsage.GrantPossessed` verifies
|
|
342
|
+
* against the grant's public key. Binding the seated key and the nonce means a captured
|
|
343
|
+
* redemption can seat no other key and spend no other use.
|
|
344
|
+
*/
|
|
345
|
+
export function ownerGrantRedeemMessage(partyId, fields) {
|
|
346
|
+
return buildAuthorizationMessage('CadreControl.OwnerGrantUsage', 'redeem', partyId, [
|
|
347
|
+
fields.grantKey, fields.usageStampId, fields.seatedKey,
|
|
348
|
+
]);
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* The exact bytes the SEATED key signs, proving the holder holds it — the TS mirror of the
|
|
352
|
+
* `'consent'` digest `OwnerGrantUsage.OwnerConsented` verifies against `SeatedKey`. Same
|
|
353
|
+
* fields as {@link ownerGrantRedeemMessage} under a distinct action tag, so neither
|
|
354
|
+
* signature can stand in for the other.
|
|
355
|
+
*/
|
|
356
|
+
export function ownerGrantConsentMessage(partyId, fields) {
|
|
357
|
+
return buildAuthorizationMessage('CadreControl.OwnerGrantUsage', 'consent', partyId, [
|
|
358
|
+
fields.grantKey, fields.usageStampId, fields.seatedKey,
|
|
359
|
+
]);
|
|
360
|
+
}
|
|
291
361
|
/** Runtime guard for the dynamic-`from` count, over the one table list. */
|
|
292
362
|
const CONTROL_TABLE_SET = new Set(CONTROL_TABLES);
|
|
293
363
|
/**
|
|
@@ -306,11 +376,14 @@ const GUARDED_KEY_COLUMN = {
|
|
|
306
376
|
JoinRequest: 'Id',
|
|
307
377
|
DeviceToken: 'PeerId',
|
|
308
378
|
CadreInvite: 'Key',
|
|
379
|
+
OwnerGrant: 'Key',
|
|
309
380
|
};
|
|
381
|
+
/** The write-reason prefix each {@link IssuedTable}'s seat and withdrawal log under. */
|
|
382
|
+
const ISSUED_TABLE_REASON = { CadreInvite: 'cadre-invite', OwnerGrant: 'owner-grant' };
|
|
310
383
|
/**
|
|
311
384
|
* The exact bytes an owner signs to append one `Revocation` tombstone — `Revocation.Authorized`
|
|
312
385
|
* binds the whole (TableName, RowKey, StampId) row under its own domain tag. Shared by every
|
|
313
|
-
* tombstone writer (a guarded removal,
|
|
386
|
+
* tombstone writer (a guarded removal, an issued row's withdrawal, the ledger marker) so the
|
|
314
387
|
* field order lives in one place; the base64url verifier is `peer-authorization.ts`'s
|
|
315
388
|
* `revocationDigest`.
|
|
316
389
|
*/
|
|
@@ -321,28 +394,46 @@ function revocationTombstoneMessage(partyId, ref) {
|
|
|
321
394
|
* The exact bytes an owner signs to seat a further `OwnerKey` row (the signed-add branch of
|
|
322
395
|
* `OwnerKey.Authorized`); the base64url twin is `peer-authorization.ts`'s `ownerKeyAddDigest`.
|
|
323
396
|
*/
|
|
324
|
-
function ownerKeyAddMessage(partyId, key, stampId) {
|
|
325
|
-
return buildAuthorizationMessage('CadreControl.OwnerKey', 'add', partyId, [key, stampId]);
|
|
397
|
+
function ownerKeyAddMessage(partyId, key, label, stampId) {
|
|
398
|
+
return buildAuthorizationMessage('CadreControl.OwnerKey', 'add', partyId, [key, label ?? '', stampId]);
|
|
326
399
|
}
|
|
327
|
-
|
|
400
|
+
/**
|
|
401
|
+
* An owner key's label as its row stores it: trimmed, a blank one left out, and refused past
|
|
402
|
+
* {@link OWNER_KEY_MAX_LABEL} by name rather than by the schema's `LabelLength` check.
|
|
403
|
+
* Idempotent, so a caller that checks before opening an owner approval can pass the result on.
|
|
404
|
+
*/
|
|
405
|
+
export function ownerKeyLabel(label) {
|
|
406
|
+
const trimmed = label?.trim();
|
|
407
|
+
if (!trimmed) {
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
if (trimmed.length > OWNER_KEY_MAX_LABEL) {
|
|
411
|
+
throw new Error(`An owner key's label must be at most ${OWNER_KEY_MAX_LABEL} characters (this one has ${trimmed.length})`);
|
|
412
|
+
}
|
|
413
|
+
return trimmed;
|
|
414
|
+
}
|
|
415
|
+
async function signGuardedRemoval(partyId, tableName, rowKey, stampId, signMessage) {
|
|
328
416
|
const ref = { tableName, rowKey, stampId };
|
|
329
417
|
return {
|
|
330
418
|
ref,
|
|
331
|
-
signature: signMessage(buildAuthorizationMessage(`CadreControl.${tableName}`, 'remove', partyId, [rowKey, stampId])),
|
|
332
|
-
revocationSignature: signMessage(revocationTombstoneMessage(partyId, ref)),
|
|
419
|
+
signature: await signMessage(buildAuthorizationMessage(`CadreControl.${tableName}`, 'remove', partyId, [rowKey, stampId])),
|
|
420
|
+
revocationSignature: await signMessage(revocationTombstoneMessage(partyId, ref)),
|
|
333
421
|
};
|
|
334
422
|
}
|
|
335
423
|
/**
|
|
336
|
-
* The rejection a second insert of an already-seated
|
|
424
|
+
* The rejection a second insert of an already-seated key into `table` produces, as the
|
|
337
425
|
* optimystic vtab words it (`uniqueConstraintMessage`, qualified by table name only — no
|
|
338
|
-
* schema prefix, matching `test/control-constraint-helpers.ts`'s `expectUniqueViolation`)
|
|
426
|
+
* schema prefix, matching `test/control-constraint-helpers.ts`'s `expectUniqueViolation`),
|
|
427
|
+
* naming the table's {@link GUARDED_KEY_COLUMN}.
|
|
339
428
|
*
|
|
340
429
|
* Each table's `StampId` is unique too, but a fresh stamp is minted per insert attempt, so
|
|
341
|
-
* only the primary key can collide on a repeat insert.
|
|
342
|
-
*
|
|
430
|
+
* only the primary key can collide on a repeat insert. An issued row's bundle copy repeats its
|
|
431
|
+
* stamp as well, and the engine still names the key (`control-owner-grant.spec.ts` pins it).
|
|
432
|
+
* Matching the column explicitly keeps any OTHER uniqueness failure out of the caller's
|
|
433
|
+
* idempotency branch.
|
|
343
434
|
*/
|
|
344
|
-
function
|
|
345
|
-
return new RegExp(`UNIQUE constraint failed: ${table}
|
|
435
|
+
function keyConflictPattern(table) {
|
|
436
|
+
return new RegExp(`UNIQUE constraint failed: ${table}\\.${GUARDED_KEY_COLUMN[table]}\\b`, 'i');
|
|
346
437
|
}
|
|
347
438
|
/**
|
|
348
439
|
* Did this write to `table` fail because the strand id is already seated there?
|
|
@@ -360,7 +451,16 @@ function idConflictPattern(table) {
|
|
|
360
451
|
* reword reddens there.
|
|
361
452
|
*/
|
|
362
453
|
export function isStrandIdConflict(error, table) {
|
|
363
|
-
return errorChainMatches(error,
|
|
454
|
+
return errorChainMatches(error, keyConflictPattern(table));
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Did a seat of an issued row fail because a cohort member already holds a row under its key?
|
|
458
|
+
* {@link ControlDatabase.seatCadreInvite} / {@link ControlDatabase.seatOwnerGrant} reach this
|
|
459
|
+
* on a node whose store lags the cohort: its guard read missed the row, and the member that
|
|
460
|
+
* checked the commit refused the insert. Text-matched, failing closed, like {@link isStrandIdConflict}.
|
|
461
|
+
*/
|
|
462
|
+
export function isIssuedRowKeyConflict(error, table) {
|
|
463
|
+
return errorChainMatches(error, keyConflictPattern(table));
|
|
364
464
|
}
|
|
365
465
|
/**
|
|
366
466
|
* Did {@link ControlDatabase.insertJoinRequest} fail because the party already holds a
|
|
@@ -372,7 +472,7 @@ export function isStrandIdConflict(error, table) {
|
|
|
372
472
|
* until the next reap pass.
|
|
373
473
|
*/
|
|
374
474
|
export function isPendingJoinConflict(error) {
|
|
375
|
-
return errorChainMatches(error,
|
|
475
|
+
return errorChainMatches(error, keyConflictPattern('JoinRequest'));
|
|
376
476
|
}
|
|
377
477
|
/**
|
|
378
478
|
* The `JoinRequest.Id` of an invitation: the base64url sha256 `digest` of its token. One row
|
|
@@ -408,7 +508,7 @@ export const REVOCATION_LEDGER_MARKER = {
|
|
|
408
508
|
};
|
|
409
509
|
/**
|
|
410
510
|
* The rejection a second insert of the ledger marker produces: the optimystic vtab's
|
|
411
|
-
* primary-key wording (see {@link
|
|
511
|
+
* primary-key wording (see {@link keyConflictPattern}), naming both columns of
|
|
412
512
|
* `Revocation`'s composite key. Applied only to the marker's own insert, whose key
|
|
413
513
|
* ('Revocation', 'opened') no row but the marker can hold (`RowIsGone`), and `Revocation`
|
|
414
514
|
* has no other unique constraint.
|
|
@@ -424,41 +524,32 @@ function errorChainMatches(error, pattern) {
|
|
|
424
524
|
}
|
|
425
525
|
/**
|
|
426
526
|
* Project `CadrePeer` rows into the {@link SeedPeer} list a seed, or a cadre invitation
|
|
427
|
-
* redemption reply, carries.
|
|
527
|
+
* redemption reply, carries. `minterPeerId` is the machine minting the seed or answering the
|
|
528
|
+
* redemption; it contributes an entry only through its own `CadrePeer` row.
|
|
428
529
|
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
* NOTE: this is the one owner lookup deliberately left on the REPLICATED table rather than
|
|
436
|
-
* the node-local anchor. `SeedPeer.isOwner` is a dial hint — the receiver dials owner-flagged
|
|
437
|
-
* peers first — not a trust decision, and the receiver re-derives real trust from its own
|
|
438
|
-
* anchor. So a polluted table costs at most a wasted dial, while anchoring here would
|
|
439
|
-
* silently drop legitimate co-owners this node never pinned. If `isOwner` ever gates
|
|
440
|
-
* anything the receiver TRUSTS, move it to the anchor.
|
|
530
|
+
* A peer is `preferred` when it is the minter, the one machine certain to be reachable by the
|
|
531
|
+
* receiver at that moment, since it is delivering the seed or the reply, or an always-on
|
|
532
|
+
* machine (`Profile = 'storage'`), the ones that stay dialable. Preference is a dial hint and
|
|
533
|
+
* reads no `OwnerKey` row: owner keys are not machines, and a receiver derives trust from its
|
|
534
|
+
* own anchor, never from this list. A `Profile` is self-declared and not checked here, so a
|
|
535
|
+
* machine misdeclaring one costs the receiver a wasted dial at most.
|
|
441
536
|
*/
|
|
442
|
-
export function projectSeedPeers(rows,
|
|
443
|
-
return rows.map(({ peerId, multiaddr }) => {
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
multiaddrs: multiaddr ? multiaddr.split(',') : [],
|
|
449
|
-
isOwner,
|
|
450
|
-
...(isOwner ? { publicKey: pubKeyB64 } : {}),
|
|
451
|
-
};
|
|
452
|
-
});
|
|
537
|
+
export function projectSeedPeers(rows, minterPeerId) {
|
|
538
|
+
return rows.map(({ peerId, multiaddr, profile }) => ({
|
|
539
|
+
peerId,
|
|
540
|
+
multiaddrs: multiaddr ? multiaddr.split(',') : [],
|
|
541
|
+
preferred: peerId === minterPeerId || profile === 'storage',
|
|
542
|
+
}));
|
|
453
543
|
}
|
|
454
544
|
/**
|
|
455
|
-
* The conditions a cadre invitation
|
|
456
|
-
* not withdrawn, unexpired (`expiresAt <= now` is
|
|
457
|
-
* and issued by a current owner. Shared by
|
|
458
|
-
* {@link ControlDatabase.
|
|
459
|
-
*
|
|
545
|
+
* The conditions an issued credential (a cadre invitation or an owner grant) must still meet
|
|
546
|
+
* to be redeemed, short of its use count: not withdrawn, unexpired (`expiresAt <= now` is
|
|
547
|
+
* expired, as the redemption refuses it), and issued by a current owner. Shared by
|
|
548
|
+
* {@link ControlDatabase.hasLiveCadreInvite}, {@link ControlDatabase.isCadreInviteLive},
|
|
549
|
+
* {@link ControlDatabase.listCadreInviteStatuses} and their owner-grant twins so they cannot
|
|
550
|
+
* drift from each other, or from `CadreInviteUsage.Authorized` / `OwnerGrantUsage.Authorized`.
|
|
460
551
|
*/
|
|
461
|
-
function
|
|
552
|
+
function issuedRowStillOpen(row, withdrawn, owners, nowMs) {
|
|
462
553
|
if (withdrawn.has(row.stampId) || !owners.has(row.issuerKey)) {
|
|
463
554
|
return false;
|
|
464
555
|
}
|
|
@@ -476,31 +567,30 @@ function cadreInviteStillOpen(row, withdrawn, owners, nowMs) {
|
|
|
476
567
|
* hold, so a reaped row is recoverable by re-forming. `JoinRequest` is reapable for the same
|
|
477
568
|
* reason: the inviting party and the user also hold its invitation. `OwnerKey` has no
|
|
478
569
|
* production removal path and `MinOneOwner` makes an automated owner-key reap a
|
|
479
|
-
* party-bricking hazard. `CadreInvite`
|
|
480
|
-
*
|
|
570
|
+
* party-bricking hazard. `CadreInvite` and `OwnerGrant` are never deleted at all (their
|
|
571
|
+
* tombstones are withdrawals over rows that stay), so there is nothing to reap.
|
|
481
572
|
*/
|
|
482
573
|
export const REAPABLE_TABLES = ['CadrePeer', 'DeviceToken', 'ValidationKey', 'JoinedStrand', 'JoinRequest'];
|
|
483
574
|
/**
|
|
484
575
|
* Runtime membership test for {@link REAPABLE_TABLES}, narrowing a tombstone's
|
|
485
576
|
* `TableName` to the subset {@link ControlDatabase.reapRevokedRow} accepts. A tombstone
|
|
486
|
-
* naming an excluded table (`Strand`, `OwnerKey`, `CadreInvite`) is skipped silently —
|
|
577
|
+
* naming an excluded table (`Strand`, `OwnerKey`, `CadreInvite`, `OwnerGrant`) is skipped silently —
|
|
487
578
|
* `Strand` tombstones are the common case in a party that has unpublished a strand, so a
|
|
488
579
|
* log line per skip would be per-pass noise.
|
|
489
580
|
*/
|
|
490
581
|
const REAPABLE_TABLE_SET = new Set(REAPABLE_TABLES);
|
|
491
582
|
const isReapableTable = (table) => REAPABLE_TABLE_SET.has(table);
|
|
492
583
|
/** Every `CadreInvite` column, in the order {@link cadreInviteRowOf} reads them; callers append their `where`. */
|
|
493
|
-
const CADRE_INVITE_SELECT_SQL = 'select Key, PeerId,
|
|
584
|
+
const CADRE_INVITE_SELECT_SQL = 'select Key, PeerId, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId from CadreControl.CadreInvite';
|
|
494
585
|
/**
|
|
495
586
|
* A stored `CadreInvite` row as a {@link CadreInviteRow}. `ExpiresAt` is kept as the
|
|
496
587
|
* engine-canonical string it reads back as — the form the `'add'` signature covers — never
|
|
497
|
-
* parsed to ms here
|
|
588
|
+
* parsed to ms here.
|
|
498
589
|
*/
|
|
499
590
|
function cadreInviteRowOf(row) {
|
|
500
591
|
return {
|
|
501
592
|
key: row.Key,
|
|
502
593
|
peerId: row.PeerId ?? null,
|
|
503
|
-
grantsOwner: Number(row.GrantsOwner) === 1,
|
|
504
594
|
expiresAt: row.ExpiresAt == null ? null : String(row.ExpiresAt),
|
|
505
595
|
totalUses: row.TotalUses ?? null,
|
|
506
596
|
issuerKey: row.IssuerKey,
|
|
@@ -516,11 +606,35 @@ function cadreInviteUsageRowOf(row) {
|
|
|
516
606
|
peerId: row.PeerId,
|
|
517
607
|
peerKey: row.PeerKey,
|
|
518
608
|
peerStampId: row.PeerStampId,
|
|
519
|
-
ownerStampId: row.OwnerStampId ?? null,
|
|
520
609
|
inviteSig: row.InviteSig,
|
|
521
610
|
peerSig: row.PeerSig,
|
|
522
611
|
};
|
|
523
612
|
}
|
|
613
|
+
/** Every `OwnerGrant` column, in the order {@link ownerGrantRowOf} reads them; callers append their `where`. */
|
|
614
|
+
const OWNER_GRANT_SELECT_SQL = 'select Key, Label, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId from CadreControl.OwnerGrant';
|
|
615
|
+
/** A stored `OwnerGrant` row as an {@link OwnerGrantRow}; `ExpiresAt` kept canonical, as {@link cadreInviteRowOf} keeps it. */
|
|
616
|
+
function ownerGrantRowOf(row) {
|
|
617
|
+
return {
|
|
618
|
+
key: row.Key,
|
|
619
|
+
label: row.Label ?? null,
|
|
620
|
+
expiresAt: row.ExpiresAt == null ? null : String(row.ExpiresAt),
|
|
621
|
+
totalUses: row.TotalUses ?? null,
|
|
622
|
+
issuerKey: row.IssuerKey,
|
|
623
|
+
issuerSig: row.IssuerSig,
|
|
624
|
+
stampId: row.StampId,
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
/** A stored `OwnerGrantUsage` row as an {@link OwnerGrantUsageRow}. */
|
|
628
|
+
function ownerGrantUsageRowOf(row) {
|
|
629
|
+
return {
|
|
630
|
+
usageStampId: row.UsageStampId,
|
|
631
|
+
grantKey: row.GrantKey,
|
|
632
|
+
seatedKey: row.SeatedKey,
|
|
633
|
+
seatedStampId: row.SeatedStampId,
|
|
634
|
+
grantSig: row.GrantSig,
|
|
635
|
+
ownerSig: row.OwnerSig,
|
|
636
|
+
};
|
|
637
|
+
}
|
|
524
638
|
/** A `JoinedStrand` row as a {@link StrandRow}; the schema's `KnownType` check backs the `Type` cast. */
|
|
525
639
|
function joinedStrandRow(row) {
|
|
526
640
|
return {
|
|
@@ -528,8 +642,20 @@ function joinedStrandRow(row) {
|
|
|
528
642
|
MemberPrivateKey: row.MemberPrivateKey,
|
|
529
643
|
Type: row.Type,
|
|
530
644
|
FounderOwnerKey: null,
|
|
645
|
+
FounderPeerId: null,
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
/** A party-own `Strand` row as a {@link StrandRow}; the `Type` cast as {@link joinedStrandRow}'s. */
|
|
649
|
+
function strandRow(row) {
|
|
650
|
+
return {
|
|
651
|
+
Id: row.Id,
|
|
652
|
+
MemberPrivateKey: row.MemberPrivateKey,
|
|
653
|
+
Type: row.Type,
|
|
654
|
+
FounderOwnerKey: row.FounderOwnerKey,
|
|
655
|
+
FounderPeerId: row.FounderPeerId,
|
|
531
656
|
};
|
|
532
657
|
}
|
|
658
|
+
const STRAND_COLUMNS = 'Id, MemberPrivateKey, Type, FounderOwnerKey, FounderPeerId';
|
|
533
659
|
const FORMATION_INVITE_SELECT_SQL = 'select Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId from CadreControl.FormationInvite';
|
|
534
660
|
function formationInviteRowOf(row) {
|
|
535
661
|
return {
|
|
@@ -599,7 +725,7 @@ function joinRequestAddFields(row) {
|
|
|
599
725
|
* `JoinSuccess` / `JoinFailure` row and the signature its `AuthorizedInsert` verifies, over the
|
|
600
726
|
* row's columns in the schema's order (a null column signs as `''`, an integer as its text).
|
|
601
727
|
*/
|
|
602
|
-
function signJoinOutcomeInsert(partyId, requestStampId, outcome, ownerKey, signMessage) {
|
|
728
|
+
async function signJoinOutcomeInsert(partyId, requestStampId, outcome, ownerKey, signMessage) {
|
|
603
729
|
const at = String(outcome.RecordedAt);
|
|
604
730
|
const { table, sql, fields, values } = outcome.kind === 'joined'
|
|
605
731
|
? {
|
|
@@ -612,9 +738,28 @@ function signJoinOutcomeInsert(partyId, requestStampId, outcome, ownerKey, signM
|
|
|
612
738
|
fields: [requestStampId, at, outcome.Code, outcome.Reason],
|
|
613
739
|
values: [requestStampId, outcome.RecordedAt, outcome.Code, outcome.Reason],
|
|
614
740
|
};
|
|
615
|
-
const signature = signMessage(buildAuthorizationMessage(`CadreControl.${table}`, 'add', partyId, fields));
|
|
741
|
+
const signature = await signMessage(buildAuthorizationMessage(`CadreControl.${table}`, 'add', partyId, fields));
|
|
616
742
|
return { sql, params: [ownerKey, signature, ...values] };
|
|
617
743
|
}
|
|
744
|
+
/** The text of a {@link ControlDatabaseConfig.schemaPath} file; Node.js only. */
|
|
745
|
+
async function readSchemaFile(schemaPath) {
|
|
746
|
+
if (typeof process === 'undefined' || !process.versions?.node) {
|
|
747
|
+
throw new Error('Loading schema from file is not supported in React Native. ' +
|
|
748
|
+
'Remove the schemaPath option to use the embedded schema instead.');
|
|
749
|
+
}
|
|
750
|
+
try {
|
|
751
|
+
// Use require to conditionally load fs only in Node.js.
|
|
752
|
+
// This won't be bundled by React Native's Metro bundler — a dynamic
|
|
753
|
+
// import() would be statically picked up by Metro, so require is
|
|
754
|
+
// intentional here (cross-platform constraint, not lazy CommonJS).
|
|
755
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
756
|
+
const fs = require('fs/promises');
|
|
757
|
+
return await fs.readFile(schemaPath, 'utf-8');
|
|
758
|
+
}
|
|
759
|
+
catch (error) {
|
|
760
|
+
throw new Error(`Failed to load schema from ${schemaPath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
761
|
+
}
|
|
762
|
+
}
|
|
618
763
|
/**
|
|
619
764
|
* ControlDatabase manages the CadreControl schema using Quereus with Optimystic backend.
|
|
620
765
|
* It provides typed query methods for accessing control network data.
|
|
@@ -741,44 +886,35 @@ export class ControlDatabase {
|
|
|
741
886
|
log('ControlDatabase initialized successfully');
|
|
742
887
|
}
|
|
743
888
|
async loadSchema() {
|
|
744
|
-
let
|
|
889
|
+
let declaration;
|
|
745
890
|
if (this.config.schemaPath) {
|
|
746
|
-
// Load from file if explicitly provided (for testing or custom schemas)
|
|
747
|
-
// This only works in Node.js environments
|
|
748
891
|
log('Loading schema from file: %s', this.config.schemaPath);
|
|
749
|
-
|
|
750
|
-
if (typeof process !== 'undefined' && process.versions?.node) {
|
|
751
|
-
try {
|
|
752
|
-
// Use require to conditionally load fs only in Node.js.
|
|
753
|
-
// This won't be bundled by React Native's Metro bundler — a dynamic
|
|
754
|
-
// import() would be statically picked up by Metro, so require is
|
|
755
|
-
// intentional here (cross-platform constraint, not lazy CommonJS).
|
|
756
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
757
|
-
const fs = require('fs/promises');
|
|
758
|
-
schemaContent = await fs.readFile(this.config.schemaPath, 'utf-8');
|
|
759
|
-
}
|
|
760
|
-
catch (error) {
|
|
761
|
-
throw new Error(`Failed to load schema from ${this.config.schemaPath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
else {
|
|
765
|
-
throw new Error('Loading schema from file is not supported in React Native. ' +
|
|
766
|
-
'Remove the schemaPath option to use the embedded schema instead.');
|
|
767
|
-
}
|
|
892
|
+
declaration = controlSchemaFileDeclaration(await readSchemaFile(this.config.schemaPath));
|
|
768
893
|
}
|
|
769
894
|
else {
|
|
770
|
-
// Use embedded schema for cross-platform compatibility
|
|
771
895
|
log('Using embedded control schema');
|
|
772
|
-
|
|
896
|
+
declaration = CONTROL_SCHEMA_DECLARATION;
|
|
773
897
|
}
|
|
898
|
+
// Declaring runs no DDL; it only records the declaration the check and the apply both read.
|
|
899
|
+
await this.db.exec(declaration);
|
|
900
|
+
// A warm store this build cannot apply to without losing data is refused HERE, before the
|
|
901
|
+
// apply runs anything: the optimystic module refuses most changes to an existing table
|
|
902
|
+
// part-way through an apply, and a dropped table takes its rows. Migrating a store in place
|
|
903
|
+
// (control-store-migrates-on-start) widens this check to let catalog-only changes through.
|
|
904
|
+
await refuseIncompatibleControlStore(this.db, this.config.partyId);
|
|
905
|
+
await this.applyControlSchema();
|
|
906
|
+
log('Schema loaded and executed');
|
|
907
|
+
}
|
|
908
|
+
/** Apply the declared control schema, retrying transient cohort failures. */
|
|
909
|
+
async applyControlSchema() {
|
|
774
910
|
// Schema init is a DISTRIBUTED write, so it gets the same bounded transient-failure
|
|
775
911
|
// retry every other control write does ({@link lockedWithRetry}). Every CadreControl
|
|
776
|
-
// table is optimystic-backed (the default vtab
|
|
912
|
+
// table is optimystic-backed (`initialize` routes the default vtab to optimystic),
|
|
777
913
|
// so each `create table` needs a super-majority of the party's peers to answer. On a
|
|
778
914
|
// node joining a party that already has two live members a single unanswered peer
|
|
779
915
|
// during any one create otherwise kills startup outright.
|
|
780
916
|
//
|
|
781
|
-
// Re-running the
|
|
917
|
+
// Re-running the apply after a partial failure is safe, and this is the
|
|
782
918
|
// non-obvious part:
|
|
783
919
|
//
|
|
784
920
|
// - `apply schema` is a DIFF, not a replay. Quereus collects the live catalog, diffs
|
|
@@ -786,11 +922,6 @@ export class ControlDatabase {
|
|
|
786
922
|
// exactly why `initialize` hydrates persisted optimystic schemas BEFORE getting here.
|
|
787
923
|
// A table the live catalog already lists generates no statements at all, which is what
|
|
788
924
|
// makes an apply over an already-complete schema (a warm start) a no-op.
|
|
789
|
-
// NOTE: a table the catalog lists with DIFFERENT constraint text diffs to
|
|
790
|
-
// `alter table ... drop constraint`, which the optimystic module refuses, so a store
|
|
791
|
-
// written by a build with older constraint text fails here on every start (policy:
|
|
792
|
-
// recreate, docs/architecture.md after the Control Network table). If backwards
|
|
793
|
-
// compatibility becomes a requirement, this apply is the migration site.
|
|
794
925
|
// - a failed apply is taken back WHOLE (Quereus 4.20.0). The migration loop keeps an
|
|
795
926
|
// undo journal; when a step fails it runs that journal in reverse, re-renders the
|
|
796
927
|
// catalog, compares it against a fingerprint taken BEFORE the apply, and only then
|
|
@@ -823,8 +954,7 @@ export class ControlDatabase {
|
|
|
823
954
|
// also what lets schema init absorb optimystic's self-coordination grace refusal, which
|
|
824
955
|
// is unsafe for control writes in general. Why, on
|
|
825
956
|
// `RETRIABLE_SCHEMA_INIT_MATCHERS` in `control-write-retry.ts`.
|
|
826
|
-
await this.lockedWithRetry(() => this.db.exec(
|
|
827
|
-
log('Schema loaded and executed');
|
|
957
|
+
await this.lockedWithRetry(() => this.db.exec(APPLY_CONTROL_SCHEMA), SCHEMA_INIT_RETRY_POLICY, 'schema-init');
|
|
828
958
|
}
|
|
829
959
|
/**
|
|
830
960
|
* One UNRETRIED drain of a control read to rows — every read in this class bottoms out
|
|
@@ -994,16 +1124,8 @@ export class ControlDatabase {
|
|
|
994
1124
|
*/
|
|
995
1125
|
async queryStrands() {
|
|
996
1126
|
this.ensureInitialized();
|
|
997
|
-
const
|
|
998
|
-
|
|
999
|
-
results.push({
|
|
1000
|
-
Id: row.Id,
|
|
1001
|
-
MemberPrivateKey: row.MemberPrivateKey,
|
|
1002
|
-
Type: row.Type,
|
|
1003
|
-
FounderOwnerKey: row.FounderOwnerKey,
|
|
1004
|
-
});
|
|
1005
|
-
}
|
|
1006
|
-
return results;
|
|
1127
|
+
const rows = await this.readRows(`select ${STRAND_COLUMNS} from CadreControl.Strand`, undefined, 'strands');
|
|
1128
|
+
return rows.map(strandRow);
|
|
1007
1129
|
}
|
|
1008
1130
|
/**
|
|
1009
1131
|
* Read a single strand row by id, or null when absent. Single-row sibling of
|
|
@@ -1013,20 +1135,13 @@ export class ControlDatabase {
|
|
|
1013
1135
|
*/
|
|
1014
1136
|
async queryStrand(strandId) {
|
|
1015
1137
|
this.ensureInitialized();
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
Id: row.Id,
|
|
1019
|
-
MemberPrivateKey: row.MemberPrivateKey,
|
|
1020
|
-
Type: row.Type,
|
|
1021
|
-
FounderOwnerKey: row.FounderOwnerKey,
|
|
1022
|
-
};
|
|
1023
|
-
}
|
|
1024
|
-
return null;
|
|
1138
|
+
const rows = await this.readRows(`select ${STRAND_COLUMNS} from CadreControl.Strand where Id = ?`, [strandId], 'strand');
|
|
1139
|
+
return rows.length === 0 ? null : strandRow(rows[0]);
|
|
1025
1140
|
}
|
|
1026
1141
|
/**
|
|
1027
1142
|
* Every strand this party joined from another party (`CadreControl.JoinedStrand`), shaped
|
|
1028
1143
|
* as a {@link StrandRow} so a reader can treat it like one of the party's own. A joiner
|
|
1029
|
-
* never founds, so `FounderOwnerKey`
|
|
1144
|
+
* never founds, so `FounderOwnerKey` and `FounderPeerId` are always null. Read raw, like {@link queryStrands}:
|
|
1030
1145
|
* no retired-stamp filter.
|
|
1031
1146
|
*/
|
|
1032
1147
|
async queryJoinedStrands() {
|
|
@@ -1113,15 +1228,17 @@ export class ControlDatabase {
|
|
|
1113
1228
|
* Idempotent genesis: insert `key` as the founding owner key only when
|
|
1114
1229
|
* the party has none yet. Returns true if it inserted, false if an owner
|
|
1115
1230
|
* key already existed (so a repeat `--owner` start is a no-op).
|
|
1231
|
+
*
|
|
1232
|
+
* @param label - the user's name for the key ({@link insertOwnerKey}).
|
|
1116
1233
|
*/
|
|
1117
|
-
async ensureOwnerKey(key) {
|
|
1234
|
+
async ensureOwnerKey(key, label) {
|
|
1118
1235
|
this.ensureInitialized();
|
|
1119
1236
|
const trimmed = requireEd25519PublicKeyB64(key, 'owner key');
|
|
1120
1237
|
if (await this.hasOwnerKey()) {
|
|
1121
1238
|
log('Owner key already present; skipping genesis insert');
|
|
1122
1239
|
return false;
|
|
1123
1240
|
}
|
|
1124
|
-
await this.insertOwnerKey(trimmed);
|
|
1241
|
+
await this.insertOwnerKey(trimmed, label);
|
|
1125
1242
|
return true;
|
|
1126
1243
|
}
|
|
1127
1244
|
/**
|
|
@@ -1150,8 +1267,9 @@ export class ControlDatabase {
|
|
|
1150
1267
|
return new Set((await this.queryOwnerKeyRows(retry)).map(row => row.key));
|
|
1151
1268
|
}
|
|
1152
1269
|
/**
|
|
1153
|
-
* Every live `OwnerKey` row with its persisted proof ({@link OwnerKeyRow}) — what
|
|
1154
|
-
* trusted-owner anchor sync derives from (`CadreNode.syncOwnerAnchor`)
|
|
1270
|
+
* Every live `OwnerKey` row with its label and persisted proof ({@link OwnerKeyRow}) — what
|
|
1271
|
+
* the trusted-owner anchor sync derives from (`CadreNode.syncOwnerAnchor`) and the owner-key
|
|
1272
|
+
* listing shows (`CadreNode.listOwnerKeys`). Rows whose
|
|
1155
1273
|
* `StampId` is retired in `CadreControl.Revocation` are excluded here, as
|
|
1156
1274
|
* {@link queryCadrePeers} excludes them and for the reason on {@link getOwnerKeys}, which
|
|
1157
1275
|
* reads through this. A row with no stamp (the schema permits it; no writer produces one)
|
|
@@ -1171,7 +1289,7 @@ export class ControlDatabase {
|
|
|
1171
1289
|
this.ensureInitialized();
|
|
1172
1290
|
const revoked = retired ?? await this.queryRevokedStamps('OwnerKey', retry);
|
|
1173
1291
|
const rows = [];
|
|
1174
|
-
const sql = 'select Key, StampId, VouchOwner, VouchSig, VouchUsage from CadreControl.OwnerKey';
|
|
1292
|
+
const sql = 'select Key, Label, StampId, VouchOwner, VouchSig, VouchUsage from CadreControl.OwnerKey';
|
|
1175
1293
|
for (const row of await this.readRows(sql, undefined, 'owner-keys', retry)) {
|
|
1176
1294
|
const stampId = row.StampId ?? null;
|
|
1177
1295
|
if (stampId === null) {
|
|
@@ -1183,6 +1301,7 @@ export class ControlDatabase {
|
|
|
1183
1301
|
}
|
|
1184
1302
|
rows.push({
|
|
1185
1303
|
key: row.Key,
|
|
1304
|
+
label: row.Label ?? null,
|
|
1186
1305
|
stampId,
|
|
1187
1306
|
vouchOwner: row.VouchOwner ?? null,
|
|
1188
1307
|
vouchSig: row.VouchSig ?? null,
|
|
@@ -1257,16 +1376,16 @@ export class ControlDatabase {
|
|
|
1257
1376
|
}
|
|
1258
1377
|
/**
|
|
1259
1378
|
* The cadre's peers as a seed or a redemption reply carries them: every live `CadrePeer`
|
|
1260
|
-
* row with its addresses,
|
|
1261
|
-
*
|
|
1379
|
+
* row with its addresses, the minter and every always-on machine marked preferred
|
|
1380
|
+
* ({@link projectSeedPeers}).
|
|
1262
1381
|
*
|
|
1263
1382
|
* Read through {@link queryCadrePeers}, never a raw `CadrePeer` select, so a removed
|
|
1264
1383
|
* member's addresses are never packed into a seed or handed to a redeeming device as a
|
|
1265
1384
|
* dial hint: a revoked peer is off the addressable surface everywhere, and this is one of
|
|
1266
1385
|
* its exits.
|
|
1267
1386
|
*/
|
|
1268
|
-
async querySeedPeers() {
|
|
1269
|
-
return projectSeedPeers(await this.queryCadrePeers(),
|
|
1387
|
+
async querySeedPeers(minterPeerId) {
|
|
1388
|
+
return projectSeedPeers(await this.queryCadrePeers(), minterPeerId);
|
|
1270
1389
|
}
|
|
1271
1390
|
/**
|
|
1272
1391
|
* Every approver public key currently enrolled in `CadreControl.ValidationKey` — the
|
|
@@ -1603,9 +1722,13 @@ export class ControlDatabase {
|
|
|
1603
1722
|
}
|
|
1604
1723
|
/**
|
|
1605
1724
|
* Insert the initial owner key (bootstrap - no existing owners required)
|
|
1725
|
+
*
|
|
1726
|
+
* @param label - the user's name for the key, at most {@link OWNER_KEY_MAX_LABEL}
|
|
1727
|
+
* characters once trimmed; blank is stored as none.
|
|
1606
1728
|
*/
|
|
1607
|
-
async insertOwnerKey(key) {
|
|
1729
|
+
async insertOwnerKey(key, label) {
|
|
1608
1730
|
this.ensureInitialized();
|
|
1731
|
+
const storedLabel = ownerKeyLabel(label);
|
|
1609
1732
|
log('Inserting owner key: %s', key);
|
|
1610
1733
|
// Bootstrap is authorized by the schema's genesis branch — `(select count(1) from
|
|
1611
1734
|
// committed.OwnerKey) = 0`, i.e. the party had no owner before this transaction — so no
|
|
@@ -1617,60 +1740,81 @@ export class ControlDatabase {
|
|
|
1617
1740
|
// optimystic `StampId()` SQL function.
|
|
1618
1741
|
const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
|
|
1619
1742
|
await this.execWrite(`
|
|
1620
|
-
insert into CadreControl.OwnerKey (Key, StampId)
|
|
1743
|
+
insert into CadreControl.OwnerKey (Key, Label, StampId)
|
|
1621
1744
|
with context OwnerKey = null, Signature = null
|
|
1622
|
-
values (?, ?)
|
|
1623
|
-
`, [key, stampId], 'owner-key-insert');
|
|
1745
|
+
values (?, ?, ?)
|
|
1746
|
+
`, [key, storedLabel, stampId], 'owner-key-insert');
|
|
1624
1747
|
log('Owner key inserted');
|
|
1625
1748
|
}
|
|
1626
1749
|
/**
|
|
1627
1750
|
* Owner-signed INSERT of a further `OwnerKey` row, notifying the membership listener once
|
|
1628
1751
|
* it has committed. The signed-add branch of `OwnerKey.Authorized`: the signer must be an
|
|
1629
1752
|
* owner in the pre-transaction snapshot, its signature covers the `'add'` digest over
|
|
1630
|
-
* (Key, StampId), and the stored `VouchOwner`/`VouchSig` are that same pair, so a
|
|
1631
|
-
* can later judge the add against its own anchor (`verifyOwnerKeyVoucher`).
|
|
1753
|
+
* (Key, Label, StampId), and the stored `VouchOwner`/`VouchSig` are that same pair, so a
|
|
1754
|
+
* reader can later judge the add against its own anchor (`verifyOwnerKeyVoucher`).
|
|
1632
1755
|
*
|
|
1633
1756
|
* Through {@link mutateCadrePeer}, the membership hub. The liveness read, the stamp and the
|
|
1634
1757
|
* signature all sit inside the locked body: a {@link lockedWithRetry} re-run starts over a
|
|
1635
1758
|
* rolled-back attempt with a fresh stamp, as {@link redeemCadreInvite} does.
|
|
1636
1759
|
*
|
|
1637
|
-
*
|
|
1638
|
-
* is refused here by name: `OwnerKey` is not reaped, so a machine that received the
|
|
1639
|
-
* tombstone without the delete (see {@link getOwnerKeys}) keeps the row, and an insert there
|
|
1640
|
-
* would collide on the primary key. Re-add from the machine that removed the key, or any
|
|
1641
|
-
* machine the delete reached; if re-adding removed owners becomes routine, the fix is an
|
|
1642
|
-
* `OwnerKey` reap branch, gated so it can never take the table below `MinOneOwner`.
|
|
1643
|
-
*
|
|
1760
|
+
* @param label - the user's name for the key, as {@link insertOwnerKey}'s.
|
|
1644
1761
|
* @returns `true` when this call seated the row, `false` when `key` is already a live owner
|
|
1645
|
-
* here (nothing written).
|
|
1646
|
-
* @throws when this node holds `key`'s row under a retired stamp
|
|
1647
|
-
* schema refuses the signer.
|
|
1762
|
+
* here (nothing written, whatever its label).
|
|
1763
|
+
* @throws when this node holds `key`'s row under a retired stamp
|
|
1764
|
+
* ({@link liveOwnerKeyStamp}), or when the schema refuses the signer.
|
|
1648
1765
|
*/
|
|
1649
|
-
async insertOwnerKeyVouched(key, ownerKey, signMessage) {
|
|
1766
|
+
async insertOwnerKeyVouched(key, ownerKey, signMessage, label) {
|
|
1650
1767
|
this.ensureInitialized();
|
|
1768
|
+
const storedLabel = ownerKeyLabel(label);
|
|
1651
1769
|
return await this.mutateCadrePeer('owner-key-add', async () => {
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
if (!(await this.queryRevokedStamps('OwnerKey', false)).has(heldStamp)) {
|
|
1656
|
-
log('insertOwnerKeyVouched: %s is already an owner; nothing written', key);
|
|
1657
|
-
return false;
|
|
1658
|
-
}
|
|
1659
|
-
throw new Error(`Cannot re-add owner key ${key} here: this node still holds the row it was removed under ` +
|
|
1660
|
-
'(OwnerKey rows are not reaped); re-add it from the machine that removed it');
|
|
1770
|
+
if (await this.liveOwnerKeyStamp(key) !== null) {
|
|
1771
|
+
log('insertOwnerKeyVouched: %s is already an owner; nothing written', key);
|
|
1772
|
+
return false;
|
|
1661
1773
|
}
|
|
1662
1774
|
const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
|
|
1663
|
-
const signature = signMessage(ownerKeyAddMessage(this.config.partyId, key, stampId));
|
|
1664
|
-
|
|
1665
|
-
await this.db.exec(`
|
|
1666
|
-
insert into CadreControl.OwnerKey (Key, StampId, VouchOwner, VouchSig)
|
|
1667
|
-
with context OwnerKey = ?, Signature = ?
|
|
1668
|
-
values (?, ?, ?, ?)
|
|
1669
|
-
`, [ownerKey, signature, key, stampId, ownerKey, signature]);
|
|
1775
|
+
const signature = await signMessage(ownerKeyAddMessage(this.config.partyId, key, storedLabel, stampId));
|
|
1776
|
+
await this.execOwnerKeyVouchedInsert({ key, label: storedLabel, stampId }, ownerKey, signature);
|
|
1670
1777
|
log('Owner key %s added (vouched by %s)', key, ownerKey);
|
|
1671
1778
|
return true;
|
|
1672
1779
|
});
|
|
1673
1780
|
}
|
|
1781
|
+
/**
|
|
1782
|
+
* The stamp of `key`'s live `OwnerKey` row here, or null when this node holds none: the
|
|
1783
|
+
* insert-if-absent read of {@link insertOwnerKeyVouched} and {@link redeemOwnerGrant}. Inside
|
|
1784
|
+
* the locked body (both reads `retry: false`; see queryStampId's NOTE).
|
|
1785
|
+
*
|
|
1786
|
+
* NOTE: re-adding a removed owner from a machine that still holds its retired physical row
|
|
1787
|
+
* is refused here by name: `OwnerKey` is not reaped, so a machine that received the
|
|
1788
|
+
* tombstone without the delete (see {@link getOwnerKeys}) keeps the row, and an insert there
|
|
1789
|
+
* would collide on the primary key. Re-add from the machine that removed the key, or any
|
|
1790
|
+
* machine the delete reached; if re-adding removed owners becomes routine, the fix is an
|
|
1791
|
+
* `OwnerKey` reap branch, gated so it can never take the table below `MinOneOwner`.
|
|
1792
|
+
*
|
|
1793
|
+
* @throws when this node holds `key`'s row under a retired stamp (above).
|
|
1794
|
+
*/
|
|
1795
|
+
async liveOwnerKeyStamp(key) {
|
|
1796
|
+
const heldStamp = await this.queryStampId('OwnerKey', key, false);
|
|
1797
|
+
if (heldStamp === null) {
|
|
1798
|
+
return null;
|
|
1799
|
+
}
|
|
1800
|
+
if ((await this.queryRevokedStamps('OwnerKey', false)).has(heldStamp)) {
|
|
1801
|
+
throw new Error(`Cannot re-add owner key ${key} here: this node still holds the row it was removed under ` +
|
|
1802
|
+
'(OwnerKey rows are not reaped); re-add it from the machine that removed it');
|
|
1803
|
+
}
|
|
1804
|
+
return heldStamp;
|
|
1805
|
+
}
|
|
1806
|
+
/**
|
|
1807
|
+
* The signed-add statement of {@link insertOwnerKeyVouched} and {@link revouchOwnerKey}, the
|
|
1808
|
+
* stored voucher being the signing pair. Bare `exec`: callers hold the write lock, which is
|
|
1809
|
+
* NOT re-entrant.
|
|
1810
|
+
*/
|
|
1811
|
+
async execOwnerKeyVouchedInsert(row, ownerKey, signature) {
|
|
1812
|
+
await this.db.exec(`
|
|
1813
|
+
insert into CadreControl.OwnerKey (Key, Label, StampId, VouchOwner, VouchSig)
|
|
1814
|
+
with context OwnerKey = ?, Signature = ?
|
|
1815
|
+
values (?, ?, ?, ?, ?)
|
|
1816
|
+
`, [ownerKey, signature, row.key, row.label, row.stampId, ownerKey, signature]);
|
|
1817
|
+
}
|
|
1674
1818
|
/**
|
|
1675
1819
|
* Owner-signed removal of one `OwnerKey` row plus the `Revocation` tombstone retiring its
|
|
1676
1820
|
* stamp, notifying the membership listener once it has committed. The remove branch of
|
|
@@ -1708,14 +1852,14 @@ export class ControlDatabase {
|
|
|
1708
1852
|
/**
|
|
1709
1853
|
* Re-vouch a live `OwnerKey` row under this owner's signature: in ONE transaction, the
|
|
1710
1854
|
* current row's guarded delete plus tombstone ({@link deleteOwnerKey}'s body) and an insert
|
|
1711
|
-
* of the same key under a fresh stamp, signed by `ownerKey`
|
|
1712
|
-
* body), as {@link rewritePendingJoin} re-seats a request. `OwnerKey` has `NoUpdate`, so
|
|
1855
|
+
* of the same key and label under a fresh stamp, signed by `ownerKey`
|
|
1856
|
+
* ({@link insertOwnerKeyVouched}'s body), as {@link rewritePendingJoin} re-seats a request. `OwnerKey` has `NoUpdate`, so
|
|
1713
1857
|
* this is the only way to rebind a row's voucher; the anchor derivation lets the re-add
|
|
1714
1858
|
* under a fresh stamp beat the older tombstone (`deriveOwnerAnchor`). For
|
|
1715
|
-
*
|
|
1716
|
-
*
|
|
1859
|
+
* `CadreNode.removeOwner`: the owners a key being removed had added keep their anchor place
|
|
1860
|
+
* everywhere once their row stands on a remaining owner's signature.
|
|
1717
1861
|
*
|
|
1718
|
-
* Through {@link mutateCadrePeer}, the membership hub. The
|
|
1862
|
+
* Through {@link mutateCadrePeer}, the membership hub. The row read runs inside the locked
|
|
1719
1863
|
* body; a row whose stamp is already retired (held without its delete) is reported as absent
|
|
1720
1864
|
* rather than tombstoned twice.
|
|
1721
1865
|
*
|
|
@@ -1727,59 +1871,48 @@ export class ControlDatabase {
|
|
|
1727
1871
|
throw new Error(`Cannot re-vouch owner key ${key}: an owner cannot re-vouch itself`);
|
|
1728
1872
|
}
|
|
1729
1873
|
return await this.mutateCadrePeer('owner-key-revouch', async () => {
|
|
1730
|
-
// retry: false
|
|
1731
|
-
const
|
|
1732
|
-
if (
|
|
1874
|
+
// retry: false — inside the locked write body (see queryStampId's NOTE).
|
|
1875
|
+
const live = (await this.queryOwnerKeyRows(false)).find((row) => row.key === key);
|
|
1876
|
+
if (live === undefined) {
|
|
1733
1877
|
log('revouchOwnerKey: %s is not a live owner here (nothing to re-vouch)', key);
|
|
1734
1878
|
return false;
|
|
1735
1879
|
}
|
|
1736
|
-
const removal = signGuardedRemoval(this.config.partyId, 'OwnerKey', key, stampId, signMessage);
|
|
1737
|
-
const
|
|
1738
|
-
const signature = signMessage(ownerKeyAddMessage(this.config.partyId, key,
|
|
1880
|
+
const removal = await signGuardedRemoval(this.config.partyId, 'OwnerKey', key, live.stampId, signMessage);
|
|
1881
|
+
const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
|
|
1882
|
+
const signature = await signMessage(ownerKeyAddMessage(this.config.partyId, key, live.label, stampId));
|
|
1739
1883
|
await this.inTransaction('revouch OwnerKey', async () => {
|
|
1740
1884
|
await this.execGuardedRemoval(removal, ownerKey);
|
|
1741
|
-
await this.
|
|
1742
|
-
insert into CadreControl.OwnerKey (Key, StampId, VouchOwner, VouchSig)
|
|
1743
|
-
with context OwnerKey = ?, Signature = ?
|
|
1744
|
-
values (?, ?, ?, ?)
|
|
1745
|
-
`, [ownerKey, signature, key, freshStampId, ownerKey, signature]);
|
|
1885
|
+
await this.execOwnerKeyVouchedInsert({ key, label: live.label, stampId }, ownerKey, signature);
|
|
1746
1886
|
});
|
|
1747
|
-
log('Owner key %s re-vouched by %s (stamp %s retired)', key, ownerKey, stampId);
|
|
1887
|
+
log('Owner key %s re-vouched by %s (stamp %s retired)', key, ownerKey, live.stampId);
|
|
1748
1888
|
this.notifyGuardedDelete(removal.ref);
|
|
1749
1889
|
return true;
|
|
1750
1890
|
});
|
|
1751
1891
|
}
|
|
1752
1892
|
/**
|
|
1753
|
-
* Remove one machine completely, in ONE transaction: its `CadrePeer` row
|
|
1754
|
-
*
|
|
1755
|
-
*
|
|
1756
|
-
*
|
|
1757
|
-
*
|
|
1758
|
-
*
|
|
1759
|
-
*
|
|
1760
|
-
*
|
|
1893
|
+
* Remove one machine completely, in ONE transaction: its `CadrePeer` row and its
|
|
1894
|
+
* `DeviceToken` row when it has one, each with the `Revocation` tombstone retiring its stamp
|
|
1895
|
+
* ({@link deleteGuardedRow}'s statement pair, composed bare as {@link deleteStrandAndPartyKey}
|
|
1896
|
+
* composes it), plus a withdrawal tombstone for every `CadreInvite` in `target.inviteKeys`
|
|
1897
|
+
* that is still live here ({@link withdrawCadreInvite}'s body). One transaction so no reader
|
|
1898
|
+
* sees the machine half removed: a row gone but its push token resolvable, or an invitation
|
|
1899
|
+
* it could redeem again still open. Owner keys are never touched: they are the person's, and
|
|
1900
|
+
* removing one is {@link deleteOwnerKey}.
|
|
1761
1901
|
*
|
|
1762
1902
|
* Through {@link mutateCadrePeer}, the membership hub. Every stamp is read inside the locked
|
|
1763
1903
|
* body, and a row whose stamp is already retired (held without its delete) is skipped rather
|
|
1764
|
-
* than tombstoned twice.
|
|
1765
|
-
* owner-key arm. Nothing to remove writes nothing and returns all false.
|
|
1766
|
-
*
|
|
1767
|
-
* The caller decides WHAT to remove, which owner key is the machine's and which invitations
|
|
1768
|
-
* it could use, from its plan (`CadreNode.planMachineRemoval`); this method only refuses to
|
|
1769
|
-
* write an inconsistent set.
|
|
1904
|
+
* than tombstoned twice. Nothing to remove writes nothing and returns all false.
|
|
1770
1905
|
*
|
|
1771
|
-
*
|
|
1906
|
+
* The caller decides which invitations the machine could use, from its plan
|
|
1907
|
+
* (`CadreNode.planMachineRemoval`).
|
|
1772
1908
|
*/
|
|
1773
1909
|
async removeMachine(target, ownerKey, signMessage) {
|
|
1774
|
-
if (target.ownerKey === ownerKey) {
|
|
1775
|
-
throw new Error(`Cannot remove owner key ${ownerKey}: an owner cannot remove itself`);
|
|
1776
|
-
}
|
|
1777
1910
|
return await this.mutateCadrePeer('machine-remove', async () => {
|
|
1778
|
-
const removals = await this.signMachineRemovals(target, signMessage);
|
|
1779
|
-
const withdrawals = await this.
|
|
1911
|
+
const removals = await this.signMachineRemovals(target.peerId, signMessage);
|
|
1912
|
+
const withdrawals = await this.signWithdrawals('CadreInvite', target.inviteKeys, signMessage);
|
|
1780
1913
|
if (removals.length === 0 && withdrawals.length === 0) {
|
|
1781
1914
|
log('removeMachine: nothing live names %s here (already removed)', target.peerId);
|
|
1782
|
-
return { removedPeer: false,
|
|
1915
|
+
return { removedPeer: false, removedDeviceToken: false, withdrawnInvitations: [] };
|
|
1783
1916
|
}
|
|
1784
1917
|
await this.inTransaction('remove machine', async () => {
|
|
1785
1918
|
for (const removal of removals) {
|
|
@@ -1799,7 +1932,6 @@ export class ControlDatabase {
|
|
|
1799
1932
|
}
|
|
1800
1933
|
return {
|
|
1801
1934
|
removedPeer: removed.has('CadrePeer'),
|
|
1802
|
-
removedOwnerKey: removed.has('OwnerKey'),
|
|
1803
1935
|
removedDeviceToken: removed.has('DeviceToken'),
|
|
1804
1936
|
withdrawnInvitations: withdrawals.map((withdrawal) => withdrawal.ref.rowKey),
|
|
1805
1937
|
};
|
|
@@ -1807,11 +1939,11 @@ export class ControlDatabase {
|
|
|
1807
1939
|
}
|
|
1808
1940
|
/**
|
|
1809
1941
|
* The guarded removals {@link removeMachine} runs, signed over each row's current stamp:
|
|
1810
|
-
* the `CadrePeer` row
|
|
1811
|
-
*
|
|
1812
|
-
*
|
|
1942
|
+
* the `CadrePeer` row and the `DeviceToken` row, each when present. Inside the locked body
|
|
1943
|
+
* (every read `retry: false`); one `Revocation` scan serves the retired-stamp check for both
|
|
1944
|
+
* tables.
|
|
1813
1945
|
*/
|
|
1814
|
-
async signMachineRemovals(
|
|
1946
|
+
async signMachineRemovals(peerId, signMessage) {
|
|
1815
1947
|
const retiredByTable = new Map();
|
|
1816
1948
|
for (const tombstone of await this.queryRevocations(false)) {
|
|
1817
1949
|
const stamps = retiredByTable.get(tombstone.tableName) ?? new Set();
|
|
@@ -1824,24 +1956,12 @@ export class ControlDatabase {
|
|
|
1824
1956
|
return stampId !== null && !retired(table).has(stampId) ? stampId : null;
|
|
1825
1957
|
};
|
|
1826
1958
|
const removals = [];
|
|
1827
|
-
const
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
if (target.ownerKey !== null) {
|
|
1832
|
-
const ownerStamp = await liveStamp('OwnerKey', target.ownerKey);
|
|
1833
|
-
if (ownerStamp !== null) {
|
|
1834
|
-
const owners = await this.queryOwnerKeyRows(false, retired('OwnerKey'));
|
|
1835
|
-
if (owners.length <= 1) {
|
|
1836
|
-
throw new Error(`Cannot remove owner key ${target.ownerKey}: cannot remove the last owner`);
|
|
1837
|
-
}
|
|
1838
|
-
removals.push(signGuardedRemoval(this.config.partyId, 'OwnerKey', target.ownerKey, ownerStamp, signMessage));
|
|
1959
|
+
for (const table of ['CadrePeer', 'DeviceToken']) {
|
|
1960
|
+
const stampId = await liveStamp(table, peerId);
|
|
1961
|
+
if (stampId !== null) {
|
|
1962
|
+
removals.push(await signGuardedRemoval(this.config.partyId, table, peerId, stampId, signMessage));
|
|
1839
1963
|
}
|
|
1840
1964
|
}
|
|
1841
|
-
const tokenStamp = await liveStamp('DeviceToken', target.peerId);
|
|
1842
|
-
if (tokenStamp !== null) {
|
|
1843
|
-
removals.push(signGuardedRemoval(this.config.partyId, 'DeviceToken', target.peerId, tokenStamp, signMessage));
|
|
1844
|
-
}
|
|
1845
1965
|
return removals;
|
|
1846
1966
|
}
|
|
1847
1967
|
/**
|
|
@@ -1870,18 +1990,18 @@ export class ControlDatabase {
|
|
|
1870
1990
|
const peerId = this.config.libp2pNode.peerId.toString();
|
|
1871
1991
|
const stampId = generateStampId(peerId);
|
|
1872
1992
|
// Field order MUST match the schema's Strand `AuthorizedInsert` verify:
|
|
1873
|
-
// Id, Type, MemberPrivateKey ('' when null), StampId. FounderOwnerKey is
|
|
1993
|
+
// Id, Type, MemberPrivateKey ('' when null), FounderPeerId, StampId. FounderOwnerKey is
|
|
1874
1994
|
// deliberately NOT in the digest — the schema binds it by equality to the
|
|
1875
1995
|
// verified context.OwnerKey instead (see the constraint's comment).
|
|
1876
|
-
const message = buildAuthorizationMessage('CadreControl.Strand', 'add', this.config.partyId, [strandId, type, memberPrivateKey ?? '', stampId]);
|
|
1877
|
-
const signature = signMessage(message);
|
|
1996
|
+
const message = buildAuthorizationMessage('CadreControl.Strand', 'add', this.config.partyId, [strandId, type, memberPrivateKey ?? '', peerId, stampId]);
|
|
1997
|
+
const signature = await signMessage(message);
|
|
1878
1998
|
// StampId is a real, unique column (single-use anti-replay), no longer a context value.
|
|
1879
1999
|
// FounderOwnerKey must equal the signing owner or the constraint rejects the row.
|
|
1880
2000
|
await this.execWrite(`
|
|
1881
|
-
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey)
|
|
2001
|
+
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey, FounderPeerId)
|
|
1882
2002
|
with context OwnerKey = ?, Signature = ?
|
|
1883
|
-
values (?, ?, ?, ?, ?)
|
|
1884
|
-
`, [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId, ownerKey], 'strand-insert');
|
|
2003
|
+
values (?, ?, ?, ?, ?, ?)
|
|
2004
|
+
`, [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId, ownerKey, peerId], 'strand-insert');
|
|
1885
2005
|
log('Strand inserted: %s', strandId);
|
|
1886
2006
|
}
|
|
1887
2007
|
/**
|
|
@@ -1935,10 +2055,10 @@ export class ControlDatabase {
|
|
|
1935
2055
|
return false;
|
|
1936
2056
|
}
|
|
1937
2057
|
const partyKeyStamp = await this.queryStampId('StrandPartyKey', strandId, false);
|
|
1938
|
-
const strandRemoval = signGuardedRemoval(this.config.partyId, 'Strand', strandId, strandStamp, signMessage);
|
|
2058
|
+
const strandRemoval = await signGuardedRemoval(this.config.partyId, 'Strand', strandId, strandStamp, signMessage);
|
|
1939
2059
|
const partyKeyRemoval = partyKeyStamp === null
|
|
1940
2060
|
? null
|
|
1941
|
-
: signGuardedRemoval(this.config.partyId, 'StrandPartyKey', strandId, partyKeyStamp, signMessage);
|
|
2061
|
+
: await signGuardedRemoval(this.config.partyId, 'StrandPartyKey', strandId, partyKeyStamp, signMessage);
|
|
1942
2062
|
await this.inTransaction('delete Strand', async () => {
|
|
1943
2063
|
await this.execGuardedRemoval(strandRemoval, ownerKey);
|
|
1944
2064
|
if (partyKeyRemoval !== null) {
|
|
@@ -1976,7 +2096,7 @@ export class ControlDatabase {
|
|
|
1976
2096
|
// Field order MUST match the schema's StrandPartyKey `AuthorizedInsert` verify:
|
|
1977
2097
|
// Id, PrivateKey, StampId.
|
|
1978
2098
|
const message = buildAuthorizationMessage('CadreControl.StrandPartyKey', 'add', this.config.partyId, [strandId, privateKey, stampId]);
|
|
1979
|
-
const signature = signMessage(message);
|
|
2099
|
+
const signature = await signMessage(message);
|
|
1980
2100
|
await this.execWrite(`
|
|
1981
2101
|
insert into CadreControl.StrandPartyKey (Id, PrivateKey, StampId)
|
|
1982
2102
|
with context OwnerKey = ?, Signature = ?
|
|
@@ -2018,7 +2138,7 @@ export class ControlDatabase {
|
|
|
2018
2138
|
// Field order MUST match the schema's JoinedStrand `AuthorizedInsert` verify:
|
|
2019
2139
|
// Id, Type, MemberPrivateKey ('' when null), StampId.
|
|
2020
2140
|
const message = buildAuthorizationMessage('CadreControl.JoinedStrand', 'add', this.config.partyId, [row.Id, row.Type, row.MemberPrivateKey ?? '', stampId]);
|
|
2021
|
-
const signature = signMessage(message);
|
|
2141
|
+
const signature = await signMessage(message);
|
|
2022
2142
|
await this.execWrite(`
|
|
2023
2143
|
insert into CadreControl.JoinedStrand (Id, Type, MemberPrivateKey, StampId)
|
|
2024
2144
|
with context OwnerKey = ?, Signature = ?
|
|
@@ -2044,7 +2164,7 @@ export class ControlDatabase {
|
|
|
2044
2164
|
*/
|
|
2045
2165
|
async insertJoinRequest(request, ownerKey, signMessage) {
|
|
2046
2166
|
this.ensureInitialized();
|
|
2047
|
-
const { written, insert } = this.signJoinRequestInsert(request, ownerKey, signMessage);
|
|
2167
|
+
const { written, insert } = await this.signJoinRequestInsert(request, ownerKey, signMessage);
|
|
2048
2168
|
await this.execWrite(insert.sql, insert.params, 'join-request-insert');
|
|
2049
2169
|
log('Join request inserted: %s', request.Id);
|
|
2050
2170
|
return { ...written, outcome: null };
|
|
@@ -2064,7 +2184,7 @@ export class ControlDatabase {
|
|
|
2064
2184
|
*/
|
|
2065
2185
|
async recordJoinOutcome(expected, outcome, ownerKey, signMessage) {
|
|
2066
2186
|
this.ensureInitialized();
|
|
2067
|
-
const insert = signJoinOutcomeInsert(this.config.partyId, expected.StampId, outcome, ownerKey, signMessage);
|
|
2187
|
+
const insert = await signJoinOutcomeInsert(this.config.partyId, expected.StampId, outcome, ownerKey, signMessage);
|
|
2068
2188
|
return this.lockedWithRetry(async () => {
|
|
2069
2189
|
const live = await this.readPendingJoin(expected.Id, false);
|
|
2070
2190
|
if (live === null || live.StampId !== expected.StampId || live.outcome?.kind !== expected.outcome?.kind) {
|
|
@@ -2097,9 +2217,9 @@ export class ControlDatabase {
|
|
|
2097
2217
|
async rewritePendingJoin(expectedStampId, next, ownerKey, signMessage) {
|
|
2098
2218
|
this.ensureInitialized();
|
|
2099
2219
|
const { outcome, ...request } = next;
|
|
2100
|
-
const removal = signGuardedRemoval(this.config.partyId, 'JoinRequest', next.Id, expectedStampId, signMessage);
|
|
2101
|
-
const { written, insert } = this.signJoinRequestInsert(request, ownerKey, signMessage);
|
|
2102
|
-
const outcomeInsert = outcome === null ? null : signJoinOutcomeInsert(this.config.partyId, written.StampId, outcome, ownerKey, signMessage);
|
|
2220
|
+
const removal = await signGuardedRemoval(this.config.partyId, 'JoinRequest', next.Id, expectedStampId, signMessage);
|
|
2221
|
+
const { written, insert } = await this.signJoinRequestInsert(request, ownerKey, signMessage);
|
|
2222
|
+
const outcomeInsert = outcome === null ? null : await signJoinOutcomeInsert(this.config.partyId, written.StampId, outcome, ownerKey, signMessage);
|
|
2103
2223
|
return this.lockedWithRetry(async () => {
|
|
2104
2224
|
// retry: false — this runs inside the locked write body (see queryStampId's NOTE).
|
|
2105
2225
|
const liveStampId = await this.queryStampId('JoinRequest', next.Id, false);
|
|
@@ -2132,9 +2252,9 @@ export class ControlDatabase {
|
|
|
2132
2252
|
* Mint a fresh stamp for `request` and the owner signature `JoinRequest.AuthorizedInsert`
|
|
2133
2253
|
* verifies, returning the row as it will be written and its insert.
|
|
2134
2254
|
*/
|
|
2135
|
-
signJoinRequestInsert(request, ownerKey, signMessage) {
|
|
2255
|
+
async signJoinRequestInsert(request, ownerKey, signMessage) {
|
|
2136
2256
|
const written = { ...request, StampId: generateStampId(this.config.libp2pNode.peerId.toString()) };
|
|
2137
|
-
const signature = signMessage(buildAuthorizationMessage('CadreControl.JoinRequest', 'add', this.config.partyId, joinRequestAddFields(written)));
|
|
2257
|
+
const signature = await signMessage(buildAuthorizationMessage('CadreControl.JoinRequest', 'add', this.config.partyId, joinRequestAddFields(written)));
|
|
2138
2258
|
const params = [ownerKey, signature, written.Id, written.Invitation, written.Disclosure, written.RequestedAt, written.ExpiresAt, written.StampId];
|
|
2139
2259
|
return { written, insert: { sql: JOIN_REQUEST_INSERT_SQL, params } };
|
|
2140
2260
|
}
|
|
@@ -2175,7 +2295,7 @@ export class ControlDatabase {
|
|
|
2175
2295
|
const stampId = generateStampId(peerId);
|
|
2176
2296
|
// Field order MUST match the schema's ValidationKey `AuthorizedInsert` verify: Key, StampId.
|
|
2177
2297
|
const message = buildAuthorizationMessage('CadreControl.ValidationKey', 'add', this.config.partyId, [key, stampId]);
|
|
2178
|
-
const signature = signMessage(message);
|
|
2298
|
+
const signature = await signMessage(message);
|
|
2179
2299
|
await this.execWrite(`
|
|
2180
2300
|
insert into CadreControl.ValidationKey (Key, StampId)
|
|
2181
2301
|
with context OwnerKey = ?, Signature = ?
|
|
@@ -2244,7 +2364,7 @@ export class ControlDatabase {
|
|
|
2244
2364
|
// Stamp is minted from the ADMITTED peer's id, not this node's — the nonce names the
|
|
2245
2365
|
// row it vouches.
|
|
2246
2366
|
const stampId = generateStampId(row.peerId);
|
|
2247
|
-
const signature = signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [row.peerId, stampId]));
|
|
2367
|
+
const signature = await signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [row.peerId, stampId]));
|
|
2248
2368
|
return await this.mutateCadrePeer('peer-insert', async () => {
|
|
2249
2369
|
// retry: false — this guard runs inside the locked write body (see queryStampId's NOTE).
|
|
2250
2370
|
if (await this.queryStampId('CadrePeer', row.peerId, false) !== null) {
|
|
@@ -2305,13 +2425,13 @@ export class ControlDatabase {
|
|
|
2305
2425
|
*
|
|
2306
2426
|
* NOTE: this rebinds VouchOwner to the CALLING owner's key, and the authorized-membership
|
|
2307
2427
|
* predicate (`CadreNode.listAuthorizedMembers`) now judges rows by that column against each
|
|
2308
|
-
* reader's node-local anchor.
|
|
2309
|
-
*
|
|
2310
|
-
*
|
|
2311
|
-
*
|
|
2312
|
-
* anchor the original owner but not this one would
|
|
2313
|
-
* must re-vouch deliberately (or preserve the existing
|
|
2314
|
-
* inherit this rebinding.
|
|
2428
|
+
* reader's node-local anchor. The two callers rebind on purpose: the write-while-alone drain
|
|
2429
|
+
* re-touches solely rows this node itself authored (`pendingPeerWrites`), so the voucher is
|
|
2430
|
+
* rewritten to the key that already signed it, and `CadreNode.removeOwner` moves rows off a
|
|
2431
|
+
* key it is about to remove. A new caller that re-touches a row a DIFFERENT owner vouched
|
|
2432
|
+
* flips the voucher silently: readers that anchor the original owner but not this one would
|
|
2433
|
+
* drop a legitimate member. It must re-vouch deliberately (or preserve the existing
|
|
2434
|
+
* VouchOwner/VouchSig) rather than inherit this rebinding.
|
|
2315
2435
|
*
|
|
2316
2436
|
* @param peerId - the membership row to re-touch.
|
|
2317
2437
|
* @param updatedAt - the strictly-increasing freshness stamp to write.
|
|
@@ -2326,7 +2446,7 @@ export class ControlDatabase {
|
|
|
2326
2446
|
log('reauthorizeCadrePeer: no CadrePeer row for %s (nothing to re-touch)', peerId);
|
|
2327
2447
|
return false;
|
|
2328
2448
|
}
|
|
2329
|
-
const signature = signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [peerId, stampId]));
|
|
2449
|
+
const signature = await signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [peerId, stampId]));
|
|
2330
2450
|
await this.mutateCadrePeer('peer-reauthorize', async () => {
|
|
2331
2451
|
// Bare `exec` — inside the non-re-entrant write lock (see insertCadrePeer).
|
|
2332
2452
|
await this.db.exec(`
|
|
@@ -2412,7 +2532,7 @@ export class ControlDatabase {
|
|
|
2412
2532
|
log('delete %s: no row for %s (already absent)', table, keyValue);
|
|
2413
2533
|
return false;
|
|
2414
2534
|
}
|
|
2415
|
-
const removal = signGuardedRemoval(this.config.partyId, table, keyValue, stampId, signMessage);
|
|
2535
|
+
const removal = await signGuardedRemoval(this.config.partyId, table, keyValue, stampId, signMessage);
|
|
2416
2536
|
await this.inTransaction(`delete ${table}`, async () => {
|
|
2417
2537
|
await alongside?.(stampId);
|
|
2418
2538
|
await this.execGuardedRemoval(removal, ownerKey);
|
|
@@ -2445,8 +2565,8 @@ export class ControlDatabase {
|
|
|
2445
2565
|
}
|
|
2446
2566
|
/**
|
|
2447
2567
|
* File the owner-signed `Revocation` tombstone retiring one row incarnation — the second
|
|
2448
|
-
* statement of every guarded removal ({@link execGuardedRemoval}) and the whole of
|
|
2449
|
-
*
|
|
2568
|
+
* statement of every guarded removal ({@link execGuardedRemoval}) and the whole of an
|
|
2569
|
+
* issued row's withdrawal ({@link withdrawIssuedRow}), whose row stays — or the ledger
|
|
2450
2570
|
* marker ({@link openRevocationLedger}), which retires nothing. The caller supplies the
|
|
2451
2571
|
* lock and, where one is needed, the transaction.
|
|
2452
2572
|
*
|
|
@@ -2692,17 +2812,20 @@ export class ControlDatabase {
|
|
|
2692
2812
|
*
|
|
2693
2813
|
* Returns how many UPDATE statements ran (`rows.length` on success).
|
|
2694
2814
|
*/
|
|
2695
|
-
reissueRevocations(rows, reissuedAt, ownerKey, signMessage) {
|
|
2815
|
+
async reissueRevocations(rows, reissuedAt, ownerKey, signMessage) {
|
|
2696
2816
|
this.ensureInitialized();
|
|
2697
2817
|
// Signatures minted OUTSIDE the locked body: a retried attempt must re-present the
|
|
2698
2818
|
// exact signed message, never re-mint (the contract on withWriteLock). ReissuedAt
|
|
2699
2819
|
// rides in the digest as a string — SQL-side it is cast(new.ReissuedAt as text),
|
|
2700
2820
|
// and every digest field is TEXT on both sides (see buildAuthorizationMessage).
|
|
2701
|
-
const signed =
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2821
|
+
const signed = [];
|
|
2822
|
+
for (const row of rows) {
|
|
2823
|
+
signed.push({
|
|
2824
|
+
row,
|
|
2825
|
+
signature: await signMessage(buildAuthorizationMessage('CadreControl.Revocation', 'reissue', this.config.partyId, [row.tableName, row.rowKey, row.stampId, String(reissuedAt)])),
|
|
2826
|
+
});
|
|
2827
|
+
}
|
|
2828
|
+
return await this.lockedWithRetry(async () => {
|
|
2706
2829
|
// Counter lives INSIDE the locked body so a lockedWithRetry re-run (which
|
|
2707
2830
|
// re-executes the whole body) starts from zero instead of double-counting.
|
|
2708
2831
|
let executed = 0;
|
|
@@ -2766,7 +2889,7 @@ export class ControlDatabase {
|
|
|
2766
2889
|
*/
|
|
2767
2890
|
async openRevocationLedger(ownerKey, signMessage) {
|
|
2768
2891
|
this.ensureInitialized();
|
|
2769
|
-
const signature = signMessage(revocationTombstoneMessage(this.config.partyId, REVOCATION_LEDGER_MARKER));
|
|
2892
|
+
const signature = await signMessage(revocationTombstoneMessage(this.config.partyId, REVOCATION_LEDGER_MARKER));
|
|
2770
2893
|
try {
|
|
2771
2894
|
return await this.lockedWithRetry(async () => {
|
|
2772
2895
|
if (await this.revocationLedgerFiled()) {
|
|
@@ -2876,8 +2999,9 @@ export class ControlDatabase {
|
|
|
2876
2999
|
* Covers every write that can change who is a member or an owner: EVERY `CadrePeer`
|
|
2877
3000
|
* writer, with one documented exception ({@link updateSelfPeerRecord}, which cannot
|
|
2878
3001
|
* change the snapshot), the `OwnerKey` writers ({@link insertOwnerKeyVouched},
|
|
2879
|
-
* {@link deleteOwnerKey})
|
|
2880
|
-
*
|
|
3002
|
+
* {@link deleteOwnerKey}), the invitation redemption ({@link redeemCadreInvite}), which
|
|
3003
|
+
* seats both, and the owner-grant redemption ({@link redeemOwnerGrant}). That is what
|
|
3004
|
+
* makes the party-membership snapshot refresh automatic
|
|
2881
3005
|
* rather than a caller obligation. The name predates the `OwnerKey` writers. A
|
|
2882
3006
|
* writer necessarily holds the target node's ControlDatabase (the `SeedBootstrapService`'s
|
|
2883
3007
|
* event callbacks are NOT a viable seam: the temp service `CadreNode.applySeed`
|
|
@@ -2988,7 +3112,7 @@ export class ControlDatabase {
|
|
|
2988
3112
|
* backoff never stalls the other local writers queued behind it. Safe because every
|
|
2989
3113
|
* locked body is atomic and re-runnable (the contract on {@link withWriteLock}).
|
|
2990
3114
|
*
|
|
2991
|
-
* Also carries {@link
|
|
3115
|
+
* Also carries {@link applyControlSchema}'s distributed DDL, the one caller that runs BEFORE
|
|
2992
3116
|
* `initialized` is set — so it reaches this method directly rather than through
|
|
2993
3117
|
* {@link execWrite}, whose {@link ensureInitialized} would reject it. The write queue is
|
|
2994
3118
|
* necessarily empty at that point, so the lock is uncontended there; it is taken anyway
|
|
@@ -3144,7 +3268,7 @@ export class ControlDatabase {
|
|
|
3144
3268
|
const message = buildAuthorizationMessage('CadreControl.FormationInvite', 'add', this.config.partyId, [
|
|
3145
3269
|
token, sAppId, expiresAtField, totalUsesField, validationUrlField, strandIdField, stampId,
|
|
3146
3270
|
]);
|
|
3147
|
-
const signature = signMessage(message);
|
|
3271
|
+
const signature = await signMessage(message);
|
|
3148
3272
|
// Persist the canonical ExpiresAt string (datetime parse is idempotent on it) so the
|
|
3149
3273
|
// signed source-of-truth and the stored value are produced once. StampId is a real,
|
|
3150
3274
|
// unique column (single-use anti-replay), no longer a context value.
|
|
@@ -3217,13 +3341,13 @@ export class ControlDatabase {
|
|
|
3217
3341
|
await this.inTransaction('redemption', async () => {
|
|
3218
3342
|
// 1. Strand row — authorised by the FormationUsage branch (no owner sig),
|
|
3219
3343
|
// still carrying a fresh unique StampId for the anti-replay column.
|
|
3220
|
-
// Hard-coded open + keyless + explicit null
|
|
3344
|
+
// Hard-coded open + keyless + explicit null founder columns: the
|
|
3221
3345
|
// consent branch admits no other shape (no signature means no
|
|
3222
|
-
// trustworthy founder machine to record — see the schema's
|
|
3346
|
+
// trustworthy founder machine to record — see the schema's comment).
|
|
3223
3347
|
await this.db.exec(`
|
|
3224
|
-
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey)
|
|
3348
|
+
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey, FounderPeerId)
|
|
3225
3349
|
with context OwnerKey = null, Signature = null
|
|
3226
|
-
values (?, 'o', null, ?, null)
|
|
3350
|
+
values (?, 'o', null, ?, null, null)
|
|
3227
3351
|
`, [strandId, strandStampId]);
|
|
3228
3352
|
// 2. FormationUsage row — authorised by the matching FormationInvite, and
|
|
3229
3353
|
// carrying the strand's stamp so it authorizes THIS row and no other.
|
|
@@ -3492,73 +3616,116 @@ export class ControlDatabase {
|
|
|
3492
3616
|
* private half to the invitee out of band. It is never stored here (`Strand.Invite` has the
|
|
3493
3617
|
* same shape).
|
|
3494
3618
|
*
|
|
3495
|
-
* @param invite.key - the invitation public key (base64url ed25519), the row's primary key
|
|
3496
|
-
* @param invite.peerId - the device the invitation is for, or null for whoever redeems first
|
|
3497
|
-
* @param invite.grantsOwner - whether redemption also seats an `OwnerKey` row for the device
|
|
3498
|
-
* @param invite.expiresAtMs - epoch ms after which it cannot be redeemed; omit for never
|
|
3499
|
-
* @param invite.totalUses - how many redemptions it allows; omit for unlimited
|
|
3500
3619
|
* @returns the row as stored, `expiresAt` in its canonical form
|
|
3501
3620
|
*/
|
|
3502
3621
|
async insertCadreInvite(invite, ownerKey, signMessage) {
|
|
3622
|
+
const row = await this.signCadreInvite(invite, ownerKey, signMessage);
|
|
3623
|
+
await this.lockedWithRetry(() => this.execCadreInviteInsert(row), {}, 'cadre-invite-insert');
|
|
3624
|
+
log('Cadre invitation inserted: %s (peer=%s)', row.key, row.peerId ?? 'any');
|
|
3625
|
+
return row;
|
|
3626
|
+
}
|
|
3627
|
+
/**
|
|
3628
|
+
* Build and owner-sign a `CadreInvite` row WITHOUT inserting it: the signing half of
|
|
3629
|
+
* {@link insertCadreInvite}, which `CadreNode.joinWithOwnerGrant` uses alone for the
|
|
3630
|
+
* invitation a node issues to itself, since a store that holds no `OwnerKey` row for the
|
|
3631
|
+
* issuer would refuse the local insert and the row is seated at a member from the bundle
|
|
3632
|
+
* instead. `ExpiresAt` is canonicalized by this database's engine, which is a format, not a
|
|
3633
|
+
* store, so the row reads the same wherever it is seated.
|
|
3634
|
+
*/
|
|
3635
|
+
async signCadreInvite(invite, ownerKey, signMessage) {
|
|
3503
3636
|
this.ensureInitialized();
|
|
3504
3637
|
const key = requireEd25519PublicKeyB64(invite.key, 'cadre invitation key');
|
|
3505
3638
|
const signed = {
|
|
3506
3639
|
key,
|
|
3507
3640
|
peerId: invite.peerId ?? null,
|
|
3508
|
-
grantsOwner: invite.grantsOwner,
|
|
3509
3641
|
expiresAt: invite.expiresAtMs == null ? null : await canonicalDatetime(this.db, invite.expiresAtMs),
|
|
3510
3642
|
totalUses: invite.totalUses ?? null,
|
|
3511
3643
|
stampId: generateStampId(this.config.libp2pNode.peerId.toString()),
|
|
3512
3644
|
};
|
|
3513
|
-
|
|
3645
|
+
return {
|
|
3514
3646
|
...signed,
|
|
3515
3647
|
issuerKey: ownerKey,
|
|
3516
|
-
issuerSig: signMessage(cadreInviteAddMessage(this.config.partyId, signed)),
|
|
3648
|
+
issuerSig: await signMessage(cadreInviteAddMessage(this.config.partyId, signed)),
|
|
3517
3649
|
};
|
|
3518
|
-
await this.lockedWithRetry(() => this.execCadreInviteInsert(row), {}, 'cadre-invite-insert');
|
|
3519
|
-
log('Cadre invitation inserted: %s (peer=%s owner=%s)', key, row.peerId ?? 'any', row.grantsOwner);
|
|
3520
|
-
return row;
|
|
3521
3650
|
}
|
|
3522
3651
|
/**
|
|
3523
3652
|
* Seat an owner-signed `CadreInvite` row received inside an invitation bundle, under its own
|
|
3524
3653
|
* stored `IssuerKey`/`IssuerSig` as the write context — insert-if-absent, so a member that
|
|
3525
|
-
* already holds the row (by replication, or from an earlier redemption attempt) is a no-op
|
|
3654
|
+
* already holds the row (by replication, or from an earlier redemption attempt) is a no-op,
|
|
3655
|
+
* and so is one whose insert the cohort refuses because a member there holds it.
|
|
3526
3656
|
*
|
|
3527
3657
|
* @returns `true` when this call inserted the row, `false` when it was already present.
|
|
3528
3658
|
* @throws {CadreInviteIssuerUnknownError} when `CadreInvite.AuthorizedInsert` refuses it: the
|
|
3529
3659
|
* issuer is not an owner on this node (not replicated yet, or removed), or the signature does
|
|
3530
|
-
* not verify.
|
|
3660
|
+
* not verify. {@link IssuedRowNotHereYetError} when the cohort holds the row and this node
|
|
3661
|
+
* cannot read it yet. Every other refusal (a withdrawn stamp, `NotRevoked`) propagates as is.
|
|
3531
3662
|
*/
|
|
3532
3663
|
async seatCadreInvite(row) {
|
|
3664
|
+
return this.seatIssuedRow('CadreInvite', row.key, () => this.execCadreInviteInsert(row), cause => new CadreInviteIssuerUnknownError(row.key, row.issuerKey, { cause }));
|
|
3665
|
+
}
|
|
3666
|
+
/**
|
|
3667
|
+
* Insert-if-absent of an issued row from a bundle's copy, under the write lock: the body of
|
|
3668
|
+
* {@link seatCadreInvite} and {@link seatOwnerGrant}. `exec` runs the bare insert under the
|
|
3669
|
+
* row's own stored issuer pair.
|
|
3670
|
+
*
|
|
3671
|
+
* @returns `true` when this call inserted the row, `false` when it was already present —
|
|
3672
|
+
* here, or at the cohort member that refused the insert for holding it
|
|
3673
|
+
* ({@link adoptCohortHeldRow}).
|
|
3674
|
+
* @throws `refuse(cause)` when the table's `AuthorizedInsert` refuses the row;
|
|
3675
|
+
* {@link IssuedRowNotHereYetError} when the cohort holds the row and this node still cannot
|
|
3676
|
+
* read it; every other refusal (a withdrawn stamp, `NotRevoked`) propagates as is.
|
|
3677
|
+
*/
|
|
3678
|
+
async seatIssuedRow(table, key, exec, refuse) {
|
|
3533
3679
|
this.ensureInitialized();
|
|
3534
3680
|
try {
|
|
3535
3681
|
return await this.lockedWithRetry(async () => {
|
|
3536
3682
|
// retry: false — inside the locked write body (see queryStampId's NOTE).
|
|
3537
|
-
if (await this.queryStampId(
|
|
3538
|
-
log('
|
|
3683
|
+
if (await this.queryStampId(table, key, false) !== null) {
|
|
3684
|
+
log('%s %s already seated; seat skipped', table, key);
|
|
3539
3685
|
return false;
|
|
3540
3686
|
}
|
|
3541
|
-
await
|
|
3542
|
-
log('
|
|
3687
|
+
await exec();
|
|
3688
|
+
log('%s seated from its bundle: %s', table, key);
|
|
3543
3689
|
return true;
|
|
3544
|
-
}, {},
|
|
3690
|
+
}, {}, `${ISSUED_TABLE_REASON[table]}-seat`);
|
|
3545
3691
|
}
|
|
3546
3692
|
catch (error) {
|
|
3547
|
-
if (errorChainMatches(error,
|
|
3548
|
-
throw
|
|
3693
|
+
if (errorChainMatches(error, ISSUER_UNAUTHORIZED)) {
|
|
3694
|
+
throw refuse(error);
|
|
3695
|
+
}
|
|
3696
|
+
if (isIssuedRowKeyConflict(error, table)) {
|
|
3697
|
+
return await this.adoptCohortHeldRow(table, key, error);
|
|
3549
3698
|
}
|
|
3550
3699
|
throw error;
|
|
3551
3700
|
}
|
|
3552
3701
|
}
|
|
3702
|
+
/**
|
|
3703
|
+
* The seat's insert was refused because a cohort member already holds a row under `key`, which
|
|
3704
|
+
* this node's guard read missed because its store lags the cohort. A bundle's copy is verbatim
|
|
3705
|
+
* and every column is under the issuer's signature, so the held row is this one: it counts as
|
|
3706
|
+
* seated once a refreshing read (unlocked, so retried as any read) finds it here.
|
|
3707
|
+
*
|
|
3708
|
+
* @throws {IssuedRowNotHereYetError} when the read still does not find it.
|
|
3709
|
+
*/
|
|
3710
|
+
async adoptCohortHeldRow(table, key, conflict) {
|
|
3711
|
+
// NOTE: while another locked body runs here, this read takes readRowsOnce's committed path,
|
|
3712
|
+
// which does not refresh from the cohort, so it can miss the row and refuse a retryable
|
|
3713
|
+
// `conflict`; if that refusal shows up in practice, wait for the running body before reading.
|
|
3714
|
+
if (await this.queryStampId(table, key) === null) {
|
|
3715
|
+
throw new IssuedRowNotHereYetError(table, key, { cause: conflict });
|
|
3716
|
+
}
|
|
3717
|
+
log('%s %s is held by the cohort; seat skipped', table, key);
|
|
3718
|
+
return false;
|
|
3719
|
+
}
|
|
3553
3720
|
/** Bare `CadreInvite` insert shared by {@link insertCadreInvite} and {@link seatCadreInvite}; the caller holds the lock. */
|
|
3554
3721
|
async execCadreInviteInsert(row) {
|
|
3555
3722
|
await this.db.exec(`
|
|
3556
|
-
insert into CadreControl.CadreInvite (Key, PeerId,
|
|
3723
|
+
insert into CadreControl.CadreInvite (Key, PeerId, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId)
|
|
3557
3724
|
with context OwnerKey = ?, Signature = ?
|
|
3558
|
-
values (?, ?, ?, ?, ?, ?,
|
|
3725
|
+
values (?, ?, ?, ?, ?, ?, ?)
|
|
3559
3726
|
`, [
|
|
3560
3727
|
row.issuerKey, row.issuerSig,
|
|
3561
|
-
row.key, row.peerId, row.
|
|
3728
|
+
row.key, row.peerId, row.expiresAt, row.totalUses,
|
|
3562
3729
|
row.issuerKey, row.issuerSig, row.stampId,
|
|
3563
3730
|
]);
|
|
3564
3731
|
}
|
|
@@ -3596,7 +3763,7 @@ export class ControlDatabase {
|
|
|
3596
3763
|
*/
|
|
3597
3764
|
async queryCadreInviteUsages(retry = true) {
|
|
3598
3765
|
this.ensureInitialized();
|
|
3599
|
-
const rows = await this.readRows('select UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId,
|
|
3766
|
+
const rows = await this.readRows('select UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, InviteSig, PeerSig from CadreControl.CadreInviteUsage', undefined, 'cadre-invite-usages', retry);
|
|
3600
3767
|
return rows.map(cadreInviteUsageRowOf);
|
|
3601
3768
|
}
|
|
3602
3769
|
/**
|
|
@@ -3607,9 +3774,12 @@ export class ControlDatabase {
|
|
|
3607
3774
|
* `retry: false` is passed by {@link assertCadreInviteSeatRemains}, inside the locked body.
|
|
3608
3775
|
*/
|
|
3609
3776
|
async countCadreInviteUsage(key, retry = true) {
|
|
3777
|
+
return this.countUsageRows('select count(1) as Count from CadreControl.CadreInviteUsage where InviteKey = ?', key, 'cadre-invite-usage-count', retry);
|
|
3778
|
+
}
|
|
3779
|
+
/** The one-row count read behind {@link countCadreInviteUsage} and {@link countOwnerGrantUsage}. */
|
|
3780
|
+
async countUsageRows(sql, key, label, retry) {
|
|
3610
3781
|
this.ensureInitialized();
|
|
3611
|
-
|
|
3612
|
-
for (const row of await this.readRows(sql, [key], 'cadre-invite-usage-count', retry)) {
|
|
3782
|
+
for (const row of await this.readRows(sql, [key], label, retry)) {
|
|
3613
3783
|
return row.Count ?? 0;
|
|
3614
3784
|
}
|
|
3615
3785
|
return 0;
|
|
@@ -3640,7 +3810,7 @@ export class ControlDatabase {
|
|
|
3640
3810
|
const metered = [];
|
|
3641
3811
|
let unlimitedLive = false;
|
|
3642
3812
|
for (const row of await this.readRows('select Key, ExpiresAt, TotalUses, IssuerKey, StampId from CadreControl.CadreInvite', undefined, 'live-cadre-invites')) {
|
|
3643
|
-
const open =
|
|
3813
|
+
const open = issuedRowStillOpen({ stampId: row.StampId, issuerKey: row.IssuerKey, expiresAt: row.ExpiresAt ?? null }, withdrawn, owners, nowMs);
|
|
3644
3814
|
if (!open) {
|
|
3645
3815
|
continue;
|
|
3646
3816
|
}
|
|
@@ -3677,14 +3847,21 @@ export class ControlDatabase {
|
|
|
3677
3847
|
async isCadreInviteLive(invite, nowMs = Date.now()) {
|
|
3678
3848
|
this.ensureInitialized();
|
|
3679
3849
|
const row = await this.queryCadreInvite(invite.key) ?? (verifyCadreInviteRow(this.config.partyId, invite) ? invite : null);
|
|
3850
|
+
return this.issuedRowLive('CadreInvite', row, nowMs, key => this.countCadreInviteUsage(key));
|
|
3851
|
+
}
|
|
3852
|
+
/**
|
|
3853
|
+
* The body of {@link isCadreInviteLive} and {@link isOwnerGrantLive}: is `row` (the one held,
|
|
3854
|
+
* or a verified bundle copy; null for neither) still open and under its use cap?
|
|
3855
|
+
*/
|
|
3856
|
+
async issuedRowLive(table, row, nowMs, countUsage) {
|
|
3680
3857
|
if (row === null) {
|
|
3681
3858
|
return false;
|
|
3682
3859
|
}
|
|
3683
|
-
const [withdrawn, owners] = await Promise.all([this.queryRevokedStamps(
|
|
3684
|
-
if (!
|
|
3860
|
+
const [withdrawn, owners] = await Promise.all([this.queryRevokedStamps(table), this.getOwnerKeys()]);
|
|
3861
|
+
if (!issuedRowStillOpen(row, withdrawn, owners, nowMs)) {
|
|
3685
3862
|
return false;
|
|
3686
3863
|
}
|
|
3687
|
-
return row.totalUses === null || await
|
|
3864
|
+
return row.totalUses === null || await countUsage(row.key) < row.totalUses;
|
|
3688
3865
|
}
|
|
3689
3866
|
/**
|
|
3690
3867
|
* Every `CadreInvite` row this node holds with its standing: how many redemptions are
|
|
@@ -3694,22 +3871,28 @@ export class ControlDatabase {
|
|
|
3694
3871
|
* occasional read, not a gate's.
|
|
3695
3872
|
*/
|
|
3696
3873
|
async listCadreInviteStatuses(nowMs = Date.now()) {
|
|
3874
|
+
const standings = await this.issuedRowStandings('CadreInvite', await this.queryCadreInvites(), nowMs, key => this.countCadreInviteUsage(key));
|
|
3875
|
+
return standings.map(({ row, ...standing }) => ({ invite: row, ...standing }));
|
|
3876
|
+
}
|
|
3877
|
+
/**
|
|
3878
|
+
* The body of {@link listCadreInviteStatuses} and {@link listOwnerGrantStatuses}: each of
|
|
3879
|
+
* `rows` with its withdrawal, its recorded uses and whether it is still redeemable.
|
|
3880
|
+
*/
|
|
3881
|
+
async issuedRowStandings(table, rows, nowMs, countUsage) {
|
|
3697
3882
|
this.ensureInitialized();
|
|
3698
|
-
const [
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
//
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
const
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
&& (invite.totalUses === null || usesRecorded < invite.totalUses);
|
|
3710
|
-
statuses.push({ invite, live, withdrawn, usesRecorded });
|
|
3883
|
+
const [withdrawnStamps, owners] = await Promise.all([this.queryRevokedStamps(table), this.getOwnerKeys()]);
|
|
3884
|
+
const standings = [];
|
|
3885
|
+
// NOTE: one retried count read per row, withdrawn and expired rows included, since rows
|
|
3886
|
+
// are never deleted; if a long-lived cadre's listing ever shows as slow, group the usage
|
|
3887
|
+
// counts in one statement or skip the count for rows that are no longer open.
|
|
3888
|
+
for (const row of rows) {
|
|
3889
|
+
const usesRecorded = await countUsage(row.key);
|
|
3890
|
+
const withdrawn = withdrawnStamps.has(row.stampId);
|
|
3891
|
+
const live = issuedRowStillOpen(row, withdrawnStamps, owners, nowMs)
|
|
3892
|
+
&& (row.totalUses === null || usesRecorded < row.totalUses);
|
|
3893
|
+
standings.push({ row, live, withdrawn, usesRecorded });
|
|
3711
3894
|
}
|
|
3712
|
-
return
|
|
3895
|
+
return standings;
|
|
3713
3896
|
}
|
|
3714
3897
|
/**
|
|
3715
3898
|
* Owner-signed WITHDRAWAL of a cadre invitation: file the `Revocation` tombstone retiring its
|
|
@@ -3729,59 +3912,66 @@ export class ControlDatabase {
|
|
|
3729
3912
|
* node was alone is queued for re-issue on cohort growth ({@link CadreNode.noteGuardedDelete}).
|
|
3730
3913
|
*/
|
|
3731
3914
|
async withdrawCadreInvite(key, ownerKey, signMessage) {
|
|
3915
|
+
return this.withdrawIssuedRow('CadreInvite', key, ownerKey, signMessage);
|
|
3916
|
+
}
|
|
3917
|
+
/**
|
|
3918
|
+
* The body of {@link withdrawCadreInvite} and {@link withdrawOwnerGrant}: under the write
|
|
3919
|
+
* lock, file the owner-signed tombstone retiring the row's stamp with the row kept, and fire
|
|
3920
|
+
* the guarded-delete listener for it (so a withdrawal made while alone is re-issued).
|
|
3921
|
+
*/
|
|
3922
|
+
async withdrawIssuedRow(table, key, ownerKey, signMessage) {
|
|
3732
3923
|
this.ensureInitialized();
|
|
3733
3924
|
const filed = await this.lockedWithRetry(async () => {
|
|
3734
|
-
const [withdrawal] = await this.
|
|
3925
|
+
const [withdrawal] = await this.signWithdrawals(table, [key], signMessage);
|
|
3735
3926
|
if (withdrawal === undefined) {
|
|
3736
3927
|
return null;
|
|
3737
3928
|
}
|
|
3738
3929
|
await this.execTombstone(withdrawal.ref, withdrawal.signature, ownerKey);
|
|
3739
3930
|
return withdrawal.ref;
|
|
3740
|
-
}, {},
|
|
3931
|
+
}, {}, `${ISSUED_TABLE_REASON[table]}-withdraw`);
|
|
3741
3932
|
if (filed === null) {
|
|
3742
3933
|
return false;
|
|
3743
3934
|
}
|
|
3744
|
-
log('
|
|
3935
|
+
log('%s withdrawn: %s (stamp %s retired, row kept)', table, key, filed.stampId);
|
|
3745
3936
|
this.notifyGuardedDelete(filed);
|
|
3746
3937
|
return true;
|
|
3747
3938
|
}
|
|
3748
3939
|
/**
|
|
3749
|
-
* The withdrawal tombstones for the `
|
|
3750
|
-
*
|
|
3751
|
-
* {@link
|
|
3940
|
+
* The withdrawal tombstones for the `table` rows among `keys` that are held here and not
|
|
3941
|
+
* yet withdrawn, signed and ready for {@link execTombstone}: the body
|
|
3942
|
+
* {@link withdrawIssuedRow} files one of and {@link removeMachine} files several of in one
|
|
3752
3943
|
* transaction. Inside the locked body (every read `retry: false`); an absent or already
|
|
3753
3944
|
* withdrawn key is logged and left out.
|
|
3754
3945
|
*/
|
|
3755
|
-
async
|
|
3946
|
+
async signWithdrawals(table, keys, signMessage) {
|
|
3756
3947
|
if (keys.length === 0) {
|
|
3757
3948
|
return [];
|
|
3758
3949
|
}
|
|
3759
|
-
const withdrawn = await this.queryRevokedStamps(
|
|
3950
|
+
const withdrawn = await this.queryRevokedStamps(table, false);
|
|
3760
3951
|
const withdrawals = [];
|
|
3761
3952
|
for (const key of keys) {
|
|
3762
|
-
const stampId = await this.queryStampId(
|
|
3953
|
+
const stampId = await this.queryStampId(table, key, false);
|
|
3763
3954
|
if (stampId === null) {
|
|
3764
|
-
log('
|
|
3955
|
+
log('withdraw %s: no row for %s (nothing to withdraw)', table, key);
|
|
3765
3956
|
continue;
|
|
3766
3957
|
}
|
|
3767
3958
|
if (withdrawn.has(stampId)) {
|
|
3768
|
-
log('
|
|
3959
|
+
log('withdraw %s: %s already withdrawn (stamp %s retired)', table, key, stampId);
|
|
3769
3960
|
continue;
|
|
3770
3961
|
}
|
|
3771
|
-
const ref = { tableName:
|
|
3772
|
-
withdrawals.push({ ref, signature: signMessage(revocationTombstoneMessage(this.config.partyId, ref)) });
|
|
3962
|
+
const ref = { tableName: table, rowKey: key, stampId };
|
|
3963
|
+
withdrawals.push({ ref, signature: await signMessage(revocationTombstoneMessage(this.config.partyId, ref)) });
|
|
3773
3964
|
}
|
|
3774
3965
|
return withdrawals;
|
|
3775
3966
|
}
|
|
3776
3967
|
/**
|
|
3777
|
-
* Redeem a seated `CadreInvite` on the owner's behalf: admit `peerId` as a cadre member
|
|
3778
|
-
*
|
|
3779
|
-
* transaction writes the `CadrePeer` row (consent branch of `AuthorizedInsert`)
|
|
3780
|
-
* `
|
|
3781
|
-
*
|
|
3782
|
-
*
|
|
3783
|
-
*
|
|
3784
|
-
* ({@link seatCadreInvite}): every consent branch reads `committed.CadreInvite`.
|
|
3968
|
+
* Redeem a seated `CadreInvite` on the owner's behalf: admit `peerId` as a cadre member —
|
|
3969
|
+
* with NO owner signature, and as a machine only (ownership is {@link redeemOwnerGrant}'s).
|
|
3970
|
+
* One transaction writes the `CadrePeer` row (consent branch of `AuthorizedInsert`) and then
|
|
3971
|
+
* the `CadreInviteUsage` row that authorizes it; the usage row's own check (`PeerExists`)
|
|
3972
|
+
* reads the peer row back, so the two are mutually bound to this exact redemption and
|
|
3973
|
+
* neither can be held in reserve. The invitation must already be committed
|
|
3974
|
+
* ({@link seatCadreInvite}): the consent branch reads `committed.CadreInvite`.
|
|
3785
3975
|
*
|
|
3786
3976
|
* The two signatures are the holder's and the device's, minted on the device over
|
|
3787
3977
|
* {@link cadreInviteRedeemMessage} / {@link cadreInviteConsentMessage}; this machine only
|
|
@@ -3790,18 +3980,17 @@ export class ControlDatabase {
|
|
|
3790
3980
|
* (`verifyInvitationAdmission`) — a mismatched pair is written and then never trusted.
|
|
3791
3981
|
*
|
|
3792
3982
|
* Idempotent for a retry after a dropped reply: when the device's row is already present
|
|
3793
|
-
*
|
|
3794
|
-
*
|
|
3795
|
-
*
|
|
3796
|
-
* The seat check ({@link assertCadreInviteSeatRemains}) runs after that, so the retry of a
|
|
3797
|
-
* single-use redemption is answered `alreadyMember` rather than refused as exhausted.
|
|
3983
|
+
* nothing is written and no seat is spent — `alreadyMember: true`. The seat check
|
|
3984
|
+
* ({@link assertCadreInviteSeatRemains}) runs after that, so the retry of a single-use
|
|
3985
|
+
* redemption is answered `alreadyMember` rather than refused as exhausted.
|
|
3798
3986
|
*
|
|
3799
3987
|
* Through {@link mutateCadrePeer}, so the membership hub is notified; inside the locked body
|
|
3800
3988
|
* every read passes `retry: false` and every stamp is minted per attempt, so a
|
|
3801
3989
|
* {@link lockedWithRetry} re-run starts clean.
|
|
3802
3990
|
*
|
|
3803
3991
|
* @throws {InvitationExhaustedError} when the invitation's seats are all spent (by name,
|
|
3804
|
-
* ahead of the schema's generic `Authorized` refusal — see {@link assertSeatRemains})
|
|
3992
|
+
* ahead of the schema's generic `Authorized` refusal — see {@link assertSeatRemains});
|
|
3993
|
+
* {@link IssuedRowNotHereYetError} when no `CadreInvite` row is readable here.
|
|
3805
3994
|
*/
|
|
3806
3995
|
async redeemCadreInvite(params) {
|
|
3807
3996
|
this.ensureInitialized();
|
|
@@ -3812,57 +4001,37 @@ export class ControlDatabase {
|
|
|
3812
4001
|
return this.mutateCadrePeer('cadre-invite-redeem', async () => {
|
|
3813
4002
|
const invite = await this.queryCadreInvite(inviteKey, false);
|
|
3814
4003
|
if (invite === null) {
|
|
3815
|
-
throw new
|
|
4004
|
+
throw new IssuedRowNotHereYetError('CadreInvite', inviteKey);
|
|
3816
4005
|
}
|
|
3817
|
-
|
|
3818
|
-
|
|
4006
|
+
// A raw stamp read (queryStampId), so a physically present row counts; inside the locked
|
|
4007
|
+
// body, so never retried.
|
|
4008
|
+
const seatedPeerStampId = await this.queryStampId('CadrePeer', peerId, false);
|
|
4009
|
+
if (seatedPeerStampId !== null) {
|
|
3819
4010
|
log('redeemCadreInvite: %s already admitted; nothing written', peerId);
|
|
3820
|
-
return { alreadyMember: true, peerStampId:
|
|
4011
|
+
return { alreadyMember: true, peerStampId: seatedPeerStampId };
|
|
3821
4012
|
}
|
|
3822
4013
|
await this.assertCadreInviteSeatRemains(invite);
|
|
3823
|
-
//
|
|
3824
|
-
// the row it seats. Minted inside the body, so a re-run mints afresh over a
|
|
3825
|
-
// attempt rather than re-presenting a stamp the usage row may already name.
|
|
3826
|
-
const peerStampId =
|
|
3827
|
-
const ownerStampId = invite.grantsOwner ? (seated.ownerStampId ?? generateStampId(peerId)) : null;
|
|
4014
|
+
// The stamp is minted from the ADMITTED device's id, as insertCadrePeer does: the nonce
|
|
4015
|
+
// names the row it seats. Minted inside the body, so a re-run mints afresh over a
|
|
4016
|
+
// rolled-back attempt rather than re-presenting a stamp the usage row may already name.
|
|
4017
|
+
const peerStampId = generateStampId(peerId);
|
|
3828
4018
|
const nowCanonical = await canonicalDatetime(this.db, nowMs);
|
|
3829
4019
|
await this.inTransaction('cadre-invite-redeem', async () => {
|
|
3830
|
-
if (seated.peerStampId === null) {
|
|
3831
|
-
await this.db.exec(`
|
|
3832
|
-
insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId, VouchOwner, VouchSig, VouchUsage)
|
|
3833
|
-
with context OwnerKey = null, Signature = null
|
|
3834
|
-
values (?, ?, ?, ?, null, ?, ?, null, ?)
|
|
3835
|
-
`, [peerId, peerKey, multiaddr, nowMs, peerStampId, invite.issuerKey, usageStampId]);
|
|
3836
|
-
}
|
|
3837
|
-
if (ownerStampId !== null && seated.ownerStampId === null) {
|
|
3838
|
-
await this.db.exec(`
|
|
3839
|
-
insert into CadreControl.OwnerKey (Key, StampId, VouchOwner, VouchSig, VouchUsage)
|
|
3840
|
-
with context OwnerKey = null, Signature = null
|
|
3841
|
-
values (?, ?, ?, null, ?)
|
|
3842
|
-
`, [peerKey, ownerStampId, invite.issuerKey, usageStampId]);
|
|
3843
|
-
}
|
|
3844
4020
|
await this.db.exec(`
|
|
3845
|
-
insert into CadreControl.
|
|
4021
|
+
insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId, VouchOwner, VouchSig, VouchUsage)
|
|
4022
|
+
with context OwnerKey = null, Signature = null
|
|
4023
|
+
values (?, ?, ?, ?, null, ?, ?, null, ?)
|
|
4024
|
+
`, [peerId, peerKey, multiaddr, nowMs, peerStampId, invite.issuerKey, usageStampId]);
|
|
4025
|
+
await this.db.exec(`
|
|
4026
|
+
insert into CadreControl.CadreInviteUsage (UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, InviteSig, PeerSig)
|
|
3846
4027
|
with context Now = ?
|
|
3847
|
-
values (?, ?, ?, ?, ?, ?,
|
|
3848
|
-
`, [nowCanonical, usageStampId, inviteKey, peerId, peerKey, peerStampId,
|
|
4028
|
+
values (?, ?, ?, ?, ?, ?, ?)
|
|
4029
|
+
`, [nowCanonical, usageStampId, inviteKey, peerId, peerKey, peerStampId, inviteSig, peerSig]);
|
|
3849
4030
|
});
|
|
3850
|
-
log('Redeemed cadre invitation %s: %s admitted (
|
|
3851
|
-
return { alreadyMember: false, peerStampId
|
|
4031
|
+
log('Redeemed cadre invitation %s: %s admitted (usage %s)', inviteKey, peerId, usageStampId);
|
|
4032
|
+
return { alreadyMember: false, peerStampId };
|
|
3852
4033
|
});
|
|
3853
4034
|
}
|
|
3854
|
-
/**
|
|
3855
|
-
* The rows a redemption would seat that are already present: the device's `CadrePeer`
|
|
3856
|
-
* stamp, and — only when the invitation grants ownership — its `OwnerKey` stamp. Raw stamp
|
|
3857
|
-
* reads ({@link queryStampId}), so a physically present row counts; inside the locked
|
|
3858
|
-
* body, so never retried.
|
|
3859
|
-
*/
|
|
3860
|
-
async readSeatedAdmission(peerId, peerKey, grantsOwner) {
|
|
3861
|
-
return {
|
|
3862
|
-
peerStampId: await this.queryStampId('CadrePeer', peerId, false),
|
|
3863
|
-
ownerStampId: grantsOwner ? await this.queryStampId('OwnerKey', peerKey, false) : null,
|
|
3864
|
-
};
|
|
3865
|
-
}
|
|
3866
4035
|
/**
|
|
3867
4036
|
* Refuse a redemption that would consume a seat the cadre invitation does not have, by
|
|
3868
4037
|
* name ({@link InvitationExhaustedError}) — the cadre-invitation twin of
|
|
@@ -3870,15 +4039,225 @@ export class ControlDatabase {
|
|
|
3870
4039
|
* generic `Authorized` failure a caller would otherwise report as retryable. Reads the
|
|
3871
4040
|
* committed count inside the locked body, so on a same-node race the loser is refused here.
|
|
3872
4041
|
*/
|
|
3873
|
-
|
|
3874
|
-
|
|
4042
|
+
assertCadreInviteSeatRemains(invite) {
|
|
4043
|
+
return this.assertIssuedSeatRemains(invite, key => this.countCadreInviteUsage(key, false));
|
|
4044
|
+
}
|
|
4045
|
+
/** The body of {@link assertCadreInviteSeatRemains} and {@link assertOwnerGrantSeatRemains}; `countUsage` reads inside the locked body. */
|
|
4046
|
+
async assertIssuedSeatRemains(row, countUsage) {
|
|
4047
|
+
if (row.totalUses === null) {
|
|
3875
4048
|
return;
|
|
3876
4049
|
}
|
|
3877
|
-
const used = await
|
|
3878
|
-
if (used >=
|
|
3879
|
-
throw new InvitationExhaustedError(
|
|
4050
|
+
const used = await countUsage(row.key);
|
|
4051
|
+
if (used >= row.totalUses) {
|
|
4052
|
+
throw new InvitationExhaustedError(row.key, used, row.totalUses);
|
|
3880
4053
|
}
|
|
3881
4054
|
}
|
|
4055
|
+
// ── Owner grants (OwnerGrant / OwnerGrantUsage) ─────────────────────────────────────────
|
|
4056
|
+
/**
|
|
4057
|
+
* Mint and insert an owner-signed `OwnerGrant`: a grant of ownership to whoever holds the
|
|
4058
|
+
* grant's private half, redeemable through any member machine ({@link redeemOwnerGrant})
|
|
4059
|
+
* to seat an owner key the holder generates or already holds. No machine is admitted and no
|
|
4060
|
+
* peer id is named. The grant twin of {@link insertCadreInvite}: the owner signs the whole
|
|
4061
|
+
* row ({@link ownerGrantAddMessage}), `ExpiresAt` through {@link canonicalDatetime}, and
|
|
4062
|
+
* the returned row is what the grant bundle carries, re-inserted verbatim by
|
|
4063
|
+
* {@link seatOwnerGrant} on a member that has not received it by replication.
|
|
4064
|
+
*
|
|
4065
|
+
* @param grant.key - the grant's ed25519 PUBLIC key (base64url), the row's primary key; the
|
|
4066
|
+
* caller minted the pair and hands the private half to the holder out of band
|
|
4067
|
+
* @param grant.label - the label every key this grant seats will carry
|
|
4068
|
+
* ({@link ownerKeyLabel}'s rule: trimmed, at most 64 characters, blank meaning none); the
|
|
4069
|
+
* issuer's choice, which the holder cannot change
|
|
4070
|
+
* @param grant.expiresAtMs - epoch ms after which it cannot be redeemed; omit or null for
|
|
4071
|
+
* never. The short default lifetime is the node API's to apply, not this writer's.
|
|
4072
|
+
* @param grant.totalUses - how many redemptions it allows; omit for unlimited
|
|
4073
|
+
* @returns the row as stored, `expiresAt` in its canonical form
|
|
4074
|
+
*/
|
|
4075
|
+
async insertOwnerGrant(grant, ownerKey, signMessage) {
|
|
4076
|
+
this.ensureInitialized();
|
|
4077
|
+
const key = requireEd25519PublicKeyB64(grant.key, 'owner grant key');
|
|
4078
|
+
const signed = {
|
|
4079
|
+
key,
|
|
4080
|
+
label: ownerKeyLabel(grant.label),
|
|
4081
|
+
expiresAt: grant.expiresAtMs == null ? null : await canonicalDatetime(this.db, grant.expiresAtMs),
|
|
4082
|
+
totalUses: grant.totalUses ?? null,
|
|
4083
|
+
stampId: generateStampId(this.config.libp2pNode.peerId.toString()),
|
|
4084
|
+
};
|
|
4085
|
+
const row = {
|
|
4086
|
+
...signed,
|
|
4087
|
+
issuerKey: ownerKey,
|
|
4088
|
+
issuerSig: await signMessage(ownerGrantAddMessage(this.config.partyId, signed)),
|
|
4089
|
+
};
|
|
4090
|
+
await this.lockedWithRetry(() => this.execOwnerGrantInsert(row), {}, 'owner-grant-insert');
|
|
4091
|
+
log('Owner grant inserted: %s (label=%s)', key, row.label ?? 'none');
|
|
4092
|
+
return row;
|
|
4093
|
+
}
|
|
4094
|
+
/**
|
|
4095
|
+
* Seat an owner-signed `OwnerGrant` row received inside a grant bundle, under its own stored
|
|
4096
|
+
* `IssuerKey`/`IssuerSig` as the write context — insert-if-absent, as {@link seatCadreInvite}.
|
|
4097
|
+
*
|
|
4098
|
+
* @returns `true` when this call inserted the row, `false` when it was already present.
|
|
4099
|
+
* @throws {OwnerGrantIssuerUnknownError} when `OwnerGrant.AuthorizedInsert` refuses it: the
|
|
4100
|
+
* issuer is not an owner on this node (not replicated yet, or removed), or the signature does
|
|
4101
|
+
* not verify. {@link IssuedRowNotHereYetError} when the cohort holds the row and this node
|
|
4102
|
+
* cannot read it yet. Every other refusal (a withdrawn stamp, `NotRevoked`) propagates as is.
|
|
4103
|
+
*/
|
|
4104
|
+
async seatOwnerGrant(row) {
|
|
4105
|
+
return this.seatIssuedRow('OwnerGrant', row.key, () => this.execOwnerGrantInsert(row), cause => new OwnerGrantIssuerUnknownError(row.key, row.issuerKey, { cause }));
|
|
4106
|
+
}
|
|
4107
|
+
/** Bare `OwnerGrant` insert shared by {@link insertOwnerGrant} and {@link seatOwnerGrant}; the caller holds the lock. */
|
|
4108
|
+
async execOwnerGrantInsert(row) {
|
|
4109
|
+
await this.db.exec(`
|
|
4110
|
+
insert into CadreControl.OwnerGrant (Key, Label, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId)
|
|
4111
|
+
with context OwnerKey = ?, Signature = ?
|
|
4112
|
+
values (?, ?, ?, ?, ?, ?, ?)
|
|
4113
|
+
`, [
|
|
4114
|
+
row.issuerKey, row.issuerSig,
|
|
4115
|
+
row.key, row.label, row.expiresAt, row.totalUses,
|
|
4116
|
+
row.issuerKey, row.issuerSig, row.stampId,
|
|
4117
|
+
]);
|
|
4118
|
+
}
|
|
4119
|
+
/**
|
|
4120
|
+
* Read one `OwnerGrant` row by key, or null when absent. Withdrawn rows are returned too,
|
|
4121
|
+
* as {@link queryCadreInvite} returns them: the row is the holder's proof and never goes
|
|
4122
|
+
* away. `retry: false` is passed by {@link redeemOwnerGrant}, inside its locked body.
|
|
4123
|
+
*/
|
|
4124
|
+
async queryOwnerGrant(key, retry = true) {
|
|
4125
|
+
this.ensureInitialized();
|
|
4126
|
+
for (const row of await this.readRows(`${OWNER_GRANT_SELECT_SQL} where Key = ?`, [key], 'owner-grant', retry)) {
|
|
4127
|
+
return ownerGrantRowOf(row);
|
|
4128
|
+
}
|
|
4129
|
+
return null;
|
|
4130
|
+
}
|
|
4131
|
+
/**
|
|
4132
|
+
* Every `OwnerGrant` row this node holds, withdrawn and expired ones included. Read by the
|
|
4133
|
+
* anchor sync when at least one `OwnerKey` row is consent-seated (`CadreNode.loadOwnerGrantChain`);
|
|
4134
|
+
* `retry: false` is forwarded from the membership-gate refresh for {@link queryCadrePeers}' reason.
|
|
4135
|
+
*/
|
|
4136
|
+
async queryOwnerGrants(retry = true) {
|
|
4137
|
+
this.ensureInitialized();
|
|
4138
|
+
const rows = await this.readRows(OWNER_GRANT_SELECT_SQL, undefined, 'owner-grants', retry);
|
|
4139
|
+
return rows.map(ownerGrantRowOf);
|
|
4140
|
+
}
|
|
4141
|
+
/** Every `OwnerGrantUsage` row this node holds — the redemption records a grant-seated `OwnerKey` row is verified against. Same `retry` contract as {@link queryOwnerGrants}. */
|
|
4142
|
+
async queryOwnerGrantUsages(retry = true) {
|
|
4143
|
+
this.ensureInitialized();
|
|
4144
|
+
const rows = await this.readRows('select UsageStampId, GrantKey, SeatedKey, SeatedStampId, GrantSig, OwnerSig from CadreControl.OwnerGrantUsage', undefined, 'owner-grant-usages', retry);
|
|
4145
|
+
return rows.map(ownerGrantUsageRowOf);
|
|
4146
|
+
}
|
|
4147
|
+
/**
|
|
4148
|
+
* How many times an owner grant has been redeemed (its `OwnerGrantUsage` rows), served
|
|
4149
|
+
* through the `OwnerGrantUsageByGrant` index. A permissive PRE-check like
|
|
4150
|
+
* {@link countCadreInviteUsage}: the cap is `OwnerGrantUsage.Authorized`'s own committed count.
|
|
4151
|
+
*/
|
|
4152
|
+
async countOwnerGrantUsage(key, retry = true) {
|
|
4153
|
+
return this.countUsageRows('select count(1) as Count from CadreControl.OwnerGrantUsage where GrantKey = ?', key, 'owner-grant-usage-count', retry);
|
|
4154
|
+
}
|
|
4155
|
+
/**
|
|
4156
|
+
* Is this one owner grant still redeemable here — not withdrawn, unexpired, uses left, issuer
|
|
4157
|
+
* still an owner — judged as {@link isCadreInviteLive} judges an invitation: by the row this
|
|
4158
|
+
* node holds under `grant.key`, else by `grant` itself (a bundle's copy, not seated yet)
|
|
4159
|
+
* when its issuer signature verifies. A permissive pre-check; {@link seatOwnerGrant} and the
|
|
4160
|
+
* redemption's own write enforce every condition.
|
|
4161
|
+
*/
|
|
4162
|
+
async isOwnerGrantLive(grant, nowMs = Date.now()) {
|
|
4163
|
+
this.ensureInitialized();
|
|
4164
|
+
const row = await this.queryOwnerGrant(grant.key) ?? (verifyOwnerGrantRow(this.config.partyId, grant) ? grant : null);
|
|
4165
|
+
return this.issuedRowLive('OwnerGrant', row, nowMs, key => this.countOwnerGrantUsage(key));
|
|
4166
|
+
}
|
|
4167
|
+
/**
|
|
4168
|
+
* Every `OwnerGrant` row this node holds with its standing — recorded redemptions, withdrawal,
|
|
4169
|
+
* and whether it is still redeemable — for an owner's listing, as {@link listCadreInviteStatuses}.
|
|
4170
|
+
*/
|
|
4171
|
+
async listOwnerGrantStatuses(nowMs = Date.now()) {
|
|
4172
|
+
const standings = await this.issuedRowStandings('OwnerGrant', await this.queryOwnerGrants(), nowMs, key => this.countOwnerGrantUsage(key));
|
|
4173
|
+
return standings.map(({ row, ...standing }) => ({ grant: row, ...standing }));
|
|
4174
|
+
}
|
|
4175
|
+
/**
|
|
4176
|
+
* Owner-signed WITHDRAWAL of an owner grant: file the `Revocation` tombstone retiring its
|
|
4177
|
+
* stamp, with the row kept, exactly as {@link withdrawCadreInvite} withdraws an invitation
|
|
4178
|
+
* and for the same reason (every key the grant seated proves itself through the row;
|
|
4179
|
+
* `OwnerGrant.NoDelete`, `Revocation.RowIsGone`). Afterwards `OwnerGrantUsage.Authorized`
|
|
4180
|
+
* refuses every further redemption and `OwnerGrant.NotRevoked` refuses re-seating the
|
|
4181
|
+
* holder's copy on any node holding the tombstone. Keys already seated stay owners;
|
|
4182
|
+
* removing one is {@link deleteOwnerKey}.
|
|
4183
|
+
*
|
|
4184
|
+
* Idempotent: `false` (nothing written) when the row is absent here or already withdrawn,
|
|
4185
|
+
* `true` when this call filed the tombstone. Fires the guarded-delete listener, so a
|
|
4186
|
+
* withdrawal committed while alone is re-issued on cohort growth.
|
|
4187
|
+
*/
|
|
4188
|
+
async withdrawOwnerGrant(key, ownerKey, signMessage) {
|
|
4189
|
+
return this.withdrawIssuedRow('OwnerGrant', key, ownerKey, signMessage);
|
|
4190
|
+
}
|
|
4191
|
+
/**
|
|
4192
|
+
* Redeem a seated `OwnerGrant` on the issuer's behalf: seat `seatedKey` as an owner with NO
|
|
4193
|
+
* owner signature. One transaction writes the `OwnerKey` row (the grant branch of
|
|
4194
|
+
* `Authorized`: `VouchOwner` the issuer, `VouchSig` null, `VouchUsage` the usage stamp,
|
|
4195
|
+
* `Label` the grant's) and then the `OwnerGrantUsage` row that authorizes it; the usage
|
|
4196
|
+
* row's `SeatedExists` reads the owner row back, so the two are bound to this exact
|
|
4197
|
+
* redemption and neither can be held in reserve. The grant must already be committed
|
|
4198
|
+
* ({@link seatOwnerGrant}): both consent reads are of `committed.OwnerGrant`. No machine is
|
|
4199
|
+
* admitted and no peer id is named.
|
|
4200
|
+
*
|
|
4201
|
+
* The two signatures are minted where the private keys are (the grant's and the seated
|
|
4202
|
+
* key's), over {@link ownerGrantRedeemMessage} / {@link ownerGrantConsentMessage}; this
|
|
4203
|
+
* machine only verifies them, by writing the row.
|
|
4204
|
+
*
|
|
4205
|
+
* Idempotent, and a no-op for a key that is already an owner: when `seatedKey` has a live
|
|
4206
|
+
* `OwnerKey` row here nothing is written and no use is spent — `alreadyOwner: true` with
|
|
4207
|
+
* that row's stamp. The seat check ({@link assertOwnerGrantSeatRemains}) runs after that, so
|
|
4208
|
+
* the retry of a single-use redemption is answered `alreadyOwner` rather than refused as
|
|
4209
|
+
* exhausted. A key removed earlier is seated afresh under a new stamp (`NotRevoked` passes),
|
|
4210
|
+
* except on a machine that still holds its retired row, which refuses by name
|
|
4211
|
+
* ({@link liveOwnerKeyStamp}).
|
|
4212
|
+
*
|
|
4213
|
+
* Through {@link mutateCadrePeer}, so the membership hub is notified and the anchors follow;
|
|
4214
|
+
* inside the locked body every read passes `retry: false` and the stamp is minted per
|
|
4215
|
+
* attempt, so a {@link lockedWithRetry} re-run starts clean.
|
|
4216
|
+
*
|
|
4217
|
+
* @throws {InvitationExhaustedError} when the grant's uses are all spent (by name, ahead of
|
|
4218
|
+
* the schema's generic `Authorized` refusal); its `token` is the grant key.
|
|
4219
|
+
* {@link IssuedRowNotHereYetError} when no `OwnerGrant` row is readable here.
|
|
4220
|
+
*/
|
|
4221
|
+
async redeemOwnerGrant(params) {
|
|
4222
|
+
this.ensureInitialized();
|
|
4223
|
+
const { grantKey, seatedKey, usageStampId, grantSig, ownerSig } = params;
|
|
4224
|
+
const nowMs = params.nowMs ?? Date.now();
|
|
4225
|
+
log('Redeeming owner grant %s for %s', grantKey, seatedKey);
|
|
4226
|
+
return this.mutateCadrePeer('owner-grant-redeem', async () => {
|
|
4227
|
+
const grant = await this.queryOwnerGrant(grantKey, false);
|
|
4228
|
+
if (grant === null) {
|
|
4229
|
+
throw new IssuedRowNotHereYetError('OwnerGrant', grantKey);
|
|
4230
|
+
}
|
|
4231
|
+
const heldStamp = await this.liveOwnerKeyStamp(seatedKey);
|
|
4232
|
+
if (heldStamp !== null) {
|
|
4233
|
+
log('redeemOwnerGrant: %s is already an owner; nothing written', seatedKey);
|
|
4234
|
+
return { alreadyOwner: true, seatedStampId: heldStamp };
|
|
4235
|
+
}
|
|
4236
|
+
await this.assertOwnerGrantSeatRemains(grant);
|
|
4237
|
+
// Minted inside the body, so a re-run mints afresh over a rolled-back attempt rather
|
|
4238
|
+
// than re-presenting a stamp the usage row may already name.
|
|
4239
|
+
const seatedStampId = generateStampId(this.config.libp2pNode.peerId.toString());
|
|
4240
|
+
const nowCanonical = await canonicalDatetime(this.db, nowMs);
|
|
4241
|
+
await this.inTransaction('owner-grant-redeem', async () => {
|
|
4242
|
+
await this.db.exec(`
|
|
4243
|
+
insert into CadreControl.OwnerKey (Key, Label, StampId, VouchOwner, VouchSig, VouchUsage)
|
|
4244
|
+
with context OwnerKey = null, Signature = null
|
|
4245
|
+
values (?, ?, ?, ?, null, ?)
|
|
4246
|
+
`, [seatedKey, grant.label, seatedStampId, grant.issuerKey, usageStampId]);
|
|
4247
|
+
await this.db.exec(`
|
|
4248
|
+
insert into CadreControl.OwnerGrantUsage (UsageStampId, GrantKey, SeatedKey, SeatedStampId, GrantSig, OwnerSig)
|
|
4249
|
+
with context Now = ?
|
|
4250
|
+
values (?, ?, ?, ?, ?, ?)
|
|
4251
|
+
`, [nowCanonical, usageStampId, grantKey, seatedKey, seatedStampId, grantSig, ownerSig]);
|
|
4252
|
+
});
|
|
4253
|
+
log('Redeemed owner grant %s: %s seated as an owner (usage %s)', grantKey, seatedKey, usageStampId);
|
|
4254
|
+
return { alreadyOwner: false, seatedStampId };
|
|
4255
|
+
});
|
|
4256
|
+
}
|
|
4257
|
+
/** {@link assertCadreInviteSeatRemains} for an owner grant: refuse an exhausted grant by name, ahead of the write. */
|
|
4258
|
+
assertOwnerGrantSeatRemains(grant) {
|
|
4259
|
+
return this.assertIssuedSeatRemains(grant, key => this.countOwnerGrantUsage(key, false));
|
|
4260
|
+
}
|
|
3882
4261
|
/**
|
|
3883
4262
|
* Close the database and cleanup resources.
|
|
3884
4263
|
*
|