@serfab/cadre-core 0.12.0 → 1.0.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 +23 -8
- package/dist/bootstrap-peer-store-file.js +2 -2
- package/dist/bootstrap-peer-store.d.ts +32 -12
- package/dist/bootstrap-peer-store.js +41 -15
- package/dist/bootstrap-peer-store.js.map +1 -1
- package/dist/cadre-node.d.ts +883 -102
- package/dist/cadre-node.js +1800 -209
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-authorization.d.ts +2 -2
- package/dist/control-authorization.js +1 -0
- package/dist/control-authorization.js.map +1 -1
- package/dist/control-cohort.d.ts +13 -26
- package/dist/control-cohort.js +0 -26
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +318 -44
- package/dist/control-database.js +516 -74
- package/dist/control-database.js.map +1 -1
- package/dist/control-read-retry.d.ts +40 -11
- package/dist/control-read-retry.js +48 -15
- package/dist/control-read-retry.js.map +1 -1
- package/dist/control-retry.d.ts +58 -3
- package/dist/control-retry.js +58 -3
- package/dist/control-retry.js.map +1 -1
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +133 -21
- package/dist/control-schema.js.map +1 -1
- package/dist/control-write-retry.d.ts +80 -11
- package/dist/control-write-retry.js +168 -29
- package/dist/control-write-retry.js.map +1 -1
- package/dist/enrolled-machine-store-file.d.ts +13 -0
- package/dist/enrolled-machine-store-file.js +41 -0
- package/dist/enrolled-machine-store-file.js.map +1 -0
- package/dist/enrolled-machine-store.d.ts +81 -0
- package/dist/enrolled-machine-store.js +225 -0
- package/dist/enrolled-machine-store.js.map +1 -0
- package/dist/file-durable-slot.js +5 -3
- package/dist/file-durable-slot.js.map +1 -1
- package/dist/index.d.ts +17 -8
- package/dist/index.js +54 -9
- package/dist/index.js.map +1 -1
- package/dist/membership-connection-gater.d.ts +15 -1
- package/dist/membership-connection-gater.js +9 -3
- package/dist/membership-connection-gater.js.map +1 -1
- package/dist/node-local-snapshot.d.ts +21 -7
- package/dist/node-local-snapshot.js +30 -9
- package/dist/node-local-snapshot.js.map +1 -1
- package/dist/peer-dial.d.ts +143 -0
- package/dist/peer-dial.js +176 -0
- package/dist/peer-dial.js.map +1 -0
- package/dist/{strand-backfill.d.ts → peer-join-backfill.d.ts} +49 -20
- package/dist/{strand-backfill.js → peer-join-backfill.js} +133 -52
- package/dist/peer-join-backfill.js.map +1 -0
- package/dist/relay-addrs.d.ts +160 -49
- package/dist/relay-addrs.js +287 -60
- package/dist/relay-addrs.js.map +1 -1
- package/dist/relay-reservation.d.ts +71 -3
- package/dist/relay-reservation.js +116 -8
- package/dist/relay-reservation.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +30 -1
- package/dist/seed-bootstrap.js +62 -17
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/storage-scope.d.ts +62 -0
- package/dist/storage-scope.js +75 -0
- package/dist/storage-scope.js.map +1 -0
- package/dist/strand-database.d.ts +41 -11
- package/dist/strand-database.js +48 -18
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-first-sync-gate.d.ts +152 -0
- package/dist/strand-first-sync-gate.js +208 -0
- package/dist/strand-first-sync-gate.js.map +1 -0
- package/dist/strand-formation-manager.d.ts +99 -5
- package/dist/strand-formation-manager.js +119 -8
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +97 -3
- package/dist/strand-formation-protocol.js +108 -2
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +389 -19
- package/dist/strand-instance-manager.js +678 -65
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-member-key.d.ts +20 -12
- package/dist/strand-member-key.js +20 -12
- package/dist/strand-member-key.js.map +1 -1
- package/dist/strand-member-registry.js +3 -0
- package/dist/strand-member-registry.js.map +1 -1
- package/dist/strand-membership-reconciler.d.ts +361 -0
- package/dist/strand-membership-reconciler.js +548 -0
- package/dist/strand-membership-reconciler.js.map +1 -0
- package/dist/strand-membership-writer.d.ts +143 -11
- package/dist/strand-membership-writer.js +419 -135
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-network-config.d.ts +116 -0
- package/dist/strand-network-config.js +192 -0
- package/dist/strand-network-config.js.map +1 -0
- package/dist/strand-revocation-enforcer.d.ts +392 -0
- package/dist/strand-revocation-enforcer.js +533 -0
- package/dist/strand-revocation-enforcer.js.map +1 -0
- package/dist/strand-solicitation.d.ts +17 -1
- package/dist/strand-solicitation.js +13 -2
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-wake-protocol.d.ts +11 -7
- package/dist/strand-wake-protocol.js +20 -52
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/strand-watcher.js +12 -1
- package/dist/strand-watcher.js.map +1 -1
- package/dist/timed-step.d.ts +12 -0
- package/dist/timed-step.js +28 -0
- package/dist/timed-step.js.map +1 -0
- package/dist/timeout-scheduler.d.ts +19 -0
- package/dist/timeout-scheduler.js +22 -0
- package/dist/timeout-scheduler.js.map +1 -0
- package/dist/types.d.ts +472 -46
- package/dist/types.js +7 -1
- package/dist/types.js.map +1 -1
- package/package.json +16 -8
- package/src/bootstrap-peer-store-file.ts +2 -2
- package/src/bootstrap-peer-store.ts +69 -27
- package/src/cadre-node.ts +1926 -228
- package/src/control-authorization.ts +2 -1
- package/src/control-cohort.ts +122 -130
- package/src/control-database.ts +586 -74
- package/src/control-read-retry.ts +202 -167
- package/src/control-retry.ts +297 -190
- package/src/control-schema.ts +133 -21
- package/src/control-write-retry.ts +525 -383
- package/src/enrolled-machine-store-file.ts +43 -0
- package/src/enrolled-machine-store.ts +280 -0
- package/src/file-durable-slot.ts +5 -3
- package/src/index.ts +134 -15
- package/src/membership-connection-gater.ts +9 -3
- package/src/node-local-snapshot.ts +38 -9
- package/src/peer-addr-book.ts +251 -250
- package/src/peer-dial.ts +215 -0
- package/src/{strand-backfill.ts → peer-join-backfill.ts} +478 -389
- package/src/relay-addrs.ts +458 -218
- package/src/relay-reservation.ts +951 -828
- package/src/seed-bootstrap.ts +76 -18
- package/src/storage-scope.ts +78 -0
- package/src/strand-database.ts +61 -21
- package/src/strand-first-sync-gate.ts +258 -0
- package/src/strand-formation-manager.ts +162 -8
- package/src/strand-formation-protocol.ts +850 -692
- package/src/strand-instance-manager.ts +877 -74
- package/src/strand-member-key.ts +20 -12
- package/src/strand-member-registry.ts +3 -0
- package/src/strand-membership-reconciler.ts +680 -0
- package/src/strand-membership-writer.ts +550 -190
- package/src/strand-network-config.ts +250 -0
- package/src/strand-revocation-enforcer.ts +653 -0
- package/src/strand-solicitation.ts +499 -471
- package/src/strand-wake-protocol.ts +21 -66
- package/src/strand-watcher.ts +325 -314
- package/src/timed-step.ts +28 -0
- package/src/timeout-scheduler.ts +28 -0
- package/src/types.ts +487 -52
- package/dist/strand-backfill.js.map +0 -1
|
@@ -115,49 +115,118 @@ export function generateStrandStampId() {
|
|
|
115
115
|
const bytes = randomBytes(256, 'bytes');
|
|
116
116
|
return uint8ArrayToString(bytes, 'base64url');
|
|
117
117
|
}
|
|
118
|
+
// ── Write batches (every membership write is one indivisible transaction) ──────
|
|
118
119
|
/**
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
* pair (Member + ConsumedInvite) and every delete-plus-tombstone pair (the
|
|
124
|
-
* `RevocationRecorded` constraints require the `Revocation` row in the SAME
|
|
125
|
-
* transaction as the delete it retires).
|
|
126
|
-
*
|
|
127
|
-
* JOINS a caller-owned transaction instead of opening its own: Quereus's
|
|
128
|
-
* `Database.beginTransaction()` hard-throws "Transaction already active" on any
|
|
129
|
-
* nesting, and a caller composing several writers in one transaction (e.g. a
|
|
130
|
-
* resign + revoke pair that must land atomically) is exactly the case where the
|
|
131
|
-
* deferred constraints should fire once, at the CALLER's commit. In joined mode
|
|
132
|
-
* commit/rollback belong to the caller, so failures simply propagate.
|
|
120
|
+
* A membership writer that runs only in a transaction of its own found another
|
|
121
|
+
* transaction already open on the strand database, and wrote nothing. Raised only
|
|
122
|
+
* under `joinOpenTransaction: false` (see {@link StrandWriteOptions}) — the background
|
|
123
|
+
* callers, which treat it as "the app is mid-transaction; try again later".
|
|
133
124
|
*/
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
125
|
+
export class StrandTransactionBusyError extends Error {
|
|
126
|
+
constructor(options) {
|
|
127
|
+
super('Another transaction is open on the strand database. This membership write runs only in a '
|
|
128
|
+
+ 'transaction of its own, so nothing was written; retry once that transaction closes.', options);
|
|
129
|
+
this.name = 'StrandTransactionBusyError';
|
|
138
130
|
}
|
|
139
|
-
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Join statement fragments into one batch. Named parameters bind across the whole batch,
|
|
134
|
+
* so each fragment builder uses its own parameter names; a name bound twice would
|
|
135
|
+
* silently hand one fragment the other's value, so it throws instead.
|
|
136
|
+
*/
|
|
137
|
+
function combineStatements(...fragments) {
|
|
138
|
+
const params = {};
|
|
139
|
+
for (const fragment of fragments) {
|
|
140
|
+
for (const [name, value] of Object.entries(fragment.params)) {
|
|
141
|
+
if (name in params) {
|
|
142
|
+
throw new Error(`Strand write batch binds the parameter :${name} in two statements`);
|
|
143
|
+
}
|
|
144
|
+
params[name] = value;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { sql: fragments.map((fragment) => fragment.sql).join('\n'), params };
|
|
148
|
+
}
|
|
149
|
+
/** Quereus's refusal of a `begin` issued while a transaction is already open. */
|
|
150
|
+
const NESTED_BEGIN_REFUSAL = /^Cannot begin transaction: already in a transaction$/;
|
|
151
|
+
function isNestedBeginRefusal(error) {
|
|
152
|
+
return error instanceof Error && NESTED_BEGIN_REFUSAL.test(error.message);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Run `statements` as one indivisible transaction on the shared strand database.
|
|
156
|
+
*
|
|
157
|
+
* The whole transaction is ONE `db.exec` batch — `begin transaction; <statements> commit;`
|
|
158
|
+
* — because Quereus holds its exec mutex for a whole batch but releases it between
|
|
159
|
+
* separate calls, and any caller's statement that runs while an explicit transaction is
|
|
160
|
+
* open lands inside it. A `beginTransaction()` / `exec` / `commit()` sequence therefore
|
|
161
|
+
* swept the app's concurrent writes into a background writer's transaction, and lost them
|
|
162
|
+
* when that transaction failed.
|
|
163
|
+
*
|
|
164
|
+
* Whether a transaction is already open is decided by the batch's own `begin`, under the
|
|
165
|
+
* mutex, not by `getAutocommit()` beforehand: that reads false whenever another caller's
|
|
166
|
+
* autocommit statement is merely in flight, which would make a background writer refuse
|
|
167
|
+
* spuriously and a composing caller run its statements as separate autocommits.
|
|
168
|
+
*
|
|
169
|
+
* - The `begin` succeeds: the batch commits or fails as a unit. A failure at `commit`
|
|
170
|
+
* (deferred constraints, a refused optimystic commit) leaves nothing open. A failure at
|
|
171
|
+
* an earlier statement leaves the batch's transaction open, so it is rolled back here.
|
|
172
|
+
* - The `begin` is refused because a transaction is already open: nothing has run. With
|
|
173
|
+
* `joinOpenTransaction: false` that is a {@link StrandTransactionBusyError} (with the
|
|
174
|
+
* refusal as `cause`), and the open transaction — someone else's — is left alone.
|
|
175
|
+
* Otherwise the statements run inside it, and its owner commits or rolls back.
|
|
176
|
+
*
|
|
177
|
+
* NOTE: interim until Quereus ships a batch that rolls back before releasing its mutex —
|
|
178
|
+
* requested as `../quereus/tickets/plan/exec-batch-as-one-transaction.md`; switch to that
|
|
179
|
+
* API when a Quereus release carries it, which also retires the message match in
|
|
180
|
+
* {@link isNestedBeginRefusal}. Until then two residual windows remain. (1) A statement
|
|
181
|
+
* that fails before `commit` (a primary-key collision such as a sibling machine seating the
|
|
182
|
+
* same `Member` first, an immediate CHECK, a storage read error) releases the mutex with the
|
|
183
|
+
* batch's transaction still open: an app `exec` already queued behind the batch runs inside
|
|
184
|
+
* it, and the rollback below discards that app write although its `exec` resolved. The
|
|
185
|
+
* common failures (an `Invite` not replicated yet, expired, cancelled, sealed, and
|
|
186
|
+
* optimystic commit refusals) all fail at `commit` and leave nothing open. (2) The rollback
|
|
187
|
+
* decision reads `getAutocommit()` after the batch has released the mutex, so after a
|
|
188
|
+
* `commit`-time failure a queued app statement in flight reads as "still open" too; the
|
|
189
|
+
* rollback then runs after that statement's own commit and finds nothing to undo — unless
|
|
190
|
+
* an app `beginTransaction()` also slipped in between, which it would end.
|
|
191
|
+
*/
|
|
192
|
+
async function execStrandTransaction(db, statements, options) {
|
|
140
193
|
try {
|
|
141
|
-
await
|
|
142
|
-
|
|
194
|
+
await db.exec(`begin transaction;\n${statements.sql}\ncommit;`, statements.params);
|
|
195
|
+
return;
|
|
143
196
|
}
|
|
144
197
|
catch (error) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
await db.rollback();
|
|
198
|
+
if (!isNestedBeginRefusal(error)) {
|
|
199
|
+
await rollbackBatchLeftOpen(db, error);
|
|
200
|
+
throw error;
|
|
149
201
|
}
|
|
150
|
-
|
|
151
|
-
|
|
202
|
+
if (options?.joinOpenTransaction === false) {
|
|
203
|
+
throw new StrandTransactionBusyError({ cause: error });
|
|
152
204
|
}
|
|
153
|
-
throw error;
|
|
154
205
|
}
|
|
206
|
+
await db.exec(statements.sql, statements.params);
|
|
155
207
|
}
|
|
156
208
|
/**
|
|
157
|
-
*
|
|
209
|
+
* Roll back the batch's own transaction when a statement before `commit` failed and left
|
|
210
|
+
* it open; a `commit`-time failure has already closed it. See the NOTE on
|
|
211
|
+
* {@link execStrandTransaction} for what this cannot tell apart.
|
|
212
|
+
*/
|
|
213
|
+
async function rollbackBatchLeftOpen(db, cause) {
|
|
214
|
+
if (db.getAutocommit()) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
await db.rollback();
|
|
219
|
+
log('Rolled back a strand write batch left open by a failed statement: %s', cause);
|
|
220
|
+
}
|
|
221
|
+
catch (rollbackError) {
|
|
222
|
+
log('Rolling back a failed strand write batch failed (%s): %o', cause, rollbackError);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* The `Strand.Revocation` tombstone insert that retires a deleted row's StampId.
|
|
158
227
|
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
228
|
+
* Always batched with the delete it accompanies — the guarded tables'
|
|
229
|
+
* `RevocationRecorded` (deferred) requires the tombstone at commit, and
|
|
161
230
|
* `Revocation.RowIsGone` (also deferred) requires the row to already be gone, so
|
|
162
231
|
* the pair only ever lands together.
|
|
163
232
|
*
|
|
@@ -168,16 +237,42 @@ async function inStrandTransaction(db, fn) {
|
|
|
168
237
|
* removes it. Unlike the sibling tables' nullable contexts, `Revocation`'s
|
|
169
238
|
* context is non-null — both fields are always supplied.
|
|
170
239
|
*
|
|
171
|
-
* @param db - The strand database, INSIDE an open transaction.
|
|
172
240
|
* @param tableName - Which guarded table the stamp belonged to.
|
|
173
241
|
* @param stampId - The retired stamp.
|
|
174
242
|
* @param retiree - The committed member filing the tombstone (the delete's own signer).
|
|
175
243
|
*/
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
with context MemberKey =
|
|
180
|
-
values (
|
|
244
|
+
function revocationStatement(tableName, stampId, retiree) {
|
|
245
|
+
return {
|
|
246
|
+
sql: `insert into Strand.Revocation (TableName, StampId)
|
|
247
|
+
with context MemberKey = :revocationSignerKey, Signature = :revocationSignature
|
|
248
|
+
values (:revocationTable, :revocationStampId);`,
|
|
249
|
+
params: {
|
|
250
|
+
revocationSignerKey: retiree.publicKeyB64,
|
|
251
|
+
revocationSignature: signStrandApproval(['Strand.Revocation', 'retire', tableName, stampId], retiree.privateKeyB64),
|
|
252
|
+
revocationTable: tableName,
|
|
253
|
+
revocationStampId: stampId,
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* A closed strand founded BEFORE the per-party identity split: its founding
|
|
259
|
+
* `Strand.Member` (and, unless handed on, `Strand.Manager`) is the key derived from the
|
|
260
|
+
* shared `MemberPrivateKey`, which every joining party holds, so any member can sign as
|
|
261
|
+
* the founder. There is no repair — rewriting the membership cannot be trusted, since any
|
|
262
|
+
* joiner could already have admitted or revoked anyone, or could race the rewrite — so
|
|
263
|
+
* the strand must be recreated. Thrown by {@link assertNotPreSplitStrand} (a founder
|
|
264
|
+
* bootstrap, or membership-invite issuance); the formation layer maps it to a
|
|
265
|
+
* non-retryable rejection.
|
|
266
|
+
*/
|
|
267
|
+
export class PreSplitStrandIdentityError extends Error {
|
|
268
|
+
constructor(strandId) {
|
|
269
|
+
super(`Closed strand ${strandId} was founded before per-party strand identity: its founding ` +
|
|
270
|
+
'member key is derived from the shared MemberPrivateKey that every joining party holds, ' +
|
|
271
|
+
'so any member can act as its founder. It cannot be repaired — recreate the strand ' +
|
|
272
|
+
'(unpublish it and found a new one).');
|
|
273
|
+
this.strandId = strandId;
|
|
274
|
+
this.name = 'PreSplitStrandIdentityError';
|
|
275
|
+
}
|
|
181
276
|
}
|
|
182
277
|
/**
|
|
183
278
|
* Count rows in a `Strand.*` table as seen by this database instance.
|
|
@@ -204,6 +299,13 @@ async function strandTableCount(db, table) {
|
|
|
204
299
|
* scale; if membership churn ever makes that table large — the growth the
|
|
205
300
|
* `Revocation` schema comment already flags, tracked by `debt-strand-tombstone-reap`
|
|
206
301
|
* — this wants a stored "founding closed" marker rather than a scan per call.
|
|
302
|
+
*
|
|
303
|
+
* NOTE: on a strand that has never revoked anyone, `Strand.Revocation` has never been
|
|
304
|
+
* written, so this node does not hold its block, and the storage layer consults the
|
|
305
|
+
* block's cohort on every read of a block it does not hold — this scan and every
|
|
306
|
+
* `NotRevoked` insert check pay that per call. Not measured, and cheap while strand
|
|
307
|
+
* membership writes are rare; if they become frequent, file a singleton marker row in
|
|
308
|
+
* the strand schema the way the control schema does (`ControlDatabase.openRevocationLedger`).
|
|
207
309
|
*/
|
|
208
310
|
async function strandHasManagerRevocation(db) {
|
|
209
311
|
for await (const row of db.eval('select TableName from Strand.Revocation')) {
|
|
@@ -305,6 +407,25 @@ async function insertFounderManagerIfAbsent(db, memberKey, strandId) {
|
|
|
305
407
|
values (?, 0, ?)`, [memberKey, generateStrandStampId()]);
|
|
306
408
|
log('Inserted founding Manager for strand %s', strandId);
|
|
307
409
|
}
|
|
410
|
+
/**
|
|
411
|
+
* Refuse a closed strand whose membership still holds the key derived from the shared
|
|
412
|
+
* `MemberPrivateKey` (see {@link FounderBootstrapParams.sharedMemberPublicKey}). `Member`
|
|
413
|
+
* is the primary probe — every manager is a member (`Manager.MemberExists`), so it also
|
|
414
|
+
* catches a pre-split founder who handed management to another key and resigned, or a
|
|
415
|
+
* sealed pre-split strand. `Manager` is probed too, for the partition case the schema
|
|
416
|
+
* notes on `MemberExists` (a Manager row converged without its Member row). Cannot
|
|
417
|
+
* misfire: nothing seats the shared-derived key since the split, and rows not yet loaded
|
|
418
|
+
* read as absent.
|
|
419
|
+
*
|
|
420
|
+
* @throws {PreSplitStrandIdentityError} when a `Strand.Member` or `Strand.Manager` row is
|
|
421
|
+
* the shared-derived key.
|
|
422
|
+
*/
|
|
423
|
+
export async function assertNotPreSplitStrand(db, strandId, sharedMemberPublicKey) {
|
|
424
|
+
if (await memberStampId(db, sharedMemberPublicKey) !== undefined
|
|
425
|
+
|| await managerRow(db, sharedMemberPublicKey) !== undefined) {
|
|
426
|
+
throw new PreSplitStrandIdentityError(strandId);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
308
429
|
/**
|
|
309
430
|
* Founder-only one-time bootstrap of a strand's `Strand.*` membership/RBAC rows.
|
|
310
431
|
*
|
|
@@ -326,20 +447,30 @@ async function insertFounderManagerIfAbsent(db, memberKey, strandId) {
|
|
|
326
447
|
* Manager could never admit anyone, so failing loudly here (which propagates out
|
|
327
448
|
* of `StrandDatabase.initialize()` and triggers the runtime's rollback) is correct.
|
|
328
449
|
*
|
|
450
|
+
* A closed strand whose membership holds `sharedMemberPublicKey` throws
|
|
451
|
+
* {@link PreSplitStrandIdentityError} before writing anything: the insert-if-absent
|
|
452
|
+
* guards would otherwise skip over its pre-split founding rows and leave the shared
|
|
453
|
+
* key seated.
|
|
454
|
+
*
|
|
329
455
|
* @param db - The strand's Quereus database (schema already applied).
|
|
330
456
|
* @param params - Strand id/type, the sApp config for the Header, and (closed only)
|
|
331
|
-
* the derived founder keypair.
|
|
457
|
+
* the derived founder keypair and the shared-derived public key to refuse.
|
|
332
458
|
* @throws If `type === 'c'` and no `founderKeyPair` is supplied.
|
|
459
|
+
* @throws {PreSplitStrandIdentityError} If `type === 'c'` and a `Strand.Member` or
|
|
460
|
+
* `Strand.Manager` row is `sharedMemberPublicKey`.
|
|
333
461
|
*/
|
|
334
462
|
export async function bootstrapFounderMembership(db, params) {
|
|
335
|
-
const { strandId, type, founderKeyPair } = params;
|
|
463
|
+
const { strandId, type, founderKeyPair, sharedMemberPublicKey } = params;
|
|
336
464
|
log('Founder bootstrap for strand %s (type %s)', strandId, type);
|
|
337
465
|
// Validate BEFORE writing anything: a closed strand with no founder key must
|
|
338
466
|
// fail without leaving a closed `Header` that has no founding Member/Manager
|
|
339
467
|
// (such a strand could never admit anyone).
|
|
340
468
|
if (type === 'c' && !founderKeyPair) {
|
|
341
|
-
throw new Error(`Cannot bootstrap closed strand ${strandId}: no founder key pair derived from
|
|
342
|
-
'A closed strand needs a founding Member + Manager or it can never admit members.');
|
|
469
|
+
throw new Error(`Cannot bootstrap closed strand ${strandId}: no founder key pair derived from this party's ` +
|
|
470
|
+
'StrandPartyKey. A closed strand needs a founding Member + Manager or it can never admit members.');
|
|
471
|
+
}
|
|
472
|
+
if (type === 'c' && sharedMemberPublicKey !== undefined) {
|
|
473
|
+
await assertNotPreSplitStrand(db, strandId, sharedMemberPublicKey);
|
|
343
474
|
}
|
|
344
475
|
// Header is written for every strand; Member/Manager are closed-only.
|
|
345
476
|
await insertHeaderIfAbsent(db, params);
|
|
@@ -428,32 +559,81 @@ export async function issueInvite(db, params) {
|
|
|
428
559
|
* @param db - The closed strand's database.
|
|
429
560
|
* @param params - The invite key/secret, the joining member's public key, and an
|
|
430
561
|
* optional `nowMs` instant for the expiry comparison (default `Date.now()`).
|
|
562
|
+
* @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
|
|
431
563
|
* @throws If any constraint rejects (bad signature, missing invite, or an expired
|
|
432
564
|
* invite); the whole transaction rolls back (neither the `Member` nor the
|
|
433
565
|
* `ConsumedInvite` row survives).
|
|
566
|
+
* @throws {StrandTransactionBusyError} Under `joinOpenTransaction: false`, when another
|
|
567
|
+
* transaction is open; nothing was written.
|
|
434
568
|
*/
|
|
435
|
-
export async function consumeInvite(db, params) {
|
|
436
|
-
const { inviteKey,
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
await db.exec(`insert into Strand.Member (Key, StampId)
|
|
447
|
-
with context ManagerKey = null, ManagerSignature = null, MemberSignature = null
|
|
448
|
-
values (?, ?)`, [memberKey, generateStrandStampId()]);
|
|
449
|
-
// 2. ConsumedInvite — proves possession of the invite private key and that the
|
|
450
|
-
// invite has not expired (NotExpired gate against the canonical Now).
|
|
451
|
-
await db.exec(`insert into Strand.ConsumedInvite (InviteKey, MemberKey)
|
|
452
|
-
with context InviteSignature = ?, Now = ?
|
|
453
|
-
values (?, ?)`, [inviteSignature, nowCanonical, inviteKey, memberKey]);
|
|
454
|
-
});
|
|
569
|
+
export async function consumeInvite(db, params, options) {
|
|
570
|
+
const { inviteKey, memberKey } = params;
|
|
571
|
+
// Member first — admitted by the deferred invite branch (the matching ConsumedInvite
|
|
572
|
+
// after it), so no manager signature is supplied.
|
|
573
|
+
const member = {
|
|
574
|
+
sql: `insert into Strand.Member (Key, StampId)
|
|
575
|
+
with context ManagerKey = null, ManagerSignature = null, MemberSignature = null
|
|
576
|
+
values (:memberKey, :memberStampId);`,
|
|
577
|
+
params: { memberKey, memberStampId: generateStrandStampId() },
|
|
578
|
+
};
|
|
579
|
+
await execStrandTransaction(db, combineStatements(member, await consumedInviteStatement(db, params)), options);
|
|
455
580
|
log('Consumed invite %s -> member %s', inviteKey, memberKey);
|
|
456
581
|
}
|
|
582
|
+
/**
|
|
583
|
+
* The `Strand.ConsumedInvite` marker row insert — the possession-plus-freshness proof
|
|
584
|
+
* shared by {@link consumeInvite} (batched after its `Member` insert) and
|
|
585
|
+
* {@link burnInvite} (alone, against an already-seated member).
|
|
586
|
+
*
|
|
587
|
+
* The invite signature covers `InviteKey || '|' || MemberKey` (the `ValidUsage` gate),
|
|
588
|
+
* and "now" is canonicalised the same way {@link issueInvite} canonicalises
|
|
589
|
+
* `Expiration`, so the schema's `I.Expiration > context.Now` compares like-for-like
|
|
590
|
+
* canonical strings. Plain runtime Date.now() — the tess Workflow restriction is on
|
|
591
|
+
* scripts, not libs.
|
|
592
|
+
*/
|
|
593
|
+
async function consumedInviteStatement(db, params) {
|
|
594
|
+
const { inviteKey, invitePrivateKey, memberKey, nowMs } = params;
|
|
595
|
+
return {
|
|
596
|
+
sql: `insert into Strand.ConsumedInvite (InviteKey, MemberKey)
|
|
597
|
+
with context InviteSignature = :consumedSignature, Now = :consumedNow
|
|
598
|
+
values (:consumedInviteKey, :consumedMemberKey);`,
|
|
599
|
+
params: {
|
|
600
|
+
consumedSignature: signStrandPayload(`${inviteKey}|${memberKey}`, invitePrivateKey),
|
|
601
|
+
consumedNow: await canonicalDatetime(db, nowMs ?? Date.now()),
|
|
602
|
+
consumedInviteKey: inviteKey,
|
|
603
|
+
consumedMemberKey: memberKey,
|
|
604
|
+
},
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Burn an invitation against an ALREADY-SEATED member: insert the
|
|
609
|
+
* `Strand.ConsumedInvite` row alone, naming the existing `Member.Key`, so the bearer
|
|
610
|
+
* credential can never be spent by anyone else.
|
|
611
|
+
*
|
|
612
|
+
* The already-member arm of the bring-up membership reconciler
|
|
613
|
+
* (`strand-membership-reconciler.ts`): a machine that finds its party's `Member` row
|
|
614
|
+
* already present (a sibling machine redeemed the invitation's twin first, or a
|
|
615
|
+
* manager admitted the party directly) may still hold an UNSPENT formation-delivered
|
|
616
|
+
* invitation — a bearer credential whoever presents can join with. Every
|
|
617
|
+
* `ConsumedInvite` constraint is satisfiable without a same-transaction `Member`
|
|
618
|
+
* insert (`MemberExists` reads the live table), so this is one statement whose
|
|
619
|
+
* deferred checks fire at its own commit. It seats nobody now or later:
|
|
620
|
+
* `Member.Authorized`'s invite branch requires a same-transaction FRESH consumption,
|
|
621
|
+
* so the row it leaves is exactly as inert as any other spent invitation's.
|
|
622
|
+
*
|
|
623
|
+
* @param db - The closed strand's database (the member already exists).
|
|
624
|
+
* @param params - The invite key/secret and the EXISTING member's public key, plus the
|
|
625
|
+
* optional `nowMs` instant for the expiry gate (default `Date.now()`).
|
|
626
|
+
* @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
|
|
627
|
+
* @throws If any constraint rejects — already consumed (the `InviteKey` primary key),
|
|
628
|
+
* cancelled, expired, or a sealed strand. Callers treat a burn failure as "already
|
|
629
|
+
* dead" and log rather than retry.
|
|
630
|
+
* @throws {StrandTransactionBusyError} Under `joinOpenTransaction: false`, when another
|
|
631
|
+
* transaction is open; nothing was tried, so the invitation is exactly as live as before.
|
|
632
|
+
*/
|
|
633
|
+
export async function burnInvite(db, params, options) {
|
|
634
|
+
await execStrandTransaction(db, await consumedInviteStatement(db, params), options);
|
|
635
|
+
log('Burned invite %s against existing member %s', params.inviteKey, params.memberKey);
|
|
636
|
+
}
|
|
457
637
|
/**
|
|
458
638
|
* Every value of `InviteKey` in one of the two invite-marker tables, as a `Set`.
|
|
459
639
|
*
|
|
@@ -593,6 +773,17 @@ async function memberStampId(db, memberKey) {
|
|
|
593
773
|
}
|
|
594
774
|
return undefined;
|
|
595
775
|
}
|
|
776
|
+
/**
|
|
777
|
+
* Whether `memberKey` holds a live `Strand.Member` row, as seen by THIS database
|
|
778
|
+
* instance — the membership probe behind the bring-up reconciler
|
|
779
|
+
* (`strand-membership-reconciler.ts`). Reads via {@link memberStampId}'s unfiltered
|
|
780
|
+
* scan + JavaScript compare (the {@link scanMemberPeers} argument for why a PK point
|
|
781
|
+
* lookup is not reliable on a networked strand). Local visibility only: `false` can
|
|
782
|
+
* mean "not replicated here yet", never a durable verdict about the strand.
|
|
783
|
+
*/
|
|
784
|
+
export async function isStrandMember(db, memberKey) {
|
|
785
|
+
return await memberStampId(db, memberKey) != null;
|
|
786
|
+
}
|
|
596
787
|
/**
|
|
597
788
|
* Admit a `Member` directly by manager signature — the sibling of the invite
|
|
598
789
|
* path on `Member.Authorized`'s direct-manager branch.
|
|
@@ -608,18 +799,31 @@ async function memberStampId(db, memberKey) {
|
|
|
608
799
|
*
|
|
609
800
|
* @param db - The closed strand's database.
|
|
610
801
|
* @param params - The admitting manager keypair and the new member key.
|
|
802
|
+
* @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
|
|
611
803
|
* @throws If `Member.Authorized` rejects (e.g. a non-manager key); the insert
|
|
612
804
|
* rolls back, leaving no `Member` row.
|
|
613
805
|
*/
|
|
614
|
-
export async function addMemberByManager(db, params) {
|
|
806
|
+
export async function addMemberByManager(db, params, options) {
|
|
615
807
|
const { managerKeyPair, memberKey } = params;
|
|
616
|
-
|
|
617
|
-
const signature = signStrandApproval(['Strand.Member', 'add', memberKey, stampId], managerKeyPair.privateKeyB64);
|
|
618
|
-
await db.exec(`insert into Strand.Member (Key, StampId)
|
|
619
|
-
with context ManagerKey = ?, ManagerSignature = ?, MemberSignature = null
|
|
620
|
-
values (?, ?)`, [managerKeyPair.publicKeyB64, signature, memberKey, stampId]);
|
|
808
|
+
await execStrandTransaction(db, memberAddByManagerStatement(params), options);
|
|
621
809
|
log('Admitted member %s by manager %s', memberKey, managerKeyPair.publicKeyB64);
|
|
622
810
|
}
|
|
811
|
+
/** The manager-signed `Strand.Member` insert behind {@link addMemberByManager}, under a fresh stamp. */
|
|
812
|
+
function memberAddByManagerStatement(params) {
|
|
813
|
+
const { managerKeyPair, memberKey } = params;
|
|
814
|
+
const stampId = generateStrandStampId();
|
|
815
|
+
return {
|
|
816
|
+
sql: `insert into Strand.Member (Key, StampId)
|
|
817
|
+
with context ManagerKey = :memberAdmitterKey, ManagerSignature = :memberAdmitterSignature, MemberSignature = null
|
|
818
|
+
values (:memberKey, :memberStampId);`,
|
|
819
|
+
params: {
|
|
820
|
+
memberAdmitterKey: managerKeyPair.publicKeyB64,
|
|
821
|
+
memberAdmitterSignature: signStrandApproval(['Strand.Member', 'add', memberKey, stampId], managerKeyPair.privateKeyB64),
|
|
822
|
+
memberKey,
|
|
823
|
+
memberStampId: stampId,
|
|
824
|
+
},
|
|
825
|
+
};
|
|
826
|
+
}
|
|
623
827
|
/**
|
|
624
828
|
* Revoke a `Member` on the signature of an existing manager.
|
|
625
829
|
*
|
|
@@ -656,27 +860,39 @@ export async function addMemberByManager(db, params) {
|
|
|
656
860
|
* (`Member.MinOneMember`, a local-count floor with the cross-node caveat its
|
|
657
861
|
* schema NOTE states).
|
|
658
862
|
*
|
|
863
|
+
* Callers running inside a cadre runtime should follow this write with
|
|
864
|
+
* `CadreNode.refreshRevocationEnforcement(strandId)`. The removed party's
|
|
865
|
+
* machines are refused at the network layer by the strand's revoked-peer gate
|
|
866
|
+
* (`strand-revocation-enforcer.ts`), which polls for membership changes because
|
|
867
|
+
* this bare `Database` handle raises no event the runtime can hook — so without
|
|
868
|
+
* that call the cut lands up to one poll interval (default 30 s) later.
|
|
869
|
+
*
|
|
659
870
|
* @param db - The closed strand's database.
|
|
660
871
|
* @param params - The revoking manager keypair and the target member key.
|
|
872
|
+
* @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
|
|
661
873
|
* @throws If `Member.Authorized` rejects (a non-manager or same-transaction
|
|
662
874
|
* signer), `Member.NotAManager` rejects (the target still holds a Manager
|
|
663
875
|
* row), or `Member.MinOneMember` rejects (the removal would empty the member
|
|
664
876
|
* set); the whole delete+tombstone transaction rolls back.
|
|
665
877
|
*/
|
|
666
|
-
export async function revokeMember(db, params) {
|
|
878
|
+
export async function revokeMember(db, params, options) {
|
|
667
879
|
const { managerKeyPair, memberKey } = params;
|
|
668
880
|
const stampId = await memberStampId(db, memberKey);
|
|
669
881
|
if (stampId == null) {
|
|
670
882
|
log('Member %s already absent; skipping revoke', memberKey);
|
|
671
883
|
return;
|
|
672
884
|
}
|
|
673
|
-
const
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
885
|
+
const removal = {
|
|
886
|
+
sql: `delete from Strand.Member
|
|
887
|
+
with context ManagerKey = :memberRemoverKey, ManagerSignature = :memberRemoverSignature, MemberSignature = null
|
|
888
|
+
where Key = :memberKey;`,
|
|
889
|
+
params: {
|
|
890
|
+
memberRemoverKey: managerKeyPair.publicKeyB64,
|
|
891
|
+
memberRemoverSignature: signStrandApproval(['Strand.Member', 'remove', memberKey, stampId], managerKeyPair.privateKeyB64),
|
|
892
|
+
memberKey,
|
|
893
|
+
},
|
|
894
|
+
};
|
|
895
|
+
await execStrandTransaction(db, combineStatements(removal, revocationStatement('Member', stampId, managerKeyPair)), options);
|
|
680
896
|
log('Revoked member %s by manager %s', memberKey, managerKeyPair.publicKeyB64);
|
|
681
897
|
}
|
|
682
898
|
/**
|
|
@@ -693,12 +909,18 @@ export async function revokeMember(db, params) {
|
|
|
693
909
|
* The same floors as {@link revokeMember} apply: a manager must resign first
|
|
694
910
|
* (`NotAManager`) and the last member cannot leave (`MinOneMember`).
|
|
695
911
|
*
|
|
912
|
+
* Like {@link revokeMember}, a caller inside a cadre runtime should follow this
|
|
913
|
+
* with `CadreNode.refreshRevocationEnforcement(strandId)` — here it is what
|
|
914
|
+
* makes the departing node notice its own departure promptly (the gate emits
|
|
915
|
+
* `strand:revoked` when it finds this node's own peer id revoked).
|
|
916
|
+
*
|
|
696
917
|
* @param db - The closed strand's database.
|
|
697
918
|
* @param params - The departing member's own keypair.
|
|
919
|
+
* @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
|
|
698
920
|
* @throws If `Member.Authorized`, `Member.NotAManager`, or `Member.MinOneMember`
|
|
699
921
|
* rejects; the whole delete+tombstone transaction rolls back.
|
|
700
922
|
*/
|
|
701
|
-
export async function leaveStrand(db, params) {
|
|
923
|
+
export async function leaveStrand(db, params, options) {
|
|
702
924
|
const { memberKeyPair } = params;
|
|
703
925
|
const memberKey = memberKeyPair.publicKeyB64;
|
|
704
926
|
const stampId = await memberStampId(db, memberKey);
|
|
@@ -706,13 +928,16 @@ export async function leaveStrand(db, params) {
|
|
|
706
928
|
log('Member %s already absent; skipping leave', memberKey);
|
|
707
929
|
return;
|
|
708
930
|
}
|
|
709
|
-
const
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
931
|
+
const departure = {
|
|
932
|
+
sql: `delete from Strand.Member
|
|
933
|
+
with context ManagerKey = null, ManagerSignature = null, MemberSignature = :memberSignature
|
|
934
|
+
where Key = :memberKey;`,
|
|
935
|
+
params: {
|
|
936
|
+
memberSignature: signStrandApproval(['Strand.Member', 'leave', memberKey, stampId], memberKeyPair.privateKeyB64),
|
|
937
|
+
memberKey,
|
|
938
|
+
},
|
|
939
|
+
};
|
|
940
|
+
await execStrandTransaction(db, combineStatements(departure, revocationStatement('Member', stampId, memberKeyPair)), options);
|
|
716
941
|
log('Member %s left the strand', memberKey);
|
|
717
942
|
}
|
|
718
943
|
/**
|
|
@@ -745,10 +970,13 @@ export async function leaveStrand(db, params) {
|
|
|
745
970
|
*
|
|
746
971
|
* @param db - The closed strand's database (the member already exists).
|
|
747
972
|
* @param params - The member's own keypair and the peer id to bind.
|
|
973
|
+
* @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
|
|
748
974
|
* @throws If `MemberPeer.Authorized`/`MemberExists` rejects (wrong signer, or no
|
|
749
975
|
* matching `Member` row); the insert rolls back, leaving no `MemberPeer` row.
|
|
976
|
+
* @throws {StrandTransactionBusyError} Under `joinOpenTransaction: false`, when another
|
|
977
|
+
* transaction is open; nothing was written.
|
|
750
978
|
*/
|
|
751
|
-
export async function registerMemberPeer(db, params) {
|
|
979
|
+
export async function registerMemberPeer(db, params, options) {
|
|
752
980
|
const { memberKeyPair, peerId } = params;
|
|
753
981
|
const memberKey = memberKeyPair.publicKeyB64;
|
|
754
982
|
if (await memberPeerStampId(db, memberKey, peerId) != null) {
|
|
@@ -756,10 +984,17 @@ export async function registerMemberPeer(db, params) {
|
|
|
756
984
|
return;
|
|
757
985
|
}
|
|
758
986
|
const stampId = generateStrandStampId();
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
with context Signature =
|
|
762
|
-
values (
|
|
987
|
+
await execStrandTransaction(db, {
|
|
988
|
+
sql: `insert into Strand.MemberPeer (MemberKey, PeerId, StampId)
|
|
989
|
+
with context Signature = :peerSignature, ManagerKey = null, ManagerSignature = null
|
|
990
|
+
values (:peerMemberKey, :peerId, :peerStampId);`,
|
|
991
|
+
params: {
|
|
992
|
+
peerSignature: signStrandApproval(['Strand.MemberPeer', 'add', memberKey, peerId, stampId], memberKeyPair.privateKeyB64),
|
|
993
|
+
peerMemberKey: memberKey,
|
|
994
|
+
peerId,
|
|
995
|
+
peerStampId: stampId,
|
|
996
|
+
},
|
|
997
|
+
}, options);
|
|
763
998
|
log('Registered MemberPeer (%s, %s)', memberKey, peerId);
|
|
764
999
|
}
|
|
765
1000
|
/**
|
|
@@ -866,10 +1101,13 @@ async function memberPeerStampId(db, memberKey, peerId) {
|
|
|
866
1101
|
* @param db - The closed strand's database.
|
|
867
1102
|
* @param params - Either the owning member's keypair, or a manager's keypair plus the
|
|
868
1103
|
* target member key.
|
|
1104
|
+
* @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
|
|
869
1105
|
* @throws If `MemberPeer.Authorized` rejects (neither branch satisfied), or if the row
|
|
870
1106
|
* is still present after the delete (see the point-lookup note below).
|
|
1107
|
+
* @throws {StrandTransactionBusyError} Under `joinOpenTransaction: false`, when another
|
|
1108
|
+
* transaction is open; nothing was written.
|
|
871
1109
|
*/
|
|
872
|
-
export async function removeMemberPeer(db, params) {
|
|
1110
|
+
export async function removeMemberPeer(db, params, options) {
|
|
873
1111
|
const memberKey = 'memberKeyPair' in params ? params.memberKeyPair.publicKeyB64 : params.memberKey;
|
|
874
1112
|
const { peerId } = params;
|
|
875
1113
|
const stampId = await memberPeerStampId(db, memberKey, peerId);
|
|
@@ -877,14 +1115,10 @@ export async function removeMemberPeer(db, params) {
|
|
|
877
1115
|
log('MemberPeer (%s, %s) already absent; skipping', memberKey, peerId);
|
|
878
1116
|
return;
|
|
879
1117
|
}
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
else {
|
|
885
|
-
await deleteMemberPeerByManager(db, params, stampId);
|
|
886
|
-
}
|
|
887
|
-
});
|
|
1118
|
+
const removal = 'memberKeyPair' in params
|
|
1119
|
+
? ownMemberPeerRemovalStatements(params, stampId)
|
|
1120
|
+
: memberPeerRemovalByManagerStatements(params, stampId);
|
|
1121
|
+
await execStrandTransaction(db, removal, options);
|
|
888
1122
|
// NOTE: the delete's `where` puts an equality on BOTH composite-PK columns, which the
|
|
889
1123
|
// optimystic vtab module serves via a single-key seek that can MISS on a networked
|
|
890
1124
|
// strand (see scanMemberPeers' doc). A missed seek deletes zero rows and still
|
|
@@ -899,23 +1133,46 @@ export async function removeMemberPeer(db, params) {
|
|
|
899
1133
|
log('Removed MemberPeer (%s, %s)', memberKey, peerId);
|
|
900
1134
|
}
|
|
901
1135
|
/** The self branch: the owning member signs the remove-tagged digest + tombstone. */
|
|
902
|
-
|
|
1136
|
+
function ownMemberPeerRemovalStatements(params, stampId) {
|
|
903
1137
|
const { memberKeyPair, peerId } = params;
|
|
904
1138
|
const memberKey = memberKeyPair.publicKeyB64;
|
|
905
|
-
const
|
|
906
|
-
|
|
907
|
-
with context Signature =
|
|
908
|
-
where MemberKey =
|
|
909
|
-
|
|
1139
|
+
const removal = {
|
|
1140
|
+
sql: `delete from Strand.MemberPeer
|
|
1141
|
+
with context Signature = :peerSignature, ManagerKey = null, ManagerSignature = null
|
|
1142
|
+
where MemberKey = :peerMemberKey and PeerId = :peerId;`,
|
|
1143
|
+
params: {
|
|
1144
|
+
peerSignature: signStrandApproval(['Strand.MemberPeer', 'remove', memberKey, peerId, stampId], memberKeyPair.privateKeyB64),
|
|
1145
|
+
peerMemberKey: memberKey,
|
|
1146
|
+
peerId,
|
|
1147
|
+
},
|
|
1148
|
+
};
|
|
1149
|
+
return combineStatements(removal, revocationStatement('MemberPeer', stampId, memberKeyPair));
|
|
910
1150
|
}
|
|
911
|
-
/**
|
|
912
|
-
|
|
1151
|
+
/**
|
|
1152
|
+
* The manager branch: a manager signs the manager-remove-tagged digest + tombstone.
|
|
1153
|
+
*
|
|
1154
|
+
* NOTE: clearing an ORPHANED binding (one whose member was already removed) also
|
|
1155
|
+
* forgets the NETWORK DENIAL of that peer id. The orphaned `MemberPeer` row IS
|
|
1156
|
+
* the record the strand revoked-peer gate derives its deny set from
|
|
1157
|
+
* (`strand-revocation-enforcer.ts`) — the `Strand.Revocation` tombstone keeps
|
|
1158
|
+
* only the stamp, not the peer id — so once the orphan is cleared, remaining
|
|
1159
|
+
* members' nodes resume answering and dialing that peer. Only clear a binding
|
|
1160
|
+
* that should never have existed, or a peer that is truly gone.
|
|
1161
|
+
*/
|
|
1162
|
+
function memberPeerRemovalByManagerStatements(params, stampId) {
|
|
913
1163
|
const { managerKeyPair, memberKey, peerId } = params;
|
|
914
|
-
const
|
|
915
|
-
|
|
916
|
-
with context Signature = null, ManagerKey =
|
|
917
|
-
where MemberKey =
|
|
918
|
-
|
|
1164
|
+
const removal = {
|
|
1165
|
+
sql: `delete from Strand.MemberPeer
|
|
1166
|
+
with context Signature = null, ManagerKey = :peerManagerKey, ManagerSignature = :peerManagerSignature
|
|
1167
|
+
where MemberKey = :peerMemberKey and PeerId = :peerId;`,
|
|
1168
|
+
params: {
|
|
1169
|
+
peerManagerKey: managerKeyPair.publicKeyB64,
|
|
1170
|
+
peerManagerSignature: signStrandApproval(['Strand.MemberPeer', 'manager-remove', memberKey, peerId, stampId], managerKeyPair.privateKeyB64),
|
|
1171
|
+
peerMemberKey: memberKey,
|
|
1172
|
+
peerId,
|
|
1173
|
+
},
|
|
1174
|
+
};
|
|
1175
|
+
return combineStatements(removal, revocationStatement('MemberPeer', stampId, managerKeyPair));
|
|
919
1176
|
}
|
|
920
1177
|
// ── Manager rotation (add / remove RBAC admins) ───────────────────────────────
|
|
921
1178
|
/**
|
|
@@ -966,11 +1223,22 @@ async function managerRow(db, memberKey) {
|
|
|
966
1223
|
*
|
|
967
1224
|
* @param db - The closed strand's database (founder manager already seated).
|
|
968
1225
|
* @param params - The authorizing manager's keypair and the new manager key.
|
|
1226
|
+
* @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
|
|
969
1227
|
* @throws If `Manager.Authorized` rejects, or if `Manager.MemberExists` rejects because
|
|
970
1228
|
* `newManagerKey` holds no `Member` row (admit it first, or use
|
|
971
1229
|
* {@link admitManager}); the insert rolls back.
|
|
972
1230
|
*/
|
|
973
|
-
export async function addManager(db, params) {
|
|
1231
|
+
export async function addManager(db, params, options) {
|
|
1232
|
+
const { byManagerKeyPair, newManagerKey } = params;
|
|
1233
|
+
const promotion = await managerAddStatement(db, params);
|
|
1234
|
+
await execStrandTransaction(db, promotion, options);
|
|
1235
|
+
log('Added manager %s (generation %d) by %s', newManagerKey, promotion.params.managerGeneration, byManagerKeyPair.publicKeyB64);
|
|
1236
|
+
}
|
|
1237
|
+
/**
|
|
1238
|
+
* The `Strand.Manager` insert behind {@link addManager}: reads the authorizer's generation,
|
|
1239
|
+
* seats the new manager one above it under a fresh stamp, and signs all three.
|
|
1240
|
+
*/
|
|
1241
|
+
async function managerAddStatement(db, params) {
|
|
974
1242
|
const { byManagerKeyPair, newManagerKey } = params;
|
|
975
1243
|
const authorizer = await managerRow(db, byManagerKeyPair.publicKeyB64);
|
|
976
1244
|
// NOTE: +1 saturates at Number.MAX_SAFE_INTEGER, where the successor compares equal
|
|
@@ -980,11 +1248,18 @@ export async function addManager(db, params) {
|
|
|
980
1248
|
// from outside this function, clamp or reject here rather than emitting a dead row.
|
|
981
1249
|
const generation = authorizer == null ? 1 : authorizer.generation + 1;
|
|
982
1250
|
const stampId = generateStrandStampId();
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
with context ManagerKey =
|
|
986
|
-
values (
|
|
987
|
-
|
|
1251
|
+
return {
|
|
1252
|
+
sql: `insert into Strand.Manager (MemberKey, Generation, StampId)
|
|
1253
|
+
with context ManagerKey = :managerAuthorizerKey, Signature = :managerSignature
|
|
1254
|
+
values (:managerMemberKey, :managerGeneration, :managerStampId);`,
|
|
1255
|
+
params: {
|
|
1256
|
+
managerAuthorizerKey: byManagerKeyPair.publicKeyB64,
|
|
1257
|
+
managerSignature: signStrandApproval(['Strand.Manager', 'add', newManagerKey, generation, stampId], byManagerKeyPair.privateKeyB64),
|
|
1258
|
+
managerMemberKey: newManagerKey,
|
|
1259
|
+
managerGeneration: generation,
|
|
1260
|
+
managerStampId: stampId,
|
|
1261
|
+
},
|
|
1262
|
+
};
|
|
988
1263
|
}
|
|
989
1264
|
/**
|
|
990
1265
|
* Admit a key as a `Member` AND promote it to `Manager`, atomically — the one-step
|
|
@@ -1013,15 +1288,15 @@ export async function addManager(db, params) {
|
|
|
1013
1288
|
*
|
|
1014
1289
|
* @param db - The closed strand's database (founder manager already seated).
|
|
1015
1290
|
* @param params - The authorizing manager's keypair and the key to admit + promote.
|
|
1291
|
+
* @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
|
|
1016
1292
|
* @throws If either half is rejected (`Member.Authorized`, `Manager.Authorized`, a
|
|
1017
1293
|
* duplicate `Member` key, …); the whole transaction rolls back, leaving neither row.
|
|
1018
1294
|
*/
|
|
1019
|
-
export async function admitManager(db, params) {
|
|
1295
|
+
export async function admitManager(db, params, options) {
|
|
1020
1296
|
const { byManagerKeyPair, newManagerKey } = params;
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
});
|
|
1297
|
+
const admission = memberAddByManagerStatement({ managerKeyPair: byManagerKeyPair, memberKey: newManagerKey });
|
|
1298
|
+
const promotion = await managerAddStatement(db, params);
|
|
1299
|
+
await execStrandTransaction(db, combineStatements(admission, promotion), options);
|
|
1025
1300
|
log('Admitted + promoted manager %s by %s', newManagerKey, byManagerKeyPair.publicKeyB64);
|
|
1026
1301
|
}
|
|
1027
1302
|
/**
|
|
@@ -1090,13 +1365,14 @@ export async function admitManager(db, params) {
|
|
|
1090
1365
|
*
|
|
1091
1366
|
* @param db - The closed strand's database.
|
|
1092
1367
|
* @param params - The authorizing keypair and the target manager key.
|
|
1368
|
+
* @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
|
|
1093
1369
|
* @throws If the signer IS the target and it is the sole manager (before writing —
|
|
1094
1370
|
* the operation the caller wants is {@link sealStrand}), or if `Manager.Authorized`
|
|
1095
1371
|
* rejects (a signer that is neither another existing manager nor the target
|
|
1096
1372
|
* itself, or a raw sole-manager `'resign'` whose post-image count is zero); the
|
|
1097
1373
|
* whole delete+tombstone transaction rolls back.
|
|
1098
1374
|
*/
|
|
1099
|
-
export async function removeManager(db, params) {
|
|
1375
|
+
export async function removeManager(db, params, options) {
|
|
1100
1376
|
const { byManagerKeyPair, targetManagerKey } = params;
|
|
1101
1377
|
const target = await managerRow(db, targetManagerKey);
|
|
1102
1378
|
if (target == null) {
|
|
@@ -1109,14 +1385,26 @@ export async function removeManager(db, params) {
|
|
|
1109
1385
|
+ 'Manager table. To permanently freeze admission instead, call sealStrand.');
|
|
1110
1386
|
}
|
|
1111
1387
|
const signature = signStrandApproval(['Strand.Manager', tag, targetManagerKey, target.stampId], byManagerKeyPair.privateKeyB64);
|
|
1112
|
-
await
|
|
1113
|
-
await db.exec(`delete from Strand.Manager
|
|
1114
|
-
with context ManagerKey = ?, Signature = ?
|
|
1115
|
-
where MemberKey = ?`, [byManagerKeyPair.publicKeyB64, signature, targetManagerKey]);
|
|
1116
|
-
await insertRevocation(db, 'Manager', target.stampId, byManagerKeyPair);
|
|
1117
|
-
});
|
|
1388
|
+
await execStrandTransaction(db, managerRemovalStatements(byManagerKeyPair, signature, targetManagerKey, target.stampId), options);
|
|
1118
1389
|
log('Removed manager %s by %s', targetManagerKey, byManagerKeyPair.publicKeyB64);
|
|
1119
1390
|
}
|
|
1391
|
+
/**
|
|
1392
|
+
* The `Strand.Manager` delete plus its tombstone, shared by {@link removeManager} and
|
|
1393
|
+
* {@link sealStrand}, which differ only in the action tag `signature` was minted under.
|
|
1394
|
+
*/
|
|
1395
|
+
function managerRemovalStatements(signer, signature, targetManagerKey, targetStampId) {
|
|
1396
|
+
const removal = {
|
|
1397
|
+
sql: `delete from Strand.Manager
|
|
1398
|
+
with context ManagerKey = :managerRemoverKey, Signature = :managerRemoverSignature
|
|
1399
|
+
where MemberKey = :managerMemberKey;`,
|
|
1400
|
+
params: {
|
|
1401
|
+
managerRemoverKey: signer.publicKeyB64,
|
|
1402
|
+
managerRemoverSignature: signature,
|
|
1403
|
+
managerMemberKey: targetManagerKey,
|
|
1404
|
+
},
|
|
1405
|
+
};
|
|
1406
|
+
return combineStatements(removal, revocationStatement('Manager', targetStampId, signer));
|
|
1407
|
+
}
|
|
1120
1408
|
/**
|
|
1121
1409
|
* Seal the strand: the SOLE manager deliberately steps down, permanently freezing
|
|
1122
1410
|
* membership.
|
|
@@ -1155,6 +1443,7 @@ export async function removeManager(db, params) {
|
|
|
1155
1443
|
*
|
|
1156
1444
|
* @param db - The closed strand's database.
|
|
1157
1445
|
* @param params - The sole manager's own keypair.
|
|
1446
|
+
* @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
|
|
1158
1447
|
* @throws If more than one manager exists (the caller wants {@link removeManager}),
|
|
1159
1448
|
* or the supplied keypair holds no `Manager` row while one exists, or the strand
|
|
1160
1449
|
* holds no `Manager` row and has never retired one (not founded yet — see
|
|
@@ -1163,7 +1452,7 @@ export async function removeManager(db, params) {
|
|
|
1163
1452
|
* and returns quietly (restart-safe, matching {@link bootstrapFounderMembership}
|
|
1164
1453
|
* and {@link removeManager}'s absent-row no-op).
|
|
1165
1454
|
*/
|
|
1166
|
-
export async function sealStrand(db, params) {
|
|
1455
|
+
export async function sealStrand(db, params, options) {
|
|
1167
1456
|
const { managerKeyPair } = params;
|
|
1168
1457
|
const managerCount = await strandTableCount(db, 'Manager');
|
|
1169
1458
|
if (managerCount === 0) {
|
|
@@ -1187,12 +1476,7 @@ export async function sealStrand(db, params) {
|
|
|
1187
1476
|
throw new Error('Cannot seal: the supplied keypair does not hold the sole Manager row.');
|
|
1188
1477
|
}
|
|
1189
1478
|
const signature = signStrandApproval(['Strand.Manager', 'seal', managerKeyPair.publicKeyB64, self.stampId], managerKeyPair.privateKeyB64);
|
|
1190
|
-
await
|
|
1191
|
-
await db.exec(`delete from Strand.Manager
|
|
1192
|
-
with context ManagerKey = ?, Signature = ?
|
|
1193
|
-
where MemberKey = ?`, [managerKeyPair.publicKeyB64, signature, managerKeyPair.publicKeyB64]);
|
|
1194
|
-
await insertRevocation(db, 'Manager', self.stampId, managerKeyPair);
|
|
1195
|
-
});
|
|
1479
|
+
await execStrandTransaction(db, managerRemovalStatements(managerKeyPair, signature, managerKeyPair.publicKeyB64, self.stampId), options);
|
|
1196
1480
|
log('Sealed strand: sole manager %s stepped down', managerKeyPair.publicKeyB64);
|
|
1197
1481
|
}
|
|
1198
1482
|
/**
|