@serfab/cadre-core 0.13.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 +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 +168 -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 +525 -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import debug from 'debug';
|
|
2
2
|
import { toString as uint8ArrayToString } from 'uint8arrays';
|
|
3
|
-
import type { Database } from '@quereus/quereus';
|
|
3
|
+
import type { Database, SqlValue } from '@quereus/quereus';
|
|
4
4
|
import { digest, sign, verify, generatePrivateKey, getPublicKey, randomBytes } from '@optimystic/quereus-plugin-crypto';
|
|
5
5
|
import type { SAppConfig } from './types.js';
|
|
6
6
|
import type { Ed25519KeyPair } from './ed25519-key.js';
|
|
@@ -126,48 +126,155 @@ export function generateStrandStampId(): string {
|
|
|
126
126
|
return uint8ArrayToString(bytes, 'base64url');
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
// ── Write batches (every membership write is one indivisible transaction) ──────
|
|
130
|
+
|
|
129
131
|
/**
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
* pair (Member + ConsumedInvite) and every delete-plus-tombstone pair (the
|
|
135
|
-
* `RevocationRecorded` constraints require the `Revocation` row in the SAME
|
|
136
|
-
* transaction as the delete it retires).
|
|
137
|
-
*
|
|
138
|
-
* JOINS a caller-owned transaction instead of opening its own: Quereus's
|
|
139
|
-
* `Database.beginTransaction()` hard-throws "Transaction already active" on any
|
|
140
|
-
* nesting, and a caller composing several writers in one transaction (e.g. a
|
|
141
|
-
* resign + revoke pair that must land atomically) is exactly the case where the
|
|
142
|
-
* deferred constraints should fire once, at the CALLER's commit. In joined mode
|
|
143
|
-
* commit/rollback belong to the caller, so failures simply propagate.
|
|
132
|
+
* A membership writer that runs only in a transaction of its own found another
|
|
133
|
+
* transaction already open on the strand database, and wrote nothing. Raised only
|
|
134
|
+
* under `joinOpenTransaction: false` (see {@link StrandWriteOptions}) — the background
|
|
135
|
+
* callers, which treat it as "the app is mid-transaction; try again later".
|
|
144
136
|
*/
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
137
|
+
export class StrandTransactionBusyError extends Error {
|
|
138
|
+
constructor(options?: ErrorOptions) {
|
|
139
|
+
super(
|
|
140
|
+
'Another transaction is open on the strand database. This membership write runs only in a '
|
|
141
|
+
+ 'transaction of its own, so nothing was written; retry once that transaction closes.',
|
|
142
|
+
options,
|
|
143
|
+
);
|
|
144
|
+
this.name = 'StrandTransactionBusyError';
|
|
149
145
|
}
|
|
150
|
-
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Options every transactional membership writer accepts. */
|
|
149
|
+
export interface StrandWriteOptions {
|
|
150
|
+
/**
|
|
151
|
+
* Default true: when a transaction is already open on the database, run the writer's
|
|
152
|
+
* statements inside it. The caller composes several writers in one transaction (e.g. a
|
|
153
|
+
* resign + revoke pair that must land together) and owns its commit and rollback, so
|
|
154
|
+
* the deferred constraints fire once, at the caller's commit.
|
|
155
|
+
*
|
|
156
|
+
* Background callers (the membership reconciler, the unpublish binding cleanup) pass
|
|
157
|
+
* false: they share the database with the app and must never join a transaction
|
|
158
|
+
* someone else opened, so the writer throws {@link StrandTransactionBusyError} instead.
|
|
159
|
+
*/
|
|
160
|
+
joinOpenTransaction?: boolean;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* SQL statements, each terminated by `;`, plus the named parameters they bind. The unit
|
|
165
|
+
* the writers assemble a batch from: a writer does every read, signature, and
|
|
166
|
+
* `canonicalDatetime` first, and only then issues all its statements in ONE `db.exec`.
|
|
167
|
+
*/
|
|
168
|
+
interface StrandStatements {
|
|
169
|
+
sql: string;
|
|
170
|
+
params: Record<string, SqlValue>;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Join statement fragments into one batch. Named parameters bind across the whole batch,
|
|
175
|
+
* so each fragment builder uses its own parameter names; a name bound twice would
|
|
176
|
+
* silently hand one fragment the other's value, so it throws instead.
|
|
177
|
+
*/
|
|
178
|
+
function combineStatements(...fragments: StrandStatements[]): StrandStatements {
|
|
179
|
+
const params: Record<string, SqlValue> = {};
|
|
180
|
+
for (const fragment of fragments) {
|
|
181
|
+
for (const [name, value] of Object.entries(fragment.params)) {
|
|
182
|
+
if (name in params) {
|
|
183
|
+
throw new Error(`Strand write batch binds the parameter :${name} in two statements`);
|
|
184
|
+
}
|
|
185
|
+
params[name] = value;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return { sql: fragments.map((fragment) => fragment.sql).join('\n'), params };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Quereus's refusal of a `begin` issued while a transaction is already open. */
|
|
192
|
+
const NESTED_BEGIN_REFUSAL = /^Cannot begin transaction: already in a transaction$/;
|
|
193
|
+
|
|
194
|
+
function isNestedBeginRefusal(error: unknown): boolean {
|
|
195
|
+
return error instanceof Error && NESTED_BEGIN_REFUSAL.test(error.message);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Run `statements` as one indivisible transaction on the shared strand database.
|
|
200
|
+
*
|
|
201
|
+
* The whole transaction is ONE `db.exec` batch — `begin transaction; <statements> commit;`
|
|
202
|
+
* — because Quereus holds its exec mutex for a whole batch but releases it between
|
|
203
|
+
* separate calls, and any caller's statement that runs while an explicit transaction is
|
|
204
|
+
* open lands inside it. A `beginTransaction()` / `exec` / `commit()` sequence therefore
|
|
205
|
+
* swept the app's concurrent writes into a background writer's transaction, and lost them
|
|
206
|
+
* when that transaction failed.
|
|
207
|
+
*
|
|
208
|
+
* Whether a transaction is already open is decided by the batch's own `begin`, under the
|
|
209
|
+
* mutex, not by `getAutocommit()` beforehand: that reads false whenever another caller's
|
|
210
|
+
* autocommit statement is merely in flight, which would make a background writer refuse
|
|
211
|
+
* spuriously and a composing caller run its statements as separate autocommits.
|
|
212
|
+
*
|
|
213
|
+
* - The `begin` succeeds: the batch commits or fails as a unit. A failure at `commit`
|
|
214
|
+
* (deferred constraints, a refused optimystic commit) leaves nothing open. A failure at
|
|
215
|
+
* an earlier statement leaves the batch's transaction open, so it is rolled back here.
|
|
216
|
+
* - The `begin` is refused because a transaction is already open: nothing has run. With
|
|
217
|
+
* `joinOpenTransaction: false` that is a {@link StrandTransactionBusyError} (with the
|
|
218
|
+
* refusal as `cause`), and the open transaction — someone else's — is left alone.
|
|
219
|
+
* Otherwise the statements run inside it, and its owner commits or rolls back.
|
|
220
|
+
*
|
|
221
|
+
* NOTE: interim until Quereus ships a batch that rolls back before releasing its mutex —
|
|
222
|
+
* requested as `../quereus/tickets/plan/exec-batch-as-one-transaction.md`; switch to that
|
|
223
|
+
* API when a Quereus release carries it, which also retires the message match in
|
|
224
|
+
* {@link isNestedBeginRefusal}. Until then two residual windows remain. (1) A statement
|
|
225
|
+
* that fails before `commit` (a primary-key collision such as a sibling machine seating the
|
|
226
|
+
* same `Member` first, an immediate CHECK, a storage read error) releases the mutex with the
|
|
227
|
+
* batch's transaction still open: an app `exec` already queued behind the batch runs inside
|
|
228
|
+
* it, and the rollback below discards that app write although its `exec` resolved. The
|
|
229
|
+
* common failures (an `Invite` not replicated yet, expired, cancelled, sealed, and
|
|
230
|
+
* optimystic commit refusals) all fail at `commit` and leave nothing open. (2) The rollback
|
|
231
|
+
* decision reads `getAutocommit()` after the batch has released the mutex, so after a
|
|
232
|
+
* `commit`-time failure a queued app statement in flight reads as "still open" too; the
|
|
233
|
+
* rollback then runs after that statement's own commit and finds nothing to undo — unless
|
|
234
|
+
* an app `beginTransaction()` also slipped in between, which it would end.
|
|
235
|
+
*/
|
|
236
|
+
async function execStrandTransaction(
|
|
237
|
+
db: Database,
|
|
238
|
+
statements: StrandStatements,
|
|
239
|
+
options?: StrandWriteOptions,
|
|
240
|
+
): Promise<void> {
|
|
151
241
|
try {
|
|
152
|
-
await
|
|
153
|
-
|
|
242
|
+
await db.exec(`begin transaction;\n${statements.sql}\ncommit;`, statements.params);
|
|
243
|
+
return;
|
|
154
244
|
} catch (error) {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
245
|
+
if (!isNestedBeginRefusal(error)) {
|
|
246
|
+
await rollbackBatchLeftOpen(db, error);
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
if (options?.joinOpenTransaction === false) {
|
|
250
|
+
throw new StrandTransactionBusyError({ cause: error });
|
|
161
251
|
}
|
|
162
|
-
throw error;
|
|
163
252
|
}
|
|
253
|
+
await db.exec(statements.sql, statements.params);
|
|
164
254
|
}
|
|
165
255
|
|
|
166
256
|
/**
|
|
167
|
-
*
|
|
257
|
+
* Roll back the batch's own transaction when a statement before `commit` failed and left
|
|
258
|
+
* it open; a `commit`-time failure has already closed it. See the NOTE on
|
|
259
|
+
* {@link execStrandTransaction} for what this cannot tell apart.
|
|
260
|
+
*/
|
|
261
|
+
async function rollbackBatchLeftOpen(db: Database, cause: unknown): Promise<void> {
|
|
262
|
+
if (db.getAutocommit()) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
await db.rollback();
|
|
267
|
+
log('Rolled back a strand write batch left open by a failed statement: %s', cause);
|
|
268
|
+
} catch (rollbackError) {
|
|
269
|
+
log('Rolling back a failed strand write batch failed (%s): %o', cause, rollbackError);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The `Strand.Revocation` tombstone insert that retires a deleted row's StampId.
|
|
168
275
|
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
276
|
+
* Always batched with the delete it accompanies — the guarded tables'
|
|
277
|
+
* `RevocationRecorded` (deferred) requires the tombstone at commit, and
|
|
171
278
|
* `Revocation.RowIsGone` (also deferred) requires the row to already be gone, so
|
|
172
279
|
* the pair only ever lands together.
|
|
173
280
|
*
|
|
@@ -178,24 +285,26 @@ async function inStrandTransaction(db: Database, fn: () => Promise<void>): Promi
|
|
|
178
285
|
* removes it. Unlike the sibling tables' nullable contexts, `Revocation`'s
|
|
179
286
|
* context is non-null — both fields are always supplied.
|
|
180
287
|
*
|
|
181
|
-
* @param db - The strand database, INSIDE an open transaction.
|
|
182
288
|
* @param tableName - Which guarded table the stamp belonged to.
|
|
183
289
|
* @param stampId - The retired stamp.
|
|
184
290
|
* @param retiree - The committed member filing the tombstone (the delete's own signer).
|
|
185
291
|
*/
|
|
186
|
-
|
|
187
|
-
db: Database,
|
|
292
|
+
function revocationStatement(
|
|
188
293
|
tableName: 'Member' | 'Manager' | 'MemberPeer',
|
|
189
294
|
stampId: string,
|
|
190
295
|
retiree: Ed25519KeyPair,
|
|
191
|
-
):
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
296
|
+
): StrandStatements {
|
|
297
|
+
return {
|
|
298
|
+
sql: `insert into Strand.Revocation (TableName, StampId)
|
|
299
|
+
with context MemberKey = :revocationSignerKey, Signature = :revocationSignature
|
|
300
|
+
values (:revocationTable, :revocationStampId);`,
|
|
301
|
+
params: {
|
|
302
|
+
revocationSignerKey: retiree.publicKeyB64,
|
|
303
|
+
revocationSignature: signStrandApproval(['Strand.Revocation', 'retire', tableName, stampId], retiree.privateKeyB64),
|
|
304
|
+
revocationTable: tableName,
|
|
305
|
+
revocationStampId: stampId,
|
|
306
|
+
},
|
|
307
|
+
};
|
|
199
308
|
}
|
|
200
309
|
|
|
201
310
|
/** Parameters for {@link bootstrapFounderMembership}. */
|
|
@@ -212,6 +321,38 @@ export interface FounderBootstrapParams {
|
|
|
212
321
|
* Required for a closed strand; ignored for an open strand.
|
|
213
322
|
*/
|
|
214
323
|
founderKeyPair?: Ed25519KeyPair;
|
|
324
|
+
/**
|
|
325
|
+
* The public key derived from the strand row's SHARED `MemberPrivateKey` (the read
|
|
326
|
+
* secret every joining party holds). When supplied for a closed strand, a
|
|
327
|
+
* `Strand.Member` or `Strand.Manager` row holding it refuses the bootstrap with
|
|
328
|
+
* {@link PreSplitStrandIdentityError} (see {@link assertNotPreSplitStrand}): nothing
|
|
329
|
+
* seats that key since the per-party identity split, so its presence is exactly the
|
|
330
|
+
* fingerprint of a strand founded before it. Omitted, no such check runs (a caller that
|
|
331
|
+
* holds no shared key has nothing to compare against). Ignored for an open strand.
|
|
332
|
+
*/
|
|
333
|
+
sharedMemberPublicKey?: string;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* A closed strand founded BEFORE the per-party identity split: its founding
|
|
338
|
+
* `Strand.Member` (and, unless handed on, `Strand.Manager`) is the key derived from the
|
|
339
|
+
* shared `MemberPrivateKey`, which every joining party holds, so any member can sign as
|
|
340
|
+
* the founder. There is no repair — rewriting the membership cannot be trusted, since any
|
|
341
|
+
* joiner could already have admitted or revoked anyone, or could race the rewrite — so
|
|
342
|
+
* the strand must be recreated. Thrown by {@link assertNotPreSplitStrand} (a founder
|
|
343
|
+
* bootstrap, or membership-invite issuance); the formation layer maps it to a
|
|
344
|
+
* non-retryable rejection.
|
|
345
|
+
*/
|
|
346
|
+
export class PreSplitStrandIdentityError extends Error {
|
|
347
|
+
constructor(readonly strandId: string) {
|
|
348
|
+
super(
|
|
349
|
+
`Closed strand ${strandId} was founded before per-party strand identity: its founding ` +
|
|
350
|
+
'member key is derived from the shared MemberPrivateKey that every joining party holds, ' +
|
|
351
|
+
'so any member can act as its founder. It cannot be repaired — recreate the strand ' +
|
|
352
|
+
'(unpublish it and found a new one).',
|
|
353
|
+
);
|
|
354
|
+
this.name = 'PreSplitStrandIdentityError';
|
|
355
|
+
}
|
|
215
356
|
}
|
|
216
357
|
|
|
217
358
|
/**
|
|
@@ -240,6 +381,13 @@ async function strandTableCount(db: Database, table: 'Header' | 'Member' | 'Mana
|
|
|
240
381
|
* scale; if membership churn ever makes that table large — the growth the
|
|
241
382
|
* `Revocation` schema comment already flags, tracked by `debt-strand-tombstone-reap`
|
|
242
383
|
* — this wants a stored "founding closed" marker rather than a scan per call.
|
|
384
|
+
*
|
|
385
|
+
* NOTE: on a strand that has never revoked anyone, `Strand.Revocation` has never been
|
|
386
|
+
* written, so this node does not hold its block, and the storage layer consults the
|
|
387
|
+
* block's cohort on every read of a block it does not hold — this scan and every
|
|
388
|
+
* `NotRevoked` insert check pay that per call. Not measured, and cheap while strand
|
|
389
|
+
* membership writes are rare; if they become frequent, file a singleton marker row in
|
|
390
|
+
* the strand schema the way the control schema does (`ControlDatabase.openRevocationLedger`).
|
|
243
391
|
*/
|
|
244
392
|
async function strandHasManagerRevocation(db: Database): Promise<boolean> {
|
|
245
393
|
for await (const row of db.eval('select TableName from Strand.Revocation')) {
|
|
@@ -354,6 +502,26 @@ async function insertFounderManagerIfAbsent(db: Database, memberKey: string, str
|
|
|
354
502
|
log('Inserted founding Manager for strand %s', strandId);
|
|
355
503
|
}
|
|
356
504
|
|
|
505
|
+
/**
|
|
506
|
+
* Refuse a closed strand whose membership still holds the key derived from the shared
|
|
507
|
+
* `MemberPrivateKey` (see {@link FounderBootstrapParams.sharedMemberPublicKey}). `Member`
|
|
508
|
+
* is the primary probe — every manager is a member (`Manager.MemberExists`), so it also
|
|
509
|
+
* catches a pre-split founder who handed management to another key and resigned, or a
|
|
510
|
+
* sealed pre-split strand. `Manager` is probed too, for the partition case the schema
|
|
511
|
+
* notes on `MemberExists` (a Manager row converged without its Member row). Cannot
|
|
512
|
+
* misfire: nothing seats the shared-derived key since the split, and rows not yet loaded
|
|
513
|
+
* read as absent.
|
|
514
|
+
*
|
|
515
|
+
* @throws {PreSplitStrandIdentityError} when a `Strand.Member` or `Strand.Manager` row is
|
|
516
|
+
* the shared-derived key.
|
|
517
|
+
*/
|
|
518
|
+
export async function assertNotPreSplitStrand(db: Database, strandId: string, sharedMemberPublicKey: string): Promise<void> {
|
|
519
|
+
if (await memberStampId(db, sharedMemberPublicKey) !== undefined
|
|
520
|
+
|| await managerRow(db, sharedMemberPublicKey) !== undefined) {
|
|
521
|
+
throw new PreSplitStrandIdentityError(strandId);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
357
525
|
/**
|
|
358
526
|
* Founder-only one-time bootstrap of a strand's `Strand.*` membership/RBAC rows.
|
|
359
527
|
*
|
|
@@ -375,13 +543,20 @@ async function insertFounderManagerIfAbsent(db: Database, memberKey: string, str
|
|
|
375
543
|
* Manager could never admit anyone, so failing loudly here (which propagates out
|
|
376
544
|
* of `StrandDatabase.initialize()` and triggers the runtime's rollback) is correct.
|
|
377
545
|
*
|
|
546
|
+
* A closed strand whose membership holds `sharedMemberPublicKey` throws
|
|
547
|
+
* {@link PreSplitStrandIdentityError} before writing anything: the insert-if-absent
|
|
548
|
+
* guards would otherwise skip over its pre-split founding rows and leave the shared
|
|
549
|
+
* key seated.
|
|
550
|
+
*
|
|
378
551
|
* @param db - The strand's Quereus database (schema already applied).
|
|
379
552
|
* @param params - Strand id/type, the sApp config for the Header, and (closed only)
|
|
380
|
-
* the derived founder keypair.
|
|
553
|
+
* the derived founder keypair and the shared-derived public key to refuse.
|
|
381
554
|
* @throws If `type === 'c'` and no `founderKeyPair` is supplied.
|
|
555
|
+
* @throws {PreSplitStrandIdentityError} If `type === 'c'` and a `Strand.Member` or
|
|
556
|
+
* `Strand.Manager` row is `sharedMemberPublicKey`.
|
|
382
557
|
*/
|
|
383
558
|
export async function bootstrapFounderMembership(db: Database, params: FounderBootstrapParams): Promise<void> {
|
|
384
|
-
const { strandId, type, founderKeyPair } = params;
|
|
559
|
+
const { strandId, type, founderKeyPair, sharedMemberPublicKey } = params;
|
|
385
560
|
log('Founder bootstrap for strand %s (type %s)', strandId, type);
|
|
386
561
|
|
|
387
562
|
// Validate BEFORE writing anything: a closed strand with no founder key must
|
|
@@ -389,10 +564,13 @@ export async function bootstrapFounderMembership(db: Database, params: FounderBo
|
|
|
389
564
|
// (such a strand could never admit anyone).
|
|
390
565
|
if (type === 'c' && !founderKeyPair) {
|
|
391
566
|
throw new Error(
|
|
392
|
-
`Cannot bootstrap closed strand ${strandId}: no founder key pair derived from
|
|
393
|
-
'A closed strand needs a founding Member + Manager or it can never admit members.',
|
|
567
|
+
`Cannot bootstrap closed strand ${strandId}: no founder key pair derived from this party's ` +
|
|
568
|
+
'StrandPartyKey. A closed strand needs a founding Member + Manager or it can never admit members.',
|
|
394
569
|
);
|
|
395
570
|
}
|
|
571
|
+
if (type === 'c' && sharedMemberPublicKey !== undefined) {
|
|
572
|
+
await assertNotPreSplitStrand(db, strandId, sharedMemberPublicKey);
|
|
573
|
+
}
|
|
396
574
|
|
|
397
575
|
// Header is written for every strand; Member/Manager are closed-only.
|
|
398
576
|
await insertHeaderIfAbsent(db, params);
|
|
@@ -540,41 +718,90 @@ export interface ConsumeInviteParams {
|
|
|
540
718
|
* @param db - The closed strand's database.
|
|
541
719
|
* @param params - The invite key/secret, the joining member's public key, and an
|
|
542
720
|
* optional `nowMs` instant for the expiry comparison (default `Date.now()`).
|
|
721
|
+
* @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
|
|
543
722
|
* @throws If any constraint rejects (bad signature, missing invite, or an expired
|
|
544
723
|
* invite); the whole transaction rolls back (neither the `Member` nor the
|
|
545
724
|
* `ConsumedInvite` row survives).
|
|
725
|
+
* @throws {StrandTransactionBusyError} Under `joinOpenTransaction: false`, when another
|
|
726
|
+
* transaction is open; nothing was written.
|
|
546
727
|
*/
|
|
547
|
-
export async function consumeInvite(
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
|
|
556
|
-
|
|
728
|
+
export async function consumeInvite(
|
|
729
|
+
db: Database,
|
|
730
|
+
params: ConsumeInviteParams,
|
|
731
|
+
options?: StrandWriteOptions,
|
|
732
|
+
): Promise<void> {
|
|
733
|
+
const { inviteKey, memberKey } = params;
|
|
734
|
+
// Member first — admitted by the deferred invite branch (the matching ConsumedInvite
|
|
735
|
+
// after it), so no manager signature is supplied.
|
|
736
|
+
const member: StrandStatements = {
|
|
737
|
+
sql: `insert into Strand.Member (Key, StampId)
|
|
738
|
+
with context ManagerKey = null, ManagerSignature = null, MemberSignature = null
|
|
739
|
+
values (:memberKey, :memberStampId);`,
|
|
740
|
+
params: { memberKey, memberStampId: generateStrandStampId() },
|
|
741
|
+
};
|
|
742
|
+
await execStrandTransaction(db, combineStatements(member, await consumedInviteStatement(db, params)), options);
|
|
743
|
+
log('Consumed invite %s -> member %s', inviteKey, memberKey);
|
|
744
|
+
}
|
|
557
745
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
746
|
+
/**
|
|
747
|
+
* The `Strand.ConsumedInvite` marker row insert — the possession-plus-freshness proof
|
|
748
|
+
* shared by {@link consumeInvite} (batched after its `Member` insert) and
|
|
749
|
+
* {@link burnInvite} (alone, against an already-seated member).
|
|
750
|
+
*
|
|
751
|
+
* The invite signature covers `InviteKey || '|' || MemberKey` (the `ValidUsage` gate),
|
|
752
|
+
* and "now" is canonicalised the same way {@link issueInvite} canonicalises
|
|
753
|
+
* `Expiration`, so the schema's `I.Expiration > context.Now` compares like-for-like
|
|
754
|
+
* canonical strings. Plain runtime Date.now() — the tess Workflow restriction is on
|
|
755
|
+
* scripts, not libs.
|
|
756
|
+
*/
|
|
757
|
+
async function consumedInviteStatement(db: Database, params: ConsumeInviteParams): Promise<StrandStatements> {
|
|
758
|
+
const { inviteKey, invitePrivateKey, memberKey, nowMs } = params;
|
|
759
|
+
return {
|
|
760
|
+
sql: `insert into Strand.ConsumedInvite (InviteKey, MemberKey)
|
|
761
|
+
with context InviteSignature = :consumedSignature, Now = :consumedNow
|
|
762
|
+
values (:consumedInviteKey, :consumedMemberKey);`,
|
|
763
|
+
params: {
|
|
764
|
+
consumedSignature: signStrandPayload(`${inviteKey}|${memberKey}`, invitePrivateKey),
|
|
765
|
+
consumedNow: await canonicalDatetime(db, nowMs ?? Date.now()),
|
|
766
|
+
consumedInviteKey: inviteKey,
|
|
767
|
+
consumedMemberKey: memberKey,
|
|
768
|
+
},
|
|
769
|
+
};
|
|
770
|
+
}
|
|
567
771
|
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
772
|
+
/**
|
|
773
|
+
* Burn an invitation against an ALREADY-SEATED member: insert the
|
|
774
|
+
* `Strand.ConsumedInvite` row alone, naming the existing `Member.Key`, so the bearer
|
|
775
|
+
* credential can never be spent by anyone else.
|
|
776
|
+
*
|
|
777
|
+
* The already-member arm of the bring-up membership reconciler
|
|
778
|
+
* (`strand-membership-reconciler.ts`): a machine that finds its party's `Member` row
|
|
779
|
+
* already present (a sibling machine redeemed the invitation's twin first, or a
|
|
780
|
+
* manager admitted the party directly) may still hold an UNSPENT formation-delivered
|
|
781
|
+
* invitation — a bearer credential whoever presents can join with. Every
|
|
782
|
+
* `ConsumedInvite` constraint is satisfiable without a same-transaction `Member`
|
|
783
|
+
* insert (`MemberExists` reads the live table), so this is one statement whose
|
|
784
|
+
* deferred checks fire at its own commit. It seats nobody now or later:
|
|
785
|
+
* `Member.Authorized`'s invite branch requires a same-transaction FRESH consumption,
|
|
786
|
+
* so the row it leaves is exactly as inert as any other spent invitation's.
|
|
787
|
+
*
|
|
788
|
+
* @param db - The closed strand's database (the member already exists).
|
|
789
|
+
* @param params - The invite key/secret and the EXISTING member's public key, plus the
|
|
790
|
+
* optional `nowMs` instant for the expiry gate (default `Date.now()`).
|
|
791
|
+
* @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
|
|
792
|
+
* @throws If any constraint rejects — already consumed (the `InviteKey` primary key),
|
|
793
|
+
* cancelled, expired, or a sealed strand. Callers treat a burn failure as "already
|
|
794
|
+
* dead" and log rather than retry.
|
|
795
|
+
* @throws {StrandTransactionBusyError} Under `joinOpenTransaction: false`, when another
|
|
796
|
+
* transaction is open; nothing was tried, so the invitation is exactly as live as before.
|
|
797
|
+
*/
|
|
798
|
+
export async function burnInvite(
|
|
799
|
+
db: Database,
|
|
800
|
+
params: ConsumeInviteParams,
|
|
801
|
+
options?: StrandWriteOptions,
|
|
802
|
+
): Promise<void> {
|
|
803
|
+
await execStrandTransaction(db, await consumedInviteStatement(db, params), options);
|
|
804
|
+
log('Burned invite %s against existing member %s', params.inviteKey, params.memberKey);
|
|
578
805
|
}
|
|
579
806
|
|
|
580
807
|
/**
|
|
@@ -759,6 +986,18 @@ async function memberStampId(db: Database, memberKey: string): Promise<string |
|
|
|
759
986
|
return undefined;
|
|
760
987
|
}
|
|
761
988
|
|
|
989
|
+
/**
|
|
990
|
+
* Whether `memberKey` holds a live `Strand.Member` row, as seen by THIS database
|
|
991
|
+
* instance — the membership probe behind the bring-up reconciler
|
|
992
|
+
* (`strand-membership-reconciler.ts`). Reads via {@link memberStampId}'s unfiltered
|
|
993
|
+
* scan + JavaScript compare (the {@link scanMemberPeers} argument for why a PK point
|
|
994
|
+
* lookup is not reliable on a networked strand). Local visibility only: `false` can
|
|
995
|
+
* mean "not replicated here yet", never a durable verdict about the strand.
|
|
996
|
+
*/
|
|
997
|
+
export async function isStrandMember(db: Database, memberKey: string): Promise<boolean> {
|
|
998
|
+
return await memberStampId(db, memberKey) != null;
|
|
999
|
+
}
|
|
1000
|
+
|
|
762
1001
|
/** Parameters for {@link addMemberByManager}. */
|
|
763
1002
|
export interface AddMemberByManagerParams {
|
|
764
1003
|
/**
|
|
@@ -786,22 +1025,37 @@ export interface AddMemberByManagerParams {
|
|
|
786
1025
|
*
|
|
787
1026
|
* @param db - The closed strand's database.
|
|
788
1027
|
* @param params - The admitting manager keypair and the new member key.
|
|
1028
|
+
* @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
|
|
789
1029
|
* @throws If `Member.Authorized` rejects (e.g. a non-manager key); the insert
|
|
790
1030
|
* rolls back, leaving no `Member` row.
|
|
791
1031
|
*/
|
|
792
|
-
export async function addMemberByManager(
|
|
1032
|
+
export async function addMemberByManager(
|
|
1033
|
+
db: Database,
|
|
1034
|
+
params: AddMemberByManagerParams,
|
|
1035
|
+
options?: StrandWriteOptions,
|
|
1036
|
+
): Promise<void> {
|
|
793
1037
|
const { managerKeyPair, memberKey } = params;
|
|
794
|
-
|
|
795
|
-
const signature = signStrandApproval(['Strand.Member', 'add', memberKey, stampId], managerKeyPair.privateKeyB64);
|
|
796
|
-
await db.exec(
|
|
797
|
-
`insert into Strand.Member (Key, StampId)
|
|
798
|
-
with context ManagerKey = ?, ManagerSignature = ?, MemberSignature = null
|
|
799
|
-
values (?, ?)`,
|
|
800
|
-
[managerKeyPair.publicKeyB64, signature, memberKey, stampId],
|
|
801
|
-
);
|
|
1038
|
+
await execStrandTransaction(db, memberAddByManagerStatement(params), options);
|
|
802
1039
|
log('Admitted member %s by manager %s', memberKey, managerKeyPair.publicKeyB64);
|
|
803
1040
|
}
|
|
804
1041
|
|
|
1042
|
+
/** The manager-signed `Strand.Member` insert behind {@link addMemberByManager}, under a fresh stamp. */
|
|
1043
|
+
function memberAddByManagerStatement(params: AddMemberByManagerParams): StrandStatements {
|
|
1044
|
+
const { managerKeyPair, memberKey } = params;
|
|
1045
|
+
const stampId = generateStrandStampId();
|
|
1046
|
+
return {
|
|
1047
|
+
sql: `insert into Strand.Member (Key, StampId)
|
|
1048
|
+
with context ManagerKey = :memberAdmitterKey, ManagerSignature = :memberAdmitterSignature, MemberSignature = null
|
|
1049
|
+
values (:memberKey, :memberStampId);`,
|
|
1050
|
+
params: {
|
|
1051
|
+
memberAdmitterKey: managerKeyPair.publicKeyB64,
|
|
1052
|
+
memberAdmitterSignature: signStrandApproval(['Strand.Member', 'add', memberKey, stampId], managerKeyPair.privateKeyB64),
|
|
1053
|
+
memberKey,
|
|
1054
|
+
memberStampId: stampId,
|
|
1055
|
+
},
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
|
|
805
1059
|
/** Parameters for {@link revokeMember}. */
|
|
806
1060
|
export interface RevokeMemberParams {
|
|
807
1061
|
/**
|
|
@@ -861,28 +1115,38 @@ export interface RevokeMemberParams {
|
|
|
861
1115
|
*
|
|
862
1116
|
* @param db - The closed strand's database.
|
|
863
1117
|
* @param params - The revoking manager keypair and the target member key.
|
|
1118
|
+
* @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
|
|
864
1119
|
* @throws If `Member.Authorized` rejects (a non-manager or same-transaction
|
|
865
1120
|
* signer), `Member.NotAManager` rejects (the target still holds a Manager
|
|
866
1121
|
* row), or `Member.MinOneMember` rejects (the removal would empty the member
|
|
867
1122
|
* set); the whole delete+tombstone transaction rolls back.
|
|
868
1123
|
*/
|
|
869
|
-
export async function revokeMember(
|
|
1124
|
+
export async function revokeMember(
|
|
1125
|
+
db: Database,
|
|
1126
|
+
params: RevokeMemberParams,
|
|
1127
|
+
options?: StrandWriteOptions,
|
|
1128
|
+
): Promise<void> {
|
|
870
1129
|
const { managerKeyPair, memberKey } = params;
|
|
871
1130
|
const stampId = await memberStampId(db, memberKey);
|
|
872
1131
|
if (stampId == null) {
|
|
873
1132
|
log('Member %s already absent; skipping revoke', memberKey);
|
|
874
1133
|
return;
|
|
875
1134
|
}
|
|
876
|
-
const
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
[
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
}
|
|
1135
|
+
const removal: StrandStatements = {
|
|
1136
|
+
sql: `delete from Strand.Member
|
|
1137
|
+
with context ManagerKey = :memberRemoverKey, ManagerSignature = :memberRemoverSignature, MemberSignature = null
|
|
1138
|
+
where Key = :memberKey;`,
|
|
1139
|
+
params: {
|
|
1140
|
+
memberRemoverKey: managerKeyPair.publicKeyB64,
|
|
1141
|
+
memberRemoverSignature: signStrandApproval(['Strand.Member', 'remove', memberKey, stampId], managerKeyPair.privateKeyB64),
|
|
1142
|
+
memberKey,
|
|
1143
|
+
},
|
|
1144
|
+
};
|
|
1145
|
+
await execStrandTransaction(
|
|
1146
|
+
db,
|
|
1147
|
+
combineStatements(removal, revocationStatement('Member', stampId, managerKeyPair)),
|
|
1148
|
+
options,
|
|
1149
|
+
);
|
|
886
1150
|
log('Revoked member %s by manager %s', memberKey, managerKeyPair.publicKeyB64);
|
|
887
1151
|
}
|
|
888
1152
|
|
|
@@ -921,10 +1185,15 @@ export interface LeaveStrandParams {
|
|
|
921
1185
|
*
|
|
922
1186
|
* @param db - The closed strand's database.
|
|
923
1187
|
* @param params - The departing member's own keypair.
|
|
1188
|
+
* @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
|
|
924
1189
|
* @throws If `Member.Authorized`, `Member.NotAManager`, or `Member.MinOneMember`
|
|
925
1190
|
* rejects; the whole delete+tombstone transaction rolls back.
|
|
926
1191
|
*/
|
|
927
|
-
export async function leaveStrand(
|
|
1192
|
+
export async function leaveStrand(
|
|
1193
|
+
db: Database,
|
|
1194
|
+
params: LeaveStrandParams,
|
|
1195
|
+
options?: StrandWriteOptions,
|
|
1196
|
+
): Promise<void> {
|
|
928
1197
|
const { memberKeyPair } = params;
|
|
929
1198
|
const memberKey = memberKeyPair.publicKeyB64;
|
|
930
1199
|
const stampId = await memberStampId(db, memberKey);
|
|
@@ -932,16 +1201,20 @@ export async function leaveStrand(db: Database, params: LeaveStrandParams): Prom
|
|
|
932
1201
|
log('Member %s already absent; skipping leave', memberKey);
|
|
933
1202
|
return;
|
|
934
1203
|
}
|
|
935
|
-
const
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
1204
|
+
const departure: StrandStatements = {
|
|
1205
|
+
sql: `delete from Strand.Member
|
|
1206
|
+
with context ManagerKey = null, ManagerSignature = null, MemberSignature = :memberSignature
|
|
1207
|
+
where Key = :memberKey;`,
|
|
1208
|
+
params: {
|
|
1209
|
+
memberSignature: signStrandApproval(['Strand.Member', 'leave', memberKey, stampId], memberKeyPair.privateKeyB64),
|
|
1210
|
+
memberKey,
|
|
1211
|
+
},
|
|
1212
|
+
};
|
|
1213
|
+
await execStrandTransaction(
|
|
1214
|
+
db,
|
|
1215
|
+
combineStatements(departure, revocationStatement('Member', stampId, memberKeyPair)),
|
|
1216
|
+
options,
|
|
1217
|
+
);
|
|
945
1218
|
log('Member %s left the strand', memberKey);
|
|
946
1219
|
}
|
|
947
1220
|
|
|
@@ -993,10 +1266,17 @@ export interface RegisterMemberPeerParams {
|
|
|
993
1266
|
*
|
|
994
1267
|
* @param db - The closed strand's database (the member already exists).
|
|
995
1268
|
* @param params - The member's own keypair and the peer id to bind.
|
|
1269
|
+
* @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
|
|
996
1270
|
* @throws If `MemberPeer.Authorized`/`MemberExists` rejects (wrong signer, or no
|
|
997
1271
|
* matching `Member` row); the insert rolls back, leaving no `MemberPeer` row.
|
|
1272
|
+
* @throws {StrandTransactionBusyError} Under `joinOpenTransaction: false`, when another
|
|
1273
|
+
* transaction is open; nothing was written.
|
|
998
1274
|
*/
|
|
999
|
-
export async function registerMemberPeer(
|
|
1275
|
+
export async function registerMemberPeer(
|
|
1276
|
+
db: Database,
|
|
1277
|
+
params: RegisterMemberPeerParams,
|
|
1278
|
+
options?: StrandWriteOptions,
|
|
1279
|
+
): Promise<void> {
|
|
1000
1280
|
const { memberKeyPair, peerId } = params;
|
|
1001
1281
|
const memberKey = memberKeyPair.publicKeyB64;
|
|
1002
1282
|
|
|
@@ -1006,16 +1286,17 @@ export async function registerMemberPeer(db: Database, params: RegisterMemberPee
|
|
|
1006
1286
|
}
|
|
1007
1287
|
|
|
1008
1288
|
const stampId = generateStrandStampId();
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1289
|
+
await execStrandTransaction(db, {
|
|
1290
|
+
sql: `insert into Strand.MemberPeer (MemberKey, PeerId, StampId)
|
|
1291
|
+
with context Signature = :peerSignature, ManagerKey = null, ManagerSignature = null
|
|
1292
|
+
values (:peerMemberKey, :peerId, :peerStampId);`,
|
|
1293
|
+
params: {
|
|
1294
|
+
peerSignature: signStrandApproval(['Strand.MemberPeer', 'add', memberKey, peerId, stampId], memberKeyPair.privateKeyB64),
|
|
1295
|
+
peerMemberKey: memberKey,
|
|
1296
|
+
peerId,
|
|
1297
|
+
peerStampId: stampId,
|
|
1298
|
+
},
|
|
1299
|
+
}, options);
|
|
1019
1300
|
log('Registered MemberPeer (%s, %s)', memberKey, peerId);
|
|
1020
1301
|
}
|
|
1021
1302
|
|
|
@@ -1167,10 +1448,17 @@ export type RemoveMemberPeerParams = RemoveOwnPeerParams | RemoveMemberPeerByMan
|
|
|
1167
1448
|
* @param db - The closed strand's database.
|
|
1168
1449
|
* @param params - Either the owning member's keypair, or a manager's keypair plus the
|
|
1169
1450
|
* target member key.
|
|
1451
|
+
* @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
|
|
1170
1452
|
* @throws If `MemberPeer.Authorized` rejects (neither branch satisfied), or if the row
|
|
1171
1453
|
* is still present after the delete (see the point-lookup note below).
|
|
1454
|
+
* @throws {StrandTransactionBusyError} Under `joinOpenTransaction: false`, when another
|
|
1455
|
+
* transaction is open; nothing was written.
|
|
1172
1456
|
*/
|
|
1173
|
-
export async function removeMemberPeer(
|
|
1457
|
+
export async function removeMemberPeer(
|
|
1458
|
+
db: Database,
|
|
1459
|
+
params: RemoveMemberPeerParams,
|
|
1460
|
+
options?: StrandWriteOptions,
|
|
1461
|
+
): Promise<void> {
|
|
1174
1462
|
const memberKey = 'memberKeyPair' in params ? params.memberKeyPair.publicKeyB64 : params.memberKey;
|
|
1175
1463
|
const { peerId } = params;
|
|
1176
1464
|
|
|
@@ -1180,13 +1468,10 @@ export async function removeMemberPeer(db: Database, params: RemoveMemberPeerPar
|
|
|
1180
1468
|
return;
|
|
1181
1469
|
}
|
|
1182
1470
|
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
await deleteMemberPeerByManager(db, params, stampId);
|
|
1188
|
-
}
|
|
1189
|
-
});
|
|
1471
|
+
const removal = 'memberKeyPair' in params
|
|
1472
|
+
? ownMemberPeerRemovalStatements(params, stampId)
|
|
1473
|
+
: memberPeerRemovalByManagerStatements(params, stampId);
|
|
1474
|
+
await execStrandTransaction(db, removal, options);
|
|
1190
1475
|
|
|
1191
1476
|
// NOTE: the delete's `where` puts an equality on BOTH composite-PK columns, which the
|
|
1192
1477
|
// optimystic vtab module serves via a single-key seek that can MISS on a networked
|
|
@@ -1203,20 +1488,20 @@ export async function removeMemberPeer(db: Database, params: RemoveMemberPeerPar
|
|
|
1203
1488
|
}
|
|
1204
1489
|
|
|
1205
1490
|
/** The self branch: the owning member signs the remove-tagged digest + tombstone. */
|
|
1206
|
-
|
|
1491
|
+
function ownMemberPeerRemovalStatements(params: RemoveOwnPeerParams, stampId: string): StrandStatements {
|
|
1207
1492
|
const { memberKeyPair, peerId } = params;
|
|
1208
1493
|
const memberKey = memberKeyPair.publicKeyB64;
|
|
1209
|
-
const
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1494
|
+
const removal: StrandStatements = {
|
|
1495
|
+
sql: `delete from Strand.MemberPeer
|
|
1496
|
+
with context Signature = :peerSignature, ManagerKey = null, ManagerSignature = null
|
|
1497
|
+
where MemberKey = :peerMemberKey and PeerId = :peerId;`,
|
|
1498
|
+
params: {
|
|
1499
|
+
peerSignature: signStrandApproval(['Strand.MemberPeer', 'remove', memberKey, peerId, stampId], memberKeyPair.privateKeyB64),
|
|
1500
|
+
peerMemberKey: memberKey,
|
|
1501
|
+
peerId,
|
|
1502
|
+
},
|
|
1503
|
+
};
|
|
1504
|
+
return combineStatements(removal, revocationStatement('MemberPeer', stampId, memberKeyPair));
|
|
1220
1505
|
}
|
|
1221
1506
|
|
|
1222
1507
|
/**
|
|
@@ -1230,23 +1515,26 @@ async function deleteOwnMemberPeer(db: Database, params: RemoveOwnPeerParams, st
|
|
|
1230
1515
|
* members' nodes resume answering and dialing that peer. Only clear a binding
|
|
1231
1516
|
* that should never have existed, or a peer that is truly gone.
|
|
1232
1517
|
*/
|
|
1233
|
-
|
|
1234
|
-
db: Database,
|
|
1518
|
+
function memberPeerRemovalByManagerStatements(
|
|
1235
1519
|
params: RemoveMemberPeerByManagerParams,
|
|
1236
1520
|
stampId: string,
|
|
1237
|
-
):
|
|
1521
|
+
): StrandStatements {
|
|
1238
1522
|
const { managerKeyPair, memberKey, peerId } = params;
|
|
1239
|
-
const
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1523
|
+
const removal: StrandStatements = {
|
|
1524
|
+
sql: `delete from Strand.MemberPeer
|
|
1525
|
+
with context Signature = null, ManagerKey = :peerManagerKey, ManagerSignature = :peerManagerSignature
|
|
1526
|
+
where MemberKey = :peerMemberKey and PeerId = :peerId;`,
|
|
1527
|
+
params: {
|
|
1528
|
+
peerManagerKey: managerKeyPair.publicKeyB64,
|
|
1529
|
+
peerManagerSignature: signStrandApproval(
|
|
1530
|
+
['Strand.MemberPeer', 'manager-remove', memberKey, peerId, stampId],
|
|
1531
|
+
managerKeyPair.privateKeyB64,
|
|
1532
|
+
),
|
|
1533
|
+
peerMemberKey: memberKey,
|
|
1534
|
+
peerId,
|
|
1535
|
+
},
|
|
1536
|
+
};
|
|
1537
|
+
return combineStatements(removal, revocationStatement('MemberPeer', stampId, managerKeyPair));
|
|
1250
1538
|
}
|
|
1251
1539
|
|
|
1252
1540
|
// ── Manager rotation (add / remove RBAC admins) ───────────────────────────────
|
|
@@ -1314,11 +1602,28 @@ export interface AddManagerParams {
|
|
|
1314
1602
|
*
|
|
1315
1603
|
* @param db - The closed strand's database (founder manager already seated).
|
|
1316
1604
|
* @param params - The authorizing manager's keypair and the new manager key.
|
|
1605
|
+
* @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
|
|
1317
1606
|
* @throws If `Manager.Authorized` rejects, or if `Manager.MemberExists` rejects because
|
|
1318
1607
|
* `newManagerKey` holds no `Member` row (admit it first, or use
|
|
1319
1608
|
* {@link admitManager}); the insert rolls back.
|
|
1320
1609
|
*/
|
|
1321
|
-
export async function addManager(
|
|
1610
|
+
export async function addManager(
|
|
1611
|
+
db: Database,
|
|
1612
|
+
params: AddManagerParams,
|
|
1613
|
+
options?: StrandWriteOptions,
|
|
1614
|
+
): Promise<void> {
|
|
1615
|
+
const { byManagerKeyPair, newManagerKey } = params;
|
|
1616
|
+
const promotion = await managerAddStatement(db, params);
|
|
1617
|
+
await execStrandTransaction(db, promotion, options);
|
|
1618
|
+
log('Added manager %s (generation %d) by %s', newManagerKey, promotion.params.managerGeneration,
|
|
1619
|
+
byManagerKeyPair.publicKeyB64);
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
/**
|
|
1623
|
+
* The `Strand.Manager` insert behind {@link addManager}: reads the authorizer's generation,
|
|
1624
|
+
* seats the new manager one above it under a fresh stamp, and signs all three.
|
|
1625
|
+
*/
|
|
1626
|
+
async function managerAddStatement(db: Database, params: AddManagerParams): Promise<StrandStatements> {
|
|
1322
1627
|
const { byManagerKeyPair, newManagerKey } = params;
|
|
1323
1628
|
const authorizer = await managerRow(db, byManagerKeyPair.publicKeyB64);
|
|
1324
1629
|
// NOTE: +1 saturates at Number.MAX_SAFE_INTEGER, where the successor compares equal
|
|
@@ -1328,17 +1633,21 @@ export async function addManager(db: Database, params: AddManagerParams): Promis
|
|
|
1328
1633
|
// from outside this function, clamp or reject here rather than emitting a dead row.
|
|
1329
1634
|
const generation = authorizer == null ? 1 : authorizer.generation + 1;
|
|
1330
1635
|
const stampId = generateStrandStampId();
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1636
|
+
return {
|
|
1637
|
+
sql: `insert into Strand.Manager (MemberKey, Generation, StampId)
|
|
1638
|
+
with context ManagerKey = :managerAuthorizerKey, Signature = :managerSignature
|
|
1639
|
+
values (:managerMemberKey, :managerGeneration, :managerStampId);`,
|
|
1640
|
+
params: {
|
|
1641
|
+
managerAuthorizerKey: byManagerKeyPair.publicKeyB64,
|
|
1642
|
+
managerSignature: signStrandApproval(
|
|
1643
|
+
['Strand.Manager', 'add', newManagerKey, generation, stampId],
|
|
1644
|
+
byManagerKeyPair.privateKeyB64,
|
|
1645
|
+
),
|
|
1646
|
+
managerMemberKey: newManagerKey,
|
|
1647
|
+
managerGeneration: generation,
|
|
1648
|
+
managerStampId: stampId,
|
|
1649
|
+
},
|
|
1650
|
+
};
|
|
1342
1651
|
}
|
|
1343
1652
|
|
|
1344
1653
|
/** Parameters for {@link admitManager} — the same shape as {@link AddManagerParams}. */
|
|
@@ -1371,15 +1680,19 @@ export type AdmitManagerParams = AddManagerParams;
|
|
|
1371
1680
|
*
|
|
1372
1681
|
* @param db - The closed strand's database (founder manager already seated).
|
|
1373
1682
|
* @param params - The authorizing manager's keypair and the key to admit + promote.
|
|
1683
|
+
* @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
|
|
1374
1684
|
* @throws If either half is rejected (`Member.Authorized`, `Manager.Authorized`, a
|
|
1375
1685
|
* duplicate `Member` key, …); the whole transaction rolls back, leaving neither row.
|
|
1376
1686
|
*/
|
|
1377
|
-
export async function admitManager(
|
|
1687
|
+
export async function admitManager(
|
|
1688
|
+
db: Database,
|
|
1689
|
+
params: AdmitManagerParams,
|
|
1690
|
+
options?: StrandWriteOptions,
|
|
1691
|
+
): Promise<void> {
|
|
1378
1692
|
const { byManagerKeyPair, newManagerKey } = params;
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
});
|
|
1693
|
+
const admission = memberAddByManagerStatement({ managerKeyPair: byManagerKeyPair, memberKey: newManagerKey });
|
|
1694
|
+
const promotion = await managerAddStatement(db, params);
|
|
1695
|
+
await execStrandTransaction(db, combineStatements(admission, promotion), options);
|
|
1383
1696
|
log('Admitted + promoted manager %s by %s', newManagerKey, byManagerKeyPair.publicKeyB64);
|
|
1384
1697
|
}
|
|
1385
1698
|
|
|
@@ -1468,13 +1781,18 @@ export interface RemoveManagerParams {
|
|
|
1468
1781
|
*
|
|
1469
1782
|
* @param db - The closed strand's database.
|
|
1470
1783
|
* @param params - The authorizing keypair and the target manager key.
|
|
1784
|
+
* @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
|
|
1471
1785
|
* @throws If the signer IS the target and it is the sole manager (before writing —
|
|
1472
1786
|
* the operation the caller wants is {@link sealStrand}), or if `Manager.Authorized`
|
|
1473
1787
|
* rejects (a signer that is neither another existing manager nor the target
|
|
1474
1788
|
* itself, or a raw sole-manager `'resign'` whose post-image count is zero); the
|
|
1475
1789
|
* whole delete+tombstone transaction rolls back.
|
|
1476
1790
|
*/
|
|
1477
|
-
export async function removeManager(
|
|
1791
|
+
export async function removeManager(
|
|
1792
|
+
db: Database,
|
|
1793
|
+
params: RemoveManagerParams,
|
|
1794
|
+
options?: StrandWriteOptions,
|
|
1795
|
+
): Promise<void> {
|
|
1478
1796
|
const { byManagerKeyPair, targetManagerKey } = params;
|
|
1479
1797
|
const target = await managerRow(db, targetManagerKey);
|
|
1480
1798
|
if (target == null) {
|
|
@@ -1492,18 +1810,37 @@ export async function removeManager(db: Database, params: RemoveManagerParams):
|
|
|
1492
1810
|
['Strand.Manager', tag, targetManagerKey, target.stampId],
|
|
1493
1811
|
byManagerKeyPair.privateKeyB64,
|
|
1494
1812
|
);
|
|
1495
|
-
await
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
[byManagerKeyPair.publicKeyB64, signature, targetManagerKey],
|
|
1501
|
-
);
|
|
1502
|
-
await insertRevocation(db, 'Manager', target.stampId, byManagerKeyPair);
|
|
1503
|
-
});
|
|
1813
|
+
await execStrandTransaction(
|
|
1814
|
+
db,
|
|
1815
|
+
managerRemovalStatements(byManagerKeyPair, signature, targetManagerKey, target.stampId),
|
|
1816
|
+
options,
|
|
1817
|
+
);
|
|
1504
1818
|
log('Removed manager %s by %s', targetManagerKey, byManagerKeyPair.publicKeyB64);
|
|
1505
1819
|
}
|
|
1506
1820
|
|
|
1821
|
+
/**
|
|
1822
|
+
* The `Strand.Manager` delete plus its tombstone, shared by {@link removeManager} and
|
|
1823
|
+
* {@link sealStrand}, which differ only in the action tag `signature` was minted under.
|
|
1824
|
+
*/
|
|
1825
|
+
function managerRemovalStatements(
|
|
1826
|
+
signer: Ed25519KeyPair,
|
|
1827
|
+
signature: string,
|
|
1828
|
+
targetManagerKey: string,
|
|
1829
|
+
targetStampId: string,
|
|
1830
|
+
): StrandStatements {
|
|
1831
|
+
const removal: StrandStatements = {
|
|
1832
|
+
sql: `delete from Strand.Manager
|
|
1833
|
+
with context ManagerKey = :managerRemoverKey, Signature = :managerRemoverSignature
|
|
1834
|
+
where MemberKey = :managerMemberKey;`,
|
|
1835
|
+
params: {
|
|
1836
|
+
managerRemoverKey: signer.publicKeyB64,
|
|
1837
|
+
managerRemoverSignature: signature,
|
|
1838
|
+
managerMemberKey: targetManagerKey,
|
|
1839
|
+
},
|
|
1840
|
+
};
|
|
1841
|
+
return combineStatements(removal, revocationStatement('Manager', targetStampId, signer));
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1507
1844
|
// ── Sealing (the sole manager permanently freezes admission) ──────────────────
|
|
1508
1845
|
|
|
1509
1846
|
/** Parameters for {@link sealStrand}. */
|
|
@@ -1555,6 +1892,7 @@ export interface SealStrandParams {
|
|
|
1555
1892
|
*
|
|
1556
1893
|
* @param db - The closed strand's database.
|
|
1557
1894
|
* @param params - The sole manager's own keypair.
|
|
1895
|
+
* @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
|
|
1558
1896
|
* @throws If more than one manager exists (the caller wants {@link removeManager}),
|
|
1559
1897
|
* or the supplied keypair holds no `Manager` row while one exists, or the strand
|
|
1560
1898
|
* holds no `Manager` row and has never retired one (not founded yet — see
|
|
@@ -1563,7 +1901,11 @@ export interface SealStrandParams {
|
|
|
1563
1901
|
* and returns quietly (restart-safe, matching {@link bootstrapFounderMembership}
|
|
1564
1902
|
* and {@link removeManager}'s absent-row no-op).
|
|
1565
1903
|
*/
|
|
1566
|
-
export async function sealStrand(
|
|
1904
|
+
export async function sealStrand(
|
|
1905
|
+
db: Database,
|
|
1906
|
+
params: SealStrandParams,
|
|
1907
|
+
options?: StrandWriteOptions,
|
|
1908
|
+
): Promise<void> {
|
|
1567
1909
|
const { managerKeyPair } = params;
|
|
1568
1910
|
const managerCount = await strandTableCount(db, 'Manager');
|
|
1569
1911
|
if (managerCount === 0) {
|
|
@@ -1594,15 +1936,11 @@ export async function sealStrand(db: Database, params: SealStrandParams): Promis
|
|
|
1594
1936
|
['Strand.Manager', 'seal', managerKeyPair.publicKeyB64, self.stampId],
|
|
1595
1937
|
managerKeyPair.privateKeyB64,
|
|
1596
1938
|
);
|
|
1597
|
-
await
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
[managerKeyPair.publicKeyB64, signature, managerKeyPair.publicKeyB64],
|
|
1603
|
-
);
|
|
1604
|
-
await insertRevocation(db, 'Manager', self.stampId, managerKeyPair);
|
|
1605
|
-
});
|
|
1939
|
+
await execStrandTransaction(
|
|
1940
|
+
db,
|
|
1941
|
+
managerRemovalStatements(managerKeyPair, signature, managerKeyPair.publicKeyB64, self.stampId),
|
|
1942
|
+
options,
|
|
1943
|
+
);
|
|
1606
1944
|
log('Sealed strand: sole manager %s stepped down', managerKeyPair.publicKeyB64);
|
|
1607
1945
|
}
|
|
1608
1946
|
|