@serfab/cadre-core 0.10.0 → 0.12.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.
Files changed (145) hide show
  1. package/README.md +255 -255
  2. package/dist/announce-addrs.d.ts +49 -0
  3. package/dist/announce-addrs.js +78 -0
  4. package/dist/announce-addrs.js.map +1 -0
  5. package/dist/cadre-node.d.ts +484 -58
  6. package/dist/cadre-node.js +953 -182
  7. package/dist/cadre-node.js.map +1 -1
  8. package/dist/control-cohort.d.ts +26 -0
  9. package/dist/control-cohort.js +26 -0
  10. package/dist/control-cohort.js.map +1 -1
  11. package/dist/control-database.d.ts +222 -125
  12. package/dist/control-database.js +356 -266
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-formation-recorder.d.ts +8 -7
  15. package/dist/control-formation-recorder.js +8 -7
  16. package/dist/control-formation-recorder.js.map +1 -1
  17. package/dist/control-read-retry.d.ts +103 -0
  18. package/dist/control-read-retry.js +145 -0
  19. package/dist/control-read-retry.js.map +1 -0
  20. package/dist/control-retry.d.ts +101 -0
  21. package/dist/control-retry.js +108 -0
  22. package/dist/control-retry.js.map +1 -0
  23. package/dist/control-schema.d.ts +1 -1
  24. package/dist/control-schema.js +805 -774
  25. package/dist/control-schema.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +65 -31
  27. package/dist/control-write-retry.js +33 -103
  28. package/dist/control-write-retry.js.map +1 -1
  29. package/dist/delegate-admission.d.ts +17 -15
  30. package/dist/delegate-admission.js +17 -15
  31. package/dist/delegate-admission.js.map +1 -1
  32. package/dist/index.d.ts +7 -5
  33. package/dist/index.js +18 -9
  34. package/dist/index.js.map +1 -1
  35. package/dist/membership-connection-gater.d.ts +211 -25
  36. package/dist/membership-connection-gater.js +309 -29
  37. package/dist/membership-connection-gater.js.map +1 -1
  38. package/dist/peer-addr-book.d.ts +92 -0
  39. package/dist/peer-addr-book.js +220 -0
  40. package/dist/peer-addr-book.js.map +1 -0
  41. package/dist/peer-record.d.ts +26 -0
  42. package/dist/peer-record.js +32 -0
  43. package/dist/peer-record.js.map +1 -1
  44. package/dist/relay-addrs.d.ts +76 -26
  45. package/dist/relay-addrs.js +115 -27
  46. package/dist/relay-addrs.js.map +1 -1
  47. package/dist/relay-reservation.d.ts +20 -15
  48. package/dist/relay-reservation.js +18 -14
  49. package/dist/relay-reservation.js.map +1 -1
  50. package/dist/seed-bootstrap.d.ts +7 -0
  51. package/dist/seed-bootstrap.js +15 -13
  52. package/dist/seed-bootstrap.js.map +1 -1
  53. package/dist/strand-addr-protocol.js +16 -0
  54. package/dist/strand-addr-protocol.js.map +1 -1
  55. package/dist/strand-backfill.js +13 -4
  56. package/dist/strand-backfill.js.map +1 -1
  57. package/dist/strand-cohort.d.ts +4 -37
  58. package/dist/strand-cohort.js +4 -15
  59. package/dist/strand-cohort.js.map +1 -1
  60. package/dist/strand-database.d.ts +11 -16
  61. package/dist/strand-database.js +19 -7
  62. package/dist/strand-database.js.map +1 -1
  63. package/dist/strand-formation-manager.d.ts +18 -17
  64. package/dist/strand-formation-manager.js +25 -27
  65. package/dist/strand-formation-manager.js.map +1 -1
  66. package/dist/strand-formation-protocol.d.ts +7 -7
  67. package/dist/strand-formation-protocol.js +8 -8
  68. package/dist/strand-formation-protocol.js.map +1 -1
  69. package/dist/strand-instance-manager.d.ts +53 -21
  70. package/dist/strand-instance-manager.js +128 -37
  71. package/dist/strand-instance-manager.js.map +1 -1
  72. package/dist/strand-membership-writer.d.ts +113 -19
  73. package/dist/strand-membership-writer.js +219 -51
  74. package/dist/strand-membership-writer.js.map +1 -1
  75. package/dist/strand-solicitation.d.ts +3 -3
  76. package/dist/strand-solicitation.js +4 -5
  77. package/dist/strand-solicitation.js.map +1 -1
  78. package/dist/strand-wake-protocol.d.ts +47 -2
  79. package/dist/strand-wake-protocol.js +76 -6
  80. package/dist/strand-wake-protocol.js.map +1 -1
  81. package/dist/types.d.ts +107 -54
  82. package/dist/types.js.map +1 -1
  83. package/package.json +8 -7
  84. package/src/announce-addrs.ts +93 -0
  85. package/src/arachnode-stub.ts +159 -159
  86. package/src/bootstrap-peer-store-file.ts +44 -44
  87. package/src/bootstrap-peer-store.ts +172 -172
  88. package/src/cadre-node.ts +1026 -194
  89. package/src/canonical-json.ts +30 -30
  90. package/src/control-authorization.ts +114 -114
  91. package/src/control-cohort.ts +130 -103
  92. package/src/control-database.ts +376 -298
  93. package/src/control-formation-recorder.ts +305 -304
  94. package/src/control-read-retry.ts +167 -0
  95. package/src/control-retry.ts +190 -0
  96. package/src/control-schema.ts +817 -786
  97. package/src/control-stream.ts +205 -205
  98. package/src/control-write-retry.ts +383 -473
  99. package/src/delegate-admission.ts +304 -302
  100. package/src/device-token.ts +94 -94
  101. package/src/diagnostics/connection-path.ts +277 -277
  102. package/src/diagnostics/webrtc-turn-tracker.ts +222 -222
  103. package/src/ed25519-key.ts +134 -134
  104. package/src/enrollment.ts +199 -199
  105. package/src/file-durable-slot.ts +61 -61
  106. package/src/formation-approval.ts +604 -604
  107. package/src/fs-atomic.ts +107 -107
  108. package/src/hibernation-manager.ts +437 -437
  109. package/src/identity-key.ts +109 -109
  110. package/src/index.ts +435 -406
  111. package/src/key-store-file.ts +132 -132
  112. package/src/key-store.ts +99 -99
  113. package/src/membership-connection-gater.ts +368 -39
  114. package/src/node-local-snapshot.ts +260 -260
  115. package/src/peer-addr-book.ts +250 -0
  116. package/src/peer-authorization.ts +259 -259
  117. package/src/peer-record.ts +197 -164
  118. package/src/push-credentials.ts +84 -84
  119. package/src/push-node.ts +64 -64
  120. package/src/push-notifier-apns.ts +247 -247
  121. package/src/push-notifier-fcm.ts +220 -220
  122. package/src/push-notifier-shared.ts +28 -28
  123. package/src/push-notifier.ts +52 -52
  124. package/src/relay-addrs.ts +134 -27
  125. package/src/relay-reservation.ts +22 -17
  126. package/src/seed-bootstrap.ts +15 -15
  127. package/src/seed-trust-policy.ts +140 -140
  128. package/src/strand-addr-protocol.ts +458 -441
  129. package/src/strand-backfill.ts +28 -5
  130. package/src/strand-cohort.ts +38 -77
  131. package/src/strand-database.ts +202 -204
  132. package/src/strand-formation-manager.ts +26 -28
  133. package/src/strand-formation-protocol.ts +692 -692
  134. package/src/strand-instance-manager.ts +636 -553
  135. package/src/strand-member-key.ts +39 -39
  136. package/src/strand-member-registry.ts +182 -182
  137. package/src/strand-membership-writer.ts +1627 -1430
  138. package/src/strand-solicitation.ts +471 -472
  139. package/src/strand-transport-key.ts +58 -58
  140. package/src/strand-wake-payload.ts +32 -32
  141. package/src/strand-wake-protocol.ts +400 -314
  142. package/src/strand-watcher.ts +314 -314
  143. package/src/trusted-owner-store-file.ts +41 -41
  144. package/src/trusted-owner-store.ts +171 -171
  145. package/src/types.ts +1354 -1303
