@serfab/cadre-core 0.12.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -8
- package/dist/bootstrap-peer-store-file.js +2 -2
- package/dist/bootstrap-peer-store.d.ts +32 -12
- package/dist/bootstrap-peer-store.js +41 -15
- package/dist/bootstrap-peer-store.js.map +1 -1
- package/dist/cadre-node.d.ts +883 -102
- package/dist/cadre-node.js +1800 -209
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-authorization.d.ts +2 -2
- package/dist/control-authorization.js +1 -0
- package/dist/control-authorization.js.map +1 -1
- package/dist/control-cohort.d.ts +13 -26
- package/dist/control-cohort.js +0 -26
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +318 -44
- package/dist/control-database.js +516 -74
- package/dist/control-database.js.map +1 -1
- package/dist/control-read-retry.d.ts +40 -11
- package/dist/control-read-retry.js +48 -15
- package/dist/control-read-retry.js.map +1 -1
- package/dist/control-retry.d.ts +58 -3
- package/dist/control-retry.js +58 -3
- package/dist/control-retry.js.map +1 -1
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +133 -21
- package/dist/control-schema.js.map +1 -1
- package/dist/control-write-retry.d.ts +80 -11
- package/dist/control-write-retry.js +168 -29
- package/dist/control-write-retry.js.map +1 -1
- package/dist/enrolled-machine-store-file.d.ts +13 -0
- package/dist/enrolled-machine-store-file.js +41 -0
- package/dist/enrolled-machine-store-file.js.map +1 -0
- package/dist/enrolled-machine-store.d.ts +81 -0
- package/dist/enrolled-machine-store.js +225 -0
- package/dist/enrolled-machine-store.js.map +1 -0
- package/dist/file-durable-slot.js +5 -3
- package/dist/file-durable-slot.js.map +1 -1
- package/dist/index.d.ts +17 -8
- package/dist/index.js +54 -9
- package/dist/index.js.map +1 -1
- package/dist/membership-connection-gater.d.ts +15 -1
- package/dist/membership-connection-gater.js +9 -3
- package/dist/membership-connection-gater.js.map +1 -1
- package/dist/node-local-snapshot.d.ts +21 -7
- package/dist/node-local-snapshot.js +30 -9
- package/dist/node-local-snapshot.js.map +1 -1
- package/dist/peer-dial.d.ts +143 -0
- package/dist/peer-dial.js +176 -0
- package/dist/peer-dial.js.map +1 -0
- package/dist/{strand-backfill.d.ts → peer-join-backfill.d.ts} +49 -20
- package/dist/{strand-backfill.js → peer-join-backfill.js} +133 -52
- package/dist/peer-join-backfill.js.map +1 -0
- package/dist/relay-addrs.d.ts +160 -49
- package/dist/relay-addrs.js +287 -60
- package/dist/relay-addrs.js.map +1 -1
- package/dist/relay-reservation.d.ts +71 -3
- package/dist/relay-reservation.js +116 -8
- package/dist/relay-reservation.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +30 -1
- package/dist/seed-bootstrap.js +62 -17
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/storage-scope.d.ts +62 -0
- package/dist/storage-scope.js +75 -0
- package/dist/storage-scope.js.map +1 -0
- package/dist/strand-database.d.ts +41 -11
- package/dist/strand-database.js +48 -18
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-first-sync-gate.d.ts +152 -0
- package/dist/strand-first-sync-gate.js +208 -0
- package/dist/strand-first-sync-gate.js.map +1 -0
- package/dist/strand-formation-manager.d.ts +99 -5
- package/dist/strand-formation-manager.js +119 -8
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +97 -3
- package/dist/strand-formation-protocol.js +108 -2
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +389 -19
- package/dist/strand-instance-manager.js +678 -65
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-member-key.d.ts +20 -12
- package/dist/strand-member-key.js +20 -12
- package/dist/strand-member-key.js.map +1 -1
- package/dist/strand-member-registry.js +3 -0
- package/dist/strand-member-registry.js.map +1 -1
- package/dist/strand-membership-reconciler.d.ts +361 -0
- package/dist/strand-membership-reconciler.js +548 -0
- package/dist/strand-membership-reconciler.js.map +1 -0
- package/dist/strand-membership-writer.d.ts +143 -11
- package/dist/strand-membership-writer.js +419 -135
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-network-config.d.ts +116 -0
- package/dist/strand-network-config.js +192 -0
- package/dist/strand-network-config.js.map +1 -0
- package/dist/strand-revocation-enforcer.d.ts +392 -0
- package/dist/strand-revocation-enforcer.js +533 -0
- package/dist/strand-revocation-enforcer.js.map +1 -0
- package/dist/strand-solicitation.d.ts +17 -1
- package/dist/strand-solicitation.js +13 -2
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-wake-protocol.d.ts +11 -7
- package/dist/strand-wake-protocol.js +20 -52
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/strand-watcher.js +12 -1
- package/dist/strand-watcher.js.map +1 -1
- package/dist/timed-step.d.ts +12 -0
- package/dist/timed-step.js +28 -0
- package/dist/timed-step.js.map +1 -0
- package/dist/timeout-scheduler.d.ts +19 -0
- package/dist/timeout-scheduler.js +22 -0
- package/dist/timeout-scheduler.js.map +1 -0
- package/dist/types.d.ts +472 -46
- package/dist/types.js +7 -1
- package/dist/types.js.map +1 -1
- package/package.json +16 -8
- package/src/bootstrap-peer-store-file.ts +2 -2
- package/src/bootstrap-peer-store.ts +69 -27
- package/src/cadre-node.ts +1926 -228
- package/src/control-authorization.ts +2 -1
- package/src/control-cohort.ts +122 -130
- package/src/control-database.ts +586 -74
- package/src/control-read-retry.ts +202 -167
- package/src/control-retry.ts +297 -190
- package/src/control-schema.ts +133 -21
- package/src/control-write-retry.ts +525 -383
- package/src/enrolled-machine-store-file.ts +43 -0
- package/src/enrolled-machine-store.ts +280 -0
- package/src/file-durable-slot.ts +5 -3
- package/src/index.ts +134 -15
- package/src/membership-connection-gater.ts +9 -3
- package/src/node-local-snapshot.ts +38 -9
- package/src/peer-addr-book.ts +251 -250
- package/src/peer-dial.ts +215 -0
- package/src/{strand-backfill.ts → peer-join-backfill.ts} +478 -389
- package/src/relay-addrs.ts +458 -218
- package/src/relay-reservation.ts +951 -828
- package/src/seed-bootstrap.ts +76 -18
- package/src/storage-scope.ts +78 -0
- package/src/strand-database.ts +61 -21
- package/src/strand-first-sync-gate.ts +258 -0
- package/src/strand-formation-manager.ts +162 -8
- package/src/strand-formation-protocol.ts +850 -692
- package/src/strand-instance-manager.ts +877 -74
- package/src/strand-member-key.ts +20 -12
- package/src/strand-member-registry.ts +3 -0
- package/src/strand-membership-reconciler.ts +680 -0
- package/src/strand-membership-writer.ts +550 -190
- package/src/strand-network-config.ts +250 -0
- package/src/strand-revocation-enforcer.ts +653 -0
- package/src/strand-solicitation.ts +499 -471
- package/src/strand-wake-protocol.ts +21 -66
- package/src/strand-watcher.ts +325 -314
- package/src/timed-step.ts +28 -0
- package/src/timeout-scheduler.ts +28 -0
- package/src/types.ts +487 -52
- package/dist/strand-backfill.js.map +0 -1
|
@@ -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';
|
|
145
|
+
}
|
|
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
|
+
}
|
|
149
187
|
}
|
|
150
|
-
|
|
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
|
/**
|
|
@@ -852,30 +1106,47 @@ export interface RevokeMemberParams {
|
|
|
852
1106
|
* (`Member.MinOneMember`, a local-count floor with the cross-node caveat its
|
|
853
1107
|
* schema NOTE states).
|
|
854
1108
|
*
|
|
1109
|
+
* Callers running inside a cadre runtime should follow this write with
|
|
1110
|
+
* `CadreNode.refreshRevocationEnforcement(strandId)`. The removed party's
|
|
1111
|
+
* machines are refused at the network layer by the strand's revoked-peer gate
|
|
1112
|
+
* (`strand-revocation-enforcer.ts`), which polls for membership changes because
|
|
1113
|
+
* this bare `Database` handle raises no event the runtime can hook — so without
|
|
1114
|
+
* that call the cut lands up to one poll interval (default 30 s) later.
|
|
1115
|
+
*
|
|
855
1116
|
* @param db - The closed strand's database.
|
|
856
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}).
|
|
857
1119
|
* @throws If `Member.Authorized` rejects (a non-manager or same-transaction
|
|
858
1120
|
* signer), `Member.NotAManager` rejects (the target still holds a Manager
|
|
859
1121
|
* row), or `Member.MinOneMember` rejects (the removal would empty the member
|
|
860
1122
|
* set); the whole delete+tombstone transaction rolls back.
|
|
861
1123
|
*/
|
|
862
|
-
export async function revokeMember(
|
|
1124
|
+
export async function revokeMember(
|
|
1125
|
+
db: Database,
|
|
1126
|
+
params: RevokeMemberParams,
|
|
1127
|
+
options?: StrandWriteOptions,
|
|
1128
|
+
): Promise<void> {
|
|
863
1129
|
const { managerKeyPair, memberKey } = params;
|
|
864
1130
|
const stampId = await memberStampId(db, memberKey);
|
|
865
1131
|
if (stampId == null) {
|
|
866
1132
|
log('Member %s already absent; skipping revoke', memberKey);
|
|
867
1133
|
return;
|
|
868
1134
|
}
|
|
869
|
-
const
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
[
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
}
|
|
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
|
+
);
|
|
879
1150
|
log('Revoked member %s by manager %s', memberKey, managerKeyPair.publicKeyB64);
|
|
880
1151
|
}
|
|
881
1152
|
|
|
@@ -907,12 +1178,22 @@ export interface LeaveStrandParams {
|
|
|
907
1178
|
* The same floors as {@link revokeMember} apply: a manager must resign first
|
|
908
1179
|
* (`NotAManager`) and the last member cannot leave (`MinOneMember`).
|
|
909
1180
|
*
|
|
1181
|
+
* Like {@link revokeMember}, a caller inside a cadre runtime should follow this
|
|
1182
|
+
* with `CadreNode.refreshRevocationEnforcement(strandId)` — here it is what
|
|
1183
|
+
* makes the departing node notice its own departure promptly (the gate emits
|
|
1184
|
+
* `strand:revoked` when it finds this node's own peer id revoked).
|
|
1185
|
+
*
|
|
910
1186
|
* @param db - The closed strand's database.
|
|
911
1187
|
* @param params - The departing member's own keypair.
|
|
1188
|
+
* @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
|
|
912
1189
|
* @throws If `Member.Authorized`, `Member.NotAManager`, or `Member.MinOneMember`
|
|
913
1190
|
* rejects; the whole delete+tombstone transaction rolls back.
|
|
914
1191
|
*/
|
|
915
|
-
export async function leaveStrand(
|
|
1192
|
+
export async function leaveStrand(
|
|
1193
|
+
db: Database,
|
|
1194
|
+
params: LeaveStrandParams,
|
|
1195
|
+
options?: StrandWriteOptions,
|
|
1196
|
+
): Promise<void> {
|
|
916
1197
|
const { memberKeyPair } = params;
|
|
917
1198
|
const memberKey = memberKeyPair.publicKeyB64;
|
|
918
1199
|
const stampId = await memberStampId(db, memberKey);
|
|
@@ -920,16 +1201,20 @@ export async function leaveStrand(db: Database, params: LeaveStrandParams): Prom
|
|
|
920
1201
|
log('Member %s already absent; skipping leave', memberKey);
|
|
921
1202
|
return;
|
|
922
1203
|
}
|
|
923
|
-
const
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
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
|
+
);
|
|
933
1218
|
log('Member %s left the strand', memberKey);
|
|
934
1219
|
}
|
|
935
1220
|
|
|
@@ -981,10 +1266,17 @@ export interface RegisterMemberPeerParams {
|
|
|
981
1266
|
*
|
|
982
1267
|
* @param db - The closed strand's database (the member already exists).
|
|
983
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}).
|
|
984
1270
|
* @throws If `MemberPeer.Authorized`/`MemberExists` rejects (wrong signer, or no
|
|
985
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.
|
|
986
1274
|
*/
|
|
987
|
-
export async function registerMemberPeer(
|
|
1275
|
+
export async function registerMemberPeer(
|
|
1276
|
+
db: Database,
|
|
1277
|
+
params: RegisterMemberPeerParams,
|
|
1278
|
+
options?: StrandWriteOptions,
|
|
1279
|
+
): Promise<void> {
|
|
988
1280
|
const { memberKeyPair, peerId } = params;
|
|
989
1281
|
const memberKey = memberKeyPair.publicKeyB64;
|
|
990
1282
|
|
|
@@ -994,16 +1286,17 @@ export async function registerMemberPeer(db: Database, params: RegisterMemberPee
|
|
|
994
1286
|
}
|
|
995
1287
|
|
|
996
1288
|
const stampId = generateStrandStampId();
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
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);
|
|
1007
1300
|
log('Registered MemberPeer (%s, %s)', memberKey, peerId);
|
|
1008
1301
|
}
|
|
1009
1302
|
|
|
@@ -1155,10 +1448,17 @@ export type RemoveMemberPeerParams = RemoveOwnPeerParams | RemoveMemberPeerByMan
|
|
|
1155
1448
|
* @param db - The closed strand's database.
|
|
1156
1449
|
* @param params - Either the owning member's keypair, or a manager's keypair plus the
|
|
1157
1450
|
* target member key.
|
|
1451
|
+
* @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
|
|
1158
1452
|
* @throws If `MemberPeer.Authorized` rejects (neither branch satisfied), or if the row
|
|
1159
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.
|
|
1160
1456
|
*/
|
|
1161
|
-
export async function removeMemberPeer(
|
|
1457
|
+
export async function removeMemberPeer(
|
|
1458
|
+
db: Database,
|
|
1459
|
+
params: RemoveMemberPeerParams,
|
|
1460
|
+
options?: StrandWriteOptions,
|
|
1461
|
+
): Promise<void> {
|
|
1162
1462
|
const memberKey = 'memberKeyPair' in params ? params.memberKeyPair.publicKeyB64 : params.memberKey;
|
|
1163
1463
|
const { peerId } = params;
|
|
1164
1464
|
|
|
@@ -1168,13 +1468,10 @@ export async function removeMemberPeer(db: Database, params: RemoveMemberPeerPar
|
|
|
1168
1468
|
return;
|
|
1169
1469
|
}
|
|
1170
1470
|
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
await deleteMemberPeerByManager(db, params, stampId);
|
|
1176
|
-
}
|
|
1177
|
-
});
|
|
1471
|
+
const removal = 'memberKeyPair' in params
|
|
1472
|
+
? ownMemberPeerRemovalStatements(params, stampId)
|
|
1473
|
+
: memberPeerRemovalByManagerStatements(params, stampId);
|
|
1474
|
+
await execStrandTransaction(db, removal, options);
|
|
1178
1475
|
|
|
1179
1476
|
// NOTE: the delete's `where` puts an equality on BOTH composite-PK columns, which the
|
|
1180
1477
|
// optimystic vtab module serves via a single-key seek that can MISS on a networked
|
|
@@ -1191,40 +1488,53 @@ export async function removeMemberPeer(db: Database, params: RemoveMemberPeerPar
|
|
|
1191
1488
|
}
|
|
1192
1489
|
|
|
1193
1490
|
/** The self branch: the owning member signs the remove-tagged digest + tombstone. */
|
|
1194
|
-
|
|
1491
|
+
function ownMemberPeerRemovalStatements(params: RemoveOwnPeerParams, stampId: string): StrandStatements {
|
|
1195
1492
|
const { memberKeyPair, peerId } = params;
|
|
1196
1493
|
const memberKey = memberKeyPair.publicKeyB64;
|
|
1197
|
-
const
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
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));
|
|
1208
1505
|
}
|
|
1209
1506
|
|
|
1210
|
-
/**
|
|
1211
|
-
|
|
1212
|
-
|
|
1507
|
+
/**
|
|
1508
|
+
* The manager branch: a manager signs the manager-remove-tagged digest + tombstone.
|
|
1509
|
+
*
|
|
1510
|
+
* NOTE: clearing an ORPHANED binding (one whose member was already removed) also
|
|
1511
|
+
* forgets the NETWORK DENIAL of that peer id. The orphaned `MemberPeer` row IS
|
|
1512
|
+
* the record the strand revoked-peer gate derives its deny set from
|
|
1513
|
+
* (`strand-revocation-enforcer.ts`) — the `Strand.Revocation` tombstone keeps
|
|
1514
|
+
* only the stamp, not the peer id — so once the orphan is cleared, remaining
|
|
1515
|
+
* members' nodes resume answering and dialing that peer. Only clear a binding
|
|
1516
|
+
* that should never have existed, or a peer that is truly gone.
|
|
1517
|
+
*/
|
|
1518
|
+
function memberPeerRemovalByManagerStatements(
|
|
1213
1519
|
params: RemoveMemberPeerByManagerParams,
|
|
1214
1520
|
stampId: string,
|
|
1215
|
-
):
|
|
1521
|
+
): StrandStatements {
|
|
1216
1522
|
const { managerKeyPair, memberKey, peerId } = params;
|
|
1217
|
-
const
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
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));
|
|
1228
1538
|
}
|
|
1229
1539
|
|
|
1230
1540
|
// ── Manager rotation (add / remove RBAC admins) ───────────────────────────────
|
|
@@ -1292,11 +1602,28 @@ export interface AddManagerParams {
|
|
|
1292
1602
|
*
|
|
1293
1603
|
* @param db - The closed strand's database (founder manager already seated).
|
|
1294
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}).
|
|
1295
1606
|
* @throws If `Manager.Authorized` rejects, or if `Manager.MemberExists` rejects because
|
|
1296
1607
|
* `newManagerKey` holds no `Member` row (admit it first, or use
|
|
1297
1608
|
* {@link admitManager}); the insert rolls back.
|
|
1298
1609
|
*/
|
|
1299
|
-
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> {
|
|
1300
1627
|
const { byManagerKeyPair, newManagerKey } = params;
|
|
1301
1628
|
const authorizer = await managerRow(db, byManagerKeyPair.publicKeyB64);
|
|
1302
1629
|
// NOTE: +1 saturates at Number.MAX_SAFE_INTEGER, where the successor compares equal
|
|
@@ -1306,17 +1633,21 @@ export async function addManager(db: Database, params: AddManagerParams): Promis
|
|
|
1306
1633
|
// from outside this function, clamp or reject here rather than emitting a dead row.
|
|
1307
1634
|
const generation = authorizer == null ? 1 : authorizer.generation + 1;
|
|
1308
1635
|
const stampId = generateStrandStampId();
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
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
|
+
};
|
|
1320
1651
|
}
|
|
1321
1652
|
|
|
1322
1653
|
/** Parameters for {@link admitManager} — the same shape as {@link AddManagerParams}. */
|
|
@@ -1349,15 +1680,19 @@ export type AdmitManagerParams = AddManagerParams;
|
|
|
1349
1680
|
*
|
|
1350
1681
|
* @param db - The closed strand's database (founder manager already seated).
|
|
1351
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}).
|
|
1352
1684
|
* @throws If either half is rejected (`Member.Authorized`, `Manager.Authorized`, a
|
|
1353
1685
|
* duplicate `Member` key, …); the whole transaction rolls back, leaving neither row.
|
|
1354
1686
|
*/
|
|
1355
|
-
export async function admitManager(
|
|
1687
|
+
export async function admitManager(
|
|
1688
|
+
db: Database,
|
|
1689
|
+
params: AdmitManagerParams,
|
|
1690
|
+
options?: StrandWriteOptions,
|
|
1691
|
+
): Promise<void> {
|
|
1356
1692
|
const { byManagerKeyPair, newManagerKey } = params;
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
});
|
|
1693
|
+
const admission = memberAddByManagerStatement({ managerKeyPair: byManagerKeyPair, memberKey: newManagerKey });
|
|
1694
|
+
const promotion = await managerAddStatement(db, params);
|
|
1695
|
+
await execStrandTransaction(db, combineStatements(admission, promotion), options);
|
|
1361
1696
|
log('Admitted + promoted manager %s by %s', newManagerKey, byManagerKeyPair.publicKeyB64);
|
|
1362
1697
|
}
|
|
1363
1698
|
|
|
@@ -1446,13 +1781,18 @@ export interface RemoveManagerParams {
|
|
|
1446
1781
|
*
|
|
1447
1782
|
* @param db - The closed strand's database.
|
|
1448
1783
|
* @param params - The authorizing keypair and the target manager key.
|
|
1784
|
+
* @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
|
|
1449
1785
|
* @throws If the signer IS the target and it is the sole manager (before writing —
|
|
1450
1786
|
* the operation the caller wants is {@link sealStrand}), or if `Manager.Authorized`
|
|
1451
1787
|
* rejects (a signer that is neither another existing manager nor the target
|
|
1452
1788
|
* itself, or a raw sole-manager `'resign'` whose post-image count is zero); the
|
|
1453
1789
|
* whole delete+tombstone transaction rolls back.
|
|
1454
1790
|
*/
|
|
1455
|
-
export async function removeManager(
|
|
1791
|
+
export async function removeManager(
|
|
1792
|
+
db: Database,
|
|
1793
|
+
params: RemoveManagerParams,
|
|
1794
|
+
options?: StrandWriteOptions,
|
|
1795
|
+
): Promise<void> {
|
|
1456
1796
|
const { byManagerKeyPair, targetManagerKey } = params;
|
|
1457
1797
|
const target = await managerRow(db, targetManagerKey);
|
|
1458
1798
|
if (target == null) {
|
|
@@ -1470,18 +1810,37 @@ export async function removeManager(db: Database, params: RemoveManagerParams):
|
|
|
1470
1810
|
['Strand.Manager', tag, targetManagerKey, target.stampId],
|
|
1471
1811
|
byManagerKeyPair.privateKeyB64,
|
|
1472
1812
|
);
|
|
1473
|
-
await
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
[byManagerKeyPair.publicKeyB64, signature, targetManagerKey],
|
|
1479
|
-
);
|
|
1480
|
-
await insertRevocation(db, 'Manager', target.stampId, byManagerKeyPair);
|
|
1481
|
-
});
|
|
1813
|
+
await execStrandTransaction(
|
|
1814
|
+
db,
|
|
1815
|
+
managerRemovalStatements(byManagerKeyPair, signature, targetManagerKey, target.stampId),
|
|
1816
|
+
options,
|
|
1817
|
+
);
|
|
1482
1818
|
log('Removed manager %s by %s', targetManagerKey, byManagerKeyPair.publicKeyB64);
|
|
1483
1819
|
}
|
|
1484
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
|
+
|
|
1485
1844
|
// ── Sealing (the sole manager permanently freezes admission) ──────────────────
|
|
1486
1845
|
|
|
1487
1846
|
/** Parameters for {@link sealStrand}. */
|
|
@@ -1533,6 +1892,7 @@ export interface SealStrandParams {
|
|
|
1533
1892
|
*
|
|
1534
1893
|
* @param db - The closed strand's database.
|
|
1535
1894
|
* @param params - The sole manager's own keypair.
|
|
1895
|
+
* @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
|
|
1536
1896
|
* @throws If more than one manager exists (the caller wants {@link removeManager}),
|
|
1537
1897
|
* or the supplied keypair holds no `Manager` row while one exists, or the strand
|
|
1538
1898
|
* holds no `Manager` row and has never retired one (not founded yet — see
|
|
@@ -1541,7 +1901,11 @@ export interface SealStrandParams {
|
|
|
1541
1901
|
* and returns quietly (restart-safe, matching {@link bootstrapFounderMembership}
|
|
1542
1902
|
* and {@link removeManager}'s absent-row no-op).
|
|
1543
1903
|
*/
|
|
1544
|
-
export async function sealStrand(
|
|
1904
|
+
export async function sealStrand(
|
|
1905
|
+
db: Database,
|
|
1906
|
+
params: SealStrandParams,
|
|
1907
|
+
options?: StrandWriteOptions,
|
|
1908
|
+
): Promise<void> {
|
|
1545
1909
|
const { managerKeyPair } = params;
|
|
1546
1910
|
const managerCount = await strandTableCount(db, 'Manager');
|
|
1547
1911
|
if (managerCount === 0) {
|
|
@@ -1572,15 +1936,11 @@ export async function sealStrand(db: Database, params: SealStrandParams): Promis
|
|
|
1572
1936
|
['Strand.Manager', 'seal', managerKeyPair.publicKeyB64, self.stampId],
|
|
1573
1937
|
managerKeyPair.privateKeyB64,
|
|
1574
1938
|
);
|
|
1575
|
-
await
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
[managerKeyPair.publicKeyB64, signature, managerKeyPair.publicKeyB64],
|
|
1581
|
-
);
|
|
1582
|
-
await insertRevocation(db, 'Manager', self.stampId, managerKeyPair);
|
|
1583
|
-
});
|
|
1939
|
+
await execStrandTransaction(
|
|
1940
|
+
db,
|
|
1941
|
+
managerRemovalStatements(managerKeyPair, signature, managerKeyPair.publicKeyB64, self.stampId),
|
|
1942
|
+
options,
|
|
1943
|
+
);
|
|
1584
1944
|
log('Sealed strand: sole manager %s stepped down', managerKeyPair.publicKeyB64);
|
|
1585
1945
|
}
|
|
1586
1946
|
|