@serfab/cadre-core 1.15.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 +16 -7
- package/src/bootstrap-peer-store-file.ts +44 -44
- package/src/bootstrap-peer-store.ts +214 -214
- package/src/cadre-invite-protocol.ts +131 -531
- package/src/cadre-join-request.ts +188 -167
- 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 +102 -5
- 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/src/control-database.ts
CHANGED
|
@@ -7,13 +7,15 @@ import optimysticPlugin from '@optimystic/quereus-plugin-optimystic/plugin';
|
|
|
7
7
|
import { digest, randomBytes } from '@optimystic/quereus-plugin-crypto';
|
|
8
8
|
import type { Libp2p } from '@libp2p/interface';
|
|
9
9
|
import type { IRepo } from '@optimystic/db-core';
|
|
10
|
-
import type { StrandRow, JoinRequestRow, JoinOutcome, PendingJoin, NodeProfile, PeerAddressRecord, CadrePeerRow, OwnerKeyRow, RevocationRow, RevocationLedgerOpenResult, DeviceTokenRecord, DeviceTokenRow, PushPlatform, CadreInviteRow, CadreInviteUsageRow, CadreInviteRedemptionResult, CadreInviteStatus, SeedPeer, MachineRemovalResult } from './types.js';
|
|
11
|
-
import {
|
|
10
|
+
import type { StrandRow, JoinRequestRow, JoinOutcome, PendingJoin, NodeProfile, PeerAddressRecord, CadrePeerRow, OwnerKeyRow, RevocationRow, RevocationLedgerOpenResult, DeviceTokenRecord, DeviceTokenRow, PushPlatform, CadreInviteRow, CadreInviteUsageRow, CadreInviteRedemptionResult, CadreInviteStatus, OwnerGrantRow, OwnerGrantUsageRow, OwnerGrantRedemptionResult, OwnerGrantStatus, SeedPeer, MachineRemovalResult } from './types.js';
|
|
11
|
+
import { APPLY_CONTROL_SCHEMA, CONTROL_SCHEMA_DECLARATION, controlSchemaFileDeclaration } from './control-schema.js';
|
|
12
|
+
import { refuseIncompatibleControlStore } from './control-schema-version.js';
|
|
12
13
|
import { canonicalDatetime } from './canonical-datetime.js';
|
|
13
|
-
import { controlAuthorizationFields, unboundAuthorizationFields, cadreInviteRowFields, CONTROL_TABLES } from './control-authorization.js';
|
|
14
|
-
import type { ControlTable, RevocableTable, BoundControlDomain, UnboundControlDomain, ControlAction, CadreInviteSignedFields } from './control-authorization.js';
|
|
15
|
-
import {
|
|
16
|
-
import { verifyCadreInviteRow } from './peer-authorization.js';
|
|
14
|
+
import { controlAuthorizationFields, unboundAuthorizationFields, cadreInviteRowFields, ownerGrantRowFields, CONTROL_TABLES } from './control-authorization.js';
|
|
15
|
+
import type { ControlTable, RevocableTable, BoundControlDomain, UnboundControlDomain, ControlAction, CadreInviteSignedFields, OwnerGrantSignedFields } from './control-authorization.js';
|
|
16
|
+
import { requireEd25519PublicKeyB64 } from './ed25519-key.js';
|
|
17
|
+
import { verifyCadreInviteRow, verifyOwnerGrantRow } from './peer-authorization.js';
|
|
18
|
+
import { OWNER_KEY_MAX_LABEL } from './owner-key-export.js';
|
|
17
19
|
import { retryControlWrite, SCHEMA_INIT_RETRY_POLICY } from './control-write-retry.js';
|
|
18
20
|
import type { ControlWriteRetryOptions } from './control-write-retry.js';
|
|
19
21
|
import { isCohortUnreachableRead, retryControlRead } from './control-read-retry.js';
|
|
@@ -97,13 +99,14 @@ export class FormationAbortedError extends Error {
|
|
|
97
99
|
/**
|
|
98
100
|
* The invitation's seat budget is already spent: the count of recorded usage rows for the
|
|
99
101
|
* invitation has reached its `TotalUses`, so this redemption cannot be given a seat. `token`
|
|
100
|
-
* is the `FormationInvite.Token` or the `
|
|
101
|
-
* redeemed against.
|
|
102
|
+
* is the `FormationInvite.Token`, the `CadreInvite.Key` or the `OwnerGrant.Key`, whichever
|
|
103
|
+
* table the caller redeemed against.
|
|
102
104
|
*
|
|
103
105
|
* Raised off the committed-count check ({@link ControlDatabase.assertSeatRemains}) ahead of
|
|
104
106
|
* the write, inside {@link ControlDatabase.redeemInvitation} /
|
|
105
107
|
* {@link ControlDatabase.recordFormationUsage}, off the same check for a cadre invitation
|
|
106
|
-
* ({@link ControlDatabase.redeemCadreInvite})
|
|
108
|
+
* ({@link ControlDatabase.redeemCadreInvite}) and an owner grant
|
|
109
|
+
* ({@link ControlDatabase.redeemOwnerGrant}), and off the unlocked pre-check
|
|
107
110
|
* `ControlFormationUsageRecorder.authorizeUsage` runs before the manager issues a membership
|
|
108
111
|
* pass — and off nothing else. It exists because
|
|
109
112
|
* without it the refusal surfaces as a generic `CHECK constraint failed: Authorized` from the
|
|
@@ -148,8 +151,51 @@ export class CadreInviteIssuerUnknownError extends Error {
|
|
|
148
151
|
}
|
|
149
152
|
}
|
|
150
153
|
|
|
151
|
-
/**
|
|
152
|
-
|
|
154
|
+
/**
|
|
155
|
+
* {@link ControlDatabase.seatOwnerGrant} was refused by `OwnerGrant.AuthorizedInsert`: the
|
|
156
|
+
* grant twin of {@link CadreInviteIssuerUnknownError}, for the same two causes (the issuer's
|
|
157
|
+
* `OwnerKey` row has not replicated here yet or has since been removed; or a tampered row).
|
|
158
|
+
* Its own class rather than a shared one so a caller that handles invitation and grant
|
|
159
|
+
* bundles can tell by `instanceof` which credential was refused.
|
|
160
|
+
*/
|
|
161
|
+
export class OwnerGrantIssuerUnknownError extends Error {
|
|
162
|
+
constructor(
|
|
163
|
+
readonly grantKey: string,
|
|
164
|
+
readonly issuerKey: string,
|
|
165
|
+
options?: { cause?: unknown }
|
|
166
|
+
) {
|
|
167
|
+
super(
|
|
168
|
+
`Owner grant ${grantKey} cannot be seated here: its issuer ${issuerKey} is not an owner on this node, or its signature does not verify`,
|
|
169
|
+
options
|
|
170
|
+
);
|
|
171
|
+
this.name = 'OwnerGrantIssuerUnknownError';
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* An issued row (a `CadreInvite` or an `OwnerGrant`) the cadre holds that this node cannot read
|
|
177
|
+
* yet: {@link ControlDatabase.seatCadreInvite} / {@link ControlDatabase.seatOwnerGrant} had its
|
|
178
|
+
* insert refused by a cohort member already holding the row, and a refreshed read here still
|
|
179
|
+
* finds nothing; or the redemption that follows the seat finds no row. It clears once this
|
|
180
|
+
* node's store catches up, which is why the redemption paths answer it with the retryable
|
|
181
|
+
* `conflict` code.
|
|
182
|
+
*/
|
|
183
|
+
export class IssuedRowNotHereYetError extends Error {
|
|
184
|
+
constructor(
|
|
185
|
+
readonly table: IssuedTable,
|
|
186
|
+
readonly key: string,
|
|
187
|
+
options?: { cause?: unknown }
|
|
188
|
+
) {
|
|
189
|
+
super(`${table} ${key} is held by the cadre but not readable on this node yet; try again once it has synced`, options);
|
|
190
|
+
this.name = 'IssuedRowNotHereYetError';
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The refusal {@link CadreInviteIssuerUnknownError} and {@link OwnerGrantIssuerUnknownError}
|
|
196
|
+
* wrap — the constraint named, as the engine words it; both tables name theirs `AuthorizedInsert`.
|
|
197
|
+
*/
|
|
198
|
+
const ISSUER_UNAUTHORIZED = /CHECK constraint failed: AuthorizedInsert\b/;
|
|
153
199
|
|
|
154
200
|
/**
|
|
155
201
|
* What a recorded `FormationUsage` row came out as.
|
|
@@ -370,6 +416,51 @@ export function cadreInviteConsentMessage(partyId: string, fields: CadreInviteRe
|
|
|
370
416
|
]);
|
|
371
417
|
}
|
|
372
418
|
|
|
419
|
+
/**
|
|
420
|
+
* The exact bytes an owner signs to seat an `OwnerGrant` row — the TS mirror of the `'add'`
|
|
421
|
+
* digest in `OwnerGrant.AuthorizedInsert`, over the whole row in the schema's order with
|
|
422
|
+
* nullable columns as `''` (`ownerGrantRowFields`, shared with the base64url verifier
|
|
423
|
+
* `peer-authorization.ts`'s `ownerGrantAddDigest`). `expiresAt` must already be the stored
|
|
424
|
+
* canonical `datetime` string, as {@link ControlDatabase.insertOwnerGrant} produces it, for
|
|
425
|
+
* the reason on {@link cadreInviteAddMessage}.
|
|
426
|
+
*/
|
|
427
|
+
export function ownerGrantAddMessage(partyId: string, row: OwnerGrantSignedFields): Uint8Array {
|
|
428
|
+
return buildAuthorizationMessage('CadreControl.OwnerGrant', 'add', partyId, ownerGrantRowFields(row));
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** The fields both owner-grant redemption signatures cover; see {@link ownerGrantRedeemMessage}. */
|
|
432
|
+
export interface OwnerGrantRedemptionFields {
|
|
433
|
+
grantKey: string;
|
|
434
|
+
/** The single-use nonce the holder minted for this redemption (`generateStampId`). */
|
|
435
|
+
usageStampId: string;
|
|
436
|
+
/** The owner key being seated (base64url ed25519). */
|
|
437
|
+
seatedKey: string;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* The exact bytes the HOLDER of an owner grant signs, with the grant's private key, to redeem
|
|
442
|
+
* it once — the TS mirror of the `'redeem'` digest `OwnerGrantUsage.GrantPossessed` verifies
|
|
443
|
+
* against the grant's public key. Binding the seated key and the nonce means a captured
|
|
444
|
+
* redemption can seat no other key and spend no other use.
|
|
445
|
+
*/
|
|
446
|
+
export function ownerGrantRedeemMessage(partyId: string, fields: OwnerGrantRedemptionFields): Uint8Array {
|
|
447
|
+
return buildAuthorizationMessage('CadreControl.OwnerGrantUsage', 'redeem', partyId, [
|
|
448
|
+
fields.grantKey, fields.usageStampId, fields.seatedKey,
|
|
449
|
+
]);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* The exact bytes the SEATED key signs, proving the holder holds it — the TS mirror of the
|
|
454
|
+
* `'consent'` digest `OwnerGrantUsage.OwnerConsented` verifies against `SeatedKey`. Same
|
|
455
|
+
* fields as {@link ownerGrantRedeemMessage} under a distinct action tag, so neither
|
|
456
|
+
* signature can stand in for the other.
|
|
457
|
+
*/
|
|
458
|
+
export function ownerGrantConsentMessage(partyId: string, fields: OwnerGrantRedemptionFields): Uint8Array {
|
|
459
|
+
return buildAuthorizationMessage('CadreControl.OwnerGrantUsage', 'consent', partyId, [
|
|
460
|
+
fields.grantKey, fields.usageStampId, fields.seatedKey,
|
|
461
|
+
]);
|
|
462
|
+
}
|
|
463
|
+
|
|
373
464
|
/**
|
|
374
465
|
* Minimal interface for the CollectionFactory returned by the optimystic plugin.
|
|
375
466
|
* We only need the methods we actually use.
|
|
@@ -418,14 +509,23 @@ const GUARDED_KEY_COLUMN: Readonly<Record<RevocableTable, GuardedKeyColumn>> = {
|
|
|
418
509
|
JoinRequest: 'Id',
|
|
419
510
|
DeviceToken: 'PeerId',
|
|
420
511
|
CadreInvite: 'Key',
|
|
512
|
+
OwnerGrant: 'Key',
|
|
421
513
|
};
|
|
422
514
|
|
|
423
515
|
/**
|
|
424
|
-
* A guarded table
|
|
425
|
-
*
|
|
426
|
-
*
|
|
516
|
+
* A guarded table of owner-issued credentials: signed rows that travel inside a bundle, are
|
|
517
|
+
* seated verbatim by any member ({@link ControlDatabase.seatCadreInvite},
|
|
518
|
+
* {@link ControlDatabase.seatOwnerGrant}), and are never deleted (`NoDelete`) because the
|
|
519
|
+
* rows they admitted prove themselves through them. Withdrawing one is a tombstone alone
|
|
520
|
+
* ({@link ControlDatabase.withdrawCadreInvite}, {@link ControlDatabase.withdrawOwnerGrant}).
|
|
427
521
|
*/
|
|
428
|
-
type
|
|
522
|
+
type IssuedTable = Extract<RevocableTable, 'CadreInvite' | 'OwnerGrant'>;
|
|
523
|
+
|
|
524
|
+
/** The write-reason prefix each {@link IssuedTable}'s seat and withdrawal log under. */
|
|
525
|
+
const ISSUED_TABLE_REASON: Readonly<Record<IssuedTable, string>> = { CadreInvite: 'cadre-invite', OwnerGrant: 'owner-grant' };
|
|
526
|
+
|
|
527
|
+
/** A guarded table an owner may remove a row from: every revocable table but the {@link IssuedTable}s. */
|
|
528
|
+
type RemovableTable = Exclude<RevocableTable, IssuedTable>;
|
|
429
529
|
|
|
430
530
|
/**
|
|
431
531
|
* One row incarnation's owner-signed removal, ready for {@link ControlDatabase.execGuardedRemoval}:
|
|
@@ -443,7 +543,7 @@ interface GuardedRemoval {
|
|
|
443
543
|
/**
|
|
444
544
|
* The exact bytes an owner signs to append one `Revocation` tombstone — `Revocation.Authorized`
|
|
445
545
|
* binds the whole (TableName, RowKey, StampId) row under its own domain tag. Shared by every
|
|
446
|
-
* tombstone writer (a guarded removal,
|
|
546
|
+
* tombstone writer (a guarded removal, an issued row's withdrawal, the ledger marker) so the
|
|
447
547
|
* field order lives in one place; the base64url verifier is `peer-authorization.ts`'s
|
|
448
548
|
* `revocationDigest`.
|
|
449
549
|
*/
|
|
@@ -455,42 +555,85 @@ function revocationTombstoneMessage(partyId: string, ref: { tableName: string; r
|
|
|
455
555
|
* The exact bytes an owner signs to seat a further `OwnerKey` row (the signed-add branch of
|
|
456
556
|
* `OwnerKey.Authorized`); the base64url twin is `peer-authorization.ts`'s `ownerKeyAddDigest`.
|
|
457
557
|
*/
|
|
458
|
-
function ownerKeyAddMessage(partyId: string, key: string, stampId: string): Uint8Array {
|
|
459
|
-
return buildAuthorizationMessage('CadreControl.OwnerKey', 'add', partyId, [key, stampId]);
|
|
558
|
+
function ownerKeyAddMessage(partyId: string, key: string, label: string | null, stampId: string): Uint8Array {
|
|
559
|
+
return buildAuthorizationMessage('CadreControl.OwnerKey', 'add', partyId, [key, label ?? '', stampId]);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* An owner key's label as its row stores it: trimmed, a blank one left out, and refused past
|
|
564
|
+
* {@link OWNER_KEY_MAX_LABEL} by name rather than by the schema's `LabelLength` check.
|
|
565
|
+
* Idempotent, so a caller that checks before opening an owner approval can pass the result on.
|
|
566
|
+
*/
|
|
567
|
+
export function ownerKeyLabel(label: string | null | undefined): string | null {
|
|
568
|
+
const trimmed = label?.trim();
|
|
569
|
+
if (!trimmed) {
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
572
|
+
if (trimmed.length > OWNER_KEY_MAX_LABEL) {
|
|
573
|
+
throw new Error(`An owner key's label must be at most ${OWNER_KEY_MAX_LABEL} characters (this one has ${trimmed.length})`);
|
|
574
|
+
}
|
|
575
|
+
return trimmed;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Signs one owner digest's raw bytes (no pre-hash) and returns a base64url Ed25519 signature:
|
|
580
|
+
* the callback every owner-signed writer takes. An `OwnerSign` from an open
|
|
581
|
+
* `OwnerSigner.approve` fits it, and so does a synchronous signer.
|
|
582
|
+
*
|
|
583
|
+
* NOTE: writers that read the stamp they sign do so inside the write lock, so the lock is held
|
|
584
|
+
* across these calls, and a `lockedWithRetry` re-run signs again inside the same approval.
|
|
585
|
+
* Free for a software signer. If a prompting signer ever holds the lock long enough to stall
|
|
586
|
+
* other control writes, sign outside the lock and re-check the stamp inside it.
|
|
587
|
+
*/
|
|
588
|
+
export type ControlSign = (message: Uint8Array) => string | Promise<string>;
|
|
589
|
+
|
|
590
|
+
/** What an owner asks {@link ControlDatabase.insertCadreInvite} (or {@link ControlDatabase.signCadreInvite}) to sign. */
|
|
591
|
+
export interface CadreInviteFields {
|
|
592
|
+
/** The invitation public key (base64url ed25519), the row's primary key. */
|
|
593
|
+
key: string;
|
|
594
|
+
/** The device the invitation is for, or null for whoever redeems first. */
|
|
595
|
+
peerId?: string | null;
|
|
596
|
+
/** Epoch ms after which it cannot be redeemed; omit for never. */
|
|
597
|
+
expiresAtMs?: number | null;
|
|
598
|
+
/** How many redemptions it allows; omit for unlimited. */
|
|
599
|
+
totalUses?: number | null;
|
|
460
600
|
}
|
|
461
601
|
|
|
462
|
-
function signGuardedRemoval(
|
|
602
|
+
async function signGuardedRemoval(
|
|
463
603
|
partyId: string,
|
|
464
604
|
tableName: RemovableTable,
|
|
465
605
|
rowKey: string,
|
|
466
606
|
stampId: string,
|
|
467
|
-
signMessage:
|
|
468
|
-
): GuardedRemoval {
|
|
607
|
+
signMessage: ControlSign
|
|
608
|
+
): Promise<GuardedRemoval> {
|
|
469
609
|
const ref = { tableName, rowKey, stampId };
|
|
470
610
|
return {
|
|
471
611
|
ref,
|
|
472
|
-
signature: signMessage(buildAuthorizationMessage(`CadreControl.${tableName}`, 'remove', partyId, [rowKey, stampId])),
|
|
473
|
-
revocationSignature: signMessage(revocationTombstoneMessage(partyId, ref)),
|
|
612
|
+
signature: await signMessage(buildAuthorizationMessage(`CadreControl.${tableName}`, 'remove', partyId, [rowKey, stampId])),
|
|
613
|
+
revocationSignature: await signMessage(revocationTombstoneMessage(partyId, ref)),
|
|
474
614
|
};
|
|
475
615
|
}
|
|
476
616
|
|
|
477
617
|
/** The control tables keyed by a strand id whose repeat insert a caller may treat as idempotent. */
|
|
478
618
|
type StrandIdTable = Extract<RevocableTable, 'Strand' | 'JoinedStrand'>;
|
|
479
619
|
|
|
480
|
-
/** The control tables
|
|
481
|
-
type
|
|
620
|
+
/** The control tables whose repeat insert of a held key a caller may classify. */
|
|
621
|
+
type KeyConflictTable = StrandIdTable | IssuedTable | Extract<RevocableTable, 'JoinRequest'>;
|
|
482
622
|
|
|
483
623
|
/**
|
|
484
|
-
* The rejection a second insert of an already-seated
|
|
624
|
+
* The rejection a second insert of an already-seated key into `table` produces, as the
|
|
485
625
|
* optimystic vtab words it (`uniqueConstraintMessage`, qualified by table name only — no
|
|
486
|
-
* schema prefix, matching `test/control-constraint-helpers.ts`'s `expectUniqueViolation`)
|
|
626
|
+
* schema prefix, matching `test/control-constraint-helpers.ts`'s `expectUniqueViolation`),
|
|
627
|
+
* naming the table's {@link GUARDED_KEY_COLUMN}.
|
|
487
628
|
*
|
|
488
629
|
* Each table's `StampId` is unique too, but a fresh stamp is minted per insert attempt, so
|
|
489
|
-
* only the primary key can collide on a repeat insert.
|
|
490
|
-
*
|
|
630
|
+
* only the primary key can collide on a repeat insert. An issued row's bundle copy repeats its
|
|
631
|
+
* stamp as well, and the engine still names the key (`control-owner-grant.spec.ts` pins it).
|
|
632
|
+
* Matching the column explicitly keeps any OTHER uniqueness failure out of the caller's
|
|
633
|
+
* idempotency branch.
|
|
491
634
|
*/
|
|
492
|
-
function
|
|
493
|
-
return new RegExp(`UNIQUE constraint failed: ${table}
|
|
635
|
+
function keyConflictPattern(table: KeyConflictTable): RegExp {
|
|
636
|
+
return new RegExp(`UNIQUE constraint failed: ${table}\\.${GUARDED_KEY_COLUMN[table]}\\b`, 'i');
|
|
494
637
|
}
|
|
495
638
|
|
|
496
639
|
/**
|
|
@@ -509,7 +652,17 @@ function idConflictPattern(table: IdKeyedTable): RegExp {
|
|
|
509
652
|
* reword reddens there.
|
|
510
653
|
*/
|
|
511
654
|
export function isStrandIdConflict(error: unknown, table: StrandIdTable): boolean {
|
|
512
|
-
return errorChainMatches(error,
|
|
655
|
+
return errorChainMatches(error, keyConflictPattern(table));
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Did a seat of an issued row fail because a cohort member already holds a row under its key?
|
|
660
|
+
* {@link ControlDatabase.seatCadreInvite} / {@link ControlDatabase.seatOwnerGrant} reach this
|
|
661
|
+
* on a node whose store lags the cohort: its guard read missed the row, and the member that
|
|
662
|
+
* checked the commit refused the insert. Text-matched, failing closed, like {@link isStrandIdConflict}.
|
|
663
|
+
*/
|
|
664
|
+
export function isIssuedRowKeyConflict(error: unknown, table: IssuedTable): boolean {
|
|
665
|
+
return errorChainMatches(error, keyConflictPattern(table));
|
|
513
666
|
}
|
|
514
667
|
|
|
515
668
|
/**
|
|
@@ -522,7 +675,7 @@ export function isStrandIdConflict(error: unknown, table: StrandIdTable): boolea
|
|
|
522
675
|
* until the next reap pass.
|
|
523
676
|
*/
|
|
524
677
|
export function isPendingJoinConflict(error: unknown): boolean {
|
|
525
|
-
return errorChainMatches(error,
|
|
678
|
+
return errorChainMatches(error, keyConflictPattern('JoinRequest'));
|
|
526
679
|
}
|
|
527
680
|
|
|
528
681
|
/**
|
|
@@ -569,7 +722,7 @@ export const REVOCATION_LEDGER_MARKER = {
|
|
|
569
722
|
|
|
570
723
|
/**
|
|
571
724
|
* The rejection a second insert of the ledger marker produces: the optimystic vtab's
|
|
572
|
-
* primary-key wording (see {@link
|
|
725
|
+
* primary-key wording (see {@link keyConflictPattern}), naming both columns of
|
|
573
726
|
* `Revocation`'s composite key. Applied only to the marker's own insert, whose key
|
|
574
727
|
* ('Revocation', 'opened') no row but the marker can hold (`RowIsGone`), and `Revocation`
|
|
575
728
|
* has no other unique constraint.
|
|
@@ -588,42 +741,33 @@ function errorChainMatches(error: unknown, pattern: RegExp): boolean {
|
|
|
588
741
|
|
|
589
742
|
/**
|
|
590
743
|
* Project `CadrePeer` rows into the {@link SeedPeer} list a seed, or a cadre invitation
|
|
591
|
-
* redemption reply, carries.
|
|
744
|
+
* redemption reply, carries. `minterPeerId` is the machine minting the seed or answering the
|
|
745
|
+
* redemption; it contributes an entry only through its own `CadrePeer` row.
|
|
592
746
|
*
|
|
593
|
-
*
|
|
594
|
-
*
|
|
595
|
-
*
|
|
596
|
-
*
|
|
597
|
-
*
|
|
598
|
-
*
|
|
599
|
-
* NOTE: this is the one owner lookup deliberately left on the REPLICATED table rather than
|
|
600
|
-
* the node-local anchor. `SeedPeer.isOwner` is a dial hint — the receiver dials owner-flagged
|
|
601
|
-
* peers first — not a trust decision, and the receiver re-derives real trust from its own
|
|
602
|
-
* anchor. So a polluted table costs at most a wasted dial, while anchoring here would
|
|
603
|
-
* silently drop legitimate co-owners this node never pinned. If `isOwner` ever gates
|
|
604
|
-
* anything the receiver TRUSTS, move it to the anchor.
|
|
747
|
+
* A peer is `preferred` when it is the minter, the one machine certain to be reachable by the
|
|
748
|
+
* receiver at that moment, since it is delivering the seed or the reply, or an always-on
|
|
749
|
+
* machine (`Profile = 'storage'`), the ones that stay dialable. Preference is a dial hint and
|
|
750
|
+
* reads no `OwnerKey` row: owner keys are not machines, and a receiver derives trust from its
|
|
751
|
+
* own anchor, never from this list. A `Profile` is self-declared and not checked here, so a
|
|
752
|
+
* machine misdeclaring one costs the receiver a wasted dial at most.
|
|
605
753
|
*/
|
|
606
|
-
export function projectSeedPeers(rows: readonly CadrePeerRow[],
|
|
607
|
-
return rows.map(({ peerId, multiaddr }) => {
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
multiaddrs: multiaddr ? multiaddr.split(',') : [],
|
|
613
|
-
isOwner,
|
|
614
|
-
...(isOwner ? { publicKey: pubKeyB64 } : {}),
|
|
615
|
-
};
|
|
616
|
-
});
|
|
754
|
+
export function projectSeedPeers(rows: readonly CadrePeerRow[], minterPeerId: string): SeedPeer[] {
|
|
755
|
+
return rows.map(({ peerId, multiaddr, profile }) => ({
|
|
756
|
+
peerId,
|
|
757
|
+
multiaddrs: multiaddr ? multiaddr.split(',') : [],
|
|
758
|
+
preferred: peerId === minterPeerId || profile === 'storage',
|
|
759
|
+
}));
|
|
617
760
|
}
|
|
618
761
|
|
|
619
762
|
/**
|
|
620
|
-
* The conditions a cadre invitation
|
|
621
|
-
* not withdrawn, unexpired (`expiresAt <= now` is
|
|
622
|
-
* and issued by a current owner. Shared by
|
|
623
|
-
* {@link ControlDatabase.
|
|
624
|
-
*
|
|
763
|
+
* The conditions an issued credential (a cadre invitation or an owner grant) must still meet
|
|
764
|
+
* to be redeemed, short of its use count: not withdrawn, unexpired (`expiresAt <= now` is
|
|
765
|
+
* expired, as the redemption refuses it), and issued by a current owner. Shared by
|
|
766
|
+
* {@link ControlDatabase.hasLiveCadreInvite}, {@link ControlDatabase.isCadreInviteLive},
|
|
767
|
+
* {@link ControlDatabase.listCadreInviteStatuses} and their owner-grant twins so they cannot
|
|
768
|
+
* drift from each other, or from `CadreInviteUsage.Authorized` / `OwnerGrantUsage.Authorized`.
|
|
625
769
|
*/
|
|
626
|
-
function
|
|
770
|
+
function issuedRowStillOpen(
|
|
627
771
|
row: { stampId: string; issuerKey: string; expiresAt: string | null },
|
|
628
772
|
withdrawn: ReadonlySet<string>,
|
|
629
773
|
owners: ReadonlySet<string>,
|
|
@@ -647,8 +791,8 @@ function cadreInviteStillOpen(
|
|
|
647
791
|
* hold, so a reaped row is recoverable by re-forming. `JoinRequest` is reapable for the same
|
|
648
792
|
* reason: the inviting party and the user also hold its invitation. `OwnerKey` has no
|
|
649
793
|
* production removal path and `MinOneOwner` makes an automated owner-key reap a
|
|
650
|
-
* party-bricking hazard. `CadreInvite`
|
|
651
|
-
*
|
|
794
|
+
* party-bricking hazard. `CadreInvite` and `OwnerGrant` are never deleted at all (their
|
|
795
|
+
* tombstones are withdrawals over rows that stay), so there is nothing to reap.
|
|
652
796
|
*/
|
|
653
797
|
export const REAPABLE_TABLES = ['CadrePeer', 'DeviceToken', 'ValidationKey', 'JoinedStrand', 'JoinRequest'] as const satisfies readonly RevocableTable[];
|
|
654
798
|
export type ReapableTable = (typeof REAPABLE_TABLES)[number];
|
|
@@ -656,7 +800,7 @@ export type ReapableTable = (typeof REAPABLE_TABLES)[number];
|
|
|
656
800
|
/**
|
|
657
801
|
* Runtime membership test for {@link REAPABLE_TABLES}, narrowing a tombstone's
|
|
658
802
|
* `TableName` to the subset {@link ControlDatabase.reapRevokedRow} accepts. A tombstone
|
|
659
|
-
* naming an excluded table (`Strand`, `OwnerKey`, `CadreInvite`) is skipped silently —
|
|
803
|
+
* naming an excluded table (`Strand`, `OwnerKey`, `CadreInvite`, `OwnerGrant`) is skipped silently —
|
|
660
804
|
* `Strand` tombstones are the common case in a party that has unpublished a strand, so a
|
|
661
805
|
* log line per skip would be per-pass noise.
|
|
662
806
|
*/
|
|
@@ -664,18 +808,17 @@ const REAPABLE_TABLE_SET: ReadonlySet<RevocableTable> = new Set<RevocableTable>(
|
|
|
664
808
|
const isReapableTable = (table: RevocableTable): table is ReapableTable => REAPABLE_TABLE_SET.has(table);
|
|
665
809
|
|
|
666
810
|
/** Every `CadreInvite` column, in the order {@link cadreInviteRowOf} reads them; callers append their `where`. */
|
|
667
|
-
const CADRE_INVITE_SELECT_SQL = 'select Key, PeerId,
|
|
811
|
+
const CADRE_INVITE_SELECT_SQL = 'select Key, PeerId, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId from CadreControl.CadreInvite';
|
|
668
812
|
|
|
669
813
|
/**
|
|
670
814
|
* A stored `CadreInvite` row as a {@link CadreInviteRow}. `ExpiresAt` is kept as the
|
|
671
815
|
* engine-canonical string it reads back as — the form the `'add'` signature covers — never
|
|
672
|
-
* parsed to ms here
|
|
816
|
+
* parsed to ms here.
|
|
673
817
|
*/
|
|
674
818
|
function cadreInviteRowOf(row: Record<string, SqlValue>): CadreInviteRow {
|
|
675
819
|
return {
|
|
676
820
|
key: row.Key as string,
|
|
677
821
|
peerId: (row.PeerId as string | null) ?? null,
|
|
678
|
-
grantsOwner: Number(row.GrantsOwner) === 1,
|
|
679
822
|
expiresAt: row.ExpiresAt == null ? null : String(row.ExpiresAt),
|
|
680
823
|
totalUses: (row.TotalUses as number | null) ?? null,
|
|
681
824
|
issuerKey: row.IssuerKey as string,
|
|
@@ -692,12 +835,47 @@ function cadreInviteUsageRowOf(row: Record<string, SqlValue>): CadreInviteUsageR
|
|
|
692
835
|
peerId: row.PeerId as string,
|
|
693
836
|
peerKey: row.PeerKey as string,
|
|
694
837
|
peerStampId: row.PeerStampId as string,
|
|
695
|
-
ownerStampId: (row.OwnerStampId as string | null) ?? null,
|
|
696
838
|
inviteSig: row.InviteSig as string,
|
|
697
839
|
peerSig: row.PeerSig as string,
|
|
698
840
|
};
|
|
699
841
|
}
|
|
700
842
|
|
|
843
|
+
/** Every `OwnerGrant` column, in the order {@link ownerGrantRowOf} reads them; callers append their `where`. */
|
|
844
|
+
const OWNER_GRANT_SELECT_SQL = 'select Key, Label, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId from CadreControl.OwnerGrant';
|
|
845
|
+
|
|
846
|
+
/** A stored `OwnerGrant` row as an {@link OwnerGrantRow}; `ExpiresAt` kept canonical, as {@link cadreInviteRowOf} keeps it. */
|
|
847
|
+
function ownerGrantRowOf(row: Record<string, SqlValue>): OwnerGrantRow {
|
|
848
|
+
return {
|
|
849
|
+
key: row.Key as string,
|
|
850
|
+
label: (row.Label as string | null) ?? null,
|
|
851
|
+
expiresAt: row.ExpiresAt == null ? null : String(row.ExpiresAt),
|
|
852
|
+
totalUses: (row.TotalUses as number | null) ?? null,
|
|
853
|
+
issuerKey: row.IssuerKey as string,
|
|
854
|
+
issuerSig: row.IssuerSig as string,
|
|
855
|
+
stampId: row.StampId as string,
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/** A stored `OwnerGrantUsage` row as an {@link OwnerGrantUsageRow}. */
|
|
860
|
+
function ownerGrantUsageRowOf(row: Record<string, SqlValue>): OwnerGrantUsageRow {
|
|
861
|
+
return {
|
|
862
|
+
usageStampId: row.UsageStampId as string,
|
|
863
|
+
grantKey: row.GrantKey as string,
|
|
864
|
+
seatedKey: row.SeatedKey as string,
|
|
865
|
+
seatedStampId: row.SeatedStampId as string,
|
|
866
|
+
grantSig: row.GrantSig as string,
|
|
867
|
+
ownerSig: row.OwnerSig as string,
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/** One issued row's standing, as {@link ControlDatabase.listCadreInviteStatuses} and {@link ControlDatabase.listOwnerGrantStatuses} report it. */
|
|
872
|
+
interface IssuedRowStanding<Row> {
|
|
873
|
+
row: Row;
|
|
874
|
+
live: boolean;
|
|
875
|
+
withdrawn: boolean;
|
|
876
|
+
usesRecorded: number;
|
|
877
|
+
}
|
|
878
|
+
|
|
701
879
|
/** A `JoinedStrand` row as a {@link StrandRow}; the schema's `KnownType` check backs the `Type` cast. */
|
|
702
880
|
function joinedStrandRow(row: Record<string, SqlValue>): StrandRow {
|
|
703
881
|
return {
|
|
@@ -705,9 +883,23 @@ function joinedStrandRow(row: Record<string, SqlValue>): StrandRow {
|
|
|
705
883
|
MemberPrivateKey: row.MemberPrivateKey as string | null,
|
|
706
884
|
Type: row.Type as 'o' | 'c',
|
|
707
885
|
FounderOwnerKey: null,
|
|
886
|
+
FounderPeerId: null,
|
|
708
887
|
};
|
|
709
888
|
}
|
|
710
889
|
|
|
890
|
+
/** A party-own `Strand` row as a {@link StrandRow}; the `Type` cast as {@link joinedStrandRow}'s. */
|
|
891
|
+
function strandRow(row: Record<string, SqlValue>): StrandRow {
|
|
892
|
+
return {
|
|
893
|
+
Id: row.Id as string,
|
|
894
|
+
MemberPrivateKey: row.MemberPrivateKey as string | null,
|
|
895
|
+
Type: row.Type as 'o' | 'c',
|
|
896
|
+
FounderOwnerKey: row.FounderOwnerKey as string | null,
|
|
897
|
+
FounderPeerId: row.FounderPeerId as string | null,
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const STRAND_COLUMNS = 'Id, MemberPrivateKey, Type, FounderOwnerKey, FounderPeerId';
|
|
902
|
+
|
|
711
903
|
/** A `FormationInvite` row as {@link ControlDatabase.queryFormationInvite} reads it. */
|
|
712
904
|
export interface FormationInviteRow {
|
|
713
905
|
token: string;
|
|
@@ -805,13 +997,13 @@ interface SignedStatement {
|
|
|
805
997
|
* `JoinSuccess` / `JoinFailure` row and the signature its `AuthorizedInsert` verifies, over the
|
|
806
998
|
* row's columns in the schema's order (a null column signs as `''`, an integer as its text).
|
|
807
999
|
*/
|
|
808
|
-
function signJoinOutcomeInsert(
|
|
1000
|
+
async function signJoinOutcomeInsert(
|
|
809
1001
|
partyId: string,
|
|
810
1002
|
requestStampId: string,
|
|
811
1003
|
outcome: JoinOutcome,
|
|
812
1004
|
ownerKey: string,
|
|
813
|
-
signMessage:
|
|
814
|
-
): SignedStatement {
|
|
1005
|
+
signMessage: ControlSign
|
|
1006
|
+
): Promise<SignedStatement> {
|
|
815
1007
|
const at = String(outcome.RecordedAt);
|
|
816
1008
|
const { table, sql, fields, values } = outcome.kind === 'joined'
|
|
817
1009
|
? {
|
|
@@ -824,7 +1016,7 @@ function signJoinOutcomeInsert(
|
|
|
824
1016
|
fields: [requestStampId, at, outcome.Code, outcome.Reason],
|
|
825
1017
|
values: [requestStampId, outcome.RecordedAt, outcome.Code, outcome.Reason],
|
|
826
1018
|
};
|
|
827
|
-
const signature = signMessage(buildAuthorizationMessage(`CadreControl.${table}`, 'add', partyId, fields));
|
|
1019
|
+
const signature = await signMessage(buildAuthorizationMessage(`CadreControl.${table}`, 'add', partyId, fields));
|
|
828
1020
|
return { sql, params: [ownerKey, signature, ...values] };
|
|
829
1021
|
}
|
|
830
1022
|
|
|
@@ -848,9 +1040,6 @@ export type MembershipChangeListener = (reason: string) => Promise<void>;
|
|
|
848
1040
|
*/
|
|
849
1041
|
export type RevokedRowRef = Pick<RevocationRow, 'tableName' | 'rowKey' | 'stampId'>;
|
|
850
1042
|
|
|
851
|
-
/** What {@link ControlDatabase.removeMachine} wrote: `CadreNode.removeMachine`'s result, less the re-vouches it made first. */
|
|
852
|
-
export type MachineRemovalWrites = Pick<MachineRemovalResult, 'removedPeer' | 'removedOwnerKey' | 'removedDeviceToken' | 'withdrawnInvitations'>;
|
|
853
|
-
|
|
854
1043
|
/**
|
|
855
1044
|
* Notified after a guarded-table delete — and the `CadreControl.Revocation`
|
|
856
1045
|
* tombstone it writes in the same transaction — has COMMITTED.
|
|
@@ -878,6 +1067,30 @@ export type GuardedDeleteListener = (revocation: RevokedRowRef) => void;
|
|
|
878
1067
|
*/
|
|
879
1068
|
export type ControlWriteAbandonedListener = (abandonment: ControlRetryAbandonment) => void;
|
|
880
1069
|
|
|
1070
|
+
/** The text of a {@link ControlDatabaseConfig.schemaPath} file; Node.js only. */
|
|
1071
|
+
async function readSchemaFile(schemaPath: string): Promise<string> {
|
|
1072
|
+
if (typeof process === 'undefined' || !process.versions?.node) {
|
|
1073
|
+
throw new Error(
|
|
1074
|
+
'Loading schema from file is not supported in React Native. ' +
|
|
1075
|
+
'Remove the schemaPath option to use the embedded schema instead.'
|
|
1076
|
+
);
|
|
1077
|
+
}
|
|
1078
|
+
try {
|
|
1079
|
+
// Use require to conditionally load fs only in Node.js.
|
|
1080
|
+
// This won't be bundled by React Native's Metro bundler — a dynamic
|
|
1081
|
+
// import() would be statically picked up by Metro, so require is
|
|
1082
|
+
// intentional here (cross-platform constraint, not lazy CommonJS).
|
|
1083
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1084
|
+
const fs = require('fs/promises');
|
|
1085
|
+
return await fs.readFile(schemaPath, 'utf-8');
|
|
1086
|
+
} catch (error) {
|
|
1087
|
+
throw new Error(
|
|
1088
|
+
`Failed to load schema from ${schemaPath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
1089
|
+
{ cause: error }
|
|
1090
|
+
);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
881
1094
|
export interface ControlDatabaseConfig {
|
|
882
1095
|
/**
|
|
883
1096
|
* Party ID for the control network. Also the party every owner and peer signature over a
|
|
@@ -890,6 +1103,8 @@ export interface ControlDatabaseConfig {
|
|
|
890
1103
|
* Optional path to the control schema file.
|
|
891
1104
|
* If not provided, uses the embedded schema for cross-platform compatibility.
|
|
892
1105
|
* Only use this if you need to override the default schema (e.g., for testing).
|
|
1106
|
+
* The file has the shape of `schemas/control.qsql`: one `declare schema CadreControl`
|
|
1107
|
+
* followed by one plain `apply schema CadreControl` ({@link controlSchemaFileDeclaration}).
|
|
893
1108
|
*/
|
|
894
1109
|
schemaPath?: string;
|
|
895
1110
|
/** Libp2p node for the control network (injected) */
|
|
@@ -1045,49 +1260,36 @@ export class ControlDatabase {
|
|
|
1045
1260
|
}
|
|
1046
1261
|
|
|
1047
1262
|
private async loadSchema(): Promise<void> {
|
|
1048
|
-
let
|
|
1049
|
-
|
|
1263
|
+
let declaration: string;
|
|
1050
1264
|
if (this.config.schemaPath) {
|
|
1051
|
-
// Load from file if explicitly provided (for testing or custom schemas)
|
|
1052
|
-
// This only works in Node.js environments
|
|
1053
1265
|
log('Loading schema from file: %s', this.config.schemaPath);
|
|
1054
|
-
|
|
1055
|
-
// Check if we're in a Node.js environment
|
|
1056
|
-
if (typeof process !== 'undefined' && process.versions?.node) {
|
|
1057
|
-
try {
|
|
1058
|
-
// Use require to conditionally load fs only in Node.js.
|
|
1059
|
-
// This won't be bundled by React Native's Metro bundler — a dynamic
|
|
1060
|
-
// import() would be statically picked up by Metro, so require is
|
|
1061
|
-
// intentional here (cross-platform constraint, not lazy CommonJS).
|
|
1062
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1063
|
-
const fs = require('fs/promises');
|
|
1064
|
-
schemaContent = await fs.readFile(this.config.schemaPath, 'utf-8');
|
|
1065
|
-
} catch (error) {
|
|
1066
|
-
throw new Error(
|
|
1067
|
-
`Failed to load schema from ${this.config.schemaPath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
1068
|
-
{ cause: error }
|
|
1069
|
-
);
|
|
1070
|
-
}
|
|
1071
|
-
} else {
|
|
1072
|
-
throw new Error(
|
|
1073
|
-
'Loading schema from file is not supported in React Native. ' +
|
|
1074
|
-
'Remove the schemaPath option to use the embedded schema instead.'
|
|
1075
|
-
);
|
|
1076
|
-
}
|
|
1266
|
+
declaration = controlSchemaFileDeclaration(await readSchemaFile(this.config.schemaPath));
|
|
1077
1267
|
} else {
|
|
1078
|
-
// Use embedded schema for cross-platform compatibility
|
|
1079
1268
|
log('Using embedded control schema');
|
|
1080
|
-
|
|
1269
|
+
declaration = CONTROL_SCHEMA_DECLARATION;
|
|
1081
1270
|
}
|
|
1082
1271
|
|
|
1272
|
+
// Declaring runs no DDL; it only records the declaration the check and the apply both read.
|
|
1273
|
+
await this.db!.exec(declaration);
|
|
1274
|
+
// A warm store this build cannot apply to without losing data is refused HERE, before the
|
|
1275
|
+
// apply runs anything: the optimystic module refuses most changes to an existing table
|
|
1276
|
+
// part-way through an apply, and a dropped table takes its rows. Migrating a store in place
|
|
1277
|
+
// (control-store-migrates-on-start) widens this check to let catalog-only changes through.
|
|
1278
|
+
await refuseIncompatibleControlStore(this.db!, this.config.partyId);
|
|
1279
|
+
await this.applyControlSchema();
|
|
1280
|
+
log('Schema loaded and executed');
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/** Apply the declared control schema, retrying transient cohort failures. */
|
|
1284
|
+
private async applyControlSchema(): Promise<void> {
|
|
1083
1285
|
// Schema init is a DISTRIBUTED write, so it gets the same bounded transient-failure
|
|
1084
1286
|
// retry every other control write does ({@link lockedWithRetry}). Every CadreControl
|
|
1085
|
-
// table is optimystic-backed (the default vtab
|
|
1287
|
+
// table is optimystic-backed (`initialize` routes the default vtab to optimystic),
|
|
1086
1288
|
// so each `create table` needs a super-majority of the party's peers to answer. On a
|
|
1087
1289
|
// node joining a party that already has two live members a single unanswered peer
|
|
1088
1290
|
// during any one create otherwise kills startup outright.
|
|
1089
1291
|
//
|
|
1090
|
-
// Re-running the
|
|
1292
|
+
// Re-running the apply after a partial failure is safe, and this is the
|
|
1091
1293
|
// non-obvious part:
|
|
1092
1294
|
//
|
|
1093
1295
|
// - `apply schema` is a DIFF, not a replay. Quereus collects the live catalog, diffs
|
|
@@ -1095,11 +1297,6 @@ export class ControlDatabase {
|
|
|
1095
1297
|
// exactly why `initialize` hydrates persisted optimystic schemas BEFORE getting here.
|
|
1096
1298
|
// A table the live catalog already lists generates no statements at all, which is what
|
|
1097
1299
|
// makes an apply over an already-complete schema (a warm start) a no-op.
|
|
1098
|
-
// NOTE: a table the catalog lists with DIFFERENT constraint text diffs to
|
|
1099
|
-
// `alter table ... drop constraint`, which the optimystic module refuses, so a store
|
|
1100
|
-
// written by a build with older constraint text fails here on every start (policy:
|
|
1101
|
-
// recreate, docs/architecture.md after the Control Network table). If backwards
|
|
1102
|
-
// compatibility becomes a requirement, this apply is the migration site.
|
|
1103
1300
|
// - a failed apply is taken back WHOLE (Quereus 4.20.0). The migration loop keeps an
|
|
1104
1301
|
// undo journal; when a step fails it runs that journal in reverse, re-renders the
|
|
1105
1302
|
// catalog, compares it against a fingerprint taken BEFORE the apply, and only then
|
|
@@ -1132,8 +1329,7 @@ export class ControlDatabase {
|
|
|
1132
1329
|
// also what lets schema init absorb optimystic's self-coordination grace refusal, which
|
|
1133
1330
|
// is unsafe for control writes in general. Why, on
|
|
1134
1331
|
// `RETRIABLE_SCHEMA_INIT_MATCHERS` in `control-write-retry.ts`.
|
|
1135
|
-
await this.lockedWithRetry(() => this.db!.exec(
|
|
1136
|
-
log('Schema loaded and executed');
|
|
1332
|
+
await this.lockedWithRetry(() => this.db!.exec(APPLY_CONTROL_SCHEMA), SCHEMA_INIT_RETRY_POLICY, 'schema-init');
|
|
1137
1333
|
}
|
|
1138
1334
|
|
|
1139
1335
|
/**
|
|
@@ -1315,16 +1511,8 @@ export class ControlDatabase {
|
|
|
1315
1511
|
*/
|
|
1316
1512
|
async queryStrands(): Promise<StrandRow[]> {
|
|
1317
1513
|
this.ensureInitialized();
|
|
1318
|
-
const
|
|
1319
|
-
|
|
1320
|
-
results.push({
|
|
1321
|
-
Id: row.Id as string,
|
|
1322
|
-
MemberPrivateKey: row.MemberPrivateKey as string | null,
|
|
1323
|
-
Type: row.Type as 'o' | 'c',
|
|
1324
|
-
FounderOwnerKey: row.FounderOwnerKey as string | null,
|
|
1325
|
-
});
|
|
1326
|
-
}
|
|
1327
|
-
return results;
|
|
1514
|
+
const rows = await this.readRows(`select ${STRAND_COLUMNS} from CadreControl.Strand`, undefined, 'strands');
|
|
1515
|
+
return rows.map(strandRow);
|
|
1328
1516
|
}
|
|
1329
1517
|
|
|
1330
1518
|
/**
|
|
@@ -1335,25 +1523,14 @@ export class ControlDatabase {
|
|
|
1335
1523
|
*/
|
|
1336
1524
|
async queryStrand(strandId: string): Promise<StrandRow | null> {
|
|
1337
1525
|
this.ensureInitialized();
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
[strandId],
|
|
1341
|
-
'strand'
|
|
1342
|
-
)) {
|
|
1343
|
-
return {
|
|
1344
|
-
Id: row.Id as string,
|
|
1345
|
-
MemberPrivateKey: row.MemberPrivateKey as string | null,
|
|
1346
|
-
Type: row.Type as 'o' | 'c',
|
|
1347
|
-
FounderOwnerKey: row.FounderOwnerKey as string | null,
|
|
1348
|
-
};
|
|
1349
|
-
}
|
|
1350
|
-
return null;
|
|
1526
|
+
const rows = await this.readRows(`select ${STRAND_COLUMNS} from CadreControl.Strand where Id = ?`, [strandId], 'strand');
|
|
1527
|
+
return rows.length === 0 ? null : strandRow(rows[0]!);
|
|
1351
1528
|
}
|
|
1352
1529
|
|
|
1353
1530
|
/**
|
|
1354
1531
|
* Every strand this party joined from another party (`CadreControl.JoinedStrand`), shaped
|
|
1355
1532
|
* as a {@link StrandRow} so a reader can treat it like one of the party's own. A joiner
|
|
1356
|
-
* never founds, so `FounderOwnerKey`
|
|
1533
|
+
* never founds, so `FounderOwnerKey` and `FounderPeerId` are always null. Read raw, like {@link queryStrands}:
|
|
1357
1534
|
* no retired-stamp filter.
|
|
1358
1535
|
*/
|
|
1359
1536
|
async queryJoinedStrands(): Promise<StrandRow[]> {
|
|
@@ -1452,15 +1629,17 @@ export class ControlDatabase {
|
|
|
1452
1629
|
* Idempotent genesis: insert `key` as the founding owner key only when
|
|
1453
1630
|
* the party has none yet. Returns true if it inserted, false if an owner
|
|
1454
1631
|
* key already existed (so a repeat `--owner` start is a no-op).
|
|
1632
|
+
*
|
|
1633
|
+
* @param label - the user's name for the key ({@link insertOwnerKey}).
|
|
1455
1634
|
*/
|
|
1456
|
-
async ensureOwnerKey(key: string): Promise<boolean> {
|
|
1635
|
+
async ensureOwnerKey(key: string, label?: string): Promise<boolean> {
|
|
1457
1636
|
this.ensureInitialized();
|
|
1458
1637
|
const trimmed = requireEd25519PublicKeyB64(key, 'owner key');
|
|
1459
1638
|
if (await this.hasOwnerKey()) {
|
|
1460
1639
|
log('Owner key already present; skipping genesis insert');
|
|
1461
1640
|
return false;
|
|
1462
1641
|
}
|
|
1463
|
-
await this.insertOwnerKey(trimmed);
|
|
1642
|
+
await this.insertOwnerKey(trimmed, label);
|
|
1464
1643
|
return true;
|
|
1465
1644
|
}
|
|
1466
1645
|
|
|
@@ -1491,8 +1670,9 @@ export class ControlDatabase {
|
|
|
1491
1670
|
}
|
|
1492
1671
|
|
|
1493
1672
|
/**
|
|
1494
|
-
* Every live `OwnerKey` row with its persisted proof ({@link OwnerKeyRow}) — what
|
|
1495
|
-
* trusted-owner anchor sync derives from (`CadreNode.syncOwnerAnchor`)
|
|
1673
|
+
* Every live `OwnerKey` row with its label and persisted proof ({@link OwnerKeyRow}) — what
|
|
1674
|
+
* the trusted-owner anchor sync derives from (`CadreNode.syncOwnerAnchor`) and the owner-key
|
|
1675
|
+
* listing shows (`CadreNode.listOwnerKeys`). Rows whose
|
|
1496
1676
|
* `StampId` is retired in `CadreControl.Revocation` are excluded here, as
|
|
1497
1677
|
* {@link queryCadrePeers} excludes them and for the reason on {@link getOwnerKeys}, which
|
|
1498
1678
|
* reads through this. A row with no stamp (the schema permits it; no writer produces one)
|
|
@@ -1512,7 +1692,7 @@ export class ControlDatabase {
|
|
|
1512
1692
|
this.ensureInitialized();
|
|
1513
1693
|
const revoked = retired ?? await this.queryRevokedStamps('OwnerKey', retry);
|
|
1514
1694
|
const rows: OwnerKeyRow[] = [];
|
|
1515
|
-
const sql = 'select Key, StampId, VouchOwner, VouchSig, VouchUsage from CadreControl.OwnerKey';
|
|
1695
|
+
const sql = 'select Key, Label, StampId, VouchOwner, VouchSig, VouchUsage from CadreControl.OwnerKey';
|
|
1516
1696
|
for (const row of await this.readRows(sql, undefined, 'owner-keys', retry)) {
|
|
1517
1697
|
const stampId = (row.StampId as string | null) ?? null;
|
|
1518
1698
|
if (stampId === null) {
|
|
@@ -1524,6 +1704,7 @@ export class ControlDatabase {
|
|
|
1524
1704
|
}
|
|
1525
1705
|
rows.push({
|
|
1526
1706
|
key: row.Key as string,
|
|
1707
|
+
label: (row.Label as string | null) ?? null,
|
|
1527
1708
|
stampId,
|
|
1528
1709
|
vouchOwner: (row.VouchOwner as string | null) ?? null,
|
|
1529
1710
|
vouchSig: (row.VouchSig as string | null) ?? null,
|
|
@@ -1600,16 +1781,16 @@ export class ControlDatabase {
|
|
|
1600
1781
|
|
|
1601
1782
|
/**
|
|
1602
1783
|
* The cadre's peers as a seed or a redemption reply carries them: every live `CadrePeer`
|
|
1603
|
-
* row with its addresses,
|
|
1604
|
-
*
|
|
1784
|
+
* row with its addresses, the minter and every always-on machine marked preferred
|
|
1785
|
+
* ({@link projectSeedPeers}).
|
|
1605
1786
|
*
|
|
1606
1787
|
* Read through {@link queryCadrePeers}, never a raw `CadrePeer` select, so a removed
|
|
1607
1788
|
* member's addresses are never packed into a seed or handed to a redeeming device as a
|
|
1608
1789
|
* dial hint: a revoked peer is off the addressable surface everywhere, and this is one of
|
|
1609
1790
|
* its exits.
|
|
1610
1791
|
*/
|
|
1611
|
-
async querySeedPeers(): Promise<SeedPeer[]> {
|
|
1612
|
-
return projectSeedPeers(await this.queryCadrePeers(),
|
|
1792
|
+
async querySeedPeers(minterPeerId: string): Promise<SeedPeer[]> {
|
|
1793
|
+
return projectSeedPeers(await this.queryCadrePeers(), minterPeerId);
|
|
1613
1794
|
}
|
|
1614
1795
|
|
|
1615
1796
|
/**
|
|
@@ -1978,9 +2159,13 @@ export class ControlDatabase {
|
|
|
1978
2159
|
|
|
1979
2160
|
/**
|
|
1980
2161
|
* Insert the initial owner key (bootstrap - no existing owners required)
|
|
2162
|
+
*
|
|
2163
|
+
* @param label - the user's name for the key, at most {@link OWNER_KEY_MAX_LABEL}
|
|
2164
|
+
* characters once trimmed; blank is stored as none.
|
|
1981
2165
|
*/
|
|
1982
|
-
async insertOwnerKey(key: string): Promise<void> {
|
|
2166
|
+
async insertOwnerKey(key: string, label?: string): Promise<void> {
|
|
1983
2167
|
this.ensureInitialized();
|
|
2168
|
+
const storedLabel = ownerKeyLabel(label);
|
|
1984
2169
|
log('Inserting owner key: %s', key);
|
|
1985
2170
|
|
|
1986
2171
|
// Bootstrap is authorized by the schema's genesis branch — `(select count(1) from
|
|
@@ -1993,10 +2178,10 @@ export class ControlDatabase {
|
|
|
1993
2178
|
// optimystic `StampId()` SQL function.
|
|
1994
2179
|
const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
|
|
1995
2180
|
await this.execWrite(`
|
|
1996
|
-
insert into CadreControl.OwnerKey (Key, StampId)
|
|
2181
|
+
insert into CadreControl.OwnerKey (Key, Label, StampId)
|
|
1997
2182
|
with context OwnerKey = null, Signature = null
|
|
1998
|
-
values (?, ?)
|
|
1999
|
-
`, [key, stampId], 'owner-key-insert');
|
|
2183
|
+
values (?, ?, ?)
|
|
2184
|
+
`, [key, storedLabel, stampId], 'owner-key-insert');
|
|
2000
2185
|
log('Owner key inserted');
|
|
2001
2186
|
}
|
|
2002
2187
|
|
|
@@ -2004,57 +2189,85 @@ export class ControlDatabase {
|
|
|
2004
2189
|
* Owner-signed INSERT of a further `OwnerKey` row, notifying the membership listener once
|
|
2005
2190
|
* it has committed. The signed-add branch of `OwnerKey.Authorized`: the signer must be an
|
|
2006
2191
|
* owner in the pre-transaction snapshot, its signature covers the `'add'` digest over
|
|
2007
|
-
* (Key, StampId), and the stored `VouchOwner`/`VouchSig` are that same pair, so a
|
|
2008
|
-
* can later judge the add against its own anchor (`verifyOwnerKeyVoucher`).
|
|
2192
|
+
* (Key, Label, StampId), and the stored `VouchOwner`/`VouchSig` are that same pair, so a
|
|
2193
|
+
* reader can later judge the add against its own anchor (`verifyOwnerKeyVoucher`).
|
|
2009
2194
|
*
|
|
2010
2195
|
* Through {@link mutateCadrePeer}, the membership hub. The liveness read, the stamp and the
|
|
2011
2196
|
* signature all sit inside the locked body: a {@link lockedWithRetry} re-run starts over a
|
|
2012
2197
|
* rolled-back attempt with a fresh stamp, as {@link redeemCadreInvite} does.
|
|
2013
2198
|
*
|
|
2014
|
-
*
|
|
2015
|
-
* is refused here by name: `OwnerKey` is not reaped, so a machine that received the
|
|
2016
|
-
* tombstone without the delete (see {@link getOwnerKeys}) keeps the row, and an insert there
|
|
2017
|
-
* would collide on the primary key. Re-add from the machine that removed the key, or any
|
|
2018
|
-
* machine the delete reached; if re-adding removed owners becomes routine, the fix is an
|
|
2019
|
-
* `OwnerKey` reap branch, gated so it can never take the table below `MinOneOwner`.
|
|
2020
|
-
*
|
|
2199
|
+
* @param label - the user's name for the key, as {@link insertOwnerKey}'s.
|
|
2021
2200
|
* @returns `true` when this call seated the row, `false` when `key` is already a live owner
|
|
2022
|
-
* here (nothing written).
|
|
2023
|
-
* @throws when this node holds `key`'s row under a retired stamp
|
|
2024
|
-
* schema refuses the signer.
|
|
2201
|
+
* here (nothing written, whatever its label).
|
|
2202
|
+
* @throws when this node holds `key`'s row under a retired stamp
|
|
2203
|
+
* ({@link liveOwnerKeyStamp}), or when the schema refuses the signer.
|
|
2025
2204
|
*/
|
|
2026
2205
|
async insertOwnerKeyVouched(
|
|
2027
2206
|
key: string,
|
|
2028
2207
|
ownerKey: string,
|
|
2029
|
-
signMessage:
|
|
2208
|
+
signMessage: ControlSign,
|
|
2209
|
+
label?: string | null
|
|
2030
2210
|
): Promise<boolean> {
|
|
2031
2211
|
this.ensureInitialized();
|
|
2212
|
+
const storedLabel = ownerKeyLabel(label);
|
|
2032
2213
|
return await this.mutateCadrePeer('owner-key-add', async () => {
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
if (!(await this.queryRevokedStamps('OwnerKey', false)).has(heldStamp)) {
|
|
2037
|
-
log('insertOwnerKeyVouched: %s is already an owner; nothing written', key);
|
|
2038
|
-
return false;
|
|
2039
|
-
}
|
|
2040
|
-
throw new Error(
|
|
2041
|
-
`Cannot re-add owner key ${key} here: this node still holds the row it was removed under ` +
|
|
2042
|
-
'(OwnerKey rows are not reaped); re-add it from the machine that removed it'
|
|
2043
|
-
);
|
|
2214
|
+
if (await this.liveOwnerKeyStamp(key) !== null) {
|
|
2215
|
+
log('insertOwnerKeyVouched: %s is already an owner; nothing written', key);
|
|
2216
|
+
return false;
|
|
2044
2217
|
}
|
|
2045
2218
|
const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
|
|
2046
|
-
const signature = signMessage(ownerKeyAddMessage(this.config.partyId, key, stampId));
|
|
2047
|
-
|
|
2048
|
-
await this.db!.exec(`
|
|
2049
|
-
insert into CadreControl.OwnerKey (Key, StampId, VouchOwner, VouchSig)
|
|
2050
|
-
with context OwnerKey = ?, Signature = ?
|
|
2051
|
-
values (?, ?, ?, ?)
|
|
2052
|
-
`, [ownerKey, signature, key, stampId, ownerKey, signature]);
|
|
2219
|
+
const signature = await signMessage(ownerKeyAddMessage(this.config.partyId, key, storedLabel, stampId));
|
|
2220
|
+
await this.execOwnerKeyVouchedInsert({ key, label: storedLabel, stampId }, ownerKey, signature);
|
|
2053
2221
|
log('Owner key %s added (vouched by %s)', key, ownerKey);
|
|
2054
2222
|
return true;
|
|
2055
2223
|
});
|
|
2056
2224
|
}
|
|
2057
2225
|
|
|
2226
|
+
/**
|
|
2227
|
+
* The stamp of `key`'s live `OwnerKey` row here, or null when this node holds none: the
|
|
2228
|
+
* insert-if-absent read of {@link insertOwnerKeyVouched} and {@link redeemOwnerGrant}. Inside
|
|
2229
|
+
* the locked body (both reads `retry: false`; see queryStampId's NOTE).
|
|
2230
|
+
*
|
|
2231
|
+
* NOTE: re-adding a removed owner from a machine that still holds its retired physical row
|
|
2232
|
+
* is refused here by name: `OwnerKey` is not reaped, so a machine that received the
|
|
2233
|
+
* tombstone without the delete (see {@link getOwnerKeys}) keeps the row, and an insert there
|
|
2234
|
+
* would collide on the primary key. Re-add from the machine that removed the key, or any
|
|
2235
|
+
* machine the delete reached; if re-adding removed owners becomes routine, the fix is an
|
|
2236
|
+
* `OwnerKey` reap branch, gated so it can never take the table below `MinOneOwner`.
|
|
2237
|
+
*
|
|
2238
|
+
* @throws when this node holds `key`'s row under a retired stamp (above).
|
|
2239
|
+
*/
|
|
2240
|
+
private async liveOwnerKeyStamp(key: string): Promise<string | null> {
|
|
2241
|
+
const heldStamp = await this.queryStampId('OwnerKey', key, false);
|
|
2242
|
+
if (heldStamp === null) {
|
|
2243
|
+
return null;
|
|
2244
|
+
}
|
|
2245
|
+
if ((await this.queryRevokedStamps('OwnerKey', false)).has(heldStamp)) {
|
|
2246
|
+
throw new Error(
|
|
2247
|
+
`Cannot re-add owner key ${key} here: this node still holds the row it was removed under ` +
|
|
2248
|
+
'(OwnerKey rows are not reaped); re-add it from the machine that removed it'
|
|
2249
|
+
);
|
|
2250
|
+
}
|
|
2251
|
+
return heldStamp;
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
/**
|
|
2255
|
+
* The signed-add statement of {@link insertOwnerKeyVouched} and {@link revouchOwnerKey}, the
|
|
2256
|
+
* stored voucher being the signing pair. Bare `exec`: callers hold the write lock, which is
|
|
2257
|
+
* NOT re-entrant.
|
|
2258
|
+
*/
|
|
2259
|
+
private async execOwnerKeyVouchedInsert(
|
|
2260
|
+
row: { key: string; label: string | null; stampId: string },
|
|
2261
|
+
ownerKey: string,
|
|
2262
|
+
signature: string
|
|
2263
|
+
): Promise<void> {
|
|
2264
|
+
await this.db!.exec(`
|
|
2265
|
+
insert into CadreControl.OwnerKey (Key, Label, StampId, VouchOwner, VouchSig)
|
|
2266
|
+
with context OwnerKey = ?, Signature = ?
|
|
2267
|
+
values (?, ?, ?, ?, ?)
|
|
2268
|
+
`, [ownerKey, signature, row.key, row.label, row.stampId, ownerKey, signature]);
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2058
2271
|
/**
|
|
2059
2272
|
* Owner-signed removal of one `OwnerKey` row plus the `Revocation` tombstone retiring its
|
|
2060
2273
|
* stamp, notifying the membership listener once it has committed. The remove branch of
|
|
@@ -2075,7 +2288,7 @@ export class ControlDatabase {
|
|
|
2075
2288
|
async deleteOwnerKey(
|
|
2076
2289
|
key: string,
|
|
2077
2290
|
ownerKey: string,
|
|
2078
|
-
signMessage:
|
|
2291
|
+
signMessage: ControlSign
|
|
2079
2292
|
): Promise<boolean> {
|
|
2080
2293
|
if (key === ownerKey) {
|
|
2081
2294
|
throw new Error(`Cannot remove owner key ${key}: an owner cannot remove itself`);
|
|
@@ -2097,14 +2310,14 @@ export class ControlDatabase {
|
|
|
2097
2310
|
/**
|
|
2098
2311
|
* Re-vouch a live `OwnerKey` row under this owner's signature: in ONE transaction, the
|
|
2099
2312
|
* current row's guarded delete plus tombstone ({@link deleteOwnerKey}'s body) and an insert
|
|
2100
|
-
* of the same key under a fresh stamp, signed by `ownerKey`
|
|
2101
|
-
* body), as {@link rewritePendingJoin} re-seats a request. `OwnerKey` has `NoUpdate`, so
|
|
2313
|
+
* of the same key and label under a fresh stamp, signed by `ownerKey`
|
|
2314
|
+
* ({@link insertOwnerKeyVouched}'s body), as {@link rewritePendingJoin} re-seats a request. `OwnerKey` has `NoUpdate`, so
|
|
2102
2315
|
* this is the only way to rebind a row's voucher; the anchor derivation lets the re-add
|
|
2103
2316
|
* under a fresh stamp beat the older tombstone (`deriveOwnerAnchor`). For
|
|
2104
|
-
*
|
|
2105
|
-
*
|
|
2317
|
+
* `CadreNode.removeOwner`: the owners a key being removed had added keep their anchor place
|
|
2318
|
+
* everywhere once their row stands on a remaining owner's signature.
|
|
2106
2319
|
*
|
|
2107
|
-
* Through {@link mutateCadrePeer}, the membership hub. The
|
|
2320
|
+
* Through {@link mutateCadrePeer}, the membership hub. The row read runs inside the locked
|
|
2108
2321
|
* body; a row whose stamp is already retired (held without its delete) is reported as absent
|
|
2109
2322
|
* rather than tombstoned twice.
|
|
2110
2323
|
*
|
|
@@ -2114,70 +2327,59 @@ export class ControlDatabase {
|
|
|
2114
2327
|
async revouchOwnerKey(
|
|
2115
2328
|
key: string,
|
|
2116
2329
|
ownerKey: string,
|
|
2117
|
-
signMessage:
|
|
2330
|
+
signMessage: ControlSign
|
|
2118
2331
|
): Promise<boolean> {
|
|
2119
2332
|
if (key === ownerKey) {
|
|
2120
2333
|
throw new Error(`Cannot re-vouch owner key ${key}: an owner cannot re-vouch itself`);
|
|
2121
2334
|
}
|
|
2122
2335
|
return await this.mutateCadrePeer('owner-key-revouch', async () => {
|
|
2123
|
-
// retry: false
|
|
2124
|
-
const
|
|
2125
|
-
if (
|
|
2336
|
+
// retry: false — inside the locked write body (see queryStampId's NOTE).
|
|
2337
|
+
const live = (await this.queryOwnerKeyRows(false)).find((row) => row.key === key);
|
|
2338
|
+
if (live === undefined) {
|
|
2126
2339
|
log('revouchOwnerKey: %s is not a live owner here (nothing to re-vouch)', key);
|
|
2127
2340
|
return false;
|
|
2128
2341
|
}
|
|
2129
|
-
const removal = signGuardedRemoval(this.config.partyId, 'OwnerKey', key, stampId, signMessage);
|
|
2130
|
-
const
|
|
2131
|
-
const signature = signMessage(ownerKeyAddMessage(this.config.partyId, key,
|
|
2342
|
+
const removal = await signGuardedRemoval(this.config.partyId, 'OwnerKey', key, live.stampId, signMessage);
|
|
2343
|
+
const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
|
|
2344
|
+
const signature = await signMessage(ownerKeyAddMessage(this.config.partyId, key, live.label, stampId));
|
|
2132
2345
|
await this.inTransaction('revouch OwnerKey', async () => {
|
|
2133
2346
|
await this.execGuardedRemoval(removal, ownerKey);
|
|
2134
|
-
await this.
|
|
2135
|
-
insert into CadreControl.OwnerKey (Key, StampId, VouchOwner, VouchSig)
|
|
2136
|
-
with context OwnerKey = ?, Signature = ?
|
|
2137
|
-
values (?, ?, ?, ?)
|
|
2138
|
-
`, [ownerKey, signature, key, freshStampId, ownerKey, signature]);
|
|
2347
|
+
await this.execOwnerKeyVouchedInsert({ key, label: live.label, stampId }, ownerKey, signature);
|
|
2139
2348
|
});
|
|
2140
|
-
log('Owner key %s re-vouched by %s (stamp %s retired)', key, ownerKey, stampId);
|
|
2349
|
+
log('Owner key %s re-vouched by %s (stamp %s retired)', key, ownerKey, live.stampId);
|
|
2141
2350
|
this.notifyGuardedDelete(removal.ref);
|
|
2142
2351
|
return true;
|
|
2143
2352
|
});
|
|
2144
2353
|
}
|
|
2145
2354
|
|
|
2146
2355
|
/**
|
|
2147
|
-
* Remove one machine completely, in ONE transaction: its `CadrePeer` row
|
|
2148
|
-
*
|
|
2149
|
-
*
|
|
2150
|
-
*
|
|
2151
|
-
*
|
|
2152
|
-
*
|
|
2153
|
-
*
|
|
2154
|
-
*
|
|
2356
|
+
* Remove one machine completely, in ONE transaction: its `CadrePeer` row and its
|
|
2357
|
+
* `DeviceToken` row when it has one, each with the `Revocation` tombstone retiring its stamp
|
|
2358
|
+
* ({@link deleteGuardedRow}'s statement pair, composed bare as {@link deleteStrandAndPartyKey}
|
|
2359
|
+
* composes it), plus a withdrawal tombstone for every `CadreInvite` in `target.inviteKeys`
|
|
2360
|
+
* that is still live here ({@link withdrawCadreInvite}'s body). One transaction so no reader
|
|
2361
|
+
* sees the machine half removed: a row gone but its push token resolvable, or an invitation
|
|
2362
|
+
* it could redeem again still open. Owner keys are never touched: they are the person's, and
|
|
2363
|
+
* removing one is {@link deleteOwnerKey}.
|
|
2155
2364
|
*
|
|
2156
2365
|
* Through {@link mutateCadrePeer}, the membership hub. Every stamp is read inside the locked
|
|
2157
2366
|
* body, and a row whose stamp is already retired (held without its delete) is skipped rather
|
|
2158
|
-
* than tombstoned twice.
|
|
2159
|
-
* owner-key arm. Nothing to remove writes nothing and returns all false.
|
|
2160
|
-
*
|
|
2161
|
-
* The caller decides WHAT to remove, which owner key is the machine's and which invitations
|
|
2162
|
-
* it could use, from its plan (`CadreNode.planMachineRemoval`); this method only refuses to
|
|
2163
|
-
* write an inconsistent set.
|
|
2367
|
+
* than tombstoned twice. Nothing to remove writes nothing and returns all false.
|
|
2164
2368
|
*
|
|
2165
|
-
*
|
|
2369
|
+
* The caller decides which invitations the machine could use, from its plan
|
|
2370
|
+
* (`CadreNode.planMachineRemoval`).
|
|
2166
2371
|
*/
|
|
2167
2372
|
async removeMachine(
|
|
2168
|
-
target: { peerId: string;
|
|
2373
|
+
target: { peerId: string; inviteKeys: readonly string[] },
|
|
2169
2374
|
ownerKey: string,
|
|
2170
|
-
signMessage:
|
|
2171
|
-
): Promise<
|
|
2172
|
-
if (target.ownerKey === ownerKey) {
|
|
2173
|
-
throw new Error(`Cannot remove owner key ${ownerKey}: an owner cannot remove itself`);
|
|
2174
|
-
}
|
|
2375
|
+
signMessage: ControlSign
|
|
2376
|
+
): Promise<MachineRemovalResult> {
|
|
2175
2377
|
return await this.mutateCadrePeer('machine-remove', async () => {
|
|
2176
|
-
const removals = await this.signMachineRemovals(target, signMessage);
|
|
2177
|
-
const withdrawals = await this.
|
|
2378
|
+
const removals = await this.signMachineRemovals(target.peerId, signMessage);
|
|
2379
|
+
const withdrawals = await this.signWithdrawals('CadreInvite', target.inviteKeys, signMessage);
|
|
2178
2380
|
if (removals.length === 0 && withdrawals.length === 0) {
|
|
2179
2381
|
log('removeMachine: nothing live names %s here (already removed)', target.peerId);
|
|
2180
|
-
return { removedPeer: false,
|
|
2382
|
+
return { removedPeer: false, removedDeviceToken: false, withdrawnInvitations: [] };
|
|
2181
2383
|
}
|
|
2182
2384
|
await this.inTransaction('remove machine', async () => {
|
|
2183
2385
|
for (const removal of removals) {
|
|
@@ -2197,7 +2399,6 @@ export class ControlDatabase {
|
|
|
2197
2399
|
}
|
|
2198
2400
|
return {
|
|
2199
2401
|
removedPeer: removed.has('CadrePeer'),
|
|
2200
|
-
removedOwnerKey: removed.has('OwnerKey'),
|
|
2201
2402
|
removedDeviceToken: removed.has('DeviceToken'),
|
|
2202
2403
|
withdrawnInvitations: withdrawals.map((withdrawal) => withdrawal.ref.rowKey),
|
|
2203
2404
|
};
|
|
@@ -2206,14 +2407,11 @@ export class ControlDatabase {
|
|
|
2206
2407
|
|
|
2207
2408
|
/**
|
|
2208
2409
|
* The guarded removals {@link removeMachine} runs, signed over each row's current stamp:
|
|
2209
|
-
* the `CadrePeer` row
|
|
2210
|
-
*
|
|
2211
|
-
*
|
|
2410
|
+
* the `CadrePeer` row and the `DeviceToken` row, each when present. Inside the locked body
|
|
2411
|
+
* (every read `retry: false`); one `Revocation` scan serves the retired-stamp check for both
|
|
2412
|
+
* tables.
|
|
2212
2413
|
*/
|
|
2213
|
-
private async signMachineRemovals(
|
|
2214
|
-
target: { peerId: string; ownerKey: string | null },
|
|
2215
|
-
signMessage: (message: Uint8Array) => string
|
|
2216
|
-
): Promise<GuardedRemoval[]> {
|
|
2414
|
+
private async signMachineRemovals(peerId: string, signMessage: ControlSign): Promise<GuardedRemoval[]> {
|
|
2217
2415
|
const retiredByTable = new Map<RevocableTable, Set<string>>();
|
|
2218
2416
|
for (const tombstone of await this.queryRevocations(false)) {
|
|
2219
2417
|
const stamps = retiredByTable.get(tombstone.tableName) ?? new Set<string>();
|
|
@@ -2226,24 +2424,12 @@ export class ControlDatabase {
|
|
|
2226
2424
|
return stampId !== null && !retired(table).has(stampId) ? stampId : null;
|
|
2227
2425
|
};
|
|
2228
2426
|
const removals: GuardedRemoval[] = [];
|
|
2229
|
-
const
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
if (target.ownerKey !== null) {
|
|
2234
|
-
const ownerStamp = await liveStamp('OwnerKey', target.ownerKey);
|
|
2235
|
-
if (ownerStamp !== null) {
|
|
2236
|
-
const owners = await this.queryOwnerKeyRows(false, retired('OwnerKey'));
|
|
2237
|
-
if (owners.length <= 1) {
|
|
2238
|
-
throw new Error(`Cannot remove owner key ${target.ownerKey}: cannot remove the last owner`);
|
|
2239
|
-
}
|
|
2240
|
-
removals.push(signGuardedRemoval(this.config.partyId, 'OwnerKey', target.ownerKey, ownerStamp, signMessage));
|
|
2427
|
+
for (const table of ['CadrePeer', 'DeviceToken'] as const) {
|
|
2428
|
+
const stampId = await liveStamp(table, peerId);
|
|
2429
|
+
if (stampId !== null) {
|
|
2430
|
+
removals.push(await signGuardedRemoval(this.config.partyId, table, peerId, stampId, signMessage));
|
|
2241
2431
|
}
|
|
2242
2432
|
}
|
|
2243
|
-
const tokenStamp = await liveStamp('DeviceToken', target.peerId);
|
|
2244
|
-
if (tokenStamp !== null) {
|
|
2245
|
-
removals.push(signGuardedRemoval(this.config.partyId, 'DeviceToken', target.peerId, tokenStamp, signMessage));
|
|
2246
|
-
}
|
|
2247
2433
|
return removals;
|
|
2248
2434
|
}
|
|
2249
2435
|
|
|
@@ -2270,7 +2456,7 @@ export class ControlDatabase {
|
|
|
2270
2456
|
strandId: string,
|
|
2271
2457
|
type: 'o' | 'c',
|
|
2272
2458
|
ownerKey: string,
|
|
2273
|
-
signMessage:
|
|
2459
|
+
signMessage: ControlSign,
|
|
2274
2460
|
memberPrivateKey?: string
|
|
2275
2461
|
): Promise<void> {
|
|
2276
2462
|
this.ensureInitialized();
|
|
@@ -2281,19 +2467,19 @@ export class ControlDatabase {
|
|
|
2281
2467
|
const stampId = generateStampId(peerId);
|
|
2282
2468
|
|
|
2283
2469
|
// Field order MUST match the schema's Strand `AuthorizedInsert` verify:
|
|
2284
|
-
// Id, Type, MemberPrivateKey ('' when null), StampId. FounderOwnerKey is
|
|
2470
|
+
// Id, Type, MemberPrivateKey ('' when null), FounderPeerId, StampId. FounderOwnerKey is
|
|
2285
2471
|
// deliberately NOT in the digest — the schema binds it by equality to the
|
|
2286
2472
|
// verified context.OwnerKey instead (see the constraint's comment).
|
|
2287
|
-
const message = buildAuthorizationMessage('CadreControl.Strand', 'add', this.config.partyId, [strandId, type, memberPrivateKey ?? '', stampId]);
|
|
2288
|
-
const signature = signMessage(message);
|
|
2473
|
+
const message = buildAuthorizationMessage('CadreControl.Strand', 'add', this.config.partyId, [strandId, type, memberPrivateKey ?? '', peerId, stampId]);
|
|
2474
|
+
const signature = await signMessage(message);
|
|
2289
2475
|
|
|
2290
2476
|
// StampId is a real, unique column (single-use anti-replay), no longer a context value.
|
|
2291
2477
|
// FounderOwnerKey must equal the signing owner or the constraint rejects the row.
|
|
2292
2478
|
await this.execWrite(`
|
|
2293
|
-
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey)
|
|
2479
|
+
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey, FounderPeerId)
|
|
2294
2480
|
with context OwnerKey = ?, Signature = ?
|
|
2295
|
-
values (?, ?, ?, ?, ?)
|
|
2296
|
-
`, [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId, ownerKey], 'strand-insert');
|
|
2481
|
+
values (?, ?, ?, ?, ?, ?)
|
|
2482
|
+
`, [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId, ownerKey, peerId], 'strand-insert');
|
|
2297
2483
|
|
|
2298
2484
|
log('Strand inserted: %s', strandId);
|
|
2299
2485
|
}
|
|
@@ -2328,7 +2514,7 @@ export class ControlDatabase {
|
|
|
2328
2514
|
deleteStrand(
|
|
2329
2515
|
strandId: string,
|
|
2330
2516
|
ownerKey: string,
|
|
2331
|
-
signMessage:
|
|
2517
|
+
signMessage: ControlSign
|
|
2332
2518
|
): Promise<boolean> {
|
|
2333
2519
|
return this.lockedWithRetry(() => this.deleteStrandAndPartyKey(strandId, ownerKey, signMessage), {}, 'strand-delete');
|
|
2334
2520
|
}
|
|
@@ -2348,7 +2534,7 @@ export class ControlDatabase {
|
|
|
2348
2534
|
private async deleteStrandAndPartyKey(
|
|
2349
2535
|
strandId: string,
|
|
2350
2536
|
ownerKey: string,
|
|
2351
|
-
signMessage:
|
|
2537
|
+
signMessage: ControlSign
|
|
2352
2538
|
): Promise<boolean> {
|
|
2353
2539
|
this.ensureInitialized();
|
|
2354
2540
|
// retry: false — these reads run inside the locked write body (see queryStampId's NOTE).
|
|
@@ -2359,10 +2545,10 @@ export class ControlDatabase {
|
|
|
2359
2545
|
}
|
|
2360
2546
|
const partyKeyStamp = await this.queryStampId('StrandPartyKey', strandId, false);
|
|
2361
2547
|
|
|
2362
|
-
const strandRemoval = signGuardedRemoval(this.config.partyId, 'Strand', strandId, strandStamp, signMessage);
|
|
2548
|
+
const strandRemoval = await signGuardedRemoval(this.config.partyId, 'Strand', strandId, strandStamp, signMessage);
|
|
2363
2549
|
const partyKeyRemoval = partyKeyStamp === null
|
|
2364
2550
|
? null
|
|
2365
|
-
: signGuardedRemoval(this.config.partyId, 'StrandPartyKey', strandId, partyKeyStamp, signMessage);
|
|
2551
|
+
: await signGuardedRemoval(this.config.partyId, 'StrandPartyKey', strandId, partyKeyStamp, signMessage);
|
|
2366
2552
|
|
|
2367
2553
|
await this.inTransaction('delete Strand', async () => {
|
|
2368
2554
|
await this.execGuardedRemoval(strandRemoval, ownerKey);
|
|
@@ -2400,7 +2586,7 @@ export class ControlDatabase {
|
|
|
2400
2586
|
strandId: string,
|
|
2401
2587
|
privateKey: string,
|
|
2402
2588
|
ownerKey: string,
|
|
2403
|
-
signMessage:
|
|
2589
|
+
signMessage: ControlSign
|
|
2404
2590
|
): Promise<void> {
|
|
2405
2591
|
this.ensureInitialized();
|
|
2406
2592
|
log('Inserting strand party key for strand: %s', strandId);
|
|
@@ -2411,7 +2597,7 @@ export class ControlDatabase {
|
|
|
2411
2597
|
// Field order MUST match the schema's StrandPartyKey `AuthorizedInsert` verify:
|
|
2412
2598
|
// Id, PrivateKey, StampId.
|
|
2413
2599
|
const message = buildAuthorizationMessage('CadreControl.StrandPartyKey', 'add', this.config.partyId, [strandId, privateKey, stampId]);
|
|
2414
|
-
const signature = signMessage(message);
|
|
2600
|
+
const signature = await signMessage(message);
|
|
2415
2601
|
|
|
2416
2602
|
await this.execWrite(`
|
|
2417
2603
|
insert into CadreControl.StrandPartyKey (Id, PrivateKey, StampId)
|
|
@@ -2437,7 +2623,7 @@ export class ControlDatabase {
|
|
|
2437
2623
|
deleteStrandPartyKey(
|
|
2438
2624
|
strandId: string,
|
|
2439
2625
|
ownerKey: string,
|
|
2440
|
-
signMessage:
|
|
2626
|
+
signMessage: ControlSign
|
|
2441
2627
|
): Promise<boolean> {
|
|
2442
2628
|
return this.lockedWithRetry(() => this.deleteGuardedRow('StrandPartyKey', strandId, ownerKey, signMessage), {}, 'strand-party-key-delete');
|
|
2443
2629
|
}
|
|
@@ -2457,7 +2643,7 @@ export class ControlDatabase {
|
|
|
2457
2643
|
async insertJoinedStrand(
|
|
2458
2644
|
row: Pick<StrandRow, 'Id' | 'Type' | 'MemberPrivateKey'>,
|
|
2459
2645
|
ownerKey: string,
|
|
2460
|
-
signMessage:
|
|
2646
|
+
signMessage: ControlSign
|
|
2461
2647
|
): Promise<void> {
|
|
2462
2648
|
this.ensureInitialized();
|
|
2463
2649
|
log('Inserting joined strand: %s (type: %s)', row.Id, row.Type);
|
|
@@ -2467,7 +2653,7 @@ export class ControlDatabase {
|
|
|
2467
2653
|
// Field order MUST match the schema's JoinedStrand `AuthorizedInsert` verify:
|
|
2468
2654
|
// Id, Type, MemberPrivateKey ('' when null), StampId.
|
|
2469
2655
|
const message = buildAuthorizationMessage('CadreControl.JoinedStrand', 'add', this.config.partyId, [row.Id, row.Type, row.MemberPrivateKey ?? '', stampId]);
|
|
2470
|
-
const signature = signMessage(message);
|
|
2656
|
+
const signature = await signMessage(message);
|
|
2471
2657
|
|
|
2472
2658
|
await this.execWrite(`
|
|
2473
2659
|
insert into CadreControl.JoinedStrand (Id, Type, MemberPrivateKey, StampId)
|
|
@@ -2487,7 +2673,7 @@ export class ControlDatabase {
|
|
|
2487
2673
|
deleteJoinedStrand(
|
|
2488
2674
|
strandId: string,
|
|
2489
2675
|
ownerKey: string,
|
|
2490
|
-
signMessage:
|
|
2676
|
+
signMessage: ControlSign
|
|
2491
2677
|
): Promise<boolean> {
|
|
2492
2678
|
return this.lockedWithRetry(() => this.deleteGuardedRow('JoinedStrand', strandId, ownerKey, signMessage), {}, 'joined-strand-delete');
|
|
2493
2679
|
}
|
|
@@ -2502,10 +2688,10 @@ export class ControlDatabase {
|
|
|
2502
2688
|
async insertJoinRequest(
|
|
2503
2689
|
request: JoinRequestFields,
|
|
2504
2690
|
ownerKey: string,
|
|
2505
|
-
signMessage:
|
|
2691
|
+
signMessage: ControlSign
|
|
2506
2692
|
): Promise<PendingJoin> {
|
|
2507
2693
|
this.ensureInitialized();
|
|
2508
|
-
const { written, insert } = this.signJoinRequestInsert(request, ownerKey, signMessage);
|
|
2694
|
+
const { written, insert } = await this.signJoinRequestInsert(request, ownerKey, signMessage);
|
|
2509
2695
|
await this.execWrite(insert.sql, insert.params, 'join-request-insert');
|
|
2510
2696
|
log('Join request inserted: %s', request.Id);
|
|
2511
2697
|
return { ...written, outcome: null };
|
|
@@ -2528,10 +2714,10 @@ export class ControlDatabase {
|
|
|
2528
2714
|
expected: PendingJoin,
|
|
2529
2715
|
outcome: JoinOutcome,
|
|
2530
2716
|
ownerKey: string,
|
|
2531
|
-
signMessage:
|
|
2717
|
+
signMessage: ControlSign
|
|
2532
2718
|
): Promise<PendingJoin> {
|
|
2533
2719
|
this.ensureInitialized();
|
|
2534
|
-
const insert = signJoinOutcomeInsert(this.config.partyId, expected.StampId, outcome, ownerKey, signMessage);
|
|
2720
|
+
const insert = await signJoinOutcomeInsert(this.config.partyId, expected.StampId, outcome, ownerKey, signMessage);
|
|
2535
2721
|
return this.lockedWithRetry(async () => {
|
|
2536
2722
|
const live = await this.readPendingJoin(expected.Id, false);
|
|
2537
2723
|
if (live === null || live.StampId !== expected.StampId || live.outcome?.kind !== expected.outcome?.kind) {
|
|
@@ -2566,13 +2752,13 @@ export class ControlDatabase {
|
|
|
2566
2752
|
expectedStampId: string,
|
|
2567
2753
|
next: PendingJoinFields,
|
|
2568
2754
|
ownerKey: string,
|
|
2569
|
-
signMessage:
|
|
2755
|
+
signMessage: ControlSign
|
|
2570
2756
|
): Promise<PendingJoin> {
|
|
2571
2757
|
this.ensureInitialized();
|
|
2572
2758
|
const { outcome, ...request } = next;
|
|
2573
|
-
const removal = signGuardedRemoval(this.config.partyId, 'JoinRequest', next.Id, expectedStampId, signMessage);
|
|
2574
|
-
const { written, insert } = this.signJoinRequestInsert(request, ownerKey, signMessage);
|
|
2575
|
-
const outcomeInsert = outcome === null ? null : signJoinOutcomeInsert(this.config.partyId, written.StampId, outcome, ownerKey, signMessage);
|
|
2759
|
+
const removal = await signGuardedRemoval(this.config.partyId, 'JoinRequest', next.Id, expectedStampId, signMessage);
|
|
2760
|
+
const { written, insert } = await this.signJoinRequestInsert(request, ownerKey, signMessage);
|
|
2761
|
+
const outcomeInsert = outcome === null ? null : await signJoinOutcomeInsert(this.config.partyId, written.StampId, outcome, ownerKey, signMessage);
|
|
2576
2762
|
|
|
2577
2763
|
return this.lockedWithRetry(async () => {
|
|
2578
2764
|
// retry: false — this runs inside the locked write body (see queryStampId's NOTE).
|
|
@@ -2603,7 +2789,7 @@ export class ControlDatabase {
|
|
|
2603
2789
|
deletePendingJoin(
|
|
2604
2790
|
id: string,
|
|
2605
2791
|
ownerKey: string,
|
|
2606
|
-
signMessage:
|
|
2792
|
+
signMessage: ControlSign
|
|
2607
2793
|
): Promise<boolean> {
|
|
2608
2794
|
return this.lockedWithRetry(
|
|
2609
2795
|
() => this.deleteGuardedRow('JoinRequest', id, ownerKey, signMessage, (stampId) => this.deleteJoinOutcomes(stampId)),
|
|
@@ -2614,13 +2800,13 @@ export class ControlDatabase {
|
|
|
2614
2800
|
* Mint a fresh stamp for `request` and the owner signature `JoinRequest.AuthorizedInsert`
|
|
2615
2801
|
* verifies, returning the row as it will be written and its insert.
|
|
2616
2802
|
*/
|
|
2617
|
-
private signJoinRequestInsert(
|
|
2803
|
+
private async signJoinRequestInsert(
|
|
2618
2804
|
request: JoinRequestFields,
|
|
2619
2805
|
ownerKey: string,
|
|
2620
|
-
signMessage:
|
|
2621
|
-
): { written: JoinRequestRow; insert: SignedStatement } {
|
|
2806
|
+
signMessage: ControlSign
|
|
2807
|
+
): Promise<{ written: JoinRequestRow; insert: SignedStatement }> {
|
|
2622
2808
|
const written: JoinRequestRow = { ...request, StampId: generateStampId(this.config.libp2pNode.peerId.toString()) };
|
|
2623
|
-
const signature = signMessage(buildAuthorizationMessage('CadreControl.JoinRequest', 'add', this.config.partyId, joinRequestAddFields(written)));
|
|
2809
|
+
const signature = await signMessage(buildAuthorizationMessage('CadreControl.JoinRequest', 'add', this.config.partyId, joinRequestAddFields(written)));
|
|
2624
2810
|
const params = [ownerKey, signature, written.Id, written.Invitation, written.Disclosure, written.RequestedAt, written.ExpiresAt, written.StampId];
|
|
2625
2811
|
return { written, insert: { sql: JOIN_REQUEST_INSERT_SQL, params } };
|
|
2626
2812
|
}
|
|
@@ -2660,7 +2846,7 @@ export class ControlDatabase {
|
|
|
2660
2846
|
async insertValidationKey(
|
|
2661
2847
|
key: string,
|
|
2662
2848
|
ownerKey: string,
|
|
2663
|
-
signMessage:
|
|
2849
|
+
signMessage: ControlSign
|
|
2664
2850
|
): Promise<void> {
|
|
2665
2851
|
this.ensureInitialized();
|
|
2666
2852
|
log('Inserting validation key: %s', key);
|
|
@@ -2670,7 +2856,7 @@ export class ControlDatabase {
|
|
|
2670
2856
|
|
|
2671
2857
|
// Field order MUST match the schema's ValidationKey `AuthorizedInsert` verify: Key, StampId.
|
|
2672
2858
|
const message = buildAuthorizationMessage('CadreControl.ValidationKey', 'add', this.config.partyId, [key, stampId]);
|
|
2673
|
-
const signature = signMessage(message);
|
|
2859
|
+
const signature = await signMessage(message);
|
|
2674
2860
|
|
|
2675
2861
|
await this.execWrite(`
|
|
2676
2862
|
insert into CadreControl.ValidationKey (Key, StampId)
|
|
@@ -2700,7 +2886,7 @@ export class ControlDatabase {
|
|
|
2700
2886
|
deleteValidationKey(
|
|
2701
2887
|
key: string,
|
|
2702
2888
|
ownerKey: string,
|
|
2703
|
-
signMessage:
|
|
2889
|
+
signMessage: ControlSign
|
|
2704
2890
|
): Promise<boolean> {
|
|
2705
2891
|
return this.lockedWithRetry(() => this.deleteGuardedRow('ValidationKey', key, ownerKey, signMessage), {}, 'validation-key-delete');
|
|
2706
2892
|
}
|
|
@@ -2752,13 +2938,13 @@ export class ControlDatabase {
|
|
|
2752
2938
|
profile: NodeProfile | null;
|
|
2753
2939
|
},
|
|
2754
2940
|
ownerKey: string,
|
|
2755
|
-
signMessage:
|
|
2941
|
+
signMessage: ControlSign
|
|
2756
2942
|
): Promise<boolean> {
|
|
2757
2943
|
this.ensureInitialized();
|
|
2758
2944
|
// Stamp is minted from the ADMITTED peer's id, not this node's — the nonce names the
|
|
2759
2945
|
// row it vouches.
|
|
2760
2946
|
const stampId = generateStampId(row.peerId);
|
|
2761
|
-
const signature = signMessage(
|
|
2947
|
+
const signature = await signMessage(
|
|
2762
2948
|
buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [row.peerId, stampId])
|
|
2763
2949
|
);
|
|
2764
2950
|
return await this.mutateCadrePeer('peer-insert', async () => {
|
|
@@ -2822,13 +3008,13 @@ export class ControlDatabase {
|
|
|
2822
3008
|
*
|
|
2823
3009
|
* NOTE: this rebinds VouchOwner to the CALLING owner's key, and the authorized-membership
|
|
2824
3010
|
* predicate (`CadreNode.listAuthorizedMembers`) now judges rows by that column against each
|
|
2825
|
-
* reader's node-local anchor.
|
|
2826
|
-
*
|
|
2827
|
-
*
|
|
2828
|
-
*
|
|
2829
|
-
* anchor the original owner but not this one would
|
|
2830
|
-
* must re-vouch deliberately (or preserve the existing
|
|
2831
|
-
* inherit this rebinding.
|
|
3011
|
+
* reader's node-local anchor. The two callers rebind on purpose: the write-while-alone drain
|
|
3012
|
+
* re-touches solely rows this node itself authored (`pendingPeerWrites`), so the voucher is
|
|
3013
|
+
* rewritten to the key that already signed it, and `CadreNode.removeOwner` moves rows off a
|
|
3014
|
+
* key it is about to remove. A new caller that re-touches a row a DIFFERENT owner vouched
|
|
3015
|
+
* flips the voucher silently: readers that anchor the original owner but not this one would
|
|
3016
|
+
* drop a legitimate member. It must re-vouch deliberately (or preserve the existing
|
|
3017
|
+
* VouchOwner/VouchSig) rather than inherit this rebinding.
|
|
2832
3018
|
*
|
|
2833
3019
|
* @param peerId - the membership row to re-touch.
|
|
2834
3020
|
* @param updatedAt - the strictly-increasing freshness stamp to write.
|
|
@@ -2840,7 +3026,7 @@ export class ControlDatabase {
|
|
|
2840
3026
|
peerId: string,
|
|
2841
3027
|
updatedAt: number,
|
|
2842
3028
|
ownerKey: string,
|
|
2843
|
-
signMessage:
|
|
3029
|
+
signMessage: ControlSign
|
|
2844
3030
|
): Promise<boolean> {
|
|
2845
3031
|
this.ensureInitialized();
|
|
2846
3032
|
const stampId = await this.queryStampId('CadrePeer', peerId);
|
|
@@ -2848,7 +3034,7 @@ export class ControlDatabase {
|
|
|
2848
3034
|
log('reauthorizeCadrePeer: no CadrePeer row for %s (nothing to re-touch)', peerId);
|
|
2849
3035
|
return false;
|
|
2850
3036
|
}
|
|
2851
|
-
const signature = signMessage(
|
|
3037
|
+
const signature = await signMessage(
|
|
2852
3038
|
buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [peerId, stampId])
|
|
2853
3039
|
);
|
|
2854
3040
|
await this.mutateCadrePeer('peer-reauthorize', async () => {
|
|
@@ -2886,7 +3072,7 @@ export class ControlDatabase {
|
|
|
2886
3072
|
deleteCadrePeer(
|
|
2887
3073
|
peerId: string,
|
|
2888
3074
|
ownerKey: string,
|
|
2889
|
-
signMessage:
|
|
3075
|
+
signMessage: ControlSign
|
|
2890
3076
|
): Promise<boolean> {
|
|
2891
3077
|
return this.mutateCadrePeer('peer-remove', () =>
|
|
2892
3078
|
this.deleteGuardedRow('CadrePeer', peerId, ownerKey, signMessage));
|
|
@@ -2908,7 +3094,7 @@ export class ControlDatabase {
|
|
|
2908
3094
|
deleteDeviceToken(
|
|
2909
3095
|
peerId: string,
|
|
2910
3096
|
ownerKey: string,
|
|
2911
|
-
signMessage:
|
|
3097
|
+
signMessage: ControlSign
|
|
2912
3098
|
): Promise<boolean> {
|
|
2913
3099
|
return this.lockedWithRetry(() => this.deleteGuardedRow('DeviceToken', peerId, ownerKey, signMessage), {}, 'device-token-delete');
|
|
2914
3100
|
}
|
|
@@ -2944,7 +3130,7 @@ export class ControlDatabase {
|
|
|
2944
3130
|
table: RemovableTable,
|
|
2945
3131
|
keyValue: string,
|
|
2946
3132
|
ownerKey: string,
|
|
2947
|
-
signMessage:
|
|
3133
|
+
signMessage: ControlSign,
|
|
2948
3134
|
alongside?: (stampId: string) => Promise<void>
|
|
2949
3135
|
): Promise<boolean> {
|
|
2950
3136
|
this.ensureInitialized();
|
|
@@ -2955,7 +3141,7 @@ export class ControlDatabase {
|
|
|
2955
3141
|
return false;
|
|
2956
3142
|
}
|
|
2957
3143
|
|
|
2958
|
-
const removal = signGuardedRemoval(this.config.partyId, table, keyValue, stampId, signMessage);
|
|
3144
|
+
const removal = await signGuardedRemoval(this.config.partyId, table, keyValue, stampId, signMessage);
|
|
2959
3145
|
await this.inTransaction(`delete ${table}`, async () => {
|
|
2960
3146
|
await alongside?.(stampId);
|
|
2961
3147
|
await this.execGuardedRemoval(removal, ownerKey);
|
|
@@ -2991,8 +3177,8 @@ export class ControlDatabase {
|
|
|
2991
3177
|
|
|
2992
3178
|
/**
|
|
2993
3179
|
* File the owner-signed `Revocation` tombstone retiring one row incarnation — the second
|
|
2994
|
-
* statement of every guarded removal ({@link execGuardedRemoval}) and the whole of
|
|
2995
|
-
*
|
|
3180
|
+
* statement of every guarded removal ({@link execGuardedRemoval}) and the whole of an
|
|
3181
|
+
* issued row's withdrawal ({@link withdrawIssuedRow}), whose row stays — or the ledger
|
|
2996
3182
|
* marker ({@link openRevocationLedger}), which retires nothing. The caller supplies the
|
|
2997
3183
|
* lock and, where one is needed, the transaction.
|
|
2998
3184
|
*
|
|
@@ -3243,25 +3429,28 @@ export class ControlDatabase {
|
|
|
3243
3429
|
*
|
|
3244
3430
|
* Returns how many UPDATE statements ran (`rows.length` on success).
|
|
3245
3431
|
*/
|
|
3246
|
-
reissueRevocations(
|
|
3432
|
+
async reissueRevocations(
|
|
3247
3433
|
rows: readonly RevocationRow[],
|
|
3248
3434
|
reissuedAt: number,
|
|
3249
3435
|
ownerKey: string,
|
|
3250
|
-
signMessage:
|
|
3436
|
+
signMessage: ControlSign
|
|
3251
3437
|
): Promise<number> {
|
|
3252
3438
|
this.ensureInitialized();
|
|
3253
3439
|
// Signatures minted OUTSIDE the locked body: a retried attempt must re-present the
|
|
3254
3440
|
// exact signed message, never re-mint (the contract on withWriteLock). ReissuedAt
|
|
3255
3441
|
// rides in the digest as a string — SQL-side it is cast(new.ReissuedAt as text),
|
|
3256
3442
|
// and every digest field is TEXT on both sides (see buildAuthorizationMessage).
|
|
3257
|
-
const signed
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3443
|
+
const signed: Array<{ row: RevocationRow; signature: string }> = [];
|
|
3444
|
+
for (const row of rows) {
|
|
3445
|
+
signed.push({
|
|
3446
|
+
row,
|
|
3447
|
+
signature: await signMessage(buildAuthorizationMessage(
|
|
3448
|
+
'CadreControl.Revocation', 'reissue', this.config.partyId,
|
|
3449
|
+
[row.tableName, row.rowKey, row.stampId, String(reissuedAt)]
|
|
3450
|
+
)),
|
|
3451
|
+
});
|
|
3452
|
+
}
|
|
3453
|
+
return await this.lockedWithRetry(async () => {
|
|
3265
3454
|
// Counter lives INSIDE the locked body so a lockedWithRetry re-run (which
|
|
3266
3455
|
// re-executes the whole body) starts from zero instead of double-counting.
|
|
3267
3456
|
let executed = 0;
|
|
@@ -3326,10 +3515,10 @@ export class ControlDatabase {
|
|
|
3326
3515
|
*/
|
|
3327
3516
|
async openRevocationLedger(
|
|
3328
3517
|
ownerKey: string,
|
|
3329
|
-
signMessage:
|
|
3518
|
+
signMessage: ControlSign
|
|
3330
3519
|
): Promise<RevocationLedgerOpenResult> {
|
|
3331
3520
|
this.ensureInitialized();
|
|
3332
|
-
const signature = signMessage(revocationTombstoneMessage(this.config.partyId, REVOCATION_LEDGER_MARKER));
|
|
3521
|
+
const signature = await signMessage(revocationTombstoneMessage(this.config.partyId, REVOCATION_LEDGER_MARKER));
|
|
3333
3522
|
try {
|
|
3334
3523
|
return await this.lockedWithRetry<RevocationLedgerOpenResult>(async () => {
|
|
3335
3524
|
if (await this.revocationLedgerFiled()) {
|
|
@@ -3447,8 +3636,9 @@ export class ControlDatabase {
|
|
|
3447
3636
|
* Covers every write that can change who is a member or an owner: EVERY `CadrePeer`
|
|
3448
3637
|
* writer, with one documented exception ({@link updateSelfPeerRecord}, which cannot
|
|
3449
3638
|
* change the snapshot), the `OwnerKey` writers ({@link insertOwnerKeyVouched},
|
|
3450
|
-
* {@link deleteOwnerKey})
|
|
3451
|
-
*
|
|
3639
|
+
* {@link deleteOwnerKey}), the invitation redemption ({@link redeemCadreInvite}), which
|
|
3640
|
+
* seats both, and the owner-grant redemption ({@link redeemOwnerGrant}). That is what
|
|
3641
|
+
* makes the party-membership snapshot refresh automatic
|
|
3452
3642
|
* rather than a caller obligation. The name predates the `OwnerKey` writers. A
|
|
3453
3643
|
* writer necessarily holds the target node's ControlDatabase (the `SeedBootstrapService`'s
|
|
3454
3644
|
* event callbacks are NOT a viable seam: the temp service `CadreNode.applySeed`
|
|
@@ -3561,7 +3751,7 @@ export class ControlDatabase {
|
|
|
3561
3751
|
* backoff never stalls the other local writers queued behind it. Safe because every
|
|
3562
3752
|
* locked body is atomic and re-runnable (the contract on {@link withWriteLock}).
|
|
3563
3753
|
*
|
|
3564
|
-
* Also carries {@link
|
|
3754
|
+
* Also carries {@link applyControlSchema}'s distributed DDL, the one caller that runs BEFORE
|
|
3565
3755
|
* `initialized` is set — so it reaches this method directly rather than through
|
|
3566
3756
|
* {@link execWrite}, whose {@link ensureInitialized} would reject it. The write queue is
|
|
3567
3757
|
* necessarily empty at that point, so the lock is uncontended there; it is taken anyway
|
|
@@ -3707,7 +3897,7 @@ export class ControlDatabase {
|
|
|
3707
3897
|
token: string,
|
|
3708
3898
|
sAppId: string,
|
|
3709
3899
|
ownerKey: string,
|
|
3710
|
-
signMessage:
|
|
3900
|
+
signMessage: ControlSign,
|
|
3711
3901
|
options: { expiresAtMs?: number; totalUses?: number; validationUrl?: string; strandId?: string } = {}
|
|
3712
3902
|
): Promise<void> {
|
|
3713
3903
|
this.ensureInitialized();
|
|
@@ -3734,7 +3924,7 @@ export class ControlDatabase {
|
|
|
3734
3924
|
const message = buildAuthorizationMessage('CadreControl.FormationInvite', 'add', this.config.partyId, [
|
|
3735
3925
|
token, sAppId, expiresAtField, totalUsesField, validationUrlField, strandIdField, stampId,
|
|
3736
3926
|
]);
|
|
3737
|
-
const signature = signMessage(message);
|
|
3927
|
+
const signature = await signMessage(message);
|
|
3738
3928
|
|
|
3739
3929
|
// Persist the canonical ExpiresAt string (datetime parse is idempotent on it) so the
|
|
3740
3930
|
// signed source-of-truth and the stored value are produced once. StampId is a real,
|
|
@@ -3842,13 +4032,13 @@ export class ControlDatabase {
|
|
|
3842
4032
|
await this.inTransaction('redemption', async () => {
|
|
3843
4033
|
// 1. Strand row — authorised by the FormationUsage branch (no owner sig),
|
|
3844
4034
|
// still carrying a fresh unique StampId for the anti-replay column.
|
|
3845
|
-
// Hard-coded open + keyless + explicit null
|
|
4035
|
+
// Hard-coded open + keyless + explicit null founder columns: the
|
|
3846
4036
|
// consent branch admits no other shape (no signature means no
|
|
3847
|
-
// trustworthy founder machine to record — see the schema's
|
|
4037
|
+
// trustworthy founder machine to record — see the schema's comment).
|
|
3848
4038
|
await this.db!.exec(`
|
|
3849
|
-
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey)
|
|
4039
|
+
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey, FounderPeerId)
|
|
3850
4040
|
with context OwnerKey = null, Signature = null
|
|
3851
|
-
values (?, 'o', null, ?, null)
|
|
4041
|
+
values (?, 'o', null, ?, null, null)
|
|
3852
4042
|
`, [strandId, strandStampId]);
|
|
3853
4043
|
|
|
3854
4044
|
// 2. FormationUsage row — authorised by the matching FormationInvite, and
|
|
@@ -4180,84 +4370,121 @@ export class ControlDatabase {
|
|
|
4180
4370
|
* private half to the invitee out of band. It is never stored here (`Strand.Invite` has the
|
|
4181
4371
|
* same shape).
|
|
4182
4372
|
*
|
|
4183
|
-
* @param invite.key - the invitation public key (base64url ed25519), the row's primary key
|
|
4184
|
-
* @param invite.peerId - the device the invitation is for, or null for whoever redeems first
|
|
4185
|
-
* @param invite.grantsOwner - whether redemption also seats an `OwnerKey` row for the device
|
|
4186
|
-
* @param invite.expiresAtMs - epoch ms after which it cannot be redeemed; omit for never
|
|
4187
|
-
* @param invite.totalUses - how many redemptions it allows; omit for unlimited
|
|
4188
4373
|
* @returns the row as stored, `expiresAt` in its canonical form
|
|
4189
4374
|
*/
|
|
4190
|
-
async insertCadreInvite(
|
|
4191
|
-
invite
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4375
|
+
async insertCadreInvite(invite: CadreInviteFields, ownerKey: string, signMessage: ControlSign): Promise<CadreInviteRow> {
|
|
4376
|
+
const row = await this.signCadreInvite(invite, ownerKey, signMessage);
|
|
4377
|
+
await this.lockedWithRetry(() => this.execCadreInviteInsert(row), {}, 'cadre-invite-insert');
|
|
4378
|
+
log('Cadre invitation inserted: %s (peer=%s)', row.key, row.peerId ?? 'any');
|
|
4379
|
+
return row;
|
|
4380
|
+
}
|
|
4381
|
+
|
|
4382
|
+
/**
|
|
4383
|
+
* Build and owner-sign a `CadreInvite` row WITHOUT inserting it: the signing half of
|
|
4384
|
+
* {@link insertCadreInvite}, which `CadreNode.joinWithOwnerGrant` uses alone for the
|
|
4385
|
+
* invitation a node issues to itself, since a store that holds no `OwnerKey` row for the
|
|
4386
|
+
* issuer would refuse the local insert and the row is seated at a member from the bundle
|
|
4387
|
+
* instead. `ExpiresAt` is canonicalized by this database's engine, which is a format, not a
|
|
4388
|
+
* store, so the row reads the same wherever it is seated.
|
|
4389
|
+
*/
|
|
4390
|
+
async signCadreInvite(invite: CadreInviteFields, ownerKey: string, signMessage: ControlSign): Promise<CadreInviteRow> {
|
|
4201
4391
|
this.ensureInitialized();
|
|
4202
4392
|
const key = requireEd25519PublicKeyB64(invite.key, 'cadre invitation key');
|
|
4203
4393
|
const signed: CadreInviteSignedFields = {
|
|
4204
4394
|
key,
|
|
4205
4395
|
peerId: invite.peerId ?? null,
|
|
4206
|
-
grantsOwner: invite.grantsOwner,
|
|
4207
4396
|
expiresAt: invite.expiresAtMs == null ? null : await canonicalDatetime(this.db!, invite.expiresAtMs),
|
|
4208
4397
|
totalUses: invite.totalUses ?? null,
|
|
4209
4398
|
stampId: generateStampId(this.config.libp2pNode.peerId.toString()),
|
|
4210
4399
|
};
|
|
4211
|
-
|
|
4400
|
+
return {
|
|
4212
4401
|
...signed,
|
|
4213
4402
|
issuerKey: ownerKey,
|
|
4214
|
-
issuerSig: signMessage(cadreInviteAddMessage(this.config.partyId, signed)),
|
|
4403
|
+
issuerSig: await signMessage(cadreInviteAddMessage(this.config.partyId, signed)),
|
|
4215
4404
|
};
|
|
4216
|
-
await this.lockedWithRetry(() => this.execCadreInviteInsert(row), {}, 'cadre-invite-insert');
|
|
4217
|
-
log('Cadre invitation inserted: %s (peer=%s owner=%s)', key, row.peerId ?? 'any', row.grantsOwner);
|
|
4218
|
-
return row;
|
|
4219
4405
|
}
|
|
4220
4406
|
|
|
4221
4407
|
/**
|
|
4222
4408
|
* Seat an owner-signed `CadreInvite` row received inside an invitation bundle, under its own
|
|
4223
4409
|
* stored `IssuerKey`/`IssuerSig` as the write context — insert-if-absent, so a member that
|
|
4224
|
-
* already holds the row (by replication, or from an earlier redemption attempt) is a no-op
|
|
4410
|
+
* already holds the row (by replication, or from an earlier redemption attempt) is a no-op,
|
|
4411
|
+
* and so is one whose insert the cohort refuses because a member there holds it.
|
|
4225
4412
|
*
|
|
4226
4413
|
* @returns `true` when this call inserted the row, `false` when it was already present.
|
|
4227
4414
|
* @throws {CadreInviteIssuerUnknownError} when `CadreInvite.AuthorizedInsert` refuses it: the
|
|
4228
4415
|
* issuer is not an owner on this node (not replicated yet, or removed), or the signature does
|
|
4229
|
-
* not verify.
|
|
4416
|
+
* not verify. {@link IssuedRowNotHereYetError} when the cohort holds the row and this node
|
|
4417
|
+
* cannot read it yet. Every other refusal (a withdrawn stamp, `NotRevoked`) propagates as is.
|
|
4230
4418
|
*/
|
|
4231
4419
|
async seatCadreInvite(row: CadreInviteRow): Promise<boolean> {
|
|
4420
|
+
return this.seatIssuedRow('CadreInvite', row.key, () => this.execCadreInviteInsert(row),
|
|
4421
|
+
cause => new CadreInviteIssuerUnknownError(row.key, row.issuerKey, { cause }));
|
|
4422
|
+
}
|
|
4423
|
+
|
|
4424
|
+
/**
|
|
4425
|
+
* Insert-if-absent of an issued row from a bundle's copy, under the write lock: the body of
|
|
4426
|
+
* {@link seatCadreInvite} and {@link seatOwnerGrant}. `exec` runs the bare insert under the
|
|
4427
|
+
* row's own stored issuer pair.
|
|
4428
|
+
*
|
|
4429
|
+
* @returns `true` when this call inserted the row, `false` when it was already present —
|
|
4430
|
+
* here, or at the cohort member that refused the insert for holding it
|
|
4431
|
+
* ({@link adoptCohortHeldRow}).
|
|
4432
|
+
* @throws `refuse(cause)` when the table's `AuthorizedInsert` refuses the row;
|
|
4433
|
+
* {@link IssuedRowNotHereYetError} when the cohort holds the row and this node still cannot
|
|
4434
|
+
* read it; every other refusal (a withdrawn stamp, `NotRevoked`) propagates as is.
|
|
4435
|
+
*/
|
|
4436
|
+
private async seatIssuedRow(table: IssuedTable, key: string, exec: () => Promise<void>, refuse: (cause: unknown) => Error): Promise<boolean> {
|
|
4232
4437
|
this.ensureInitialized();
|
|
4233
4438
|
try {
|
|
4234
4439
|
return await this.lockedWithRetry(async () => {
|
|
4235
4440
|
// retry: false — inside the locked write body (see queryStampId's NOTE).
|
|
4236
|
-
if (await this.queryStampId(
|
|
4237
|
-
log('
|
|
4441
|
+
if (await this.queryStampId(table, key, false) !== null) {
|
|
4442
|
+
log('%s %s already seated; seat skipped', table, key);
|
|
4238
4443
|
return false;
|
|
4239
4444
|
}
|
|
4240
|
-
await
|
|
4241
|
-
log('
|
|
4445
|
+
await exec();
|
|
4446
|
+
log('%s seated from its bundle: %s', table, key);
|
|
4242
4447
|
return true;
|
|
4243
|
-
}, {},
|
|
4448
|
+
}, {}, `${ISSUED_TABLE_REASON[table]}-seat`);
|
|
4244
4449
|
} catch (error) {
|
|
4245
|
-
if (errorChainMatches(error,
|
|
4246
|
-
throw
|
|
4450
|
+
if (errorChainMatches(error, ISSUER_UNAUTHORIZED)) {
|
|
4451
|
+
throw refuse(error);
|
|
4452
|
+
}
|
|
4453
|
+
if (isIssuedRowKeyConflict(error, table)) {
|
|
4454
|
+
return await this.adoptCohortHeldRow(table, key, error);
|
|
4247
4455
|
}
|
|
4248
4456
|
throw error;
|
|
4249
4457
|
}
|
|
4250
4458
|
}
|
|
4251
4459
|
|
|
4460
|
+
/**
|
|
4461
|
+
* The seat's insert was refused because a cohort member already holds a row under `key`, which
|
|
4462
|
+
* this node's guard read missed because its store lags the cohort. A bundle's copy is verbatim
|
|
4463
|
+
* and every column is under the issuer's signature, so the held row is this one: it counts as
|
|
4464
|
+
* seated once a refreshing read (unlocked, so retried as any read) finds it here.
|
|
4465
|
+
*
|
|
4466
|
+
* @throws {IssuedRowNotHereYetError} when the read still does not find it.
|
|
4467
|
+
*/
|
|
4468
|
+
private async adoptCohortHeldRow(table: IssuedTable, key: string, conflict: unknown): Promise<false> {
|
|
4469
|
+
// NOTE: while another locked body runs here, this read takes readRowsOnce's committed path,
|
|
4470
|
+
// which does not refresh from the cohort, so it can miss the row and refuse a retryable
|
|
4471
|
+
// `conflict`; if that refusal shows up in practice, wait for the running body before reading.
|
|
4472
|
+
if (await this.queryStampId(table, key) === null) {
|
|
4473
|
+
throw new IssuedRowNotHereYetError(table, key, { cause: conflict });
|
|
4474
|
+
}
|
|
4475
|
+
log('%s %s is held by the cohort; seat skipped', table, key);
|
|
4476
|
+
return false;
|
|
4477
|
+
}
|
|
4478
|
+
|
|
4252
4479
|
/** Bare `CadreInvite` insert shared by {@link insertCadreInvite} and {@link seatCadreInvite}; the caller holds the lock. */
|
|
4253
4480
|
private async execCadreInviteInsert(row: CadreInviteRow): Promise<void> {
|
|
4254
4481
|
await this.db!.exec(`
|
|
4255
|
-
insert into CadreControl.CadreInvite (Key, PeerId,
|
|
4482
|
+
insert into CadreControl.CadreInvite (Key, PeerId, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId)
|
|
4256
4483
|
with context OwnerKey = ?, Signature = ?
|
|
4257
|
-
values (?, ?, ?, ?, ?, ?,
|
|
4484
|
+
values (?, ?, ?, ?, ?, ?, ?)
|
|
4258
4485
|
`, [
|
|
4259
4486
|
row.issuerKey, row.issuerSig,
|
|
4260
|
-
row.key, row.peerId, row.
|
|
4487
|
+
row.key, row.peerId, row.expiresAt, row.totalUses,
|
|
4261
4488
|
row.issuerKey, row.issuerSig, row.stampId,
|
|
4262
4489
|
]);
|
|
4263
4490
|
}
|
|
@@ -4299,7 +4526,7 @@ export class ControlDatabase {
|
|
|
4299
4526
|
async queryCadreInviteUsages(retry = true): Promise<CadreInviteUsageRow[]> {
|
|
4300
4527
|
this.ensureInitialized();
|
|
4301
4528
|
const rows = await this.readRows(
|
|
4302
|
-
'select UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId,
|
|
4529
|
+
'select UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, InviteSig, PeerSig from CadreControl.CadreInviteUsage',
|
|
4303
4530
|
undefined, 'cadre-invite-usages', retry
|
|
4304
4531
|
);
|
|
4305
4532
|
return rows.map(cadreInviteUsageRowOf);
|
|
@@ -4313,9 +4540,13 @@ export class ControlDatabase {
|
|
|
4313
4540
|
* `retry: false` is passed by {@link assertCadreInviteSeatRemains}, inside the locked body.
|
|
4314
4541
|
*/
|
|
4315
4542
|
async countCadreInviteUsage(key: string, retry = true): Promise<number> {
|
|
4543
|
+
return this.countUsageRows('select count(1) as Count from CadreControl.CadreInviteUsage where InviteKey = ?', key, 'cadre-invite-usage-count', retry);
|
|
4544
|
+
}
|
|
4545
|
+
|
|
4546
|
+
/** The one-row count read behind {@link countCadreInviteUsage} and {@link countOwnerGrantUsage}. */
|
|
4547
|
+
private async countUsageRows(sql: string, key: string, label: string, retry: boolean): Promise<number> {
|
|
4316
4548
|
this.ensureInitialized();
|
|
4317
|
-
|
|
4318
|
-
for (const row of await this.readRows(sql, [key], 'cadre-invite-usage-count', retry)) {
|
|
4549
|
+
for (const row of await this.readRows(sql, [key], label, retry)) {
|
|
4319
4550
|
return (row.Count as number) ?? 0;
|
|
4320
4551
|
}
|
|
4321
4552
|
return 0;
|
|
@@ -4351,7 +4582,7 @@ export class ControlDatabase {
|
|
|
4351
4582
|
undefined,
|
|
4352
4583
|
'live-cadre-invites'
|
|
4353
4584
|
)) {
|
|
4354
|
-
const open =
|
|
4585
|
+
const open = issuedRowStillOpen(
|
|
4355
4586
|
{ stampId: row.StampId as string, issuerKey: row.IssuerKey as string, expiresAt: (row.ExpiresAt as string | null) ?? null },
|
|
4356
4587
|
withdrawn, owners, nowMs
|
|
4357
4588
|
);
|
|
@@ -4391,14 +4622,27 @@ export class ControlDatabase {
|
|
|
4391
4622
|
async isCadreInviteLive(invite: CadreInviteRow, nowMs: number = Date.now()): Promise<boolean> {
|
|
4392
4623
|
this.ensureInitialized();
|
|
4393
4624
|
const row = await this.queryCadreInvite(invite.key) ?? (verifyCadreInviteRow(this.config.partyId, invite) ? invite : null);
|
|
4625
|
+
return this.issuedRowLive('CadreInvite', row, nowMs, key => this.countCadreInviteUsage(key));
|
|
4626
|
+
}
|
|
4627
|
+
|
|
4628
|
+
/**
|
|
4629
|
+
* The body of {@link isCadreInviteLive} and {@link isOwnerGrantLive}: is `row` (the one held,
|
|
4630
|
+
* or a verified bundle copy; null for neither) still open and under its use cap?
|
|
4631
|
+
*/
|
|
4632
|
+
private async issuedRowLive(
|
|
4633
|
+
table: IssuedTable,
|
|
4634
|
+
row: { key: string; stampId: string; issuerKey: string; expiresAt: string | null; totalUses: number | null } | null,
|
|
4635
|
+
nowMs: number,
|
|
4636
|
+
countUsage: (key: string) => Promise<number>
|
|
4637
|
+
): Promise<boolean> {
|
|
4394
4638
|
if (row === null) {
|
|
4395
4639
|
return false;
|
|
4396
4640
|
}
|
|
4397
|
-
const [withdrawn, owners] = await Promise.all([this.queryRevokedStamps(
|
|
4398
|
-
if (!
|
|
4641
|
+
const [withdrawn, owners] = await Promise.all([this.queryRevokedStamps(table), this.getOwnerKeys()]);
|
|
4642
|
+
if (!issuedRowStillOpen(row, withdrawn, owners, nowMs)) {
|
|
4399
4643
|
return false;
|
|
4400
4644
|
}
|
|
4401
|
-
return row.totalUses === null || await
|
|
4645
|
+
return row.totalUses === null || await countUsage(row.key) < row.totalUses;
|
|
4402
4646
|
}
|
|
4403
4647
|
|
|
4404
4648
|
/**
|
|
@@ -4409,22 +4653,34 @@ export class ControlDatabase {
|
|
|
4409
4653
|
* occasional read, not a gate's.
|
|
4410
4654
|
*/
|
|
4411
4655
|
async listCadreInviteStatuses(nowMs: number = Date.now()): Promise<CadreInviteStatus[]> {
|
|
4656
|
+
const standings = await this.issuedRowStandings('CadreInvite', await this.queryCadreInvites(), nowMs, key => this.countCadreInviteUsage(key));
|
|
4657
|
+
return standings.map(({ row, ...standing }) => ({ invite: row, ...standing }));
|
|
4658
|
+
}
|
|
4659
|
+
|
|
4660
|
+
/**
|
|
4661
|
+
* The body of {@link listCadreInviteStatuses} and {@link listOwnerGrantStatuses}: each of
|
|
4662
|
+
* `rows` with its withdrawal, its recorded uses and whether it is still redeemable.
|
|
4663
|
+
*/
|
|
4664
|
+
private async issuedRowStandings<Row extends { key: string; stampId: string; issuerKey: string; expiresAt: string | null; totalUses: number | null }>(
|
|
4665
|
+
table: IssuedTable,
|
|
4666
|
+
rows: readonly Row[],
|
|
4667
|
+
nowMs: number,
|
|
4668
|
+
countUsage: (key: string) => Promise<number>
|
|
4669
|
+
): Promise<Array<IssuedRowStanding<Row>>> {
|
|
4412
4670
|
this.ensureInitialized();
|
|
4413
|
-
const [
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
//
|
|
4418
|
-
|
|
4419
|
-
|
|
4420
|
-
|
|
4421
|
-
const
|
|
4422
|
-
|
|
4423
|
-
|
|
4424
|
-
&& (invite.totalUses === null || usesRecorded < invite.totalUses);
|
|
4425
|
-
statuses.push({ invite, live, withdrawn, usesRecorded });
|
|
4671
|
+
const [withdrawnStamps, owners] = await Promise.all([this.queryRevokedStamps(table), this.getOwnerKeys()]);
|
|
4672
|
+
const standings: Array<IssuedRowStanding<Row>> = [];
|
|
4673
|
+
// NOTE: one retried count read per row, withdrawn and expired rows included, since rows
|
|
4674
|
+
// are never deleted; if a long-lived cadre's listing ever shows as slow, group the usage
|
|
4675
|
+
// counts in one statement or skip the count for rows that are no longer open.
|
|
4676
|
+
for (const row of rows) {
|
|
4677
|
+
const usesRecorded = await countUsage(row.key);
|
|
4678
|
+
const withdrawn = withdrawnStamps.has(row.stampId);
|
|
4679
|
+
const live = issuedRowStillOpen(row, withdrawnStamps, owners, nowMs)
|
|
4680
|
+
&& (row.totalUses === null || usesRecorded < row.totalUses);
|
|
4681
|
+
standings.push({ row, live, withdrawn, usesRecorded });
|
|
4426
4682
|
}
|
|
4427
|
-
return
|
|
4683
|
+
return standings;
|
|
4428
4684
|
}
|
|
4429
4685
|
|
|
4430
4686
|
/**
|
|
@@ -4447,66 +4703,75 @@ export class ControlDatabase {
|
|
|
4447
4703
|
async withdrawCadreInvite(
|
|
4448
4704
|
key: string,
|
|
4449
4705
|
ownerKey: string,
|
|
4450
|
-
signMessage:
|
|
4706
|
+
signMessage: ControlSign
|
|
4451
4707
|
): Promise<boolean> {
|
|
4708
|
+
return this.withdrawIssuedRow('CadreInvite', key, ownerKey, signMessage);
|
|
4709
|
+
}
|
|
4710
|
+
|
|
4711
|
+
/**
|
|
4712
|
+
* The body of {@link withdrawCadreInvite} and {@link withdrawOwnerGrant}: under the write
|
|
4713
|
+
* lock, file the owner-signed tombstone retiring the row's stamp with the row kept, and fire
|
|
4714
|
+
* the guarded-delete listener for it (so a withdrawal made while alone is re-issued).
|
|
4715
|
+
*/
|
|
4716
|
+
private async withdrawIssuedRow(table: IssuedTable, key: string, ownerKey: string, signMessage: ControlSign): Promise<boolean> {
|
|
4452
4717
|
this.ensureInitialized();
|
|
4453
4718
|
const filed = await this.lockedWithRetry(async (): Promise<RevokedRowRef | null> => {
|
|
4454
|
-
const [withdrawal] = await this.
|
|
4719
|
+
const [withdrawal] = await this.signWithdrawals(table, [key], signMessage);
|
|
4455
4720
|
if (withdrawal === undefined) {
|
|
4456
4721
|
return null;
|
|
4457
4722
|
}
|
|
4458
4723
|
await this.execTombstone(withdrawal.ref, withdrawal.signature, ownerKey);
|
|
4459
4724
|
return withdrawal.ref;
|
|
4460
|
-
}, {},
|
|
4725
|
+
}, {}, `${ISSUED_TABLE_REASON[table]}-withdraw`);
|
|
4461
4726
|
if (filed === null) {
|
|
4462
4727
|
return false;
|
|
4463
4728
|
}
|
|
4464
|
-
log('
|
|
4729
|
+
log('%s withdrawn: %s (stamp %s retired, row kept)', table, key, filed.stampId);
|
|
4465
4730
|
this.notifyGuardedDelete(filed);
|
|
4466
4731
|
return true;
|
|
4467
4732
|
}
|
|
4468
4733
|
|
|
4469
4734
|
/**
|
|
4470
|
-
* The withdrawal tombstones for the `
|
|
4471
|
-
*
|
|
4472
|
-
* {@link
|
|
4735
|
+
* The withdrawal tombstones for the `table` rows among `keys` that are held here and not
|
|
4736
|
+
* yet withdrawn, signed and ready for {@link execTombstone}: the body
|
|
4737
|
+
* {@link withdrawIssuedRow} files one of and {@link removeMachine} files several of in one
|
|
4473
4738
|
* transaction. Inside the locked body (every read `retry: false`); an absent or already
|
|
4474
4739
|
* withdrawn key is logged and left out.
|
|
4475
4740
|
*/
|
|
4476
|
-
private async
|
|
4741
|
+
private async signWithdrawals(
|
|
4742
|
+
table: IssuedTable,
|
|
4477
4743
|
keys: readonly string[],
|
|
4478
|
-
signMessage:
|
|
4744
|
+
signMessage: ControlSign
|
|
4479
4745
|
): Promise<Array<{ ref: RevokedRowRef; signature: string }>> {
|
|
4480
4746
|
if (keys.length === 0) {
|
|
4481
4747
|
return [];
|
|
4482
4748
|
}
|
|
4483
|
-
const withdrawn = await this.queryRevokedStamps(
|
|
4749
|
+
const withdrawn = await this.queryRevokedStamps(table, false);
|
|
4484
4750
|
const withdrawals: Array<{ ref: RevokedRowRef; signature: string }> = [];
|
|
4485
4751
|
for (const key of keys) {
|
|
4486
|
-
const stampId = await this.queryStampId(
|
|
4752
|
+
const stampId = await this.queryStampId(table, key, false);
|
|
4487
4753
|
if (stampId === null) {
|
|
4488
|
-
log('
|
|
4754
|
+
log('withdraw %s: no row for %s (nothing to withdraw)', table, key);
|
|
4489
4755
|
continue;
|
|
4490
4756
|
}
|
|
4491
4757
|
if (withdrawn.has(stampId)) {
|
|
4492
|
-
log('
|
|
4758
|
+
log('withdraw %s: %s already withdrawn (stamp %s retired)', table, key, stampId);
|
|
4493
4759
|
continue;
|
|
4494
4760
|
}
|
|
4495
|
-
const ref: RevokedRowRef = { tableName:
|
|
4496
|
-
withdrawals.push({ ref, signature: signMessage(revocationTombstoneMessage(this.config.partyId, ref)) });
|
|
4761
|
+
const ref: RevokedRowRef = { tableName: table, rowKey: key, stampId };
|
|
4762
|
+
withdrawals.push({ ref, signature: await signMessage(revocationTombstoneMessage(this.config.partyId, ref)) });
|
|
4497
4763
|
}
|
|
4498
4764
|
return withdrawals;
|
|
4499
4765
|
}
|
|
4500
4766
|
|
|
4501
4767
|
/**
|
|
4502
|
-
* Redeem a seated `CadreInvite` on the owner's behalf: admit `peerId` as a cadre member
|
|
4503
|
-
*
|
|
4504
|
-
* transaction writes the `CadrePeer` row (consent branch of `AuthorizedInsert`)
|
|
4505
|
-
* `
|
|
4506
|
-
*
|
|
4507
|
-
*
|
|
4508
|
-
*
|
|
4509
|
-
* ({@link seatCadreInvite}): every consent branch reads `committed.CadreInvite`.
|
|
4768
|
+
* Redeem a seated `CadreInvite` on the owner's behalf: admit `peerId` as a cadre member —
|
|
4769
|
+
* with NO owner signature, and as a machine only (ownership is {@link redeemOwnerGrant}'s).
|
|
4770
|
+
* One transaction writes the `CadrePeer` row (consent branch of `AuthorizedInsert`) and then
|
|
4771
|
+
* the `CadreInviteUsage` row that authorizes it; the usage row's own check (`PeerExists`)
|
|
4772
|
+
* reads the peer row back, so the two are mutually bound to this exact redemption and
|
|
4773
|
+
* neither can be held in reserve. The invitation must already be committed
|
|
4774
|
+
* ({@link seatCadreInvite}): the consent branch reads `committed.CadreInvite`.
|
|
4510
4775
|
*
|
|
4511
4776
|
* The two signatures are the holder's and the device's, minted on the device over
|
|
4512
4777
|
* {@link cadreInviteRedeemMessage} / {@link cadreInviteConsentMessage}; this machine only
|
|
@@ -4515,18 +4780,17 @@ export class ControlDatabase {
|
|
|
4515
4780
|
* (`verifyInvitationAdmission`) — a mismatched pair is written and then never trusted.
|
|
4516
4781
|
*
|
|
4517
4782
|
* Idempotent for a retry after a dropped reply: when the device's row is already present
|
|
4518
|
-
*
|
|
4519
|
-
*
|
|
4520
|
-
*
|
|
4521
|
-
* The seat check ({@link assertCadreInviteSeatRemains}) runs after that, so the retry of a
|
|
4522
|
-
* single-use redemption is answered `alreadyMember` rather than refused as exhausted.
|
|
4783
|
+
* nothing is written and no seat is spent — `alreadyMember: true`. The seat check
|
|
4784
|
+
* ({@link assertCadreInviteSeatRemains}) runs after that, so the retry of a single-use
|
|
4785
|
+
* redemption is answered `alreadyMember` rather than refused as exhausted.
|
|
4523
4786
|
*
|
|
4524
4787
|
* Through {@link mutateCadrePeer}, so the membership hub is notified; inside the locked body
|
|
4525
4788
|
* every read passes `retry: false` and every stamp is minted per attempt, so a
|
|
4526
4789
|
* {@link lockedWithRetry} re-run starts clean.
|
|
4527
4790
|
*
|
|
4528
4791
|
* @throws {InvitationExhaustedError} when the invitation's seats are all spent (by name,
|
|
4529
|
-
* ahead of the schema's generic `Authorized` refusal — see {@link assertSeatRemains})
|
|
4792
|
+
* ahead of the schema's generic `Authorized` refusal — see {@link assertSeatRemains});
|
|
4793
|
+
* {@link IssuedRowNotHereYetError} when no `CadreInvite` row is readable here.
|
|
4530
4794
|
*/
|
|
4531
4795
|
async redeemCadreInvite(params: {
|
|
4532
4796
|
inviteKey: string;
|
|
@@ -4552,64 +4816,39 @@ export class ControlDatabase {
|
|
|
4552
4816
|
return this.mutateCadrePeer('cadre-invite-redeem', async () => {
|
|
4553
4817
|
const invite = await this.queryCadreInvite(inviteKey, false);
|
|
4554
4818
|
if (invite === null) {
|
|
4555
|
-
throw new
|
|
4819
|
+
throw new IssuedRowNotHereYetError('CadreInvite', inviteKey);
|
|
4556
4820
|
}
|
|
4557
|
-
|
|
4558
|
-
|
|
4821
|
+
// A raw stamp read (queryStampId), so a physically present row counts; inside the locked
|
|
4822
|
+
// body, so never retried.
|
|
4823
|
+
const seatedPeerStampId = await this.queryStampId('CadrePeer', peerId, false);
|
|
4824
|
+
if (seatedPeerStampId !== null) {
|
|
4559
4825
|
log('redeemCadreInvite: %s already admitted; nothing written', peerId);
|
|
4560
|
-
return { alreadyMember: true, peerStampId:
|
|
4826
|
+
return { alreadyMember: true, peerStampId: seatedPeerStampId };
|
|
4561
4827
|
}
|
|
4562
4828
|
await this.assertCadreInviteSeatRemains(invite);
|
|
4563
4829
|
|
|
4564
|
-
//
|
|
4565
|
-
// the row it seats. Minted inside the body, so a re-run mints afresh over a
|
|
4566
|
-
// attempt rather than re-presenting a stamp the usage row may already name.
|
|
4567
|
-
const peerStampId =
|
|
4568
|
-
const ownerStampId = invite.grantsOwner ? (seated.ownerStampId ?? generateStampId(peerId)) : null;
|
|
4830
|
+
// The stamp is minted from the ADMITTED device's id, as insertCadrePeer does: the nonce
|
|
4831
|
+
// names the row it seats. Minted inside the body, so a re-run mints afresh over a
|
|
4832
|
+
// rolled-back attempt rather than re-presenting a stamp the usage row may already name.
|
|
4833
|
+
const peerStampId = generateStampId(peerId);
|
|
4569
4834
|
const nowCanonical = await canonicalDatetime(this.db!, nowMs);
|
|
4570
4835
|
await this.inTransaction('cadre-invite-redeem', async () => {
|
|
4571
|
-
if (seated.peerStampId === null) {
|
|
4572
|
-
await this.db!.exec(`
|
|
4573
|
-
insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId, VouchOwner, VouchSig, VouchUsage)
|
|
4574
|
-
with context OwnerKey = null, Signature = null
|
|
4575
|
-
values (?, ?, ?, ?, null, ?, ?, null, ?)
|
|
4576
|
-
`, [peerId, peerKey, multiaddr, nowMs, peerStampId, invite.issuerKey, usageStampId]);
|
|
4577
|
-
}
|
|
4578
|
-
if (ownerStampId !== null && seated.ownerStampId === null) {
|
|
4579
|
-
await this.db!.exec(`
|
|
4580
|
-
insert into CadreControl.OwnerKey (Key, StampId, VouchOwner, VouchSig, VouchUsage)
|
|
4581
|
-
with context OwnerKey = null, Signature = null
|
|
4582
|
-
values (?, ?, ?, null, ?)
|
|
4583
|
-
`, [peerKey, ownerStampId, invite.issuerKey, usageStampId]);
|
|
4584
|
-
}
|
|
4585
4836
|
await this.db!.exec(`
|
|
4586
|
-
insert into CadreControl.
|
|
4837
|
+
insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId, VouchOwner, VouchSig, VouchUsage)
|
|
4838
|
+
with context OwnerKey = null, Signature = null
|
|
4839
|
+
values (?, ?, ?, ?, null, ?, ?, null, ?)
|
|
4840
|
+
`, [peerId, peerKey, multiaddr, nowMs, peerStampId, invite.issuerKey, usageStampId]);
|
|
4841
|
+
await this.db!.exec(`
|
|
4842
|
+
insert into CadreControl.CadreInviteUsage (UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, InviteSig, PeerSig)
|
|
4587
4843
|
with context Now = ?
|
|
4588
|
-
values (?, ?, ?, ?, ?, ?,
|
|
4589
|
-
`, [nowCanonical, usageStampId, inviteKey, peerId, peerKey, peerStampId,
|
|
4844
|
+
values (?, ?, ?, ?, ?, ?, ?)
|
|
4845
|
+
`, [nowCanonical, usageStampId, inviteKey, peerId, peerKey, peerStampId, inviteSig, peerSig]);
|
|
4590
4846
|
});
|
|
4591
|
-
log('Redeemed cadre invitation %s: %s admitted (
|
|
4592
|
-
return { alreadyMember: false, peerStampId
|
|
4847
|
+
log('Redeemed cadre invitation %s: %s admitted (usage %s)', inviteKey, peerId, usageStampId);
|
|
4848
|
+
return { alreadyMember: false, peerStampId };
|
|
4593
4849
|
});
|
|
4594
4850
|
}
|
|
4595
4851
|
|
|
4596
|
-
/**
|
|
4597
|
-
* The rows a redemption would seat that are already present: the device's `CadrePeer`
|
|
4598
|
-
* stamp, and — only when the invitation grants ownership — its `OwnerKey` stamp. Raw stamp
|
|
4599
|
-
* reads ({@link queryStampId}), so a physically present row counts; inside the locked
|
|
4600
|
-
* body, so never retried.
|
|
4601
|
-
*/
|
|
4602
|
-
private async readSeatedAdmission(
|
|
4603
|
-
peerId: string,
|
|
4604
|
-
peerKey: string,
|
|
4605
|
-
grantsOwner: boolean
|
|
4606
|
-
): Promise<{ peerStampId: string | null; ownerStampId: string | null }> {
|
|
4607
|
-
return {
|
|
4608
|
-
peerStampId: await this.queryStampId('CadrePeer', peerId, false),
|
|
4609
|
-
ownerStampId: grantsOwner ? await this.queryStampId('OwnerKey', peerKey, false) : null,
|
|
4610
|
-
};
|
|
4611
|
-
}
|
|
4612
|
-
|
|
4613
4852
|
/**
|
|
4614
4853
|
* Refuse a redemption that would consume a seat the cadre invitation does not have, by
|
|
4615
4854
|
* name ({@link InvitationExhaustedError}) — the cadre-invitation twin of
|
|
@@ -4617,14 +4856,259 @@ export class ControlDatabase {
|
|
|
4617
4856
|
* generic `Authorized` failure a caller would otherwise report as retryable. Reads the
|
|
4618
4857
|
* committed count inside the locked body, so on a same-node race the loser is refused here.
|
|
4619
4858
|
*/
|
|
4620
|
-
private
|
|
4621
|
-
|
|
4859
|
+
private assertCadreInviteSeatRemains(invite: CadreInviteRow): Promise<void> {
|
|
4860
|
+
return this.assertIssuedSeatRemains(invite, key => this.countCadreInviteUsage(key, false));
|
|
4861
|
+
}
|
|
4862
|
+
|
|
4863
|
+
/** The body of {@link assertCadreInviteSeatRemains} and {@link assertOwnerGrantSeatRemains}; `countUsage` reads inside the locked body. */
|
|
4864
|
+
private async assertIssuedSeatRemains(row: { key: string; totalUses: number | null }, countUsage: (key: string) => Promise<number>): Promise<void> {
|
|
4865
|
+
if (row.totalUses === null) {
|
|
4622
4866
|
return;
|
|
4623
4867
|
}
|
|
4624
|
-
const used = await
|
|
4625
|
-
if (used >=
|
|
4626
|
-
throw new InvitationExhaustedError(
|
|
4868
|
+
const used = await countUsage(row.key);
|
|
4869
|
+
if (used >= row.totalUses) {
|
|
4870
|
+
throw new InvitationExhaustedError(row.key, used, row.totalUses);
|
|
4871
|
+
}
|
|
4872
|
+
}
|
|
4873
|
+
|
|
4874
|
+
// ── Owner grants (OwnerGrant / OwnerGrantUsage) ─────────────────────────────────────────
|
|
4875
|
+
|
|
4876
|
+
/**
|
|
4877
|
+
* Mint and insert an owner-signed `OwnerGrant`: a grant of ownership to whoever holds the
|
|
4878
|
+
* grant's private half, redeemable through any member machine ({@link redeemOwnerGrant})
|
|
4879
|
+
* to seat an owner key the holder generates or already holds. No machine is admitted and no
|
|
4880
|
+
* peer id is named. The grant twin of {@link insertCadreInvite}: the owner signs the whole
|
|
4881
|
+
* row ({@link ownerGrantAddMessage}), `ExpiresAt` through {@link canonicalDatetime}, and
|
|
4882
|
+
* the returned row is what the grant bundle carries, re-inserted verbatim by
|
|
4883
|
+
* {@link seatOwnerGrant} on a member that has not received it by replication.
|
|
4884
|
+
*
|
|
4885
|
+
* @param grant.key - the grant's ed25519 PUBLIC key (base64url), the row's primary key; the
|
|
4886
|
+
* caller minted the pair and hands the private half to the holder out of band
|
|
4887
|
+
* @param grant.label - the label every key this grant seats will carry
|
|
4888
|
+
* ({@link ownerKeyLabel}'s rule: trimmed, at most 64 characters, blank meaning none); the
|
|
4889
|
+
* issuer's choice, which the holder cannot change
|
|
4890
|
+
* @param grant.expiresAtMs - epoch ms after which it cannot be redeemed; omit or null for
|
|
4891
|
+
* never. The short default lifetime is the node API's to apply, not this writer's.
|
|
4892
|
+
* @param grant.totalUses - how many redemptions it allows; omit for unlimited
|
|
4893
|
+
* @returns the row as stored, `expiresAt` in its canonical form
|
|
4894
|
+
*/
|
|
4895
|
+
async insertOwnerGrant(
|
|
4896
|
+
grant: { key: string; label?: string | null; expiresAtMs?: number | null; totalUses?: number | null },
|
|
4897
|
+
ownerKey: string,
|
|
4898
|
+
signMessage: ControlSign
|
|
4899
|
+
): Promise<OwnerGrantRow> {
|
|
4900
|
+
this.ensureInitialized();
|
|
4901
|
+
const key = requireEd25519PublicKeyB64(grant.key, 'owner grant key');
|
|
4902
|
+
const signed: OwnerGrantSignedFields = {
|
|
4903
|
+
key,
|
|
4904
|
+
label: ownerKeyLabel(grant.label),
|
|
4905
|
+
expiresAt: grant.expiresAtMs == null ? null : await canonicalDatetime(this.db!, grant.expiresAtMs),
|
|
4906
|
+
totalUses: grant.totalUses ?? null,
|
|
4907
|
+
stampId: generateStampId(this.config.libp2pNode.peerId.toString()),
|
|
4908
|
+
};
|
|
4909
|
+
const row: OwnerGrantRow = {
|
|
4910
|
+
...signed,
|
|
4911
|
+
issuerKey: ownerKey,
|
|
4912
|
+
issuerSig: await signMessage(ownerGrantAddMessage(this.config.partyId, signed)),
|
|
4913
|
+
};
|
|
4914
|
+
await this.lockedWithRetry(() => this.execOwnerGrantInsert(row), {}, 'owner-grant-insert');
|
|
4915
|
+
log('Owner grant inserted: %s (label=%s)', key, row.label ?? 'none');
|
|
4916
|
+
return row;
|
|
4917
|
+
}
|
|
4918
|
+
|
|
4919
|
+
/**
|
|
4920
|
+
* Seat an owner-signed `OwnerGrant` row received inside a grant bundle, under its own stored
|
|
4921
|
+
* `IssuerKey`/`IssuerSig` as the write context — insert-if-absent, as {@link seatCadreInvite}.
|
|
4922
|
+
*
|
|
4923
|
+
* @returns `true` when this call inserted the row, `false` when it was already present.
|
|
4924
|
+
* @throws {OwnerGrantIssuerUnknownError} when `OwnerGrant.AuthorizedInsert` refuses it: the
|
|
4925
|
+
* issuer is not an owner on this node (not replicated yet, or removed), or the signature does
|
|
4926
|
+
* not verify. {@link IssuedRowNotHereYetError} when the cohort holds the row and this node
|
|
4927
|
+
* cannot read it yet. Every other refusal (a withdrawn stamp, `NotRevoked`) propagates as is.
|
|
4928
|
+
*/
|
|
4929
|
+
async seatOwnerGrant(row: OwnerGrantRow): Promise<boolean> {
|
|
4930
|
+
return this.seatIssuedRow('OwnerGrant', row.key, () => this.execOwnerGrantInsert(row),
|
|
4931
|
+
cause => new OwnerGrantIssuerUnknownError(row.key, row.issuerKey, { cause }));
|
|
4932
|
+
}
|
|
4933
|
+
|
|
4934
|
+
/** Bare `OwnerGrant` insert shared by {@link insertOwnerGrant} and {@link seatOwnerGrant}; the caller holds the lock. */
|
|
4935
|
+
private async execOwnerGrantInsert(row: OwnerGrantRow): Promise<void> {
|
|
4936
|
+
await this.db!.exec(`
|
|
4937
|
+
insert into CadreControl.OwnerGrant (Key, Label, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId)
|
|
4938
|
+
with context OwnerKey = ?, Signature = ?
|
|
4939
|
+
values (?, ?, ?, ?, ?, ?, ?)
|
|
4940
|
+
`, [
|
|
4941
|
+
row.issuerKey, row.issuerSig,
|
|
4942
|
+
row.key, row.label, row.expiresAt, row.totalUses,
|
|
4943
|
+
row.issuerKey, row.issuerSig, row.stampId,
|
|
4944
|
+
]);
|
|
4945
|
+
}
|
|
4946
|
+
|
|
4947
|
+
/**
|
|
4948
|
+
* Read one `OwnerGrant` row by key, or null when absent. Withdrawn rows are returned too,
|
|
4949
|
+
* as {@link queryCadreInvite} returns them: the row is the holder's proof and never goes
|
|
4950
|
+
* away. `retry: false` is passed by {@link redeemOwnerGrant}, inside its locked body.
|
|
4951
|
+
*/
|
|
4952
|
+
async queryOwnerGrant(key: string, retry = true): Promise<OwnerGrantRow | null> {
|
|
4953
|
+
this.ensureInitialized();
|
|
4954
|
+
for (const row of await this.readRows(`${OWNER_GRANT_SELECT_SQL} where Key = ?`, [key], 'owner-grant', retry)) {
|
|
4955
|
+
return ownerGrantRowOf(row);
|
|
4627
4956
|
}
|
|
4957
|
+
return null;
|
|
4958
|
+
}
|
|
4959
|
+
|
|
4960
|
+
/**
|
|
4961
|
+
* Every `OwnerGrant` row this node holds, withdrawn and expired ones included. Read by the
|
|
4962
|
+
* anchor sync when at least one `OwnerKey` row is consent-seated (`CadreNode.loadOwnerGrantChain`);
|
|
4963
|
+
* `retry: false` is forwarded from the membership-gate refresh for {@link queryCadrePeers}' reason.
|
|
4964
|
+
*/
|
|
4965
|
+
async queryOwnerGrants(retry = true): Promise<OwnerGrantRow[]> {
|
|
4966
|
+
this.ensureInitialized();
|
|
4967
|
+
const rows = await this.readRows(OWNER_GRANT_SELECT_SQL, undefined, 'owner-grants', retry);
|
|
4968
|
+
return rows.map(ownerGrantRowOf);
|
|
4969
|
+
}
|
|
4970
|
+
|
|
4971
|
+
/** Every `OwnerGrantUsage` row this node holds — the redemption records a grant-seated `OwnerKey` row is verified against. Same `retry` contract as {@link queryOwnerGrants}. */
|
|
4972
|
+
async queryOwnerGrantUsages(retry = true): Promise<OwnerGrantUsageRow[]> {
|
|
4973
|
+
this.ensureInitialized();
|
|
4974
|
+
const rows = await this.readRows(
|
|
4975
|
+
'select UsageStampId, GrantKey, SeatedKey, SeatedStampId, GrantSig, OwnerSig from CadreControl.OwnerGrantUsage',
|
|
4976
|
+
undefined, 'owner-grant-usages', retry
|
|
4977
|
+
);
|
|
4978
|
+
return rows.map(ownerGrantUsageRowOf);
|
|
4979
|
+
}
|
|
4980
|
+
|
|
4981
|
+
/**
|
|
4982
|
+
* How many times an owner grant has been redeemed (its `OwnerGrantUsage` rows), served
|
|
4983
|
+
* through the `OwnerGrantUsageByGrant` index. A permissive PRE-check like
|
|
4984
|
+
* {@link countCadreInviteUsage}: the cap is `OwnerGrantUsage.Authorized`'s own committed count.
|
|
4985
|
+
*/
|
|
4986
|
+
async countOwnerGrantUsage(key: string, retry = true): Promise<number> {
|
|
4987
|
+
return this.countUsageRows('select count(1) as Count from CadreControl.OwnerGrantUsage where GrantKey = ?', key, 'owner-grant-usage-count', retry);
|
|
4988
|
+
}
|
|
4989
|
+
|
|
4990
|
+
/**
|
|
4991
|
+
* Is this one owner grant still redeemable here — not withdrawn, unexpired, uses left, issuer
|
|
4992
|
+
* still an owner — judged as {@link isCadreInviteLive} judges an invitation: by the row this
|
|
4993
|
+
* node holds under `grant.key`, else by `grant` itself (a bundle's copy, not seated yet)
|
|
4994
|
+
* when its issuer signature verifies. A permissive pre-check; {@link seatOwnerGrant} and the
|
|
4995
|
+
* redemption's own write enforce every condition.
|
|
4996
|
+
*/
|
|
4997
|
+
async isOwnerGrantLive(grant: OwnerGrantRow, nowMs: number = Date.now()): Promise<boolean> {
|
|
4998
|
+
this.ensureInitialized();
|
|
4999
|
+
const row = await this.queryOwnerGrant(grant.key) ?? (verifyOwnerGrantRow(this.config.partyId, grant) ? grant : null);
|
|
5000
|
+
return this.issuedRowLive('OwnerGrant', row, nowMs, key => this.countOwnerGrantUsage(key));
|
|
5001
|
+
}
|
|
5002
|
+
|
|
5003
|
+
/**
|
|
5004
|
+
* Every `OwnerGrant` row this node holds with its standing — recorded redemptions, withdrawal,
|
|
5005
|
+
* and whether it is still redeemable — for an owner's listing, as {@link listCadreInviteStatuses}.
|
|
5006
|
+
*/
|
|
5007
|
+
async listOwnerGrantStatuses(nowMs: number = Date.now()): Promise<OwnerGrantStatus[]> {
|
|
5008
|
+
const standings = await this.issuedRowStandings('OwnerGrant', await this.queryOwnerGrants(), nowMs, key => this.countOwnerGrantUsage(key));
|
|
5009
|
+
return standings.map(({ row, ...standing }) => ({ grant: row, ...standing }));
|
|
5010
|
+
}
|
|
5011
|
+
|
|
5012
|
+
/**
|
|
5013
|
+
* Owner-signed WITHDRAWAL of an owner grant: file the `Revocation` tombstone retiring its
|
|
5014
|
+
* stamp, with the row kept, exactly as {@link withdrawCadreInvite} withdraws an invitation
|
|
5015
|
+
* and for the same reason (every key the grant seated proves itself through the row;
|
|
5016
|
+
* `OwnerGrant.NoDelete`, `Revocation.RowIsGone`). Afterwards `OwnerGrantUsage.Authorized`
|
|
5017
|
+
* refuses every further redemption and `OwnerGrant.NotRevoked` refuses re-seating the
|
|
5018
|
+
* holder's copy on any node holding the tombstone. Keys already seated stay owners;
|
|
5019
|
+
* removing one is {@link deleteOwnerKey}.
|
|
5020
|
+
*
|
|
5021
|
+
* Idempotent: `false` (nothing written) when the row is absent here or already withdrawn,
|
|
5022
|
+
* `true` when this call filed the tombstone. Fires the guarded-delete listener, so a
|
|
5023
|
+
* withdrawal committed while alone is re-issued on cohort growth.
|
|
5024
|
+
*/
|
|
5025
|
+
async withdrawOwnerGrant(key: string, ownerKey: string, signMessage: ControlSign): Promise<boolean> {
|
|
5026
|
+
return this.withdrawIssuedRow('OwnerGrant', key, ownerKey, signMessage);
|
|
5027
|
+
}
|
|
5028
|
+
|
|
5029
|
+
/**
|
|
5030
|
+
* Redeem a seated `OwnerGrant` on the issuer's behalf: seat `seatedKey` as an owner with NO
|
|
5031
|
+
* owner signature. One transaction writes the `OwnerKey` row (the grant branch of
|
|
5032
|
+
* `Authorized`: `VouchOwner` the issuer, `VouchSig` null, `VouchUsage` the usage stamp,
|
|
5033
|
+
* `Label` the grant's) and then the `OwnerGrantUsage` row that authorizes it; the usage
|
|
5034
|
+
* row's `SeatedExists` reads the owner row back, so the two are bound to this exact
|
|
5035
|
+
* redemption and neither can be held in reserve. The grant must already be committed
|
|
5036
|
+
* ({@link seatOwnerGrant}): both consent reads are of `committed.OwnerGrant`. No machine is
|
|
5037
|
+
* admitted and no peer id is named.
|
|
5038
|
+
*
|
|
5039
|
+
* The two signatures are minted where the private keys are (the grant's and the seated
|
|
5040
|
+
* key's), over {@link ownerGrantRedeemMessage} / {@link ownerGrantConsentMessage}; this
|
|
5041
|
+
* machine only verifies them, by writing the row.
|
|
5042
|
+
*
|
|
5043
|
+
* Idempotent, and a no-op for a key that is already an owner: when `seatedKey` has a live
|
|
5044
|
+
* `OwnerKey` row here nothing is written and no use is spent — `alreadyOwner: true` with
|
|
5045
|
+
* that row's stamp. The seat check ({@link assertOwnerGrantSeatRemains}) runs after that, so
|
|
5046
|
+
* the retry of a single-use redemption is answered `alreadyOwner` rather than refused as
|
|
5047
|
+
* exhausted. A key removed earlier is seated afresh under a new stamp (`NotRevoked` passes),
|
|
5048
|
+
* except on a machine that still holds its retired row, which refuses by name
|
|
5049
|
+
* ({@link liveOwnerKeyStamp}).
|
|
5050
|
+
*
|
|
5051
|
+
* Through {@link mutateCadrePeer}, so the membership hub is notified and the anchors follow;
|
|
5052
|
+
* inside the locked body every read passes `retry: false` and the stamp is minted per
|
|
5053
|
+
* attempt, so a {@link lockedWithRetry} re-run starts clean.
|
|
5054
|
+
*
|
|
5055
|
+
* @throws {InvitationExhaustedError} when the grant's uses are all spent (by name, ahead of
|
|
5056
|
+
* the schema's generic `Authorized` refusal); its `token` is the grant key.
|
|
5057
|
+
* {@link IssuedRowNotHereYetError} when no `OwnerGrant` row is readable here.
|
|
5058
|
+
*/
|
|
5059
|
+
async redeemOwnerGrant(params: {
|
|
5060
|
+
grantKey: string;
|
|
5061
|
+
/** The owner key to seat (base64url ed25519): the holder generated it, or already holds it. */
|
|
5062
|
+
seatedKey: string;
|
|
5063
|
+
/** Single-use nonce minted by the holder; both signatures cover it. */
|
|
5064
|
+
usageStampId: string;
|
|
5065
|
+
/** The holder's signature with the grant private key over the `'redeem'` digest. */
|
|
5066
|
+
grantSig: string;
|
|
5067
|
+
/** The seated key's signature over the `'consent'` digest. */
|
|
5068
|
+
ownerSig: string;
|
|
5069
|
+
nowMs?: number;
|
|
5070
|
+
}): Promise<OwnerGrantRedemptionResult> {
|
|
5071
|
+
this.ensureInitialized();
|
|
5072
|
+
const { grantKey, seatedKey, usageStampId, grantSig, ownerSig } = params;
|
|
5073
|
+
const nowMs = params.nowMs ?? Date.now();
|
|
5074
|
+
log('Redeeming owner grant %s for %s', grantKey, seatedKey);
|
|
5075
|
+
|
|
5076
|
+
return this.mutateCadrePeer('owner-grant-redeem', async () => {
|
|
5077
|
+
const grant = await this.queryOwnerGrant(grantKey, false);
|
|
5078
|
+
if (grant === null) {
|
|
5079
|
+
throw new IssuedRowNotHereYetError('OwnerGrant', grantKey);
|
|
5080
|
+
}
|
|
5081
|
+
const heldStamp = await this.liveOwnerKeyStamp(seatedKey);
|
|
5082
|
+
if (heldStamp !== null) {
|
|
5083
|
+
log('redeemOwnerGrant: %s is already an owner; nothing written', seatedKey);
|
|
5084
|
+
return { alreadyOwner: true, seatedStampId: heldStamp };
|
|
5085
|
+
}
|
|
5086
|
+
await this.assertOwnerGrantSeatRemains(grant);
|
|
5087
|
+
|
|
5088
|
+
// Minted inside the body, so a re-run mints afresh over a rolled-back attempt rather
|
|
5089
|
+
// than re-presenting a stamp the usage row may already name.
|
|
5090
|
+
const seatedStampId = generateStampId(this.config.libp2pNode.peerId.toString());
|
|
5091
|
+
const nowCanonical = await canonicalDatetime(this.db!, nowMs);
|
|
5092
|
+
await this.inTransaction('owner-grant-redeem', async () => {
|
|
5093
|
+
await this.db!.exec(`
|
|
5094
|
+
insert into CadreControl.OwnerKey (Key, Label, StampId, VouchOwner, VouchSig, VouchUsage)
|
|
5095
|
+
with context OwnerKey = null, Signature = null
|
|
5096
|
+
values (?, ?, ?, ?, null, ?)
|
|
5097
|
+
`, [seatedKey, grant.label, seatedStampId, grant.issuerKey, usageStampId]);
|
|
5098
|
+
await this.db!.exec(`
|
|
5099
|
+
insert into CadreControl.OwnerGrantUsage (UsageStampId, GrantKey, SeatedKey, SeatedStampId, GrantSig, OwnerSig)
|
|
5100
|
+
with context Now = ?
|
|
5101
|
+
values (?, ?, ?, ?, ?, ?)
|
|
5102
|
+
`, [nowCanonical, usageStampId, grantKey, seatedKey, seatedStampId, grantSig, ownerSig]);
|
|
5103
|
+
});
|
|
5104
|
+
log('Redeemed owner grant %s: %s seated as an owner (usage %s)', grantKey, seatedKey, usageStampId);
|
|
5105
|
+
return { alreadyOwner: false, seatedStampId };
|
|
5106
|
+
});
|
|
5107
|
+
}
|
|
5108
|
+
|
|
5109
|
+
/** {@link assertCadreInviteSeatRemains} for an owner grant: refuse an exhausted grant by name, ahead of the write. */
|
|
5110
|
+
private assertOwnerGrantSeatRemains(grant: OwnerGrantRow): Promise<void> {
|
|
5111
|
+
return this.assertIssuedSeatRemains(grant, key => this.countOwnerGrantUsage(key, false));
|
|
4628
5112
|
}
|
|
4629
5113
|
|
|
4630
5114
|
/**
|