@serfab/cadre-core 1.13.0 → 1.14.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/dist/cadre-invite-protocol.d.ts +348 -0
- package/dist/cadre-invite-protocol.js +605 -0
- package/dist/cadre-invite-protocol.js.map +1 -0
- package/dist/cadre-node.d.ts +523 -180
- package/dist/cadre-node.js +921 -293
- package/dist/cadre-node.js.map +1 -1
- package/dist/claim-proof.d.ts +45 -0
- package/dist/claim-proof.js +132 -0
- package/dist/claim-proof.js.map +1 -0
- package/dist/control-authorization.d.ts +80 -19
- package/dist/control-authorization.js +55 -13
- package/dist/control-authorization.js.map +1 -1
- package/dist/control-cohort.js +1 -1
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +514 -97
- package/dist/control-database.js +1033 -195
- package/dist/control-database.js.map +1 -1
- package/dist/control-protocol-guard.d.ts +142 -0
- package/dist/control-protocol-guard.js +293 -0
- package/dist/control-protocol-guard.js.map +1 -0
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +801 -1067
- package/dist/control-schema.js.map +1 -1
- package/dist/control-stream.d.ts +4 -0
- package/dist/control-stream.js +22 -7
- package/dist/control-stream.js.map +1 -1
- package/dist/delegate-admission.d.ts +1 -1
- package/dist/delegate-admission.js +1 -1
- package/dist/device-token.d.ts +11 -9
- package/dist/device-token.js +14 -12
- package/dist/device-token.js.map +1 -1
- package/dist/ed25519-key.d.ts +15 -0
- package/dist/ed25519-key.js +28 -1
- package/dist/ed25519-key.js.map +1 -1
- package/dist/index.d.ts +14 -9
- package/dist/index.js +27 -9
- package/dist/index.js.map +1 -1
- package/dist/link-budget.d.ts +16 -29
- package/dist/link-budget.js +16 -31
- package/dist/link-budget.js.map +1 -1
- package/dist/membership-connection-gater.d.ts +190 -151
- package/dist/membership-connection-gater.js +235 -173
- package/dist/membership-connection-gater.js.map +1 -1
- package/dist/node-claim-payload.d.ts +19 -0
- package/dist/node-claim-payload.js +120 -0
- package/dist/node-claim-payload.js.map +1 -0
- package/dist/owner-anchor-sync.d.ts +22 -0
- package/dist/owner-anchor-sync.js +143 -0
- package/dist/owner-anchor-sync.js.map +1 -0
- package/dist/peer-authorization.d.ts +113 -18
- package/dist/peer-authorization.js +225 -29
- package/dist/peer-authorization.js.map +1 -1
- package/dist/peer-dial.d.ts +19 -0
- package/dist/peer-dial.js +22 -1
- package/dist/peer-dial.js.map +1 -1
- package/dist/peer-join-backfill.d.ts +26 -2
- package/dist/peer-join-backfill.js +54 -12
- package/dist/peer-join-backfill.js.map +1 -1
- package/dist/peer-record.d.ts +13 -9
- package/dist/peer-record.js +16 -12
- package/dist/peer-record.js.map +1 -1
- package/dist/pending-join-runner.d.ts +66 -58
- package/dist/pending-join-runner.js +136 -145
- package/dist/pending-join-runner.js.map +1 -1
- package/dist/push-fanout.d.ts +8 -2
- package/dist/push-fanout.js +8 -2
- package/dist/push-fanout.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +147 -122
- package/dist/seed-bootstrap.js +284 -269
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/seed-trust-policy.d.ts +98 -37
- package/dist/seed-trust-policy.js +188 -2
- package/dist/seed-trust-policy.js.map +1 -1
- package/dist/strand-addr-protocol.d.ts +24 -26
- package/dist/strand-addr-protocol.js +19 -26
- package/dist/strand-addr-protocol.js.map +1 -1
- package/dist/strand-formation-protocol.js +1 -1
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-revocation-enforcer.d.ts +11 -11
- package/dist/strand-revocation-enforcer.js +11 -11
- package/dist/strand-wake-protocol.d.ts +13 -16
- package/dist/strand-wake-protocol.js +13 -20
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/trusted-owner-store.d.ts +35 -5
- package/dist/trusted-owner-store.js +50 -8
- package/dist/trusted-owner-store.js.map +1 -1
- package/dist/types.d.ts +302 -87
- package/dist/types.js.map +1 -1
- package/package.json +8 -7
- package/src/cadre-invite-protocol.ts +864 -0
- package/src/cadre-node.ts +9381 -8686
- package/src/claim-proof.ts +147 -0
- package/src/control-authorization.ts +203 -117
- package/src/control-cohort.ts +1 -1
- package/src/control-database.ts +1216 -218
- package/src/control-protocol-guard.ts +348 -0
- package/src/control-schema.ts +812 -1078
- package/src/control-stream.ts +26 -7
- package/src/delegate-admission.ts +1 -1
- package/src/device-token.ts +14 -10
- package/src/ed25519-key.ts +28 -1
- package/src/index.ts +84 -10
- package/src/link-budget.ts +16 -32
- package/src/membership-connection-gater.ts +637 -570
- package/src/node-claim-payload.ts +125 -0
- package/src/owner-anchor-sync.ts +171 -0
- package/src/peer-authorization.ts +258 -29
- package/src/peer-dial.ts +22 -1
- package/src/peer-join-backfill.ts +56 -11
- package/src/peer-record.ts +16 -11
- package/src/pending-join-runner.ts +729 -735
- package/src/push-fanout.ts +8 -2
- package/src/seed-bootstrap.ts +344 -321
- package/src/seed-trust-policy.ts +353 -140
- package/src/strand-addr-protocol.ts +26 -35
- package/src/strand-formation-protocol.ts +1 -1
- package/src/strand-revocation-enforcer.ts +11 -11
- package/src/strand-wake-protocol.ts +13 -23
- package/src/trusted-owner-store.ts +242 -171
- package/src/types.ts +2471 -2242
package/dist/control-database.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import debug from 'debug';
|
|
2
2
|
import { toString as uint8ArrayToString } from 'uint8arrays';
|
|
3
|
-
import { Database, registerPlugin } from '@quereus/quereus';
|
|
3
|
+
import { Database, FunctionFlags, TEXT_RETURN, createScalarFunction, registerPlugin } from '@quereus/quereus';
|
|
4
4
|
import cryptoPlugin from '@optimystic/quereus-plugin-crypto/plugin';
|
|
5
5
|
import optimysticPlugin from '@optimystic/quereus-plugin-optimystic/plugin';
|
|
6
6
|
import { digest, randomBytes } from '@optimystic/quereus-plugin-crypto';
|
|
7
7
|
import { CONTROL_SCHEMA } from './control-schema.js';
|
|
8
8
|
import { canonicalDatetime } from './canonical-datetime.js';
|
|
9
|
-
import { controlAuthorizationFields, CONTROL_TABLES } from './control-authorization.js';
|
|
10
|
-
import { requireEd25519PublicKeyB64 } from './ed25519-key.js';
|
|
9
|
+
import { controlAuthorizationFields, unboundAuthorizationFields, cadreInviteRowFields, CONTROL_TABLES } from './control-authorization.js';
|
|
10
|
+
import { ed25519PublicKeyB64FromPeerId, requireEd25519PublicKeyB64 } from './ed25519-key.js';
|
|
11
|
+
import { verifyCadreInviteRow } from './peer-authorization.js';
|
|
11
12
|
import { retryControlWrite, SCHEMA_INIT_RETRY_POLICY } from './control-write-retry.js';
|
|
12
13
|
import { isCohortUnreachableRead, retryControlRead } from './control-read-retry.js';
|
|
13
14
|
import { chainMessages } from './control-retry.js';
|
|
@@ -75,13 +76,15 @@ export class FormationAbortedError extends Error {
|
|
|
75
76
|
}
|
|
76
77
|
}
|
|
77
78
|
/**
|
|
78
|
-
* The invitation's seat budget is already spent: the count of recorded
|
|
79
|
-
*
|
|
80
|
-
*
|
|
79
|
+
* The invitation's seat budget is already spent: the count of recorded usage rows for the
|
|
80
|
+
* invitation has reached its `TotalUses`, so this redemption cannot be given a seat. `token`
|
|
81
|
+
* is the `FormationInvite.Token` or the `CadreInvite.Key`, whichever table the caller
|
|
82
|
+
* redeemed against.
|
|
81
83
|
*
|
|
82
84
|
* Raised off the committed-count check ({@link ControlDatabase.assertSeatRemains}) ahead of
|
|
83
85
|
* the write, inside {@link ControlDatabase.redeemInvitation} /
|
|
84
|
-
* {@link ControlDatabase.recordFormationUsage},
|
|
86
|
+
* {@link ControlDatabase.recordFormationUsage}, off the same check for a cadre invitation
|
|
87
|
+
* ({@link ControlDatabase.redeemCadreInvite}), and off the unlocked pre-check
|
|
85
88
|
* `ControlFormationUsageRecorder.authorizeUsage` runs before the manager issues a membership
|
|
86
89
|
* pass — and off nothing else. It exists because
|
|
87
90
|
* without it the refusal surfaces as a generic `CHECK constraint failed: Authorized` from the
|
|
@@ -101,6 +104,23 @@ export class InvitationExhaustedError extends Error {
|
|
|
101
104
|
this.name = 'InvitationExhaustedError';
|
|
102
105
|
}
|
|
103
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* {@link ControlDatabase.seatCadreInvite} was refused by `CadreInvite.AuthorizedInsert`: the
|
|
109
|
+
* row's `IssuerKey` is not an owner on THIS node, or its `IssuerSig` does not verify. The
|
|
110
|
+
* common cause is the first — the issuing owner's `OwnerKey` row has not replicated here yet,
|
|
111
|
+
* or that owner has since been removed — which is why the redemption protocol maps this to a
|
|
112
|
+
* retryable code; a tampered row fails the same way and simply never succeeds.
|
|
113
|
+
*/
|
|
114
|
+
export class CadreInviteIssuerUnknownError extends Error {
|
|
115
|
+
constructor(inviteKey, issuerKey, options) {
|
|
116
|
+
super(`Cadre invitation ${inviteKey} cannot be seated here: its issuer ${issuerKey} is not an owner on this node, or its signature does not verify`, options);
|
|
117
|
+
this.inviteKey = inviteKey;
|
|
118
|
+
this.issuerKey = issuerKey;
|
|
119
|
+
this.name = 'CadreInviteIssuerUnknownError';
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** The refusal {@link CadreInviteIssuerUnknownError} wraps — the constraint named, as the engine words it. */
|
|
123
|
+
const CADRE_INVITE_UNAUTHORIZED = /CHECK constraint failed: AuthorizedInsert\b/;
|
|
104
124
|
/**
|
|
105
125
|
* Parse a stored Quereus `datetime` value into epoch milliseconds.
|
|
106
126
|
*
|
|
@@ -132,14 +152,14 @@ function parseNullableStoredDatetimeMs(value) {
|
|
|
132
152
|
* The message is a SINGLE framed SHA-256 digest over the ordered field vector from
|
|
133
153
|
* {@link controlAuthorizationFields} (the crypto plugin's injective multi-field
|
|
134
154
|
* encoding): two fixed literals — the domain tag naming the table rule and the action
|
|
135
|
-
* tag —
|
|
136
|
-
* StampId as the final field where the table has one:
|
|
155
|
+
* tag — then the party id, then the row fields in the schema's fixed order, with the
|
|
156
|
+
* single-use StampId as the final field where the table has one:
|
|
137
157
|
*
|
|
138
|
-
* message = sha256(encodeFields([domain, action, field_1, ..., StampId])) // raw digest bytes
|
|
158
|
+
* message = sha256(encodeFields([domain, action, partyId, field_1, ..., StampId])) // raw digest bytes
|
|
139
159
|
*
|
|
140
160
|
* ed25519 signs these raw digest bytes DIRECTLY (no second hash). The SQL constraints
|
|
141
161
|
* verify the identical bytes with one variadic call
|
|
142
|
-
* (`verify(digest('CadreControl.X', 'add', field_1, ...), context.Signature, A.Key, 'ed25519')`):
|
|
162
|
+
* (`verify(digest('CadreControl.X', 'add', party_id(), field_1, ...), context.Signature, A.Key, 'ed25519')`):
|
|
143
163
|
* SQL `digest(...)` returns the base64url string of the same digest, which `verify`'s default
|
|
144
164
|
* base64url input encoding decodes back to those raw bytes — so signer and verifier
|
|
145
165
|
* operate on the same bytes. Every field is TEXT on both sides (the SQL columns are
|
|
@@ -147,12 +167,43 @@ function parseNullableStoredDatetimeMs(value) {
|
|
|
147
167
|
* type tags agree. Binding the row contents closes captured-stamp replay; the leading
|
|
148
168
|
* domain/action tags scope the signature to ONE table rule, so an approval minted for
|
|
149
169
|
* one constraint can never satisfy another (e.g. a ValidationKey enrollment can no
|
|
150
|
-
* longer double as an OwnerKey enrollment)
|
|
151
|
-
*
|
|
152
|
-
*
|
|
170
|
+
* longer double as an OwnerKey enrollment); the party id scopes it to ONE party, so two
|
|
171
|
+
* parties sharing an owner key never accept each other's approvals. `partyId` is the
|
|
172
|
+
* party the signer is configured for, and the SQL side reads its own through the
|
|
173
|
+
* node-local `party_id()` ({@link registerPartyIdFunction}). Single source of truth:
|
|
174
|
+
* every signed writer (and every test/harness signer) MUST build the message through
|
|
175
|
+
* this function with the schema's tags and field order, or `verify` will reject the row.
|
|
176
|
+
*/
|
|
177
|
+
export function buildAuthorizationMessage(domain, action, partyId, rowFields) {
|
|
178
|
+
return digest(controlAuthorizationFields(domain, action, partyId, rowFields), 'sha256', 'bytes');
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* {@link buildAuthorizationMessage} for the two party-free digests (`UnboundControlDomain`):
|
|
182
|
+
* the same framed digest with no party id after the tags.
|
|
183
|
+
*/
|
|
184
|
+
function buildUnboundAuthorizationMessage(domain, action, rowFields) {
|
|
185
|
+
return digest(unboundAuthorizationFields(domain, action, rowFields), 'sha256', 'bytes');
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Register the zero-argument scalar `party_id()` on `db`: the party id this machine is
|
|
189
|
+
* configured for, which every party-bound `digest(...)` in the control schema puts third.
|
|
190
|
+
* Node-local by design — a `context` value would be the writer's choice (a cohort member
|
|
191
|
+
* that re-ran the statement would replay it as a literal), and a stored `Party` table would
|
|
192
|
+
* be replicated data the schema already treats as untrusted. A cohort member that re-runs a
|
|
193
|
+
* statement therefore evaluates its OWN party id, and the schema text stays identical for
|
|
194
|
+
* every party. Each `Database` carries its own registration, so a process serving two
|
|
195
|
+
* parties holds two.
|
|
196
|
+
*
|
|
197
|
+
* NOTE: flagged deterministic, so the planner may fold the call into a plan; plans are
|
|
198
|
+
* per `Database` today. If Quereus ever shares plans across `Database` instances, drop
|
|
199
|
+
* the DETERMINISTIC flag or one party's id would be folded into another's checks.
|
|
200
|
+
*
|
|
201
|
+
* `ControlDatabase.initialize` calls this before hydrating persisted schemas, because a
|
|
202
|
+
* warm restart parses the persisted CHECK text, which calls it. Exported for specs that
|
|
203
|
+
* build their own `Database` around a copy of a bound constraint.
|
|
153
204
|
*/
|
|
154
|
-
export function
|
|
155
|
-
|
|
205
|
+
export function registerPartyIdFunction(db, partyId) {
|
|
206
|
+
db.registerFunction(createScalarFunction({ name: 'party_id', numArgs: 0, flags: FunctionFlags.UTF8 | FunctionFlags.DETERMINISTIC, returnType: TEXT_RETURN }, () => partyId));
|
|
156
207
|
}
|
|
157
208
|
/**
|
|
158
209
|
* The exact bytes an outside approver signs to authorize ONE redemption of a
|
|
@@ -169,9 +220,13 @@ export function buildAuthorizationMessage(domain, action, rowFields) {
|
|
|
169
220
|
* that same nonce, then pass BOTH the nonce and the signature to
|
|
170
221
|
* {@link ControlDatabase.redeemInvitation} / {@link ControlDatabase.recordFormationUsage}:
|
|
171
222
|
* signing one nonce and inserting another fails the CHECK.
|
|
223
|
+
*
|
|
224
|
+
* No party id, unlike every owner digest: the approver is sent only these five fields
|
|
225
|
+
* (`formation-approval.ts`), and the `token` it binds is a random secret seated in one
|
|
226
|
+
* party's `FormationInvite` table (the schema's `FormationUsage.Authorized` comment).
|
|
172
227
|
*/
|
|
173
228
|
export function formationVouchMessage(fields) {
|
|
174
|
-
return
|
|
229
|
+
return buildUnboundAuthorizationMessage('CadreControl.FormationUsage', 'vouch', [
|
|
175
230
|
fields.token, fields.usageStampId, fields.strandId, fields.peerKey, fields.disclosure,
|
|
176
231
|
]);
|
|
177
232
|
}
|
|
@@ -191,13 +246,48 @@ export function formationVouchMessage(fields) {
|
|
|
191
246
|
* the strand when it signs (a bound invite's host strand arrives only in the result
|
|
192
247
|
* frame; an unbound strand is minted by the responder). The responder cannot
|
|
193
248
|
* substitute one anyway — a bound invite is pinned to its own strand by `Authorized`,
|
|
194
|
-
* and an unbound redemption mints a fresh strand, so there is no victim to name.
|
|
249
|
+
* and an unbound redemption mints a fresh strand, so there is no victim to name. The
|
|
250
|
+
* party id is absent for the same reason (it too arrives only in the result frame) and
|
|
251
|
+
* the `token` scopes the digest to one party instead, as for {@link formationVouchMessage}.
|
|
195
252
|
*/
|
|
196
253
|
export function formationConsentMessage(fields) {
|
|
197
|
-
return
|
|
254
|
+
return buildUnboundAuthorizationMessage('CadreControl.FormationUsage', 'consent', [
|
|
198
255
|
fields.token, fields.usageStampId, fields.peerKey, fields.disclosure,
|
|
199
256
|
]);
|
|
200
257
|
}
|
|
258
|
+
/**
|
|
259
|
+
* The exact bytes an owner signs to seat a `CadreInvite` row — the TS mirror of the `'add'`
|
|
260
|
+
* digest in `CadreInvite.AuthorizedInsert`, over the whole row in the schema's order with
|
|
261
|
+
* nullable columns as `''` (`cadreInviteRowFields`, shared with the base64url verifier
|
|
262
|
+
* `peer-authorization.ts`'s `cadreInviteAddDigest`). `expiresAt` must already be the stored
|
|
263
|
+
* canonical `datetime` string ({@link canonicalDatetime}), as {@link ControlDatabase.insertCadreInvite}
|
|
264
|
+
* produces it, or the signed segment will not match what the CHECK sees after coercion.
|
|
265
|
+
*/
|
|
266
|
+
export function cadreInviteAddMessage(partyId, row) {
|
|
267
|
+
return buildAuthorizationMessage('CadreControl.CadreInvite', 'add', partyId, cadreInviteRowFields(row));
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* The exact bytes the HOLDER of a cadre invitation signs, with the invitation's private key,
|
|
271
|
+
* to redeem it once — the TS mirror of the `'redeem'` digest `CadreInviteUsage.InvitePossessed`
|
|
272
|
+
* verifies against the invitation's public key. Binding the device and the nonce means a
|
|
273
|
+
* captured redemption can admit no other device and spend no other use.
|
|
274
|
+
*/
|
|
275
|
+
export function cadreInviteRedeemMessage(partyId, fields) {
|
|
276
|
+
return buildAuthorizationMessage('CadreControl.CadreInviteUsage', 'redeem', partyId, [
|
|
277
|
+
fields.inviteKey, fields.usageStampId, fields.peerKey,
|
|
278
|
+
]);
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* The exact bytes the DEVICE signs, with its own key, to consent to being admitted by one
|
|
282
|
+
* redemption — the TS mirror of the `'consent'` digest `CadreInviteUsage.PeerConsented`
|
|
283
|
+
* verifies against `PeerKey`. Same fields as {@link cadreInviteRedeemMessage} under a
|
|
284
|
+
* distinct action tag, so neither signature can stand in for the other.
|
|
285
|
+
*/
|
|
286
|
+
export function cadreInviteConsentMessage(partyId, fields) {
|
|
287
|
+
return buildAuthorizationMessage('CadreControl.CadreInviteUsage', 'consent', partyId, [
|
|
288
|
+
fields.inviteKey, fields.usageStampId, fields.peerKey,
|
|
289
|
+
]);
|
|
290
|
+
}
|
|
201
291
|
/** Runtime guard for the dynamic-`from` count, over the one table list. */
|
|
202
292
|
const CONTROL_TABLE_SET = new Set(CONTROL_TABLES);
|
|
203
293
|
/**
|
|
@@ -213,14 +303,33 @@ const GUARDED_KEY_COLUMN = {
|
|
|
213
303
|
Strand: 'Id',
|
|
214
304
|
StrandPartyKey: 'Id',
|
|
215
305
|
JoinedStrand: 'Id',
|
|
216
|
-
|
|
306
|
+
JoinRequest: 'Id',
|
|
217
307
|
DeviceToken: 'PeerId',
|
|
308
|
+
CadreInvite: 'Key',
|
|
218
309
|
};
|
|
219
|
-
|
|
310
|
+
/**
|
|
311
|
+
* The exact bytes an owner signs to append one `Revocation` tombstone — `Revocation.Authorized`
|
|
312
|
+
* binds the whole (TableName, RowKey, StampId) row under its own domain tag. Shared by every
|
|
313
|
+
* tombstone writer (a guarded removal, a `CadreInvite` withdrawal, the ledger marker) so the
|
|
314
|
+
* field order lives in one place; the base64url verifier is `peer-authorization.ts`'s
|
|
315
|
+
* `revocationDigest`.
|
|
316
|
+
*/
|
|
317
|
+
function revocationTombstoneMessage(partyId, ref) {
|
|
318
|
+
return buildAuthorizationMessage('CadreControl.Revocation', 'remove', partyId, [ref.tableName, ref.rowKey, ref.stampId]);
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* The exact bytes an owner signs to seat a further `OwnerKey` row (the signed-add branch of
|
|
322
|
+
* `OwnerKey.Authorized`); the base64url twin is `peer-authorization.ts`'s `ownerKeyAddDigest`.
|
|
323
|
+
*/
|
|
324
|
+
function ownerKeyAddMessage(partyId, key, stampId) {
|
|
325
|
+
return buildAuthorizationMessage('CadreControl.OwnerKey', 'add', partyId, [key, stampId]);
|
|
326
|
+
}
|
|
327
|
+
function signGuardedRemoval(partyId, tableName, rowKey, stampId, signMessage) {
|
|
328
|
+
const ref = { tableName, rowKey, stampId };
|
|
220
329
|
return {
|
|
221
|
-
ref
|
|
222
|
-
signature: signMessage(buildAuthorizationMessage(`CadreControl.${tableName}`, 'remove', [rowKey, stampId])),
|
|
223
|
-
revocationSignature: signMessage(
|
|
330
|
+
ref,
|
|
331
|
+
signature: signMessage(buildAuthorizationMessage(`CadreControl.${tableName}`, 'remove', partyId, [rowKey, stampId])),
|
|
332
|
+
revocationSignature: signMessage(revocationTombstoneMessage(partyId, ref)),
|
|
224
333
|
};
|
|
225
334
|
}
|
|
226
335
|
/**
|
|
@@ -254,8 +363,8 @@ export function isStrandIdConflict(error, table) {
|
|
|
254
363
|
return errorChainMatches(error, idConflictPattern(table));
|
|
255
364
|
}
|
|
256
365
|
/**
|
|
257
|
-
* Did {@link ControlDatabase.
|
|
258
|
-
* `
|
|
366
|
+
* Did {@link ControlDatabase.insertJoinRequest} fail because the party already holds a
|
|
367
|
+
* `JoinRequest` row for this invitation? The caller adopts the existing request instead.
|
|
259
368
|
* Text-matched, failing closed, like {@link isStrandIdConflict}.
|
|
260
369
|
*
|
|
261
370
|
* NOTE: the existing row may be one {@link ControlDatabase.queryPendingJoin} hides (its stamp
|
|
@@ -263,10 +372,10 @@ export function isStrandIdConflict(error, table) {
|
|
|
263
372
|
* until the next reap pass.
|
|
264
373
|
*/
|
|
265
374
|
export function isPendingJoinConflict(error) {
|
|
266
|
-
return errorChainMatches(error, idConflictPattern('
|
|
375
|
+
return errorChainMatches(error, idConflictPattern('JoinRequest'));
|
|
267
376
|
}
|
|
268
377
|
/**
|
|
269
|
-
* The `
|
|
378
|
+
* The `JoinRequest.Id` of an invitation: the base64url sha256 `digest` of its token. One row
|
|
270
379
|
* per invitation, and the `Revocation` tombstone that names a removed row carries this, never
|
|
271
380
|
* the bearer token itself.
|
|
272
381
|
*/
|
|
@@ -274,17 +383,15 @@ export function pendingJoinId(token) {
|
|
|
274
383
|
return digest([token], 'sha256', 'base64url');
|
|
275
384
|
}
|
|
276
385
|
/**
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
* re-reads and decides again. `liveStampId` is null when the row is gone.
|
|
386
|
+
* A pending-join write found that the live join is not the one the caller read: another write
|
|
387
|
+
* recorded an outcome, asked again or dismissed it first. Nothing was written; the caller
|
|
388
|
+
* decides again from `live`, which is null when the request is gone.
|
|
281
389
|
*/
|
|
282
390
|
export class PendingJoinChangedError extends Error {
|
|
283
|
-
constructor(id,
|
|
284
|
-
super(`
|
|
391
|
+
constructor(id, live) {
|
|
392
|
+
super(`Pending join ${id} changed: ${live === null ? 'no request' : `request ${live.StampId}, ${live.outcome?.kind ?? 'pending'}`}`);
|
|
285
393
|
this.id = id;
|
|
286
|
-
this.
|
|
287
|
-
this.liveStampId = liveStampId;
|
|
394
|
+
this.live = live;
|
|
288
395
|
this.name = 'PendingJoinChangedError';
|
|
289
396
|
}
|
|
290
397
|
}
|
|
@@ -315,6 +422,49 @@ function isRevocationLedgerConflict(error) {
|
|
|
315
422
|
function errorChainMatches(error, pattern) {
|
|
316
423
|
return error instanceof Error && chainMessages(error).some(message => pattern.test(message));
|
|
317
424
|
}
|
|
425
|
+
/**
|
|
426
|
+
* Project `CadrePeer` rows into the {@link SeedPeer} list a seed, or a cadre invitation
|
|
427
|
+
* redemption reply, carries.
|
|
428
|
+
*
|
|
429
|
+
* Owner identity is sourced from the `OwnerKey` table, not from the transport peer id. An
|
|
430
|
+
* Ed25519 libp2p peer id embeds its public key (identity multihash), so each peer's ed25519
|
|
431
|
+
* key is derivable from its `PeerId`; a peer is an owner iff that derived key is in
|
|
432
|
+
* `ownerKeys`. This makes any owner node markable — not just the local one. A non-Ed25519
|
|
433
|
+
* or unparsable id yields a non-owner rather than failing the whole projection.
|
|
434
|
+
*
|
|
435
|
+
* NOTE: this is the one owner lookup deliberately left on the REPLICATED table rather than
|
|
436
|
+
* the node-local anchor. `SeedPeer.isOwner` is a dial hint — the receiver dials owner-flagged
|
|
437
|
+
* peers first — not a trust decision, and the receiver re-derives real trust from its own
|
|
438
|
+
* anchor. So a polluted table costs at most a wasted dial, while anchoring here would
|
|
439
|
+
* silently drop legitimate co-owners this node never pinned. If `isOwner` ever gates
|
|
440
|
+
* anything the receiver TRUSTS, move it to the anchor.
|
|
441
|
+
*/
|
|
442
|
+
export function projectSeedPeers(rows, ownerKeys) {
|
|
443
|
+
return rows.map(({ peerId, multiaddr }) => {
|
|
444
|
+
const pubKeyB64 = ed25519PublicKeyB64FromPeerId(peerId);
|
|
445
|
+
const isOwner = pubKeyB64 !== null && ownerKeys.has(pubKeyB64);
|
|
446
|
+
return {
|
|
447
|
+
peerId,
|
|
448
|
+
multiaddrs: multiaddr ? multiaddr.split(',') : [],
|
|
449
|
+
isOwner,
|
|
450
|
+
...(isOwner ? { publicKey: pubKeyB64 } : {}),
|
|
451
|
+
};
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* The conditions a cadre invitation must still meet to be redeemed, short of its use count:
|
|
456
|
+
* not withdrawn, unexpired (`expiresAt <= now` is expired, as the redemption refuses it),
|
|
457
|
+
* and issued by a current owner. Shared by {@link ControlDatabase.hasLiveCadreInvite},
|
|
458
|
+
* {@link ControlDatabase.isCadreInviteLive} and {@link ControlDatabase.listCadreInviteStatuses}
|
|
459
|
+
* so they cannot drift from each other, or from `CadreInviteUsage.Authorized`.
|
|
460
|
+
*/
|
|
461
|
+
function cadreInviteStillOpen(row, withdrawn, owners, nowMs) {
|
|
462
|
+
if (withdrawn.has(row.stampId) || !owners.has(row.issuerKey)) {
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
465
|
+
const expiresAtMs = parseNullableStoredDatetimeMs(row.expiresAt);
|
|
466
|
+
return expiresAtMs === null || expiresAtMs > nowMs;
|
|
467
|
+
}
|
|
318
468
|
/**
|
|
319
469
|
* Guarded tables a node may reap locally once their tombstone has committed — the
|
|
320
470
|
* tables whose `AuthorizedDelete` carries the REAP branch (see the constraint comment on
|
|
@@ -323,21 +473,54 @@ function errorChainMatches(error, pattern) {
|
|
|
323
473
|
* secrets stored nowhere else (tickets/backlog/debt-strand-tombstone-reap.md owns any
|
|
324
474
|
* future change). `JoinedStrand` also carries a `MemberPrivateKey` but IS reapable: that
|
|
325
475
|
* secret is the joined strand's shared read key, which its founding party and every member
|
|
326
|
-
* hold, so a reaped row is recoverable by re-forming. `
|
|
476
|
+
* hold, so a reaped row is recoverable by re-forming. `JoinRequest` is reapable for the same
|
|
327
477
|
* reason: the inviting party and the user also hold its invitation. `OwnerKey` has no
|
|
328
478
|
* production removal path and `MinOneOwner` makes an automated owner-key reap a
|
|
329
|
-
* party-bricking hazard.
|
|
479
|
+
* party-bricking hazard. `CadreInvite` is never deleted at all (its tombstone is a
|
|
480
|
+
* withdrawal over a row that stays), so there is nothing to reap.
|
|
330
481
|
*/
|
|
331
|
-
export const REAPABLE_TABLES = ['CadrePeer', 'DeviceToken', 'ValidationKey', 'JoinedStrand', '
|
|
482
|
+
export const REAPABLE_TABLES = ['CadrePeer', 'DeviceToken', 'ValidationKey', 'JoinedStrand', 'JoinRequest'];
|
|
332
483
|
/**
|
|
333
484
|
* Runtime membership test for {@link REAPABLE_TABLES}, narrowing a tombstone's
|
|
334
485
|
* `TableName` to the subset {@link ControlDatabase.reapRevokedRow} accepts. A tombstone
|
|
335
|
-
* naming an excluded table (`Strand`, `OwnerKey`) is skipped silently —
|
|
336
|
-
* tombstones are the common case in a party that has unpublished a strand, so a
|
|
337
|
-
* per skip would be per-pass noise.
|
|
486
|
+
* naming an excluded table (`Strand`, `OwnerKey`, `CadreInvite`) is skipped silently —
|
|
487
|
+
* `Strand` tombstones are the common case in a party that has unpublished a strand, so a
|
|
488
|
+
* log line per skip would be per-pass noise.
|
|
338
489
|
*/
|
|
339
490
|
const REAPABLE_TABLE_SET = new Set(REAPABLE_TABLES);
|
|
340
491
|
const isReapableTable = (table) => REAPABLE_TABLE_SET.has(table);
|
|
492
|
+
/** Every `CadreInvite` column, in the order {@link cadreInviteRowOf} reads them; callers append their `where`. */
|
|
493
|
+
const CADRE_INVITE_SELECT_SQL = 'select Key, PeerId, GrantsOwner, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId from CadreControl.CadreInvite';
|
|
494
|
+
/**
|
|
495
|
+
* A stored `CadreInvite` row as a {@link CadreInviteRow}. `ExpiresAt` is kept as the
|
|
496
|
+
* engine-canonical string it reads back as — the form the `'add'` signature covers — never
|
|
497
|
+
* parsed to ms here; `GrantsOwner` is the schema's 0/1 integer.
|
|
498
|
+
*/
|
|
499
|
+
function cadreInviteRowOf(row) {
|
|
500
|
+
return {
|
|
501
|
+
key: row.Key,
|
|
502
|
+
peerId: row.PeerId ?? null,
|
|
503
|
+
grantsOwner: Number(row.GrantsOwner) === 1,
|
|
504
|
+
expiresAt: row.ExpiresAt == null ? null : String(row.ExpiresAt),
|
|
505
|
+
totalUses: row.TotalUses ?? null,
|
|
506
|
+
issuerKey: row.IssuerKey,
|
|
507
|
+
issuerSig: row.IssuerSig,
|
|
508
|
+
stampId: row.StampId,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
/** A stored `CadreInviteUsage` row as a {@link CadreInviteUsageRow}. */
|
|
512
|
+
function cadreInviteUsageRowOf(row) {
|
|
513
|
+
return {
|
|
514
|
+
usageStampId: row.UsageStampId,
|
|
515
|
+
inviteKey: row.InviteKey,
|
|
516
|
+
peerId: row.PeerId,
|
|
517
|
+
peerKey: row.PeerKey,
|
|
518
|
+
peerStampId: row.PeerStampId,
|
|
519
|
+
ownerStampId: row.OwnerStampId ?? null,
|
|
520
|
+
inviteSig: row.InviteSig,
|
|
521
|
+
peerSig: row.PeerSig,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
341
524
|
/** A `JoinedStrand` row as a {@link StrandRow}; the schema's `KnownType` check backs the `Type` cast. */
|
|
342
525
|
function joinedStrandRow(row) {
|
|
343
526
|
return {
|
|
@@ -347,47 +530,79 @@ function joinedStrandRow(row) {
|
|
|
347
530
|
FounderOwnerKey: null,
|
|
348
531
|
};
|
|
349
532
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
533
|
+
const JOIN_REQUEST_INSERT_SQL = `
|
|
534
|
+
insert into CadreControl.JoinRequest (Id, Invitation, Disclosure, RequestedAt, ExpiresAt, StampId)
|
|
535
|
+
with context OwnerKey = ?, Signature = ?
|
|
536
|
+
values (?, ?, ?, ?, ?, ?)
|
|
537
|
+
`;
|
|
538
|
+
const JOIN_SUCCESS_INSERT_SQL = `
|
|
539
|
+
insert into CadreControl.JoinSuccess (RequestStampId, RecordedAt, StrandId, MembershipInvite)
|
|
540
|
+
with context OwnerKey = ?, Signature = ?
|
|
541
|
+
values (?, ?, ?, ?)
|
|
542
|
+
`;
|
|
543
|
+
const JOIN_FAILURE_INSERT_SQL = `
|
|
544
|
+
insert into CadreControl.JoinFailure (RequestStampId, RecordedAt, Code, Reason)
|
|
354
545
|
with context OwnerKey = ?, Signature = ?
|
|
355
|
-
values (?, ?, ?,
|
|
546
|
+
values (?, ?, ?, ?)
|
|
547
|
+
`;
|
|
548
|
+
/**
|
|
549
|
+
* A request with its outcome in one statement: the outcome tables are keyed by the request's
|
|
550
|
+
* stamp, so the join finds exactly this incarnation's outcome.
|
|
551
|
+
*/
|
|
552
|
+
const PENDING_JOIN_SELECT_SQL = `
|
|
553
|
+
select R.Id, R.Invitation, R.Disclosure, R.RequestedAt, R.ExpiresAt, R.StampId,
|
|
554
|
+
S.RecordedAt as JoinedAt, S.StrandId, S.MembershipInvite,
|
|
555
|
+
F.RecordedAt as FailedAt, F.Code, F.Reason
|
|
556
|
+
from CadreControl.JoinRequest R
|
|
557
|
+
left join CadreControl.JoinSuccess S on S.RequestStampId = R.StampId
|
|
558
|
+
left join CadreControl.JoinFailure F on F.RequestStampId = R.StampId
|
|
356
559
|
`;
|
|
357
|
-
/** A
|
|
358
|
-
function
|
|
560
|
+
/** A {@link PENDING_JOIN_SELECT_SQL} row as read. */
|
|
561
|
+
function pendingJoinOf(row) {
|
|
359
562
|
return {
|
|
360
563
|
Id: row.Id,
|
|
361
564
|
Invitation: row.Invitation,
|
|
362
565
|
Disclosure: row.Disclosure,
|
|
363
566
|
RequestedAt: row.RequestedAt,
|
|
364
567
|
ExpiresAt: row.ExpiresAt,
|
|
365
|
-
Outcome: row.Outcome,
|
|
366
|
-
OutcomeAt: row.OutcomeAt,
|
|
367
|
-
StrandId: row.StrandId,
|
|
368
|
-
MembershipInvite: row.MembershipInvite,
|
|
369
|
-
FailureCode: row.FailureCode,
|
|
370
|
-
FailureReason: row.FailureReason,
|
|
371
568
|
StampId: row.StampId,
|
|
569
|
+
outcome: joinOutcomeOf(row),
|
|
372
570
|
};
|
|
373
571
|
}
|
|
572
|
+
/** The outcome half of a {@link PENDING_JOIN_SELECT_SQL} row; a request never has both (`JoinSuccess.NoFailure`, `JoinFailure.NotJoined`). */
|
|
573
|
+
function joinOutcomeOf(row) {
|
|
574
|
+
if (typeof row.JoinedAt === 'number') {
|
|
575
|
+
return { kind: 'joined', RecordedAt: row.JoinedAt, StrandId: row.StrandId, MembershipInvite: row.MembershipInvite };
|
|
576
|
+
}
|
|
577
|
+
if (typeof row.FailedAt === 'number') {
|
|
578
|
+
return { kind: 'failed', RecordedAt: row.FailedAt, Code: row.Code, Reason: row.Reason };
|
|
579
|
+
}
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
/** The fields `JoinRequest.AuthorizedInsert` signs, in the schema's order; integers sign as their text. */
|
|
583
|
+
function joinRequestAddFields(row) {
|
|
584
|
+
return [row.Id, row.Invitation, row.Disclosure, String(row.RequestedAt), String(row.ExpiresAt), row.StampId];
|
|
585
|
+
}
|
|
374
586
|
/**
|
|
375
|
-
* The
|
|
376
|
-
*
|
|
587
|
+
* The owner-signed insert of `outcome` for the request incarnation `requestStampId`: the
|
|
588
|
+
* `JoinSuccess` / `JoinFailure` row and the signature its `AuthorizedInsert` verifies, over the
|
|
589
|
+
* row's columns in the schema's order (a null column signs as `''`, an integer as its text).
|
|
377
590
|
*/
|
|
378
|
-
function
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
591
|
+
function signJoinOutcomeInsert(partyId, requestStampId, outcome, ownerKey, signMessage) {
|
|
592
|
+
const at = String(outcome.RecordedAt);
|
|
593
|
+
const { table, sql, fields, values } = outcome.kind === 'joined'
|
|
594
|
+
? {
|
|
595
|
+
table: 'JoinSuccess', sql: JOIN_SUCCESS_INSERT_SQL,
|
|
596
|
+
fields: [requestStampId, at, outcome.StrandId, outcome.MembershipInvite ?? ''],
|
|
597
|
+
values: [requestStampId, outcome.RecordedAt, outcome.StrandId, outcome.MembershipInvite],
|
|
598
|
+
}
|
|
599
|
+
: {
|
|
600
|
+
table: 'JoinFailure', sql: JOIN_FAILURE_INSERT_SQL,
|
|
601
|
+
fields: [requestStampId, at, outcome.Code, outcome.Reason],
|
|
602
|
+
values: [requestStampId, outcome.RecordedAt, outcome.Code, outcome.Reason],
|
|
603
|
+
};
|
|
604
|
+
const signature = signMessage(buildAuthorizationMessage(`CadreControl.${table}`, 'add', partyId, fields));
|
|
605
|
+
return { sql, params: [ownerKey, signature, ...values] };
|
|
391
606
|
}
|
|
392
607
|
/**
|
|
393
608
|
* ControlDatabase manages the CadreControl schema using Quereus with Optimystic backend.
|
|
@@ -424,6 +639,9 @@ export class ControlDatabase {
|
|
|
424
639
|
* backoff. Applied OVER the read policy's defaults but UNDER the call site's `label`.
|
|
425
640
|
*/
|
|
426
641
|
this.controlReadRetryPacing = {};
|
|
642
|
+
if (typeof config.partyId !== 'string' || config.partyId.length === 0) {
|
|
643
|
+
throw new Error('ControlDatabase requires a non-empty partyId: every control approval is bound to it');
|
|
644
|
+
}
|
|
427
645
|
this.config = config;
|
|
428
646
|
}
|
|
429
647
|
/**
|
|
@@ -459,6 +677,8 @@ export class ControlDatabase {
|
|
|
459
677
|
this.db.registerFunction(func.schema);
|
|
460
678
|
}
|
|
461
679
|
timing('[controlDb] optimysticPlugin: %dms', Math.round(performance.now() - t0));
|
|
680
|
+
// Before hydrate: a warm restart parses the persisted CHECK text, which calls party_id().
|
|
681
|
+
registerPartyIdFunction(this.db, this.config.partyId);
|
|
462
682
|
this.collectionFactory = pluginResult.collectionFactory;
|
|
463
683
|
// Inject the libp2p node into the collection factory
|
|
464
684
|
t0 = performance.now();
|
|
@@ -810,27 +1030,31 @@ export class ControlDatabase {
|
|
|
810
1030
|
return rows.length === 0 ? null : joinedStrandRow(rows[0]);
|
|
811
1031
|
}
|
|
812
1032
|
/**
|
|
813
|
-
* Every join this party asked for and has not dismissed
|
|
814
|
-
*
|
|
1033
|
+
* Every join this party asked for and has not dismissed, pending or finished: each
|
|
1034
|
+
* `CadreControl.JoinRequest` row with its `JoinSuccess` / `JoinFailure` row.
|
|
815
1035
|
*
|
|
816
|
-
*
|
|
817
|
-
* drops
|
|
818
|
-
*
|
|
819
|
-
*
|
|
1036
|
+
* A request whose `StampId` is retired in `Revocation` is dropped, as {@link queryCadrePeers}
|
|
1037
|
+
* drops such rows: it is a re-asked or dismissed incarnation this node still holds until the
|
|
1038
|
+
* reap sweep removes it, or one re-seated by a replayed approval on a node that had not yet
|
|
1039
|
+
* seen the tombstone. Either way it must not bring a dismissed join back.
|
|
820
1040
|
*/
|
|
821
1041
|
async queryPendingJoins() {
|
|
822
1042
|
this.ensureInitialized();
|
|
823
|
-
const revoked = await this.queryRevokedStamps('
|
|
824
|
-
const rows = await this.readRows(
|
|
825
|
-
return rows.map(
|
|
1043
|
+
const revoked = await this.queryRevokedStamps('JoinRequest');
|
|
1044
|
+
const rows = await this.readRows(PENDING_JOIN_SELECT_SQL, undefined, 'pending-joins');
|
|
1045
|
+
return rows.map(pendingJoinOf).filter(join => !revoked.has(join.StampId));
|
|
826
1046
|
}
|
|
827
|
-
/** Single-row sibling of {@link queryPendingJoins}, with the same retired-stamp filter: the
|
|
1047
|
+
/** Single-row sibling of {@link queryPendingJoins}, with the same retired-stamp filter: the join with this id, or null. */
|
|
828
1048
|
async queryPendingJoin(id) {
|
|
829
1049
|
this.ensureInitialized();
|
|
830
|
-
const revoked = await this.queryRevokedStamps('
|
|
831
|
-
const
|
|
832
|
-
|
|
833
|
-
|
|
1050
|
+
const revoked = await this.queryRevokedStamps('JoinRequest');
|
|
1051
|
+
const join = await this.readPendingJoin(id, true);
|
|
1052
|
+
return join === null || revoked.has(join.StampId) ? null : join;
|
|
1053
|
+
}
|
|
1054
|
+
/** The raw read behind {@link queryPendingJoin}, with no retired-stamp filter. `retry: false` inside a locked write body. */
|
|
1055
|
+
async readPendingJoin(id, retry) {
|
|
1056
|
+
const rows = await this.readRows(`${PENDING_JOIN_SELECT_SQL} where R.Id = ?`, [id], 'pending-join', retry);
|
|
1057
|
+
return rows.length === 0 ? null : pendingJoinOf(rows[0]);
|
|
834
1058
|
}
|
|
835
1059
|
/**
|
|
836
1060
|
* Count rows in a CadreControl table as seen by THIS database instance.
|
|
@@ -863,6 +1087,9 @@ export class ControlDatabase {
|
|
|
863
1087
|
/**
|
|
864
1088
|
* Check whether any owner key exists in the control database.
|
|
865
1089
|
* Used to decide whether a fresh-party genesis insert is required.
|
|
1090
|
+
*
|
|
1091
|
+
* Deliberately RAW (no retired-stamp filter, unlike {@link getOwnerKeys}): the genesis
|
|
1092
|
+
* insert is refused by the schema whenever any physical row exists, so this must see one.
|
|
866
1093
|
*/
|
|
867
1094
|
async hasOwnerKey() {
|
|
868
1095
|
this.ensureInitialized();
|
|
@@ -893,14 +1120,65 @@ export class ControlDatabase {
|
|
|
893
1120
|
* trusted only if it is already enrolled here (see `SeedTrustPolicy`). It is
|
|
894
1121
|
* also the owner-identity source for `queryPeers`, decoupling owner
|
|
895
1122
|
* status from the libp2p transport peer ID.
|
|
896
|
-
|
|
897
|
-
|
|
1123
|
+
*
|
|
1124
|
+
* Rows whose `StampId` is retired in `CadreControl.Revocation` are excluded, as
|
|
1125
|
+
* {@link queryCadrePeers} excludes them: `OwnerKey` is never reaped (`MinOneOwner` makes
|
|
1126
|
+
* an automated reap able to empty the table), so a node that received the tombstone
|
|
1127
|
+
* without the delete (a removal committed while the remover was alone, re-issued on growth)
|
|
1128
|
+
* keeps the removed owner's physical row next to it. Without the filter a removed owner
|
|
1129
|
+
* would still count as an owner in every membership snapshot and seed-trust read, still
|
|
1130
|
+
* read as the issuer of a live invitation and still be preferred as a dial target. The
|
|
1131
|
+
* write side filters for itself: every owner lookup in the schema disqualifies a row whose
|
|
1132
|
+
* stamp is retired (`schemas/control.qsql` header), so a removed owner cannot sign here
|
|
1133
|
+
* once its tombstone has arrived, row or no row.
|
|
1134
|
+
*
|
|
1135
|
+
* `retry: false` from a caller that reads under the write lock, for the reason on
|
|
1136
|
+
* {@link queryCadrePeers}.
|
|
1137
|
+
*/
|
|
1138
|
+
async getOwnerKeys(retry = true) {
|
|
1139
|
+
return new Set((await this.queryOwnerKeyRows(retry)).map(row => row.key));
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* Every live `OwnerKey` row with its persisted proof ({@link OwnerKeyRow}) — what the
|
|
1143
|
+
* trusted-owner anchor sync derives from (`CadreNode.syncOwnerAnchor`). Rows whose
|
|
1144
|
+
* `StampId` is retired in `CadreControl.Revocation` are excluded here, as
|
|
1145
|
+
* {@link queryCadrePeers} excludes them and for the reason on {@link getOwnerKeys}, which
|
|
1146
|
+
* reads through this. A row with no stamp (the schema permits it; no writer produces one)
|
|
1147
|
+
* is dropped too: no proof can bind to it, so no reader could ever derive it.
|
|
1148
|
+
*
|
|
1149
|
+
* `retry: false` from the membership-gate refresh, which runs as the control database's
|
|
1150
|
+
* membership listener with that database's write lock held — the reason on
|
|
1151
|
+
* {@link queryCadrePeers}; both reads here take the flag.
|
|
1152
|
+
*
|
|
1153
|
+
* @param retired - the retired `OwnerKey` stamps, when the caller has already read the
|
|
1154
|
+
* tombstones (`CadreNode.syncOwnerAnchor` reads {@link queryRevocations} for their
|
|
1155
|
+
* signers); omitted, they are read here. Before the ledger marker is filed every
|
|
1156
|
+
* `Revocation` read is a cohort consult, so the sync passing its own set keeps the
|
|
1157
|
+
* membership refresh at one such read for the anchor.
|
|
1158
|
+
*/
|
|
1159
|
+
async queryOwnerKeyRows(retry = true, retired) {
|
|
898
1160
|
this.ensureInitialized();
|
|
899
|
-
const
|
|
900
|
-
|
|
901
|
-
|
|
1161
|
+
const revoked = retired ?? await this.queryRevokedStamps('OwnerKey', retry);
|
|
1162
|
+
const rows = [];
|
|
1163
|
+
const sql = 'select Key, StampId, VouchOwner, VouchSig, VouchUsage from CadreControl.OwnerKey';
|
|
1164
|
+
for (const row of await this.readRows(sql, undefined, 'owner-keys', retry)) {
|
|
1165
|
+
const stampId = row.StampId ?? null;
|
|
1166
|
+
if (stampId === null) {
|
|
1167
|
+
log('queryOwnerKeyRows: dropping OwnerKey row %s with no StampId', row.Key);
|
|
1168
|
+
continue;
|
|
1169
|
+
}
|
|
1170
|
+
if (revoked.has(stampId)) {
|
|
1171
|
+
continue;
|
|
1172
|
+
}
|
|
1173
|
+
rows.push({
|
|
1174
|
+
key: row.Key,
|
|
1175
|
+
stampId,
|
|
1176
|
+
vouchOwner: row.VouchOwner ?? null,
|
|
1177
|
+
vouchSig: row.VouchSig ?? null,
|
|
1178
|
+
vouchUsage: row.VouchUsage ?? null,
|
|
1179
|
+
});
|
|
902
1180
|
}
|
|
903
|
-
return
|
|
1181
|
+
return rows;
|
|
904
1182
|
}
|
|
905
1183
|
/**
|
|
906
1184
|
* Enumerate the CadrePeer rows (cadre membership) for admin/membership reads.
|
|
@@ -942,7 +1220,7 @@ export class ControlDatabase {
|
|
|
942
1220
|
this.ensureInitialized();
|
|
943
1221
|
const revoked = await this.queryRevokedStamps('CadrePeer', retry);
|
|
944
1222
|
const rows = [];
|
|
945
|
-
for (const row of await this.readRows('select PeerId, Multiaddr, StampId, VouchOwner, VouchSig from CadreControl.CadrePeer', undefined, 'cadre-peers', retry)) {
|
|
1223
|
+
for (const row of await this.readRows('select PeerId, Multiaddr, StampId, VouchOwner, VouchSig, VouchUsage from CadreControl.CadrePeer', undefined, 'cadre-peers', retry)) {
|
|
946
1224
|
const stampId = row.StampId ?? null;
|
|
947
1225
|
if (stampId !== null && revoked.has(stampId)) {
|
|
948
1226
|
continue;
|
|
@@ -953,10 +1231,24 @@ export class ControlDatabase {
|
|
|
953
1231
|
stampId,
|
|
954
1232
|
vouchOwner: row.VouchOwner ?? null,
|
|
955
1233
|
vouchSig: row.VouchSig ?? null,
|
|
1234
|
+
vouchUsage: row.VouchUsage ?? null,
|
|
956
1235
|
});
|
|
957
1236
|
}
|
|
958
1237
|
return rows;
|
|
959
1238
|
}
|
|
1239
|
+
/**
|
|
1240
|
+
* The cadre's peers as a seed or a redemption reply carries them: every live `CadrePeer`
|
|
1241
|
+
* row with its addresses, each flagged `isOwner` when the key behind its peer id is in
|
|
1242
|
+
* the replicated `OwnerKey` table ({@link projectSeedPeers}).
|
|
1243
|
+
*
|
|
1244
|
+
* Read through {@link queryCadrePeers}, never a raw `CadrePeer` select, so a removed
|
|
1245
|
+
* member's addresses are never packed into a seed or handed to a redeeming device as a
|
|
1246
|
+
* dial hint: a revoked peer is off the addressable surface everywhere, and this is one of
|
|
1247
|
+
* its exits.
|
|
1248
|
+
*/
|
|
1249
|
+
async querySeedPeers() {
|
|
1250
|
+
return projectSeedPeers(await this.queryCadrePeers(), await this.getOwnerKeys());
|
|
1251
|
+
}
|
|
960
1252
|
/**
|
|
961
1253
|
* Every approver public key currently enrolled in `CadreControl.ValidationKey` — the
|
|
962
1254
|
* set a `ValidationUrl` redemption's approval signature must be signed by (see
|
|
@@ -1136,22 +1428,26 @@ export class ControlDatabase {
|
|
|
1136
1428
|
}
|
|
1137
1429
|
}
|
|
1138
1430
|
/**
|
|
1139
|
-
* Every locally-held `CadreControl.Revocation` tombstone — identity triple
|
|
1140
|
-
* `ReissuedAt` counter. Consumed by the cohort-growth re-issue
|
|
1141
|
-
* enumerates what this node holds before {@link reissueRevocations}
|
|
1142
|
-
* it, and by the reap sweep ({@link reapRevokedRows}). Plain scan with no
|
|
1143
|
-
* the composite-primary-key point-lookup hazard (see the statement comment in
|
|
1431
|
+
* Every locally-held `CadreControl.Revocation` tombstone — identity triple, its
|
|
1432
|
+
* `ReissuedAt` counter and the stored signer pair. Consumed by the cohort-growth re-issue
|
|
1433
|
+
* sweep, which enumerates what this node holds before {@link reissueRevocations}
|
|
1434
|
+
* re-broadcasts it, and by the reap sweep ({@link reapRevokedRows}). Plain scan with no
|
|
1435
|
+
* `where`, so the composite-primary-key point-lookup hazard (see the statement comment in
|
|
1144
1436
|
* {@link reissueRevocations}) does not arise. Unlocked, like every other read.
|
|
1145
1437
|
*
|
|
1146
1438
|
* Skips the ledger marker ({@link REVOCATION_LEDGER_MARKER}): it retires nothing, so
|
|
1147
1439
|
* neither sweep may reap or re-sign it, and skipping it keeps `RevocationRow.tableName`
|
|
1148
1440
|
* a {@link RevocableTable}. Filtered on `TableName` alone, in TypeScript: `RowIsGone`
|
|
1149
1441
|
* admits no other row under `'Revocation'`.
|
|
1442
|
+
*
|
|
1443
|
+
* `retry: false` from a caller that reads under the write lock, for the reason on
|
|
1444
|
+
* {@link queryCadrePeers}.
|
|
1150
1445
|
*/
|
|
1151
|
-
async queryRevocations() {
|
|
1446
|
+
async queryRevocations(retry = true) {
|
|
1152
1447
|
this.ensureInitialized();
|
|
1153
1448
|
const rows = [];
|
|
1154
|
-
|
|
1449
|
+
const sql = 'select TableName, RowKey, StampId, ReissuedAt, SignerKey, SignerSig from CadreControl.Revocation';
|
|
1450
|
+
for (const row of await this.readRows(sql, undefined, 'revocations', retry)) {
|
|
1155
1451
|
if (row.TableName === REVOCATION_LEDGER_MARKER.tableName) {
|
|
1156
1452
|
continue;
|
|
1157
1453
|
}
|
|
@@ -1160,6 +1456,8 @@ export class ControlDatabase {
|
|
|
1160
1456
|
rowKey: row.RowKey,
|
|
1161
1457
|
stampId: row.StampId,
|
|
1162
1458
|
reissuedAt: row.ReissuedAt ?? 0,
|
|
1459
|
+
signerKey: row.SignerKey,
|
|
1460
|
+
signerSig: row.SignerSig,
|
|
1163
1461
|
});
|
|
1164
1462
|
}
|
|
1165
1463
|
return rows;
|
|
@@ -1291,12 +1589,12 @@ export class ControlDatabase {
|
|
|
1291
1589
|
log('Inserting owner key: %s', key);
|
|
1292
1590
|
// Bootstrap is authorized by the schema's genesis branch — `(select count(1) from
|
|
1293
1591
|
// committed.OwnerKey) = 0`, i.e. the party had no owner before this transaction — so no
|
|
1294
|
-
// signature is needed. Every other branch of `OwnerKey.Authorized` requires a
|
|
1295
|
-
//
|
|
1296
|
-
//
|
|
1297
|
-
//
|
|
1298
|
-
//
|
|
1299
|
-
//
|
|
1592
|
+
// signature is needed. Every other branch of `OwnerKey.Authorized` requires a PRE-EXISTING,
|
|
1593
|
+
// unretired owner, so this method only ever succeeds on a fresh party; a second
|
|
1594
|
+
// owner is seated by {@link insertOwnerKeyVouched} and removed by {@link deleteOwnerKey}.
|
|
1595
|
+
// We still persist a fresh, unique StampId in the row's own column to satisfy the
|
|
1596
|
+
// not-null/unique anti-replay constraint — the StampId is a real column value, not the
|
|
1597
|
+
// optimystic `StampId()` SQL function.
|
|
1300
1598
|
const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
|
|
1301
1599
|
await this.execWrite(`
|
|
1302
1600
|
insert into CadreControl.OwnerKey (Key, StampId)
|
|
@@ -1305,6 +1603,88 @@ export class ControlDatabase {
|
|
|
1305
1603
|
`, [key, stampId], 'owner-key-insert');
|
|
1306
1604
|
log('Owner key inserted');
|
|
1307
1605
|
}
|
|
1606
|
+
/**
|
|
1607
|
+
* Owner-signed INSERT of a further `OwnerKey` row, notifying the membership listener once
|
|
1608
|
+
* it has committed. The signed-add branch of `OwnerKey.Authorized`: the signer must be an
|
|
1609
|
+
* owner in the pre-transaction snapshot, its signature covers the `'add'` digest over
|
|
1610
|
+
* (Key, StampId), and the stored `VouchOwner`/`VouchSig` are that same pair, so a reader
|
|
1611
|
+
* can later judge the add against its own anchor (`verifyOwnerKeyVoucher`).
|
|
1612
|
+
*
|
|
1613
|
+
* Through {@link mutateCadrePeer}, the membership hub. The liveness read, the stamp and the
|
|
1614
|
+
* signature all sit inside the locked body: a {@link lockedWithRetry} re-run starts over a
|
|
1615
|
+
* rolled-back attempt with a fresh stamp, as {@link redeemCadreInvite} does.
|
|
1616
|
+
*
|
|
1617
|
+
* NOTE: re-adding a removed owner from a machine that still holds its retired physical row
|
|
1618
|
+
* is refused here by name: `OwnerKey` is not reaped, so a machine that received the
|
|
1619
|
+
* tombstone without the delete (see {@link getOwnerKeys}) keeps the row, and an insert there
|
|
1620
|
+
* would collide on the primary key. Re-add from the machine that removed the key, or any
|
|
1621
|
+
* machine the delete reached; if re-adding removed owners becomes routine, the fix is an
|
|
1622
|
+
* `OwnerKey` reap branch, gated so it can never take the table below `MinOneOwner`.
|
|
1623
|
+
*
|
|
1624
|
+
* @returns `true` when this call seated the row, `false` when `key` is already a live owner
|
|
1625
|
+
* here (nothing written).
|
|
1626
|
+
* @throws when this node holds `key`'s row under a retired stamp (above), or when the
|
|
1627
|
+
* schema refuses the signer.
|
|
1628
|
+
*/
|
|
1629
|
+
async insertOwnerKeyVouched(key, ownerKey, signMessage) {
|
|
1630
|
+
this.ensureInitialized();
|
|
1631
|
+
return await this.mutateCadrePeer('owner-key-add', async () => {
|
|
1632
|
+
// retry: false on both reads — inside the locked write body (see queryStampId's NOTE).
|
|
1633
|
+
const heldStamp = await this.queryStampId('OwnerKey', key, false);
|
|
1634
|
+
if (heldStamp !== null) {
|
|
1635
|
+
if (!(await this.queryRevokedStamps('OwnerKey', false)).has(heldStamp)) {
|
|
1636
|
+
log('insertOwnerKeyVouched: %s is already an owner; nothing written', key);
|
|
1637
|
+
return false;
|
|
1638
|
+
}
|
|
1639
|
+
throw new Error(`Cannot re-add owner key ${key} here: this node still holds the row it was removed under ` +
|
|
1640
|
+
'(OwnerKey rows are not reaped); re-add it from the machine that removed it');
|
|
1641
|
+
}
|
|
1642
|
+
const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
|
|
1643
|
+
const signature = signMessage(ownerKeyAddMessage(this.config.partyId, key, stampId));
|
|
1644
|
+
// Bare `exec`: already inside the write lock, which is NOT re-entrant.
|
|
1645
|
+
await this.db.exec(`
|
|
1646
|
+
insert into CadreControl.OwnerKey (Key, StampId, VouchOwner, VouchSig)
|
|
1647
|
+
with context OwnerKey = ?, Signature = ?
|
|
1648
|
+
values (?, ?, ?, ?)
|
|
1649
|
+
`, [ownerKey, signature, key, stampId, ownerKey, signature]);
|
|
1650
|
+
log('Owner key %s added (vouched by %s)', key, ownerKey);
|
|
1651
|
+
return true;
|
|
1652
|
+
});
|
|
1653
|
+
}
|
|
1654
|
+
/**
|
|
1655
|
+
* Owner-signed removal of one `OwnerKey` row plus the `Revocation` tombstone retiring its
|
|
1656
|
+
* stamp, notifying the membership listener once it has committed. The remove branch of
|
|
1657
|
+
* `OwnerKey.Authorized` requires a signer other than the removed key, and `MinOneOwner`
|
|
1658
|
+
* keeps the table from emptying; both are refused by name here first, read from this
|
|
1659
|
+
* node's live owners ({@link getOwnerKeys}), so a caller gets a clear error rather than a
|
|
1660
|
+
* constraint failure. The schema stays the authority: a concurrent removal elsewhere can
|
|
1661
|
+
* make the local count wrong in either direction, and then `MinOneOwner` or the tombstone's
|
|
1662
|
+
* primary key decides (see the NOTE on {@link deleteGuardedRow}).
|
|
1663
|
+
*
|
|
1664
|
+
* Through {@link mutateCadrePeer}, the membership hub, which notifies whenever the body
|
|
1665
|
+
* resolves, the absent-key answer included.
|
|
1666
|
+
*
|
|
1667
|
+
* @returns `true` when the row was removed, `false` when `key` is not a live owner here
|
|
1668
|
+
* (nothing written, no tombstone).
|
|
1669
|
+
* @throws when `key` is `ownerKey`, or is the only live owner.
|
|
1670
|
+
*/
|
|
1671
|
+
async deleteOwnerKey(key, ownerKey, signMessage) {
|
|
1672
|
+
if (key === ownerKey) {
|
|
1673
|
+
throw new Error(`Cannot remove owner key ${key}: an owner cannot remove itself`);
|
|
1674
|
+
}
|
|
1675
|
+
return await this.mutateCadrePeer('owner-key-remove', async () => {
|
|
1676
|
+
// retry: false — inside the locked write body (see queryStampId's NOTE).
|
|
1677
|
+
const owners = await this.getOwnerKeys(false);
|
|
1678
|
+
if (!owners.has(key)) {
|
|
1679
|
+
log('deleteOwnerKey: %s is not a live owner here (already absent)', key);
|
|
1680
|
+
return false;
|
|
1681
|
+
}
|
|
1682
|
+
if (owners.size <= 1) {
|
|
1683
|
+
throw new Error(`Cannot remove owner key ${key}: cannot remove the last owner`);
|
|
1684
|
+
}
|
|
1685
|
+
return await this.deleteGuardedRow('OwnerKey', key, ownerKey, signMessage);
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1308
1688
|
/**
|
|
1309
1689
|
* Insert a strand into the control database using an owner signature.
|
|
1310
1690
|
*
|
|
@@ -1334,7 +1714,7 @@ export class ControlDatabase {
|
|
|
1334
1714
|
// Id, Type, MemberPrivateKey ('' when null), StampId. FounderOwnerKey is
|
|
1335
1715
|
// deliberately NOT in the digest — the schema binds it by equality to the
|
|
1336
1716
|
// verified context.OwnerKey instead (see the constraint's comment).
|
|
1337
|
-
const message = buildAuthorizationMessage('CadreControl.Strand', 'add', [strandId, type, memberPrivateKey ?? '', stampId]);
|
|
1717
|
+
const message = buildAuthorizationMessage('CadreControl.Strand', 'add', this.config.partyId, [strandId, type, memberPrivateKey ?? '', stampId]);
|
|
1338
1718
|
const signature = signMessage(message);
|
|
1339
1719
|
// StampId is a real, unique column (single-use anti-replay), no longer a context value.
|
|
1340
1720
|
// FounderOwnerKey must equal the signing owner or the constraint rejects the row.
|
|
@@ -1396,10 +1776,10 @@ export class ControlDatabase {
|
|
|
1396
1776
|
return false;
|
|
1397
1777
|
}
|
|
1398
1778
|
const partyKeyStamp = await this.queryStampId('StrandPartyKey', strandId, false);
|
|
1399
|
-
const strandRemoval = signGuardedRemoval('Strand', strandId, strandStamp, signMessage);
|
|
1779
|
+
const strandRemoval = signGuardedRemoval(this.config.partyId, 'Strand', strandId, strandStamp, signMessage);
|
|
1400
1780
|
const partyKeyRemoval = partyKeyStamp === null
|
|
1401
1781
|
? null
|
|
1402
|
-
: signGuardedRemoval('StrandPartyKey', strandId, partyKeyStamp, signMessage);
|
|
1782
|
+
: signGuardedRemoval(this.config.partyId, 'StrandPartyKey', strandId, partyKeyStamp, signMessage);
|
|
1403
1783
|
await this.inTransaction('delete Strand', async () => {
|
|
1404
1784
|
await this.execGuardedRemoval(strandRemoval, ownerKey);
|
|
1405
1785
|
if (partyKeyRemoval !== null) {
|
|
@@ -1436,7 +1816,7 @@ export class ControlDatabase {
|
|
|
1436
1816
|
const stampId = generateStampId(peerId);
|
|
1437
1817
|
// Field order MUST match the schema's StrandPartyKey `AuthorizedInsert` verify:
|
|
1438
1818
|
// Id, PrivateKey, StampId.
|
|
1439
|
-
const message = buildAuthorizationMessage('CadreControl.StrandPartyKey', 'add', [strandId, privateKey, stampId]);
|
|
1819
|
+
const message = buildAuthorizationMessage('CadreControl.StrandPartyKey', 'add', this.config.partyId, [strandId, privateKey, stampId]);
|
|
1440
1820
|
const signature = signMessage(message);
|
|
1441
1821
|
await this.execWrite(`
|
|
1442
1822
|
insert into CadreControl.StrandPartyKey (Id, PrivateKey, StampId)
|
|
@@ -1478,7 +1858,7 @@ export class ControlDatabase {
|
|
|
1478
1858
|
const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
|
|
1479
1859
|
// Field order MUST match the schema's JoinedStrand `AuthorizedInsert` verify:
|
|
1480
1860
|
// Id, Type, MemberPrivateKey ('' when null), StampId.
|
|
1481
|
-
const message = buildAuthorizationMessage('CadreControl.JoinedStrand', 'add', [row.Id, row.Type, row.MemberPrivateKey ?? '', stampId]);
|
|
1861
|
+
const message = buildAuthorizationMessage('CadreControl.JoinedStrand', 'add', this.config.partyId, [row.Id, row.Type, row.MemberPrivateKey ?? '', stampId]);
|
|
1482
1862
|
const signature = signMessage(message);
|
|
1483
1863
|
await this.execWrite(`
|
|
1484
1864
|
insert into CadreControl.JoinedStrand (Id, Type, MemberPrivateKey, StampId)
|
|
@@ -1497,76 +1877,124 @@ export class ControlDatabase {
|
|
|
1497
1877
|
return this.lockedWithRetry(() => this.deleteGuardedRow('JoinedStrand', strandId, ownerKey, signMessage), {}, 'joined-strand-delete');
|
|
1498
1878
|
}
|
|
1499
1879
|
/**
|
|
1500
|
-
* Record, party-wide, a join this party asked for
|
|
1501
|
-
*
|
|
1502
|
-
* row as written, with the fresh `StampId` a later {@link replacePendingJoin} names.
|
|
1880
|
+
* Record, party-wide, a join this party asked for: a `JoinRequest` row under a fresh stamp,
|
|
1881
|
+
* owner-signed over every column. Returns the pending join as written.
|
|
1503
1882
|
*
|
|
1504
|
-
* Fails with a `
|
|
1505
|
-
* this invitation; {@link isPendingJoinConflict} identifies that case.
|
|
1883
|
+
* Fails with a `JoinRequest.Id` uniqueness violation when the party already holds a request
|
|
1884
|
+
* for this invitation; {@link isPendingJoinConflict} identifies that case.
|
|
1506
1885
|
*/
|
|
1507
|
-
async
|
|
1886
|
+
async insertJoinRequest(request, ownerKey, signMessage) {
|
|
1508
1887
|
this.ensureInitialized();
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
return written;
|
|
1888
|
+
const { written, insert } = this.signJoinRequestInsert(request, ownerKey, signMessage);
|
|
1889
|
+
await this.execWrite(insert.sql, insert.params, 'join-request-insert');
|
|
1890
|
+
log('Join request inserted: %s', request.Id);
|
|
1891
|
+
return { ...written, outcome: null };
|
|
1514
1892
|
}
|
|
1515
1893
|
/**
|
|
1516
|
-
*
|
|
1517
|
-
*
|
|
1894
|
+
* Record the outcome of the pending join `expected`: its owner-signed `JoinSuccess` /
|
|
1895
|
+
* `JoinFailure` row, keyed by the request's stamp. A join recorded over a failure deletes the
|
|
1896
|
+
* failure row in the same transaction (the schema authorizes that delete by the join, and
|
|
1897
|
+
* leaves the one-outcome rule in that direction to this writer). Returns the pending join as
|
|
1898
|
+
* written.
|
|
1518
1899
|
*
|
|
1519
|
-
*
|
|
1520
|
-
*
|
|
1521
|
-
*
|
|
1522
|
-
*
|
|
1523
|
-
*
|
|
1524
|
-
|
|
1525
|
-
|
|
1900
|
+
* The live join must still be what the caller read: the same request incarnation with the
|
|
1901
|
+
* same outcome kind. Otherwise this throws {@link PendingJoinChangedError} and writes nothing.
|
|
1902
|
+
* That check is local; a sibling machine's write landing between it and the commit fails the
|
|
1903
|
+
* transaction instead (its outcome row collides on the primary key, or its tombstone retires
|
|
1904
|
+
* the stamp), and the caller re-reads either way.
|
|
1905
|
+
*/
|
|
1906
|
+
async recordJoinOutcome(expected, outcome, ownerKey, signMessage) {
|
|
1907
|
+
this.ensureInitialized();
|
|
1908
|
+
const insert = signJoinOutcomeInsert(this.config.partyId, expected.StampId, outcome, ownerKey, signMessage);
|
|
1909
|
+
return this.lockedWithRetry(async () => {
|
|
1910
|
+
const live = await this.readPendingJoin(expected.Id, false);
|
|
1911
|
+
if (live === null || live.StampId !== expected.StampId || live.outcome?.kind !== expected.outcome?.kind) {
|
|
1912
|
+
throw new PendingJoinChangedError(expected.Id, live);
|
|
1913
|
+
}
|
|
1914
|
+
await this.inTransaction('record join outcome', async () => {
|
|
1915
|
+
if (live.outcome?.kind === 'failed') {
|
|
1916
|
+
await this.deleteJoinOutcome('JoinFailure', expected.StampId);
|
|
1917
|
+
}
|
|
1918
|
+
await this.db.exec(insert.sql, insert.params);
|
|
1919
|
+
});
|
|
1920
|
+
log('Join outcome recorded: %s (%s)', expected.Id, outcome.kind);
|
|
1921
|
+
return { ...live, outcome };
|
|
1922
|
+
}, {}, 'join-outcome-record');
|
|
1923
|
+
}
|
|
1924
|
+
/**
|
|
1925
|
+
* Re-write the pending join `next` (request and outcome) under a fresh stamp, retiring the
|
|
1926
|
+
* live request incarnation `expectedStampId`: how a finished request is asked for again, and
|
|
1927
|
+
* how a join written while this machine was alone is re-broadcast. One locked transaction
|
|
1928
|
+
* deletes the outcome row and the request (owner-signed `'remove'` digest, with its
|
|
1929
|
+
* `Revocation` tombstone) and inserts the successors, so no reader ever sees the id missing in
|
|
1930
|
+
* between. Throws {@link PendingJoinChangedError} and writes nothing when the live request is
|
|
1931
|
+
* not `expectedStampId`.
|
|
1526
1932
|
*
|
|
1527
1933
|
* Signatures and the new stamp are minted before the lock, so a retried attempt re-presents
|
|
1528
1934
|
* the same signed messages (the contract on {@link withWriteLock}). A retry after an attempt
|
|
1529
|
-
* that committed but reported failure throws {@link PendingJoinChangedError} whose
|
|
1530
|
-
*
|
|
1935
|
+
* that committed but reported failure throws {@link PendingJoinChangedError} whose live join
|
|
1936
|
+
* carries the returned stamp, so a caller that re-reads finds its write.
|
|
1531
1937
|
*/
|
|
1532
|
-
async
|
|
1938
|
+
async rewritePendingJoin(expectedStampId, next, ownerKey, signMessage) {
|
|
1533
1939
|
this.ensureInitialized();
|
|
1534
|
-
const
|
|
1535
|
-
const removal = signGuardedRemoval('
|
|
1536
|
-
const { written,
|
|
1940
|
+
const { outcome, ...request } = next;
|
|
1941
|
+
const removal = signGuardedRemoval(this.config.partyId, 'JoinRequest', next.Id, expectedStampId, signMessage);
|
|
1942
|
+
const { written, insert } = this.signJoinRequestInsert(request, ownerKey, signMessage);
|
|
1943
|
+
const outcomeInsert = outcome === null ? null : signJoinOutcomeInsert(this.config.partyId, written.StampId, outcome, ownerKey, signMessage);
|
|
1537
1944
|
return this.lockedWithRetry(async () => {
|
|
1538
1945
|
// retry: false — this runs inside the locked write body (see queryStampId's NOTE).
|
|
1539
|
-
const liveStampId = await this.queryStampId('
|
|
1946
|
+
const liveStampId = await this.queryStampId('JoinRequest', next.Id, false);
|
|
1540
1947
|
if (liveStampId !== expectedStampId) {
|
|
1541
|
-
throw new PendingJoinChangedError(
|
|
1948
|
+
throw new PendingJoinChangedError(next.Id, await this.readPendingJoin(next.Id, false));
|
|
1542
1949
|
}
|
|
1543
|
-
await this.inTransaction('
|
|
1950
|
+
await this.inTransaction('rewrite pending join', async () => {
|
|
1951
|
+
await this.deleteJoinOutcomes(expectedStampId);
|
|
1544
1952
|
await this.execGuardedRemoval(removal, ownerKey);
|
|
1545
|
-
await this.db.exec(
|
|
1953
|
+
await this.db.exec(insert.sql, insert.params);
|
|
1954
|
+
if (outcomeInsert !== null) {
|
|
1955
|
+
await this.db.exec(outcomeInsert.sql, outcomeInsert.params);
|
|
1956
|
+
}
|
|
1546
1957
|
});
|
|
1547
|
-
log('Pending join
|
|
1958
|
+
log('Pending join rewritten: %s (%s; stamp retired)', next.Id, outcome?.kind ?? 'pending');
|
|
1548
1959
|
this.notifyGuardedDelete(removal.ref);
|
|
1549
|
-
return written;
|
|
1550
|
-
}, {}, 'pending-join-
|
|
1960
|
+
return { ...written, outcome };
|
|
1961
|
+
}, {}, 'pending-join-rewrite');
|
|
1551
1962
|
}
|
|
1552
1963
|
/**
|
|
1553
|
-
* Owner-signed removal of one
|
|
1554
|
-
*
|
|
1555
|
-
*
|
|
1556
|
-
* `true` when
|
|
1964
|
+
* Owner-signed removal of one pending join, as {@link deleteJoinedStrand} removes its row:
|
|
1965
|
+
* `'remove'`-tagged digest over (Id, StampId), `Revocation` tombstone in the same transaction,
|
|
1966
|
+
* and the request's outcome row with it. A no-op (no throw, no tombstone) when there is no
|
|
1967
|
+
* request — `false` then, `true` when one was actually removed.
|
|
1557
1968
|
*/
|
|
1558
1969
|
deletePendingJoin(id, ownerKey, signMessage) {
|
|
1559
|
-
return this.lockedWithRetry(() => this.deleteGuardedRow('
|
|
1970
|
+
return this.lockedWithRetry(() => this.deleteGuardedRow('JoinRequest', id, ownerKey, signMessage, (stampId) => this.deleteJoinOutcomes(stampId)), {}, 'pending-join-delete');
|
|
1560
1971
|
}
|
|
1561
1972
|
/**
|
|
1562
|
-
* Mint a fresh stamp for `
|
|
1563
|
-
* verifies, returning the row as it will be written and
|
|
1564
|
-
* {@link PENDING_JOIN_INSERT_SQL}.
|
|
1973
|
+
* Mint a fresh stamp for `request` and the owner signature `JoinRequest.AuthorizedInsert`
|
|
1974
|
+
* verifies, returning the row as it will be written and its insert.
|
|
1565
1975
|
*/
|
|
1566
|
-
|
|
1567
|
-
const written = { ...
|
|
1568
|
-
const signature = signMessage(buildAuthorizationMessage('CadreControl.
|
|
1569
|
-
|
|
1976
|
+
signJoinRequestInsert(request, ownerKey, signMessage) {
|
|
1977
|
+
const written = { ...request, StampId: generateStampId(this.config.libp2pNode.peerId.toString()) };
|
|
1978
|
+
const signature = signMessage(buildAuthorizationMessage('CadreControl.JoinRequest', 'add', this.config.partyId, joinRequestAddFields(written)));
|
|
1979
|
+
const params = [ownerKey, signature, written.Id, written.Invitation, written.Disclosure, written.RequestedAt, written.ExpiresAt, written.StampId];
|
|
1980
|
+
return { written, insert: { sql: JOIN_REQUEST_INSERT_SQL, params } };
|
|
1981
|
+
}
|
|
1982
|
+
/**
|
|
1983
|
+
* Remove the outcome row of the request incarnation `requestStampId`, whichever table holds
|
|
1984
|
+
* it. Unsigned: the schema authorizes the delete by the tombstone retiring that stamp, filed
|
|
1985
|
+
* in the same transaction or already committed (a reap).
|
|
1986
|
+
*/
|
|
1987
|
+
async deleteJoinOutcomes(requestStampId) {
|
|
1988
|
+
await this.deleteJoinOutcome('JoinSuccess', requestStampId);
|
|
1989
|
+
await this.deleteJoinOutcome('JoinFailure', requestStampId);
|
|
1990
|
+
}
|
|
1991
|
+
/** One unsigned outcome delete (see {@link deleteJoinOutcomes}; a failure's is also authorized by the join superseding it). */
|
|
1992
|
+
async deleteJoinOutcome(table, requestStampId) {
|
|
1993
|
+
await this.db.exec(`
|
|
1994
|
+
delete from CadreControl.${table}
|
|
1995
|
+
with context OwnerKey = null, Signature = null
|
|
1996
|
+
where RequestStampId = ?
|
|
1997
|
+
`, [requestStampId]);
|
|
1570
1998
|
}
|
|
1571
1999
|
/**
|
|
1572
2000
|
* Insert a validation key into the control database using an owner signature.
|
|
@@ -1587,7 +2015,7 @@ export class ControlDatabase {
|
|
|
1587
2015
|
const peerId = this.config.libp2pNode.peerId.toString();
|
|
1588
2016
|
const stampId = generateStampId(peerId);
|
|
1589
2017
|
// Field order MUST match the schema's ValidationKey `AuthorizedInsert` verify: Key, StampId.
|
|
1590
|
-
const message = buildAuthorizationMessage('CadreControl.ValidationKey', 'add', [key, stampId]);
|
|
2018
|
+
const message = buildAuthorizationMessage('CadreControl.ValidationKey', 'add', this.config.partyId, [key, stampId]);
|
|
1591
2019
|
const signature = signMessage(message);
|
|
1592
2020
|
await this.execWrite(`
|
|
1593
2021
|
insert into CadreControl.ValidationKey (Key, StampId)
|
|
@@ -1657,7 +2085,7 @@ export class ControlDatabase {
|
|
|
1657
2085
|
// Stamp is minted from the ADMITTED peer's id, not this node's — the nonce names the
|
|
1658
2086
|
// row it vouches.
|
|
1659
2087
|
const stampId = generateStampId(row.peerId);
|
|
1660
|
-
const signature = signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', [row.peerId, stampId]));
|
|
2088
|
+
const signature = signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [row.peerId, stampId]));
|
|
1661
2089
|
return await this.mutateCadrePeer('peer-insert', async () => {
|
|
1662
2090
|
// retry: false — this guard runs inside the locked write body (see queryStampId's NOTE).
|
|
1663
2091
|
if (await this.queryStampId('CadrePeer', row.peerId, false) !== null) {
|
|
@@ -1710,6 +2138,11 @@ export class ControlDatabase {
|
|
|
1710
2138
|
* change the member set. Keeping the rule uniform ("every CadrePeer mutator notifies")
|
|
1711
2139
|
* beats a per-method exception the next reader has to relearn.
|
|
1712
2140
|
*
|
|
2141
|
+
* Also clears `VouchUsage`: a row carries exactly one proof (the owner branch of
|
|
2142
|
+
* `AuthorizedUpdate` requires it null), so re-vouching an invitation-admitted row moves it
|
|
2143
|
+
* onto the signature and its invitation is no longer consulted for it — the way an owner
|
|
2144
|
+
* retires a device's dependency on an invitation it may later withdraw.
|
|
2145
|
+
*
|
|
1713
2146
|
* NOTE: this rebinds VouchOwner to the CALLING owner's key, and the authorized-membership
|
|
1714
2147
|
* predicate (`CadreNode.listAuthorizedMembers`) now judges rows by that column against each
|
|
1715
2148
|
* reader's node-local anchor. Benign today because the only caller — the write-while-alone
|
|
@@ -1733,13 +2166,13 @@ export class ControlDatabase {
|
|
|
1733
2166
|
log('reauthorizeCadrePeer: no CadrePeer row for %s (nothing to re-touch)', peerId);
|
|
1734
2167
|
return false;
|
|
1735
2168
|
}
|
|
1736
|
-
const signature = signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', [peerId, stampId]));
|
|
2169
|
+
const signature = signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [peerId, stampId]));
|
|
1737
2170
|
await this.mutateCadrePeer('peer-reauthorize', async () => {
|
|
1738
2171
|
// Bare `exec` — inside the non-re-entrant write lock (see insertCadrePeer).
|
|
1739
2172
|
await this.db.exec(`
|
|
1740
2173
|
update CadreControl.CadrePeer
|
|
1741
2174
|
with context OwnerKey = ?, Signature = ?
|
|
1742
|
-
set UpdatedAt = ?, VouchOwner = ?, VouchSig =
|
|
2175
|
+
set UpdatedAt = ?, VouchOwner = ?, VouchSig = ?, VouchUsage = null
|
|
1743
2176
|
where PeerId = ?
|
|
1744
2177
|
`, [ownerKey, signature, updatedAt, ownerKey, signature, peerId]);
|
|
1745
2178
|
});
|
|
@@ -1788,15 +2221,9 @@ export class ControlDatabase {
|
|
|
1788
2221
|
* Owner-signed delete of one guarded row plus the `Revocation` tombstone retiring its
|
|
1789
2222
|
* stamp, in ONE transaction. The single body behind EVERY guarded delete —
|
|
1790
2223
|
* {@link deleteStrand}, {@link deleteValidationKey}, {@link deleteCadrePeer},
|
|
1791
|
-
* {@link deleteDeviceToken}; see any of them for the per-table
|
|
1792
|
-
* of those is a thin named wrapper, so callers never pass a
|
|
1793
|
-
* generic shape stays off the public surface.
|
|
1794
|
-
*
|
|
1795
|
-
* `OwnerKey` is excluded from `table` only because no owner-key removal path exists in
|
|
1796
|
-
* production yet — the schema has the `'remove'` branch (`OwnerKey.Authorized`, which
|
|
1797
|
-
* requires a DIFFERENT owner as signer) and `control-revocation-replay.spec.ts` drives
|
|
1798
|
-
* it with hand-rolled SQL. Widen this and add a wrapper when that path lands; the body
|
|
1799
|
-
* needs no change.
|
|
2224
|
+
* {@link deleteDeviceToken}, {@link deleteOwnerKey}; see any of them for the per-table
|
|
2225
|
+
* security rationale. Each of those is a thin named wrapper, so callers never pass a
|
|
2226
|
+
* table name and this generic shape stays off the public surface.
|
|
1800
2227
|
*
|
|
1801
2228
|
* The row's CURRENT stamp is read first and signed over, so the remove digest binds to
|
|
1802
2229
|
* this exact row instance. A no-op (no throw, no tombstone) when the row is absent —
|
|
@@ -1804,6 +2231,9 @@ export class ControlDatabase {
|
|
|
1804
2231
|
* second read (e.g. {@link CadreNode.unpublishStrand}'s committed-while-alone warning,
|
|
1805
2232
|
* which must not fire for a no-op).
|
|
1806
2233
|
*
|
|
2234
|
+
* `alongside`, when given, runs inside the transaction before the removal, for a table whose
|
|
2235
|
+
* row has dependants the schema requires removed with it ({@link deletePendingJoin}).
|
|
2236
|
+
*
|
|
1807
2237
|
* NOTE: the stamp read is outside the transaction. A concurrent writer that removes
|
|
1808
2238
|
* the row in between makes the signature bind a stamp that is no longer live; the
|
|
1809
2239
|
* delete then matches nothing and the tombstone insert collides with the other
|
|
@@ -1814,7 +2244,7 @@ export class ControlDatabase {
|
|
|
1814
2244
|
* Deliberately NOT wrapped in {@link withWriteLock}: every public entry point already
|
|
1815
2245
|
* holds the (non-re-entrant) lock, so taking it again here would self-deadlock.
|
|
1816
2246
|
*/
|
|
1817
|
-
async deleteGuardedRow(table, keyValue, ownerKey, signMessage) {
|
|
2247
|
+
async deleteGuardedRow(table, keyValue, ownerKey, signMessage, alongside) {
|
|
1818
2248
|
this.ensureInitialized();
|
|
1819
2249
|
// retry: false — this runs inside the locked write body (see queryStampId's NOTE).
|
|
1820
2250
|
const stampId = await this.queryStampId(table, keyValue, false);
|
|
@@ -1822,8 +2252,11 @@ export class ControlDatabase {
|
|
|
1822
2252
|
log('delete %s: no row for %s (already absent)', table, keyValue);
|
|
1823
2253
|
return false;
|
|
1824
2254
|
}
|
|
1825
|
-
const removal = signGuardedRemoval(table, keyValue, stampId, signMessage);
|
|
1826
|
-
await this.inTransaction(`delete ${table}`, () =>
|
|
2255
|
+
const removal = signGuardedRemoval(this.config.partyId, table, keyValue, stampId, signMessage);
|
|
2256
|
+
await this.inTransaction(`delete ${table}`, async () => {
|
|
2257
|
+
await alongside?.(stampId);
|
|
2258
|
+
await this.execGuardedRemoval(removal, ownerKey);
|
|
2259
|
+
});
|
|
1827
2260
|
log('%s deleted: %s (stamp retired)', table, keyValue);
|
|
1828
2261
|
this.notifyGuardedDelete(removal.ref);
|
|
1829
2262
|
return true;
|
|
@@ -1831,7 +2264,7 @@ export class ControlDatabase {
|
|
|
1831
2264
|
/**
|
|
1832
2265
|
* Delete the row incarnation `removal` names and file its tombstone — the statement pair
|
|
1833
2266
|
* every owner removal of a guarded row runs ({@link deleteGuardedRow},
|
|
1834
|
-
* {@link deleteStrandAndPartyKey}, {@link
|
|
2267
|
+
* {@link deleteStrandAndPartyKey}, {@link rewritePendingJoin}). The caller supplies the
|
|
1835
2268
|
* transaction: the schema's `RevocationRecorded` check refuses the delete unless the
|
|
1836
2269
|
* tombstone commits with it.
|
|
1837
2270
|
*
|
|
@@ -1848,14 +2281,28 @@ export class ControlDatabase {
|
|
|
1848
2281
|
with context OwnerKey = ?, Signature = ?
|
|
1849
2282
|
where ${GUARDED_KEY_COLUMN[tableName]} = ? and StampId = ?
|
|
1850
2283
|
`, [ownerKey, removal.signature, rowKey, stampId]);
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
2284
|
+
await this.execTombstone(removal.ref, removal.revocationSignature, ownerKey);
|
|
2285
|
+
}
|
|
2286
|
+
/**
|
|
2287
|
+
* File the owner-signed `Revocation` tombstone retiring one row incarnation — the second
|
|
2288
|
+
* statement of every guarded removal ({@link execGuardedRemoval}) and the whole of a
|
|
2289
|
+
* `CadreInvite` withdrawal ({@link withdrawCadreInvite}), whose row stays — or the ledger
|
|
2290
|
+
* marker ({@link openRevocationLedger}), which retires nothing. The caller supplies the
|
|
2291
|
+
* lock and, where one is needed, the transaction.
|
|
2292
|
+
*
|
|
2293
|
+
* `ReissuedAt` is named explicitly at 0 (the only value `FreshTombstone` accepts) rather
|
|
2294
|
+
* than leaning on the column default — the seat-at-zero rule is load-bearing for
|
|
2295
|
+
* {@link reissueRevocations}' monotonic bump, so state it at the write site.
|
|
2296
|
+
*
|
|
2297
|
+
* `SignerKey` / `SignerSig` store the context pair (`Revocation.Authorized` pins them
|
|
2298
|
+
* equal), so a node that receives the row by replication can tell which owner filed it.
|
|
2299
|
+
*/
|
|
2300
|
+
async execTombstone(ref, revocationSignature, ownerKey) {
|
|
1854
2301
|
await this.db.exec(`
|
|
1855
|
-
insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt)
|
|
2302
|
+
insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt, SignerKey, SignerSig)
|
|
1856
2303
|
with context OwnerKey = ?, Signature = ?
|
|
1857
|
-
values (?, ?, ?, 0)
|
|
1858
|
-
`, [ownerKey,
|
|
2304
|
+
values (?, ?, ?, 0, ?, ?)
|
|
2305
|
+
`, [ownerKey, revocationSignature, ref.tableName, ref.rowKey, ref.stampId, ownerKey, revocationSignature]);
|
|
1859
2306
|
}
|
|
1860
2307
|
/**
|
|
1861
2308
|
* Fire the guarded-delete listener for one committed tombstone, swallowing (and
|
|
@@ -1956,6 +2403,16 @@ export class ControlDatabase {
|
|
|
1956
2403
|
// in one pass, hoist the notify to one refresh after the sweep instead.
|
|
1957
2404
|
await this.mutateCadrePeer('peer-reap', () => this.db.exec(sql, [rowKey, stampId]));
|
|
1958
2405
|
}
|
|
2406
|
+
else if (table === 'JoinRequest') {
|
|
2407
|
+
// The request's outcome row goes with it; the committed tombstone authorizes that delete
|
|
2408
|
+
// too. An outcome row held for a request this node never received (the request was
|
|
2409
|
+
// written alone and its outcome connected, before the growth-edge re-issue) is left: it
|
|
2410
|
+
// is unreadable, since every reader starts from the request.
|
|
2411
|
+
await this.lockedWithRetry(() => this.inTransaction('reap JoinRequest', async () => {
|
|
2412
|
+
await this.deleteJoinOutcomes(stampId);
|
|
2413
|
+
await this.db.exec(sql, [rowKey, stampId]);
|
|
2414
|
+
}), {}, 'reap-JoinRequest');
|
|
2415
|
+
}
|
|
1959
2416
|
else {
|
|
1960
2417
|
await this.execWrite(sql, [rowKey, stampId], `reap-${table}`);
|
|
1961
2418
|
}
|
|
@@ -1994,7 +2451,7 @@ export class ControlDatabase {
|
|
|
1994
2451
|
* reads from this node (every other node already filters it by tombstone). A revoked
|
|
1995
2452
|
* node therefore keeps its own copy of its own row; "a node that learns it has been
|
|
1996
2453
|
* revoked should shut itself down" is a distinct behaviour and its own ticket.
|
|
1997
|
-
* `ValidationKey`, `JoinedStrand` and `
|
|
2454
|
+
* `ValidationKey`, `JoinedStrand` and `JoinRequest` have no self notion and are not
|
|
1998
2455
|
* special-cased.
|
|
1999
2456
|
*
|
|
2000
2457
|
* A per-row failure is logged and skipped rather than aborting the sweep: an abort would
|
|
@@ -2083,7 +2540,7 @@ export class ControlDatabase {
|
|
|
2083
2540
|
// and every digest field is TEXT on both sides (see buildAuthorizationMessage).
|
|
2084
2541
|
const signed = rows.map((row) => ({
|
|
2085
2542
|
row,
|
|
2086
|
-
signature: signMessage(buildAuthorizationMessage('CadreControl.Revocation', 'reissue', [row.tableName, row.rowKey, row.stampId, String(reissuedAt)])),
|
|
2543
|
+
signature: signMessage(buildAuthorizationMessage('CadreControl.Revocation', 'reissue', this.config.partyId, [row.tableName, row.rowKey, row.stampId, String(reissuedAt)])),
|
|
2087
2544
|
}));
|
|
2088
2545
|
return this.lockedWithRetry(async () => {
|
|
2089
2546
|
// Counter lives INSIDE the locked body so a lockedWithRetry re-run (which
|
|
@@ -2149,19 +2606,14 @@ export class ControlDatabase {
|
|
|
2149
2606
|
*/
|
|
2150
2607
|
async openRevocationLedger(ownerKey, signMessage) {
|
|
2151
2608
|
this.ensureInitialized();
|
|
2152
|
-
const
|
|
2153
|
-
const signature = signMessage(buildAuthorizationMessage('CadreControl.Revocation', 'remove', [tableName, rowKey, stampId]));
|
|
2609
|
+
const signature = signMessage(revocationTombstoneMessage(this.config.partyId, REVOCATION_LEDGER_MARKER));
|
|
2154
2610
|
try {
|
|
2155
2611
|
return await this.lockedWithRetry(async () => {
|
|
2156
2612
|
if (await this.revocationLedgerFiled()) {
|
|
2157
2613
|
return 'already-open';
|
|
2158
2614
|
}
|
|
2159
|
-
//
|
|
2160
|
-
await this.
|
|
2161
|
-
insert into CadreControl.Revocation (TableName, RowKey, StampId)
|
|
2162
|
-
with context OwnerKey = ?, Signature = ?
|
|
2163
|
-
values (?, ?, ?)
|
|
2164
|
-
`, [ownerKey, signature, tableName, rowKey, stampId]);
|
|
2615
|
+
// execTombstone is a bare `exec`: already inside the write lock, which is NOT re-entrant.
|
|
2616
|
+
await this.execTombstone(REVOCATION_LEDGER_MARKER, signature, ownerKey);
|
|
2165
2617
|
log('Revocation ledger marker filed');
|
|
2166
2618
|
return 'opened';
|
|
2167
2619
|
}, {}, 'revocation-ledger-open');
|
|
@@ -2258,15 +2710,18 @@ export class ControlDatabase {
|
|
|
2258
2710
|
this.controlWriteAbandonedListener = listener;
|
|
2259
2711
|
}
|
|
2260
2712
|
/**
|
|
2261
|
-
* Run a
|
|
2713
|
+
* Run a membership-relevant mutation and notify the membership listener once it has
|
|
2262
2714
|
* COMMITTED.
|
|
2263
2715
|
*
|
|
2264
|
-
*
|
|
2265
|
-
* ({@link updateSelfPeerRecord}, which cannot
|
|
2266
|
-
* the
|
|
2716
|
+
* Covers every write that can change who is a member or an owner: EVERY `CadrePeer`
|
|
2717
|
+
* writer, with one documented exception ({@link updateSelfPeerRecord}, which cannot
|
|
2718
|
+
* change the snapshot), the `OwnerKey` writers ({@link insertOwnerKeyVouched},
|
|
2719
|
+
* {@link deleteOwnerKey}) and the invitation redemption ({@link redeemCadreInvite}),
|
|
2720
|
+
* which seats both. That is what makes the party-membership snapshot refresh automatic
|
|
2721
|
+
* rather than a caller obligation. The name predates the `OwnerKey` writers. A
|
|
2267
2722
|
* writer necessarily holds the target node's ControlDatabase (the `SeedBootstrapService`'s
|
|
2268
|
-
* event callbacks are NOT a viable seam: the temp
|
|
2269
|
-
*
|
|
2723
|
+
* event callbacks are NOT a viable seam: the temp service `CadreNode.applySeed`
|
|
2724
|
+
* builds, and services constructed outside `CadreNode` entirely, never get
|
|
2270
2725
|
* callbacks wired), so this is the one point on the write path that cannot be bypassed.
|
|
2271
2726
|
*
|
|
2272
2727
|
* `body` owns its own transaction if it needs one and must COMMIT before returning, so
|
|
@@ -2447,7 +2902,7 @@ export class ControlDatabase {
|
|
|
2447
2902
|
return;
|
|
2448
2903
|
}
|
|
2449
2904
|
throw new Error(`mutateCadrePeer(${reason}): a transaction is open ${where} the mutation body — a ` +
|
|
2450
|
-
'
|
|
2905
|
+
'membership write must commit before the membership listener runs; move the ' +
|
|
2451
2906
|
'mutateCadrePeer wrapper out to enclose the commit');
|
|
2452
2907
|
}
|
|
2453
2908
|
/**
|
|
@@ -2526,7 +2981,7 @@ export class ControlDatabase {
|
|
|
2526
2981
|
const strandIdField = options.strandId ?? '';
|
|
2527
2982
|
// Field order MUST match the schema's FormationInvite `AuthorizedInsert` verify:
|
|
2528
2983
|
// Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId.
|
|
2529
|
-
const message = buildAuthorizationMessage('CadreControl.FormationInvite', 'add', [
|
|
2984
|
+
const message = buildAuthorizationMessage('CadreControl.FormationInvite', 'add', this.config.partyId, [
|
|
2530
2985
|
token, sAppId, expiresAtField, totalUsesField, validationUrlField, strandIdField, stampId,
|
|
2531
2986
|
]);
|
|
2532
2987
|
const signature = signMessage(message);
|
|
@@ -2860,6 +3315,389 @@ export class ControlDatabase {
|
|
|
2860
3315
|
}
|
|
2861
3316
|
return false;
|
|
2862
3317
|
}
|
|
3318
|
+
// ── Cadre invitations (CadreInvite / CadreInviteUsage) ─────────────────────────────────
|
|
3319
|
+
/**
|
|
3320
|
+
* Mint and insert an owner-signed `CadreInvite`: an invitation to join THIS cadre that any
|
|
3321
|
+
* member machine can later redeem on the owner's behalf ({@link redeemCadreInvite}).
|
|
3322
|
+
*
|
|
3323
|
+
* The owner signs the whole row ({@link cadreInviteAddMessage}) — `ExpiresAt` through
|
|
3324
|
+
* {@link canonicalDatetime} so the signed segment byte-matches the stored `datetime`, as
|
|
3325
|
+
* {@link insertFormationInvite} does — and the stored `IssuerKey`/`IssuerSig` are pinned to
|
|
3326
|
+
* that signer by `CadreInvite.AuthorizedInsert`. The signature is part of the row because
|
|
3327
|
+
* the row travels: the returned {@link CadreInviteRow} is what the invitation bundle
|
|
3328
|
+
* carries to the holder, and {@link seatCadreInvite} re-inserts it verbatim on a member
|
|
3329
|
+
* that has not received it by replication.
|
|
3330
|
+
*
|
|
3331
|
+
* `key` is the invitation's ed25519 PUBLIC key; the caller minted the pair and hands the
|
|
3332
|
+
* private half to the invitee out of band. It is never stored here (`Strand.Invite` has the
|
|
3333
|
+
* same shape).
|
|
3334
|
+
*
|
|
3335
|
+
* @param invite.key - the invitation public key (base64url ed25519), the row's primary key
|
|
3336
|
+
* @param invite.peerId - the device the invitation is for, or null for whoever redeems first
|
|
3337
|
+
* @param invite.grantsOwner - whether redemption also seats an `OwnerKey` row for the device
|
|
3338
|
+
* @param invite.expiresAtMs - epoch ms after which it cannot be redeemed; omit for never
|
|
3339
|
+
* @param invite.totalUses - how many redemptions it allows; omit for unlimited
|
|
3340
|
+
* @returns the row as stored, `expiresAt` in its canonical form
|
|
3341
|
+
*/
|
|
3342
|
+
async insertCadreInvite(invite, ownerKey, signMessage) {
|
|
3343
|
+
this.ensureInitialized();
|
|
3344
|
+
const key = requireEd25519PublicKeyB64(invite.key, 'cadre invitation key');
|
|
3345
|
+
const signed = {
|
|
3346
|
+
key,
|
|
3347
|
+
peerId: invite.peerId ?? null,
|
|
3348
|
+
grantsOwner: invite.grantsOwner,
|
|
3349
|
+
expiresAt: invite.expiresAtMs == null ? null : await canonicalDatetime(this.db, invite.expiresAtMs),
|
|
3350
|
+
totalUses: invite.totalUses ?? null,
|
|
3351
|
+
stampId: generateStampId(this.config.libp2pNode.peerId.toString()),
|
|
3352
|
+
};
|
|
3353
|
+
const row = {
|
|
3354
|
+
...signed,
|
|
3355
|
+
issuerKey: ownerKey,
|
|
3356
|
+
issuerSig: signMessage(cadreInviteAddMessage(this.config.partyId, signed)),
|
|
3357
|
+
};
|
|
3358
|
+
await this.lockedWithRetry(() => this.execCadreInviteInsert(row), {}, 'cadre-invite-insert');
|
|
3359
|
+
log('Cadre invitation inserted: %s (peer=%s owner=%s)', key, row.peerId ?? 'any', row.grantsOwner);
|
|
3360
|
+
return row;
|
|
3361
|
+
}
|
|
3362
|
+
/**
|
|
3363
|
+
* Seat an owner-signed `CadreInvite` row received inside an invitation bundle, under its own
|
|
3364
|
+
* stored `IssuerKey`/`IssuerSig` as the write context — insert-if-absent, so a member that
|
|
3365
|
+
* already holds the row (by replication, or from an earlier redemption attempt) is a no-op.
|
|
3366
|
+
*
|
|
3367
|
+
* @returns `true` when this call inserted the row, `false` when it was already present.
|
|
3368
|
+
* @throws {CadreInviteIssuerUnknownError} when `CadreInvite.AuthorizedInsert` refuses it: the
|
|
3369
|
+
* issuer is not an owner on this node (not replicated yet, or removed), or the signature does
|
|
3370
|
+
* not verify. Every other refusal (a withdrawn stamp, `NotRevoked`) propagates as is.
|
|
3371
|
+
*/
|
|
3372
|
+
async seatCadreInvite(row) {
|
|
3373
|
+
this.ensureInitialized();
|
|
3374
|
+
try {
|
|
3375
|
+
return await this.lockedWithRetry(async () => {
|
|
3376
|
+
// retry: false — inside the locked write body (see queryStampId's NOTE).
|
|
3377
|
+
if (await this.queryStampId('CadreInvite', row.key, false) !== null) {
|
|
3378
|
+
log('CadreInvite %s already seated; seat skipped', row.key);
|
|
3379
|
+
return false;
|
|
3380
|
+
}
|
|
3381
|
+
await this.execCadreInviteInsert(row);
|
|
3382
|
+
log('Cadre invitation seated from its bundle: %s', row.key);
|
|
3383
|
+
return true;
|
|
3384
|
+
}, {}, 'cadre-invite-seat');
|
|
3385
|
+
}
|
|
3386
|
+
catch (error) {
|
|
3387
|
+
if (errorChainMatches(error, CADRE_INVITE_UNAUTHORIZED)) {
|
|
3388
|
+
throw new CadreInviteIssuerUnknownError(row.key, row.issuerKey, { cause: error });
|
|
3389
|
+
}
|
|
3390
|
+
throw error;
|
|
3391
|
+
}
|
|
3392
|
+
}
|
|
3393
|
+
/** Bare `CadreInvite` insert shared by {@link insertCadreInvite} and {@link seatCadreInvite}; the caller holds the lock. */
|
|
3394
|
+
async execCadreInviteInsert(row) {
|
|
3395
|
+
await this.db.exec(`
|
|
3396
|
+
insert into CadreControl.CadreInvite (Key, PeerId, GrantsOwner, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId)
|
|
3397
|
+
with context OwnerKey = ?, Signature = ?
|
|
3398
|
+
values (?, ?, ?, ?, ?, ?, ?, ?)
|
|
3399
|
+
`, [
|
|
3400
|
+
row.issuerKey, row.issuerSig,
|
|
3401
|
+
row.key, row.peerId, row.grantsOwner ? 1 : 0, row.expiresAt, row.totalUses,
|
|
3402
|
+
row.issuerKey, row.issuerSig, row.stampId,
|
|
3403
|
+
]);
|
|
3404
|
+
}
|
|
3405
|
+
/**
|
|
3406
|
+
* Read one `CadreInvite` row by key, or null when absent. Withdrawn rows are returned too —
|
|
3407
|
+
* the row is the holder's proof and never goes away; liveness is {@link hasLiveCadreInvite}'s
|
|
3408
|
+
* question, and `CadreInviteUsage.Authorized`'s at redemption.
|
|
3409
|
+
*
|
|
3410
|
+
* `retry: false` is passed by {@link redeemCadreInvite}, which reads INSIDE its locked write
|
|
3411
|
+
* body — the same per-call opt-out, for the same reason, as {@link queryStampId}'s.
|
|
3412
|
+
*/
|
|
3413
|
+
async queryCadreInvite(key, retry = true) {
|
|
3414
|
+
this.ensureInitialized();
|
|
3415
|
+
for (const row of await this.readRows(`${CADRE_INVITE_SELECT_SQL} where Key = ?`, [key], 'cadre-invite', retry)) {
|
|
3416
|
+
return cadreInviteRowOf(row);
|
|
3417
|
+
}
|
|
3418
|
+
return null;
|
|
3419
|
+
}
|
|
3420
|
+
/**
|
|
3421
|
+
* Every `CadreInvite` row this node holds, withdrawn and expired ones included (see
|
|
3422
|
+
* {@link queryCadreInvite}). Read by the membership predicate when at least one `CadrePeer`
|
|
3423
|
+
* row is invitation-admitted; `retry: false` is forwarded from
|
|
3424
|
+
* {@link CadreNode.listAuthorizedMembers} on the membership-gate refresh path for
|
|
3425
|
+
* {@link queryCadrePeers}' reason.
|
|
3426
|
+
*/
|
|
3427
|
+
async queryCadreInvites(retry = true) {
|
|
3428
|
+
this.ensureInitialized();
|
|
3429
|
+
const rows = await this.readRows(CADRE_INVITE_SELECT_SQL, undefined, 'cadre-invites', retry);
|
|
3430
|
+
return rows.map(cadreInviteRowOf);
|
|
3431
|
+
}
|
|
3432
|
+
/**
|
|
3433
|
+
* Every `CadreInviteUsage` row this node holds — the redemption records the membership
|
|
3434
|
+
* predicate verifies invitation-admitted rows against. Same `retry` contract as
|
|
3435
|
+
* {@link queryCadreInvites}.
|
|
3436
|
+
*/
|
|
3437
|
+
async queryCadreInviteUsages(retry = true) {
|
|
3438
|
+
this.ensureInitialized();
|
|
3439
|
+
const rows = await this.readRows('select UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, OwnerStampId, InviteSig, PeerSig from CadreControl.CadreInviteUsage', undefined, 'cadre-invite-usages', retry);
|
|
3440
|
+
return rows.map(cadreInviteUsageRowOf);
|
|
3441
|
+
}
|
|
3442
|
+
/**
|
|
3443
|
+
* How many times a cadre invitation has been redeemed (its `CadreInviteUsage` rows), served
|
|
3444
|
+
* through the `CadreInviteUsageByInvite` index. As {@link countFormationUsage}: a permissive
|
|
3445
|
+
* PRE-check, not the cap — the cap is `CadreInviteUsage.Authorized`'s own committed count.
|
|
3446
|
+
*
|
|
3447
|
+
* `retry: false` is passed by {@link assertCadreInviteSeatRemains}, inside the locked body.
|
|
3448
|
+
*/
|
|
3449
|
+
async countCadreInviteUsage(key, retry = true) {
|
|
3450
|
+
this.ensureInitialized();
|
|
3451
|
+
const sql = 'select count(1) as Count from CadreControl.CadreInviteUsage where InviteKey = ?';
|
|
3452
|
+
for (const row of await this.readRows(sql, [key], 'cadre-invite-usage-count', retry)) {
|
|
3453
|
+
return row.Count ?? 0;
|
|
3454
|
+
}
|
|
3455
|
+
return 0;
|
|
3456
|
+
}
|
|
3457
|
+
/**
|
|
3458
|
+
* Is any cadre invitation still redeemable — not withdrawn, unexpired, with uses below its
|
|
3459
|
+
* `TotalUses`, and issued by a current owner? The conditions `CadreInviteUsage.Authorized`
|
|
3460
|
+
* applies, read ahead of time so the control-network connection gate can answer "does this
|
|
3461
|
+
* node expect a stranger?" with no key to ask about, exactly as
|
|
3462
|
+
* {@link hasOutstandingFormationInvite} answers it for strand formation. The expiry
|
|
3463
|
+
* comparison is `expiresAtMs <= now`, so an invitation the redemption would refuse never
|
|
3464
|
+
* holds the gate open.
|
|
3465
|
+
*
|
|
3466
|
+
* Withdrawal is read through {@link queryRevokedStamps}, the per-table retired-stamp seam
|
|
3467
|
+
* every other revocation-aware reader uses (and the one an isolated node can still answer).
|
|
3468
|
+
*/
|
|
3469
|
+
async hasLiveCadreInvite(nowMs = Date.now()) {
|
|
3470
|
+
this.ensureInitialized();
|
|
3471
|
+
// NOTE: getOwnerKeys reads the retired OwnerKey stamps first, so this gate path scans
|
|
3472
|
+
// Revocation twice (once per table, the two in parallel). If the stranger path's decision
|
|
3473
|
+
// ever nears the admission gate's 2 s deadline, read the retired stamps once for both.
|
|
3474
|
+
const [withdrawn, owners] = await Promise.all([this.queryRevokedStamps('CadreInvite'), this.getOwnerKeys()]);
|
|
3475
|
+
// NOTE: scans every CadreInvite row (expired and withdrawn ones included, since rows are
|
|
3476
|
+
// never deleted) on the stranger path of an inbound connection. Cadre-scale invitation
|
|
3477
|
+
// counts make that free today; if a long-lived cadre accumulates thousands and inbound
|
|
3478
|
+
// upgrades slow down, add an expiry-ordered index or prune withdrawn/expired rows (which
|
|
3479
|
+
// first needs every device they admitted re-vouched, so the chain no longer needs them).
|
|
3480
|
+
const metered = [];
|
|
3481
|
+
let unlimitedLive = false;
|
|
3482
|
+
for (const row of await this.readRows('select Key, ExpiresAt, TotalUses, IssuerKey, StampId from CadreControl.CadreInvite', undefined, 'live-cadre-invites')) {
|
|
3483
|
+
const open = cadreInviteStillOpen({ stampId: row.StampId, issuerKey: row.IssuerKey, expiresAt: row.ExpiresAt ?? null }, withdrawn, owners, nowMs);
|
|
3484
|
+
if (!open) {
|
|
3485
|
+
continue;
|
|
3486
|
+
}
|
|
3487
|
+
const totalUses = row.TotalUses ?? null;
|
|
3488
|
+
if (totalUses === null) {
|
|
3489
|
+
unlimitedLive = true;
|
|
3490
|
+
}
|
|
3491
|
+
else {
|
|
3492
|
+
metered.push({ key: row.Key, totalUses });
|
|
3493
|
+
}
|
|
3494
|
+
}
|
|
3495
|
+
if (unlimitedLive) {
|
|
3496
|
+
return true;
|
|
3497
|
+
}
|
|
3498
|
+
// NOTE: one retried read per metered invitation, each with its own read budget, on a path
|
|
3499
|
+
// the admission gate awaits under a 2 s fail-open deadline — the same shape and the same
|
|
3500
|
+
// revisit condition as hasOutstandingFormationInvite's loop.
|
|
3501
|
+
for (const invite of metered) {
|
|
3502
|
+
if (await this.countCadreInviteUsage(invite.key) < invite.totalUses) {
|
|
3503
|
+
return true;
|
|
3504
|
+
}
|
|
3505
|
+
}
|
|
3506
|
+
return false;
|
|
3507
|
+
}
|
|
3508
|
+
/**
|
|
3509
|
+
* Is this one cadre invitation still redeemable here — the conditions
|
|
3510
|
+
* {@link hasLiveCadreInvite} applies, for one invitation? The row judged is the one this node
|
|
3511
|
+
* holds under `invite.key`; when it holds none, `invite` itself (a bundle's copy, not seated
|
|
3512
|
+
* yet), but only if its issuer signature verifies, because an unsigned copy would otherwise be
|
|
3513
|
+
* judged by whichever owner it names as issuer. A permissive pre-check like
|
|
3514
|
+
* {@link countCadreInviteUsage}: {@link seatCadreInvite} and the redemption's own write are
|
|
3515
|
+
* what enforce every condition.
|
|
3516
|
+
*/
|
|
3517
|
+
async isCadreInviteLive(invite, nowMs = Date.now()) {
|
|
3518
|
+
this.ensureInitialized();
|
|
3519
|
+
const row = await this.queryCadreInvite(invite.key) ?? (verifyCadreInviteRow(this.config.partyId, invite) ? invite : null);
|
|
3520
|
+
if (row === null) {
|
|
3521
|
+
return false;
|
|
3522
|
+
}
|
|
3523
|
+
const [withdrawn, owners] = await Promise.all([this.queryRevokedStamps('CadreInvite'), this.getOwnerKeys()]);
|
|
3524
|
+
if (!cadreInviteStillOpen(row, withdrawn, owners, nowMs)) {
|
|
3525
|
+
return false;
|
|
3526
|
+
}
|
|
3527
|
+
return row.totalUses === null || await this.countCadreInviteUsage(row.key) < row.totalUses;
|
|
3528
|
+
}
|
|
3529
|
+
/**
|
|
3530
|
+
* Every `CadreInvite` row this node holds with its standing: how many redemptions are
|
|
3531
|
+
* recorded, whether it is withdrawn, and whether it is still redeemable — the same
|
|
3532
|
+
* conditions {@link hasLiveCadreInvite} applies, answered per row for an owner's listing
|
|
3533
|
+
* rather than short-circuited. One usage count per row, so this is the owner's
|
|
3534
|
+
* occasional read, not a gate's.
|
|
3535
|
+
*/
|
|
3536
|
+
async listCadreInviteStatuses(nowMs = Date.now()) {
|
|
3537
|
+
this.ensureInitialized();
|
|
3538
|
+
const [invites, withdrawnStamps, owners] = await Promise.all([
|
|
3539
|
+
this.queryCadreInvites(), this.queryRevokedStamps('CadreInvite'), this.getOwnerKeys()
|
|
3540
|
+
]);
|
|
3541
|
+
const statuses = [];
|
|
3542
|
+
// NOTE: one retried count read per invitation, withdrawn and expired rows included, since
|
|
3543
|
+
// rows are never deleted; if a long-lived cadre's listing ever shows as slow, group the
|
|
3544
|
+
// usage counts in one statement or skip the count for rows that are no longer open.
|
|
3545
|
+
for (const invite of invites) {
|
|
3546
|
+
const usesRecorded = await this.countCadreInviteUsage(invite.key);
|
|
3547
|
+
const withdrawn = withdrawnStamps.has(invite.stampId);
|
|
3548
|
+
const live = cadreInviteStillOpen(invite, withdrawnStamps, owners, nowMs)
|
|
3549
|
+
&& (invite.totalUses === null || usesRecorded < invite.totalUses);
|
|
3550
|
+
statuses.push({ invite, live, withdrawn, usesRecorded });
|
|
3551
|
+
}
|
|
3552
|
+
return statuses;
|
|
3553
|
+
}
|
|
3554
|
+
/**
|
|
3555
|
+
* Owner-signed WITHDRAWAL of a cadre invitation: file the `Revocation` tombstone retiring its
|
|
3556
|
+
* stamp, with the row kept. The row stays because every device the invitation admitted
|
|
3557
|
+
* proves its membership through it (`CadrePeer.AuthorizedInsert`'s consent branch,
|
|
3558
|
+
* `verifyInvitationAdmission`); `CadreInvite.NoDelete` refuses a delete, and
|
|
3559
|
+
* `Revocation.RowIsGone` admits a `'CadreInvite'` tombstone while the row lives. Afterwards
|
|
3560
|
+
* `CadreInviteUsage.Authorized` refuses every further redemption, and `CadreInvite.NotRevoked`
|
|
3561
|
+
* refuses re-seating the row from the holder's signed copy on any node holding the
|
|
3562
|
+
* tombstone.
|
|
3563
|
+
*
|
|
3564
|
+
* Idempotent: a no-op (no throw, nothing written) when the row is absent locally or already
|
|
3565
|
+
* withdrawn — `false` then, `true` when this call filed the tombstone. The owner must hold the
|
|
3566
|
+
* row to withdraw it, since the tombstone names the row's stamp.
|
|
3567
|
+
*
|
|
3568
|
+
* Fires the guarded-delete listener like a removal does, so a withdrawal committed while the
|
|
3569
|
+
* node was alone is queued for re-issue on cohort growth ({@link CadreNode.noteGuardedDelete}).
|
|
3570
|
+
*/
|
|
3571
|
+
async withdrawCadreInvite(key, ownerKey, signMessage) {
|
|
3572
|
+
this.ensureInitialized();
|
|
3573
|
+
const filed = await this.lockedWithRetry(async () => {
|
|
3574
|
+
// retry: false on both reads — inside the locked write body (see queryStampId's NOTE).
|
|
3575
|
+
const stampId = await this.queryStampId('CadreInvite', key, false);
|
|
3576
|
+
if (stampId === null) {
|
|
3577
|
+
log('withdrawCadreInvite: no CadreInvite row for %s (nothing to withdraw)', key);
|
|
3578
|
+
return null;
|
|
3579
|
+
}
|
|
3580
|
+
if ((await this.queryRevokedStamps('CadreInvite', false)).has(stampId)) {
|
|
3581
|
+
log('withdrawCadreInvite: %s already withdrawn (stamp %s retired)', key, stampId);
|
|
3582
|
+
return null;
|
|
3583
|
+
}
|
|
3584
|
+
const ref = { tableName: 'CadreInvite', rowKey: key, stampId };
|
|
3585
|
+
await this.execTombstone(ref, signMessage(revocationTombstoneMessage(this.config.partyId, ref)), ownerKey);
|
|
3586
|
+
return ref;
|
|
3587
|
+
}, {}, 'cadre-invite-withdraw');
|
|
3588
|
+
if (filed === null) {
|
|
3589
|
+
return false;
|
|
3590
|
+
}
|
|
3591
|
+
log('Cadre invitation withdrawn: %s (stamp %s retired, row kept)', key, filed.stampId);
|
|
3592
|
+
this.notifyGuardedDelete(filed);
|
|
3593
|
+
return true;
|
|
3594
|
+
}
|
|
3595
|
+
/**
|
|
3596
|
+
* Redeem a seated `CadreInvite` on the owner's behalf: admit `peerId` as a cadre member and,
|
|
3597
|
+
* when the invitation grants ownership, as an owner — with NO owner signature. One
|
|
3598
|
+
* transaction writes the `CadrePeer` row (consent branch of `AuthorizedInsert`), the
|
|
3599
|
+
* `OwnerKey` row when granted (consent branch of `Authorized`), and last the
|
|
3600
|
+
* `CadreInviteUsage` row that authorizes both; the usage row's own checks (`PeerExists`,
|
|
3601
|
+
* `OwnerExists`) read the rows back, so the three are mutually bound to this exact
|
|
3602
|
+
* redemption and none can be held in reserve. The invitation must already be committed
|
|
3603
|
+
* ({@link seatCadreInvite}): every consent branch reads `committed.CadreInvite`.
|
|
3604
|
+
*
|
|
3605
|
+
* The two signatures are the holder's and the device's, minted on the device over
|
|
3606
|
+
* {@link cadreInviteRedeemMessage} / {@link cadreInviteConsentMessage}; this machine only
|
|
3607
|
+
* verifies them (by writing the row). `peerId` must be the identity multihash of `peerKey`:
|
|
3608
|
+
* the schema cannot derive it, so a writer asserts the pair and every reader re-derives it
|
|
3609
|
+
* (`verifyInvitationAdmission`) — a mismatched pair is written and then never trusted.
|
|
3610
|
+
*
|
|
3611
|
+
* Idempotent for a retry after a dropped reply: when the device's row is already present
|
|
3612
|
+
* (and its `OwnerKey` row too, when granted) nothing is written and no seat is spent —
|
|
3613
|
+
* `alreadyMember: true`. A present row whose `OwnerKey` row is missing under an
|
|
3614
|
+
* owner-granting invitation is completed: the usage row then names the existing peer stamp.
|
|
3615
|
+
* The seat check ({@link assertCadreInviteSeatRemains}) runs after that, so the retry of a
|
|
3616
|
+
* single-use redemption is answered `alreadyMember` rather than refused as exhausted.
|
|
3617
|
+
*
|
|
3618
|
+
* Through {@link mutateCadrePeer}, so the membership hub is notified; inside the locked body
|
|
3619
|
+
* every read passes `retry: false` and every stamp is minted per attempt, so a
|
|
3620
|
+
* {@link lockedWithRetry} re-run starts clean.
|
|
3621
|
+
*
|
|
3622
|
+
* @throws {InvitationExhaustedError} when the invitation's seats are all spent (by name,
|
|
3623
|
+
* ahead of the schema's generic `Authorized` refusal — see {@link assertSeatRemains}).
|
|
3624
|
+
*/
|
|
3625
|
+
async redeemCadreInvite(params) {
|
|
3626
|
+
this.ensureInitialized();
|
|
3627
|
+
const { inviteKey, peerId, peerKey, usageStampId, inviteSig, peerSig } = params;
|
|
3628
|
+
const nowMs = params.nowMs ?? Date.now();
|
|
3629
|
+
const multiaddr = params.multiaddrs?.length ? params.multiaddrs.join(',') : '';
|
|
3630
|
+
log('Redeeming cadre invitation %s for %s', inviteKey, peerId);
|
|
3631
|
+
return this.mutateCadrePeer('cadre-invite-redeem', async () => {
|
|
3632
|
+
const invite = await this.queryCadreInvite(inviteKey, false);
|
|
3633
|
+
if (invite === null) {
|
|
3634
|
+
throw new Error(`Cannot redeem cadre invitation ${inviteKey}: no CadreInvite row is seated on this node (seatCadreInvite first)`);
|
|
3635
|
+
}
|
|
3636
|
+
const seated = await this.readSeatedAdmission(peerId, peerKey, invite.grantsOwner);
|
|
3637
|
+
if (seated.peerStampId !== null && (!invite.grantsOwner || seated.ownerStampId !== null)) {
|
|
3638
|
+
log('redeemCadreInvite: %s already admitted; nothing written', peerId);
|
|
3639
|
+
return { alreadyMember: true, peerStampId: seated.peerStampId, ownerStampId: seated.ownerStampId };
|
|
3640
|
+
}
|
|
3641
|
+
await this.assertCadreInviteSeatRemains(invite);
|
|
3642
|
+
// Stamps minted from the ADMITTED device's id, as insertCadrePeer does: the nonce names
|
|
3643
|
+
// the row it seats. Minted inside the body, so a re-run mints afresh over a rolled-back
|
|
3644
|
+
// attempt rather than re-presenting a stamp the usage row may already name.
|
|
3645
|
+
const peerStampId = seated.peerStampId ?? generateStampId(peerId);
|
|
3646
|
+
const ownerStampId = invite.grantsOwner ? (seated.ownerStampId ?? generateStampId(peerId)) : null;
|
|
3647
|
+
const nowCanonical = await canonicalDatetime(this.db, nowMs);
|
|
3648
|
+
await this.inTransaction('cadre-invite-redeem', async () => {
|
|
3649
|
+
if (seated.peerStampId === null) {
|
|
3650
|
+
await this.db.exec(`
|
|
3651
|
+
insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId, VouchOwner, VouchSig, VouchUsage)
|
|
3652
|
+
with context OwnerKey = null, Signature = null
|
|
3653
|
+
values (?, ?, ?, ?, null, ?, ?, null, ?)
|
|
3654
|
+
`, [peerId, peerKey, multiaddr, nowMs, peerStampId, invite.issuerKey, usageStampId]);
|
|
3655
|
+
}
|
|
3656
|
+
if (ownerStampId !== null && seated.ownerStampId === null) {
|
|
3657
|
+
await this.db.exec(`
|
|
3658
|
+
insert into CadreControl.OwnerKey (Key, StampId, VouchOwner, VouchSig, VouchUsage)
|
|
3659
|
+
with context OwnerKey = null, Signature = null
|
|
3660
|
+
values (?, ?, ?, null, ?)
|
|
3661
|
+
`, [peerKey, ownerStampId, invite.issuerKey, usageStampId]);
|
|
3662
|
+
}
|
|
3663
|
+
await this.db.exec(`
|
|
3664
|
+
insert into CadreControl.CadreInviteUsage (UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, OwnerStampId, InviteSig, PeerSig)
|
|
3665
|
+
with context Now = ?
|
|
3666
|
+
values (?, ?, ?, ?, ?, ?, ?, ?)
|
|
3667
|
+
`, [nowCanonical, usageStampId, inviteKey, peerId, peerKey, peerStampId, ownerStampId, inviteSig, peerSig]);
|
|
3668
|
+
});
|
|
3669
|
+
log('Redeemed cadre invitation %s: %s admitted (owner=%s, usage %s)', inviteKey, peerId, ownerStampId !== null, usageStampId);
|
|
3670
|
+
return { alreadyMember: false, peerStampId, ownerStampId };
|
|
3671
|
+
});
|
|
3672
|
+
}
|
|
3673
|
+
/**
|
|
3674
|
+
* The rows a redemption would seat that are already present: the device's `CadrePeer`
|
|
3675
|
+
* stamp, and — only when the invitation grants ownership — its `OwnerKey` stamp. Raw stamp
|
|
3676
|
+
* reads ({@link queryStampId}), so a physically present row counts; inside the locked
|
|
3677
|
+
* body, so never retried.
|
|
3678
|
+
*/
|
|
3679
|
+
async readSeatedAdmission(peerId, peerKey, grantsOwner) {
|
|
3680
|
+
return {
|
|
3681
|
+
peerStampId: await this.queryStampId('CadrePeer', peerId, false),
|
|
3682
|
+
ownerStampId: grantsOwner ? await this.queryStampId('OwnerKey', peerKey, false) : null,
|
|
3683
|
+
};
|
|
3684
|
+
}
|
|
3685
|
+
/**
|
|
3686
|
+
* Refuse a redemption that would consume a seat the cadre invitation does not have, by
|
|
3687
|
+
* name ({@link InvitationExhaustedError}) — the cadre-invitation twin of
|
|
3688
|
+
* {@link assertSeatRemains}, for the same reason: the schema's cap clause refuses as a
|
|
3689
|
+
* generic `Authorized` failure a caller would otherwise report as retryable. Reads the
|
|
3690
|
+
* committed count inside the locked body, so on a same-node race the loser is refused here.
|
|
3691
|
+
*/
|
|
3692
|
+
async assertCadreInviteSeatRemains(invite) {
|
|
3693
|
+
if (invite.totalUses === null) {
|
|
3694
|
+
return;
|
|
3695
|
+
}
|
|
3696
|
+
const used = await this.countCadreInviteUsage(invite.key, false);
|
|
3697
|
+
if (used >= invite.totalUses) {
|
|
3698
|
+
throw new InvitationExhaustedError(invite.key, used, invite.totalUses);
|
|
3699
|
+
}
|
|
3700
|
+
}
|
|
2863
3701
|
/**
|
|
2864
3702
|
* Close the database and cleanup resources.
|
|
2865
3703
|
*
|