@@ -1,1430 +1,1627 @@
1
- import debug from 'debug';
2
- import { toString as uint8ArrayToString } from 'uint8arrays';
3
- import type { Database } from '@quereus/quereus';
4
- import { digest, sign, verify, generatePrivateKey, getPublicKey, randomBytes } from '@optimystic/quereus-plugin-crypto';
5
- import type { SAppConfig } from './types.js';
6
- import type { Ed25519KeyPair } from './ed25519-key.js';
7
- import { canonicalDatetime } from './canonical-datetime.js';
8
-
9
- const log = debug('sereus:cadre:strand-membership');
10
-
11
- /**
12
- * The engine (rules-logic system) assumed to manage every strand. Persisted in
13
- * `Strand.Header.Engine`.
14
- *
15
- * PLACEHOLDER SEAM: there is no engine-selection mechanism yet — Quereus is the
16
- * only engine. When a real engine-config seam lands, the founder bootstrap should
17
- * read the strand's chosen engine from there instead of this constant. Documented
18
- * deliberately so the column is non-null and self-describing in the meantime.
19
- */
20
- export const STRAND_ENGINE = 'quereus';
21
-
22
- /**
23
- * The pinned engine version recorded in `Strand.Header.EngineVersion`. A pinned
24
- * string for the same reason as {@link STRAND_ENGINE}: it is a documented
25
- * placeholder until an engine-selection seam supplies the real running version.
26
- */
27
- export const STRAND_ENGINE_VERSION = '0.1.0';
28
-
29
- /**
30
- * The single-payload ed25519 signer the `Strand.Invite`/`Strand.ConsumedInvite`
31
- * handshake constraints verify against.
32
- *
33
- * Those constraints sign a single SHA-256 digest over a `'|'`-joined payload
34
- * (e.g. `InviteValid` verifies
35
- * `verify(digest(new.Key || '|' || coalesce(new.Expiration, '')), ...)`).
36
- * So the signer hashes the payload to raw bytes and ed25519-signs *those bytes*:
37
- * SQL `digest(...)`'s default output is base64url and `verify(...)`'s default
38
- * `inputEncoding` is base64url, so signer and verifier operate on identical bytes.
39
- * The membership/RBAC tables (Member/Manager/MemberPeer/Revocation) instead use
40
- * domain/action-tagged VARIADIC digests — see {@link signStrandApproval}.
41
- *
42
- * All strand keys are ed25519, so the explicit `'ed25519'` curve arg is mandatory
43
- * (the crypto plugin otherwise defaults to secp256k1). Mirrors the proven
44
- * `signItem` helper in the `rbac-signed-write` integration scenario.
45
- *
46
- * @param payload - The `'|'`-joined payload the matching constraint hashes.
47
- * @param privateKeyB64 - The signer's base64url ed25519 private seed.
48
- * @returns The base64url ed25519 signature over the payload's SHA-256 digest.
49
- */
50
- export function signStrandPayload(payload: string, privateKeyB64: string): string {
51
- const hashBytes = digest([payload], 'sha256', 'bytes') as Uint8Array;
52
- return sign(hashBytes, privateKeyB64, 'ed25519', 'bytes', 'base64url', 'base64url') as string;
53
- }
54
-
55
- /**
56
- * The verifier counterpart to {@link signStrandPayload}.
57
- *
58
- * Mirrors what the single-payload `Strand.*` constraints compute:
59
- * `verify(digest(payload), signature, publicKey, 'ed25519')`.
60
- * SQL `digest`'s default output and `verify`'s default `inputEncoding`/`sigEncoding`/
61
- * `keyEncoding` are all base64url, so the off-engine check operates on the exact
62
- * same bytes the in-engine CHECK does. Used by the off-engine `MemberVerifier`
63
- * (pre-flight registration checks) so the on-engine constraint is not the only
64
- * place a member self-proof is validated.
65
- *
66
- * @param payload - The `'|'`-joined payload the matching constraint hashes.
67
- * @param signatureB64 - The base64url ed25519 signature to check.
68
- * @param publicKeyB64 - The base64url ed25519 public key to verify against.
69
- * @returns `true` iff the signature is valid for the payload under that key.
70
- */
71
- export function verifyStrandPayload(payload: string, signatureB64: string, publicKeyB64: string): boolean {
72
- const payloadDigest = digest([payload], 'sha256', 'base64url') as string;
73
- return verify(payloadDigest, signatureB64, publicKeyB64, 'ed25519', 'base64url', 'base64url', 'base64url');
74
- }
75
-
76
- /**
77
- * Sign a domain/action-tagged `Strand.*` membership/RBAC approval.
78
- *
79
- * Every `Authorized` branch on `Member`/`Manager`/`MemberPeer`/`Revocation`
80
- * verifies a VARIADIC digest — `digest('<domain>', '<action>', <field>, ...,
81
- * <StampId>)` — whose leading two elements are fixed literals (the domain tag,
82
- * e.g. `'Strand.Member'`, and an action tag, e.g. `'add'`) and whose trailing
83
- * element is the row's per-incarnation StampId. The tags keep an approval from
84
- * verifying against any rule but the one it was minted for (the idiom of the
85
- * control layer's `buildAuthorizationMessage`); the StampId binding makes it
86
- * single-use — it authorizes exactly one incarnation of one row, and dies when
87
- * that row's stamp is retired into `Strand.Revocation`.
88
- *
89
- * The caller passes the digest elements exactly as the matching SQL constraint
90
- * lists them (each SQL argument = one array element), PRESERVING TYPE: the
91
- * crypto plugin's digest framing is type-tagged (INTEGER 1 and TEXT '1' encode
92
- * differently, deliberately), so `Manager.Authorized`'s promotion branch —
93
- * whose digest includes the INTEGER `new.Generation` — must be signed over the
94
- * NUMBER, not its string form. Integer `number` and `bigint` encode
95
- * identically in that framing, so JS-side numbers match however the engine
96
- * represents the SQL integer.
97
- *
98
- * Like {@link signStrandPayload}, the raw digest BYTES are ed25519-signed
99
- * directly, matching SQL `verify(...)`'s default base64url input decoding.
100
- *
101
- * @param fields - The digest elements, in constraint order (tags first, StampId last).
102
- * @param privateKeyB64 - The signer's base64url ed25519 private seed.
103
- * @returns The base64url ed25519 signature over the tagged digest.
104
- */
105
- export function signStrandApproval(fields: ReadonlyArray<string | number>, privateKeyB64: string): string {
106
- const hashBytes = digest(fields, 'sha256', 'bytes') as Uint8Array;
107
- return sign(hashBytes, privateKeyB64, 'ed25519', 'bytes', 'base64url', 'base64url') as string;
108
- }
109
-
110
- /**
111
- * Mint a fresh per-row authorization nonce (StampId) for a `Strand.Member`,
112
- * `Strand.Manager`, or `Strand.MemberPeer` row.
113
- *
114
- * 256 bits of CSPRNG output, base64url-encoded — enough entropy that a future
115
- * legitimate stamp cannot be guessed and pre-planted into `Strand.Revocation`
116
- * (the `RowIsGone` comment in `schemas/strand.qsql` leans on exactly this).
117
- * Plain randomness, deliberately NOT the control layer's peer-derived
118
- * `generateStampId`: strand rows are not per-peer, so entropy alone carries
119
- * the uniqueness.
120
- *
121
- * @returns A fresh base64url stamp (43 chars for 32 bytes).
122
- */
123
- export function generateStrandStampId(): string {
124
- // randomBytes' first arg is BITS, not bytes — 256 bits = 32 bytes.
125
- const bytes = randomBytes(256, 'bytes') as Uint8Array;
126
- return uint8ArrayToString(bytes, 'base64url');
127
- }
128
-
129
- /**
130
- * Run `fn` inside one explicit strand transaction: begin, fn, commit — with the
131
- * standard rollback-on-failure shape.
132
- *
133
- * Used wherever two `Strand.*` writes must land atomically: the invite-consume
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.
144
- */
145
- async function inStrandTransaction(db: Database, fn: () => Promise<void>): Promise<void> {
146
- if (!db.getAutocommit()) {
147
- await fn();
148
- return;
149
- }
150
- await db.beginTransaction();
151
- try {
152
- await fn();
153
- await db.commit();
154
- } catch (error) {
155
- // A failed commit() already tears down the transaction, so rollback() would
156
- // throw "No transaction active" and mask the real cause — swallow only that.
157
- try {
158
- await db.rollback();
159
- } catch (rollbackError) {
160
- log('Rollback after strand transaction failure was a no-op: %s', rollbackError);
161
- }
162
- throw error;
163
- }
164
- }
165
-
166
- /**
167
- * File the `Strand.Revocation` tombstone that retires a deleted row's StampId.
168
- *
169
- * Runs inside the SAME transaction as the delete it accompanies — the guarded
170
- * tables' `RevocationRecorded` (deferred) requires the tombstone at commit, and
171
- * `Revocation.RowIsGone` (also deferred) requires the row to already be gone, so
172
- * the pair only ever lands together.
173
- *
174
- * `Revocation.Authorized` verifies the retire-tagged digest against a
175
- * PRE-transaction (`committed.Member`) row, so the retiree must have been a
176
- * member BEFORE this transaction. Every writer flow satisfies this: managers are
177
- * members, and a self-departing member is committed until this very transaction
178
- * removes it. Unlike the sibling tables' nullable contexts, `Revocation`'s
179
- * context is non-null — both fields are always supplied.
180
- *
181
- * @param db - The strand database, INSIDE an open transaction.
182
- * @param tableName - Which guarded table the stamp belonged to.
183
- * @param stampId - The retired stamp.
184
- * @param retiree - The committed member filing the tombstone (the delete's own signer).
185
- */
186
- async function insertRevocation(
187
- db: Database,
188
- tableName: 'Member' | 'Manager' | 'MemberPeer',
189
- stampId: string,
190
- retiree: Ed25519KeyPair,
191
- ): Promise<void> {
192
- const signature = signStrandApproval(['Strand.Revocation', 'retire', tableName, stampId], retiree.privateKeyB64);
193
- await db.exec(
194
- `insert into Strand.Revocation (TableName, StampId)
195
- with context MemberKey = ?, Signature = ?
196
- values (?, ?)`,
197
- [retiree.publicKeyB64, signature, tableName, stampId],
198
- );
199
- }
200
-
201
- /** Parameters for {@link bootstrapFounderMembership}. */
202
- export interface FounderBootstrapParams {
203
- /** The strand id — written to `Header.Id`. */
204
- strandId: string;
205
- /** Strand type: `'o'` (open, Header only) or `'c'` (closed, Header+Member+Manager). */
206
- type: 'o' | 'c';
207
- /** The sApp config whose id/version/schema/signature populate the Header. */
208
- sApp: SAppConfig;
209
- /**
210
- * The founder's derived strand keypair (from {@link strandMemberKeyPair}). Its
211
- * `publicKeyB64` becomes the founding `Member.Key` and `Manager.MemberKey`.
212
- * Required for a closed strand; ignored for an open strand.
213
- */
214
- founderKeyPair?: Ed25519KeyPair;
215
- }
216
-
217
- /**
218
- * Count rows in a `Strand.*` table as seen by this database instance.
219
- *
220
- * The table name is a fixed literal supplied by this module (never user input),
221
- * so the interpolation is not an injection surface — it just keeps the singleton
222
- * insert-if-absent guards terse.
223
- */
224
- async function strandTableCount(db: Database, table: 'Header' | 'Member' | 'Manager'): Promise<number> {
225
- for await (const row of db.eval(`select count(1) as Count from Strand.${table}`)) {
226
- return (row.Count as number) ?? 0;
227
- }
228
- return 0;
229
- }
230
-
231
- /**
232
- * Insert the singleton `Strand.Header` if absent.
233
- *
234
- * Every `Header` column is NOT NULL (Quereus defaults unqualified columns to NOT
235
- * NULL; the table declares no `null` columns), so all eight are supplied non-null.
236
- * A missing `sApp.signature` (dev `requireSignedSchemas:false`) coalesces to `''`
237
- * rather than inserting null. `Header` carries only `InsertOnly` + a singleton PK,
238
- * so the insert needs no `with context`.
239
- */
240
- async function insertHeaderIfAbsent(db: Database, params: FounderBootstrapParams): Promise<void> {
241
- if (await strandTableCount(db, 'Header') > 0) {
242
- log('Header already present for strand %s; skipping', params.strandId);
243
- return;
244
- }
245
- await db.exec(
246
- `insert into Strand.Header
247
- (Id, Type, sAppId, sAppVersion, sAppSchema, sAppSignature, Engine, EngineVersion)
248
- values (?, ?, ?, ?, ?, ?, ?, ?)`,
249
- [
250
- params.strandId,
251
- params.type,
252
- params.sApp.id,
253
- params.sApp.version,
254
- params.sApp.schema,
255
- params.sApp.signature ?? '',
256
- STRAND_ENGINE,
257
- STRAND_ENGINE_VERSION,
258
- ],
259
- );
260
- log('Inserted Header for strand %s (type %s)', params.strandId, params.type);
261
- }
262
-
263
- /**
264
- * Insert the founding `Strand.Member` if no member exists yet.
265
- *
266
- * The empty PRE-transaction member set (`count(1) from committed.Member = 0`)
267
- * satisfies the bootstrap branch of `Member.Authorized`, so no signature is
268
- * needed — the context fields are explicit nulls. The branch also caps the
269
- * POST-image at one member, so it waives authorization for exactly this single
270
- * seat, never for a batch. The row still mints a fresh StampId — the column is
271
- * NOT NULL on every path; bootstrap only waives the SIGNATURE. Guarding on the
272
- * count makes this idempotent: a re-run (the founder re-`addStrand`/
273
- * `resumeStrand`) finds the member present and skips.
274
- */
275
- async function insertFounderMemberIfAbsent(db: Database, memberKey: string, strandId: string): Promise<void> {
276
- if (await strandTableCount(db, 'Member') > 0) {
277
- log('Member already present for strand %s; skipping founder Member', strandId);
278
- return;
279
- }
280
- await db.exec(
281
- `insert into Strand.Member (Key, StampId)
282
- with context ManagerKey = null, ManagerSignature = null, MemberSignature = null
283
- values (?, ?)`,
284
- [memberKey, generateStrandStampId()],
285
- );
286
- log('Inserted founding Member for strand %s', strandId);
287
- }
288
-
289
- /**
290
- * Insert the founding `Strand.Manager` if no manager exists yet.
291
- *
292
- * The empty-table state satisfies the bootstrap branch of `Manager.Authorized`, so
293
- * no signature is needed (a fresh StampId is still minted — the column is NOT
294
- * NULL). The founder is seated at `Generation = 0` — the root of
295
- * the manager lineage; the bootstrap branch requires exactly that value, and every
296
- * later manager is seated at a strictly greater generation (see {@link addManager}).
297
- * Idempotent via the same count guard.
298
- *
299
- * ORDERING IS LOAD-BEARING, not merely convenient. The bootstrap branch is gated to
300
- * the founding state and now reads `exists (select 1 from Member M where M.Key =
301
- * new.MemberKey)` alongside `count(Manager) <= 1` and `count(Member) <= 1`, so the
302
- * founding `Member` row MUST already exist when this commits — a Manager-first
303
- * seeding path is rejected. It must also run AFTER the Header insert:
304
- * `Manager.OnlyClosed` is a deferred (subquery) check evaluated at commit. The
305
- * sequential auto-commit Header→Member→Manager order in
306
- * {@link bootstrapFounderMembership} satisfies both.
307
- */
308
- async function insertFounderManagerIfAbsent(db: Database, memberKey: string, strandId: string): Promise<void> {
309
- if (await strandTableCount(db, 'Manager') > 0) {
310
- log('Manager already present for strand %s; skipping founder Manager', strandId);
311
- return;
312
- }
313
- await db.exec(
314
- `insert into Strand.Manager (MemberKey, Generation, StampId)
315
- with context ManagerKey = null, Signature = null
316
- values (?, 0, ?)`,
317
- [memberKey, generateStrandStampId()],
318
- );
319
- log('Inserted founding Manager for strand %s', strandId);
320
- }
321
-
322
- /**
323
- * Founder-only one-time bootstrap of a strand's `Strand.*` membership/RBAC rows.
324
- *
325
- * Runs once at bring-up on the strand's FOUNDER (the party that provisioned and
326
- * published the strand). A joiner never calls this — it receives these rows via
327
- * Optimystic sync. Every write is insert-if-absent (guarded by a row count) so a
328
- * restart / founder re-`addStrand` is a no-op and never double-inserts.
329
- *
330
- * Behavior by strand type:
331
- * - **Open (`'o'`)**: insert `Header` only. `Member`/`Manager`/`Invite` are
332
- * `OnlyClosed` and would trip that constraint — they are skipped entirely.
333
- * - **Closed (`'c'`)**: insert `Header(Type='c')`, then the founding `Member`
334
- * (`Key = founderKeyPair.publicKeyB64`), then the founding `Manager`
335
- * (`MemberKey = founderKeyPair.publicKeyB64`). Insert order matters: the
336
- * Header must commit before the deferred `OnlyClosed` checks on Member/Manager
337
- * evaluate at commit.
338
- *
339
- * A closed strand with no `founderKeyPair` throws: a closed strand with no founding
340
- * Manager could never admit anyone, so failing loudly here (which propagates out
341
- * of `StrandDatabase.initialize()` and triggers the runtime's rollback) is correct.
342
- *
343
- * @param db - The strand's Quereus database (schema already applied).
344
- * @param params - Strand id/type, the sApp config for the Header, and (closed only)
345
- * the derived founder keypair.
346
- * @throws If `type === 'c'` and no `founderKeyPair` is supplied.
347
- */
348
- export async function bootstrapFounderMembership(db: Database, params: FounderBootstrapParams): Promise<void> {
349
- const { strandId, type, founderKeyPair } = params;
350
- log('Founder bootstrap for strand %s (type %s)', strandId, type);
351
-
352
- // Validate BEFORE writing anything: a closed strand with no founder key must
353
- // fail without leaving a closed `Header` that has no founding Member/Manager
354
- // (such a strand could never admit anyone).
355
- if (type === 'c' && !founderKeyPair) {
356
- throw new Error(
357
- `Cannot bootstrap closed strand ${strandId}: no founder key pair derived from MemberPrivateKey. ` +
358
- 'A closed strand needs a founding Member + Manager or it can never admit members.',
359
- );
360
- }
361
-
362
- // Header is written for every strand; Member/Manager are closed-only.
363
- await insertHeaderIfAbsent(db, params);
364
-
365
- if (type === 'o') {
366
- // Open strand: Member/Manager/Invite are OnlyClosed — nothing else to write.
367
- return;
368
- }
369
-
370
- const memberKey = founderKeyPair!.publicKeyB64;
371
- await insertFounderMemberIfAbsent(db, memberKey, strandId);
372
- await insertFounderManagerIfAbsent(db, memberKey, strandId);
373
- log('Founder bootstrap complete for closed strand %s', strandId);
374
- }
375
-
376
- // ── Invite issuance + consumption (the per-strand join handshake) ─────────────
377
-
378
- /** Parameters for {@link issueInvite}. */
379
- export interface IssueInviteParams {
380
- /**
381
- * The issuing manager's strand keypair. Its `publicKeyB64` must already be a
382
- * `Strand.Manager` row (the `InviteValid` constraint rejects a non-manager).
383
- */
384
- managerKeyPair: Ed25519KeyPair;
385
- /**
386
- * Optional invite expiry as epoch milliseconds. When set, it is canonicalised
387
- * via {@link canonicalDatetime} so the signed payload segment byte-matches the
388
- * `datetime`-coerced `Invite.Expiration` the deferred CHECK sees. When omitted,
389
- * the invite never expires and the signed segment is `''` (matching the schema's
390
- * `coalesce(new.Expiration, '')`).
391
- */
392
- expiration?: number;
393
- }
394
-
395
- /** The minted invite: a public key (the `Invite.Key`) plus its private seed. */
396
- export interface IssuedInvite {
397
- /** The invite ed25519 PUBLIC key (base64url) — the `Invite.Key` primary key. */
398
- inviteKey: string;
399
- /**
400
- * The invite ed25519 PRIVATE seed (base64url). Handed out-of-band to the
401
- * invitee; whoever holds it can {@link consumeInvite} exactly once. NEVER
402
- * persisted in the strand — only the public key is.
403
- */
404
- invitePrivateKey: string;
405
- }
406
-
407
- /**
408
- * Issue a single-use invitation to join a closed strand.
409
- *
410
- * Generates a fresh invite ed25519 keypair (the public key becomes `Invite.Key`),
411
- * builds the constraint's payload (`Key || '|' || coalesce(Expiration, '')`), and
412
- * signs it TWICE: with the manager private key (→ `ManagerSignature`, proving
413
- * a manager issued it) and with the invite private key (→ `InviteSignature`,
414
- * proving the issuer actually holds the invite secret). Both signatures plus the
415
- * manager public key are bound as constraint context for the `Invite` insert.
416
- *
417
- * The returned `invitePrivateKey` is the only secret the invitee needs to redeem
418
- * the invite — it is NOT stored in the strand (only `Invite.Key`, the public half,
419
- * is). Single-use is enforced at consumption time: `ConsumedInvite`'s primary key
420
- * is `InviteKey`, so a given invite can be consumed at most once.
421
- *
422
- * @param db - The closed strand's database (founder already bootstrapped).
423
- * @param params - The issuing manager keypair and optional expiry.
424
- * @returns The invite public key and the out-of-band private seed.
425
- */
426
- export async function issueInvite(db: Database, params: IssueInviteParams): Promise<IssuedInvite> {
427
- const { managerKeyPair, expiration } = params;
428
-
429
- const invitePrivateKey = generatePrivateKey('ed25519', 'base64url') as string;
430
- const inviteKey = getPublicKey(invitePrivateKey, 'ed25519', 'base64url', 'base64url') as string;
431
-
432
- // Canonicalise the expiry to the engine's stored `datetime` form so the signed
433
- // segment matches `new.Expiration` post-coercion; null expiry signs as ''.
434
- const expirationSegment = expiration == null ? '' : await canonicalDatetime(db, expiration);
435
- const expirationColumn = expiration == null ? null : expirationSegment;
436
-
437
- const payload = `${inviteKey}|${expirationSegment}`;
438
- const managerSignature = signStrandPayload(payload, managerKeyPair.privateKeyB64);
439
- const inviteSignature = signStrandPayload(payload, invitePrivateKey);
440
-
441
- await db.exec(
442
- `insert into Strand.Invite (Key, Expiration)
443
- with context ManagerKey = ?, ManagerSignature = ?, InviteSignature = ?
444
- values (?, ?)`,
445
- [managerKeyPair.publicKeyB64, managerSignature, inviteSignature, inviteKey, expirationColumn],
446
- );
447
- log('Issued invite %s (expires=%s)', inviteKey, expirationColumn ?? 'never');
448
-
449
- return { inviteKey, invitePrivateKey };
450
- }
451
-
452
- /** Parameters for {@link consumeInvite}. */
453
- export interface ConsumeInviteParams {
454
- /** The `Invite.Key` (invite PUBLIC key, base64url) being redeemed. */
455
- inviteKey: string;
456
- /** The matching invite PRIVATE seed (base64url) received out-of-band. */
457
- invitePrivateKey: string;
458
- /** The joining member's ed25519 PUBLIC key (base64url) — the new `Member.Key`. */
459
- memberKey: string;
460
- /**
461
- * The instant (epoch ms) to compare against the invite's `Expiration` for the
462
- * `NotExpired` gate. Defaults to `Date.now()`; tests pin it so the comparison is
463
- * deterministic. Mirrors the control-layer `redeemInvitation`'s `nowMs` convention.
464
- */
465
- nowMs?: number;
466
- }
467
-
468
- /**
469
- * Redeem an invite to admit a new `Member`, atomically.
470
- *
471
- * `Member.Authorized`'s invite branch needs a `ConsumedInvite` row, while
472
- * `ConsumedInvite`'s `MemberExists` needs the `Member` row — a circular
473
- * dependency. Both are deferred (subquery-bearing) checks that evaluate
474
- * at COMMIT, so inserting `Member` then `ConsumedInvite` inside one explicit
475
- * transaction lets both rows exist when the deferred checks fire. This mirrors
476
- * `ControlDatabase.redeemInvitation` (Strand + FormationUsage in one txn).
477
- *
478
- * The `ConsumedInvite` insert carries an `InviteSignature` over
479
- * `InviteKey || '|' || MemberKey`, proving the consumer holds the invite private
480
- * key (the on-engine `ValidUsage` gate). The `Member` insert needs no member
481
- * signature — its admission is the existence of the matching `ConsumedInvite` —
482
- * but still mints a fresh StampId (NOT NULL on every path; a revoked-then-
483
- * re-invited member is a fresh incarnation under a fresh stamp).
484
- *
485
- * Single-use: `ConsumedInvite`'s primary key is `InviteKey`, so a second consume
486
- * of the same invite is rejected by the PK (a distinct layer from the control
487
- * network's `FormationUsage` single-use, which gates strand FORMATION, not the
488
- * per-strand member join enforced here).
489
- *
490
- * Expiry: the `ConsumedInvite.NotExpired` deferred check rejects redeeming an invite
491
- * whose `Expiration` is at or before `context.Now`. `Now` is supplied here as a
492
- * canonical-datetime string from `canonicalDatetime(db, nowMs)` — the SAME transform
493
- * `issueInvite` uses to store `Invite.Expiration` — so both sides of the schema's
494
- * `I.Expiration > context.Now` comparison are byte-identical canonical strings and
495
- * the lexical `>` orders chronologically at any granularity. (This intentionally
496
- * diverges from the control layer, which passes `Now` as a JS ISO string; Quereus
497
- * does not coerce context params, so an ISO `Now` would be compared lexically against
498
- * the canonical, T-separated `Expiration` and could mis-order near-same-instant
499
- * timestamps (due to a trailing `.000Z` suffix). The control tests only use far-future/far-past expiries, so that latent
500
- * skew never bites there — the strand layer avoids it outright.) A null `Expiration`
501
- * never expires. Like `ValidUsage`, `NotExpired` defers to commit, so an expired
502
- * invite rolls back the whole txn — neither the `Member` nor the `ConsumedInvite`
503
- * row survives.
504
- *
505
- * @param db - The closed strand's database.
506
- * @param params - The invite key/secret, the joining member's public key, and an
507
- * optional `nowMs` instant for the expiry comparison (default `Date.now()`).
508
- * @throws If any constraint rejects (bad signature, missing invite, or an expired
509
- * invite); the whole transaction rolls back (neither the `Member` nor the
510
- * `ConsumedInvite` row survives).
511
- */
512
- export async function consumeInvite(db: Database, params: ConsumeInviteParams): Promise<void> {
513
- const { inviteKey, invitePrivateKey, memberKey, nowMs } = params;
514
-
515
- const usagePayload = `${inviteKey}|${memberKey}`;
516
- const inviteSignature = signStrandPayload(usagePayload, invitePrivateKey);
517
-
518
- // Canonicalise "now" the same way issueInvite canonicalises Expiration, so the
519
- // schema's `I.Expiration > context.Now` compares like-for-like canonical strings.
520
- // Plain runtime Date.now() — the tess Workflow restriction is on scripts, not libs.
521
- const nowCanonical = await canonicalDatetime(db, nowMs ?? Date.now());
522
-
523
- await inStrandTransaction(db, async () => {
524
- // 1. Member — admitted by the deferred invite branch (the matching
525
- // ConsumedInvite below), so no manager signature is supplied.
526
- await db.exec(
527
- `insert into Strand.Member (Key, StampId)
528
- with context ManagerKey = null, ManagerSignature = null, MemberSignature = null
529
- values (?, ?)`,
530
- [memberKey, generateStrandStampId()],
531
- );
532
-
533
- // 2. ConsumedInvite — proves possession of the invite private key and that the
534
- // invite has not expired (NotExpired gate against the canonical Now).
535
- await db.exec(
536
- `insert into Strand.ConsumedInvite (InviteKey, MemberKey)
537
- with context InviteSignature = ?, Now = ?
538
- values (?, ?)`,
539
- [inviteSignature, nowCanonical, inviteKey, memberKey],
540
- );
541
- });
542
- log('Consumed invite %s -> member %s', inviteKey, memberKey);
543
- }
544
-
545
- /**
546
- * Every value of `InviteKey` in one of the two invite-marker tables, as a `Set`.
547
- *
548
- * Unfiltered scan + JavaScript comparison, deliberately: `ConsumedInvite` and
549
- * `CancelledInvite` each have the single column `InviteKey` as their primary key, so
550
- * ANY where-equality on it is a FULL-PK predicate — which the optimystic virtual-table
551
- * module serves via a single-key point lookup that can MISS on a networked strand (the
552
- * argument {@link scanMemberPeers} makes in full). Reading the whole column and
553
- * comparing in JS depends only on the scan returning a SUPERSET of the live rows.
554
- *
555
- * The table name is a fixed literal supplied by this module (never user input), so the
556
- * interpolation is not an injection surface — same as {@link strandTableCount}.
557
- *
558
- * NOTE: every caller pays a WHOLE-column read — {@link cancelInvite}'s idempotence guard
559
- * reads all cancelled keys to answer one membership question, and
560
- * {@link listOutstandingInvites} reads both marker tables per call. Fine at strand scale
561
- * (a handful of invitations); if invite churn ever gets large this wants a real filtered
562
- * read, which in turn needs the networked point-lookup gap closed
563
- * (`debt-composite-pk-point-lookup-unreliable-untracked`).
564
- */
565
- async function scanInviteKeys(db: Database, table: 'ConsumedInvite' | 'CancelledInvite'): Promise<Set<string>> {
566
- const keys = new Set<string>();
567
- for await (const row of db.eval(`select InviteKey from Strand.${table}`)) {
568
- keys.add(row.InviteKey as string);
569
- }
570
- return keys;
571
- }
572
-
573
- /** Parameters for {@link cancelInvite}. */
574
- export interface CancelInviteParams {
575
- /**
576
- * The cancelling manager's strand keypair. Its `publicKeyB64` must be a
577
- * PRE-EXISTING `Strand.Manager` row — `CancelledInvite.Authorized` reads
578
- * `committed.Manager`, so a manager seated in the SAME transaction cannot cancel.
579
- */
580
- managerKeyPair: Ed25519KeyPair;
581
- /** The `Invite.Key` to kill. */
582
- inviteKey: string;
583
- }
584
-
585
- /**
586
- * Cancel an outstanding invitation, permanently: it may never be consumed.
587
- *
588
- * Files a `Strand.CancelledInvite` tombstone carrying a manager signature over
589
- * `digest('Strand.CancelledInvite', 'cancel', InviteKey)`. The digest binds the exact
590
- * invite key, so an approval minted for one invitation cannot cancel another.
591
- *
592
- * One row, one statement — no transaction needed. `CancelledInvite.Authorized` is a
593
- * deferred (subquery-bearing) check, so it fires at this statement's own auto-commit.
594
- *
595
- * Insert-if-absent, matching {@link registerMemberPeer}'s and {@link revokeMember}'s
596
- * restart-safe shape: a repeat cancellation logs and returns rather than throwing on the
597
- * primary key. (Check-then-write is not atomic; the primary key is the real backstop —
598
- * the guard is for the sequential repeat/restart path.)
599
- *
600
- * Cancellation is what makes removal a re-entry gate: it is the `ConsumedInvite` insert
601
- * that `NotCancelled` blocks, and `Member.Authorized`'s invite branch needs a
602
- * same-transaction FRESH `ConsumedInvite` row, so a cancelled invitation rolls the whole
603
- * join back. Un-cancelling is impossible (`CancelledInvite.Immutable`) — to re-invite a
604
- * party, {@link issueInvite} a fresh invitation.
605
- *
606
- * Enumerate what there is to cancel with {@link listOutstandingInvites}: an invitation
607
- * names no invitee, so the strand cannot tell a manager WHICH invitations a departing
608
- * member holds. Invitee binding is tracked as `feat-strand-invitee-bound-invites`.
609
- *
610
- * @param db - The closed strand's database.
611
- * @param params - The cancelling manager's keypair and the invite key to kill.
612
- * @throws If `CancelledInvite.Authorized` rejects (a non-manager, a manager seated in
613
- * this transaction, or an approval minted for a different invite key) or `OnlyClosed`
614
- * rejects (an open strand); the insert rolls back, leaving no tombstone.
615
- */
616
- export async function cancelInvite(db: Database, params: CancelInviteParams): Promise<void> {
617
- const { managerKeyPair, inviteKey } = params;
618
-
619
- if ((await scanInviteKeys(db, 'CancelledInvite')).has(inviteKey)) {
620
- log('Invite %s already cancelled; skipping', inviteKey);
621
- return;
622
- }
623
-
624
- const signature = signStrandApproval(
625
- ['Strand.CancelledInvite', 'cancel', inviteKey],
626
- managerKeyPair.privateKeyB64,
627
- );
628
- await db.exec(
629
- `insert into Strand.CancelledInvite (InviteKey)
630
- with context ManagerKey = ?, ManagerSignature = ?
631
- values (?)`,
632
- [managerKeyPair.publicKeyB64, signature, inviteKey],
633
- );
634
- log('Cancelled invite %s by manager %s', inviteKey, managerKeyPair.publicKeyB64);
635
- }
636
-
637
- /** One redeemable invitation, as reported by {@link listOutstandingInvites}. */
638
- export interface OutstandingInvite {
639
- /** The `Invite.Key` (invite public key, base64url). */
640
- inviteKey: string;
641
- /**
642
- * The expiry as the engine's canonical `datetime` string
643
- * (`YYYY-MM-DDTHH:MM:SS[.frac]`, no zone suffix — see {@link canonicalDatetime}), or
644
- * `null` for never-expires.
645
- *
646
- * Deliberately the STORED form, not the epoch-ms {@link IssueInviteParams.expiration}
647
- * takes: it is what compares like-for-like against another canonicalised instant, and
648
- * what the on-engine `NotExpired` gate sees. It is NOT a JS-parseable ISO instant —
649
- * pass it through the engine (or `canonicalDatetime`) rather than `Date.parse`.
650
- */
651
- expiration: string | null;
652
- }
653
-
654
- /**
655
- * The invitations that are still redeemable: neither consumed, nor cancelled, nor
656
- * expired at `nowMs`.
657
- *
658
- * `Strand.Invite` is insert-only and carries no state column, so "outstanding" is not a
659
- * property of the row — it is the `Invite` set minus the `ConsumedInvite` keys, minus
660
- * the `CancelledInvite` keys, minus anything whose `Expiration` has passed. All three
661
- * exclusions mirror gates a consume would hit anyway (`ConsumedInvite`'s primary key,
662
- * `NotCancelled`, `NotExpired`).
663
- *
664
- * Reads via unfiltered scans and compares in JavaScript — see {@link scanInviteKeys} for
665
- * why a full-PK where-equality is not reliable on a networked strand.
666
- *
667
- * Expiry is compared as `expiration > canonicalDatetime(nowMs)`: the same transform
668
- * {@link issueInvite} uses to STORE `Invite.Expiration`, so the string comparison orders
669
- * chronologically, and the same strict `>` the on-engine `NotExpired` gate uses (expiry
670
- * is exclusive — an invitation is dead at its expiry instant, not after it).
671
- *
672
- * This is the manager's enumeration side of {@link cancelInvite}, and the "door is open"
673
- * pre-flight behind `StrandMemberVerifier.isAuthorizedToJoin`.
674
- *
675
- * @param db - The closed strand's database.
676
- * @param nowMs - The instant (epoch ms) to compare expiries against; defaults to
677
- * `Date.now()`. Tests pin it so the comparison is deterministic, mirroring
678
- * {@link consumeInvite}'s `nowMs` convention.
679
- * @returns The redeemable invitations, in NO guaranteed order (the scans promise only a
680
- * superset of the live rows, not a stable sequence — sort by `expiration` if order
681
- * matters); empty if none.
682
- */
683
- export async function listOutstandingInvites(db: Database, nowMs?: number): Promise<OutstandingInvite[]> {
684
- // Plain runtime Date.now() — the tess Workflow restriction is on scripts, not libs.
685
- const nowCanonical = await canonicalDatetime(db, nowMs ?? Date.now());
686
- const consumed = await scanInviteKeys(db, 'ConsumedInvite');
687
- const cancelled = await scanInviteKeys(db, 'CancelledInvite');
688
-
689
- const outstanding: OutstandingInvite[] = [];
690
- for await (const row of db.eval('select Key, Expiration from Strand.Invite')) {
691
- const inviteKey = row.Key as string;
692
- const expiration = (row.Expiration as string | null) ?? null;
693
- if (consumed.has(inviteKey) || cancelled.has(inviteKey)) {
694
- continue;
695
- }
696
- if (expiration != null && expiration <= nowCanonical) {
697
- continue;
698
- }
699
- outstanding.push({ inviteKey, expiration });
700
- }
701
- return outstanding;
702
- }
703
-
704
- // ── Member admission + removal (manager-admit, revoke, leave) ─────────────────
705
-
706
- /**
707
- * The live StampId of the `Strand.Member` row keyed by `memberKey`, or
708
- * `undefined` if no such row is visible.
709
- *
710
- * `Member`'s primary key is the single `Key` column, so ANY where-equality on it
711
- * is a full-PK predicate — which the optimystic virtual-table module serves via a
712
- * single-key point lookup that can MISS on a networked strand (the same failure
713
- * mode {@link scanMemberPeers} documents). So this reads via an UNFILTERED scan
714
- * and compares the key in JavaScript: correctness depends only on the scan
715
- * returning a superset of the live rows — the weakest possible assumption about
716
- * the storage layer.
717
- */
718
- async function memberStampId(db: Database, memberKey: string): Promise<string | undefined> {
719
- for await (const row of db.eval('select Key, StampId from Strand.Member')) {
720
- if (row.Key === memberKey) {
721
- return row.StampId as string;
722
- }
723
- }
724
- return undefined;
725
- }
726
-
727
- /** Parameters for {@link addMemberByManager}. */
728
- export interface AddMemberByManagerParams {
729
- /**
730
- * The admitting manager's strand keypair. Its `publicKeyB64` must be a
731
- * `Strand.Manager` row; it signs the add-tagged digest over the new member key
732
- * and its fresh stamp.
733
- */
734
- managerKeyPair: Ed25519KeyPair;
735
- /** The joining member's ed25519 PUBLIC key (base64url) — the new `Member.Key`. */
736
- memberKey: string;
737
- }
738
-
739
- /**
740
- * Admit a `Member` directly by manager signature — the sibling of the invite
741
- * path on `Member.Authorized`'s direct-manager branch.
742
- *
743
- * The constraint verifies the add-tagged digest
744
- * `digest('Strand.Member', 'add', new.Key, new.StampId)` against a pre-existing
745
- * (`committed.Manager`) row matching `context.ManagerKey`. The stamp is minted
746
- * fresh here and bound inside the signed digest, so the approval seats exactly
747
- * this incarnation: once the member is removed (retiring the stamp), the captured
748
- * approval can never re-seat it. No `ConsumedInvite` is involved: this is the
749
- * path a manager uses to seat a member it already trusts (e.g. a manager-side
750
- * enrollment that admits a party already authorised out-of-band).
751
- *
752
- * @param db - The closed strand's database.
753
- * @param params - The admitting manager keypair and the new member key.
754
- * @throws If `Member.Authorized` rejects (e.g. a non-manager key); the insert
755
- * rolls back, leaving no `Member` row.
756
- */
757
- export async function addMemberByManager(db: Database, params: AddMemberByManagerParams): Promise<void> {
758
- const { managerKeyPair, memberKey } = params;
759
- const stampId = generateStrandStampId();
760
- const signature = signStrandApproval(['Strand.Member', 'add', memberKey, stampId], managerKeyPair.privateKeyB64);
761
- await db.exec(
762
- `insert into Strand.Member (Key, StampId)
763
- with context ManagerKey = ?, ManagerSignature = ?, MemberSignature = null
764
- values (?, ?)`,
765
- [managerKeyPair.publicKeyB64, signature, memberKey, stampId],
766
- );
767
- log('Admitted member %s by manager %s', memberKey, managerKeyPair.publicKeyB64);
768
- }
769
-
770
- /** Parameters for {@link revokeMember}. */
771
- export interface RevokeMemberParams {
772
- /**
773
- * The revoking manager's strand keypair. Its `publicKeyB64` must be a
774
- * PRE-EXISTING `Strand.Manager` row (the manager-removal branch of
775
- * `Member.Authorized` reads `committed.Manager` — a manager seated in the same
776
- * transaction cannot authorize); it signs the remove-tagged digest over the
777
- * target row's key and live stamp, and files the accompanying tombstone.
778
- */
779
- managerKeyPair: Ed25519KeyPair;
780
- /** The `Member.Key` row to delete. */
781
- memberKey: string;
782
- }
783
-
784
- /**
785
- * Revoke a `Member` on the signature of an existing manager.
786
- *
787
- * Reads the target row's live StampId first (a quiet no-op if the row is already
788
- * absent — a repeated or restarted revocation should not throw), then runs ONE
789
- * transaction pairing the delete with its `Strand.Revocation` tombstone: the
790
- * schema's `Member.RevocationRecorded` requires the tombstone in the same
791
- * transaction, and the tombstone is what permanently retires the stamp so the
792
- * captured admission approval can never re-seat this incarnation.
793
- *
794
- * The manager-removal branch of `Member.Authorized` verifies the remove-tagged
795
- * digest `digest('Strand.Member', 'remove', old.Key, old.StampId)` against a
796
- * `committed.Manager` row matching `context.ManagerKey` — distinct from the
797
- * add-tagged admission payload, and bound to the live stamp, so neither a
798
- * captured admission nor a captured PRIOR removal (whose stamp differs) replays.
799
- *
800
- * A revoked member cannot re-admit itself off the invite it ALREADY SPENT: that
801
- * `ConsumedInvite` row is stale, and the invite branch requires a
802
- * same-transaction FRESH consumption. Re-admission normally takes a fresh
803
- * manager action ({@link addMemberByManager} or a new invite).
804
- *
805
- * An UNSPENT invite the revoked party holds is killed EXPLICITLY, by the manager
806
- * calling {@link cancelInvite} on it (enumerate the candidates with
807
- * {@link listOutstandingInvites}); a cancelled invitation can never be consumed,
808
- * and cancellation is permanent. This removal does NOT cascade into cancellation:
809
- * an invitation names no invitee, so there is nothing to match the removed member
810
- * against — the strand cannot tell which invitations were meant for it, or whether
811
- * it holds any. Binding an invitation to its invitee is tracked as
812
- * `feat-strand-invitee-bound-invites`.
813
- *
814
- * A manager must resign its `Manager` row before (or in the same transaction
815
- * as) losing membership — `Member.NotAManager` rejects un-membering a key that
816
- * still holds a Manager row. And the strand never drops to zero members
817
- * (`Member.MinOneMember`, a local-count floor with the same cross-node caveat
818
- * as `MinOneManager`).
819
- *
820
- * @param db - The closed strand's database.
821
- * @param params - The revoking manager keypair and the target member key.
822
- * @throws If `Member.Authorized` rejects (a non-manager or same-transaction
823
- * signer), `Member.NotAManager` rejects (the target still holds a Manager
824
- * row), or `Member.MinOneMember` rejects (the removal would empty the member
825
- * set); the whole delete+tombstone transaction rolls back.
826
- */
827
- export async function revokeMember(db: Database, params: RevokeMemberParams): Promise<void> {
828
- const { managerKeyPair, memberKey } = params;
829
- const stampId = await memberStampId(db, memberKey);
830
- if (stampId == null) {
831
- log('Member %s already absent; skipping revoke', memberKey);
832
- return;
833
- }
834
- const signature = signStrandApproval(['Strand.Member', 'remove', memberKey, stampId], managerKeyPair.privateKeyB64);
835
- await inStrandTransaction(db, async () => {
836
- await db.exec(
837
- `delete from Strand.Member
838
- with context ManagerKey = ?, ManagerSignature = ?, MemberSignature = null
839
- where Key = ?`,
840
- [managerKeyPair.publicKeyB64, signature, memberKey],
841
- );
842
- await insertRevocation(db, 'Member', stampId, managerKeyPair);
843
- });
844
- log('Revoked member %s by manager %s', memberKey, managerKeyPair.publicKeyB64);
845
- }
846
-
847
- /** Parameters for {@link leaveStrand}. */
848
- export interface LeaveStrandParams {
849
- /**
850
- * The departing member's OWN strand keypair. Its `publicKeyB64` is the
851
- * `Member.Key` row being deleted; it self-signs the leave-tagged digest —
852
- * the self-departure branch verifies the signature against `old.Key` itself,
853
- * so only the departing key's holder can produce it — and files the
854
- * accompanying tombstone (it is still a committed member until this very
855
- * transaction removes it, which is what `Revocation.Authorized` checks). No
856
- * manager is involved.
857
- */
858
- memberKeyPair: Ed25519KeyPair;
859
- }
860
-
861
- /**
862
- * A member leaves the strand by deleting its own `Member` row.
863
- *
864
- * The self-departure branch of `Member.Authorized` verifies
865
- * `digest('Strand.Member', 'leave', old.Key, old.StampId)` against `old.Key`
866
- * itself via `context.MemberSignature`. The `'leave'` tag (vs the manager
867
- * branch's `'remove'`) is belt-and-braces only — the two branches verify against
868
- * different keys; the anti-replay fix is the stamp binding. Like
869
- * {@link revokeMember}, the live stamp is read first (absent row → quiet no-op)
870
- * and the delete pairs with its `Strand.Revocation` tombstone in one transaction.
871
- *
872
- * The same floors as {@link revokeMember} apply: a manager must resign first
873
- * (`NotAManager`) and the last member cannot leave (`MinOneMember`).
874
- *
875
- * @param db - The closed strand's database.
876
- * @param params - The departing member's own keypair.
877
- * @throws If `Member.Authorized`, `Member.NotAManager`, or `Member.MinOneMember`
878
- * rejects; the whole delete+tombstone transaction rolls back.
879
- */
880
- export async function leaveStrand(db: Database, params: LeaveStrandParams): Promise<void> {
881
- const { memberKeyPair } = params;
882
- const memberKey = memberKeyPair.publicKeyB64;
883
- const stampId = await memberStampId(db, memberKey);
884
- if (stampId == null) {
885
- log('Member %s already absent; skipping leave', memberKey);
886
- return;
887
- }
888
- const signature = signStrandApproval(['Strand.Member', 'leave', memberKey, stampId], memberKeyPair.privateKeyB64);
889
- await inStrandTransaction(db, async () => {
890
- await db.exec(
891
- `delete from Strand.Member
892
- with context ManagerKey = null, ManagerSignature = null, MemberSignature = ?
893
- where Key = ?`,
894
- [signature, memberKey],
895
- );
896
- await insertRevocation(db, 'Member', stampId, memberKeyPair);
897
- });
898
- log('Member %s left the strand', memberKey);
899
- }
900
-
901
- // ── MemberPeer registration (a member binds its own network nodes) ─────────────
902
-
903
- /** Parameters for {@link registerMemberPeer}. */
904
- export interface RegisterMemberPeerParams {
905
- /**
906
- * The member's OWN strand keypair (`{ privateKeyB64, publicKeyB64 }`). Its
907
- * `publicKeyB64` becomes `MemberPeer.MemberKey` and it self-signs the binding —
908
- * `MemberPeer.Authorized` verifies the signature against `MemberKey` itself, so a
909
- * peer can only be registered by the very member it belongs to (no manager
910
- * involved). The founder passes its `strandMemberKeyPair`; an invited member
911
- * passes its own keypair. Typed as {@link Ed25519KeyPair} only for the shared
912
- * base64url keypair shape — no manager privilege is implied.
913
- */
914
- memberKeyPair: Ed25519KeyPair;
915
- /** The peer/node id (libp2p peer id string) to associate with the member. */
916
- peerId: string;
917
- }
918
-
919
- /**
920
- * Register a network node (`PeerId`) as acting on behalf of a member.
921
- *
922
- * The member self-signs the add-tagged digest
923
- * `digest('Strand.MemberPeer', 'add', new.MemberKey, new.PeerId, new.StampId)`
924
- * with its OWN key; the schema's `MemberPeer.Authorized` verifies that signature
925
- * against `new.MemberKey` — i.e. the member key itself — so only the member that
926
- * owns the key can register peers for it. The stamp is minted fresh here and
927
- * bound inside the digest, so a captured registration approval neither
928
- * re-registers a cleared binding (its stamp is retired) nor authorizes a removal
929
- * (wrong tag). A deferred `MemberExists` additionally requires the `Member` row
930
- * to already exist, so a peer for a non-member is rejected at commit.
931
- *
932
- * Insert-if-absent: a re-register on restart (or a redundant call) is a no-op. The
933
- * platform DOES reject a duplicate-PK insert, but a restart-safe re-register should
934
- * succeed quietly rather than throw, so the writer guards on existence instead of
935
- * catching. The guard ({@link memberPeerStampId}) scans the member's peers and compares
936
- * both key columns in JavaScript rather than seeking the composite primary key — see
937
- * {@link scanMemberPeers} for why a full-PK point lookup is not reliable on a networked
938
- * strand. A member may register multiple DISTINCT `PeerId`s (multi-device); each is its
939
- * own row (and its own stamp) under the same `MemberKey`.
940
- *
941
- * Peer DELETION is signature-checked too, not rejected outright: `Authorized` carries
942
- * distinct delete branches (self + manager), each over its own action-tagged digest
943
- * bound to the row's stamp. Both removal paths live in {@link removeMemberPeer}.
944
- * UPDATE is refused outright by `NoUpdate`: a re-binding is a remove plus a fresh
945
- * register (under a fresh stamp).
946
- *
947
- * @param db - The closed strand's database (the member already exists).
948
- * @param params - The member's own keypair and the peer id to bind.
949
- * @throws If `MemberPeer.Authorized`/`MemberExists` rejects (wrong signer, or no
950
- * matching `Member` row); the insert rolls back, leaving no `MemberPeer` row.
951
- */
952
- export async function registerMemberPeer(db: Database, params: RegisterMemberPeerParams): Promise<void> {
953
- const { memberKeyPair, peerId } = params;
954
- const memberKey = memberKeyPair.publicKeyB64;
955
-
956
- if (await memberPeerStampId(db, memberKey, peerId) != null) {
957
- log('MemberPeer (%s, %s) already present; skipping', memberKey, peerId);
958
- return;
959
- }
960
-
961
- const stampId = generateStrandStampId();
962
- const signature = signStrandApproval(
963
- ['Strand.MemberPeer', 'add', memberKey, peerId, stampId],
964
- memberKeyPair.privateKeyB64,
965
- );
966
- await db.exec(
967
- `insert into Strand.MemberPeer (MemberKey, PeerId, StampId)
968
- with context Signature = ?, ManagerKey = null, ManagerSignature = null
969
- values (?, ?, ?)`,
970
- [signature, memberKey, peerId, stampId],
971
- );
972
- log('Registered MemberPeer (%s, %s)', memberKey, peerId);
973
- }
974
-
975
- /**
976
- * Yield every `(PeerId, StampId)` bound to `memberKey`, re-comparing the member key
977
- * in JavaScript.
978
- *
979
- * Deliberately does NOT filter on the full composite primary key. `MemberPeer`'s PK
980
- * is `(MemberKey, PeerId)`, so a `where MemberKey = ? and PeerId = ?` predicate puts
981
- * an equality on BOTH key columns — which the optimystic virtual-table module reports
982
- * as fully handled and serves via a single-key point lookup (one `find` descent), with
983
- * the SQL engine adding no filter of its own. On a networked strand that descent is not
984
- * reliable: a miss returns zero rows for a row that provably exists.
985
- *
986
- * Filtering on only the LEADING key column is a partial PK match, which the same module
987
- * explicitly declines to handle — it falls through to a table scan and the SQL engine
988
- * applies `MemberKey = ?` itself. No seek is involved, so no seek can miss. The member key
989
- * is then re-compared here in JavaScript, so correctness depends only on the scan returning
990
- * a SUPERSET of the matching rows — the weakest possible assumption about the storage layer.
991
- * The `where` clause only trims what crosses into JS — it is NOT pushed down (see the cost
992
- * note below) — so it is not a correctness dependency: a dropped or mis-applied predicate
993
- * cannot produce a row attributed to the wrong member.
994
- */
995
- async function* scanMemberPeers(db: Database, memberKey: string): AsyncGenerator<{ peerId: string; stampId: string }> {
996
- // NOTE: because the predicate is not pushed down, the storage layer walks the WHOLE
997
- // MemberPeer table (every member's rows) per call and the SQL engine filters. Fine at
998
- // strand scale; if MemberPeer ever grows large, the fix is a reliable composite-key
999
- // seek, not a bigger scan.
1000
- // NOTE: if a secondary index on MemberPeer.MemberKey is ever added, this query stops
1001
- // being a scan and becomes an index seek — re-introducing the seek dependency this
1002
- // shape exists to remove.
1003
- for await (const row of db.eval(
1004
- 'select MemberKey, PeerId, StampId from Strand.MemberPeer where MemberKey = ?',
1005
- [memberKey],
1006
- )) {
1007
- if (row.MemberKey === memberKey) {
1008
- yield { peerId: row.PeerId as string, stampId: row.StampId as string };
1009
- }
1010
- }
1011
- }
1012
-
1013
- /**
1014
- * Every `PeerId` currently bound to `memberKey`, in storage order.
1015
- *
1016
- * The enumeration side of {@link removeMemberPeer}: a manager cleaning up after a
1017
- * revocation knows only the departed member's key, never which devices it registered, so
1018
- * cleanup is `listMemberPeers` then one `removeMemberPeer` per id. Reads only — the rows
1019
- * it returns may name a `MemberKey` with no `Member` row (see the `MemberPeer` table NOTE
1020
- * in the schema), so this is NOT a membership test.
1021
- *
1022
- * @param db - The closed strand's database.
1023
- * @param memberKey - The `MemberPeer.MemberKey` to enumerate.
1024
- * @returns The bound peer ids, empty if the member has none.
1025
- */
1026
- export async function listMemberPeers(db: Database, memberKey: string): Promise<string[]> {
1027
- const peerIds: string[] = [];
1028
- for await (const peer of scanMemberPeers(db, memberKey)) {
1029
- peerIds.push(peer.peerId);
1030
- }
1031
- return peerIds;
1032
- }
1033
-
1034
- /**
1035
- * The live StampId of the `MemberPeer` row for this `(MemberKey, PeerId)`, or
1036
- * `undefined` if no such row is visible — doubling as the existence probe.
1037
- *
1038
- * NOTE: check-then-write is not atomic. Two nodes registering the same
1039
- * (MemberKey, PeerId) concurrently can both observe "absent"; the primary key is the
1040
- * real backstop. The existence half of this guard is for the sequential restart /
1041
- * re-register path.
1042
- */
1043
- async function memberPeerStampId(db: Database, memberKey: string, peerId: string): Promise<string | undefined> {
1044
- for await (const peer of scanMemberPeers(db, memberKey)) {
1045
- if (peer.peerId === peerId) {
1046
- return peer.stampId;
1047
- }
1048
- }
1049
- return undefined;
1050
- }
1051
-
1052
- /** Parameters for the SELF branch of {@link removeMemberPeer}. */
1053
- export interface RemoveOwnPeerParams {
1054
- /**
1055
- * The member's OWN strand keypair. Its `publicKeyB64` is the `MemberPeer.MemberKey`
1056
- * of the row being deleted, and it signs the remove-tagged digest
1057
- * `digest('Strand.MemberPeer', 'remove', MemberKey, PeerId, StampId)` — the self
1058
- * branch of `MemberPeer.Authorized` verifies against the row's own member key, so
1059
- * no OTHER member's row is reachable this way, and the tag + stamp binding keep a
1060
- * captured registration approval from doubling as a removal (and vice versa).
1061
- */
1062
- memberKeyPair: Ed25519KeyPair;
1063
- /** The `PeerId` of the binding to delete. */
1064
- peerId: string;
1065
- }
1066
-
1067
- /** Parameters for the MANAGER branch of {@link removeMemberPeer}. */
1068
- export interface RemoveMemberPeerByManagerParams {
1069
- /**
1070
- * The clearing manager's strand keypair. Its `publicKeyB64` must be a PRE-EXISTING
1071
- * `Strand.Manager` row (the manager branch reads `committed.Manager`, so a manager
1072
- * seated in the same transaction cannot authorize); it signs the
1073
- * manager-remove-tagged digest over the exact row and its live stamp, and files
1074
- * the accompanying tombstone.
1075
- */
1076
- managerKeyPair: Ed25519KeyPair;
1077
- /** The `MemberPeer.MemberKey` whose binding is being cleared. */
1078
- memberKey: string;
1079
- /** The `PeerId` of the binding to delete. */
1080
- peerId: string;
1081
- }
1082
-
1083
- /**
1084
- * Either shape accepted by {@link removeMemberPeer}. A discriminated union rather than
1085
- * one opaque keypair: the two branches sign DIFFERENT action tags (`'remove'` self vs
1086
- * `'manager-remove'`), so each case carries exactly the fields its payload needs.
1087
- */
1088
- export type RemoveMemberPeerParams = RemoveOwnPeerParams | RemoveMemberPeerByManagerParams;
1089
-
1090
- /**
1091
- * Delete a `MemberPeer` binding — either the member clearing its own, or a manager
1092
- * clearing another member's.
1093
- *
1094
- * Reads the row's live StampId first (absent → quiet no-op, mirroring
1095
- * {@link registerMemberPeer}'s insert-if-absent, so a repeated or restarted cleanup
1096
- * is not a silent zero-row "success" the caller cannot distinguish from a real one),
1097
- * then runs ONE transaction pairing the delete with its `Strand.Revocation`
1098
- * tombstone — `MemberPeer.RevocationRecorded` requires it, and the retirement is
1099
- * what keeps the captured registration approval from re-binding the cleared row.
1100
- *
1101
- * `MemberPeer` rows do NOT cascade when the member is revoked (`MemberExists` runs on
1102
- * insert only, and nothing deletes them), so a removed member's peer bindings survive
1103
- * as orphans — enumerate them with {@link listMemberPeers}, which is how a manager
1104
- * discovers what to clear. Only that member can sign the self branch, and a member being
1105
- * removed against its will has no reason to cooperate — hence the manager branch, which
1106
- * is the cleanup path after a revocation.
1107
- *
1108
- * The manager branch is deliberately NOT gated on the member already being gone: a
1109
- * manager may clear a still-present member's binding too, matching "any manager can
1110
- * remove any member". It verifies the manager-remove-tagged
1111
- * `digest('Strand.MemberPeer', 'manager-remove', old.MemberKey, old.PeerId, old.StampId)`
1112
- * against a `committed.Manager` row; the self branch verifies the remove-tagged
1113
- * sibling against the row's own member key.
1114
- *
1115
- * Cleanup is an explicit follow-up call by the revoking manager, never a cascade inside
1116
- * {@link revokeMember}: a revocation that also had to clear an unbounded number of peer
1117
- * rows in the same transaction would couple two concerns and make its failure modes
1118
- * harder to reason about.
1119
- *
1120
- * @param db - The closed strand's database.
1121
- * @param params - Either the owning member's keypair, or a manager's keypair plus the
1122
- * target member key.
1123
- * @throws If `MemberPeer.Authorized` rejects (neither branch satisfied), or if the row
1124
- * is still present after the delete (see the point-lookup note below).
1125
- */
1126
- export async function removeMemberPeer(db: Database, params: RemoveMemberPeerParams): Promise<void> {
1127
- const memberKey = 'memberKeyPair' in params ? params.memberKeyPair.publicKeyB64 : params.memberKey;
1128
- const { peerId } = params;
1129
-
1130
- const stampId = await memberPeerStampId(db, memberKey, peerId);
1131
- if (stampId == null) {
1132
- log('MemberPeer (%s, %s) already absent; skipping', memberKey, peerId);
1133
- return;
1134
- }
1135
-
1136
- await inStrandTransaction(db, async () => {
1137
- if ('memberKeyPair' in params) {
1138
- await deleteOwnMemberPeer(db, params, stampId);
1139
- } else {
1140
- await deleteMemberPeerByManager(db, params, stampId);
1141
- }
1142
- });
1143
-
1144
- // NOTE: the delete's `where` puts an equality on BOTH composite-PK columns, which the
1145
- // optimystic vtab module serves via a single-key seek that can MISS on a networked
1146
- // strand (see scanMemberPeers' doc). A missed seek deletes zero rows and still
1147
- // reports success. `Revocation.RowIsGone` now catches that first — the tombstone filed
1148
- // in the same transaction refuses to retire a stamp whose row is still visible, so the
1149
- // commit above fails loudly and this re-check is belt-and-braces behind it. Tracked as
1150
- // `debt-composite-pk-point-lookup-unreliable-untracked`; this is an availability
1151
- // failure mode, never a security one (a miss removes nothing, it never over-removes).
1152
- if (await memberPeerStampId(db, memberKey, peerId) != null) {
1153
- throw new Error(`MemberPeer (${memberKey}, ${peerId}) still present after delete`);
1154
- }
1155
- log('Removed MemberPeer (%s, %s)', memberKey, peerId);
1156
- }
1157
-
1158
- /** The self branch: the owning member signs the remove-tagged digest + tombstone. */
1159
- async function deleteOwnMemberPeer(db: Database, params: RemoveOwnPeerParams, stampId: string): Promise<void> {
1160
- const { memberKeyPair, peerId } = params;
1161
- const memberKey = memberKeyPair.publicKeyB64;
1162
- const signature = signStrandApproval(
1163
- ['Strand.MemberPeer', 'remove', memberKey, peerId, stampId],
1164
- memberKeyPair.privateKeyB64,
1165
- );
1166
- await db.exec(
1167
- `delete from Strand.MemberPeer
1168
- with context Signature = ?, ManagerKey = null, ManagerSignature = null
1169
- where MemberKey = ? and PeerId = ?`,
1170
- [signature, memberKey, peerId],
1171
- );
1172
- await insertRevocation(db, 'MemberPeer', stampId, memberKeyPair);
1173
- }
1174
-
1175
- /** The manager branch: a manager signs the manager-remove-tagged digest + tombstone. */
1176
- async function deleteMemberPeerByManager(
1177
- db: Database,
1178
- params: RemoveMemberPeerByManagerParams,
1179
- stampId: string,
1180
- ): Promise<void> {
1181
- const { managerKeyPair, memberKey, peerId } = params;
1182
- const signature = signStrandApproval(
1183
- ['Strand.MemberPeer', 'manager-remove', memberKey, peerId, stampId],
1184
- managerKeyPair.privateKeyB64,
1185
- );
1186
- await db.exec(
1187
- `delete from Strand.MemberPeer
1188
- with context Signature = null, ManagerKey = ?, ManagerSignature = ?
1189
- where MemberKey = ? and PeerId = ?`,
1190
- [managerKeyPair.publicKeyB64, signature, memberKey, peerId],
1191
- );
1192
- await insertRevocation(db, 'MemberPeer', stampId, managerKeyPair);
1193
- }
1194
-
1195
- // ── Manager rotation (add / remove RBAC admins) ───────────────────────────────
1196
-
1197
- /**
1198
- * The live `(Generation, StampId)` of the `Strand.Manager` row keyed by
1199
- * `memberKey`, or `undefined` if no such row is visible.
1200
- *
1201
- * `Manager`'s primary key is the single `MemberKey` column, so any where-equality
1202
- * on it is a full-PK point lookup — unreliable on a networked strand (see
1203
- * {@link scanMemberPeers}). An unfiltered scan + JavaScript filter instead, same
1204
- * as {@link memberStampId}. Serves both {@link addManager} (the authorizer's
1205
- * generation) and {@link removeManager} (the target's stamp).
1206
- */
1207
- async function managerRow(db: Database, memberKey: string): Promise<{ generation: number; stampId: string } | undefined> {
1208
- for await (const row of db.eval('select MemberKey, Generation, StampId from Strand.Manager')) {
1209
- if (row.MemberKey === memberKey) {
1210
- return { generation: Number(row.Generation), stampId: row.StampId as string };
1211
- }
1212
- }
1213
- return undefined;
1214
- }
1215
-
1216
- /** Parameters for {@link addManager}. */
1217
- export interface AddManagerParams {
1218
- /**
1219
- * An EXISTING manager's strand keypair. Its `publicKeyB64` must already be a
1220
- * `Strand.Manager` row (the existing-manager branch of `Manager.Authorized`
1221
- * rejects a non-manager once the founder manager exists); it signs the
1222
- * add-tagged digest over the new manager key, generation, and fresh stamp,
1223
- * and is bound as `context.ManagerKey`.
1224
- */
1225
- byManagerKeyPair: Ed25519KeyPair;
1226
- /** The member key to promote — the new `Manager.MemberKey` row. */
1227
- newManagerKey: string;
1228
- }
1229
-
1230
- /**
1231
- * Promote a key that is ALREADY a `Strand.Member` to `Manager` on the signature of
1232
- * an existing manager — use {@link admitManager} to admit and promote atomically.
1233
- *
1234
- * The promotion branch of `Manager.Authorized` requires the authorizer to be a
1235
- * `Manager` row of STRICTLY SMALLER `Generation` than the new row, verifying
1236
- * `digest('Strand.Manager', 'add', new.MemberKey, new.Generation, new.StampId)`
1237
- * against `context.ManagerKey`. So this writer reads the authorizer's generation,
1238
- * seats the new manager at that value + 1 (the natural successor; the schema
1239
- * enforces only the strict ordering), mints a fresh stamp, and signs the
1240
- * add-tagged digest over all three: the generation inside the signed digest keeps
1241
- * a captured promotion from replaying at a different generation, and the stamp
1242
- * keeps it from re-seating a second incarnation once this one is removed.
1243
- * `new.Generation` is an SQL INTEGER digest arg — the digest framing is
1244
- * type-tagged, so the TS side signs over the NUMBER itself
1245
- * (see {@link signStrandApproval}).
1246
- *
1247
- * The strict ordering — not this writer — is what makes a same-transaction takeover
1248
- * impossible; the `Manager.Generation` column comment in `schemas/strand.qsql` carries
1249
- * that argument. All this writer owes it is a generation strictly above the
1250
- * authorizer's, inside the signed payload.
1251
- *
1252
- * When the authorizer has NO `Manager` row (a non-manager signer, or an open
1253
- * strand with no managers at all), the scan finds nothing and the writer falls
1254
- * back to generation 1 and issues the insert anyway — deliberately letting the
1255
- * SCHEMA be the rejector (`Manager.Authorized` / `OnlyClosed`), not a
1256
- * writer-thrown error, so enforcement is pinned where it actually lives.
1257
- *
1258
- * @param db - The closed strand's database (founder manager already seated).
1259
- * @param params - The authorizing manager's keypair and the new manager key.
1260
- * @throws If `Manager.Authorized` rejects, or if `Manager.MemberExists` rejects because
1261
- * `newManagerKey` holds no `Member` row (admit it first, or use
1262
- * {@link admitManager}); the insert rolls back.
1263
- */
1264
- export async function addManager(db: Database, params: AddManagerParams): Promise<void> {
1265
- const { byManagerKeyPair, newManagerKey } = params;
1266
- const authorizer = await managerRow(db, byManagerKeyPair.publicKeyB64);
1267
- // NOTE: +1 saturates at Number.MAX_SAFE_INTEGER, where the successor compares equal
1268
- // to its authorizer and the schema rejects. Unreachable while generations only ever
1269
- // grow by 1 from 0, but the schema enforces ordering, not adjacency, so a manager may
1270
- // seat a successor at any larger value; if arbitrary generations ever become writable
1271
- // from outside this function, clamp or reject here rather than emitting a dead row.
1272
- const generation = authorizer == null ? 1 : authorizer.generation + 1;
1273
- const stampId = generateStrandStampId();
1274
- const signature = signStrandApproval(
1275
- ['Strand.Manager', 'add', newManagerKey, generation, stampId],
1276
- byManagerKeyPair.privateKeyB64,
1277
- );
1278
- await db.exec(
1279
- `insert into Strand.Manager (MemberKey, Generation, StampId)
1280
- with context ManagerKey = ?, Signature = ?
1281
- values (?, ?, ?)`,
1282
- [byManagerKeyPair.publicKeyB64, signature, newManagerKey, generation, stampId],
1283
- );
1284
- log('Added manager %s (generation %d) by %s', newManagerKey, generation, byManagerKeyPair.publicKeyB64);
1285
- }
1286
-
1287
- /** Parameters for {@link admitManager} — the same shape as {@link AddManagerParams}. */
1288
- export type AdmitManagerParams = AddManagerParams;
1289
-
1290
- /**
1291
- * Admit a key as a `Member` AND promote it to `Manager`, atomically — the one-step
1292
- * pair for a key that is not in the strand yet. {@link addManager} alone promotes a
1293
- * key that is already a member.
1294
- *
1295
- * ONE transaction, because `Manager.MemberExists` is a deferred check reading the LIVE
1296
- * `Member` table: the sibling `Member` insert satisfies it at the shared commit, and a
1297
- * rejection of either half rolls BOTH rows back — never a `Member` row seated by a
1298
- * failed promotion, nor a `Manager` row with no member behind it.
1299
- *
1300
- * NO new authority: both halves are signed by the SAME manager, over two distinct
1301
- * action-tagged digests — `digest('Strand.Member', 'add', …)` for the admission and
1302
- * `digest('Strand.Manager', 'add', …)` for the promotion — each bound to its own row's
1303
- * fresh stamp. This composes exactly the two approvals that manager could already
1304
- * issue separately.
1305
- *
1306
- * The promoting manager must be a PRE-transaction manager: the direct-admit branch of
1307
- * `Member.Authorized` reads `committed.Manager`, so `admitManager` cannot be chained —
1308
- * a manager seated by an `admitManager` earlier in the SAME transaction cannot admit
1309
- * the next one. (A same-transaction promotion CHAIN rooted at a pre-existing manager
1310
- * is fine; only the `Member` half needs the committed authorizer.)
1311
- *
1312
- * NOT insert-if-absent: a repeat call for a key that is already a member fails on the
1313
- * `Member` primary key, matching {@link addMemberByManager}'s unguarded shape.
1314
- *
1315
- * @param db - The closed strand's database (founder manager already seated).
1316
- * @param params - The authorizing manager's keypair and the key to admit + promote.
1317
- * @throws If either half is rejected (`Member.Authorized`, `Manager.Authorized`, a
1318
- * duplicate `Member` key, …); the whole transaction rolls back, leaving neither row.
1319
- */
1320
- export async function admitManager(db: Database, params: AdmitManagerParams): Promise<void> {
1321
- const { byManagerKeyPair, newManagerKey } = params;
1322
- await inStrandTransaction(db, async () => {
1323
- await addMemberByManager(db, { managerKeyPair: byManagerKeyPair, memberKey: newManagerKey });
1324
- await addManager(db, { byManagerKeyPair, newManagerKey });
1325
- });
1326
- log('Admitted + promoted manager %s by %s', newManagerKey, byManagerKeyPair.publicKeyB64);
1327
- }
1328
-
1329
- /** Parameters for {@link removeManager}. */
1330
- export interface RemoveManagerParams {
1331
- /**
1332
- * The keypair authorizing the removal, bound as `context.ManagerKey`. For an
1333
- * ADMIN removal this is a DIFFERENT existing manager, signing the
1334
- * remove-tagged digest (satisfying the existing-manager branch); for a
1335
- * SELF-resignation it is the target's OWN keypair (`publicKeyB64 ===
1336
- * targetManagerKey`), signing the resign-tagged digest (satisfying the
1337
- * former-manager self branch). The writer picks the tag by comparing the keys —
1338
- * see {@link removeManager}. Either way this keypair also files the
1339
- * accompanying tombstone, which requires it to be a PRE-transaction MEMBER
1340
- * (`Revocation.Authorized` reads `committed.Member`) — true for every manager,
1341
- * since managers are members.
1342
- */
1343
- byManagerKeyPair: Ed25519KeyPair;
1344
- /** The `Manager.MemberKey` row to delete. */
1345
- targetManagerKey: string;
1346
- }
1347
-
1348
- /**
1349
- * Remove a `Manager` row — either an admin removing a different manager or a
1350
- * manager resigning itself.
1351
- *
1352
- * Reads the target row's live `(Generation, StampId)` first (absent → quiet
1353
- * no-op, same restart-safe shape as {@link revokeMember}), then runs ONE
1354
- * transaction pairing the delete with its `Strand.Revocation` tombstone
1355
- * (`Manager.RevocationRecorded`). The two delete-side branches of
1356
- * `Manager.Authorized` verify DIFFERENT action-tagged digests, each bound to
1357
- * `(old.MemberKey, old.StampId)`:
1358
- * - **Admin removal** (`byManagerKeyPair` is a different existing manager): the
1359
- * removal branch verifies `digest('Strand.Manager', 'remove', old.MemberKey,
1360
- * old.StampId)` against `A.MemberKey = context.ManagerKey`. Deliberately NO
1361
- * generation condition: a later-generation manager may remove an
1362
- * earlier-generation one (generation is lineage, not privilege), and deletes
1363
- * are safe once inserts are — every accepting branch requires a `Manager` row
1364
- * in the post-image, and the promotion branch's generation ordering keeps
1365
- * attacker rows out of it. The stamp binding is what makes a captured removal
1366
- * single-use: a re-promoted manager carries a fresh stamp the old approval
1367
- * does not match — closing the removal half of
1368
- * `bug-strand-manager-authority-antireplay` (the insert half was already
1369
- * closed by the generation-bearing promotion payload).
1370
- * - **Self-resignation** (`byManagerKeyPair` IS the target): the former-manager
1371
- * self branch verifies `digest('Strand.Manager', 'resign', old.MemberKey,
1372
- * old.StampId)` against `old.MemberKey` itself. The distinct `'resign'` tag is
1373
- * belt-and-braces (different verifying keys); the anti-replay fix is the stamp.
1374
- *
1375
- * The caller selects the case purely by which keypair it passes; the only
1376
- * branching here is the action tag, chosen by comparing the signer to the target.
1377
- *
1378
- * The optimystic bootstrap-mode transactor evaluates deferred (subquery-bearing)
1379
- * CHECK constraints on DELETE as well as INSERT, so `Manager.Authorized` — deferred —
1380
- * IS enforced here: a signer that is neither an existing manager nor the target
1381
- * itself is rejected at commit.
1382
- *
1383
- * THE LAST MANAGER CANNOT BE REMOVED. `Manager.MinOneManager` (`check on delete`,
1384
- * deferred, so it sees the POST-delete count) rejects any delete that would leave the
1385
- * strand with zero managers — an admin-less closed strand could never admit anyone
1386
- * again, since `Invite`, `addMemberByManager`, and `addManager` all require a
1387
- * `Manager` row. The bootstrap branch of `Manager.Authorized` is gated to INSERTs
1388
- * (`old.MemberKey is null`), so it does not waive the signature check on a delete
1389
- * that drops the count toward the floor: a second-to-last removal is authorized
1390
- * exactly like any other.
1391
- *
1392
- * HAND-OFF ORDER: a sole manager transferring control must ADD the successor FIRST
1393
- * and resign SECOND. A same-transaction delete-then-insert swap is rejected — the
1394
- * bootstrap branch is gated to the founding state (at most one `Member`), and the
1395
- * successor's insert has no other manager to authorize it once the sole manager's
1396
- * row is gone.
1397
- *
1398
- * Cross-node caveat: `MinOneManager` counts rows visible to THIS transaction, so two
1399
- * nodes concurrently removing different managers can each see a survivor and still
1400
- * converge to zero. Noted in the schema; a cross-node floor is not attempted here.
1401
- *
1402
- * @param db - The closed strand's database.
1403
- * @param params - The authorizing keypair and the target manager key.
1404
- * @throws If `Manager.Authorized` rejects (a signer that is neither another existing
1405
- * manager nor the target itself) or `Manager.MinOneManager` rejects (the removal
1406
- * would leave no managers); the whole delete+tombstone transaction rolls back.
1407
- */
1408
- export async function removeManager(db: Database, params: RemoveManagerParams): Promise<void> {
1409
- const { byManagerKeyPair, targetManagerKey } = params;
1410
- const target = await managerRow(db, targetManagerKey);
1411
- if (target == null) {
1412
- log('Manager %s already absent; skipping removal', targetManagerKey);
1413
- return;
1414
- }
1415
- const tag = byManagerKeyPair.publicKeyB64 === targetManagerKey ? 'resign' : 'remove';
1416
- const signature = signStrandApproval(
1417
- ['Strand.Manager', tag, targetManagerKey, target.stampId],
1418
- byManagerKeyPair.privateKeyB64,
1419
- );
1420
- await inStrandTransaction(db, async () => {
1421
- await db.exec(
1422
- `delete from Strand.Manager
1423
- with context ManagerKey = ?, Signature = ?
1424
- where MemberKey = ?`,
1425
- [byManagerKeyPair.publicKeyB64, signature, targetManagerKey],
1426
- );
1427
- await insertRevocation(db, 'Manager', target.stampId, byManagerKeyPair);
1428
- });
1429
- log('Removed manager %s by %s', targetManagerKey, byManagerKeyPair.publicKeyB64);
1430
- }
1
+ import debug from 'debug';
2
+ import { toString as uint8ArrayToString } from 'uint8arrays';
3
+ import type { Database } from '@quereus/quereus';
4
+ import { digest, sign, verify, generatePrivateKey, getPublicKey, randomBytes } from '@optimystic/quereus-plugin-crypto';
5
+ import type { SAppConfig } from './types.js';
6
+ import type { Ed25519KeyPair } from './ed25519-key.js';
7
+ import { canonicalDatetime } from './canonical-datetime.js';
8
+
9
+ const log = debug('sereus:cadre:strand-membership');
10
+
11
+ /**
12
+ * The engine (rules-logic system) assumed to manage every strand. Persisted in
13
+ * `Strand.Header.Engine`.
14
+ *
15
+ * PLACEHOLDER SEAM: there is no engine-selection mechanism yet — Quereus is the
16
+ * only engine. When a real engine-config seam lands, the founder bootstrap should
17
+ * read the strand's chosen engine from there instead of this constant. Documented
18
+ * deliberately so the column is non-null and self-describing in the meantime.
19
+ */
20
+ export const STRAND_ENGINE = 'quereus';
21
+
22
+ /**
23
+ * The pinned engine version recorded in `Strand.Header.EngineVersion`. A pinned
24
+ * string for the same reason as {@link STRAND_ENGINE}: it is a documented
25
+ * placeholder until an engine-selection seam supplies the real running version.
26
+ */
27
+ export const STRAND_ENGINE_VERSION = '0.1.0';
28
+
29
+ /**
30
+ * The single-payload ed25519 signer the `Strand.Invite`/`Strand.ConsumedInvite`
31
+ * handshake constraints verify against.
32
+ *
33
+ * Those constraints sign a single SHA-256 digest over a `'|'`-joined payload
34
+ * (e.g. `InviteValid` verifies
35
+ * `verify(digest(new.Key || '|' || coalesce(new.Expiration, '')), ...)`).
36
+ * So the signer hashes the payload to raw bytes and ed25519-signs *those bytes*:
37
+ * SQL `digest(...)`'s default output is base64url and `verify(...)`'s default
38
+ * `inputEncoding` is base64url, so signer and verifier operate on identical bytes.
39
+ * The membership/RBAC tables (Member/Manager/MemberPeer/Revocation) instead use
40
+ * domain/action-tagged VARIADIC digests — see {@link signStrandApproval}.
41
+ *
42
+ * All strand keys are ed25519, so the explicit `'ed25519'` curve arg is mandatory
43
+ * (the crypto plugin otherwise defaults to secp256k1). Mirrors the proven
44
+ * `signItem` helper in the `rbac-signed-write` integration scenario.
45
+ *
46
+ * @param payload - The `'|'`-joined payload the matching constraint hashes.
47
+ * @param privateKeyB64 - The signer's base64url ed25519 private seed.
48
+ * @returns The base64url ed25519 signature over the payload's SHA-256 digest.
49
+ */
50
+ export function signStrandPayload(payload: string, privateKeyB64: string): string {
51
+ const hashBytes = digest([payload], 'sha256', 'bytes') as Uint8Array;
52
+ return sign(hashBytes, privateKeyB64, 'ed25519', 'bytes', 'base64url', 'base64url') as string;
53
+ }
54
+
55
+ /**
56
+ * The verifier counterpart to {@link signStrandPayload}.
57
+ *
58
+ * Mirrors what the single-payload `Strand.*` constraints compute:
59
+ * `verify(digest(payload), signature, publicKey, 'ed25519')`.
60
+ * SQL `digest`'s default output and `verify`'s default `inputEncoding`/`sigEncoding`/
61
+ * `keyEncoding` are all base64url, so the off-engine check operates on the exact
62
+ * same bytes the in-engine CHECK does. Used by the off-engine `MemberVerifier`
63
+ * (pre-flight registration checks) so the on-engine constraint is not the only
64
+ * place a member self-proof is validated.
65
+ *
66
+ * @param payload - The `'|'`-joined payload the matching constraint hashes.
67
+ * @param signatureB64 - The base64url ed25519 signature to check.
68
+ * @param publicKeyB64 - The base64url ed25519 public key to verify against.
69
+ * @returns `true` iff the signature is valid for the payload under that key.
70
+ */
71
+ export function verifyStrandPayload(payload: string, signatureB64: string, publicKeyB64: string): boolean {
72
+ const payloadDigest = digest([payload], 'sha256', 'base64url') as string;
73
+ return verify(payloadDigest, signatureB64, publicKeyB64, 'ed25519', 'base64url', 'base64url', 'base64url');
74
+ }
75
+
76
+ /**
77
+ * Sign a domain/action-tagged `Strand.*` membership/RBAC approval.
78
+ *
79
+ * Every `Authorized` branch on `Member`/`Manager`/`MemberPeer`/`Revocation`
80
+ * verifies a VARIADIC digest — `digest('<domain>', '<action>', <field>, ...,
81
+ * <StampId>)` — whose leading two elements are fixed literals (the domain tag,
82
+ * e.g. `'Strand.Member'`, and an action tag, e.g. `'add'`) and whose trailing
83
+ * element is the row's per-incarnation StampId. The tags keep an approval from
84
+ * verifying against any rule but the one it was minted for (the idiom of the
85
+ * control layer's `buildAuthorizationMessage`); the StampId binding makes it
86
+ * single-use — it authorizes exactly one incarnation of one row, and dies when
87
+ * that row's stamp is retired into `Strand.Revocation`.
88
+ *
89
+ * The caller passes the digest elements exactly as the matching SQL constraint
90
+ * lists them (each SQL argument = one array element), PRESERVING TYPE: the
91
+ * crypto plugin's digest framing is type-tagged (INTEGER 1 and TEXT '1' encode
92
+ * differently, deliberately), so `Manager.Authorized`'s promotion branch —
93
+ * whose digest includes the INTEGER `new.Generation` — must be signed over the
94
+ * NUMBER, not its string form. Integer `number` and `bigint` encode
95
+ * identically in that framing, so JS-side numbers match however the engine
96
+ * represents the SQL integer.
97
+ *
98
+ * Like {@link signStrandPayload}, the raw digest BYTES are ed25519-signed
99
+ * directly, matching SQL `verify(...)`'s default base64url input decoding.
100
+ *
101
+ * @param fields - The digest elements, in constraint order (tags first, StampId last).
102
+ * @param privateKeyB64 - The signer's base64url ed25519 private seed.
103
+ * @returns The base64url ed25519 signature over the tagged digest.
104
+ */
105
+ export function signStrandApproval(fields: ReadonlyArray<string | number>, privateKeyB64: string): string {
106
+ const hashBytes = digest(fields, 'sha256', 'bytes') as Uint8Array;
107
+ return sign(hashBytes, privateKeyB64, 'ed25519', 'bytes', 'base64url', 'base64url') as string;
108
+ }
109
+
110
+ /**
111
+ * Mint a fresh per-row authorization nonce (StampId) for a `Strand.Member`,
112
+ * `Strand.Manager`, or `Strand.MemberPeer` row.
113
+ *
114
+ * 256 bits of CSPRNG output, base64url-encoded — enough entropy that a future
115
+ * legitimate stamp cannot be guessed and pre-planted into `Strand.Revocation`
116
+ * (the `RowIsGone` comment in `schemas/strand.qsql` leans on exactly this).
117
+ * Plain randomness, deliberately NOT the control layer's peer-derived
118
+ * `generateStampId`: strand rows are not per-peer, so entropy alone carries
119
+ * the uniqueness.
120
+ *
121
+ * @returns A fresh base64url stamp (43 chars for 32 bytes).
122
+ */
123
+ export function generateStrandStampId(): string {
124
+ // randomBytes' first arg is BITS, not bytes — 256 bits = 32 bytes.
125
+ const bytes = randomBytes(256, 'bytes') as Uint8Array;
126
+ return uint8ArrayToString(bytes, 'base64url');
127
+ }
128
+
129
+ /**
130
+ * Run `fn` inside one explicit strand transaction: begin, fn, commit — with the
131
+ * standard rollback-on-failure shape.
132
+ *
133
+ * Used wherever two `Strand.*` writes must land atomically: the invite-consume
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.
144
+ */
145
+ async function inStrandTransaction(db: Database, fn: () => Promise<void>): Promise<void> {
146
+ if (!db.getAutocommit()) {
147
+ await fn();
148
+ return;
149
+ }
150
+ await db.beginTransaction();
151
+ try {
152
+ await fn();
153
+ await db.commit();
154
+ } catch (error) {
155
+ // A failed commit() already tears down the transaction, so rollback() would
156
+ // throw "No transaction active" and mask the real cause — swallow only that.
157
+ try {
158
+ await db.rollback();
159
+ } catch (rollbackError) {
160
+ log('Rollback after strand transaction failure was a no-op: %s', rollbackError);
161
+ }
162
+ throw error;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * File the `Strand.Revocation` tombstone that retires a deleted row's StampId.
168
+ *
169
+ * Runs inside the SAME transaction as the delete it accompanies — the guarded
170
+ * tables' `RevocationRecorded` (deferred) requires the tombstone at commit, and
171
+ * `Revocation.RowIsGone` (also deferred) requires the row to already be gone, so
172
+ * the pair only ever lands together.
173
+ *
174
+ * `Revocation.Authorized` verifies the retire-tagged digest against a
175
+ * PRE-transaction (`committed.Member`) row, so the retiree must have been a
176
+ * member BEFORE this transaction. Every writer flow satisfies this: managers are
177
+ * members, and a self-departing member is committed until this very transaction
178
+ * removes it. Unlike the sibling tables' nullable contexts, `Revocation`'s
179
+ * context is non-null — both fields are always supplied.
180
+ *
181
+ * @param db - The strand database, INSIDE an open transaction.
182
+ * @param tableName - Which guarded table the stamp belonged to.
183
+ * @param stampId - The retired stamp.
184
+ * @param retiree - The committed member filing the tombstone (the delete's own signer).
185
+ */
186
+ async function insertRevocation(
187
+ db: Database,
188
+ tableName: 'Member' | 'Manager' | 'MemberPeer',
189
+ stampId: string,
190
+ retiree: Ed25519KeyPair,
191
+ ): Promise<void> {
192
+ const signature = signStrandApproval(['Strand.Revocation', 'retire', tableName, stampId], retiree.privateKeyB64);
193
+ await db.exec(
194
+ `insert into Strand.Revocation (TableName, StampId)
195
+ with context MemberKey = ?, Signature = ?
196
+ values (?, ?)`,
197
+ [retiree.publicKeyB64, signature, tableName, stampId],
198
+ );
199
+ }
200
+
201
+ /** Parameters for {@link bootstrapFounderMembership}. */
202
+ export interface FounderBootstrapParams {
203
+ /** The strand id — written to `Header.Id`. */
204
+ strandId: string;
205
+ /** Strand type: `'o'` (open, Header only) or `'c'` (closed, Header+Member+Manager). */
206
+ type: 'o' | 'c';
207
+ /** The sApp config whose id/version/schema/signature populate the Header. */
208
+ sApp: SAppConfig;
209
+ /**
210
+ * The founder's derived strand keypair (from {@link strandMemberKeyPair}). Its
211
+ * `publicKeyB64` becomes the founding `Member.Key` and `Manager.MemberKey`.
212
+ * Required for a closed strand; ignored for an open strand.
213
+ */
214
+ founderKeyPair?: Ed25519KeyPair;
215
+ }
216
+
217
+ /**
218
+ * Count rows in a `Strand.*` table as seen by this database instance.
219
+ *
220
+ * The table name is a fixed literal supplied by this module (never user input),
221
+ * so the interpolation is not an injection surface — it just keeps the singleton
222
+ * insert-if-absent guards terse.
223
+ */
224
+ async function strandTableCount(db: Database, table: 'Header' | 'Member' | 'Manager'): Promise<number> {
225
+ for await (const row of db.eval(`select count(1) as Count from Strand.${table}`)) {
226
+ return (row.Count as number) ?? 0;
227
+ }
228
+ return 0;
229
+ }
230
+
231
+ /**
232
+ * Whether any `Strand.Revocation` row has ever retired a `Manager` seat — the
233
+ * exact condition that permanently closes the founding branch of
234
+ * `Manager.Authorized` (see the schema's bootstrap-branch comment). Unfiltered
235
+ * scan + JavaScript comparison, the module's scan-not-seek idiom (rationale on
236
+ * {@link scanMemberPeers}).
237
+ *
238
+ * NOTE: walks the whole append-only `Strand.Revocation` table when no `Manager`
239
+ * tombstone is present (it returns on the first one it finds). Cheap at strand
240
+ * scale; if membership churn ever makes that table large — the growth the
241
+ * `Revocation` schema comment already flags, tracked by `debt-strand-tombstone-reap`
242
+ * — this wants a stored "founding closed" marker rather than a scan per call.
243
+ */
244
+ async function strandHasManagerRevocation(db: Database): Promise<boolean> {
245
+ for await (const row of db.eval('select TableName from Strand.Revocation')) {
246
+ if (row.TableName === 'Manager') {
247
+ return true;
248
+ }
249
+ }
250
+ return false;
251
+ }
252
+
253
+ /**
254
+ * Insert the singleton `Strand.Header` if absent.
255
+ *
256
+ * Every `Header` column is NOT NULL (Quereus defaults unqualified columns to NOT
257
+ * NULL; the table declares no `null` columns), so all eight are supplied non-null.
258
+ * A missing `sApp.signature` (dev `requireSignedSchemas:false`) coalesces to `''`
259
+ * rather than inserting null. `Header` carries only `InsertOnly` + a singleton PK,
260
+ * so the insert needs no `with context`.
261
+ */
262
+ async function insertHeaderIfAbsent(db: Database, params: FounderBootstrapParams): Promise<void> {
263
+ if (await strandTableCount(db, 'Header') > 0) {
264
+ log('Header already present for strand %s; skipping', params.strandId);
265
+ return;
266
+ }
267
+ await db.exec(
268
+ `insert into Strand.Header
269
+ (Id, Type, sAppId, sAppVersion, sAppSchema, sAppSignature, Engine, EngineVersion)
270
+ values (?, ?, ?, ?, ?, ?, ?, ?)`,
271
+ [
272
+ params.strandId,
273
+ params.type,
274
+ params.sApp.id,
275
+ params.sApp.version,
276
+ params.sApp.schema,
277
+ params.sApp.signature ?? '',
278
+ STRAND_ENGINE,
279
+ STRAND_ENGINE_VERSION,
280
+ ],
281
+ );
282
+ log('Inserted Header for strand %s (type %s)', params.strandId, params.type);
283
+ }
284
+
285
+ /**
286
+ * Insert the founding `Strand.Member` if no member exists yet.
287
+ *
288
+ * The empty PRE-transaction member set (`count(1) from committed.Member = 0`)
289
+ * satisfies the bootstrap branch of `Member.Authorized`, so no signature is
290
+ * needed — the context fields are explicit nulls. The branch also caps the
291
+ * POST-image at one member, so it waives authorization for exactly this single
292
+ * seat, never for a batch. The row still mints a fresh StampId — the column is
293
+ * NOT NULL on every path; bootstrap only waives the SIGNATURE. Guarding on the
294
+ * count makes this idempotent: a re-run (the founder re-`addStrand`/
295
+ * `resumeStrand`) finds the member present and skips.
296
+ */
297
+ async function insertFounderMemberIfAbsent(db: Database, memberKey: string, strandId: string): Promise<void> {
298
+ if (await strandTableCount(db, 'Member') > 0) {
299
+ log('Member already present for strand %s; skipping founder Member', strandId);
300
+ return;
301
+ }
302
+ await db.exec(
303
+ `insert into Strand.Member (Key, StampId)
304
+ with context ManagerKey = null, ManagerSignature = null, MemberSignature = null
305
+ values (?, ?)`,
306
+ [memberKey, generateStrandStampId()],
307
+ );
308
+ log('Inserted founding Member for strand %s', strandId);
309
+ }
310
+
311
+ /**
312
+ * Insert the founding `Strand.Manager` if no manager exists yet.
313
+ *
314
+ * The empty-table state satisfies the bootstrap branch of `Manager.Authorized`, so
315
+ * no signature is needed (a fresh StampId is still minted — the column is NOT
316
+ * NULL). The founder is seated at `Generation = 0` — the root of
317
+ * the manager lineage; the bootstrap branch requires exactly that value, and every
318
+ * later manager is seated at a strictly greater generation (see {@link addManager}).
319
+ * Idempotent via the same count guard.
320
+ *
321
+ * ORDERING IS LOAD-BEARING, not merely convenient. The bootstrap branch is gated to
322
+ * the founding state and now reads `exists (select 1 from Member M where M.Key =
323
+ * new.MemberKey)` alongside `count(Manager) <= 1` and `count(Member) <= 1`, so the
324
+ * founding `Member` row MUST already exist when this commits — a Manager-first
325
+ * seeding path is rejected. It must also run AFTER the Header insert:
326
+ * `Manager.OnlyClosed` is a deferred (subquery) check evaluated at commit. The
327
+ * sequential auto-commit Header→Member→Manager order in
328
+ * {@link bootstrapFounderMembership} satisfies both.
329
+ *
330
+ * SEALED strands are skipped, not re-founded: a strand whose sole manager has
331
+ * {@link sealStrand}ed also has zero `Manager` rows, but its founding branch is
332
+ * closed for good (the schema's bootstrap branch requires no `Manager`-table
333
+ * `Revocation` row to exist). Attempting the founding insert here would be
334
+ * schema-rejected and turn every founder restart of a sealed strand into a
335
+ * loud failure — so this mirrors the schema's own gate and returns quietly.
336
+ * A crash BETWEEN the founding Member and Manager inserts leaves no Revocation
337
+ * rows at all, so that restart still completes the founding.
338
+ */
339
+ async function insertFounderManagerIfAbsent(db: Database, memberKey: string, strandId: string): Promise<void> {
340
+ if (await strandTableCount(db, 'Manager') > 0) {
341
+ log('Manager already present for strand %s; skipping founder Manager', strandId);
342
+ return;
343
+ }
344
+ if (await strandHasManagerRevocation(db)) {
345
+ log('Strand %s has retired a manager seat (sealed); skipping founder Manager', strandId);
346
+ return;
347
+ }
348
+ await db.exec(
349
+ `insert into Strand.Manager (MemberKey, Generation, StampId)
350
+ with context ManagerKey = null, Signature = null
351
+ values (?, 0, ?)`,
352
+ [memberKey, generateStrandStampId()],
353
+ );
354
+ log('Inserted founding Manager for strand %s', strandId);
355
+ }
356
+
357
+ /**
358
+ * Founder-only one-time bootstrap of a strand's `Strand.*` membership/RBAC rows.
359
+ *
360
+ * Runs once at bring-up on the strand's FOUNDER (the party that provisioned and
361
+ * published the strand). A joiner never calls this — it receives these rows via
362
+ * Optimystic sync. Every write is insert-if-absent (guarded by a row count) so a
363
+ * restart / founder re-`addStrand` is a no-op and never double-inserts.
364
+ *
365
+ * Behavior by strand type:
366
+ * - **Open (`'o'`)**: insert `Header` only. `Member`/`Manager`/`Invite` are
367
+ * `OnlyClosed` and would trip that constraint — they are skipped entirely.
368
+ * - **Closed (`'c'`)**: insert `Header(Type='c')`, then the founding `Member`
369
+ * (`Key = founderKeyPair.publicKeyB64`), then the founding `Manager`
370
+ * (`MemberKey = founderKeyPair.publicKeyB64`). Insert order matters: the
371
+ * Header must commit before the deferred `OnlyClosed` checks on Member/Manager
372
+ * evaluate at commit.
373
+ *
374
+ * A closed strand with no `founderKeyPair` throws: a closed strand with no founding
375
+ * Manager could never admit anyone, so failing loudly here (which propagates out
376
+ * of `StrandDatabase.initialize()` and triggers the runtime's rollback) is correct.
377
+ *
378
+ * @param db - The strand's Quereus database (schema already applied).
379
+ * @param params - Strand id/type, the sApp config for the Header, and (closed only)
380
+ * the derived founder keypair.
381
+ * @throws If `type === 'c'` and no `founderKeyPair` is supplied.
382
+ */
383
+ export async function bootstrapFounderMembership(db: Database, params: FounderBootstrapParams): Promise<void> {
384
+ const { strandId, type, founderKeyPair } = params;
385
+ log('Founder bootstrap for strand %s (type %s)', strandId, type);
386
+
387
+ // Validate BEFORE writing anything: a closed strand with no founder key must
388
+ // fail without leaving a closed `Header` that has no founding Member/Manager
389
+ // (such a strand could never admit anyone).
390
+ if (type === 'c' && !founderKeyPair) {
391
+ throw new Error(
392
+ `Cannot bootstrap closed strand ${strandId}: no founder key pair derived from MemberPrivateKey. ` +
393
+ 'A closed strand needs a founding Member + Manager or it can never admit members.',
394
+ );
395
+ }
396
+
397
+ // Header is written for every strand; Member/Manager are closed-only.
398
+ await insertHeaderIfAbsent(db, params);
399
+
400
+ if (type === 'o') {
401
+ // Open strand: Member/Manager/Invite are OnlyClosed — nothing else to write.
402
+ return;
403
+ }
404
+
405
+ const memberKey = founderKeyPair!.publicKeyB64;
406
+ await insertFounderMemberIfAbsent(db, memberKey, strandId);
407
+ await insertFounderManagerIfAbsent(db, memberKey, strandId);
408
+ log('Founder bootstrap complete for closed strand %s', strandId);
409
+ }
410
+
411
+ // ── Invite issuance + consumption (the per-strand join handshake) ─────────────
412
+
413
+ /** Parameters for {@link issueInvite}. */
414
+ export interface IssueInviteParams {
415
+ /**
416
+ * The issuing manager's strand keypair. Its `publicKeyB64` must already be a
417
+ * `Strand.Manager` row (the `InviteValid` constraint rejects a non-manager).
418
+ */
419
+ managerKeyPair: Ed25519KeyPair;
420
+ /**
421
+ * Optional invite expiry as epoch milliseconds. When set, it is canonicalised
422
+ * via {@link canonicalDatetime} so the signed payload segment byte-matches the
423
+ * `datetime`-coerced `Invite.Expiration` the deferred CHECK sees. When omitted,
424
+ * the invite never expires and the signed segment is `''` (matching the schema's
425
+ * `coalesce(new.Expiration, '')`).
426
+ */
427
+ expiration?: number;
428
+ }
429
+
430
+ /** The minted invite: a public key (the `Invite.Key`) plus its private seed. */
431
+ export interface IssuedInvite {
432
+ /** The invite ed25519 PUBLIC key (base64url) — the `Invite.Key` primary key. */
433
+ inviteKey: string;
434
+ /**
435
+ * The invite ed25519 PRIVATE seed (base64url). Handed out-of-band to the
436
+ * invitee; whoever holds it can {@link consumeInvite} exactly once. NEVER
437
+ * persisted in the strand — only the public key is.
438
+ */
439
+ invitePrivateKey: string;
440
+ }
441
+
442
+ /**
443
+ * Issue a single-use invitation to join a closed strand.
444
+ *
445
+ * Generates a fresh invite ed25519 keypair (the public key becomes `Invite.Key`),
446
+ * builds the constraint's payload (`Key || '|' || coalesce(Expiration, '')`), and
447
+ * signs it TWICE: with the manager private key (→ `ManagerSignature`, proving
448
+ * a manager issued it) and with the invite private key (→ `InviteSignature`,
449
+ * proving the issuer actually holds the invite secret). Both signatures plus the
450
+ * manager public key are bound as constraint context for the `Invite` insert.
451
+ *
452
+ * The returned `invitePrivateKey` is the only secret the invitee needs to redeem
453
+ * the invite — it is NOT stored in the strand (only `Invite.Key`, the public half,
454
+ * is). Single-use is enforced at consumption time: `ConsumedInvite`'s primary key
455
+ * is `InviteKey`, so a given invite can be consumed at most once.
456
+ *
457
+ * @param db - The closed strand's database (founder already bootstrapped).
458
+ * @param params - The issuing manager keypair and optional expiry.
459
+ * @returns The invite public key and the out-of-band private seed.
460
+ */
461
+ export async function issueInvite(db: Database, params: IssueInviteParams): Promise<IssuedInvite> {
462
+ const { managerKeyPair, expiration } = params;
463
+
464
+ const invitePrivateKey = generatePrivateKey('ed25519', 'base64url') as string;
465
+ const inviteKey = getPublicKey(invitePrivateKey, 'ed25519', 'base64url', 'base64url') as string;
466
+
467
+ // Canonicalise the expiry to the engine's stored `datetime` form so the signed
468
+ // segment matches `new.Expiration` post-coercion; null expiry signs as ''.
469
+ const expirationSegment = expiration == null ? '' : await canonicalDatetime(db, expiration);
470
+ const expirationColumn = expiration == null ? null : expirationSegment;
471
+
472
+ const payload = `${inviteKey}|${expirationSegment}`;
473
+ const managerSignature = signStrandPayload(payload, managerKeyPair.privateKeyB64);
474
+ const inviteSignature = signStrandPayload(payload, invitePrivateKey);
475
+
476
+ await db.exec(
477
+ `insert into Strand.Invite (Key, Expiration)
478
+ with context ManagerKey = ?, ManagerSignature = ?, InviteSignature = ?
479
+ values (?, ?)`,
480
+ [managerKeyPair.publicKeyB64, managerSignature, inviteSignature, inviteKey, expirationColumn],
481
+ );
482
+ log('Issued invite %s (expires=%s)', inviteKey, expirationColumn ?? 'never');
483
+
484
+ return { inviteKey, invitePrivateKey };
485
+ }
486
+
487
+ /** Parameters for {@link consumeInvite}. */
488
+ export interface ConsumeInviteParams {
489
+ /** The `Invite.Key` (invite PUBLIC key, base64url) being redeemed. */
490
+ inviteKey: string;
491
+ /** The matching invite PRIVATE seed (base64url) received out-of-band. */
492
+ invitePrivateKey: string;
493
+ /** The joining member's ed25519 PUBLIC key (base64url) — the new `Member.Key`. */
494
+ memberKey: string;
495
+ /**
496
+ * The instant (epoch ms) to compare against the invite's `Expiration` for the
497
+ * `NotExpired` gate. Defaults to `Date.now()`; tests pin it so the comparison is
498
+ * deterministic. Mirrors the control-layer `redeemInvitation`'s `nowMs` convention.
499
+ */
500
+ nowMs?: number;
501
+ }
502
+
503
+ /**
504
+ * Redeem an invite to admit a new `Member`, atomically.
505
+ *
506
+ * `Member.Authorized`'s invite branch needs a `ConsumedInvite` row, while
507
+ * `ConsumedInvite`'s `MemberExists` needs the `Member` row — a circular
508
+ * dependency. Both are deferred (subquery-bearing) checks that evaluate
509
+ * at COMMIT, so inserting `Member` then `ConsumedInvite` inside one explicit
510
+ * transaction lets both rows exist when the deferred checks fire. This mirrors
511
+ * `ControlDatabase.redeemInvitation` (Strand + FormationUsage in one txn).
512
+ *
513
+ * The `ConsumedInvite` insert carries an `InviteSignature` over
514
+ * `InviteKey || '|' || MemberKey`, proving the consumer holds the invite private
515
+ * key (the on-engine `ValidUsage` gate). The `Member` insert needs no member
516
+ * signature — its admission is the existence of the matching `ConsumedInvite` —
517
+ * but still mints a fresh StampId (NOT NULL on every path; a revoked-then-
518
+ * re-invited member is a fresh incarnation under a fresh stamp).
519
+ *
520
+ * Single-use: `ConsumedInvite`'s primary key is `InviteKey`, so a second consume
521
+ * of the same invite is rejected by the PK (a distinct layer from the control
522
+ * network's `FormationUsage` single-use, which gates strand FORMATION, not the
523
+ * per-strand member join enforced here).
524
+ *
525
+ * Expiry: the `ConsumedInvite.NotExpired` deferred check rejects redeeming an invite
526
+ * whose `Expiration` is at or before `context.Now`. `Now` is supplied here as a
527
+ * canonical-datetime string from `canonicalDatetime(db, nowMs)` — the SAME transform
528
+ * `issueInvite` uses to store `Invite.Expiration` — so both sides of the schema's
529
+ * `I.Expiration > context.Now` comparison are byte-identical canonical strings and
530
+ * the lexical `>` orders chronologically at any granularity. (This intentionally
531
+ * diverges from the control layer, which passes `Now` as a JS ISO string; Quereus
532
+ * does not coerce context params, so an ISO `Now` would be compared lexically against
533
+ * the canonical, T-separated `Expiration` and could mis-order near-same-instant
534
+ * timestamps (due to a trailing `.000Z` suffix). The control tests only use far-future/far-past expiries, so that latent
535
+ * skew never bites there — the strand layer avoids it outright.) A null `Expiration`
536
+ * never expires. Like `ValidUsage`, `NotExpired` defers to commit, so an expired
537
+ * invite rolls back the whole txn — neither the `Member` nor the `ConsumedInvite`
538
+ * row survives.
539
+ *
540
+ * @param db - The closed strand's database.
541
+ * @param params - The invite key/secret, the joining member's public key, and an
542
+ * optional `nowMs` instant for the expiry comparison (default `Date.now()`).
543
+ * @throws If any constraint rejects (bad signature, missing invite, or an expired
544
+ * invite); the whole transaction rolls back (neither the `Member` nor the
545
+ * `ConsumedInvite` row survives).
546
+ */
547
+ export async function consumeInvite(db: Database, params: ConsumeInviteParams): Promise<void> {
548
+ const { inviteKey, invitePrivateKey, memberKey, nowMs } = params;
549
+
550
+ const usagePayload = `${inviteKey}|${memberKey}`;
551
+ const inviteSignature = signStrandPayload(usagePayload, invitePrivateKey);
552
+
553
+ // Canonicalise "now" the same way issueInvite canonicalises Expiration, so the
554
+ // schema's `I.Expiration > context.Now` compares like-for-like canonical strings.
555
+ // Plain runtime Date.now() — the tess Workflow restriction is on scripts, not libs.
556
+ const nowCanonical = await canonicalDatetime(db, nowMs ?? Date.now());
557
+
558
+ await inStrandTransaction(db, async () => {
559
+ // 1. Member — admitted by the deferred invite branch (the matching
560
+ // ConsumedInvite below), so no manager signature is supplied.
561
+ await db.exec(
562
+ `insert into Strand.Member (Key, StampId)
563
+ with context ManagerKey = null, ManagerSignature = null, MemberSignature = null
564
+ values (?, ?)`,
565
+ [memberKey, generateStrandStampId()],
566
+ );
567
+
568
+ // 2. ConsumedInvite — proves possession of the invite private key and that the
569
+ // invite has not expired (NotExpired gate against the canonical Now).
570
+ await db.exec(
571
+ `insert into Strand.ConsumedInvite (InviteKey, MemberKey)
572
+ with context InviteSignature = ?, Now = ?
573
+ values (?, ?)`,
574
+ [inviteSignature, nowCanonical, inviteKey, memberKey],
575
+ );
576
+ });
577
+ log('Consumed invite %s -> member %s', inviteKey, memberKey);
578
+ }
579
+
580
+ /**
581
+ * Every value of `InviteKey` in one of the two invite-marker tables, as a `Set`.
582
+ *
583
+ * Unfiltered scan + JavaScript comparison, deliberately: `ConsumedInvite` and
584
+ * `CancelledInvite` each have the single column `InviteKey` as their primary key, so
585
+ * ANY where-equality on it is a FULL-PK predicate — which the optimystic virtual-table
586
+ * module serves via a single-key point lookup that can MISS on a networked strand (the
587
+ * argument {@link scanMemberPeers} makes in full). Reading the whole column and
588
+ * comparing in JS depends only on the scan returning a SUPERSET of the live rows.
589
+ *
590
+ * The table name is a fixed literal supplied by this module (never user input), so the
591
+ * interpolation is not an injection surface — same as {@link strandTableCount}.
592
+ *
593
+ * NOTE: every caller pays a WHOLE-column read — {@link cancelInvite}'s idempotence guard
594
+ * reads all cancelled keys to answer one membership question, and
595
+ * {@link listOutstandingInvites} reads both marker tables per call. Fine at strand scale
596
+ * (a handful of invitations); if invite churn ever gets large this wants a real filtered
597
+ * read, which in turn needs the networked point-lookup gap closed
598
+ * (`debt-composite-pk-point-lookup-unreliable-untracked`).
599
+ */
600
+ async function scanInviteKeys(db: Database, table: 'ConsumedInvite' | 'CancelledInvite'): Promise<Set<string>> {
601
+ const keys = new Set<string>();
602
+ for await (const row of db.eval(`select InviteKey from Strand.${table}`)) {
603
+ keys.add(row.InviteKey as string);
604
+ }
605
+ return keys;
606
+ }
607
+
608
+ /** Parameters for {@link cancelInvite}. */
609
+ export interface CancelInviteParams {
610
+ /**
611
+ * The cancelling manager's strand keypair. Its `publicKeyB64` must be a
612
+ * PRE-EXISTING `Strand.Manager` row — `CancelledInvite.Authorized` reads
613
+ * `committed.Manager`, so a manager seated in the SAME transaction cannot cancel.
614
+ */
615
+ managerKeyPair: Ed25519KeyPair;
616
+ /** The `Invite.Key` to kill. */
617
+ inviteKey: string;
618
+ }
619
+
620
+ /**
621
+ * Cancel an outstanding invitation, permanently: it may never be consumed.
622
+ *
623
+ * Files a `Strand.CancelledInvite` tombstone carrying a manager signature over
624
+ * `digest('Strand.CancelledInvite', 'cancel', InviteKey)`. The digest binds the exact
625
+ * invite key, so an approval minted for one invitation cannot cancel another.
626
+ *
627
+ * One row, one statement — no transaction needed. `CancelledInvite.Authorized` is a
628
+ * deferred (subquery-bearing) check, so it fires at this statement's own auto-commit.
629
+ *
630
+ * Insert-if-absent, matching {@link registerMemberPeer}'s and {@link revokeMember}'s
631
+ * restart-safe shape: a repeat cancellation logs and returns rather than throwing on the
632
+ * primary key. (Check-then-write is not atomic; the primary key is the real backstop —
633
+ * the guard is for the sequential repeat/restart path.)
634
+ *
635
+ * Cancellation is what makes removal a re-entry gate: it is the `ConsumedInvite` insert
636
+ * that `NotCancelled` blocks, and `Member.Authorized`'s invite branch needs a
637
+ * same-transaction FRESH `ConsumedInvite` row, so a cancelled invitation rolls the whole
638
+ * join back. Un-cancelling is impossible (`CancelledInvite.Immutable`) — to re-invite a
639
+ * party, {@link issueInvite} a fresh invitation.
640
+ *
641
+ * Enumerate what there is to cancel with {@link listOutstandingInvites}: an invitation
642
+ * names no invitee, so the strand cannot tell a manager WHICH invitations a departing
643
+ * member holds. Invitee binding is tracked as `feat-strand-invitee-bound-invites`.
644
+ *
645
+ * @param db - The closed strand's database.
646
+ * @param params - The cancelling manager's keypair and the invite key to kill.
647
+ * @throws If `CancelledInvite.Authorized` rejects (a non-manager, a manager seated in
648
+ * this transaction, or an approval minted for a different invite key) or `OnlyClosed`
649
+ * rejects (an open strand); the insert rolls back, leaving no tombstone.
650
+ */
651
+ export async function cancelInvite(db: Database, params: CancelInviteParams): Promise<void> {
652
+ const { managerKeyPair, inviteKey } = params;
653
+
654
+ if ((await scanInviteKeys(db, 'CancelledInvite')).has(inviteKey)) {
655
+ log('Invite %s already cancelled; skipping', inviteKey);
656
+ return;
657
+ }
658
+
659
+ const signature = signStrandApproval(
660
+ ['Strand.CancelledInvite', 'cancel', inviteKey],
661
+ managerKeyPair.privateKeyB64,
662
+ );
663
+ await db.exec(
664
+ `insert into Strand.CancelledInvite (InviteKey)
665
+ with context ManagerKey = ?, ManagerSignature = ?
666
+ values (?)`,
667
+ [managerKeyPair.publicKeyB64, signature, inviteKey],
668
+ );
669
+ log('Cancelled invite %s by manager %s', inviteKey, managerKeyPair.publicKeyB64);
670
+ }
671
+
672
+ /** One redeemable invitation, as reported by {@link listOutstandingInvites}. */
673
+ export interface OutstandingInvite {
674
+ /** The `Invite.Key` (invite public key, base64url). */
675
+ inviteKey: string;
676
+ /**
677
+ * The expiry as the engine's canonical `datetime` string
678
+ * (`YYYY-MM-DDTHH:MM:SS[.frac]`, no zone suffix — see {@link canonicalDatetime}), or
679
+ * `null` for never-expires.
680
+ *
681
+ * Deliberately the STORED form, not the epoch-ms {@link IssueInviteParams.expiration}
682
+ * takes: it is what compares like-for-like against another canonicalised instant, and
683
+ * what the on-engine `NotExpired` gate sees. It is NOT a JS-parseable ISO instant —
684
+ * pass it through the engine (or `canonicalDatetime`) rather than `Date.parse`.
685
+ */
686
+ expiration: string | null;
687
+ }
688
+
689
+ /**
690
+ * The invitations that are still redeemable: neither consumed, nor cancelled, nor
691
+ * expired at `nowMs`.
692
+ *
693
+ * `Strand.Invite` is insert-only and carries no state column, so "outstanding" is not a
694
+ * property of the row — it is the `Invite` set minus the `ConsumedInvite` keys, minus
695
+ * the `CancelledInvite` keys, minus anything whose `Expiration` has passed. All three
696
+ * exclusions mirror gates a consume would hit anyway (`ConsumedInvite`'s primary key,
697
+ * `NotCancelled`, `NotExpired`).
698
+ *
699
+ * Reads via unfiltered scans and compares in JavaScript — see {@link scanInviteKeys} for
700
+ * why a full-PK where-equality is not reliable on a networked strand.
701
+ *
702
+ * Expiry is compared as `expiration > canonicalDatetime(nowMs)`: the same transform
703
+ * {@link issueInvite} uses to STORE `Invite.Expiration`, so the string comparison orders
704
+ * chronologically, and the same strict `>` the on-engine `NotExpired` gate uses (expiry
705
+ * is exclusive — an invitation is dead at its expiry instant, not after it).
706
+ *
707
+ * This is the manager's enumeration side of {@link cancelInvite}, and the "door is open"
708
+ * pre-flight behind `StrandMemberVerifier.isAuthorizedToJoin`.
709
+ *
710
+ * @param db - The closed strand's database.
711
+ * @param nowMs - The instant (epoch ms) to compare expiries against; defaults to
712
+ * `Date.now()`. Tests pin it so the comparison is deterministic, mirroring
713
+ * {@link consumeInvite}'s `nowMs` convention.
714
+ * @returns The redeemable invitations, in NO guaranteed order (the scans promise only a
715
+ * superset of the live rows, not a stable sequence — sort by `expiration` if order
716
+ * matters); empty if none.
717
+ */
718
+ export async function listOutstandingInvites(db: Database, nowMs?: number): Promise<OutstandingInvite[]> {
719
+ // Plain runtime Date.now() — the tess Workflow restriction is on scripts, not libs.
720
+ const nowCanonical = await canonicalDatetime(db, nowMs ?? Date.now());
721
+ const consumed = await scanInviteKeys(db, 'ConsumedInvite');
722
+ const cancelled = await scanInviteKeys(db, 'CancelledInvite');
723
+
724
+ const outstanding: OutstandingInvite[] = [];
725
+ for await (const row of db.eval('select Key, Expiration from Strand.Invite')) {
726
+ const inviteKey = row.Key as string;
727
+ const expiration = (row.Expiration as string | null) ?? null;
728
+ if (consumed.has(inviteKey) || cancelled.has(inviteKey)) {
729
+ continue;
730
+ }
731
+ if (expiration != null && expiration <= nowCanonical) {
732
+ continue;
733
+ }
734
+ outstanding.push({ inviteKey, expiration });
735
+ }
736
+ return outstanding;
737
+ }
738
+
739
+ // ── Member admission + removal (manager-admit, revoke, leave) ─────────────────
740
+
741
+ /**
742
+ * The live StampId of the `Strand.Member` row keyed by `memberKey`, or
743
+ * `undefined` if no such row is visible.
744
+ *
745
+ * `Member`'s primary key is the single `Key` column, so ANY where-equality on it
746
+ * is a full-PK predicate — which the optimystic virtual-table module serves via a
747
+ * single-key point lookup that can MISS on a networked strand (the same failure
748
+ * mode {@link scanMemberPeers} documents). So this reads via an UNFILTERED scan
749
+ * and compares the key in JavaScript: correctness depends only on the scan
750
+ * returning a superset of the live rows — the weakest possible assumption about
751
+ * the storage layer.
752
+ */
753
+ async function memberStampId(db: Database, memberKey: string): Promise<string | undefined> {
754
+ for await (const row of db.eval('select Key, StampId from Strand.Member')) {
755
+ if (row.Key === memberKey) {
756
+ return row.StampId as string;
757
+ }
758
+ }
759
+ return undefined;
760
+ }
761
+
762
+ /** Parameters for {@link addMemberByManager}. */
763
+ export interface AddMemberByManagerParams {
764
+ /**
765
+ * The admitting manager's strand keypair. Its `publicKeyB64` must be a
766
+ * `Strand.Manager` row; it signs the add-tagged digest over the new member key
767
+ * and its fresh stamp.
768
+ */
769
+ managerKeyPair: Ed25519KeyPair;
770
+ /** The joining member's ed25519 PUBLIC key (base64url) — the new `Member.Key`. */
771
+ memberKey: string;
772
+ }
773
+
774
+ /**
775
+ * Admit a `Member` directly by manager signature — the sibling of the invite
776
+ * path on `Member.Authorized`'s direct-manager branch.
777
+ *
778
+ * The constraint verifies the add-tagged digest
779
+ * `digest('Strand.Member', 'add', new.Key, new.StampId)` against a pre-existing
780
+ * (`committed.Manager`) row matching `context.ManagerKey`. The stamp is minted
781
+ * fresh here and bound inside the signed digest, so the approval seats exactly
782
+ * this incarnation: once the member is removed (retiring the stamp), the captured
783
+ * approval can never re-seat it. No `ConsumedInvite` is involved: this is the
784
+ * path a manager uses to seat a member it already trusts (e.g. a manager-side
785
+ * enrollment that admits a party already authorised out-of-band).
786
+ *
787
+ * @param db - The closed strand's database.
788
+ * @param params - The admitting manager keypair and the new member key.
789
+ * @throws If `Member.Authorized` rejects (e.g. a non-manager key); the insert
790
+ * rolls back, leaving no `Member` row.
791
+ */
792
+ export async function addMemberByManager(db: Database, params: AddMemberByManagerParams): Promise<void> {
793
+ const { managerKeyPair, memberKey } = params;
794
+ const stampId = generateStrandStampId();
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
+ );
802
+ log('Admitted member %s by manager %s', memberKey, managerKeyPair.publicKeyB64);
803
+ }
804
+
805
+ /** Parameters for {@link revokeMember}. */
806
+ export interface RevokeMemberParams {
807
+ /**
808
+ * The revoking manager's strand keypair. Its `publicKeyB64` must be a
809
+ * PRE-EXISTING `Strand.Manager` row (the manager-removal branch of
810
+ * `Member.Authorized` reads `committed.Manager` — a manager seated in the same
811
+ * transaction cannot authorize); it signs the remove-tagged digest over the
812
+ * target row's key and live stamp, and files the accompanying tombstone.
813
+ */
814
+ managerKeyPair: Ed25519KeyPair;
815
+ /** The `Member.Key` row to delete. */
816
+ memberKey: string;
817
+ }
818
+
819
+ /**
820
+ * Revoke a `Member` on the signature of an existing manager.
821
+ *
822
+ * Reads the target row's live StampId first (a quiet no-op if the row is already
823
+ * absent — a repeated or restarted revocation should not throw), then runs ONE
824
+ * transaction pairing the delete with its `Strand.Revocation` tombstone: the
825
+ * schema's `Member.RevocationRecorded` requires the tombstone in the same
826
+ * transaction, and the tombstone is what permanently retires the stamp so the
827
+ * captured admission approval can never re-seat this incarnation.
828
+ *
829
+ * The manager-removal branch of `Member.Authorized` verifies the remove-tagged
830
+ * digest `digest('Strand.Member', 'remove', old.Key, old.StampId)` against a
831
+ * `committed.Manager` row matching `context.ManagerKey` — distinct from the
832
+ * add-tagged admission payload, and bound to the live stamp, so neither a
833
+ * captured admission nor a captured PRIOR removal (whose stamp differs) replays.
834
+ *
835
+ * A revoked member cannot re-admit itself off the invite it ALREADY SPENT: that
836
+ * `ConsumedInvite` row is stale, and the invite branch requires a
837
+ * same-transaction FRESH consumption. Re-admission normally takes a fresh
838
+ * manager action ({@link addMemberByManager} or a new invite).
839
+ *
840
+ * An UNSPENT invite the revoked party holds is killed EXPLICITLY, by the manager
841
+ * calling {@link cancelInvite} on it (enumerate the candidates with
842
+ * {@link listOutstandingInvites}); a cancelled invitation can never be consumed,
843
+ * and cancellation is permanent. This removal does NOT cascade into cancellation:
844
+ * an invitation names no invitee, so there is nothing to match the removed member
845
+ * against — the strand cannot tell which invitations were meant for it, or whether
846
+ * it holds any. Binding an invitation to its invitee is tracked as
847
+ * `feat-strand-invitee-bound-invites`.
848
+ *
849
+ * A manager must resign its `Manager` row before (or in the same transaction
850
+ * as) losing membership — `Member.NotAManager` rejects un-membering a key that
851
+ * still holds a Manager row. And the strand never drops to zero members
852
+ * (`Member.MinOneMember`, a local-count floor with the cross-node caveat its
853
+ * schema NOTE states).
854
+ *
855
+ * @param db - The closed strand's database.
856
+ * @param params - The revoking manager keypair and the target member key.
857
+ * @throws If `Member.Authorized` rejects (a non-manager or same-transaction
858
+ * signer), `Member.NotAManager` rejects (the target still holds a Manager
859
+ * row), or `Member.MinOneMember` rejects (the removal would empty the member
860
+ * set); the whole delete+tombstone transaction rolls back.
861
+ */
862
+ export async function revokeMember(db: Database, params: RevokeMemberParams): Promise<void> {
863
+ const { managerKeyPair, memberKey } = params;
864
+ const stampId = await memberStampId(db, memberKey);
865
+ if (stampId == null) {
866
+ log('Member %s already absent; skipping revoke', memberKey);
867
+ return;
868
+ }
869
+ const signature = signStrandApproval(['Strand.Member', 'remove', memberKey, stampId], managerKeyPair.privateKeyB64);
870
+ await inStrandTransaction(db, async () => {
871
+ await db.exec(
872
+ `delete from Strand.Member
873
+ with context ManagerKey = ?, ManagerSignature = ?, MemberSignature = null
874
+ where Key = ?`,
875
+ [managerKeyPair.publicKeyB64, signature, memberKey],
876
+ );
877
+ await insertRevocation(db, 'Member', stampId, managerKeyPair);
878
+ });
879
+ log('Revoked member %s by manager %s', memberKey, managerKeyPair.publicKeyB64);
880
+ }
881
+
882
+ /** Parameters for {@link leaveStrand}. */
883
+ export interface LeaveStrandParams {
884
+ /**
885
+ * The departing member's OWN strand keypair. Its `publicKeyB64` is the
886
+ * `Member.Key` row being deleted; it self-signs the leave-tagged digest —
887
+ * the self-departure branch verifies the signature against `old.Key` itself,
888
+ * so only the departing key's holder can produce it — and files the
889
+ * accompanying tombstone (it is still a committed member until this very
890
+ * transaction removes it, which is what `Revocation.Authorized` checks). No
891
+ * manager is involved.
892
+ */
893
+ memberKeyPair: Ed25519KeyPair;
894
+ }
895
+
896
+ /**
897
+ * A member leaves the strand by deleting its own `Member` row.
898
+ *
899
+ * The self-departure branch of `Member.Authorized` verifies
900
+ * `digest('Strand.Member', 'leave', old.Key, old.StampId)` against `old.Key`
901
+ * itself via `context.MemberSignature`. The `'leave'` tag (vs the manager
902
+ * branch's `'remove'`) is belt-and-braces only — the two branches verify against
903
+ * different keys; the anti-replay fix is the stamp binding. Like
904
+ * {@link revokeMember}, the live stamp is read first (absent row → quiet no-op)
905
+ * and the delete pairs with its `Strand.Revocation` tombstone in one transaction.
906
+ *
907
+ * The same floors as {@link revokeMember} apply: a manager must resign first
908
+ * (`NotAManager`) and the last member cannot leave (`MinOneMember`).
909
+ *
910
+ * @param db - The closed strand's database.
911
+ * @param params - The departing member's own keypair.
912
+ * @throws If `Member.Authorized`, `Member.NotAManager`, or `Member.MinOneMember`
913
+ * rejects; the whole delete+tombstone transaction rolls back.
914
+ */
915
+ export async function leaveStrand(db: Database, params: LeaveStrandParams): Promise<void> {
916
+ const { memberKeyPair } = params;
917
+ const memberKey = memberKeyPair.publicKeyB64;
918
+ const stampId = await memberStampId(db, memberKey);
919
+ if (stampId == null) {
920
+ log('Member %s already absent; skipping leave', memberKey);
921
+ return;
922
+ }
923
+ const signature = signStrandApproval(['Strand.Member', 'leave', memberKey, stampId], memberKeyPair.privateKeyB64);
924
+ await inStrandTransaction(db, async () => {
925
+ await db.exec(
926
+ `delete from Strand.Member
927
+ with context ManagerKey = null, ManagerSignature = null, MemberSignature = ?
928
+ where Key = ?`,
929
+ [signature, memberKey],
930
+ );
931
+ await insertRevocation(db, 'Member', stampId, memberKeyPair);
932
+ });
933
+ log('Member %s left the strand', memberKey);
934
+ }
935
+
936
+ // ── MemberPeer registration (a member binds its own network nodes) ─────────────
937
+
938
+ /** Parameters for {@link registerMemberPeer}. */
939
+ export interface RegisterMemberPeerParams {
940
+ /**
941
+ * The member's OWN strand keypair (`{ privateKeyB64, publicKeyB64 }`). Its
942
+ * `publicKeyB64` becomes `MemberPeer.MemberKey` and it self-signs the binding —
943
+ * `MemberPeer.Authorized` verifies the signature against `MemberKey` itself, so a
944
+ * peer can only be registered by the very member it belongs to (no manager
945
+ * involved). The founder passes its `strandMemberKeyPair`; an invited member
946
+ * passes its own keypair. Typed as {@link Ed25519KeyPair} only for the shared
947
+ * base64url keypair shape — no manager privilege is implied.
948
+ */
949
+ memberKeyPair: Ed25519KeyPair;
950
+ /** The peer/node id (libp2p peer id string) to associate with the member. */
951
+ peerId: string;
952
+ }
953
+
954
+ /**
955
+ * Register a network node (`PeerId`) as acting on behalf of a member.
956
+ *
957
+ * The member self-signs the add-tagged digest
958
+ * `digest('Strand.MemberPeer', 'add', new.MemberKey, new.PeerId, new.StampId)`
959
+ * with its OWN key; the schema's `MemberPeer.Authorized` verifies that signature
960
+ * against `new.MemberKey` — i.e. the member key itself — so only the member that
961
+ * owns the key can register peers for it. The stamp is minted fresh here and
962
+ * bound inside the digest, so a captured registration approval neither
963
+ * re-registers a cleared binding (its stamp is retired) nor authorizes a removal
964
+ * (wrong tag). A deferred `MemberExists` additionally requires the `Member` row
965
+ * to already exist, so a peer for a non-member is rejected at commit.
966
+ *
967
+ * Insert-if-absent: a re-register on restart (or a redundant call) is a no-op. The
968
+ * platform DOES reject a duplicate-PK insert, but a restart-safe re-register should
969
+ * succeed quietly rather than throw, so the writer guards on existence instead of
970
+ * catching. The guard ({@link memberPeerStampId}) scans the member's peers and compares
971
+ * both key columns in JavaScript rather than seeking the composite primary key — see
972
+ * {@link scanMemberPeers} for why a full-PK point lookup is not reliable on a networked
973
+ * strand. A member may register multiple DISTINCT `PeerId`s (multi-device); each is its
974
+ * own row (and its own stamp) under the same `MemberKey`.
975
+ *
976
+ * Peer DELETION is signature-checked too, not rejected outright: `Authorized` carries
977
+ * distinct delete branches (self + manager), each over its own action-tagged digest
978
+ * bound to the row's stamp. Both removal paths live in {@link removeMemberPeer}.
979
+ * UPDATE is refused outright by `NoUpdate`: a re-binding is a remove plus a fresh
980
+ * register (under a fresh stamp).
981
+ *
982
+ * @param db - The closed strand's database (the member already exists).
983
+ * @param params - The member's own keypair and the peer id to bind.
984
+ * @throws If `MemberPeer.Authorized`/`MemberExists` rejects (wrong signer, or no
985
+ * matching `Member` row); the insert rolls back, leaving no `MemberPeer` row.
986
+ */
987
+ export async function registerMemberPeer(db: Database, params: RegisterMemberPeerParams): Promise<void> {
988
+ const { memberKeyPair, peerId } = params;
989
+ const memberKey = memberKeyPair.publicKeyB64;
990
+
991
+ if (await memberPeerStampId(db, memberKey, peerId) != null) {
992
+ log('MemberPeer (%s, %s) already present; skipping', memberKey, peerId);
993
+ return;
994
+ }
995
+
996
+ const stampId = generateStrandStampId();
997
+ const signature = signStrandApproval(
998
+ ['Strand.MemberPeer', 'add', memberKey, peerId, stampId],
999
+ memberKeyPair.privateKeyB64,
1000
+ );
1001
+ await db.exec(
1002
+ `insert into Strand.MemberPeer (MemberKey, PeerId, StampId)
1003
+ with context Signature = ?, ManagerKey = null, ManagerSignature = null
1004
+ values (?, ?, ?)`,
1005
+ [signature, memberKey, peerId, stampId],
1006
+ );
1007
+ log('Registered MemberPeer (%s, %s)', memberKey, peerId);
1008
+ }
1009
+
1010
+ /**
1011
+ * Yield every `(PeerId, StampId)` bound to `memberKey`, re-comparing the member key
1012
+ * in JavaScript.
1013
+ *
1014
+ * Deliberately does NOT filter on the full composite primary key. `MemberPeer`'s PK
1015
+ * is `(MemberKey, PeerId)`, so a `where MemberKey = ? and PeerId = ?` predicate puts
1016
+ * an equality on BOTH key columns — which the optimystic virtual-table module reports
1017
+ * as fully handled and serves via a single-key point lookup (one `find` descent), with
1018
+ * the SQL engine adding no filter of its own. On a networked strand that descent is not
1019
+ * reliable: a miss returns zero rows for a row that provably exists.
1020
+ *
1021
+ * Filtering on only the LEADING key column is a partial PK match, which the same module
1022
+ * explicitly declines to handle — it falls through to a table scan and the SQL engine
1023
+ * applies `MemberKey = ?` itself. No seek is involved, so no seek can miss. The member key
1024
+ * is then re-compared here in JavaScript, so correctness depends only on the scan returning
1025
+ * a SUPERSET of the matching rows — the weakest possible assumption about the storage layer.
1026
+ * The `where` clause only trims what crosses into JS — it is NOT pushed down (see the cost
1027
+ * note below) — so it is not a correctness dependency: a dropped or mis-applied predicate
1028
+ * cannot produce a row attributed to the wrong member.
1029
+ */
1030
+ async function* scanMemberPeers(db: Database, memberKey: string): AsyncGenerator<{ peerId: string; stampId: string }> {
1031
+ // NOTE: because the predicate is not pushed down, the storage layer walks the WHOLE
1032
+ // MemberPeer table (every member's rows) per call and the SQL engine filters. Fine at
1033
+ // strand scale; if MemberPeer ever grows large, the fix is a reliable composite-key
1034
+ // seek, not a bigger scan.
1035
+ // NOTE: if a secondary index on MemberPeer.MemberKey is ever added, this query stops
1036
+ // being a scan and becomes an index seek — re-introducing the seek dependency this
1037
+ // shape exists to remove.
1038
+ for await (const row of db.eval(
1039
+ 'select MemberKey, PeerId, StampId from Strand.MemberPeer where MemberKey = ?',
1040
+ [memberKey],
1041
+ )) {
1042
+ if (row.MemberKey === memberKey) {
1043
+ yield { peerId: row.PeerId as string, stampId: row.StampId as string };
1044
+ }
1045
+ }
1046
+ }
1047
+
1048
+ /**
1049
+ * Every `PeerId` currently bound to `memberKey`, in storage order.
1050
+ *
1051
+ * The enumeration side of {@link removeMemberPeer}: a manager cleaning up after a
1052
+ * revocation knows only the departed member's key, never which devices it registered, so
1053
+ * cleanup is `listMemberPeers` then one `removeMemberPeer` per id. Reads only — the rows
1054
+ * it returns may name a `MemberKey` with no `Member` row (see the `MemberPeer` table NOTE
1055
+ * in the schema), so this is NOT a membership test.
1056
+ *
1057
+ * @param db - The closed strand's database.
1058
+ * @param memberKey - The `MemberPeer.MemberKey` to enumerate.
1059
+ * @returns The bound peer ids, empty if the member has none.
1060
+ */
1061
+ export async function listMemberPeers(db: Database, memberKey: string): Promise<string[]> {
1062
+ const peerIds: string[] = [];
1063
+ for await (const peer of scanMemberPeers(db, memberKey)) {
1064
+ peerIds.push(peer.peerId);
1065
+ }
1066
+ return peerIds;
1067
+ }
1068
+
1069
+ /**
1070
+ * The live StampId of the `MemberPeer` row for this `(MemberKey, PeerId)`, or
1071
+ * `undefined` if no such row is visible — doubling as the existence probe.
1072
+ *
1073
+ * NOTE: check-then-write is not atomic. Two nodes registering the same
1074
+ * (MemberKey, PeerId) concurrently can both observe "absent"; the primary key is the
1075
+ * real backstop. The existence half of this guard is for the sequential restart /
1076
+ * re-register path.
1077
+ */
1078
+ async function memberPeerStampId(db: Database, memberKey: string, peerId: string): Promise<string | undefined> {
1079
+ for await (const peer of scanMemberPeers(db, memberKey)) {
1080
+ if (peer.peerId === peerId) {
1081
+ return peer.stampId;
1082
+ }
1083
+ }
1084
+ return undefined;
1085
+ }
1086
+
1087
+ /** Parameters for the SELF branch of {@link removeMemberPeer}. */
1088
+ export interface RemoveOwnPeerParams {
1089
+ /**
1090
+ * The member's OWN strand keypair. Its `publicKeyB64` is the `MemberPeer.MemberKey`
1091
+ * of the row being deleted, and it signs the remove-tagged digest
1092
+ * `digest('Strand.MemberPeer', 'remove', MemberKey, PeerId, StampId)` — the self
1093
+ * branch of `MemberPeer.Authorized` verifies against the row's own member key, so
1094
+ * no OTHER member's row is reachable this way, and the tag + stamp binding keep a
1095
+ * captured registration approval from doubling as a removal (and vice versa).
1096
+ */
1097
+ memberKeyPair: Ed25519KeyPair;
1098
+ /** The `PeerId` of the binding to delete. */
1099
+ peerId: string;
1100
+ }
1101
+
1102
+ /** Parameters for the MANAGER branch of {@link removeMemberPeer}. */
1103
+ export interface RemoveMemberPeerByManagerParams {
1104
+ /**
1105
+ * The clearing manager's strand keypair. Its `publicKeyB64` must be a PRE-EXISTING
1106
+ * `Strand.Manager` row (the manager branch reads `committed.Manager`, so a manager
1107
+ * seated in the same transaction cannot authorize); it signs the
1108
+ * manager-remove-tagged digest over the exact row and its live stamp, and files
1109
+ * the accompanying tombstone.
1110
+ */
1111
+ managerKeyPair: Ed25519KeyPair;
1112
+ /** The `MemberPeer.MemberKey` whose binding is being cleared. */
1113
+ memberKey: string;
1114
+ /** The `PeerId` of the binding to delete. */
1115
+ peerId: string;
1116
+ }
1117
+
1118
+ /**
1119
+ * Either shape accepted by {@link removeMemberPeer}. A discriminated union rather than
1120
+ * one opaque keypair: the two branches sign DIFFERENT action tags (`'remove'` self vs
1121
+ * `'manager-remove'`), so each case carries exactly the fields its payload needs.
1122
+ */
1123
+ export type RemoveMemberPeerParams = RemoveOwnPeerParams | RemoveMemberPeerByManagerParams;
1124
+
1125
+ /**
1126
+ * Delete a `MemberPeer` binding — either the member clearing its own, or a manager
1127
+ * clearing another member's.
1128
+ *
1129
+ * Reads the row's live StampId first (absent → quiet no-op, mirroring
1130
+ * {@link registerMemberPeer}'s insert-if-absent, so a repeated or restarted cleanup
1131
+ * is not a silent zero-row "success" the caller cannot distinguish from a real one),
1132
+ * then runs ONE transaction pairing the delete with its `Strand.Revocation`
1133
+ * tombstone — `MemberPeer.RevocationRecorded` requires it, and the retirement is
1134
+ * what keeps the captured registration approval from re-binding the cleared row.
1135
+ *
1136
+ * `MemberPeer` rows do NOT cascade when the member is revoked (`MemberExists` runs on
1137
+ * insert only, and nothing deletes them), so a removed member's peer bindings survive
1138
+ * as orphans — enumerate them with {@link listMemberPeers}, which is how a manager
1139
+ * discovers what to clear. Only that member can sign the self branch, and a member being
1140
+ * removed against its will has no reason to cooperate — hence the manager branch, which
1141
+ * is the cleanup path after a revocation.
1142
+ *
1143
+ * The manager branch is deliberately NOT gated on the member already being gone: a
1144
+ * manager may clear a still-present member's binding too, matching "any manager can
1145
+ * remove any member". It verifies the manager-remove-tagged
1146
+ * `digest('Strand.MemberPeer', 'manager-remove', old.MemberKey, old.PeerId, old.StampId)`
1147
+ * against a `committed.Manager` row; the self branch verifies the remove-tagged
1148
+ * sibling against the row's own member key.
1149
+ *
1150
+ * Cleanup is an explicit follow-up call by the revoking manager, never a cascade inside
1151
+ * {@link revokeMember}: a revocation that also had to clear an unbounded number of peer
1152
+ * rows in the same transaction would couple two concerns and make its failure modes
1153
+ * harder to reason about.
1154
+ *
1155
+ * @param db - The closed strand's database.
1156
+ * @param params - Either the owning member's keypair, or a manager's keypair plus the
1157
+ * target member key.
1158
+ * @throws If `MemberPeer.Authorized` rejects (neither branch satisfied), or if the row
1159
+ * is still present after the delete (see the point-lookup note below).
1160
+ */
1161
+ export async function removeMemberPeer(db: Database, params: RemoveMemberPeerParams): Promise<void> {
1162
+ const memberKey = 'memberKeyPair' in params ? params.memberKeyPair.publicKeyB64 : params.memberKey;
1163
+ const { peerId } = params;
1164
+
1165
+ const stampId = await memberPeerStampId(db, memberKey, peerId);
1166
+ if (stampId == null) {
1167
+ log('MemberPeer (%s, %s) already absent; skipping', memberKey, peerId);
1168
+ return;
1169
+ }
1170
+
1171
+ await inStrandTransaction(db, async () => {
1172
+ if ('memberKeyPair' in params) {
1173
+ await deleteOwnMemberPeer(db, params, stampId);
1174
+ } else {
1175
+ await deleteMemberPeerByManager(db, params, stampId);
1176
+ }
1177
+ });
1178
+
1179
+ // NOTE: the delete's `where` puts an equality on BOTH composite-PK columns, which the
1180
+ // optimystic vtab module serves via a single-key seek that can MISS on a networked
1181
+ // strand (see scanMemberPeers' doc). A missed seek deletes zero rows and still
1182
+ // reports success. `Revocation.RowIsGone` now catches that first — the tombstone filed
1183
+ // in the same transaction refuses to retire a stamp whose row is still visible, so the
1184
+ // commit above fails loudly and this re-check is belt-and-braces behind it. Tracked as
1185
+ // `debt-composite-pk-point-lookup-unreliable-untracked`; this is an availability
1186
+ // failure mode, never a security one (a miss removes nothing, it never over-removes).
1187
+ if (await memberPeerStampId(db, memberKey, peerId) != null) {
1188
+ throw new Error(`MemberPeer (${memberKey}, ${peerId}) still present after delete`);
1189
+ }
1190
+ log('Removed MemberPeer (%s, %s)', memberKey, peerId);
1191
+ }
1192
+
1193
+ /** The self branch: the owning member signs the remove-tagged digest + tombstone. */
1194
+ async function deleteOwnMemberPeer(db: Database, params: RemoveOwnPeerParams, stampId: string): Promise<void> {
1195
+ const { memberKeyPair, peerId } = params;
1196
+ const memberKey = memberKeyPair.publicKeyB64;
1197
+ const signature = signStrandApproval(
1198
+ ['Strand.MemberPeer', 'remove', memberKey, peerId, stampId],
1199
+ memberKeyPair.privateKeyB64,
1200
+ );
1201
+ await db.exec(
1202
+ `delete from Strand.MemberPeer
1203
+ with context Signature = ?, ManagerKey = null, ManagerSignature = null
1204
+ where MemberKey = ? and PeerId = ?`,
1205
+ [signature, memberKey, peerId],
1206
+ );
1207
+ await insertRevocation(db, 'MemberPeer', stampId, memberKeyPair);
1208
+ }
1209
+
1210
+ /** The manager branch: a manager signs the manager-remove-tagged digest + tombstone. */
1211
+ async function deleteMemberPeerByManager(
1212
+ db: Database,
1213
+ params: RemoveMemberPeerByManagerParams,
1214
+ stampId: string,
1215
+ ): Promise<void> {
1216
+ const { managerKeyPair, memberKey, peerId } = params;
1217
+ const signature = signStrandApproval(
1218
+ ['Strand.MemberPeer', 'manager-remove', memberKey, peerId, stampId],
1219
+ managerKeyPair.privateKeyB64,
1220
+ );
1221
+ await db.exec(
1222
+ `delete from Strand.MemberPeer
1223
+ with context Signature = null, ManagerKey = ?, ManagerSignature = ?
1224
+ where MemberKey = ? and PeerId = ?`,
1225
+ [managerKeyPair.publicKeyB64, signature, memberKey, peerId],
1226
+ );
1227
+ await insertRevocation(db, 'MemberPeer', stampId, managerKeyPair);
1228
+ }
1229
+
1230
+ // ── Manager rotation (add / remove RBAC admins) ───────────────────────────────
1231
+
1232
+ /**
1233
+ * The live `(Generation, StampId)` of the `Strand.Manager` row keyed by
1234
+ * `memberKey`, or `undefined` if no such row is visible.
1235
+ *
1236
+ * `Manager`'s primary key is the single `MemberKey` column, so any where-equality
1237
+ * on it is a full-PK point lookup — unreliable on a networked strand (see
1238
+ * {@link scanMemberPeers}). An unfiltered scan + JavaScript filter instead, same
1239
+ * as {@link memberStampId}. Serves both {@link addManager} (the authorizer's
1240
+ * generation) and {@link removeManager} (the target's stamp).
1241
+ */
1242
+ async function managerRow(db: Database, memberKey: string): Promise<{ generation: number; stampId: string } | undefined> {
1243
+ for await (const row of db.eval('select MemberKey, Generation, StampId from Strand.Manager')) {
1244
+ if (row.MemberKey === memberKey) {
1245
+ return { generation: Number(row.Generation), stampId: row.StampId as string };
1246
+ }
1247
+ }
1248
+ return undefined;
1249
+ }
1250
+
1251
+ /** Parameters for {@link addManager}. */
1252
+ export interface AddManagerParams {
1253
+ /**
1254
+ * An EXISTING manager's strand keypair. Its `publicKeyB64` must already be a
1255
+ * `Strand.Manager` row (the existing-manager branch of `Manager.Authorized`
1256
+ * rejects a non-manager once the founder manager exists); it signs the
1257
+ * add-tagged digest over the new manager key, generation, and fresh stamp,
1258
+ * and is bound as `context.ManagerKey`.
1259
+ */
1260
+ byManagerKeyPair: Ed25519KeyPair;
1261
+ /** The member key to promote — the new `Manager.MemberKey` row. */
1262
+ newManagerKey: string;
1263
+ }
1264
+
1265
+ /**
1266
+ * Promote a key that is ALREADY a `Strand.Member` to `Manager` on the signature of
1267
+ * an existing manager — use {@link admitManager} to admit and promote atomically.
1268
+ *
1269
+ * The promotion branch of `Manager.Authorized` requires the authorizer to be a
1270
+ * `Manager` row of STRICTLY SMALLER `Generation` than the new row, verifying
1271
+ * `digest('Strand.Manager', 'add', new.MemberKey, new.Generation, new.StampId)`
1272
+ * against `context.ManagerKey`. So this writer reads the authorizer's generation,
1273
+ * seats the new manager at that value + 1 (the natural successor; the schema
1274
+ * enforces only the strict ordering), mints a fresh stamp, and signs the
1275
+ * add-tagged digest over all three: the generation inside the signed digest keeps
1276
+ * a captured promotion from replaying at a different generation, and the stamp
1277
+ * keeps it from re-seating a second incarnation once this one is removed.
1278
+ * `new.Generation` is an SQL INTEGER digest arg — the digest framing is
1279
+ * type-tagged, so the TS side signs over the NUMBER itself
1280
+ * (see {@link signStrandApproval}).
1281
+ *
1282
+ * The strict ordering — not this writer — is what makes a same-transaction takeover
1283
+ * impossible; the `Manager.Generation` column comment in `schemas/strand.qsql` carries
1284
+ * that argument. All this writer owes it is a generation strictly above the
1285
+ * authorizer's, inside the signed payload.
1286
+ *
1287
+ * When the authorizer has NO `Manager` row (a non-manager signer, or an open
1288
+ * strand with no managers at all), the scan finds nothing and the writer falls
1289
+ * back to generation 1 and issues the insert anyway — deliberately letting the
1290
+ * SCHEMA be the rejector (`Manager.Authorized` / `OnlyClosed`), not a
1291
+ * writer-thrown error, so enforcement is pinned where it actually lives.
1292
+ *
1293
+ * @param db - The closed strand's database (founder manager already seated).
1294
+ * @param params - The authorizing manager's keypair and the new manager key.
1295
+ * @throws If `Manager.Authorized` rejects, or if `Manager.MemberExists` rejects because
1296
+ * `newManagerKey` holds no `Member` row (admit it first, or use
1297
+ * {@link admitManager}); the insert rolls back.
1298
+ */
1299
+ export async function addManager(db: Database, params: AddManagerParams): Promise<void> {
1300
+ const { byManagerKeyPair, newManagerKey } = params;
1301
+ const authorizer = await managerRow(db, byManagerKeyPair.publicKeyB64);
1302
+ // NOTE: +1 saturates at Number.MAX_SAFE_INTEGER, where the successor compares equal
1303
+ // to its authorizer and the schema rejects. Unreachable while generations only ever
1304
+ // grow by 1 from 0, but the schema enforces ordering, not adjacency, so a manager may
1305
+ // seat a successor at any larger value; if arbitrary generations ever become writable
1306
+ // from outside this function, clamp or reject here rather than emitting a dead row.
1307
+ const generation = authorizer == null ? 1 : authorizer.generation + 1;
1308
+ const stampId = generateStrandStampId();
1309
+ const signature = signStrandApproval(
1310
+ ['Strand.Manager', 'add', newManagerKey, generation, stampId],
1311
+ byManagerKeyPair.privateKeyB64,
1312
+ );
1313
+ await db.exec(
1314
+ `insert into Strand.Manager (MemberKey, Generation, StampId)
1315
+ with context ManagerKey = ?, Signature = ?
1316
+ values (?, ?, ?)`,
1317
+ [byManagerKeyPair.publicKeyB64, signature, newManagerKey, generation, stampId],
1318
+ );
1319
+ log('Added manager %s (generation %d) by %s', newManagerKey, generation, byManagerKeyPair.publicKeyB64);
1320
+ }
1321
+
1322
+ /** Parameters for {@link admitManager} — the same shape as {@link AddManagerParams}. */
1323
+ export type AdmitManagerParams = AddManagerParams;
1324
+
1325
+ /**
1326
+ * Admit a key as a `Member` AND promote it to `Manager`, atomically — the one-step
1327
+ * pair for a key that is not in the strand yet. {@link addManager} alone promotes a
1328
+ * key that is already a member.
1329
+ *
1330
+ * ONE transaction, because `Manager.MemberExists` is a deferred check reading the LIVE
1331
+ * `Member` table: the sibling `Member` insert satisfies it at the shared commit, and a
1332
+ * rejection of either half rolls BOTH rows back — never a `Member` row seated by a
1333
+ * failed promotion, nor a `Manager` row with no member behind it.
1334
+ *
1335
+ * NO new authority: both halves are signed by the SAME manager, over two distinct
1336
+ * action-tagged digests — `digest('Strand.Member', 'add', …)` for the admission and
1337
+ * `digest('Strand.Manager', 'add', …)` for the promotion — each bound to its own row's
1338
+ * fresh stamp. This composes exactly the two approvals that manager could already
1339
+ * issue separately.
1340
+ *
1341
+ * The promoting manager must be a PRE-transaction manager: the direct-admit branch of
1342
+ * `Member.Authorized` reads `committed.Manager`, so `admitManager` cannot be chained —
1343
+ * a manager seated by an `admitManager` earlier in the SAME transaction cannot admit
1344
+ * the next one. (A same-transaction promotion CHAIN rooted at a pre-existing manager
1345
+ * is fine; only the `Member` half needs the committed authorizer.)
1346
+ *
1347
+ * NOT insert-if-absent: a repeat call for a key that is already a member fails on the
1348
+ * `Member` primary key, matching {@link addMemberByManager}'s unguarded shape.
1349
+ *
1350
+ * @param db - The closed strand's database (founder manager already seated).
1351
+ * @param params - The authorizing manager's keypair and the key to admit + promote.
1352
+ * @throws If either half is rejected (`Member.Authorized`, `Manager.Authorized`, a
1353
+ * duplicate `Member` key, …); the whole transaction rolls back, leaving neither row.
1354
+ */
1355
+ export async function admitManager(db: Database, params: AdmitManagerParams): Promise<void> {
1356
+ const { byManagerKeyPair, newManagerKey } = params;
1357
+ await inStrandTransaction(db, async () => {
1358
+ await addMemberByManager(db, { managerKeyPair: byManagerKeyPair, memberKey: newManagerKey });
1359
+ await addManager(db, { byManagerKeyPair, newManagerKey });
1360
+ });
1361
+ log('Admitted + promoted manager %s by %s', newManagerKey, byManagerKeyPair.publicKeyB64);
1362
+ }
1363
+
1364
+ /** Parameters for {@link removeManager}. */
1365
+ export interface RemoveManagerParams {
1366
+ /**
1367
+ * The keypair authorizing the removal, bound as `context.ManagerKey`. For an
1368
+ * ADMIN removal this is a DIFFERENT existing manager, signing the
1369
+ * remove-tagged digest (satisfying the existing-manager branch); for a
1370
+ * SELF-resignation it is the target's OWN keypair (`publicKeyB64 ===
1371
+ * targetManagerKey`), signing the resign-tagged digest (satisfying the
1372
+ * former-manager self branch). The writer picks the tag by comparing the keys —
1373
+ * see {@link removeManager}. Either way this keypair also files the
1374
+ * accompanying tombstone, which requires it to be a PRE-transaction MEMBER
1375
+ * (`Revocation.Authorized` reads `committed.Member`) — true for every manager,
1376
+ * since managers are members.
1377
+ */
1378
+ byManagerKeyPair: Ed25519KeyPair;
1379
+ /** The `Manager.MemberKey` row to delete. */
1380
+ targetManagerKey: string;
1381
+ }
1382
+
1383
+ /**
1384
+ * Remove a `Manager` row — either an admin removing a different manager or a
1385
+ * manager resigning itself.
1386
+ *
1387
+ * Reads the target row's live `(Generation, StampId)` first (absent → quiet
1388
+ * no-op, same restart-safe shape as {@link revokeMember}), then runs ONE
1389
+ * transaction pairing the delete with its `Strand.Revocation` tombstone
1390
+ * (`Manager.RevocationRecorded`). The two delete-side branches of
1391
+ * `Manager.Authorized` verify DIFFERENT action-tagged digests, each bound to
1392
+ * `(old.MemberKey, old.StampId)`:
1393
+ * - **Admin removal** (`byManagerKeyPair` is a different existing manager): the
1394
+ * removal branch verifies `digest('Strand.Manager', 'remove', old.MemberKey,
1395
+ * old.StampId)` against `A.MemberKey = context.ManagerKey`. Deliberately NO
1396
+ * generation condition: a later-generation manager may remove an
1397
+ * earlier-generation one (generation is lineage, not privilege), and deletes
1398
+ * are safe once inserts are — every accepting branch requires a `Manager` row
1399
+ * in the post-image, and the promotion branch's generation ordering keeps
1400
+ * attacker rows out of it. The stamp binding is what makes a captured removal
1401
+ * single-use: a re-promoted manager carries a fresh stamp the old approval
1402
+ * does not match — closing the removal half of
1403
+ * `bug-strand-manager-authority-antireplay` (the insert half was already
1404
+ * closed by the generation-bearing promotion payload).
1405
+ * - **Self-resignation** (`byManagerKeyPair` IS the target): the former-manager
1406
+ * self branch verifies `digest('Strand.Manager', 'resign', old.MemberKey,
1407
+ * old.StampId)` against `old.MemberKey` itself. The distinct `'resign'` tag is
1408
+ * belt-and-braces (different verifying keys); the anti-replay fix is the stamp.
1409
+ *
1410
+ * The caller selects the case purely by which keypair it passes; the only
1411
+ * branching here is the action tag, chosen by comparing the signer to the target.
1412
+ *
1413
+ * Deferred (subquery-bearing) CHECK constraints are evaluated on DELETE as well
1414
+ * as INSERT by Quereus plus the Optimystic vtab session — enforcement lives above
1415
+ * the transactor (proven on the network transactor by
1416
+ * `strand-membership-network-transactor-parity.spec.ts`), so `Manager.Authorized`
1417
+ * — deferred — IS enforced here: a signer that is neither an existing manager nor
1418
+ * the target itself is rejected at commit.
1419
+ *
1420
+ * THE SOLE MANAGER CANNOT ORDINARILY RESIGN — it SEALS instead. The `'resign'`
1421
+ * branch of `Manager.Authorized` requires at least one manager in the POST-delete
1422
+ * image, so a resignation that would empty the `Manager` table is schema-rejected;
1423
+ * emptying it deliberately is a distinct, self-signed act — {@link sealStrand} —
1424
+ * that permanently freezes admission. This writer refuses the sole-manager
1425
+ * self-resignation up front with an error naming `sealStrand`; that TS guard is UX,
1426
+ * not the trust boundary — the schema rejects a mis-counted `'resign'` approval
1427
+ * regardless. Reading the count inside a caller-joined transaction is correct: an
1428
+ * add-then-resign hand-off composed in one transaction sees 2 and passes, while
1429
+ * the already-rejected delete-then-add swap sees 1 and is refused here as well as
1430
+ * by the schema. The bootstrap branch of `Manager.Authorized` is gated to INSERTs
1431
+ * (`old.MemberKey is null`), so it does not waive the signature check on a delete
1432
+ * that drops the count toward zero: a second-to-last removal is authorized
1433
+ * exactly like any other.
1434
+ *
1435
+ * HAND-OFF ORDER: a sole manager transferring control must ADD the successor FIRST
1436
+ * and resign SECOND. A same-transaction delete-then-insert swap is rejected — the
1437
+ * bootstrap branch is gated to the founding state (at most one `Member`), and the
1438
+ * successor's insert has no other manager to authorize it once the sole manager's
1439
+ * row is gone.
1440
+ *
1441
+ * Cross-node caveat: the `'resign'`/`'seal'` branches count rows visible to THIS
1442
+ * transaction, so two nodes concurrently removing different managers can each see
1443
+ * a survivor and still converge to zero — an unintended seal. Noted in the schema
1444
+ * (the `Manager` table's seal-propagation NOTE); a cross-node floor is not
1445
+ * attempted here.
1446
+ *
1447
+ * @param db - The closed strand's database.
1448
+ * @param params - The authorizing keypair and the target manager key.
1449
+ * @throws If the signer IS the target and it is the sole manager (before writing —
1450
+ * the operation the caller wants is {@link sealStrand}), or if `Manager.Authorized`
1451
+ * rejects (a signer that is neither another existing manager nor the target
1452
+ * itself, or a raw sole-manager `'resign'` whose post-image count is zero); the
1453
+ * whole delete+tombstone transaction rolls back.
1454
+ */
1455
+ export async function removeManager(db: Database, params: RemoveManagerParams): Promise<void> {
1456
+ const { byManagerKeyPair, targetManagerKey } = params;
1457
+ const target = await managerRow(db, targetManagerKey);
1458
+ if (target == null) {
1459
+ log('Manager %s already absent; skipping removal', targetManagerKey);
1460
+ return;
1461
+ }
1462
+ const tag = byManagerKeyPair.publicKeyB64 === targetManagerKey ? 'resign' : 'remove';
1463
+ if (tag === 'resign' && await strandTableCount(db, 'Manager') === 1) {
1464
+ throw new Error(
1465
+ 'Cannot resign as the SOLE manager: an ordinary resignation never empties the '
1466
+ + 'Manager table. To permanently freeze admission instead, call sealStrand.',
1467
+ );
1468
+ }
1469
+ const signature = signStrandApproval(
1470
+ ['Strand.Manager', tag, targetManagerKey, target.stampId],
1471
+ byManagerKeyPair.privateKeyB64,
1472
+ );
1473
+ await inStrandTransaction(db, async () => {
1474
+ await db.exec(
1475
+ `delete from Strand.Manager
1476
+ with context ManagerKey = ?, Signature = ?
1477
+ where MemberKey = ?`,
1478
+ [byManagerKeyPair.publicKeyB64, signature, targetManagerKey],
1479
+ );
1480
+ await insertRevocation(db, 'Manager', target.stampId, byManagerKeyPair);
1481
+ });
1482
+ log('Removed manager %s by %s', targetManagerKey, byManagerKeyPair.publicKeyB64);
1483
+ }
1484
+
1485
+ // ── Sealing (the sole manager permanently freezes admission) ──────────────────
1486
+
1487
+ /** Parameters for {@link sealStrand}. */
1488
+ export interface SealStrandParams {
1489
+ /**
1490
+ * The SOLE manager's strand keypair. Its `publicKeyB64` must be the one live
1491
+ * `Strand.Manager` row; it signs the seal-tagged digest over its own key and
1492
+ * live stamp, and files the accompanying tombstone (it is still a committed
1493
+ * member, which is what `Revocation.Authorized` checks).
1494
+ */
1495
+ managerKeyPair: Ed25519KeyPair;
1496
+ }
1497
+
1498
+ /**
1499
+ * Seal the strand: the SOLE manager deliberately steps down, permanently freezing
1500
+ * membership.
1501
+ *
1502
+ * A sealed strand has zero `Manager` rows, and every admission path — issuing or
1503
+ * cancelling an invitation, consuming one issued BEFORE the seal
1504
+ * (`ConsumedInvite.NotSealed`), admitting a member, promoting a manager — requires
1505
+ * one, so nobody can ever be let in again. That freeze is a privacy guarantee to
1506
+ * everyone who contributed: no key holds the power to admit a party who would then
1507
+ * read the strand's whole history. Sealing is irreversible — the founding branch
1508
+ * of `Manager.Authorized` closes for good once any manager stamp is retired into
1509
+ * `Strand.Revocation`. What remains possible: members may {@link leaveStrand}
1510
+ * (floored by `MinOneMember`), manage their own `MemberPeer` rows, and file
1511
+ * `Revocation` tombstones.
1512
+ *
1513
+ * The `'seal'` branch of `Manager.Authorized` accepts a self-signed delete only
1514
+ * when the POST-image manager count is ZERO — a distinct action tag from
1515
+ * `'resign'`, so a captured resignation approval can never seal and a raw writer
1516
+ * cannot seal by accident. Like {@link removeManager}, the delete pairs with its
1517
+ * `Strand.Revocation` tombstone in one transaction.
1518
+ *
1519
+ * The TS count/identity checks below are UX guards, not the trust boundary — the
1520
+ * schema rejects a mis-tagged or mis-counted approval regardless of what this
1521
+ * writer (or any raw writer) presents.
1522
+ *
1523
+ * NOTE: a seal approval is a bearer token for its row incarnation, like every other
1524
+ * self-signed approval here — but the `'seal'` branch's post-image count of 0 is
1525
+ * satisfied for EVERY row deleted in the same transaction, so N managers each
1526
+ * presenting a valid `'seal'` approval seal jointly (pinned as an accepted case in
1527
+ * `strand-seal.spec.ts`). Unreachable today: this writer mints a seal signature only
1528
+ * while its caller is the SOLE manager, and spends it immediately, so a second
1529
+ * manager never has one to combine. If seal-approval MINTING is ever split from
1530
+ * spending — offline signing, a two-phase seal, a queued approval — one manager
1531
+ * holding another's unspent seal approval could freeze the strand irreversibly
1532
+ * without its current consent; re-gate here (or narrow the schema branch) then.
1533
+ *
1534
+ * @param db - The closed strand's database.
1535
+ * @param params - The sole manager's own keypair.
1536
+ * @throws If more than one manager exists (the caller wants {@link removeManager}),
1537
+ * or the supplied keypair holds no `Manager` row while one exists, or the strand
1538
+ * holds no `Manager` row and has never retired one (not founded yet — see
1539
+ * {@link isStrandSealed}), or any schema constraint rejects; the whole
1540
+ * delete+tombstone transaction rolls back. A strand that is ALREADY sealed logs
1541
+ * and returns quietly (restart-safe, matching {@link bootstrapFounderMembership}
1542
+ * and {@link removeManager}'s absent-row no-op).
1543
+ */
1544
+ export async function sealStrand(db: Database, params: SealStrandParams): Promise<void> {
1545
+ const { managerKeyPair } = params;
1546
+ const managerCount = await strandTableCount(db, 'Manager');
1547
+ if (managerCount === 0) {
1548
+ // Zero managers is NOT by itself a seal — a closed strand between its Header
1549
+ // and Manager founding inserts looks identical. The retired `Manager` stamp is
1550
+ // what separates "frozen forever" from "still foundable"; returning quietly on
1551
+ // the latter would report a seal that never happened.
1552
+ if (await strandHasManagerRevocation(db)) {
1553
+ log('Strand already sealed (no Manager rows, manager stamp retired); skipping seal');
1554
+ return;
1555
+ }
1556
+ throw new Error(
1557
+ 'Cannot seal: the strand holds no Manager row and has never retired one — it is '
1558
+ + 'not founded yet, not sealed. Bootstrap the founding manager first.',
1559
+ );
1560
+ }
1561
+ if (managerCount > 1) {
1562
+ throw new Error(
1563
+ `Cannot seal: ${managerCount} managers exist and sealing is the SOLE manager's act. `
1564
+ + 'The other managers must step down first (removeManager).',
1565
+ );
1566
+ }
1567
+ const self = await managerRow(db, managerKeyPair.publicKeyB64);
1568
+ if (self == null) {
1569
+ throw new Error('Cannot seal: the supplied keypair does not hold the sole Manager row.');
1570
+ }
1571
+ const signature = signStrandApproval(
1572
+ ['Strand.Manager', 'seal', managerKeyPair.publicKeyB64, self.stampId],
1573
+ managerKeyPair.privateKeyB64,
1574
+ );
1575
+ await inStrandTransaction(db, async () => {
1576
+ await db.exec(
1577
+ `delete from Strand.Manager
1578
+ with context ManagerKey = ?, Signature = ?
1579
+ where MemberKey = ?`,
1580
+ [managerKeyPair.publicKeyB64, signature, managerKeyPair.publicKeyB64],
1581
+ );
1582
+ await insertRevocation(db, 'Manager', self.stampId, managerKeyPair);
1583
+ });
1584
+ log('Sealed strand: sole manager %s stepped down', managerKeyPair.publicKeyB64);
1585
+ }
1586
+
1587
+ /**
1588
+ * Whether the strand is `Type = 'c'` (closed). Reads the singleton `Header` via
1589
+ * the same `db.eval` scan idiom as {@link strandTableCount}; a strand with no
1590
+ * `Header` row yet reports `false`.
1591
+ */
1592
+ async function strandIsClosed(db: Database): Promise<boolean> {
1593
+ for await (const row of db.eval('select Type from Strand.Header')) {
1594
+ return row.Type === 'c';
1595
+ }
1596
+ return false;
1597
+ }
1598
+
1599
+ /**
1600
+ * Whether the strand is sealed: a CLOSED strand that holds no `Manager` rows AND
1601
+ * has retired at least one `Manager` stamp into `Strand.Revocation`.
1602
+ *
1603
+ * All three conjuncts are load-bearing:
1604
+ * - `Header.Type` — an OPEN strand never holds `Manager` rows at all
1605
+ * (`Manager.OnlyClosed`), so a bare manager-count test would report every open
1606
+ * strand as sealed.
1607
+ * - the manager count — sealing IS the empty `Manager` table.
1608
+ * - the retired `Manager` stamp — "sealed" means admission is frozen FOREVER, and
1609
+ * what makes it permanent is the founding branch of `Manager.Authorized`
1610
+ * refusing to re-seat a generation-0 manager once any `Manager` stamp has been
1611
+ * retired. Without this conjunct a closed strand that is merely NOT FOUNDED YET
1612
+ * reads as sealed: {@link bootstrapFounderMembership} commits `Header`,
1613
+ * `Member` and `Manager` as three sequential statements, so the window between
1614
+ * the first and the last is exactly "closed, zero managers, still foundable".
1615
+ *
1616
+ * Reads locally visible rows — a node that has not yet converged on the seal
1617
+ * still reports `false` (the schema's seal-propagation NOTE).
1618
+ *
1619
+ * @param db - The strand's database.
1620
+ * @returns `true` iff the strand is closed, holds zero `Manager` rows, and has
1621
+ * permanently closed its founding branch.
1622
+ */
1623
+ export async function isStrandSealed(db: Database): Promise<boolean> {
1624
+ return await strandIsClosed(db)
1625
+ && await strandTableCount(db, 'Manager') === 0
1626
+ && await strandHasManagerRevocation(db);
1627
+ }