@serfab/cadre-core 1.13.0 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +293 -274
- package/dist/cadre-invite-protocol.d.ts +383 -0
- package/dist/cadre-invite-protocol.js +774 -0
- package/dist/cadre-invite-protocol.js.map +1 -0
- package/dist/cadre-join-request.d.ts +42 -0
- package/dist/cadre-join-request.js +144 -0
- package/dist/cadre-join-request.js.map +1 -0
- package/dist/cadre-node.d.ts +849 -210
- package/dist/cadre-node.js +1574 -374
- 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 +605 -108
- package/dist/control-database.js +1299 -280
- 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 +817 -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 +22 -9
- package/dist/ed25519-key.js +47 -10
- package/dist/ed25519-key.js.map +1 -1
- package/dist/fetch-impl.d.ts +12 -0
- package/dist/fetch-impl.js +23 -0
- package/dist/fetch-impl.js.map +1 -0
- package/dist/file-durable-slot.js +2 -1
- package/dist/file-durable-slot.js.map +1 -1
- package/dist/file-safe-name.d.ts +25 -0
- package/dist/file-safe-name.js +29 -0
- package/dist/file-safe-name.js.map +1 -0
- package/dist/formation-approval.js +5 -11
- package/dist/formation-approval.js.map +1 -1
- package/dist/fs-atomic.d.ts +0 -19
- package/dist/fs-atomic.js +3 -24
- package/dist/fs-atomic.js.map +1 -1
- package/dist/index.d.ts +22 -10
- package/dist/index.js +54 -11
- package/dist/index.js.map +1 -1
- package/dist/invitation-bootstrap.d.ts +50 -12
- package/dist/invitation-bootstrap.js +76 -13
- package/dist/invitation-bootstrap.js.map +1 -1
- package/dist/joined-strand-store.d.ts +7 -0
- package/dist/joined-strand-store.js +9 -0
- package/dist/joined-strand-store.js.map +1 -1
- package/dist/key-store-file.js +2 -1
- package/dist/key-store-file.js.map +1 -1
- package/dist/link-budget.d.ts +25 -33
- package/dist/link-budget.js +25 -35
- package/dist/link-budget.js.map +1 -1
- package/dist/machine-removal.d.ts +78 -0
- package/dist/machine-removal.js +166 -0
- package/dist/machine-removal.js.map +1 -0
- package/dist/membership-connection-gater.d.ts +191 -151
- package/dist/membership-connection-gater.js +236 -173
- package/dist/membership-connection-gater.js.map +1 -1
- package/dist/node-claim-payload.d.ts +74 -0
- package/dist/node-claim-payload.js +224 -0
- package/dist/node-claim-payload.js.map +1 -0
- package/dist/node-default-transports.d.ts +27 -0
- package/dist/node-default-transports.js +35 -0
- package/dist/node-default-transports.js.map +1 -0
- package/dist/node-transports.d.ts +30 -0
- package/dist/node-transports.js +40 -0
- package/dist/node-transports.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 +143 -27
- package/dist/peer-dial.js +276 -31
- package/dist/peer-dial.js.map +1 -1
- package/dist/peer-join-backfill.d.ts +26 -2
- package/dist/peer-join-backfill.js +60 -12
- package/dist/peer-join-backfill.js.map +1 -1
- package/dist/peer-record.d.ts +17 -52
- package/dist/peer-record.js +20 -58
- 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/primary-lan-address.d.ts +8 -0
- package/dist/primary-lan-address.js +46 -0
- package/dist/primary-lan-address.js.map +1 -0
- package/dist/provider/client.d.ts +131 -0
- package/dist/provider/client.js +226 -0
- package/dist/provider/client.js.map +1 -0
- package/dist/provider/descriptor.d.ts +41 -0
- package/dist/provider/descriptor.js +103 -0
- package/dist/provider/descriptor.js.map +1 -0
- package/dist/provider/index.d.ts +9 -0
- package/dist/provider/index.js +10 -0
- package/dist/provider/index.js.map +1 -0
- package/dist/provider/oauth.d.ts +125 -0
- package/dist/provider/oauth.js +199 -0
- package/dist/provider/oauth.js.map +1 -0
- package/dist/provider/rent-node.d.ts +101 -0
- package/dist/provider/rent-node.js +220 -0
- package/dist/provider/rent-node.js.map +1 -0
- 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/relay-addrs.d.ts +9 -4
- package/dist/relay-addrs.js +9 -4
- package/dist/relay-addrs.js.map +1 -1
- package/dist/relay-reservation.d.ts +51 -16
- package/dist/relay-reservation.js +107 -24
- package/dist/relay-reservation.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +165 -123
- package/dist/seed-bootstrap.js +313 -275
- 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-commit-durability.d.ts +54 -0
- package/dist/strand-commit-durability.js +74 -0
- package/dist/strand-commit-durability.js.map +1 -0
- package/dist/strand-database.d.ts +23 -8
- package/dist/strand-database.js +41 -13
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-first-sync-gate.d.ts +7 -1
- package/dist/strand-first-sync-gate.js +7 -1
- package/dist/strand-first-sync-gate.js.map +1 -1
- package/dist/strand-formation-protocol.js +5 -2
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-fret-addrs.d.ts +19 -1
- package/dist/strand-fret-addrs.js +27 -9
- package/dist/strand-fret-addrs.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +81 -19
- package/dist/strand-instance-manager.js +216 -105
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-move.d.ts +124 -0
- package/dist/strand-move.js +438 -0
- package/dist/strand-move.js.map +1 -0
- package/dist/strand-network-config.js +7 -6
- package/dist/strand-network-config.js.map +1 -1
- package/dist/strand-peer-redial.d.ts +113 -0
- package/dist/strand-peer-redial.js +236 -0
- package/dist/strand-peer-redial.js.map +1 -0
- 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 +23 -27
- 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 +512 -89
- package/dist/types.js +10 -0
- package/dist/types.js.map +1 -1
- package/package.json +19 -10
- package/src/cadre-invite-protocol.ts +1052 -0
- package/src/cadre-join-request.ts +167 -0
- package/src/cadre-node.ts +1703 -386
- 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 +4663 -3453
- package/src/control-protocol-guard.ts +348 -0
- package/src/control-schema.ts +828 -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 +47 -10
- package/src/fetch-impl.ts +23 -0
- package/src/file-durable-slot.ts +64 -63
- package/src/file-safe-name.ts +32 -0
- package/src/formation-approval.ts +604 -610
- package/src/fs-atomic.ts +92 -117
- package/src/index.ts +789 -648
- package/src/invitation-bootstrap.ts +91 -13
- package/src/joined-strand-store.ts +508 -498
- package/src/key-store-file.ts +142 -141
- package/src/link-budget.ts +25 -36
- package/src/machine-removal.ts +216 -0
- package/src/membership-connection-gater.ts +259 -191
- package/src/node-claim-payload.ts +259 -0
- package/src/node-default-transports.ts +38 -0
- package/src/node-transports.ts +49 -0
- package/src/owner-anchor-sync.ts +171 -0
- package/src/peer-authorization.ts +258 -29
- package/src/peer-dial.ts +604 -315
- package/src/peer-join-backfill.ts +62 -11
- package/src/peer-record.ts +159 -197
- package/src/pending-join-runner.ts +729 -735
- package/src/primary-lan-address.ts +47 -0
- package/src/provider/client.ts +326 -0
- package/src/provider/descriptor.ts +129 -0
- package/src/provider/index.ts +59 -0
- package/src/provider/oauth.ts +285 -0
- package/src/provider/rent-node.ts +275 -0
- package/src/push-fanout.ts +8 -2
- package/src/relay-addrs.ts +9 -4
- package/src/relay-reservation.ts +1211 -1121
- package/src/seed-bootstrap.ts +1502 -1448
- package/src/seed-trust-policy.ts +353 -140
- package/src/strand-addr-protocol.ts +26 -35
- package/src/strand-commit-durability.ts +116 -0
- package/src/strand-database.ts +46 -14
- package/src/strand-first-sync-gate.ts +7 -1
- package/src/strand-formation-protocol.ts +1039 -1036
- package/src/strand-fret-addrs.ts +179 -145
- package/src/strand-instance-manager.ts +2026 -1883
- package/src/strand-move.ts +585 -0
- package/src/strand-network-config.ts +7 -6
- package/src/strand-peer-redial.ts +307 -0
- package/src/strand-revocation-enforcer.ts +11 -11
- package/src/strand-wake-protocol.ts +23 -30
- package/src/trusted-owner-store.ts +242 -171
- package/src/types.ts +2687 -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
|
*
|
|
@@ -120,7 +140,7 @@ function parseStoredDatetimeMs(value) {
|
|
|
120
140
|
* and "unparseable" to null — i.e. "no expiry". Shared by every `ExpiresAt`
|
|
121
141
|
* reader so the NaN guard cannot drift between them.
|
|
122
142
|
*/
|
|
123
|
-
function parseNullableStoredDatetimeMs(value) {
|
|
143
|
+
export function parseNullableStoredDatetimeMs(value) {
|
|
124
144
|
if (value === null || value === undefined)
|
|
125
145
|
return null;
|
|
126
146
|
const parsed = parseStoredDatetimeMs(value);
|
|
@@ -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.
|
|
153
183
|
*/
|
|
154
|
-
|
|
155
|
-
return digest(
|
|
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.
|
|
204
|
+
*/
|
|
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,90 @@ function joinedStrandRow(row) {
|
|
|
347
530
|
FounderOwnerKey: null,
|
|
348
531
|
};
|
|
349
532
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
533
|
+
const FORMATION_INVITE_SELECT_SQL = 'select Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId from CadreControl.FormationInvite';
|
|
534
|
+
function formationInviteRowOf(row) {
|
|
535
|
+
return {
|
|
536
|
+
token: row.Token,
|
|
537
|
+
sAppId: row.sAppId,
|
|
538
|
+
expiresAtMs: parseNullableStoredDatetimeMs(row.ExpiresAt),
|
|
539
|
+
totalUses: row.TotalUses ?? null,
|
|
540
|
+
validationUrl: row.ValidationUrl ?? null,
|
|
541
|
+
strandId: row.StrandId ?? null,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
const JOIN_REQUEST_INSERT_SQL = `
|
|
545
|
+
insert into CadreControl.JoinRequest (Id, Invitation, Disclosure, RequestedAt, ExpiresAt, StampId)
|
|
546
|
+
with context OwnerKey = ?, Signature = ?
|
|
547
|
+
values (?, ?, ?, ?, ?, ?)
|
|
548
|
+
`;
|
|
549
|
+
const JOIN_SUCCESS_INSERT_SQL = `
|
|
550
|
+
insert into CadreControl.JoinSuccess (RequestStampId, RecordedAt, StrandId, MembershipInvite)
|
|
551
|
+
with context OwnerKey = ?, Signature = ?
|
|
552
|
+
values (?, ?, ?, ?)
|
|
553
|
+
`;
|
|
554
|
+
const JOIN_FAILURE_INSERT_SQL = `
|
|
555
|
+
insert into CadreControl.JoinFailure (RequestStampId, RecordedAt, Code, Reason)
|
|
556
|
+
with context OwnerKey = ?, Signature = ?
|
|
557
|
+
values (?, ?, ?, ?)
|
|
558
|
+
`;
|
|
559
|
+
/**
|
|
560
|
+
* A request with its outcome in one statement: the outcome tables are keyed by the request's
|
|
561
|
+
* stamp, so the join finds exactly this incarnation's outcome.
|
|
562
|
+
*/
|
|
563
|
+
const PENDING_JOIN_SELECT_SQL = `
|
|
564
|
+
select R.Id, R.Invitation, R.Disclosure, R.RequestedAt, R.ExpiresAt, R.StampId,
|
|
565
|
+
S.RecordedAt as JoinedAt, S.StrandId, S.MembershipInvite,
|
|
566
|
+
F.RecordedAt as FailedAt, F.Code, F.Reason
|
|
567
|
+
from CadreControl.JoinRequest R
|
|
568
|
+
left join CadreControl.JoinSuccess S on S.RequestStampId = R.StampId
|
|
569
|
+
left join CadreControl.JoinFailure F on F.RequestStampId = R.StampId
|
|
356
570
|
`;
|
|
357
|
-
/** A
|
|
358
|
-
function
|
|
571
|
+
/** A {@link PENDING_JOIN_SELECT_SQL} row as read. */
|
|
572
|
+
function pendingJoinOf(row) {
|
|
359
573
|
return {
|
|
360
574
|
Id: row.Id,
|
|
361
575
|
Invitation: row.Invitation,
|
|
362
576
|
Disclosure: row.Disclosure,
|
|
363
577
|
RequestedAt: row.RequestedAt,
|
|
364
578
|
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
579
|
StampId: row.StampId,
|
|
580
|
+
outcome: joinOutcomeOf(row),
|
|
372
581
|
};
|
|
373
582
|
}
|
|
583
|
+
/** The outcome half of a {@link PENDING_JOIN_SELECT_SQL} row; a request never has both (`JoinSuccess.NoFailure`, `JoinFailure.NotJoined`). */
|
|
584
|
+
function joinOutcomeOf(row) {
|
|
585
|
+
if (typeof row.JoinedAt === 'number') {
|
|
586
|
+
return { kind: 'joined', RecordedAt: row.JoinedAt, StrandId: row.StrandId, MembershipInvite: row.MembershipInvite };
|
|
587
|
+
}
|
|
588
|
+
if (typeof row.FailedAt === 'number') {
|
|
589
|
+
return { kind: 'failed', RecordedAt: row.FailedAt, Code: row.Code, Reason: row.Reason };
|
|
590
|
+
}
|
|
591
|
+
return null;
|
|
592
|
+
}
|
|
593
|
+
/** The fields `JoinRequest.AuthorizedInsert` signs, in the schema's order; integers sign as their text. */
|
|
594
|
+
function joinRequestAddFields(row) {
|
|
595
|
+
return [row.Id, row.Invitation, row.Disclosure, String(row.RequestedAt), String(row.ExpiresAt), row.StampId];
|
|
596
|
+
}
|
|
374
597
|
/**
|
|
375
|
-
* The
|
|
376
|
-
*
|
|
598
|
+
* The owner-signed insert of `outcome` for the request incarnation `requestStampId`: the
|
|
599
|
+
* `JoinSuccess` / `JoinFailure` row and the signature its `AuthorizedInsert` verifies, over the
|
|
600
|
+
* row's columns in the schema's order (a null column signs as `''`, an integer as its text).
|
|
377
601
|
*/
|
|
378
|
-
function
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
602
|
+
function signJoinOutcomeInsert(partyId, requestStampId, outcome, ownerKey, signMessage) {
|
|
603
|
+
const at = String(outcome.RecordedAt);
|
|
604
|
+
const { table, sql, fields, values } = outcome.kind === 'joined'
|
|
605
|
+
? {
|
|
606
|
+
table: 'JoinSuccess', sql: JOIN_SUCCESS_INSERT_SQL,
|
|
607
|
+
fields: [requestStampId, at, outcome.StrandId, outcome.MembershipInvite ?? ''],
|
|
608
|
+
values: [requestStampId, outcome.RecordedAt, outcome.StrandId, outcome.MembershipInvite],
|
|
609
|
+
}
|
|
610
|
+
: {
|
|
611
|
+
table: 'JoinFailure', sql: JOIN_FAILURE_INSERT_SQL,
|
|
612
|
+
fields: [requestStampId, at, outcome.Code, outcome.Reason],
|
|
613
|
+
values: [requestStampId, outcome.RecordedAt, outcome.Code, outcome.Reason],
|
|
614
|
+
};
|
|
615
|
+
const signature = signMessage(buildAuthorizationMessage(`CadreControl.${table}`, 'add', partyId, fields));
|
|
616
|
+
return { sql, params: [ownerKey, signature, ...values] };
|
|
391
617
|
}
|
|
392
618
|
/**
|
|
393
619
|
* ControlDatabase manages the CadreControl schema using Quereus with Optimystic backend.
|
|
@@ -424,6 +650,9 @@ export class ControlDatabase {
|
|
|
424
650
|
* backoff. Applied OVER the read policy's defaults but UNDER the call site's `label`.
|
|
425
651
|
*/
|
|
426
652
|
this.controlReadRetryPacing = {};
|
|
653
|
+
if (typeof config.partyId !== 'string' || config.partyId.length === 0) {
|
|
654
|
+
throw new Error('ControlDatabase requires a non-empty partyId: every control approval is bound to it');
|
|
655
|
+
}
|
|
427
656
|
this.config = config;
|
|
428
657
|
}
|
|
429
658
|
/**
|
|
@@ -459,6 +688,8 @@ export class ControlDatabase {
|
|
|
459
688
|
this.db.registerFunction(func.schema);
|
|
460
689
|
}
|
|
461
690
|
timing('[controlDb] optimysticPlugin: %dms', Math.round(performance.now() - t0));
|
|
691
|
+
// Before hydrate: a warm restart parses the persisted CHECK text, which calls party_id().
|
|
692
|
+
registerPartyIdFunction(this.db, this.config.partyId);
|
|
462
693
|
this.collectionFactory = pluginResult.collectionFactory;
|
|
463
694
|
// Inject the libp2p node into the collection factory
|
|
464
695
|
t0 = performance.now();
|
|
@@ -810,27 +1041,31 @@ export class ControlDatabase {
|
|
|
810
1041
|
return rows.length === 0 ? null : joinedStrandRow(rows[0]);
|
|
811
1042
|
}
|
|
812
1043
|
/**
|
|
813
|
-
* Every join this party asked for and has not dismissed
|
|
814
|
-
*
|
|
1044
|
+
* Every join this party asked for and has not dismissed, pending or finished: each
|
|
1045
|
+
* `CadreControl.JoinRequest` row with its `JoinSuccess` / `JoinFailure` row.
|
|
815
1046
|
*
|
|
816
|
-
*
|
|
817
|
-
* drops
|
|
818
|
-
*
|
|
819
|
-
*
|
|
1047
|
+
* A request whose `StampId` is retired in `Revocation` is dropped, as {@link queryCadrePeers}
|
|
1048
|
+
* drops such rows: it is a re-asked or dismissed incarnation this node still holds until the
|
|
1049
|
+
* reap sweep removes it, or one re-seated by a replayed approval on a node that had not yet
|
|
1050
|
+
* seen the tombstone. Either way it must not bring a dismissed join back.
|
|
820
1051
|
*/
|
|
821
1052
|
async queryPendingJoins() {
|
|
822
1053
|
this.ensureInitialized();
|
|
823
|
-
const revoked = await this.queryRevokedStamps('
|
|
824
|
-
const rows = await this.readRows(
|
|
825
|
-
return rows.map(
|
|
1054
|
+
const revoked = await this.queryRevokedStamps('JoinRequest');
|
|
1055
|
+
const rows = await this.readRows(PENDING_JOIN_SELECT_SQL, undefined, 'pending-joins');
|
|
1056
|
+
return rows.map(pendingJoinOf).filter(join => !revoked.has(join.StampId));
|
|
826
1057
|
}
|
|
827
|
-
/** Single-row sibling of {@link queryPendingJoins}, with the same retired-stamp filter: the
|
|
1058
|
+
/** Single-row sibling of {@link queryPendingJoins}, with the same retired-stamp filter: the join with this id, or null. */
|
|
828
1059
|
async queryPendingJoin(id) {
|
|
829
1060
|
this.ensureInitialized();
|
|
830
|
-
const revoked = await this.queryRevokedStamps('
|
|
831
|
-
const
|
|
832
|
-
|
|
833
|
-
|
|
1061
|
+
const revoked = await this.queryRevokedStamps('JoinRequest');
|
|
1062
|
+
const join = await this.readPendingJoin(id, true);
|
|
1063
|
+
return join === null || revoked.has(join.StampId) ? null : join;
|
|
1064
|
+
}
|
|
1065
|
+
/** The raw read behind {@link queryPendingJoin}, with no retired-stamp filter. `retry: false` inside a locked write body. */
|
|
1066
|
+
async readPendingJoin(id, retry) {
|
|
1067
|
+
const rows = await this.readRows(`${PENDING_JOIN_SELECT_SQL} where R.Id = ?`, [id], 'pending-join', retry);
|
|
1068
|
+
return rows.length === 0 ? null : pendingJoinOf(rows[0]);
|
|
834
1069
|
}
|
|
835
1070
|
/**
|
|
836
1071
|
* Count rows in a CadreControl table as seen by THIS database instance.
|
|
@@ -863,6 +1098,9 @@ export class ControlDatabase {
|
|
|
863
1098
|
/**
|
|
864
1099
|
* Check whether any owner key exists in the control database.
|
|
865
1100
|
* Used to decide whether a fresh-party genesis insert is required.
|
|
1101
|
+
*
|
|
1102
|
+
* Deliberately RAW (no retired-stamp filter, unlike {@link getOwnerKeys}): the genesis
|
|
1103
|
+
* insert is refused by the schema whenever any physical row exists, so this must see one.
|
|
866
1104
|
*/
|
|
867
1105
|
async hasOwnerKey() {
|
|
868
1106
|
this.ensureInitialized();
|
|
@@ -893,14 +1131,65 @@ export class ControlDatabase {
|
|
|
893
1131
|
* trusted only if it is already enrolled here (see `SeedTrustPolicy`). It is
|
|
894
1132
|
* also the owner-identity source for `queryPeers`, decoupling owner
|
|
895
1133
|
* status from the libp2p transport peer ID.
|
|
896
|
-
|
|
897
|
-
|
|
1134
|
+
*
|
|
1135
|
+
* Rows whose `StampId` is retired in `CadreControl.Revocation` are excluded, as
|
|
1136
|
+
* {@link queryCadrePeers} excludes them: `OwnerKey` is never reaped (`MinOneOwner` makes
|
|
1137
|
+
* an automated reap able to empty the table), so a node that received the tombstone
|
|
1138
|
+
* without the delete (a removal committed while the remover was alone, re-issued on growth)
|
|
1139
|
+
* keeps the removed owner's physical row next to it. Without the filter a removed owner
|
|
1140
|
+
* would still count as an owner in every membership snapshot and seed-trust read, still
|
|
1141
|
+
* read as the issuer of a live invitation and still be preferred as a dial target. The
|
|
1142
|
+
* write side filters for itself: every owner lookup in the schema disqualifies a row whose
|
|
1143
|
+
* stamp is retired (`schemas/control.qsql` header), so a removed owner cannot sign here
|
|
1144
|
+
* once its tombstone has arrived, row or no row.
|
|
1145
|
+
*
|
|
1146
|
+
* `retry: false` from a caller that reads under the write lock, for the reason on
|
|
1147
|
+
* {@link queryCadrePeers}.
|
|
1148
|
+
*/
|
|
1149
|
+
async getOwnerKeys(retry = true) {
|
|
1150
|
+
return new Set((await this.queryOwnerKeyRows(retry)).map(row => row.key));
|
|
1151
|
+
}
|
|
1152
|
+
/**
|
|
1153
|
+
* Every live `OwnerKey` row with its persisted proof ({@link OwnerKeyRow}) — what the
|
|
1154
|
+
* trusted-owner anchor sync derives from (`CadreNode.syncOwnerAnchor`). Rows whose
|
|
1155
|
+
* `StampId` is retired in `CadreControl.Revocation` are excluded here, as
|
|
1156
|
+
* {@link queryCadrePeers} excludes them and for the reason on {@link getOwnerKeys}, which
|
|
1157
|
+
* reads through this. A row with no stamp (the schema permits it; no writer produces one)
|
|
1158
|
+
* is dropped too: no proof can bind to it, so no reader could ever derive it.
|
|
1159
|
+
*
|
|
1160
|
+
* `retry: false` from the membership-gate refresh, which runs as the control database's
|
|
1161
|
+
* membership listener with that database's write lock held — the reason on
|
|
1162
|
+
* {@link queryCadrePeers}; both reads here take the flag.
|
|
1163
|
+
*
|
|
1164
|
+
* @param retired - the retired `OwnerKey` stamps, when the caller has already read the
|
|
1165
|
+
* tombstones (`CadreNode.syncOwnerAnchor` reads {@link queryRevocations} for their
|
|
1166
|
+
* signers); omitted, they are read here. Before the ledger marker is filed every
|
|
1167
|
+
* `Revocation` read is a cohort consult, so the sync passing its own set keeps the
|
|
1168
|
+
* membership refresh at one such read for the anchor.
|
|
1169
|
+
*/
|
|
1170
|
+
async queryOwnerKeyRows(retry = true, retired) {
|
|
898
1171
|
this.ensureInitialized();
|
|
899
|
-
const
|
|
900
|
-
|
|
901
|
-
|
|
1172
|
+
const revoked = retired ?? await this.queryRevokedStamps('OwnerKey', retry);
|
|
1173
|
+
const rows = [];
|
|
1174
|
+
const sql = 'select Key, StampId, VouchOwner, VouchSig, VouchUsage from CadreControl.OwnerKey';
|
|
1175
|
+
for (const row of await this.readRows(sql, undefined, 'owner-keys', retry)) {
|
|
1176
|
+
const stampId = row.StampId ?? null;
|
|
1177
|
+
if (stampId === null) {
|
|
1178
|
+
log('queryOwnerKeyRows: dropping OwnerKey row %s with no StampId', row.Key);
|
|
1179
|
+
continue;
|
|
1180
|
+
}
|
|
1181
|
+
if (revoked.has(stampId)) {
|
|
1182
|
+
continue;
|
|
1183
|
+
}
|
|
1184
|
+
rows.push({
|
|
1185
|
+
key: row.Key,
|
|
1186
|
+
stampId,
|
|
1187
|
+
vouchOwner: row.VouchOwner ?? null,
|
|
1188
|
+
vouchSig: row.VouchSig ?? null,
|
|
1189
|
+
vouchUsage: row.VouchUsage ?? null,
|
|
1190
|
+
});
|
|
902
1191
|
}
|
|
903
|
-
return
|
|
1192
|
+
return rows;
|
|
904
1193
|
}
|
|
905
1194
|
/**
|
|
906
1195
|
* Enumerate the CadrePeer rows (cadre membership) for admin/membership reads.
|
|
@@ -942,21 +1231,43 @@ export class ControlDatabase {
|
|
|
942
1231
|
this.ensureInitialized();
|
|
943
1232
|
const revoked = await this.queryRevokedStamps('CadrePeer', retry);
|
|
944
1233
|
const rows = [];
|
|
945
|
-
|
|
1234
|
+
// The proof columns lead and the record columns follow, so this scan keeps a prefix no
|
|
1235
|
+
// other `CadrePeer` statement shares (`queryPeerRecord` starts `PeerId, PublicKey`): the
|
|
1236
|
+
// read-retry spec rigs the membership scan by that prefix.
|
|
1237
|
+
const sql = 'select PeerId, Multiaddr, StampId, VouchOwner, VouchSig, VouchUsage, PublicKey, UpdatedAt, Profile, Sig from CadreControl.CadrePeer';
|
|
1238
|
+
for (const row of await this.readRows(sql, undefined, 'cadre-peers', retry)) {
|
|
946
1239
|
const stampId = row.StampId ?? null;
|
|
947
1240
|
if (stampId !== null && revoked.has(stampId)) {
|
|
948
1241
|
continue;
|
|
949
1242
|
}
|
|
950
1243
|
rows.push({
|
|
951
1244
|
peerId: row.PeerId,
|
|
1245
|
+
publicKey: row.PublicKey ?? null,
|
|
952
1246
|
multiaddr: row.Multiaddr ?? null,
|
|
1247
|
+
updatedAt: row.UpdatedAt ?? 0,
|
|
1248
|
+
profile: row.Profile ?? null,
|
|
1249
|
+
sig: row.Sig ?? null,
|
|
953
1250
|
stampId,
|
|
954
1251
|
vouchOwner: row.VouchOwner ?? null,
|
|
955
1252
|
vouchSig: row.VouchSig ?? null,
|
|
1253
|
+
vouchUsage: row.VouchUsage ?? null,
|
|
956
1254
|
});
|
|
957
1255
|
}
|
|
958
1256
|
return rows;
|
|
959
1257
|
}
|
|
1258
|
+
/**
|
|
1259
|
+
* The cadre's peers as a seed or a redemption reply carries them: every live `CadrePeer`
|
|
1260
|
+
* row with its addresses, each flagged `isOwner` when the key behind its peer id is in
|
|
1261
|
+
* the replicated `OwnerKey` table ({@link projectSeedPeers}).
|
|
1262
|
+
*
|
|
1263
|
+
* Read through {@link queryCadrePeers}, never a raw `CadrePeer` select, so a removed
|
|
1264
|
+
* member's addresses are never packed into a seed or handed to a redeeming device as a
|
|
1265
|
+
* dial hint: a revoked peer is off the addressable surface everywhere, and this is one of
|
|
1266
|
+
* its exits.
|
|
1267
|
+
*/
|
|
1268
|
+
async querySeedPeers() {
|
|
1269
|
+
return projectSeedPeers(await this.queryCadrePeers(), await this.getOwnerKeys());
|
|
1270
|
+
}
|
|
960
1271
|
/**
|
|
961
1272
|
* Every approver public key currently enrolled in `CadreControl.ValidationKey` — the
|
|
962
1273
|
* set a `ValidationUrl` redemption's approval signature must be signed by (see
|
|
@@ -1136,22 +1447,26 @@ export class ControlDatabase {
|
|
|
1136
1447
|
}
|
|
1137
1448
|
}
|
|
1138
1449
|
/**
|
|
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
|
|
1450
|
+
* Every locally-held `CadreControl.Revocation` tombstone — identity triple, its
|
|
1451
|
+
* `ReissuedAt` counter and the stored signer pair. Consumed by the cohort-growth re-issue
|
|
1452
|
+
* sweep, which enumerates what this node holds before {@link reissueRevocations}
|
|
1453
|
+
* re-broadcasts it, and by the reap sweep ({@link reapRevokedRows}). Plain scan with no
|
|
1454
|
+
* `where`, so the composite-primary-key point-lookup hazard (see the statement comment in
|
|
1144
1455
|
* {@link reissueRevocations}) does not arise. Unlocked, like every other read.
|
|
1145
1456
|
*
|
|
1146
1457
|
* Skips the ledger marker ({@link REVOCATION_LEDGER_MARKER}): it retires nothing, so
|
|
1147
1458
|
* neither sweep may reap or re-sign it, and skipping it keeps `RevocationRow.tableName`
|
|
1148
1459
|
* a {@link RevocableTable}. Filtered on `TableName` alone, in TypeScript: `RowIsGone`
|
|
1149
1460
|
* admits no other row under `'Revocation'`.
|
|
1461
|
+
*
|
|
1462
|
+
* `retry: false` from a caller that reads under the write lock, for the reason on
|
|
1463
|
+
* {@link queryCadrePeers}.
|
|
1150
1464
|
*/
|
|
1151
|
-
async queryRevocations() {
|
|
1465
|
+
async queryRevocations(retry = true) {
|
|
1152
1466
|
this.ensureInitialized();
|
|
1153
1467
|
const rows = [];
|
|
1154
|
-
|
|
1468
|
+
const sql = 'select TableName, RowKey, StampId, ReissuedAt, SignerKey, SignerSig from CadreControl.Revocation';
|
|
1469
|
+
for (const row of await this.readRows(sql, undefined, 'revocations', retry)) {
|
|
1155
1470
|
if (row.TableName === REVOCATION_LEDGER_MARKER.tableName) {
|
|
1156
1471
|
continue;
|
|
1157
1472
|
}
|
|
@@ -1160,6 +1475,8 @@ export class ControlDatabase {
|
|
|
1160
1475
|
rowKey: row.RowKey,
|
|
1161
1476
|
stampId: row.StampId,
|
|
1162
1477
|
reissuedAt: row.ReissuedAt ?? 0,
|
|
1478
|
+
signerKey: row.SignerKey,
|
|
1479
|
+
signerSig: row.SignerSig,
|
|
1163
1480
|
});
|
|
1164
1481
|
}
|
|
1165
1482
|
return rows;
|
|
@@ -1185,7 +1502,7 @@ export class ControlDatabase {
|
|
|
1185
1502
|
async queryPeerRecord(peerId) {
|
|
1186
1503
|
this.ensureInitialized();
|
|
1187
1504
|
const revoked = await this.queryRevokedStamps('CadrePeer');
|
|
1188
|
-
for (const row of await this.readRows('select PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId from CadreControl.CadrePeer where PeerId = ?', [peerId], 'peer-record')) {
|
|
1505
|
+
for (const row of await this.readRows('select PeerId, PublicKey, Multiaddr, UpdatedAt, Profile, Sig, StampId from CadreControl.CadrePeer where PeerId = ?', [peerId], 'peer-record')) {
|
|
1189
1506
|
const stampId = row.StampId ?? null;
|
|
1190
1507
|
if (stampId !== null && revoked.has(stampId)) {
|
|
1191
1508
|
return null;
|
|
@@ -1196,6 +1513,7 @@ export class ControlDatabase {
|
|
|
1196
1513
|
publicKey: row.PublicKey ?? '',
|
|
1197
1514
|
addrs: multiaddr.length > 0 ? multiaddr.split(',') : [],
|
|
1198
1515
|
updatedAt: row.UpdatedAt ?? 0,
|
|
1516
|
+
profile: row.Profile ?? null,
|
|
1199
1517
|
sig: row.Sig ?? '',
|
|
1200
1518
|
};
|
|
1201
1519
|
}
|
|
@@ -1227,12 +1545,12 @@ export class ControlDatabase {
|
|
|
1227
1545
|
async updateSelfPeerRecord(record) {
|
|
1228
1546
|
this.ensureInitialized();
|
|
1229
1547
|
const multiaddr = record.addrs.join(',');
|
|
1230
|
-
await this.execWrite(`
|
|
1231
|
-
update CadreControl.CadrePeer
|
|
1232
|
-
with context OwnerKey = null, Signature = ?
|
|
1233
|
-
set Multiaddr = ?, UpdatedAt = ?, Sig = ?
|
|
1234
|
-
where PeerId = ?
|
|
1235
|
-
`, [record.sig, multiaddr, record.updatedAt, record.sig, record.peerId], 'self-record-update');
|
|
1548
|
+
await this.execWrite(`
|
|
1549
|
+
update CadreControl.CadrePeer
|
|
1550
|
+
with context OwnerKey = null, Signature = ?
|
|
1551
|
+
set Multiaddr = ?, UpdatedAt = ?, Profile = ?, Sig = ?
|
|
1552
|
+
where PeerId = ?
|
|
1553
|
+
`, [record.sig, multiaddr, record.updatedAt, record.profile, record.sig, record.peerId], 'self-record-update');
|
|
1236
1554
|
log('Self peer record updated: %s (updatedAt=%d)', record.peerId, record.updatedAt);
|
|
1237
1555
|
}
|
|
1238
1556
|
/**
|
|
@@ -1275,11 +1593,11 @@ export class ControlDatabase {
|
|
|
1275
1593
|
*/
|
|
1276
1594
|
async updateSelfDeviceToken(record) {
|
|
1277
1595
|
this.ensureInitialized();
|
|
1278
|
-
await this.execWrite(`
|
|
1279
|
-
update CadreControl.DeviceToken
|
|
1280
|
-
with context OwnerKey = null, Signature = ?
|
|
1281
|
-
set Platform = ?, Token = ?, UpdatedAt = ?, Sig = ?
|
|
1282
|
-
where PeerId = ?
|
|
1596
|
+
await this.execWrite(`
|
|
1597
|
+
update CadreControl.DeviceToken
|
|
1598
|
+
with context OwnerKey = null, Signature = ?
|
|
1599
|
+
set Platform = ?, Token = ?, UpdatedAt = ?, Sig = ?
|
|
1600
|
+
where PeerId = ?
|
|
1283
1601
|
`, [record.sig, record.platform, record.token, record.updatedAt, record.sig, record.peerId], 'device-token-update');
|
|
1284
1602
|
log('Self device token updated: %s (platform=%s, updatedAt=%d)', record.peerId, record.platform, record.updatedAt);
|
|
1285
1603
|
}
|
|
@@ -1291,20 +1609,241 @@ export class ControlDatabase {
|
|
|
1291
1609
|
log('Inserting owner key: %s', key);
|
|
1292
1610
|
// Bootstrap is authorized by the schema's genesis branch — `(select count(1) from
|
|
1293
1611
|
// 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
|
-
//
|
|
1612
|
+
// signature is needed. Every other branch of `OwnerKey.Authorized` requires a PRE-EXISTING,
|
|
1613
|
+
// unretired owner, so this method only ever succeeds on a fresh party; a second
|
|
1614
|
+
// owner is seated by {@link insertOwnerKeyVouched} and removed by {@link deleteOwnerKey}.
|
|
1615
|
+
// We still persist a fresh, unique StampId in the row's own column to satisfy the
|
|
1616
|
+
// not-null/unique anti-replay constraint — the StampId is a real column value, not the
|
|
1617
|
+
// optimystic `StampId()` SQL function.
|
|
1300
1618
|
const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
|
|
1301
|
-
await this.execWrite(`
|
|
1302
|
-
insert into CadreControl.OwnerKey (Key, StampId)
|
|
1303
|
-
with context OwnerKey = null, Signature = null
|
|
1304
|
-
values (?, ?)
|
|
1619
|
+
await this.execWrite(`
|
|
1620
|
+
insert into CadreControl.OwnerKey (Key, StampId)
|
|
1621
|
+
with context OwnerKey = null, Signature = null
|
|
1622
|
+
values (?, ?)
|
|
1305
1623
|
`, [key, stampId], 'owner-key-insert');
|
|
1306
1624
|
log('Owner key inserted');
|
|
1307
1625
|
}
|
|
1626
|
+
/**
|
|
1627
|
+
* Owner-signed INSERT of a further `OwnerKey` row, notifying the membership listener once
|
|
1628
|
+
* it has committed. The signed-add branch of `OwnerKey.Authorized`: the signer must be an
|
|
1629
|
+
* owner in the pre-transaction snapshot, its signature covers the `'add'` digest over
|
|
1630
|
+
* (Key, StampId), and the stored `VouchOwner`/`VouchSig` are that same pair, so a reader
|
|
1631
|
+
* can later judge the add against its own anchor (`verifyOwnerKeyVoucher`).
|
|
1632
|
+
*
|
|
1633
|
+
* Through {@link mutateCadrePeer}, the membership hub. The liveness read, the stamp and the
|
|
1634
|
+
* signature all sit inside the locked body: a {@link lockedWithRetry} re-run starts over a
|
|
1635
|
+
* rolled-back attempt with a fresh stamp, as {@link redeemCadreInvite} does.
|
|
1636
|
+
*
|
|
1637
|
+
* NOTE: re-adding a removed owner from a machine that still holds its retired physical row
|
|
1638
|
+
* is refused here by name: `OwnerKey` is not reaped, so a machine that received the
|
|
1639
|
+
* tombstone without the delete (see {@link getOwnerKeys}) keeps the row, and an insert there
|
|
1640
|
+
* would collide on the primary key. Re-add from the machine that removed the key, or any
|
|
1641
|
+
* machine the delete reached; if re-adding removed owners becomes routine, the fix is an
|
|
1642
|
+
* `OwnerKey` reap branch, gated so it can never take the table below `MinOneOwner`.
|
|
1643
|
+
*
|
|
1644
|
+
* @returns `true` when this call seated the row, `false` when `key` is already a live owner
|
|
1645
|
+
* here (nothing written).
|
|
1646
|
+
* @throws when this node holds `key`'s row under a retired stamp (above), or when the
|
|
1647
|
+
* schema refuses the signer.
|
|
1648
|
+
*/
|
|
1649
|
+
async insertOwnerKeyVouched(key, ownerKey, signMessage) {
|
|
1650
|
+
this.ensureInitialized();
|
|
1651
|
+
return await this.mutateCadrePeer('owner-key-add', async () => {
|
|
1652
|
+
// retry: false on both reads — inside the locked write body (see queryStampId's NOTE).
|
|
1653
|
+
const heldStamp = await this.queryStampId('OwnerKey', key, false);
|
|
1654
|
+
if (heldStamp !== null) {
|
|
1655
|
+
if (!(await this.queryRevokedStamps('OwnerKey', false)).has(heldStamp)) {
|
|
1656
|
+
log('insertOwnerKeyVouched: %s is already an owner; nothing written', key);
|
|
1657
|
+
return false;
|
|
1658
|
+
}
|
|
1659
|
+
throw new Error(`Cannot re-add owner key ${key} here: this node still holds the row it was removed under ` +
|
|
1660
|
+
'(OwnerKey rows are not reaped); re-add it from the machine that removed it');
|
|
1661
|
+
}
|
|
1662
|
+
const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
|
|
1663
|
+
const signature = signMessage(ownerKeyAddMessage(this.config.partyId, key, stampId));
|
|
1664
|
+
// Bare `exec`: already inside the write lock, which is NOT re-entrant.
|
|
1665
|
+
await this.db.exec(`
|
|
1666
|
+
insert into CadreControl.OwnerKey (Key, StampId, VouchOwner, VouchSig)
|
|
1667
|
+
with context OwnerKey = ?, Signature = ?
|
|
1668
|
+
values (?, ?, ?, ?)
|
|
1669
|
+
`, [ownerKey, signature, key, stampId, ownerKey, signature]);
|
|
1670
|
+
log('Owner key %s added (vouched by %s)', key, ownerKey);
|
|
1671
|
+
return true;
|
|
1672
|
+
});
|
|
1673
|
+
}
|
|
1674
|
+
/**
|
|
1675
|
+
* Owner-signed removal of one `OwnerKey` row plus the `Revocation` tombstone retiring its
|
|
1676
|
+
* stamp, notifying the membership listener once it has committed. The remove branch of
|
|
1677
|
+
* `OwnerKey.Authorized` requires a signer other than the removed key, and `MinOneOwner`
|
|
1678
|
+
* keeps the table from emptying; both are refused by name here first, read from this
|
|
1679
|
+
* node's live owners ({@link getOwnerKeys}), so a caller gets a clear error rather than a
|
|
1680
|
+
* constraint failure. The schema stays the authority: a concurrent removal elsewhere can
|
|
1681
|
+
* make the local count wrong in either direction, and then `MinOneOwner` or the tombstone's
|
|
1682
|
+
* primary key decides (see the NOTE on {@link deleteGuardedRow}).
|
|
1683
|
+
*
|
|
1684
|
+
* Through {@link mutateCadrePeer}, the membership hub, which notifies whenever the body
|
|
1685
|
+
* resolves, the absent-key answer included.
|
|
1686
|
+
*
|
|
1687
|
+
* @returns `true` when the row was removed, `false` when `key` is not a live owner here
|
|
1688
|
+
* (nothing written, no tombstone).
|
|
1689
|
+
* @throws when `key` is `ownerKey`, or is the only live owner.
|
|
1690
|
+
*/
|
|
1691
|
+
async deleteOwnerKey(key, ownerKey, signMessage) {
|
|
1692
|
+
if (key === ownerKey) {
|
|
1693
|
+
throw new Error(`Cannot remove owner key ${key}: an owner cannot remove itself`);
|
|
1694
|
+
}
|
|
1695
|
+
return await this.mutateCadrePeer('owner-key-remove', async () => {
|
|
1696
|
+
// retry: false — inside the locked write body (see queryStampId's NOTE).
|
|
1697
|
+
const owners = await this.getOwnerKeys(false);
|
|
1698
|
+
if (!owners.has(key)) {
|
|
1699
|
+
log('deleteOwnerKey: %s is not a live owner here (already absent)', key);
|
|
1700
|
+
return false;
|
|
1701
|
+
}
|
|
1702
|
+
if (owners.size <= 1) {
|
|
1703
|
+
throw new Error(`Cannot remove owner key ${key}: cannot remove the last owner`);
|
|
1704
|
+
}
|
|
1705
|
+
return await this.deleteGuardedRow('OwnerKey', key, ownerKey, signMessage);
|
|
1706
|
+
});
|
|
1707
|
+
}
|
|
1708
|
+
/**
|
|
1709
|
+
* Re-vouch a live `OwnerKey` row under this owner's signature: in ONE transaction, the
|
|
1710
|
+
* current row's guarded delete plus tombstone ({@link deleteOwnerKey}'s body) and an insert
|
|
1711
|
+
* of the same key under a fresh stamp, signed by `ownerKey` ({@link insertOwnerKeyVouched}'s
|
|
1712
|
+
* body), as {@link rewritePendingJoin} re-seats a request. `OwnerKey` has `NoUpdate`, so
|
|
1713
|
+
* this is the only way to rebind a row's voucher; the anchor derivation lets the re-add
|
|
1714
|
+
* under a fresh stamp beat the older tombstone (`deriveOwnerAnchor`). For
|
|
1715
|
+
* {@link removeMachine}: the owners a machine being removed had added keep their anchor
|
|
1716
|
+
* place everywhere once their row stands on a remaining owner's signature.
|
|
1717
|
+
*
|
|
1718
|
+
* Through {@link mutateCadrePeer}, the membership hub. The stamp read runs inside the locked
|
|
1719
|
+
* body; a row whose stamp is already retired (held without its delete) is reported as absent
|
|
1720
|
+
* rather than tombstoned twice.
|
|
1721
|
+
*
|
|
1722
|
+
* @returns `true` when the row was re-vouched, `false` when `key` is not a live owner here.
|
|
1723
|
+
* @throws when `key` is `ownerKey` (an owner cannot sign its own row's removal).
|
|
1724
|
+
*/
|
|
1725
|
+
async revouchOwnerKey(key, ownerKey, signMessage) {
|
|
1726
|
+
if (key === ownerKey) {
|
|
1727
|
+
throw new Error(`Cannot re-vouch owner key ${key}: an owner cannot re-vouch itself`);
|
|
1728
|
+
}
|
|
1729
|
+
return await this.mutateCadrePeer('owner-key-revouch', async () => {
|
|
1730
|
+
// retry: false on both reads — inside the locked write body (see queryStampId's NOTE).
|
|
1731
|
+
const stampId = await this.queryStampId('OwnerKey', key, false);
|
|
1732
|
+
if (stampId === null || (await this.queryRevokedStamps('OwnerKey', false)).has(stampId)) {
|
|
1733
|
+
log('revouchOwnerKey: %s is not a live owner here (nothing to re-vouch)', key);
|
|
1734
|
+
return false;
|
|
1735
|
+
}
|
|
1736
|
+
const removal = signGuardedRemoval(this.config.partyId, 'OwnerKey', key, stampId, signMessage);
|
|
1737
|
+
const freshStampId = generateStampId(this.config.libp2pNode.peerId.toString());
|
|
1738
|
+
const signature = signMessage(ownerKeyAddMessage(this.config.partyId, key, freshStampId));
|
|
1739
|
+
await this.inTransaction('revouch OwnerKey', async () => {
|
|
1740
|
+
await this.execGuardedRemoval(removal, ownerKey);
|
|
1741
|
+
await this.db.exec(`
|
|
1742
|
+
insert into CadreControl.OwnerKey (Key, StampId, VouchOwner, VouchSig)
|
|
1743
|
+
with context OwnerKey = ?, Signature = ?
|
|
1744
|
+
values (?, ?, ?, ?)
|
|
1745
|
+
`, [ownerKey, signature, key, freshStampId, ownerKey, signature]);
|
|
1746
|
+
});
|
|
1747
|
+
log('Owner key %s re-vouched by %s (stamp %s retired)', key, ownerKey, stampId);
|
|
1748
|
+
this.notifyGuardedDelete(removal.ref);
|
|
1749
|
+
return true;
|
|
1750
|
+
});
|
|
1751
|
+
}
|
|
1752
|
+
/**
|
|
1753
|
+
* Remove one machine completely, in ONE transaction: its `CadrePeer` row, its `OwnerKey`
|
|
1754
|
+
* row when `target.ownerKey` names one, its `DeviceToken` row when it has one, each with
|
|
1755
|
+
* the `Revocation` tombstone retiring its stamp ({@link deleteGuardedRow}'s statement pair,
|
|
1756
|
+
* composed bare as {@link deleteStrandAndPartyKey} composes it), plus a withdrawal tombstone
|
|
1757
|
+
* for every `CadreInvite` in `target.inviteKeys` that is still live here
|
|
1758
|
+
* ({@link withdrawCadreInvite}'s body). One transaction so no reader sees the machine half
|
|
1759
|
+
* removed: a row gone but its push token resolvable, or its owner key gone but an invitation
|
|
1760
|
+
* it could redeem again still open.
|
|
1761
|
+
*
|
|
1762
|
+
* Through {@link mutateCadrePeer}, the membership hub. Every stamp is read inside the locked
|
|
1763
|
+
* body, and a row whose stamp is already retired (held without its delete) is skipped rather
|
|
1764
|
+
* than tombstoned twice. The by-name owner refusals of {@link deleteOwnerKey} apply to the
|
|
1765
|
+
* owner-key arm. Nothing to remove writes nothing and returns all false.
|
|
1766
|
+
*
|
|
1767
|
+
* The caller decides WHAT to remove, which owner key is the machine's and which invitations
|
|
1768
|
+
* it could use, from its plan (`CadreNode.planMachineRemoval`); this method only refuses to
|
|
1769
|
+
* write an inconsistent set.
|
|
1770
|
+
*
|
|
1771
|
+
* @throws when `target.ownerKey` is `ownerKey`, or is the only live owner here.
|
|
1772
|
+
*/
|
|
1773
|
+
async removeMachine(target, ownerKey, signMessage) {
|
|
1774
|
+
if (target.ownerKey === ownerKey) {
|
|
1775
|
+
throw new Error(`Cannot remove owner key ${ownerKey}: an owner cannot remove itself`);
|
|
1776
|
+
}
|
|
1777
|
+
return await this.mutateCadrePeer('machine-remove', async () => {
|
|
1778
|
+
const removals = await this.signMachineRemovals(target, signMessage);
|
|
1779
|
+
const withdrawals = await this.signCadreInviteWithdrawals(target.inviteKeys, signMessage);
|
|
1780
|
+
if (removals.length === 0 && withdrawals.length === 0) {
|
|
1781
|
+
log('removeMachine: nothing live names %s here (already removed)', target.peerId);
|
|
1782
|
+
return { removedPeer: false, removedOwnerKey: false, removedDeviceToken: false, withdrawnInvitations: [] };
|
|
1783
|
+
}
|
|
1784
|
+
await this.inTransaction('remove machine', async () => {
|
|
1785
|
+
for (const removal of removals) {
|
|
1786
|
+
await this.execGuardedRemoval(removal, ownerKey);
|
|
1787
|
+
}
|
|
1788
|
+
for (const withdrawal of withdrawals) {
|
|
1789
|
+
await this.execTombstone(withdrawal.ref, withdrawal.signature, ownerKey);
|
|
1790
|
+
}
|
|
1791
|
+
});
|
|
1792
|
+
const removed = new Set(removals.map((removal) => removal.ref.tableName));
|
|
1793
|
+
log('Machine %s removed: %s; %d invitation(s) withdrawn', target.peerId, [...removed].join(', ') || 'no row', withdrawals.length);
|
|
1794
|
+
for (const removal of removals) {
|
|
1795
|
+
this.notifyGuardedDelete(removal.ref);
|
|
1796
|
+
}
|
|
1797
|
+
for (const withdrawal of withdrawals) {
|
|
1798
|
+
this.notifyGuardedDelete(withdrawal.ref);
|
|
1799
|
+
}
|
|
1800
|
+
return {
|
|
1801
|
+
removedPeer: removed.has('CadrePeer'),
|
|
1802
|
+
removedOwnerKey: removed.has('OwnerKey'),
|
|
1803
|
+
removedDeviceToken: removed.has('DeviceToken'),
|
|
1804
|
+
withdrawnInvitations: withdrawals.map((withdrawal) => withdrawal.ref.rowKey),
|
|
1805
|
+
};
|
|
1806
|
+
});
|
|
1807
|
+
}
|
|
1808
|
+
/**
|
|
1809
|
+
* The guarded removals {@link removeMachine} runs, signed over each row's current stamp:
|
|
1810
|
+
* the `CadrePeer` row, the `OwnerKey` row when named, the `DeviceToken` row when present.
|
|
1811
|
+
* Inside the locked body (every read `retry: false`); one `Revocation` scan serves the
|
|
1812
|
+
* retired-stamp check for all three tables and the last-owner refusal.
|
|
1813
|
+
*/
|
|
1814
|
+
async signMachineRemovals(target, signMessage) {
|
|
1815
|
+
const retiredByTable = new Map();
|
|
1816
|
+
for (const tombstone of await this.queryRevocations(false)) {
|
|
1817
|
+
const stamps = retiredByTable.get(tombstone.tableName) ?? new Set();
|
|
1818
|
+
stamps.add(tombstone.stampId);
|
|
1819
|
+
retiredByTable.set(tombstone.tableName, stamps);
|
|
1820
|
+
}
|
|
1821
|
+
const retired = (table) => retiredByTable.get(table) ?? new Set();
|
|
1822
|
+
const liveStamp = async (table, keyValue) => {
|
|
1823
|
+
const stampId = await this.queryStampId(table, keyValue, false);
|
|
1824
|
+
return stampId !== null && !retired(table).has(stampId) ? stampId : null;
|
|
1825
|
+
};
|
|
1826
|
+
const removals = [];
|
|
1827
|
+
const peerStamp = await liveStamp('CadrePeer', target.peerId);
|
|
1828
|
+
if (peerStamp !== null) {
|
|
1829
|
+
removals.push(signGuardedRemoval(this.config.partyId, 'CadrePeer', target.peerId, peerStamp, signMessage));
|
|
1830
|
+
}
|
|
1831
|
+
if (target.ownerKey !== null) {
|
|
1832
|
+
const ownerStamp = await liveStamp('OwnerKey', target.ownerKey);
|
|
1833
|
+
if (ownerStamp !== null) {
|
|
1834
|
+
const owners = await this.queryOwnerKeyRows(false, retired('OwnerKey'));
|
|
1835
|
+
if (owners.length <= 1) {
|
|
1836
|
+
throw new Error(`Cannot remove owner key ${target.ownerKey}: cannot remove the last owner`);
|
|
1837
|
+
}
|
|
1838
|
+
removals.push(signGuardedRemoval(this.config.partyId, 'OwnerKey', target.ownerKey, ownerStamp, signMessage));
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
const tokenStamp = await liveStamp('DeviceToken', target.peerId);
|
|
1842
|
+
if (tokenStamp !== null) {
|
|
1843
|
+
removals.push(signGuardedRemoval(this.config.partyId, 'DeviceToken', target.peerId, tokenStamp, signMessage));
|
|
1844
|
+
}
|
|
1845
|
+
return removals;
|
|
1846
|
+
}
|
|
1308
1847
|
/**
|
|
1309
1848
|
* Insert a strand into the control database using an owner signature.
|
|
1310
1849
|
*
|
|
@@ -1334,14 +1873,14 @@ export class ControlDatabase {
|
|
|
1334
1873
|
// Id, Type, MemberPrivateKey ('' when null), StampId. FounderOwnerKey is
|
|
1335
1874
|
// deliberately NOT in the digest — the schema binds it by equality to the
|
|
1336
1875
|
// verified context.OwnerKey instead (see the constraint's comment).
|
|
1337
|
-
const message = buildAuthorizationMessage('CadreControl.Strand', 'add', [strandId, type, memberPrivateKey ?? '', stampId]);
|
|
1876
|
+
const message = buildAuthorizationMessage('CadreControl.Strand', 'add', this.config.partyId, [strandId, type, memberPrivateKey ?? '', stampId]);
|
|
1338
1877
|
const signature = signMessage(message);
|
|
1339
1878
|
// StampId is a real, unique column (single-use anti-replay), no longer a context value.
|
|
1340
1879
|
// FounderOwnerKey must equal the signing owner or the constraint rejects the row.
|
|
1341
|
-
await this.execWrite(`
|
|
1342
|
-
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey)
|
|
1343
|
-
with context OwnerKey = ?, Signature = ?
|
|
1344
|
-
values (?, ?, ?, ?, ?)
|
|
1880
|
+
await this.execWrite(`
|
|
1881
|
+
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey)
|
|
1882
|
+
with context OwnerKey = ?, Signature = ?
|
|
1883
|
+
values (?, ?, ?, ?, ?)
|
|
1345
1884
|
`, [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId, ownerKey], 'strand-insert');
|
|
1346
1885
|
log('Strand inserted: %s', strandId);
|
|
1347
1886
|
}
|
|
@@ -1396,10 +1935,10 @@ export class ControlDatabase {
|
|
|
1396
1935
|
return false;
|
|
1397
1936
|
}
|
|
1398
1937
|
const partyKeyStamp = await this.queryStampId('StrandPartyKey', strandId, false);
|
|
1399
|
-
const strandRemoval = signGuardedRemoval('Strand', strandId, strandStamp, signMessage);
|
|
1938
|
+
const strandRemoval = signGuardedRemoval(this.config.partyId, 'Strand', strandId, strandStamp, signMessage);
|
|
1400
1939
|
const partyKeyRemoval = partyKeyStamp === null
|
|
1401
1940
|
? null
|
|
1402
|
-
: signGuardedRemoval('StrandPartyKey', strandId, partyKeyStamp, signMessage);
|
|
1941
|
+
: signGuardedRemoval(this.config.partyId, 'StrandPartyKey', strandId, partyKeyStamp, signMessage);
|
|
1403
1942
|
await this.inTransaction('delete Strand', async () => {
|
|
1404
1943
|
await this.execGuardedRemoval(strandRemoval, ownerKey);
|
|
1405
1944
|
if (partyKeyRemoval !== null) {
|
|
@@ -1436,12 +1975,12 @@ export class ControlDatabase {
|
|
|
1436
1975
|
const stampId = generateStampId(peerId);
|
|
1437
1976
|
// Field order MUST match the schema's StrandPartyKey `AuthorizedInsert` verify:
|
|
1438
1977
|
// Id, PrivateKey, StampId.
|
|
1439
|
-
const message = buildAuthorizationMessage('CadreControl.StrandPartyKey', 'add', [strandId, privateKey, stampId]);
|
|
1978
|
+
const message = buildAuthorizationMessage('CadreControl.StrandPartyKey', 'add', this.config.partyId, [strandId, privateKey, stampId]);
|
|
1440
1979
|
const signature = signMessage(message);
|
|
1441
|
-
await this.execWrite(`
|
|
1442
|
-
insert into CadreControl.StrandPartyKey (Id, PrivateKey, StampId)
|
|
1443
|
-
with context OwnerKey = ?, Signature = ?
|
|
1444
|
-
values (?, ?, ?)
|
|
1980
|
+
await this.execWrite(`
|
|
1981
|
+
insert into CadreControl.StrandPartyKey (Id, PrivateKey, StampId)
|
|
1982
|
+
with context OwnerKey = ?, Signature = ?
|
|
1983
|
+
values (?, ?, ?)
|
|
1445
1984
|
`, [ownerKey, signature, strandId, privateKey, stampId], 'strand-party-key-insert');
|
|
1446
1985
|
log('Strand party key inserted for strand: %s', strandId);
|
|
1447
1986
|
}
|
|
@@ -1478,12 +2017,12 @@ export class ControlDatabase {
|
|
|
1478
2017
|
const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
|
|
1479
2018
|
// Field order MUST match the schema's JoinedStrand `AuthorizedInsert` verify:
|
|
1480
2019
|
// Id, Type, MemberPrivateKey ('' when null), StampId.
|
|
1481
|
-
const message = buildAuthorizationMessage('CadreControl.JoinedStrand', 'add', [row.Id, row.Type, row.MemberPrivateKey ?? '', stampId]);
|
|
2020
|
+
const message = buildAuthorizationMessage('CadreControl.JoinedStrand', 'add', this.config.partyId, [row.Id, row.Type, row.MemberPrivateKey ?? '', stampId]);
|
|
1482
2021
|
const signature = signMessage(message);
|
|
1483
|
-
await this.execWrite(`
|
|
1484
|
-
insert into CadreControl.JoinedStrand (Id, Type, MemberPrivateKey, StampId)
|
|
1485
|
-
with context OwnerKey = ?, Signature = ?
|
|
1486
|
-
values (?, ?, ?, ?)
|
|
2022
|
+
await this.execWrite(`
|
|
2023
|
+
insert into CadreControl.JoinedStrand (Id, Type, MemberPrivateKey, StampId)
|
|
2024
|
+
with context OwnerKey = ?, Signature = ?
|
|
2025
|
+
values (?, ?, ?, ?)
|
|
1487
2026
|
`, [ownerKey, signature, row.Id, row.Type, row.MemberPrivateKey, stampId], 'joined-strand-insert');
|
|
1488
2027
|
log('Joined strand inserted: %s', row.Id);
|
|
1489
2028
|
}
|
|
@@ -1497,76 +2036,124 @@ export class ControlDatabase {
|
|
|
1497
2036
|
return this.lockedWithRetry(() => this.deleteGuardedRow('JoinedStrand', strandId, ownerKey, signMessage), {}, 'joined-strand-delete');
|
|
1498
2037
|
}
|
|
1499
2038
|
/**
|
|
1500
|
-
* Record, party-wide, a join this party asked for
|
|
1501
|
-
*
|
|
1502
|
-
* row as written, with the fresh `StampId` a later {@link replacePendingJoin} names.
|
|
2039
|
+
* Record, party-wide, a join this party asked for: a `JoinRequest` row under a fresh stamp,
|
|
2040
|
+
* owner-signed over every column. Returns the pending join as written.
|
|
1503
2041
|
*
|
|
1504
|
-
* Fails with a `
|
|
1505
|
-
* this invitation; {@link isPendingJoinConflict} identifies that case.
|
|
2042
|
+
* Fails with a `JoinRequest.Id` uniqueness violation when the party already holds a request
|
|
2043
|
+
* for this invitation; {@link isPendingJoinConflict} identifies that case.
|
|
1506
2044
|
*/
|
|
1507
|
-
async
|
|
2045
|
+
async insertJoinRequest(request, ownerKey, signMessage) {
|
|
1508
2046
|
this.ensureInitialized();
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
return written;
|
|
2047
|
+
const { written, insert } = this.signJoinRequestInsert(request, ownerKey, signMessage);
|
|
2048
|
+
await this.execWrite(insert.sql, insert.params, 'join-request-insert');
|
|
2049
|
+
log('Join request inserted: %s', request.Id);
|
|
2050
|
+
return { ...written, outcome: null };
|
|
1514
2051
|
}
|
|
1515
2052
|
/**
|
|
1516
|
-
*
|
|
1517
|
-
*
|
|
2053
|
+
* Record the outcome of the pending join `expected`: its owner-signed `JoinSuccess` /
|
|
2054
|
+
* `JoinFailure` row, keyed by the request's stamp. A join recorded over a failure deletes the
|
|
2055
|
+
* failure row in the same transaction (the schema authorizes that delete by the join, and
|
|
2056
|
+
* leaves the one-outcome rule in that direction to this writer). Returns the pending join as
|
|
2057
|
+
* written.
|
|
1518
2058
|
*
|
|
1519
|
-
*
|
|
1520
|
-
*
|
|
1521
|
-
*
|
|
1522
|
-
*
|
|
1523
|
-
*
|
|
1524
|
-
|
|
1525
|
-
|
|
2059
|
+
* The live join must still be what the caller read: the same request incarnation with the
|
|
2060
|
+
* same outcome kind. Otherwise this throws {@link PendingJoinChangedError} and writes nothing.
|
|
2061
|
+
* That check is local; a sibling machine's write landing between it and the commit fails the
|
|
2062
|
+
* transaction instead (its outcome row collides on the primary key, or its tombstone retires
|
|
2063
|
+
* the stamp), and the caller re-reads either way.
|
|
2064
|
+
*/
|
|
2065
|
+
async recordJoinOutcome(expected, outcome, ownerKey, signMessage) {
|
|
2066
|
+
this.ensureInitialized();
|
|
2067
|
+
const insert = signJoinOutcomeInsert(this.config.partyId, expected.StampId, outcome, ownerKey, signMessage);
|
|
2068
|
+
return this.lockedWithRetry(async () => {
|
|
2069
|
+
const live = await this.readPendingJoin(expected.Id, false);
|
|
2070
|
+
if (live === null || live.StampId !== expected.StampId || live.outcome?.kind !== expected.outcome?.kind) {
|
|
2071
|
+
throw new PendingJoinChangedError(expected.Id, live);
|
|
2072
|
+
}
|
|
2073
|
+
await this.inTransaction('record join outcome', async () => {
|
|
2074
|
+
if (live.outcome?.kind === 'failed') {
|
|
2075
|
+
await this.deleteJoinOutcome('JoinFailure', expected.StampId);
|
|
2076
|
+
}
|
|
2077
|
+
await this.db.exec(insert.sql, insert.params);
|
|
2078
|
+
});
|
|
2079
|
+
log('Join outcome recorded: %s (%s)', expected.Id, outcome.kind);
|
|
2080
|
+
return { ...live, outcome };
|
|
2081
|
+
}, {}, 'join-outcome-record');
|
|
2082
|
+
}
|
|
2083
|
+
/**
|
|
2084
|
+
* Re-write the pending join `next` (request and outcome) under a fresh stamp, retiring the
|
|
2085
|
+
* live request incarnation `expectedStampId`: how a finished request is asked for again, and
|
|
2086
|
+
* how a join written while this machine was alone is re-broadcast. One locked transaction
|
|
2087
|
+
* deletes the outcome row and the request (owner-signed `'remove'` digest, with its
|
|
2088
|
+
* `Revocation` tombstone) and inserts the successors, so no reader ever sees the id missing in
|
|
2089
|
+
* between. Throws {@link PendingJoinChangedError} and writes nothing when the live request is
|
|
2090
|
+
* not `expectedStampId`.
|
|
1526
2091
|
*
|
|
1527
2092
|
* Signatures and the new stamp are minted before the lock, so a retried attempt re-presents
|
|
1528
2093
|
* 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
|
-
*
|
|
2094
|
+
* that committed but reported failure throws {@link PendingJoinChangedError} whose live join
|
|
2095
|
+
* carries the returned stamp, so a caller that re-reads finds its write.
|
|
1531
2096
|
*/
|
|
1532
|
-
async
|
|
2097
|
+
async rewritePendingJoin(expectedStampId, next, ownerKey, signMessage) {
|
|
1533
2098
|
this.ensureInitialized();
|
|
1534
|
-
const
|
|
1535
|
-
const removal = signGuardedRemoval('
|
|
1536
|
-
const { written,
|
|
2099
|
+
const { outcome, ...request } = next;
|
|
2100
|
+
const removal = signGuardedRemoval(this.config.partyId, 'JoinRequest', next.Id, expectedStampId, signMessage);
|
|
2101
|
+
const { written, insert } = this.signJoinRequestInsert(request, ownerKey, signMessage);
|
|
2102
|
+
const outcomeInsert = outcome === null ? null : signJoinOutcomeInsert(this.config.partyId, written.StampId, outcome, ownerKey, signMessage);
|
|
1537
2103
|
return this.lockedWithRetry(async () => {
|
|
1538
2104
|
// retry: false — this runs inside the locked write body (see queryStampId's NOTE).
|
|
1539
|
-
const liveStampId = await this.queryStampId('
|
|
2105
|
+
const liveStampId = await this.queryStampId('JoinRequest', next.Id, false);
|
|
1540
2106
|
if (liveStampId !== expectedStampId) {
|
|
1541
|
-
throw new PendingJoinChangedError(
|
|
2107
|
+
throw new PendingJoinChangedError(next.Id, await this.readPendingJoin(next.Id, false));
|
|
1542
2108
|
}
|
|
1543
|
-
await this.inTransaction('
|
|
2109
|
+
await this.inTransaction('rewrite pending join', async () => {
|
|
2110
|
+
await this.deleteJoinOutcomes(expectedStampId);
|
|
1544
2111
|
await this.execGuardedRemoval(removal, ownerKey);
|
|
1545
|
-
await this.db.exec(
|
|
2112
|
+
await this.db.exec(insert.sql, insert.params);
|
|
2113
|
+
if (outcomeInsert !== null) {
|
|
2114
|
+
await this.db.exec(outcomeInsert.sql, outcomeInsert.params);
|
|
2115
|
+
}
|
|
1546
2116
|
});
|
|
1547
|
-
log('Pending join
|
|
2117
|
+
log('Pending join rewritten: %s (%s; stamp retired)', next.Id, outcome?.kind ?? 'pending');
|
|
1548
2118
|
this.notifyGuardedDelete(removal.ref);
|
|
1549
|
-
return written;
|
|
1550
|
-
}, {}, 'pending-join-
|
|
2119
|
+
return { ...written, outcome };
|
|
2120
|
+
}, {}, 'pending-join-rewrite');
|
|
1551
2121
|
}
|
|
1552
2122
|
/**
|
|
1553
|
-
* Owner-signed removal of one
|
|
1554
|
-
*
|
|
1555
|
-
*
|
|
1556
|
-
* `true` when
|
|
2123
|
+
* Owner-signed removal of one pending join, as {@link deleteJoinedStrand} removes its row:
|
|
2124
|
+
* `'remove'`-tagged digest over (Id, StampId), `Revocation` tombstone in the same transaction,
|
|
2125
|
+
* and the request's outcome row with it. A no-op (no throw, no tombstone) when there is no
|
|
2126
|
+
* request — `false` then, `true` when one was actually removed.
|
|
1557
2127
|
*/
|
|
1558
2128
|
deletePendingJoin(id, ownerKey, signMessage) {
|
|
1559
|
-
return this.lockedWithRetry(() => this.deleteGuardedRow('
|
|
2129
|
+
return this.lockedWithRetry(() => this.deleteGuardedRow('JoinRequest', id, ownerKey, signMessage, (stampId) => this.deleteJoinOutcomes(stampId)), {}, 'pending-join-delete');
|
|
1560
2130
|
}
|
|
1561
2131
|
/**
|
|
1562
|
-
* Mint a fresh stamp for `
|
|
1563
|
-
* verifies, returning the row as it will be written and
|
|
1564
|
-
* {@link PENDING_JOIN_INSERT_SQL}.
|
|
2132
|
+
* Mint a fresh stamp for `request` and the owner signature `JoinRequest.AuthorizedInsert`
|
|
2133
|
+
* verifies, returning the row as it will be written and its insert.
|
|
1565
2134
|
*/
|
|
1566
|
-
|
|
1567
|
-
const written = { ...
|
|
1568
|
-
const signature = signMessage(buildAuthorizationMessage('CadreControl.
|
|
1569
|
-
|
|
2135
|
+
signJoinRequestInsert(request, ownerKey, signMessage) {
|
|
2136
|
+
const written = { ...request, StampId: generateStampId(this.config.libp2pNode.peerId.toString()) };
|
|
2137
|
+
const signature = signMessage(buildAuthorizationMessage('CadreControl.JoinRequest', 'add', this.config.partyId, joinRequestAddFields(written)));
|
|
2138
|
+
const params = [ownerKey, signature, written.Id, written.Invitation, written.Disclosure, written.RequestedAt, written.ExpiresAt, written.StampId];
|
|
2139
|
+
return { written, insert: { sql: JOIN_REQUEST_INSERT_SQL, params } };
|
|
2140
|
+
}
|
|
2141
|
+
/**
|
|
2142
|
+
* Remove the outcome row of the request incarnation `requestStampId`, whichever table holds
|
|
2143
|
+
* it. Unsigned: the schema authorizes the delete by the tombstone retiring that stamp, filed
|
|
2144
|
+
* in the same transaction or already committed (a reap).
|
|
2145
|
+
*/
|
|
2146
|
+
async deleteJoinOutcomes(requestStampId) {
|
|
2147
|
+
await this.deleteJoinOutcome('JoinSuccess', requestStampId);
|
|
2148
|
+
await this.deleteJoinOutcome('JoinFailure', requestStampId);
|
|
2149
|
+
}
|
|
2150
|
+
/** One unsigned outcome delete (see {@link deleteJoinOutcomes}; a failure's is also authorized by the join superseding it). */
|
|
2151
|
+
async deleteJoinOutcome(table, requestStampId) {
|
|
2152
|
+
await this.db.exec(`
|
|
2153
|
+
delete from CadreControl.${table}
|
|
2154
|
+
with context OwnerKey = null, Signature = null
|
|
2155
|
+
where RequestStampId = ?
|
|
2156
|
+
`, [requestStampId]);
|
|
1570
2157
|
}
|
|
1571
2158
|
/**
|
|
1572
2159
|
* Insert a validation key into the control database using an owner signature.
|
|
@@ -1587,12 +2174,12 @@ export class ControlDatabase {
|
|
|
1587
2174
|
const peerId = this.config.libp2pNode.peerId.toString();
|
|
1588
2175
|
const stampId = generateStampId(peerId);
|
|
1589
2176
|
// Field order MUST match the schema's ValidationKey `AuthorizedInsert` verify: Key, StampId.
|
|
1590
|
-
const message = buildAuthorizationMessage('CadreControl.ValidationKey', 'add', [key, stampId]);
|
|
2177
|
+
const message = buildAuthorizationMessage('CadreControl.ValidationKey', 'add', this.config.partyId, [key, stampId]);
|
|
1591
2178
|
const signature = signMessage(message);
|
|
1592
|
-
await this.execWrite(`
|
|
1593
|
-
insert into CadreControl.ValidationKey (Key, StampId)
|
|
1594
|
-
with context OwnerKey = ?, Signature = ?
|
|
1595
|
-
values (?, ?)
|
|
2179
|
+
await this.execWrite(`
|
|
2180
|
+
insert into CadreControl.ValidationKey (Key, StampId)
|
|
2181
|
+
with context OwnerKey = ?, Signature = ?
|
|
2182
|
+
values (?, ?)
|
|
1596
2183
|
`, [ownerKey, signature, key, stampId], 'validation-key-insert');
|
|
1597
2184
|
log('Validation key inserted: %s', key);
|
|
1598
2185
|
}
|
|
@@ -1657,7 +2244,7 @@ export class ControlDatabase {
|
|
|
1657
2244
|
// Stamp is minted from the ADMITTED peer's id, not this node's — the nonce names the
|
|
1658
2245
|
// row it vouches.
|
|
1659
2246
|
const stampId = generateStampId(row.peerId);
|
|
1660
|
-
const signature = signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', [row.peerId, stampId]));
|
|
2247
|
+
const signature = signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [row.peerId, stampId]));
|
|
1661
2248
|
return await this.mutateCadrePeer('peer-insert', async () => {
|
|
1662
2249
|
// retry: false — this guard runs inside the locked write body (see queryStampId's NOTE).
|
|
1663
2250
|
if (await this.queryStampId('CadrePeer', row.peerId, false) !== null) {
|
|
@@ -1670,11 +2257,11 @@ export class ControlDatabase {
|
|
|
1670
2257
|
// Persist the vouching (owner, signature) onto the row (VouchOwner/VouchSig) —
|
|
1671
2258
|
// identical to the context pair, which the AuthorizedInsert constraint binds — so a
|
|
1672
2259
|
// reader can later re-check the voucher against its node-local trusted-owner anchor.
|
|
1673
|
-
await this.db.exec(`
|
|
1674
|
-
insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId, VouchOwner, VouchSig)
|
|
1675
|
-
with context OwnerKey = ?, Signature = ?
|
|
1676
|
-
values (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1677
|
-
`, [ownerKey, signature, row.peerId, row.publicKey, row.multiaddr, row.updatedAt, row.sig, stampId, ownerKey, signature]);
|
|
2260
|
+
await this.db.exec(`
|
|
2261
|
+
insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, Profile, StampId, VouchOwner, VouchSig)
|
|
2262
|
+
with context OwnerKey = ?, Signature = ?
|
|
2263
|
+
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2264
|
+
`, [ownerKey, signature, row.peerId, row.publicKey, row.multiaddr, row.updatedAt, row.sig, row.profile, stampId, ownerKey, signature]);
|
|
1678
2265
|
return true;
|
|
1679
2266
|
});
|
|
1680
2267
|
}
|
|
@@ -1688,7 +2275,8 @@ export class ControlDatabase {
|
|
|
1688
2275
|
* local-only (its block's cluster ≤1 at insert) is pushed to the cohort once it grows, by
|
|
1689
2276
|
* re-issuing this monotonic bump. It is an UPDATE (not the original INSERT) because the row
|
|
1690
2277
|
* already exists locally; a re-INSERT would hit the `PeerId` PK. Only the freshness stamp
|
|
1691
|
-
* and voucher change — `PublicKey` / `Multiaddr` / `Sig` are left intact
|
|
2278
|
+
* and voucher change — `PublicKey` / `Multiaddr` / `Sig` / `Profile` are left intact (the
|
|
2279
|
+
* owner branch requires them kept: they are the machine's own claim) — so it is safe
|
|
1692
2280
|
* over a row whose peer has not self-published (`Sig` null); the caller must skip a row
|
|
1693
2281
|
* that already carries a self-`Sig` (that row is the owning peer's to refresh, and bumping
|
|
1694
2282
|
* `UpdatedAt` without re-signing would invalidate its self-signature).
|
|
@@ -1710,6 +2298,11 @@ export class ControlDatabase {
|
|
|
1710
2298
|
* change the member set. Keeping the rule uniform ("every CadrePeer mutator notifies")
|
|
1711
2299
|
* beats a per-method exception the next reader has to relearn.
|
|
1712
2300
|
*
|
|
2301
|
+
* Also clears `VouchUsage`: a row carries exactly one proof (the owner branch of
|
|
2302
|
+
* `AuthorizedUpdate` requires it null), so re-vouching an invitation-admitted row moves it
|
|
2303
|
+
* onto the signature and its invitation is no longer consulted for it — the way an owner
|
|
2304
|
+
* retires a device's dependency on an invitation it may later withdraw.
|
|
2305
|
+
*
|
|
1713
2306
|
* NOTE: this rebinds VouchOwner to the CALLING owner's key, and the authorized-membership
|
|
1714
2307
|
* predicate (`CadreNode.listAuthorizedMembers`) now judges rows by that column against each
|
|
1715
2308
|
* reader's node-local anchor. Benign today because the only caller — the write-while-alone
|
|
@@ -1733,14 +2326,14 @@ export class ControlDatabase {
|
|
|
1733
2326
|
log('reauthorizeCadrePeer: no CadrePeer row for %s (nothing to re-touch)', peerId);
|
|
1734
2327
|
return false;
|
|
1735
2328
|
}
|
|
1736
|
-
const signature = signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', [peerId, stampId]));
|
|
2329
|
+
const signature = signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [peerId, stampId]));
|
|
1737
2330
|
await this.mutateCadrePeer('peer-reauthorize', async () => {
|
|
1738
2331
|
// Bare `exec` — inside the non-re-entrant write lock (see insertCadrePeer).
|
|
1739
|
-
await this.db.exec(`
|
|
1740
|
-
update CadreControl.CadrePeer
|
|
1741
|
-
with context OwnerKey = ?, Signature = ?
|
|
1742
|
-
set UpdatedAt = ?, VouchOwner = ?, VouchSig =
|
|
1743
|
-
where PeerId = ?
|
|
2332
|
+
await this.db.exec(`
|
|
2333
|
+
update CadreControl.CadrePeer
|
|
2334
|
+
with context OwnerKey = ?, Signature = ?
|
|
2335
|
+
set UpdatedAt = ?, VouchOwner = ?, VouchSig = ?, VouchUsage = null
|
|
2336
|
+
where PeerId = ?
|
|
1744
2337
|
`, [ownerKey, signature, updatedAt, ownerKey, signature, peerId]);
|
|
1745
2338
|
});
|
|
1746
2339
|
return true;
|
|
@@ -1788,15 +2381,9 @@ export class ControlDatabase {
|
|
|
1788
2381
|
* Owner-signed delete of one guarded row plus the `Revocation` tombstone retiring its
|
|
1789
2382
|
* stamp, in ONE transaction. The single body behind EVERY guarded delete —
|
|
1790
2383
|
* {@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.
|
|
2384
|
+
* {@link deleteDeviceToken}, {@link deleteOwnerKey}; see any of them for the per-table
|
|
2385
|
+
* security rationale. Each of those is a thin named wrapper, so callers never pass a
|
|
2386
|
+
* table name and this generic shape stays off the public surface.
|
|
1800
2387
|
*
|
|
1801
2388
|
* The row's CURRENT stamp is read first and signed over, so the remove digest binds to
|
|
1802
2389
|
* this exact row instance. A no-op (no throw, no tombstone) when the row is absent —
|
|
@@ -1804,6 +2391,9 @@ export class ControlDatabase {
|
|
|
1804
2391
|
* second read (e.g. {@link CadreNode.unpublishStrand}'s committed-while-alone warning,
|
|
1805
2392
|
* which must not fire for a no-op).
|
|
1806
2393
|
*
|
|
2394
|
+
* `alongside`, when given, runs inside the transaction before the removal, for a table whose
|
|
2395
|
+
* row has dependants the schema requires removed with it ({@link deletePendingJoin}).
|
|
2396
|
+
*
|
|
1807
2397
|
* NOTE: the stamp read is outside the transaction. A concurrent writer that removes
|
|
1808
2398
|
* the row in between makes the signature bind a stamp that is no longer live; the
|
|
1809
2399
|
* delete then matches nothing and the tombstone insert collides with the other
|
|
@@ -1814,7 +2404,7 @@ export class ControlDatabase {
|
|
|
1814
2404
|
* Deliberately NOT wrapped in {@link withWriteLock}: every public entry point already
|
|
1815
2405
|
* holds the (non-re-entrant) lock, so taking it again here would self-deadlock.
|
|
1816
2406
|
*/
|
|
1817
|
-
async deleteGuardedRow(table, keyValue, ownerKey, signMessage) {
|
|
2407
|
+
async deleteGuardedRow(table, keyValue, ownerKey, signMessage, alongside) {
|
|
1818
2408
|
this.ensureInitialized();
|
|
1819
2409
|
// retry: false — this runs inside the locked write body (see queryStampId's NOTE).
|
|
1820
2410
|
const stampId = await this.queryStampId(table, keyValue, false);
|
|
@@ -1822,8 +2412,11 @@ export class ControlDatabase {
|
|
|
1822
2412
|
log('delete %s: no row for %s (already absent)', table, keyValue);
|
|
1823
2413
|
return false;
|
|
1824
2414
|
}
|
|
1825
|
-
const removal = signGuardedRemoval(table, keyValue, stampId, signMessage);
|
|
1826
|
-
await this.inTransaction(`delete ${table}`, () =>
|
|
2415
|
+
const removal = signGuardedRemoval(this.config.partyId, table, keyValue, stampId, signMessage);
|
|
2416
|
+
await this.inTransaction(`delete ${table}`, async () => {
|
|
2417
|
+
await alongside?.(stampId);
|
|
2418
|
+
await this.execGuardedRemoval(removal, ownerKey);
|
|
2419
|
+
});
|
|
1827
2420
|
log('%s deleted: %s (stamp retired)', table, keyValue);
|
|
1828
2421
|
this.notifyGuardedDelete(removal.ref);
|
|
1829
2422
|
return true;
|
|
@@ -1831,7 +2424,7 @@ export class ControlDatabase {
|
|
|
1831
2424
|
/**
|
|
1832
2425
|
* Delete the row incarnation `removal` names and file its tombstone — the statement pair
|
|
1833
2426
|
* every owner removal of a guarded row runs ({@link deleteGuardedRow},
|
|
1834
|
-
* {@link deleteStrandAndPartyKey}, {@link
|
|
2427
|
+
* {@link deleteStrandAndPartyKey}, {@link rewritePendingJoin}). The caller supplies the
|
|
1835
2428
|
* transaction: the schema's `RevocationRecorded` check refuses the delete unless the
|
|
1836
2429
|
* tombstone commits with it.
|
|
1837
2430
|
*
|
|
@@ -1843,19 +2436,33 @@ export class ControlDatabase {
|
|
|
1843
2436
|
*/
|
|
1844
2437
|
async execGuardedRemoval(removal, ownerKey) {
|
|
1845
2438
|
const { tableName, rowKey, stampId } = removal.ref;
|
|
1846
|
-
await this.db.exec(`
|
|
1847
|
-
delete from CadreControl.${tableName}
|
|
1848
|
-
with context OwnerKey = ?, Signature = ?
|
|
1849
|
-
where ${GUARDED_KEY_COLUMN[tableName]} = ? and StampId = ?
|
|
2439
|
+
await this.db.exec(`
|
|
2440
|
+
delete from CadreControl.${tableName}
|
|
2441
|
+
with context OwnerKey = ?, Signature = ?
|
|
2442
|
+
where ${GUARDED_KEY_COLUMN[tableName]} = ? and StampId = ?
|
|
1850
2443
|
`, [ownerKey, removal.signature, rowKey, stampId]);
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
2444
|
+
await this.execTombstone(removal.ref, removal.revocationSignature, ownerKey);
|
|
2445
|
+
}
|
|
2446
|
+
/**
|
|
2447
|
+
* File the owner-signed `Revocation` tombstone retiring one row incarnation — the second
|
|
2448
|
+
* statement of every guarded removal ({@link execGuardedRemoval}) and the whole of a
|
|
2449
|
+
* `CadreInvite` withdrawal ({@link withdrawCadreInvite}), whose row stays — or the ledger
|
|
2450
|
+
* marker ({@link openRevocationLedger}), which retires nothing. The caller supplies the
|
|
2451
|
+
* lock and, where one is needed, the transaction.
|
|
2452
|
+
*
|
|
2453
|
+
* `ReissuedAt` is named explicitly at 0 (the only value `FreshTombstone` accepts) rather
|
|
2454
|
+
* than leaning on the column default — the seat-at-zero rule is load-bearing for
|
|
2455
|
+
* {@link reissueRevocations}' monotonic bump, so state it at the write site.
|
|
2456
|
+
*
|
|
2457
|
+
* `SignerKey` / `SignerSig` store the context pair (`Revocation.Authorized` pins them
|
|
2458
|
+
* equal), so a node that receives the row by replication can tell which owner filed it.
|
|
2459
|
+
*/
|
|
2460
|
+
async execTombstone(ref, revocationSignature, ownerKey) {
|
|
2461
|
+
await this.db.exec(`
|
|
2462
|
+
insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt, SignerKey, SignerSig)
|
|
2463
|
+
with context OwnerKey = ?, Signature = ?
|
|
2464
|
+
values (?, ?, ?, 0, ?, ?)
|
|
2465
|
+
`, [ownerKey, revocationSignature, ref.tableName, ref.rowKey, ref.stampId, ownerKey, revocationSignature]);
|
|
1859
2466
|
}
|
|
1860
2467
|
/**
|
|
1861
2468
|
* Fire the guarded-delete listener for one committed tombstone, swallowing (and
|
|
@@ -1937,10 +2544,10 @@ export class ControlDatabase {
|
|
|
1937
2544
|
log('reap %s: %s holds a fresh incarnation (live stamp differs from the tombstoned one); leaving it', table, rowKey);
|
|
1938
2545
|
return false;
|
|
1939
2546
|
}
|
|
1940
|
-
const sql = `
|
|
1941
|
-
delete from CadreControl.${table}
|
|
1942
|
-
with context OwnerKey = null, Signature = null
|
|
1943
|
-
where ${GUARDED_KEY_COLUMN[table]} = ? and StampId = ?
|
|
2547
|
+
const sql = `
|
|
2548
|
+
delete from CadreControl.${table}
|
|
2549
|
+
with context OwnerKey = null, Signature = null
|
|
2550
|
+
where ${GUARDED_KEY_COLUMN[table]} = ? and StampId = ?
|
|
1944
2551
|
`;
|
|
1945
2552
|
if (table === 'CadrePeer') {
|
|
1946
2553
|
// Through mutateCadrePeer so the "EVERY CadrePeer writer goes through here"
|
|
@@ -1956,6 +2563,16 @@ export class ControlDatabase {
|
|
|
1956
2563
|
// in one pass, hoist the notify to one refresh after the sweep instead.
|
|
1957
2564
|
await this.mutateCadrePeer('peer-reap', () => this.db.exec(sql, [rowKey, stampId]));
|
|
1958
2565
|
}
|
|
2566
|
+
else if (table === 'JoinRequest') {
|
|
2567
|
+
// The request's outcome row goes with it; the committed tombstone authorizes that delete
|
|
2568
|
+
// too. An outcome row held for a request this node never received (the request was
|
|
2569
|
+
// written alone and its outcome connected, before the growth-edge re-issue) is left: it
|
|
2570
|
+
// is unreadable, since every reader starts from the request.
|
|
2571
|
+
await this.lockedWithRetry(() => this.inTransaction('reap JoinRequest', async () => {
|
|
2572
|
+
await this.deleteJoinOutcomes(stampId);
|
|
2573
|
+
await this.db.exec(sql, [rowKey, stampId]);
|
|
2574
|
+
}), {}, 'reap-JoinRequest');
|
|
2575
|
+
}
|
|
1959
2576
|
else {
|
|
1960
2577
|
await this.execWrite(sql, [rowKey, stampId], `reap-${table}`);
|
|
1961
2578
|
}
|
|
@@ -1992,9 +2609,9 @@ export class ControlDatabase {
|
|
|
1992
2609
|
* a retired stamp, and which a revoked drone cannot produce at all. The trade is
|
|
1993
2610
|
* recurring failure noise every cycle against removing the one stale row nobody else
|
|
1994
2611
|
* reads from this node (every other node already filters it by tombstone). A revoked
|
|
1995
|
-
* node therefore keeps its own copy of its own row;
|
|
1996
|
-
*
|
|
1997
|
-
* `ValidationKey`, `JoinedStrand` and `
|
|
2612
|
+
* node therefore keeps its own copy of its own row; learning that it was removed is a
|
|
2613
|
+
* separate behaviour (`CadreNode.removedFromCadre`, the `cadre:removed` event).
|
|
2614
|
+
* `ValidationKey`, `JoinedStrand` and `JoinRequest` have no self notion and are not
|
|
1998
2615
|
* special-cased.
|
|
1999
2616
|
*
|
|
2000
2617
|
* A per-row failure is logged and skipped rather than aborting the sweep: an abort would
|
|
@@ -2083,7 +2700,7 @@ export class ControlDatabase {
|
|
|
2083
2700
|
// and every digest field is TEXT on both sides (see buildAuthorizationMessage).
|
|
2084
2701
|
const signed = rows.map((row) => ({
|
|
2085
2702
|
row,
|
|
2086
|
-
signature: signMessage(buildAuthorizationMessage('CadreControl.Revocation', 'reissue', [row.tableName, row.rowKey, row.stampId, String(reissuedAt)])),
|
|
2703
|
+
signature: signMessage(buildAuthorizationMessage('CadreControl.Revocation', 'reissue', this.config.partyId, [row.tableName, row.rowKey, row.stampId, String(reissuedAt)])),
|
|
2087
2704
|
}));
|
|
2088
2705
|
return this.lockedWithRetry(async () => {
|
|
2089
2706
|
// Counter lives INSIDE the locked body so a lockedWithRetry re-run (which
|
|
@@ -2103,11 +2720,11 @@ export class ControlDatabase {
|
|
|
2103
2720
|
// output minted per row incarnation (generateStampId), so it identifies one
|
|
2104
2721
|
// row across all TableName values. Do NOT "fix" this by adding TableName to
|
|
2105
2722
|
// the where clause.
|
|
2106
|
-
await this.db.exec(`
|
|
2107
|
-
update CadreControl.Revocation
|
|
2108
|
-
with context OwnerKey = ?, Signature = ?
|
|
2109
|
-
set ReissuedAt = ?
|
|
2110
|
-
where StampId = ?
|
|
2723
|
+
await this.db.exec(`
|
|
2724
|
+
update CadreControl.Revocation
|
|
2725
|
+
with context OwnerKey = ?, Signature = ?
|
|
2726
|
+
set ReissuedAt = ?
|
|
2727
|
+
where StampId = ?
|
|
2111
2728
|
`, [ownerKey, signature, reissuedAt, row.stampId]);
|
|
2112
2729
|
executed++;
|
|
2113
2730
|
}
|
|
@@ -2149,19 +2766,14 @@ export class ControlDatabase {
|
|
|
2149
2766
|
*/
|
|
2150
2767
|
async openRevocationLedger(ownerKey, signMessage) {
|
|
2151
2768
|
this.ensureInitialized();
|
|
2152
|
-
const
|
|
2153
|
-
const signature = signMessage(buildAuthorizationMessage('CadreControl.Revocation', 'remove', [tableName, rowKey, stampId]));
|
|
2769
|
+
const signature = signMessage(revocationTombstoneMessage(this.config.partyId, REVOCATION_LEDGER_MARKER));
|
|
2154
2770
|
try {
|
|
2155
2771
|
return await this.lockedWithRetry(async () => {
|
|
2156
2772
|
if (await this.revocationLedgerFiled()) {
|
|
2157
2773
|
return 'already-open';
|
|
2158
2774
|
}
|
|
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]);
|
|
2775
|
+
// execTombstone is a bare `exec`: already inside the write lock, which is NOT re-entrant.
|
|
2776
|
+
await this.execTombstone(REVOCATION_LEDGER_MARKER, signature, ownerKey);
|
|
2165
2777
|
log('Revocation ledger marker filed');
|
|
2166
2778
|
return 'opened';
|
|
2167
2779
|
}, {}, 'revocation-ledger-open');
|
|
@@ -2258,15 +2870,18 @@ export class ControlDatabase {
|
|
|
2258
2870
|
this.controlWriteAbandonedListener = listener;
|
|
2259
2871
|
}
|
|
2260
2872
|
/**
|
|
2261
|
-
* Run a
|
|
2873
|
+
* Run a membership-relevant mutation and notify the membership listener once it has
|
|
2262
2874
|
* COMMITTED.
|
|
2263
2875
|
*
|
|
2264
|
-
*
|
|
2265
|
-
* ({@link updateSelfPeerRecord}, which cannot
|
|
2266
|
-
* the
|
|
2876
|
+
* Covers every write that can change who is a member or an owner: EVERY `CadrePeer`
|
|
2877
|
+
* writer, with one documented exception ({@link updateSelfPeerRecord}, which cannot
|
|
2878
|
+
* change the snapshot), the `OwnerKey` writers ({@link insertOwnerKeyVouched},
|
|
2879
|
+
* {@link deleteOwnerKey}) and the invitation redemption ({@link redeemCadreInvite}),
|
|
2880
|
+
* which seats both. That is what makes the party-membership snapshot refresh automatic
|
|
2881
|
+
* rather than a caller obligation. The name predates the `OwnerKey` writers. A
|
|
2267
2882
|
* writer necessarily holds the target node's ControlDatabase (the `SeedBootstrapService`'s
|
|
2268
|
-
* event callbacks are NOT a viable seam: the temp
|
|
2269
|
-
*
|
|
2883
|
+
* event callbacks are NOT a viable seam: the temp service `CadreNode.applySeed`
|
|
2884
|
+
* builds, and services constructed outside `CadreNode` entirely, never get
|
|
2270
2885
|
* callbacks wired), so this is the one point on the write path that cannot be bypassed.
|
|
2271
2886
|
*
|
|
2272
2887
|
* `body` owns its own transaction if it needs one and must COMMIT before returning, so
|
|
@@ -2447,7 +3062,7 @@ export class ControlDatabase {
|
|
|
2447
3062
|
return;
|
|
2448
3063
|
}
|
|
2449
3064
|
throw new Error(`mutateCadrePeer(${reason}): a transaction is open ${where} the mutation body — a ` +
|
|
2450
|
-
'
|
|
3065
|
+
'membership write must commit before the membership listener runs; move the ' +
|
|
2451
3066
|
'mutateCadrePeer wrapper out to enclose the commit');
|
|
2452
3067
|
}
|
|
2453
3068
|
/**
|
|
@@ -2526,17 +3141,17 @@ export class ControlDatabase {
|
|
|
2526
3141
|
const strandIdField = options.strandId ?? '';
|
|
2527
3142
|
// Field order MUST match the schema's FormationInvite `AuthorizedInsert` verify:
|
|
2528
3143
|
// Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId.
|
|
2529
|
-
const message = buildAuthorizationMessage('CadreControl.FormationInvite', 'add', [
|
|
3144
|
+
const message = buildAuthorizationMessage('CadreControl.FormationInvite', 'add', this.config.partyId, [
|
|
2530
3145
|
token, sAppId, expiresAtField, totalUsesField, validationUrlField, strandIdField, stampId,
|
|
2531
3146
|
]);
|
|
2532
3147
|
const signature = signMessage(message);
|
|
2533
3148
|
// Persist the canonical ExpiresAt string (datetime parse is idempotent on it) so the
|
|
2534
3149
|
// signed source-of-truth and the stored value are produced once. StampId is a real,
|
|
2535
3150
|
// unique column (single-use anti-replay), no longer a context value.
|
|
2536
|
-
await this.execWrite(`
|
|
2537
|
-
insert into CadreControl.FormationInvite (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId)
|
|
2538
|
-
with context OwnerKey = ?, Signature = ?
|
|
2539
|
-
values (?, ?, ?, ?, ?, ?, ?)
|
|
3151
|
+
await this.execWrite(`
|
|
3152
|
+
insert into CadreControl.FormationInvite (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId)
|
|
3153
|
+
with context OwnerKey = ?, Signature = ?
|
|
3154
|
+
values (?, ?, ?, ?, ?, ?, ?)
|
|
2540
3155
|
`, [
|
|
2541
3156
|
ownerKey, signature,
|
|
2542
3157
|
token, sAppId,
|
|
@@ -2605,10 +3220,10 @@ export class ControlDatabase {
|
|
|
2605
3220
|
// Hard-coded open + keyless + explicit null FounderOwnerKey: the
|
|
2606
3221
|
// consent branch admits no other shape (no signature means no
|
|
2607
3222
|
// trustworthy founder machine to record — see the schema's NOTE).
|
|
2608
|
-
await this.db.exec(`
|
|
2609
|
-
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey)
|
|
2610
|
-
with context OwnerKey = null, Signature = null
|
|
2611
|
-
values (?, 'o', null, ?, null)
|
|
3223
|
+
await this.db.exec(`
|
|
3224
|
+
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey)
|
|
3225
|
+
with context OwnerKey = null, Signature = null
|
|
3226
|
+
values (?, 'o', null, ?, null)
|
|
2612
3227
|
`, [strandId, strandStampId]);
|
|
2613
3228
|
// 2. FormationUsage row — authorised by the matching FormationInvite, and
|
|
2614
3229
|
// carrying the strand's stamp so it authorizes THIS row and no other.
|
|
@@ -2728,10 +3343,10 @@ export class ControlDatabase {
|
|
|
2728
3343
|
// robustness/consistency change, matching the strand layer's `consumeInvite`,
|
|
2729
3344
|
// not a fix for an observable mis-ordering.
|
|
2730
3345
|
const nowCanonical = await canonicalDatetime(this.db, opts.nowMs);
|
|
2731
|
-
await this.db.exec(`
|
|
2732
|
-
insert into CadreControl.FormationUsage (Token, UsageStampId, PeerKey, PeerSig, Disclosure, StrandId, StrandStampId)
|
|
2733
|
-
with context Now = ?, ValidationKey = ?, ValidationSignature = ?
|
|
2734
|
-
values (?, ?, ?, ?, ?, ?, ?)
|
|
3346
|
+
await this.db.exec(`
|
|
3347
|
+
insert into CadreControl.FormationUsage (Token, UsageStampId, PeerKey, PeerSig, Disclosure, StrandId, StrandStampId)
|
|
3348
|
+
with context Now = ?, ValidationKey = ?, ValidationSignature = ?
|
|
3349
|
+
values (?, ?, ?, ?, ?, ?, ?)
|
|
2735
3350
|
`, [
|
|
2736
3351
|
nowCanonical, opts.validationKey, opts.validationSignature,
|
|
2737
3352
|
opts.token, opts.usageStampId, opts.peerKey,
|
|
@@ -2749,18 +3364,18 @@ export class ControlDatabase {
|
|
|
2749
3364
|
*/
|
|
2750
3365
|
async queryFormationInvite(token, retry = true) {
|
|
2751
3366
|
this.ensureInitialized();
|
|
2752
|
-
const
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
return
|
|
3367
|
+
const rows = await this.readRows(`${FORMATION_INVITE_SELECT_SQL} where Token = ?`, [token], 'formation-invite', retry);
|
|
3368
|
+
return rows.length === 0 ? null : formationInviteRowOf(rows[0]);
|
|
3369
|
+
}
|
|
3370
|
+
/**
|
|
3371
|
+
* Every `FormationInvite` row, expired and spent ones included, in the shape
|
|
3372
|
+
* {@link queryFormationInvite} returns one. Read by a strand move
|
|
3373
|
+
* (`exportStrandBundle`), which decides per row what is still redeemable.
|
|
3374
|
+
*/
|
|
3375
|
+
async queryFormationInvites() {
|
|
3376
|
+
this.ensureInitialized();
|
|
3377
|
+
const rows = await this.readRows(FORMATION_INVITE_SELECT_SQL, undefined, 'formation-invites');
|
|
3378
|
+
return rows.map(formationInviteRowOf);
|
|
2764
3379
|
}
|
|
2765
3380
|
/**
|
|
2766
3381
|
* Count `FormationUsage` rows recorded against a token (uses consumed so far).
|
|
@@ -2860,6 +3475,410 @@ export class ControlDatabase {
|
|
|
2860
3475
|
}
|
|
2861
3476
|
return false;
|
|
2862
3477
|
}
|
|
3478
|
+
// ── Cadre invitations (CadreInvite / CadreInviteUsage) ─────────────────────────────────
|
|
3479
|
+
/**
|
|
3480
|
+
* Mint and insert an owner-signed `CadreInvite`: an invitation to join THIS cadre that any
|
|
3481
|
+
* member machine can later redeem on the owner's behalf ({@link redeemCadreInvite}).
|
|
3482
|
+
*
|
|
3483
|
+
* The owner signs the whole row ({@link cadreInviteAddMessage}) — `ExpiresAt` through
|
|
3484
|
+
* {@link canonicalDatetime} so the signed segment byte-matches the stored `datetime`, as
|
|
3485
|
+
* {@link insertFormationInvite} does — and the stored `IssuerKey`/`IssuerSig` are pinned to
|
|
3486
|
+
* that signer by `CadreInvite.AuthorizedInsert`. The signature is part of the row because
|
|
3487
|
+
* the row travels: the returned {@link CadreInviteRow} is what the invitation bundle
|
|
3488
|
+
* carries to the holder, and {@link seatCadreInvite} re-inserts it verbatim on a member
|
|
3489
|
+
* that has not received it by replication.
|
|
3490
|
+
*
|
|
3491
|
+
* `key` is the invitation's ed25519 PUBLIC key; the caller minted the pair and hands the
|
|
3492
|
+
* private half to the invitee out of band. It is never stored here (`Strand.Invite` has the
|
|
3493
|
+
* same shape).
|
|
3494
|
+
*
|
|
3495
|
+
* @param invite.key - the invitation public key (base64url ed25519), the row's primary key
|
|
3496
|
+
* @param invite.peerId - the device the invitation is for, or null for whoever redeems first
|
|
3497
|
+
* @param invite.grantsOwner - whether redemption also seats an `OwnerKey` row for the device
|
|
3498
|
+
* @param invite.expiresAtMs - epoch ms after which it cannot be redeemed; omit for never
|
|
3499
|
+
* @param invite.totalUses - how many redemptions it allows; omit for unlimited
|
|
3500
|
+
* @returns the row as stored, `expiresAt` in its canonical form
|
|
3501
|
+
*/
|
|
3502
|
+
async insertCadreInvite(invite, ownerKey, signMessage) {
|
|
3503
|
+
this.ensureInitialized();
|
|
3504
|
+
const key = requireEd25519PublicKeyB64(invite.key, 'cadre invitation key');
|
|
3505
|
+
const signed = {
|
|
3506
|
+
key,
|
|
3507
|
+
peerId: invite.peerId ?? null,
|
|
3508
|
+
grantsOwner: invite.grantsOwner,
|
|
3509
|
+
expiresAt: invite.expiresAtMs == null ? null : await canonicalDatetime(this.db, invite.expiresAtMs),
|
|
3510
|
+
totalUses: invite.totalUses ?? null,
|
|
3511
|
+
stampId: generateStampId(this.config.libp2pNode.peerId.toString()),
|
|
3512
|
+
};
|
|
3513
|
+
const row = {
|
|
3514
|
+
...signed,
|
|
3515
|
+
issuerKey: ownerKey,
|
|
3516
|
+
issuerSig: signMessage(cadreInviteAddMessage(this.config.partyId, signed)),
|
|
3517
|
+
};
|
|
3518
|
+
await this.lockedWithRetry(() => this.execCadreInviteInsert(row), {}, 'cadre-invite-insert');
|
|
3519
|
+
log('Cadre invitation inserted: %s (peer=%s owner=%s)', key, row.peerId ?? 'any', row.grantsOwner);
|
|
3520
|
+
return row;
|
|
3521
|
+
}
|
|
3522
|
+
/**
|
|
3523
|
+
* Seat an owner-signed `CadreInvite` row received inside an invitation bundle, under its own
|
|
3524
|
+
* stored `IssuerKey`/`IssuerSig` as the write context — insert-if-absent, so a member that
|
|
3525
|
+
* already holds the row (by replication, or from an earlier redemption attempt) is a no-op.
|
|
3526
|
+
*
|
|
3527
|
+
* @returns `true` when this call inserted the row, `false` when it was already present.
|
|
3528
|
+
* @throws {CadreInviteIssuerUnknownError} when `CadreInvite.AuthorizedInsert` refuses it: the
|
|
3529
|
+
* issuer is not an owner on this node (not replicated yet, or removed), or the signature does
|
|
3530
|
+
* not verify. Every other refusal (a withdrawn stamp, `NotRevoked`) propagates as is.
|
|
3531
|
+
*/
|
|
3532
|
+
async seatCadreInvite(row) {
|
|
3533
|
+
this.ensureInitialized();
|
|
3534
|
+
try {
|
|
3535
|
+
return await this.lockedWithRetry(async () => {
|
|
3536
|
+
// retry: false — inside the locked write body (see queryStampId's NOTE).
|
|
3537
|
+
if (await this.queryStampId('CadreInvite', row.key, false) !== null) {
|
|
3538
|
+
log('CadreInvite %s already seated; seat skipped', row.key);
|
|
3539
|
+
return false;
|
|
3540
|
+
}
|
|
3541
|
+
await this.execCadreInviteInsert(row);
|
|
3542
|
+
log('Cadre invitation seated from its bundle: %s', row.key);
|
|
3543
|
+
return true;
|
|
3544
|
+
}, {}, 'cadre-invite-seat');
|
|
3545
|
+
}
|
|
3546
|
+
catch (error) {
|
|
3547
|
+
if (errorChainMatches(error, CADRE_INVITE_UNAUTHORIZED)) {
|
|
3548
|
+
throw new CadreInviteIssuerUnknownError(row.key, row.issuerKey, { cause: error });
|
|
3549
|
+
}
|
|
3550
|
+
throw error;
|
|
3551
|
+
}
|
|
3552
|
+
}
|
|
3553
|
+
/** Bare `CadreInvite` insert shared by {@link insertCadreInvite} and {@link seatCadreInvite}; the caller holds the lock. */
|
|
3554
|
+
async execCadreInviteInsert(row) {
|
|
3555
|
+
await this.db.exec(`
|
|
3556
|
+
insert into CadreControl.CadreInvite (Key, PeerId, GrantsOwner, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId)
|
|
3557
|
+
with context OwnerKey = ?, Signature = ?
|
|
3558
|
+
values (?, ?, ?, ?, ?, ?, ?, ?)
|
|
3559
|
+
`, [
|
|
3560
|
+
row.issuerKey, row.issuerSig,
|
|
3561
|
+
row.key, row.peerId, row.grantsOwner ? 1 : 0, row.expiresAt, row.totalUses,
|
|
3562
|
+
row.issuerKey, row.issuerSig, row.stampId,
|
|
3563
|
+
]);
|
|
3564
|
+
}
|
|
3565
|
+
/**
|
|
3566
|
+
* Read one `CadreInvite` row by key, or null when absent. Withdrawn rows are returned too —
|
|
3567
|
+
* the row is the holder's proof and never goes away; liveness is {@link hasLiveCadreInvite}'s
|
|
3568
|
+
* question, and `CadreInviteUsage.Authorized`'s at redemption.
|
|
3569
|
+
*
|
|
3570
|
+
* `retry: false` is passed by {@link redeemCadreInvite}, which reads INSIDE its locked write
|
|
3571
|
+
* body — the same per-call opt-out, for the same reason, as {@link queryStampId}'s.
|
|
3572
|
+
*/
|
|
3573
|
+
async queryCadreInvite(key, retry = true) {
|
|
3574
|
+
this.ensureInitialized();
|
|
3575
|
+
for (const row of await this.readRows(`${CADRE_INVITE_SELECT_SQL} where Key = ?`, [key], 'cadre-invite', retry)) {
|
|
3576
|
+
return cadreInviteRowOf(row);
|
|
3577
|
+
}
|
|
3578
|
+
return null;
|
|
3579
|
+
}
|
|
3580
|
+
/**
|
|
3581
|
+
* Every `CadreInvite` row this node holds, withdrawn and expired ones included (see
|
|
3582
|
+
* {@link queryCadreInvite}). Read by the membership predicate when at least one `CadrePeer`
|
|
3583
|
+
* row is invitation-admitted; `retry: false` is forwarded from
|
|
3584
|
+
* {@link CadreNode.listAuthorizedMembers} on the membership-gate refresh path for
|
|
3585
|
+
* {@link queryCadrePeers}' reason.
|
|
3586
|
+
*/
|
|
3587
|
+
async queryCadreInvites(retry = true) {
|
|
3588
|
+
this.ensureInitialized();
|
|
3589
|
+
const rows = await this.readRows(CADRE_INVITE_SELECT_SQL, undefined, 'cadre-invites', retry);
|
|
3590
|
+
return rows.map(cadreInviteRowOf);
|
|
3591
|
+
}
|
|
3592
|
+
/**
|
|
3593
|
+
* Every `CadreInviteUsage` row this node holds — the redemption records the membership
|
|
3594
|
+
* predicate verifies invitation-admitted rows against. Same `retry` contract as
|
|
3595
|
+
* {@link queryCadreInvites}.
|
|
3596
|
+
*/
|
|
3597
|
+
async queryCadreInviteUsages(retry = true) {
|
|
3598
|
+
this.ensureInitialized();
|
|
3599
|
+
const rows = await this.readRows('select UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, OwnerStampId, InviteSig, PeerSig from CadreControl.CadreInviteUsage', undefined, 'cadre-invite-usages', retry);
|
|
3600
|
+
return rows.map(cadreInviteUsageRowOf);
|
|
3601
|
+
}
|
|
3602
|
+
/**
|
|
3603
|
+
* How many times a cadre invitation has been redeemed (its `CadreInviteUsage` rows), served
|
|
3604
|
+
* through the `CadreInviteUsageByInvite` index. As {@link countFormationUsage}: a permissive
|
|
3605
|
+
* PRE-check, not the cap — the cap is `CadreInviteUsage.Authorized`'s own committed count.
|
|
3606
|
+
*
|
|
3607
|
+
* `retry: false` is passed by {@link assertCadreInviteSeatRemains}, inside the locked body.
|
|
3608
|
+
*/
|
|
3609
|
+
async countCadreInviteUsage(key, retry = true) {
|
|
3610
|
+
this.ensureInitialized();
|
|
3611
|
+
const sql = 'select count(1) as Count from CadreControl.CadreInviteUsage where InviteKey = ?';
|
|
3612
|
+
for (const row of await this.readRows(sql, [key], 'cadre-invite-usage-count', retry)) {
|
|
3613
|
+
return row.Count ?? 0;
|
|
3614
|
+
}
|
|
3615
|
+
return 0;
|
|
3616
|
+
}
|
|
3617
|
+
/**
|
|
3618
|
+
* Is any cadre invitation still redeemable — not withdrawn, unexpired, with uses below its
|
|
3619
|
+
* `TotalUses`, and issued by a current owner? The conditions `CadreInviteUsage.Authorized`
|
|
3620
|
+
* applies, read ahead of time so the control-network connection gate can answer "does this
|
|
3621
|
+
* node expect a stranger?" with no key to ask about, exactly as
|
|
3622
|
+
* {@link hasOutstandingFormationInvite} answers it for strand formation. The expiry
|
|
3623
|
+
* comparison is `expiresAtMs <= now`, so an invitation the redemption would refuse never
|
|
3624
|
+
* holds the gate open.
|
|
3625
|
+
*
|
|
3626
|
+
* Withdrawal is read through {@link queryRevokedStamps}, the per-table retired-stamp seam
|
|
3627
|
+
* every other revocation-aware reader uses (and the one an isolated node can still answer).
|
|
3628
|
+
*/
|
|
3629
|
+
async hasLiveCadreInvite(nowMs = Date.now()) {
|
|
3630
|
+
this.ensureInitialized();
|
|
3631
|
+
// NOTE: getOwnerKeys reads the retired OwnerKey stamps first, so this gate path scans
|
|
3632
|
+
// Revocation twice (once per table, the two in parallel). If the stranger path's decision
|
|
3633
|
+
// ever nears the admission gate's 2 s deadline, read the retired stamps once for both.
|
|
3634
|
+
const [withdrawn, owners] = await Promise.all([this.queryRevokedStamps('CadreInvite'), this.getOwnerKeys()]);
|
|
3635
|
+
// NOTE: scans every CadreInvite row (expired and withdrawn ones included, since rows are
|
|
3636
|
+
// never deleted) on the stranger path of an inbound connection. Cadre-scale invitation
|
|
3637
|
+
// counts make that free today; if a long-lived cadre accumulates thousands and inbound
|
|
3638
|
+
// upgrades slow down, add an expiry-ordered index or prune withdrawn/expired rows (which
|
|
3639
|
+
// first needs every device they admitted re-vouched, so the chain no longer needs them).
|
|
3640
|
+
const metered = [];
|
|
3641
|
+
let unlimitedLive = false;
|
|
3642
|
+
for (const row of await this.readRows('select Key, ExpiresAt, TotalUses, IssuerKey, StampId from CadreControl.CadreInvite', undefined, 'live-cadre-invites')) {
|
|
3643
|
+
const open = cadreInviteStillOpen({ stampId: row.StampId, issuerKey: row.IssuerKey, expiresAt: row.ExpiresAt ?? null }, withdrawn, owners, nowMs);
|
|
3644
|
+
if (!open) {
|
|
3645
|
+
continue;
|
|
3646
|
+
}
|
|
3647
|
+
const totalUses = row.TotalUses ?? null;
|
|
3648
|
+
if (totalUses === null) {
|
|
3649
|
+
unlimitedLive = true;
|
|
3650
|
+
}
|
|
3651
|
+
else {
|
|
3652
|
+
metered.push({ key: row.Key, totalUses });
|
|
3653
|
+
}
|
|
3654
|
+
}
|
|
3655
|
+
if (unlimitedLive) {
|
|
3656
|
+
return true;
|
|
3657
|
+
}
|
|
3658
|
+
// NOTE: one retried read per metered invitation, each with its own read budget, on a path
|
|
3659
|
+
// the admission gate awaits under a 2 s fail-open deadline — the same shape and the same
|
|
3660
|
+
// revisit condition as hasOutstandingFormationInvite's loop.
|
|
3661
|
+
for (const invite of metered) {
|
|
3662
|
+
if (await this.countCadreInviteUsage(invite.key) < invite.totalUses) {
|
|
3663
|
+
return true;
|
|
3664
|
+
}
|
|
3665
|
+
}
|
|
3666
|
+
return false;
|
|
3667
|
+
}
|
|
3668
|
+
/**
|
|
3669
|
+
* Is this one cadre invitation still redeemable here — the conditions
|
|
3670
|
+
* {@link hasLiveCadreInvite} applies, for one invitation? The row judged is the one this node
|
|
3671
|
+
* holds under `invite.key`; when it holds none, `invite` itself (a bundle's copy, not seated
|
|
3672
|
+
* yet), but only if its issuer signature verifies, because an unsigned copy would otherwise be
|
|
3673
|
+
* judged by whichever owner it names as issuer. A permissive pre-check like
|
|
3674
|
+
* {@link countCadreInviteUsage}: {@link seatCadreInvite} and the redemption's own write are
|
|
3675
|
+
* what enforce every condition.
|
|
3676
|
+
*/
|
|
3677
|
+
async isCadreInviteLive(invite, nowMs = Date.now()) {
|
|
3678
|
+
this.ensureInitialized();
|
|
3679
|
+
const row = await this.queryCadreInvite(invite.key) ?? (verifyCadreInviteRow(this.config.partyId, invite) ? invite : null);
|
|
3680
|
+
if (row === null) {
|
|
3681
|
+
return false;
|
|
3682
|
+
}
|
|
3683
|
+
const [withdrawn, owners] = await Promise.all([this.queryRevokedStamps('CadreInvite'), this.getOwnerKeys()]);
|
|
3684
|
+
if (!cadreInviteStillOpen(row, withdrawn, owners, nowMs)) {
|
|
3685
|
+
return false;
|
|
3686
|
+
}
|
|
3687
|
+
return row.totalUses === null || await this.countCadreInviteUsage(row.key) < row.totalUses;
|
|
3688
|
+
}
|
|
3689
|
+
/**
|
|
3690
|
+
* Every `CadreInvite` row this node holds with its standing: how many redemptions are
|
|
3691
|
+
* recorded, whether it is withdrawn, and whether it is still redeemable — the same
|
|
3692
|
+
* conditions {@link hasLiveCadreInvite} applies, answered per row for an owner's listing
|
|
3693
|
+
* rather than short-circuited. One usage count per row, so this is the owner's
|
|
3694
|
+
* occasional read, not a gate's.
|
|
3695
|
+
*/
|
|
3696
|
+
async listCadreInviteStatuses(nowMs = Date.now()) {
|
|
3697
|
+
this.ensureInitialized();
|
|
3698
|
+
const [invites, withdrawnStamps, owners] = await Promise.all([
|
|
3699
|
+
this.queryCadreInvites(), this.queryRevokedStamps('CadreInvite'), this.getOwnerKeys()
|
|
3700
|
+
]);
|
|
3701
|
+
const statuses = [];
|
|
3702
|
+
// NOTE: one retried count read per invitation, withdrawn and expired rows included, since
|
|
3703
|
+
// rows are never deleted; if a long-lived cadre's listing ever shows as slow, group the
|
|
3704
|
+
// usage counts in one statement or skip the count for rows that are no longer open.
|
|
3705
|
+
for (const invite of invites) {
|
|
3706
|
+
const usesRecorded = await this.countCadreInviteUsage(invite.key);
|
|
3707
|
+
const withdrawn = withdrawnStamps.has(invite.stampId);
|
|
3708
|
+
const live = cadreInviteStillOpen(invite, withdrawnStamps, owners, nowMs)
|
|
3709
|
+
&& (invite.totalUses === null || usesRecorded < invite.totalUses);
|
|
3710
|
+
statuses.push({ invite, live, withdrawn, usesRecorded });
|
|
3711
|
+
}
|
|
3712
|
+
return statuses;
|
|
3713
|
+
}
|
|
3714
|
+
/**
|
|
3715
|
+
* Owner-signed WITHDRAWAL of a cadre invitation: file the `Revocation` tombstone retiring its
|
|
3716
|
+
* stamp, with the row kept. The row stays because every device the invitation admitted
|
|
3717
|
+
* proves its membership through it (`CadrePeer.AuthorizedInsert`'s consent branch,
|
|
3718
|
+
* `verifyInvitationAdmission`); `CadreInvite.NoDelete` refuses a delete, and
|
|
3719
|
+
* `Revocation.RowIsGone` admits a `'CadreInvite'` tombstone while the row lives. Afterwards
|
|
3720
|
+
* `CadreInviteUsage.Authorized` refuses every further redemption, and `CadreInvite.NotRevoked`
|
|
3721
|
+
* refuses re-seating the row from the holder's signed copy on any node holding the
|
|
3722
|
+
* tombstone.
|
|
3723
|
+
*
|
|
3724
|
+
* Idempotent: a no-op (no throw, nothing written) when the row is absent locally or already
|
|
3725
|
+
* withdrawn — `false` then, `true` when this call filed the tombstone. The owner must hold the
|
|
3726
|
+
* row to withdraw it, since the tombstone names the row's stamp.
|
|
3727
|
+
*
|
|
3728
|
+
* Fires the guarded-delete listener like a removal does, so a withdrawal committed while the
|
|
3729
|
+
* node was alone is queued for re-issue on cohort growth ({@link CadreNode.noteGuardedDelete}).
|
|
3730
|
+
*/
|
|
3731
|
+
async withdrawCadreInvite(key, ownerKey, signMessage) {
|
|
3732
|
+
this.ensureInitialized();
|
|
3733
|
+
const filed = await this.lockedWithRetry(async () => {
|
|
3734
|
+
const [withdrawal] = await this.signCadreInviteWithdrawals([key], signMessage);
|
|
3735
|
+
if (withdrawal === undefined) {
|
|
3736
|
+
return null;
|
|
3737
|
+
}
|
|
3738
|
+
await this.execTombstone(withdrawal.ref, withdrawal.signature, ownerKey);
|
|
3739
|
+
return withdrawal.ref;
|
|
3740
|
+
}, {}, 'cadre-invite-withdraw');
|
|
3741
|
+
if (filed === null) {
|
|
3742
|
+
return false;
|
|
3743
|
+
}
|
|
3744
|
+
log('Cadre invitation withdrawn: %s (stamp %s retired, row kept)', key, filed.stampId);
|
|
3745
|
+
this.notifyGuardedDelete(filed);
|
|
3746
|
+
return true;
|
|
3747
|
+
}
|
|
3748
|
+
/**
|
|
3749
|
+
* The withdrawal tombstones for the `CadreInvite` rows among `keys` that are held here and
|
|
3750
|
+
* not yet withdrawn, signed and ready for {@link execTombstone}: the body
|
|
3751
|
+
* {@link withdrawCadreInvite} files one of and {@link removeMachine} files several of in one
|
|
3752
|
+
* transaction. Inside the locked body (every read `retry: false`); an absent or already
|
|
3753
|
+
* withdrawn key is logged and left out.
|
|
3754
|
+
*/
|
|
3755
|
+
async signCadreInviteWithdrawals(keys, signMessage) {
|
|
3756
|
+
if (keys.length === 0) {
|
|
3757
|
+
return [];
|
|
3758
|
+
}
|
|
3759
|
+
const withdrawn = await this.queryRevokedStamps('CadreInvite', false);
|
|
3760
|
+
const withdrawals = [];
|
|
3761
|
+
for (const key of keys) {
|
|
3762
|
+
const stampId = await this.queryStampId('CadreInvite', key, false);
|
|
3763
|
+
if (stampId === null) {
|
|
3764
|
+
log('withdrawCadreInvite: no CadreInvite row for %s (nothing to withdraw)', key);
|
|
3765
|
+
continue;
|
|
3766
|
+
}
|
|
3767
|
+
if (withdrawn.has(stampId)) {
|
|
3768
|
+
log('withdrawCadreInvite: %s already withdrawn (stamp %s retired)', key, stampId);
|
|
3769
|
+
continue;
|
|
3770
|
+
}
|
|
3771
|
+
const ref = { tableName: 'CadreInvite', rowKey: key, stampId };
|
|
3772
|
+
withdrawals.push({ ref, signature: signMessage(revocationTombstoneMessage(this.config.partyId, ref)) });
|
|
3773
|
+
}
|
|
3774
|
+
return withdrawals;
|
|
3775
|
+
}
|
|
3776
|
+
/**
|
|
3777
|
+
* Redeem a seated `CadreInvite` on the owner's behalf: admit `peerId` as a cadre member and,
|
|
3778
|
+
* when the invitation grants ownership, as an owner — with NO owner signature. One
|
|
3779
|
+
* transaction writes the `CadrePeer` row (consent branch of `AuthorizedInsert`), the
|
|
3780
|
+
* `OwnerKey` row when granted (consent branch of `Authorized`), and last the
|
|
3781
|
+
* `CadreInviteUsage` row that authorizes both; the usage row's own checks (`PeerExists`,
|
|
3782
|
+
* `OwnerExists`) read the rows back, so the three are mutually bound to this exact
|
|
3783
|
+
* redemption and none can be held in reserve. The invitation must already be committed
|
|
3784
|
+
* ({@link seatCadreInvite}): every consent branch reads `committed.CadreInvite`.
|
|
3785
|
+
*
|
|
3786
|
+
* The two signatures are the holder's and the device's, minted on the device over
|
|
3787
|
+
* {@link cadreInviteRedeemMessage} / {@link cadreInviteConsentMessage}; this machine only
|
|
3788
|
+
* verifies them (by writing the row). `peerId` must be the identity multihash of `peerKey`:
|
|
3789
|
+
* the schema cannot derive it, so a writer asserts the pair and every reader re-derives it
|
|
3790
|
+
* (`verifyInvitationAdmission`) — a mismatched pair is written and then never trusted.
|
|
3791
|
+
*
|
|
3792
|
+
* Idempotent for a retry after a dropped reply: when the device's row is already present
|
|
3793
|
+
* (and its `OwnerKey` row too, when granted) nothing is written and no seat is spent —
|
|
3794
|
+
* `alreadyMember: true`. A present row whose `OwnerKey` row is missing under an
|
|
3795
|
+
* owner-granting invitation is completed: the usage row then names the existing peer stamp.
|
|
3796
|
+
* The seat check ({@link assertCadreInviteSeatRemains}) runs after that, so the retry of a
|
|
3797
|
+
* single-use redemption is answered `alreadyMember` rather than refused as exhausted.
|
|
3798
|
+
*
|
|
3799
|
+
* Through {@link mutateCadrePeer}, so the membership hub is notified; inside the locked body
|
|
3800
|
+
* every read passes `retry: false` and every stamp is minted per attempt, so a
|
|
3801
|
+
* {@link lockedWithRetry} re-run starts clean.
|
|
3802
|
+
*
|
|
3803
|
+
* @throws {InvitationExhaustedError} when the invitation's seats are all spent (by name,
|
|
3804
|
+
* ahead of the schema's generic `Authorized` refusal — see {@link assertSeatRemains}).
|
|
3805
|
+
*/
|
|
3806
|
+
async redeemCadreInvite(params) {
|
|
3807
|
+
this.ensureInitialized();
|
|
3808
|
+
const { inviteKey, peerId, peerKey, usageStampId, inviteSig, peerSig } = params;
|
|
3809
|
+
const nowMs = params.nowMs ?? Date.now();
|
|
3810
|
+
const multiaddr = params.multiaddrs?.length ? params.multiaddrs.join(',') : '';
|
|
3811
|
+
log('Redeeming cadre invitation %s for %s', inviteKey, peerId);
|
|
3812
|
+
return this.mutateCadrePeer('cadre-invite-redeem', async () => {
|
|
3813
|
+
const invite = await this.queryCadreInvite(inviteKey, false);
|
|
3814
|
+
if (invite === null) {
|
|
3815
|
+
throw new Error(`Cannot redeem cadre invitation ${inviteKey}: no CadreInvite row is seated on this node (seatCadreInvite first)`);
|
|
3816
|
+
}
|
|
3817
|
+
const seated = await this.readSeatedAdmission(peerId, peerKey, invite.grantsOwner);
|
|
3818
|
+
if (seated.peerStampId !== null && (!invite.grantsOwner || seated.ownerStampId !== null)) {
|
|
3819
|
+
log('redeemCadreInvite: %s already admitted; nothing written', peerId);
|
|
3820
|
+
return { alreadyMember: true, peerStampId: seated.peerStampId, ownerStampId: seated.ownerStampId };
|
|
3821
|
+
}
|
|
3822
|
+
await this.assertCadreInviteSeatRemains(invite);
|
|
3823
|
+
// Stamps minted from the ADMITTED device's id, as insertCadrePeer does: the nonce names
|
|
3824
|
+
// the row it seats. Minted inside the body, so a re-run mints afresh over a rolled-back
|
|
3825
|
+
// attempt rather than re-presenting a stamp the usage row may already name.
|
|
3826
|
+
const peerStampId = seated.peerStampId ?? generateStampId(peerId);
|
|
3827
|
+
const ownerStampId = invite.grantsOwner ? (seated.ownerStampId ?? generateStampId(peerId)) : null;
|
|
3828
|
+
const nowCanonical = await canonicalDatetime(this.db, nowMs);
|
|
3829
|
+
await this.inTransaction('cadre-invite-redeem', async () => {
|
|
3830
|
+
if (seated.peerStampId === null) {
|
|
3831
|
+
await this.db.exec(`
|
|
3832
|
+
insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId, VouchOwner, VouchSig, VouchUsage)
|
|
3833
|
+
with context OwnerKey = null, Signature = null
|
|
3834
|
+
values (?, ?, ?, ?, null, ?, ?, null, ?)
|
|
3835
|
+
`, [peerId, peerKey, multiaddr, nowMs, peerStampId, invite.issuerKey, usageStampId]);
|
|
3836
|
+
}
|
|
3837
|
+
if (ownerStampId !== null && seated.ownerStampId === null) {
|
|
3838
|
+
await this.db.exec(`
|
|
3839
|
+
insert into CadreControl.OwnerKey (Key, StampId, VouchOwner, VouchSig, VouchUsage)
|
|
3840
|
+
with context OwnerKey = null, Signature = null
|
|
3841
|
+
values (?, ?, ?, null, ?)
|
|
3842
|
+
`, [peerKey, ownerStampId, invite.issuerKey, usageStampId]);
|
|
3843
|
+
}
|
|
3844
|
+
await this.db.exec(`
|
|
3845
|
+
insert into CadreControl.CadreInviteUsage (UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, OwnerStampId, InviteSig, PeerSig)
|
|
3846
|
+
with context Now = ?
|
|
3847
|
+
values (?, ?, ?, ?, ?, ?, ?, ?)
|
|
3848
|
+
`, [nowCanonical, usageStampId, inviteKey, peerId, peerKey, peerStampId, ownerStampId, inviteSig, peerSig]);
|
|
3849
|
+
});
|
|
3850
|
+
log('Redeemed cadre invitation %s: %s admitted (owner=%s, usage %s)', inviteKey, peerId, ownerStampId !== null, usageStampId);
|
|
3851
|
+
return { alreadyMember: false, peerStampId, ownerStampId };
|
|
3852
|
+
});
|
|
3853
|
+
}
|
|
3854
|
+
/**
|
|
3855
|
+
* The rows a redemption would seat that are already present: the device's `CadrePeer`
|
|
3856
|
+
* stamp, and — only when the invitation grants ownership — its `OwnerKey` stamp. Raw stamp
|
|
3857
|
+
* reads ({@link queryStampId}), so a physically present row counts; inside the locked
|
|
3858
|
+
* body, so never retried.
|
|
3859
|
+
*/
|
|
3860
|
+
async readSeatedAdmission(peerId, peerKey, grantsOwner) {
|
|
3861
|
+
return {
|
|
3862
|
+
peerStampId: await this.queryStampId('CadrePeer', peerId, false),
|
|
3863
|
+
ownerStampId: grantsOwner ? await this.queryStampId('OwnerKey', peerKey, false) : null,
|
|
3864
|
+
};
|
|
3865
|
+
}
|
|
3866
|
+
/**
|
|
3867
|
+
* Refuse a redemption that would consume a seat the cadre invitation does not have, by
|
|
3868
|
+
* name ({@link InvitationExhaustedError}) — the cadre-invitation twin of
|
|
3869
|
+
* {@link assertSeatRemains}, for the same reason: the schema's cap clause refuses as a
|
|
3870
|
+
* generic `Authorized` failure a caller would otherwise report as retryable. Reads the
|
|
3871
|
+
* committed count inside the locked body, so on a same-node race the loser is refused here.
|
|
3872
|
+
*/
|
|
3873
|
+
async assertCadreInviteSeatRemains(invite) {
|
|
3874
|
+
if (invite.totalUses === null) {
|
|
3875
|
+
return;
|
|
3876
|
+
}
|
|
3877
|
+
const used = await this.countCadreInviteUsage(invite.key, false);
|
|
3878
|
+
if (used >= invite.totalUses) {
|
|
3879
|
+
throw new InvitationExhaustedError(invite.key, used, invite.totalUses);
|
|
3880
|
+
}
|
|
3881
|
+
}
|
|
2863
3882
|
/**
|
|
2864
3883
|
* Close the database and cleanup resources.
|
|
2865
3884
|
*
|