@serfab/cadre-core 0.13.0 → 1.1.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 +20 -6
- 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 +558 -80
- package/dist/cadre-node.js +1060 -170
- 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 +299 -44
- package/dist/control-database.js +469 -69
- 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 +103 -19
- package/dist/control-schema.js.map +1 -1
- package/dist/control-write-retry.d.ts +80 -11
- package/dist/control-write-retry.js +176 -29
- package/dist/control-write-retry.js.map +1 -1
- package/dist/enrolled-machine-store.js +2 -2
- package/dist/index.d.ts +14 -7
- package/dist/index.js +33 -6
- package/dist/index.js.map +1 -1
- package/dist/node-local-snapshot.d.ts +15 -7
- package/dist/node-local-snapshot.js +23 -8
- 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/relay-addrs.d.ts +90 -52
- package/dist/relay-addrs.js +91 -59
- 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 +34 -16
- package/dist/strand-database.js +38 -24
- 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 +87 -5
- package/dist/strand-formation-manager.js +109 -5
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +21 -1
- package/dist/strand-formation-protocol.js +30 -0
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +243 -6
- package/dist/strand-instance-manager.js +470 -37
- 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 +131 -11
- package/dist/strand-membership-writer.js +396 -134
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-network-config.d.ts +46 -14
- package/dist/strand-network-config.js +79 -23
- package/dist/strand-network-config.js.map +1 -1
- package/dist/strand-solicitation.d.ts +10 -1
- package/dist/strand-solicitation.js +5 -0
- 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 +6 -0
- 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 +262 -30
- package/dist/types.js.map +1 -1
- package/package.json +8 -8
- package/src/bootstrap-peer-store-file.ts +2 -2
- package/src/bootstrap-peer-store.ts +69 -27
- package/src/cadre-node.ts +7301 -6353
- package/src/control-authorization.ts +2 -1
- package/src/control-cohort.ts +122 -130
- package/src/control-database.ts +537 -69
- package/src/control-read-retry.ts +202 -167
- package/src/control-retry.ts +297 -190
- package/src/control-schema.ts +103 -19
- package/src/control-write-retry.ts +533 -383
- package/src/enrolled-machine-store.ts +2 -2
- package/src/index.ts +83 -6
- package/src/node-local-snapshot.ts +25 -8
- package/src/peer-dial.ts +215 -0
- package/src/relay-addrs.ts +458 -436
- 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 +50 -27
- package/src/strand-first-sync-gate.ts +258 -0
- package/src/strand-formation-manager.ts +140 -5
- package/src/strand-formation-protocol.ts +43 -1
- package/src/strand-instance-manager.ts +577 -37
- 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 +527 -189
- package/src/strand-network-config.ts +102 -24
- package/src/strand-solicitation.ts +16 -1
- package/src/strand-wake-protocol.ts +21 -66
- package/src/strand-watcher.ts +6 -0
- package/src/timed-step.ts +28 -0
- package/src/timeout-scheduler.ts +28 -0
- package/src/types.ts +1789 -1554
|
@@ -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';
|
|
130
|
+
}
|
|
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
|
+
}
|
|
138
146
|
}
|
|
139
|
-
|
|
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
|
*
|
|
@@ -665,25 +869,30 @@ export async function addMemberByManager(db, params) {
|
|
|
665
869
|
*
|
|
666
870
|
* @param db - The closed strand's database.
|
|
667
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}).
|
|
668
873
|
* @throws If `Member.Authorized` rejects (a non-manager or same-transaction
|
|
669
874
|
* signer), `Member.NotAManager` rejects (the target still holds a Manager
|
|
670
875
|
* row), or `Member.MinOneMember` rejects (the removal would empty the member
|
|
671
876
|
* set); the whole delete+tombstone transaction rolls back.
|
|
672
877
|
*/
|
|
673
|
-
export async function revokeMember(db, params) {
|
|
878
|
+
export async function revokeMember(db, params, options) {
|
|
674
879
|
const { managerKeyPair, memberKey } = params;
|
|
675
880
|
const stampId = await memberStampId(db, memberKey);
|
|
676
881
|
if (stampId == null) {
|
|
677
882
|
log('Member %s already absent; skipping revoke', memberKey);
|
|
678
883
|
return;
|
|
679
884
|
}
|
|
680
|
-
const
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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);
|
|
687
896
|
log('Revoked member %s by manager %s', memberKey, managerKeyPair.publicKeyB64);
|
|
688
897
|
}
|
|
689
898
|
/**
|
|
@@ -707,10 +916,11 @@ export async function revokeMember(db, params) {
|
|
|
707
916
|
*
|
|
708
917
|
* @param db - The closed strand's database.
|
|
709
918
|
* @param params - The departing member's own keypair.
|
|
919
|
+
* @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
|
|
710
920
|
* @throws If `Member.Authorized`, `Member.NotAManager`, or `Member.MinOneMember`
|
|
711
921
|
* rejects; the whole delete+tombstone transaction rolls back.
|
|
712
922
|
*/
|
|
713
|
-
export async function leaveStrand(db, params) {
|
|
923
|
+
export async function leaveStrand(db, params, options) {
|
|
714
924
|
const { memberKeyPair } = params;
|
|
715
925
|
const memberKey = memberKeyPair.publicKeyB64;
|
|
716
926
|
const stampId = await memberStampId(db, memberKey);
|
|
@@ -718,13 +928,16 @@ export async function leaveStrand(db, params) {
|
|
|
718
928
|
log('Member %s already absent; skipping leave', memberKey);
|
|
719
929
|
return;
|
|
720
930
|
}
|
|
721
|
-
const
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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);
|
|
728
941
|
log('Member %s left the strand', memberKey);
|
|
729
942
|
}
|
|
730
943
|
/**
|
|
@@ -757,10 +970,13 @@ export async function leaveStrand(db, params) {
|
|
|
757
970
|
*
|
|
758
971
|
* @param db - The closed strand's database (the member already exists).
|
|
759
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}).
|
|
760
974
|
* @throws If `MemberPeer.Authorized`/`MemberExists` rejects (wrong signer, or no
|
|
761
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.
|
|
762
978
|
*/
|
|
763
|
-
export async function registerMemberPeer(db, params) {
|
|
979
|
+
export async function registerMemberPeer(db, params, options) {
|
|
764
980
|
const { memberKeyPair, peerId } = params;
|
|
765
981
|
const memberKey = memberKeyPair.publicKeyB64;
|
|
766
982
|
if (await memberPeerStampId(db, memberKey, peerId) != null) {
|
|
@@ -768,10 +984,17 @@ export async function registerMemberPeer(db, params) {
|
|
|
768
984
|
return;
|
|
769
985
|
}
|
|
770
986
|
const stampId = generateStrandStampId();
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
with context Signature =
|
|
774
|
-
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);
|
|
775
998
|
log('Registered MemberPeer (%s, %s)', memberKey, peerId);
|
|
776
999
|
}
|
|
777
1000
|
/**
|
|
@@ -878,10 +1101,13 @@ async function memberPeerStampId(db, memberKey, peerId) {
|
|
|
878
1101
|
* @param db - The closed strand's database.
|
|
879
1102
|
* @param params - Either the owning member's keypair, or a manager's keypair plus the
|
|
880
1103
|
* target member key.
|
|
1104
|
+
* @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
|
|
881
1105
|
* @throws If `MemberPeer.Authorized` rejects (neither branch satisfied), or if the row
|
|
882
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.
|
|
883
1109
|
*/
|
|
884
|
-
export async function removeMemberPeer(db, params) {
|
|
1110
|
+
export async function removeMemberPeer(db, params, options) {
|
|
885
1111
|
const memberKey = 'memberKeyPair' in params ? params.memberKeyPair.publicKeyB64 : params.memberKey;
|
|
886
1112
|
const { peerId } = params;
|
|
887
1113
|
const stampId = await memberPeerStampId(db, memberKey, peerId);
|
|
@@ -889,14 +1115,10 @@ export async function removeMemberPeer(db, params) {
|
|
|
889
1115
|
log('MemberPeer (%s, %s) already absent; skipping', memberKey, peerId);
|
|
890
1116
|
return;
|
|
891
1117
|
}
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
else {
|
|
897
|
-
await deleteMemberPeerByManager(db, params, stampId);
|
|
898
|
-
}
|
|
899
|
-
});
|
|
1118
|
+
const removal = 'memberKeyPair' in params
|
|
1119
|
+
? ownMemberPeerRemovalStatements(params, stampId)
|
|
1120
|
+
: memberPeerRemovalByManagerStatements(params, stampId);
|
|
1121
|
+
await execStrandTransaction(db, removal, options);
|
|
900
1122
|
// NOTE: the delete's `where` puts an equality on BOTH composite-PK columns, which the
|
|
901
1123
|
// optimystic vtab module serves via a single-key seek that can MISS on a networked
|
|
902
1124
|
// strand (see scanMemberPeers' doc). A missed seek deletes zero rows and still
|
|
@@ -911,14 +1133,20 @@ export async function removeMemberPeer(db, params) {
|
|
|
911
1133
|
log('Removed MemberPeer (%s, %s)', memberKey, peerId);
|
|
912
1134
|
}
|
|
913
1135
|
/** The self branch: the owning member signs the remove-tagged digest + tombstone. */
|
|
914
|
-
|
|
1136
|
+
function ownMemberPeerRemovalStatements(params, stampId) {
|
|
915
1137
|
const { memberKeyPair, peerId } = params;
|
|
916
1138
|
const memberKey = memberKeyPair.publicKeyB64;
|
|
917
|
-
const
|
|
918
|
-
|
|
919
|
-
with context Signature =
|
|
920
|
-
where MemberKey =
|
|
921
|
-
|
|
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));
|
|
922
1150
|
}
|
|
923
1151
|
/**
|
|
924
1152
|
* The manager branch: a manager signs the manager-remove-tagged digest + tombstone.
|
|
@@ -931,13 +1159,20 @@ async function deleteOwnMemberPeer(db, params, stampId) {
|
|
|
931
1159
|
* members' nodes resume answering and dialing that peer. Only clear a binding
|
|
932
1160
|
* that should never have existed, or a peer that is truly gone.
|
|
933
1161
|
*/
|
|
934
|
-
|
|
1162
|
+
function memberPeerRemovalByManagerStatements(params, stampId) {
|
|
935
1163
|
const { managerKeyPair, memberKey, peerId } = params;
|
|
936
|
-
const
|
|
937
|
-
|
|
938
|
-
with context Signature = null, ManagerKey =
|
|
939
|
-
where MemberKey =
|
|
940
|
-
|
|
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));
|
|
941
1176
|
}
|
|
942
1177
|
// ── Manager rotation (add / remove RBAC admins) ───────────────────────────────
|
|
943
1178
|
/**
|
|
@@ -988,11 +1223,22 @@ async function managerRow(db, memberKey) {
|
|
|
988
1223
|
*
|
|
989
1224
|
* @param db - The closed strand's database (founder manager already seated).
|
|
990
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}).
|
|
991
1227
|
* @throws If `Manager.Authorized` rejects, or if `Manager.MemberExists` rejects because
|
|
992
1228
|
* `newManagerKey` holds no `Member` row (admit it first, or use
|
|
993
1229
|
* {@link admitManager}); the insert rolls back.
|
|
994
1230
|
*/
|
|
995
|
-
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) {
|
|
996
1242
|
const { byManagerKeyPair, newManagerKey } = params;
|
|
997
1243
|
const authorizer = await managerRow(db, byManagerKeyPair.publicKeyB64);
|
|
998
1244
|
// NOTE: +1 saturates at Number.MAX_SAFE_INTEGER, where the successor compares equal
|
|
@@ -1002,11 +1248,18 @@ export async function addManager(db, params) {
|
|
|
1002
1248
|
// from outside this function, clamp or reject here rather than emitting a dead row.
|
|
1003
1249
|
const generation = authorizer == null ? 1 : authorizer.generation + 1;
|
|
1004
1250
|
const stampId = generateStrandStampId();
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
with context ManagerKey =
|
|
1008
|
-
values (
|
|
1009
|
-
|
|
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
|
+
};
|
|
1010
1263
|
}
|
|
1011
1264
|
/**
|
|
1012
1265
|
* Admit a key as a `Member` AND promote it to `Manager`, atomically — the one-step
|
|
@@ -1035,15 +1288,15 @@ export async function addManager(db, params) {
|
|
|
1035
1288
|
*
|
|
1036
1289
|
* @param db - The closed strand's database (founder manager already seated).
|
|
1037
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}).
|
|
1038
1292
|
* @throws If either half is rejected (`Member.Authorized`, `Manager.Authorized`, a
|
|
1039
1293
|
* duplicate `Member` key, …); the whole transaction rolls back, leaving neither row.
|
|
1040
1294
|
*/
|
|
1041
|
-
export async function admitManager(db, params) {
|
|
1295
|
+
export async function admitManager(db, params, options) {
|
|
1042
1296
|
const { byManagerKeyPair, newManagerKey } = params;
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
});
|
|
1297
|
+
const admission = memberAddByManagerStatement({ managerKeyPair: byManagerKeyPair, memberKey: newManagerKey });
|
|
1298
|
+
const promotion = await managerAddStatement(db, params);
|
|
1299
|
+
await execStrandTransaction(db, combineStatements(admission, promotion), options);
|
|
1047
1300
|
log('Admitted + promoted manager %s by %s', newManagerKey, byManagerKeyPair.publicKeyB64);
|
|
1048
1301
|
}
|
|
1049
1302
|
/**
|
|
@@ -1112,13 +1365,14 @@ export async function admitManager(db, params) {
|
|
|
1112
1365
|
*
|
|
1113
1366
|
* @param db - The closed strand's database.
|
|
1114
1367
|
* @param params - The authorizing keypair and the target manager key.
|
|
1368
|
+
* @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
|
|
1115
1369
|
* @throws If the signer IS the target and it is the sole manager (before writing —
|
|
1116
1370
|
* the operation the caller wants is {@link sealStrand}), or if `Manager.Authorized`
|
|
1117
1371
|
* rejects (a signer that is neither another existing manager nor the target
|
|
1118
1372
|
* itself, or a raw sole-manager `'resign'` whose post-image count is zero); the
|
|
1119
1373
|
* whole delete+tombstone transaction rolls back.
|
|
1120
1374
|
*/
|
|
1121
|
-
export async function removeManager(db, params) {
|
|
1375
|
+
export async function removeManager(db, params, options) {
|
|
1122
1376
|
const { byManagerKeyPair, targetManagerKey } = params;
|
|
1123
1377
|
const target = await managerRow(db, targetManagerKey);
|
|
1124
1378
|
if (target == null) {
|
|
@@ -1131,14 +1385,26 @@ export async function removeManager(db, params) {
|
|
|
1131
1385
|
+ 'Manager table. To permanently freeze admission instead, call sealStrand.');
|
|
1132
1386
|
}
|
|
1133
1387
|
const signature = signStrandApproval(['Strand.Manager', tag, targetManagerKey, target.stampId], byManagerKeyPair.privateKeyB64);
|
|
1134
|
-
await
|
|
1135
|
-
await db.exec(`delete from Strand.Manager
|
|
1136
|
-
with context ManagerKey = ?, Signature = ?
|
|
1137
|
-
where MemberKey = ?`, [byManagerKeyPair.publicKeyB64, signature, targetManagerKey]);
|
|
1138
|
-
await insertRevocation(db, 'Manager', target.stampId, byManagerKeyPair);
|
|
1139
|
-
});
|
|
1388
|
+
await execStrandTransaction(db, managerRemovalStatements(byManagerKeyPair, signature, targetManagerKey, target.stampId), options);
|
|
1140
1389
|
log('Removed manager %s by %s', targetManagerKey, byManagerKeyPair.publicKeyB64);
|
|
1141
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
|
+
}
|
|
1142
1408
|
/**
|
|
1143
1409
|
* Seal the strand: the SOLE manager deliberately steps down, permanently freezing
|
|
1144
1410
|
* membership.
|
|
@@ -1177,6 +1443,7 @@ export async function removeManager(db, params) {
|
|
|
1177
1443
|
*
|
|
1178
1444
|
* @param db - The closed strand's database.
|
|
1179
1445
|
* @param params - The sole manager's own keypair.
|
|
1446
|
+
* @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
|
|
1180
1447
|
* @throws If more than one manager exists (the caller wants {@link removeManager}),
|
|
1181
1448
|
* or the supplied keypair holds no `Manager` row while one exists, or the strand
|
|
1182
1449
|
* holds no `Manager` row and has never retired one (not founded yet — see
|
|
@@ -1185,7 +1452,7 @@ export async function removeManager(db, params) {
|
|
|
1185
1452
|
* and returns quietly (restart-safe, matching {@link bootstrapFounderMembership}
|
|
1186
1453
|
* and {@link removeManager}'s absent-row no-op).
|
|
1187
1454
|
*/
|
|
1188
|
-
export async function sealStrand(db, params) {
|
|
1455
|
+
export async function sealStrand(db, params, options) {
|
|
1189
1456
|
const { managerKeyPair } = params;
|
|
1190
1457
|
const managerCount = await strandTableCount(db, 'Manager');
|
|
1191
1458
|
if (managerCount === 0) {
|
|
@@ -1209,12 +1476,7 @@ export async function sealStrand(db, params) {
|
|
|
1209
1476
|
throw new Error('Cannot seal: the supplied keypair does not hold the sole Manager row.');
|
|
1210
1477
|
}
|
|
1211
1478
|
const signature = signStrandApproval(['Strand.Manager', 'seal', managerKeyPair.publicKeyB64, self.stampId], managerKeyPair.privateKeyB64);
|
|
1212
|
-
await
|
|
1213
|
-
await db.exec(`delete from Strand.Manager
|
|
1214
|
-
with context ManagerKey = ?, Signature = ?
|
|
1215
|
-
where MemberKey = ?`, [managerKeyPair.publicKeyB64, signature, managerKeyPair.publicKeyB64]);
|
|
1216
|
-
await insertRevocation(db, 'Manager', self.stampId, managerKeyPair);
|
|
1217
|
-
});
|
|
1479
|
+
await execStrandTransaction(db, managerRemovalStatements(managerKeyPair, signature, managerKeyPair.publicKeyB64, self.stampId), options);
|
|
1218
1480
|
log('Sealed strand: sole manager %s stepped down', managerKeyPair.publicKeyB64);
|
|
1219
1481
|
}
|
|
1220
1482
|
/**
|