@serfab/cadre-core 0.7.1 → 0.9.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 (170) hide show
  1. package/README.md +11 -10
  2. package/dist/cadre-node.d.ts +818 -19
  3. package/dist/cadre-node.js +1952 -99
  4. package/dist/cadre-node.js.map +1 -1
  5. package/dist/canonical-datetime.d.ts +33 -0
  6. package/dist/canonical-datetime.js +38 -0
  7. package/dist/canonical-datetime.js.map +1 -0
  8. package/dist/canonical-json.d.ts +12 -0
  9. package/dist/canonical-json.js +33 -0
  10. package/dist/canonical-json.js.map +1 -0
  11. package/dist/control-cohort.d.ts +63 -0
  12. package/dist/control-cohort.js +80 -0
  13. package/dist/control-cohort.js.map +1 -0
  14. package/dist/control-database.d.ts +279 -8
  15. package/dist/control-database.js +563 -134
  16. package/dist/control-database.js.map +1 -1
  17. package/dist/control-formation-recorder.d.ts +89 -0
  18. package/dist/control-formation-recorder.js +139 -0
  19. package/dist/control-formation-recorder.js.map +1 -0
  20. package/dist/control-schema.d.ts +12 -0
  21. package/dist/control-schema.js +223 -0
  22. package/dist/control-schema.js.map +1 -0
  23. package/dist/control-stream.d.ts +54 -0
  24. package/dist/control-stream.js +104 -0
  25. package/dist/control-stream.js.map +1 -0
  26. package/dist/device-token.d.ts +50 -0
  27. package/dist/device-token.js +66 -0
  28. package/dist/device-token.js.map +1 -0
  29. package/dist/diagnostics/connection-path.d.ts +118 -0
  30. package/dist/diagnostics/connection-path.js +204 -0
  31. package/dist/diagnostics/connection-path.js.map +1 -0
  32. package/dist/diagnostics/webrtc-turn-tracker.d.ts +50 -0
  33. package/dist/diagnostics/webrtc-turn-tracker.js +196 -0
  34. package/dist/diagnostics/webrtc-turn-tracker.js.map +1 -0
  35. package/dist/ed25519-key.d.ts +42 -0
  36. package/dist/ed25519-key.js +51 -0
  37. package/dist/ed25519-key.js.map +1 -0
  38. package/dist/hibernation-manager.d.ts +79 -0
  39. package/dist/hibernation-manager.js +173 -23
  40. package/dist/hibernation-manager.js.map +1 -1
  41. package/dist/index.d.ts +25 -4
  42. package/dist/index.js +59 -4
  43. package/dist/index.js.map +1 -1
  44. package/dist/key-store-file.d.ts +52 -0
  45. package/dist/key-store-file.js +194 -0
  46. package/dist/key-store-file.js.map +1 -0
  47. package/dist/key-store.d.ts +75 -0
  48. package/dist/key-store.js +66 -0
  49. package/dist/key-store.js.map +1 -0
  50. package/dist/peer-authorization.d.ts +48 -0
  51. package/dist/peer-authorization.js +66 -0
  52. package/dist/peer-authorization.js.map +1 -0
  53. package/dist/peer-record.d.ts +89 -0
  54. package/dist/peer-record.js +117 -0
  55. package/dist/peer-record.js.map +1 -0
  56. package/dist/push-credentials.d.ts +34 -0
  57. package/dist/push-credentials.js +81 -0
  58. package/dist/push-credentials.js.map +1 -0
  59. package/dist/push-fanout.d.ts +137 -0
  60. package/dist/push-fanout.js +197 -0
  61. package/dist/push-fanout.js.map +1 -0
  62. package/dist/push-node.d.ts +38 -0
  63. package/dist/push-node.js +48 -0
  64. package/dist/push-node.js.map +1 -0
  65. package/dist/push-notifier-apns.d.ts +61 -0
  66. package/dist/push-notifier-apns.js +199 -0
  67. package/dist/push-notifier-apns.js.map +1 -0
  68. package/dist/push-notifier-fcm.d.ts +44 -0
  69. package/dist/push-notifier-fcm.js +182 -0
  70. package/dist/push-notifier-fcm.js.map +1 -0
  71. package/dist/push-notifier-shared.d.ts +18 -0
  72. package/dist/push-notifier-shared.js +25 -0
  73. package/dist/push-notifier-shared.js.map +1 -0
  74. package/dist/push-notifier.d.ts +52 -0
  75. package/dist/push-notifier.js +22 -0
  76. package/dist/push-notifier.js.map +1 -0
  77. package/dist/schema-verification.d.ts +11 -2
  78. package/dist/schema-verification.js +15 -4
  79. package/dist/schema-verification.js.map +1 -1
  80. package/dist/seed-bootstrap.d.ts +228 -21
  81. package/dist/seed-bootstrap.js +489 -168
  82. package/dist/seed-bootstrap.js.map +1 -1
  83. package/dist/seed-trust-policy.d.ts +63 -0
  84. package/dist/seed-trust-policy.js +56 -0
  85. package/dist/seed-trust-policy.js.map +1 -0
  86. package/dist/strand-addr-protocol.d.ts +132 -0
  87. package/dist/strand-addr-protocol.js +313 -0
  88. package/dist/strand-addr-protocol.js.map +1 -0
  89. package/dist/strand-cohort.d.ts +54 -0
  90. package/dist/strand-cohort.js +38 -0
  91. package/dist/strand-cohort.js.map +1 -0
  92. package/dist/strand-database.d.ts +54 -6
  93. package/dist/strand-database.js +88 -100
  94. package/dist/strand-database.js.map +1 -1
  95. package/dist/strand-formation-manager.d.ts +79 -19
  96. package/dist/strand-formation-manager.js +196 -104
  97. package/dist/strand-formation-manager.js.map +1 -1
  98. package/dist/strand-formation-protocol.d.ts +162 -0
  99. package/dist/strand-formation-protocol.js +279 -0
  100. package/dist/strand-formation-protocol.js.map +1 -0
  101. package/dist/strand-instance-manager.d.ts +73 -4
  102. package/dist/strand-instance-manager.js +167 -41
  103. package/dist/strand-instance-manager.js.map +1 -1
  104. package/dist/strand-member-key.d.ts +29 -0
  105. package/dist/strand-member-key.js +38 -0
  106. package/dist/strand-member-key.js.map +1 -0
  107. package/dist/strand-member-registry.d.ts +98 -0
  108. package/dist/strand-member-registry.js +129 -0
  109. package/dist/strand-member-registry.js.map +1 -0
  110. package/dist/strand-membership-writer.d.ts +361 -0
  111. package/dist/strand-membership-writer.js +449 -0
  112. package/dist/strand-membership-writer.js.map +1 -0
  113. package/dist/strand-solicitation.d.ts +77 -2
  114. package/dist/strand-solicitation.js +25 -4
  115. package/dist/strand-solicitation.js.map +1 -1
  116. package/dist/strand-wake-payload.d.ts +30 -0
  117. package/dist/strand-wake-payload.js +20 -0
  118. package/dist/strand-wake-payload.js.map +1 -0
  119. package/dist/strand-wake-protocol.d.ts +114 -0
  120. package/dist/strand-wake-protocol.js +257 -0
  121. package/dist/strand-wake-protocol.js.map +1 -0
  122. package/dist/strand-watcher.d.ts +6 -2
  123. package/dist/strand-watcher.js +57 -17
  124. package/dist/strand-watcher.js.map +1 -1
  125. package/dist/types.d.ts +436 -33
  126. package/dist/types.js +22 -5
  127. package/dist/types.js.map +1 -1
  128. package/package.json +18 -8
  129. package/src/cadre-node.ts +2981 -948
  130. package/src/canonical-datetime.ts +39 -0
  131. package/src/canonical-json.ts +30 -0
  132. package/src/control-cohort.ts +103 -0
  133. package/src/control-database.ts +948 -383
  134. package/src/control-formation-recorder.ts +151 -0
  135. package/src/control-schema.ts +222 -0
  136. package/src/control-stream.ts +128 -0
  137. package/src/device-token.ts +89 -0
  138. package/src/diagnostics/connection-path.ts +277 -0
  139. package/src/diagnostics/webrtc-turn-tracker.ts +222 -0
  140. package/src/ed25519-key.ts +69 -0
  141. package/src/hibernation-manager.ts +197 -28
  142. package/src/index.ts +275 -79
  143. package/src/key-store-file.ts +203 -0
  144. package/src/key-store.ts +99 -0
  145. package/src/peer-authorization.ts +81 -0
  146. package/src/peer-record.ts +146 -0
  147. package/src/push-credentials.ts +84 -0
  148. package/src/push-fanout.ts +272 -0
  149. package/src/push-node.ts +64 -0
  150. package/src/push-notifier-apns.ts +247 -0
  151. package/src/push-notifier-fcm.ts +220 -0
  152. package/src/push-notifier-shared.ts +28 -0
  153. package/src/push-notifier.ts +52 -0
  154. package/src/schema-verification.ts +18 -4
  155. package/src/seed-bootstrap.ts +563 -223
  156. package/src/seed-trust-policy.ts +107 -0
  157. package/src/strand-addr-protocol.ts +410 -0
  158. package/src/strand-cohort.ts +77 -0
  159. package/src/strand-database.ts +113 -141
  160. package/src/strand-formation-manager.ts +233 -142
  161. package/src/strand-formation-protocol.ts +391 -0
  162. package/src/strand-instance-manager.ts +207 -56
  163. package/src/strand-member-key.ts +39 -0
  164. package/src/strand-member-registry.ts +177 -0
  165. package/src/strand-membership-writer.ts +640 -0
  166. package/src/strand-solicitation.ts +93 -4
  167. package/src/strand-wake-payload.ts +32 -0
  168. package/src/strand-wake-protocol.ts +331 -0
  169. package/src/strand-watcher.ts +61 -17
  170. package/src/types.ts +1032 -582
@@ -0,0 +1,640 @@
1
+ import debug from 'debug';
2
+ import type { Database } from '@quereus/quereus';
3
+ import { digest, sign, verify, generatePrivateKey, getPublicKey } from '@optimystic/quereus-plugin-crypto';
4
+ import type { SAppConfig } from './types.js';
5
+ import type { Ed25519KeyPair } from './ed25519-key.js';
6
+ import { canonicalDatetime } from './canonical-datetime.js';
7
+
8
+ const log = debug('sereus:cadre:strand-membership');
9
+
10
+ /**
11
+ * The engine (rules-logic system) assumed to manage every strand. Persisted in
12
+ * `Strand.Header.Engine`.
13
+ *
14
+ * PLACEHOLDER SEAM: there is no engine-selection mechanism yet — Quereus is the
15
+ * only engine. When a real engine-config seam lands, the founder bootstrap should
16
+ * read the strand's chosen engine from there instead of this constant. Documented
17
+ * deliberately so the column is non-null and self-describing in the meantime.
18
+ */
19
+ export const STRAND_ENGINE = 'quereus';
20
+
21
+ /**
22
+ * The pinned engine version recorded in `Strand.Header.EngineVersion`. A pinned
23
+ * string for the same reason as {@link STRAND_ENGINE}: it is a documented
24
+ * placeholder until an engine-selection seam supplies the real running version.
25
+ */
26
+ export const STRAND_ENGINE_VERSION = '0.1.0';
27
+
28
+ /**
29
+ * The single-digest ed25519 signer the `Strand.*` membership/RBAC constraints
30
+ * verify against.
31
+ *
32
+ * `schemas/strand.qsql` signs a single SHA-256 digest over a `'|'`-joined payload
33
+ * (e.g. `Member.Authorized` verifies
34
+ * `verify(digest(new.Key), context.ManagerSignature, A.MemberKey, 'ed25519')`).
35
+ * So the signer hashes the payload to raw bytes and ed25519-signs *those bytes*:
36
+ * SQL `digest(...)`'s default output is base64url and `verify(...)`'s default
37
+ * `inputEncoding` is base64url, so signer and verifier operate on identical bytes.
38
+ * This differs from the control-layer's multi-field `buildAuthorizationMessage`
39
+ * (a single digest over MANY fields) — the strand layer is a single digest over ONE
40
+ * joined payload field.
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 every `Strand.*` constraint computes:
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
+ /** Parameters for {@link bootstrapFounderMembership}. */
77
+ export interface FounderBootstrapParams {
78
+ /** The strand id — written to `Header.Id`. */
79
+ strandId: string;
80
+ /** Strand type: `'o'` (open, Header only) or `'c'` (closed, Header+Member+Manager). */
81
+ type: 'o' | 'c';
82
+ /** The sApp config whose id/version/schema/signature populate the Header. */
83
+ sApp: SAppConfig;
84
+ /**
85
+ * The founder's derived strand keypair (from {@link strandMemberKeyPair}). Its
86
+ * `publicKeyB64` becomes the founding `Member.Key` and `Manager.MemberKey`.
87
+ * Required for a closed strand; ignored for an open strand.
88
+ */
89
+ founderKeyPair?: Ed25519KeyPair;
90
+ }
91
+
92
+ /**
93
+ * Count rows in a `Strand.*` table as seen by this database instance.
94
+ *
95
+ * The table name is a fixed literal supplied by this module (never user input),
96
+ * so the interpolation is not an injection surface — it just keeps the singleton
97
+ * insert-if-absent guards terse.
98
+ */
99
+ async function strandTableCount(db: Database, table: 'Header' | 'Member' | 'Manager'): Promise<number> {
100
+ for await (const row of db.eval(`select count(1) as Count from Strand.${table}`)) {
101
+ return (row.Count as number) ?? 0;
102
+ }
103
+ return 0;
104
+ }
105
+
106
+ /**
107
+ * Insert the singleton `Strand.Header` if absent.
108
+ *
109
+ * Every `Header` column is NOT NULL (Quereus defaults unqualified columns to NOT
110
+ * NULL; the table declares no `null` columns), so all eight are supplied non-null.
111
+ * A missing `sApp.signature` (dev `requireSignedSchemas:false`) coalesces to `''`
112
+ * rather than inserting null. `Header` carries only `InsertOnly` + a singleton PK,
113
+ * so the insert needs no `with context`.
114
+ */
115
+ async function insertHeaderIfAbsent(db: Database, params: FounderBootstrapParams): Promise<void> {
116
+ if (await strandTableCount(db, 'Header') > 0) {
117
+ log('Header already present for strand %s; skipping', params.strandId);
118
+ return;
119
+ }
120
+ await db.exec(
121
+ `insert into Strand.Header
122
+ (Id, Type, sAppId, sAppVersion, sAppSchema, sAppSignature, Engine, EngineVersion)
123
+ values (?, ?, ?, ?, ?, ?, ?, ?)`,
124
+ [
125
+ params.strandId,
126
+ params.type,
127
+ params.sApp.id,
128
+ params.sApp.version,
129
+ params.sApp.schema,
130
+ params.sApp.signature ?? '',
131
+ STRAND_ENGINE,
132
+ STRAND_ENGINE_VERSION,
133
+ ],
134
+ );
135
+ log('Inserted Header for strand %s (type %s)', params.strandId, params.type);
136
+ }
137
+
138
+ /**
139
+ * Insert the founding `Strand.Member` if no member exists yet.
140
+ *
141
+ * The empty-table state satisfies the `count(1) from Member <= 1` bootstrap branch
142
+ * of `Member.Authorized`, so no manager signature is needed — the context fields
143
+ * are explicit nulls. Guarding on the count makes this idempotent: a re-run (the
144
+ * founder re-`addStrand`/`resumeStrand`) finds the member present and skips.
145
+ */
146
+ async function insertFounderMemberIfAbsent(db: Database, memberKey: string, strandId: string): Promise<void> {
147
+ if (await strandTableCount(db, 'Member') > 0) {
148
+ log('Member already present for strand %s; skipping founder Member', strandId);
149
+ return;
150
+ }
151
+ await db.exec(
152
+ `insert into Strand.Member (Key)
153
+ with context ManagerKey = null, ManagerSignature = null
154
+ values (?)`,
155
+ [memberKey],
156
+ );
157
+ log('Inserted founding Member for strand %s', strandId);
158
+ }
159
+
160
+ /**
161
+ * Insert the founding `Strand.Manager` if no manager exists yet.
162
+ *
163
+ * The empty-table state satisfies the `count(1) from Manager <= 1` bootstrap
164
+ * branch of `Manager.Authorized`, so no signature is needed. Idempotent via the
165
+ * same count guard. Must run AFTER the Header insert: `Manager.OnlyClosed` is a
166
+ * deferred (subquery) check evaluated at commit, and the sequential auto-commit
167
+ * Header→Member→Manager order ensures the closed `Header` is committed first.
168
+ */
169
+ async function insertFounderManagerIfAbsent(db: Database, memberKey: string, strandId: string): Promise<void> {
170
+ if (await strandTableCount(db, 'Manager') > 0) {
171
+ log('Manager already present for strand %s; skipping founder Manager', strandId);
172
+ return;
173
+ }
174
+ await db.exec(
175
+ `insert into Strand.Manager (MemberKey)
176
+ with context ManagerKey = null, Signature = null
177
+ values (?)`,
178
+ [memberKey],
179
+ );
180
+ log('Inserted founding Manager for strand %s', strandId);
181
+ }
182
+
183
+ /**
184
+ * Founder-only one-time bootstrap of a strand's `Strand.*` membership/RBAC rows.
185
+ *
186
+ * Runs once at bring-up on the strand's FOUNDER (the party that provisioned and
187
+ * published the strand). A joiner never calls this — it receives these rows via
188
+ * Optimystic sync. Every write is insert-if-absent (guarded by a row count) so a
189
+ * restart / founder re-`addStrand` is a no-op and never double-inserts.
190
+ *
191
+ * Behavior by strand type:
192
+ * - **Open (`'o'`)**: insert `Header` only. `Member`/`Manager`/`Invite` are
193
+ * `OnlyClosed` and would trip that constraint — they are skipped entirely.
194
+ * - **Closed (`'c'`)**: insert `Header(Type='c')`, then the founding `Member`
195
+ * (`Key = founderKeyPair.publicKeyB64`), then the founding `Manager`
196
+ * (`MemberKey = founderKeyPair.publicKeyB64`). Insert order matters: the
197
+ * Header must commit before the deferred `OnlyClosed` checks on Member/Manager
198
+ * evaluate at commit.
199
+ *
200
+ * A closed strand with no `founderKeyPair` throws: a closed strand with no founding
201
+ * Manager could never admit anyone, so failing loudly here (which propagates out
202
+ * of `StrandDatabase.initialize()` and triggers the runtime's rollback) is correct.
203
+ *
204
+ * @param db - The strand's Quereus database (schema already applied).
205
+ * @param params - Strand id/type, the sApp config for the Header, and (closed only)
206
+ * the derived founder keypair.
207
+ * @throws If `type === 'c'` and no `founderKeyPair` is supplied.
208
+ */
209
+ export async function bootstrapFounderMembership(db: Database, params: FounderBootstrapParams): Promise<void> {
210
+ const { strandId, type, founderKeyPair } = params;
211
+ log('Founder bootstrap for strand %s (type %s)', strandId, type);
212
+
213
+ // Validate BEFORE writing anything: a closed strand with no founder key must
214
+ // fail without leaving a closed `Header` that has no founding Member/Manager
215
+ // (such a strand could never admit anyone).
216
+ if (type === 'c' && !founderKeyPair) {
217
+ throw new Error(
218
+ `Cannot bootstrap closed strand ${strandId}: no founder key pair derived from MemberPrivateKey. ` +
219
+ 'A closed strand needs a founding Member + Manager or it can never admit members.',
220
+ );
221
+ }
222
+
223
+ // Header is written for every strand; Member/Manager are closed-only.
224
+ await insertHeaderIfAbsent(db, params);
225
+
226
+ if (type === 'o') {
227
+ // Open strand: Member/Manager/Invite are OnlyClosed — nothing else to write.
228
+ return;
229
+ }
230
+
231
+ const memberKey = founderKeyPair!.publicKeyB64;
232
+ await insertFounderMemberIfAbsent(db, memberKey, strandId);
233
+ await insertFounderManagerIfAbsent(db, memberKey, strandId);
234
+ log('Founder bootstrap complete for closed strand %s', strandId);
235
+ }
236
+
237
+ // ── Invite issuance + consumption (the per-strand join handshake) ─────────────
238
+
239
+ /** Parameters for {@link issueInvite}. */
240
+ export interface IssueInviteParams {
241
+ /**
242
+ * The issuing manager's strand keypair. Its `publicKeyB64` must already be a
243
+ * `Strand.Manager` row (the `InviteValid` constraint rejects a non-manager).
244
+ */
245
+ managerKeyPair: Ed25519KeyPair;
246
+ /**
247
+ * Optional invite expiry as epoch milliseconds. When set, it is canonicalised
248
+ * via {@link canonicalDatetime} so the signed payload segment byte-matches the
249
+ * `datetime`-coerced `Invite.Expiration` the deferred CHECK sees. When omitted,
250
+ * the invite never expires and the signed segment is `''` (matching the schema's
251
+ * `coalesce(new.Expiration, '')`).
252
+ */
253
+ expiration?: number;
254
+ }
255
+
256
+ /** The minted invite: a public key (the `Invite.Key`) plus its private seed. */
257
+ export interface IssuedInvite {
258
+ /** The invite ed25519 PUBLIC key (base64url) — the `Invite.Key` primary key. */
259
+ inviteKey: string;
260
+ /**
261
+ * The invite ed25519 PRIVATE seed (base64url). Handed out-of-band to the
262
+ * invitee; whoever holds it can {@link consumeInvite} exactly once. NEVER
263
+ * persisted in the strand — only the public key is.
264
+ */
265
+ invitePrivateKey: string;
266
+ }
267
+
268
+ /**
269
+ * Issue a single-use invitation to join a closed strand.
270
+ *
271
+ * Generates a fresh invite ed25519 keypair (the public key becomes `Invite.Key`),
272
+ * builds the constraint's payload (`Key || '|' || coalesce(Expiration, '')`), and
273
+ * signs it TWICE: with the manager private key (→ `ManagerSignature`, proving
274
+ * a manager issued it) and with the invite private key (→ `InviteSignature`,
275
+ * proving the issuer actually holds the invite secret). Both signatures plus the
276
+ * manager public key are bound as constraint context for the `Invite` insert.
277
+ *
278
+ * The returned `invitePrivateKey` is the only secret the invitee needs to redeem
279
+ * the invite — it is NOT stored in the strand (only `Invite.Key`, the public half,
280
+ * is). Single-use is enforced at consumption time: `ConsumedInvite`'s primary key
281
+ * is `InviteKey`, so a given invite can be consumed at most once.
282
+ *
283
+ * @param db - The closed strand's database (founder already bootstrapped).
284
+ * @param params - The issuing manager keypair and optional expiry.
285
+ * @returns The invite public key and the out-of-band private seed.
286
+ */
287
+ export async function issueInvite(db: Database, params: IssueInviteParams): Promise<IssuedInvite> {
288
+ const { managerKeyPair, expiration } = params;
289
+
290
+ const invitePrivateKey = generatePrivateKey('ed25519', 'base64url') as string;
291
+ const inviteKey = getPublicKey(invitePrivateKey, 'ed25519', 'base64url', 'base64url') as string;
292
+
293
+ // Canonicalise the expiry to the engine's stored `datetime` form so the signed
294
+ // segment matches `new.Expiration` post-coercion; null expiry signs as ''.
295
+ const expirationSegment = expiration == null ? '' : await canonicalDatetime(db, expiration);
296
+ const expirationColumn = expiration == null ? null : expirationSegment;
297
+
298
+ const payload = `${inviteKey}|${expirationSegment}`;
299
+ const managerSignature = signStrandPayload(payload, managerKeyPair.privateKeyB64);
300
+ const inviteSignature = signStrandPayload(payload, invitePrivateKey);
301
+
302
+ await db.exec(
303
+ `insert into Strand.Invite (Key, Expiration)
304
+ with context ManagerKey = ?, ManagerSignature = ?, InviteSignature = ?
305
+ values (?, ?)`,
306
+ [managerKeyPair.publicKeyB64, managerSignature, inviteSignature, inviteKey, expirationColumn],
307
+ );
308
+ log('Issued invite %s (expires=%s)', inviteKey, expirationColumn ?? 'never');
309
+
310
+ return { inviteKey, invitePrivateKey };
311
+ }
312
+
313
+ /** Parameters for {@link consumeInvite}. */
314
+ export interface ConsumeInviteParams {
315
+ /** The `Invite.Key` (invite PUBLIC key, base64url) being redeemed. */
316
+ inviteKey: string;
317
+ /** The matching invite PRIVATE seed (base64url) received out-of-band. */
318
+ invitePrivateKey: string;
319
+ /** The joining member's ed25519 PUBLIC key (base64url) — the new `Member.Key`. */
320
+ memberKey: string;
321
+ /**
322
+ * The instant (epoch ms) to compare against the invite's `Expiration` for the
323
+ * `NotExpired` gate. Defaults to `Date.now()`; tests pin it so the comparison is
324
+ * deterministic. Mirrors the control-layer `redeemInvitation`'s `nowMs` convention.
325
+ */
326
+ nowMs?: number;
327
+ }
328
+
329
+ /**
330
+ * Redeem an invite to admit a new `Member`, atomically.
331
+ *
332
+ * `Member.Authorized`'s invite branch needs a `ConsumedInvite` row, while
333
+ * `ConsumedInvite`'s `MemberExists`/`MemberValid` need the `Member` row — a
334
+ * circular dependency. Both are deferred (subquery-bearing) checks that evaluate
335
+ * at COMMIT, so inserting `Member` then `ConsumedInvite` inside one explicit
336
+ * transaction lets both rows exist when the deferred checks fire. This mirrors
337
+ * `ControlDatabase.redeemInvitation` (Strand + FormationUsage in one txn).
338
+ *
339
+ * The `ConsumedInvite` insert carries an `InviteSignature` over
340
+ * `InviteKey || '|' || MemberKey`, proving the consumer holds the invite private
341
+ * key (the on-engine `ValidUsage` gate). The `Member` insert needs no member
342
+ * signature — its admission is the existence of the matching `ConsumedInvite`.
343
+ *
344
+ * Single-use: `ConsumedInvite`'s primary key is `InviteKey`, so a second consume
345
+ * of the same invite is rejected by the PK (a distinct layer from the control
346
+ * network's `FormationUsage` single-use, which gates strand FORMATION, not the
347
+ * per-strand member join enforced here).
348
+ *
349
+ * Expiry: the `ConsumedInvite.NotExpired` deferred check rejects redeeming an invite
350
+ * whose `Expiration` is at or before `context.Now`. `Now` is supplied here as a
351
+ * canonical-datetime string from `canonicalDatetime(db, nowMs)` — the SAME transform
352
+ * `issueInvite` uses to store `Invite.Expiration` — so both sides of the schema's
353
+ * `I.Expiration > context.Now` comparison are byte-identical canonical strings and
354
+ * the lexical `>` orders chronologically at any granularity. (This intentionally
355
+ * diverges from the control layer, which passes `Now` as a JS ISO string; Quereus
356
+ * does not coerce context params, so an ISO `Now` would be compared lexically against
357
+ * the canonical, T-separated `Expiration` and could mis-order near-same-instant
358
+ * timestamps (due to a trailing `.000Z` suffix). The control tests only use far-future/far-past expiries, so that latent
359
+ * skew never bites there — the strand layer avoids it outright.) A null `Expiration`
360
+ * never expires. Like `ValidUsage`, `NotExpired` defers to commit, so an expired
361
+ * invite rolls back the whole txn — neither the `Member` nor the `ConsumedInvite`
362
+ * row survives.
363
+ *
364
+ * @param db - The closed strand's database.
365
+ * @param params - The invite key/secret, the joining member's public key, and an
366
+ * optional `nowMs` instant for the expiry comparison (default `Date.now()`).
367
+ * @throws If any constraint rejects (bad signature, missing invite, or an expired
368
+ * invite); the whole transaction rolls back (neither the `Member` nor the
369
+ * `ConsumedInvite` row survives).
370
+ */
371
+ export async function consumeInvite(db: Database, params: ConsumeInviteParams): Promise<void> {
372
+ const { inviteKey, invitePrivateKey, memberKey, nowMs } = params;
373
+
374
+ const usagePayload = `${inviteKey}|${memberKey}`;
375
+ const inviteSignature = signStrandPayload(usagePayload, invitePrivateKey);
376
+
377
+ // Canonicalise "now" the same way issueInvite canonicalises Expiration, so the
378
+ // schema's `I.Expiration > context.Now` compares like-for-like canonical strings.
379
+ // Plain runtime Date.now() — the tess Workflow restriction is on scripts, not libs.
380
+ const nowCanonical = await canonicalDatetime(db, nowMs ?? Date.now());
381
+
382
+ await db.beginTransaction();
383
+ try {
384
+ // 1. Member — admitted by the deferred invite branch (the matching
385
+ // ConsumedInvite below), so no manager signature is supplied.
386
+ await db.exec(
387
+ `insert into Strand.Member (Key)
388
+ with context ManagerKey = null, ManagerSignature = null
389
+ values (?)`,
390
+ [memberKey],
391
+ );
392
+
393
+ // 2. ConsumedInvite — proves possession of the invite private key and that the
394
+ // invite has not expired (NotExpired gate against the canonical Now).
395
+ await db.exec(
396
+ `insert into Strand.ConsumedInvite (InviteKey, MemberKey)
397
+ with context InviteSignature = ?, Now = ?
398
+ values (?, ?)`,
399
+ [inviteSignature, nowCanonical, inviteKey, memberKey],
400
+ );
401
+
402
+ await db.commit();
403
+ log('Consumed invite %s -> member %s', inviteKey, memberKey);
404
+ } catch (error) {
405
+ // A failed commit() already tears down the transaction, so rollback() would
406
+ // throw "No transaction active" and mask the real cause — swallow only that.
407
+ try {
408
+ await db.rollback();
409
+ } catch (rollbackError) {
410
+ log('Rollback after consumeInvite failure was a no-op: %s', rollbackError);
411
+ }
412
+ throw error;
413
+ }
414
+ }
415
+
416
+ /** Parameters for {@link addMemberByManager}. */
417
+ export interface AddMemberByManagerParams {
418
+ /**
419
+ * The admitting manager's strand keypair. Its `publicKeyB64` must be a
420
+ * `Strand.Manager` row; it signs the new member key directly.
421
+ */
422
+ managerKeyPair: Ed25519KeyPair;
423
+ /** The joining member's ed25519 PUBLIC key (base64url) — the new `Member.Key`. */
424
+ memberKey: string;
425
+ }
426
+
427
+ /**
428
+ * Admit a `Member` directly by manager signature — the sibling of the invite
429
+ * path on `Member.Authorized`'s direct-manager branch.
430
+ *
431
+ * The constraint verifies `digest(new.Key)` against a `Manager` row matching
432
+ * `context.ManagerKey`, so the payload is just the member key (no `'|'` join).
433
+ * No `ConsumedInvite` is involved: this is the path a manager uses to seat a
434
+ * member it already trusts (e.g. a manager-side enrollment that admits a
435
+ * party already authorised out-of-band).
436
+ *
437
+ * @param db - The closed strand's database.
438
+ * @param params - The admitting manager keypair and the new member key.
439
+ * @throws If `Member.Authorized` rejects (e.g. a non-manager key); the insert
440
+ * rolls back, leaving no `Member` row.
441
+ */
442
+ export async function addMemberByManager(db: Database, params: AddMemberByManagerParams): Promise<void> {
443
+ const { managerKeyPair, memberKey } = params;
444
+ const signature = signStrandPayload(memberKey, managerKeyPair.privateKeyB64);
445
+ await db.exec(
446
+ `insert into Strand.Member (Key)
447
+ with context ManagerKey = ?, ManagerSignature = ?
448
+ values (?)`,
449
+ [managerKeyPair.publicKeyB64, signature, memberKey],
450
+ );
451
+ log('Admitted member %s by manager %s', memberKey, managerKeyPair.publicKeyB64);
452
+ }
453
+
454
+ // ── MemberPeer registration (a member binds its own network nodes) ─────────────
455
+
456
+ /** Parameters for {@link registerMemberPeer}. */
457
+ export interface RegisterMemberPeerParams {
458
+ /**
459
+ * The member's OWN strand keypair (`{ privateKeyB64, publicKeyB64 }`). Its
460
+ * `publicKeyB64` becomes `MemberPeer.MemberKey` and it self-signs the binding —
461
+ * `MemberPeer.Authorized` verifies the signature against `MemberKey` itself, so a
462
+ * peer can only be registered by the very member it belongs to (no manager
463
+ * involved). The founder passes its `strandMemberKeyPair`; an invited member
464
+ * passes its own keypair. Typed as {@link Ed25519KeyPair} only for the shared
465
+ * base64url keypair shape — no manager privilege is implied.
466
+ */
467
+ memberKeyPair: Ed25519KeyPair;
468
+ /** The peer/node id (libp2p peer id string) to associate with the member. */
469
+ peerId: string;
470
+ }
471
+
472
+ /**
473
+ * Register a network node (`PeerId`) as acting on behalf of a member.
474
+ *
475
+ * The member self-signs `MemberKey || '|' || PeerId` with its OWN key; the schema's
476
+ * `MemberPeer.Authorized` verifies that signature against `coalesce(new.MemberKey,
477
+ * old.MemberKey)` — i.e. the member key itself — so only the member that owns the
478
+ * key can register peers for it. A deferred `MemberExists` additionally requires the
479
+ * `Member` row to already exist, so a peer for a non-member is rejected at commit.
480
+ *
481
+ * Insert-if-absent on the composite PK `(MemberKey, PeerId)`: a re-register on
482
+ * restart (or a redundant call) is a no-op rather than relying on the platform's
483
+ * PK-uniqueness rejection (not enforced in bootstrap mode — see
484
+ * `optimystic-insert-pk-uniqueness-not-enforced`). A member may register multiple
485
+ * DISTINCT `PeerId`s (multi-device); each is its own row under the same `MemberKey`.
486
+ *
487
+ * Out of scope: peer DELETION. The schema's `MemberExists` reads `new.MemberKey`,
488
+ * which is null on delete, so a `MemberPeer` delete is currently rejected by the
489
+ * schema (noted in the `apply-strand-membership-schema` review). Removing a peer
490
+ * would need a schema tweak (e.g. `coalesce(new.MemberKey, old.MemberKey)` in
491
+ * `MemberExists`), so no `removeMemberPeer` is provided here.
492
+ *
493
+ * @param db - The closed strand's database (the member already exists).
494
+ * @param params - The member's own keypair and the peer id to bind.
495
+ * @throws If `MemberPeer.Authorized`/`MemberExists` rejects (wrong signer, or no
496
+ * matching `Member` row); the insert rolls back, leaving no `MemberPeer` row.
497
+ */
498
+ export async function registerMemberPeer(db: Database, params: RegisterMemberPeerParams): Promise<void> {
499
+ const { memberKeyPair, peerId } = params;
500
+ const memberKey = memberKeyPair.publicKeyB64;
501
+
502
+ if (await memberPeerExists(db, memberKey, peerId)) {
503
+ log('MemberPeer (%s, %s) already present; skipping', memberKey, peerId);
504
+ return;
505
+ }
506
+
507
+ const payload = `${memberKey}|${peerId}`;
508
+ const signature = signStrandPayload(payload, memberKeyPair.privateKeyB64);
509
+ await db.exec(
510
+ `insert into Strand.MemberPeer (MemberKey, PeerId)
511
+ with context Signature = ?
512
+ values (?, ?)`,
513
+ [signature, memberKey, peerId],
514
+ );
515
+ log('Registered MemberPeer (%s, %s)', memberKey, peerId);
516
+ }
517
+
518
+ /** True iff a `MemberPeer` row already exists for this `(MemberKey, PeerId)`. */
519
+ async function memberPeerExists(db: Database, memberKey: string, peerId: string): Promise<boolean> {
520
+ for await (const row of db.eval(
521
+ 'select count(1) as Count from Strand.MemberPeer where MemberKey = ? and PeerId = ?',
522
+ [memberKey, peerId],
523
+ )) {
524
+ return ((row.Count as number) ?? 0) > 0;
525
+ }
526
+ return false;
527
+ }
528
+
529
+ // ── Manager rotation (add / remove RBAC admins) ───────────────────────────────
530
+
531
+ /** Parameters for {@link addManager}. */
532
+ export interface AddManagerParams {
533
+ /**
534
+ * An EXISTING manager's strand keypair. Its `publicKeyB64` must already be a
535
+ * `Strand.Manager` row (the existing-manager branch of `Manager.Authorized`
536
+ * rejects a non-manager once the founder manager exists); it signs the new
537
+ * manager key and is bound as `context.ManagerKey`.
538
+ */
539
+ byManagerKeyPair: Ed25519KeyPair;
540
+ /** The member key to promote — the new `Manager.MemberKey` row. */
541
+ newManagerKey: string;
542
+ }
543
+
544
+ /**
545
+ * Promote a member to `Manager` on the signature of an existing manager.
546
+ *
547
+ * The existing-manager branch of `Manager.Authorized` verifies
548
+ * `digest(coalesce(new.MemberKey, old.MemberKey))` — `new.MemberKey` =
549
+ * `newManagerKey` on insert — against a `Manager` row matching
550
+ * `context.ManagerKey`. So the signer signs the new manager key directly (no
551
+ * `'|'` join) and binds itself as `context.ManagerKey`.
552
+ *
553
+ * Once the founder manager exists, the schema's `(select count(1) from Manager)
554
+ * <= 1` bootstrap shortcut no longer applies to a second add (at commit the count
555
+ * includes the new row, so it is ≥ 2), so this genuinely exercises signature
556
+ * verification — a non-manager signer, or a signature over the wrong key, is
557
+ * rejected.
558
+ *
559
+ * @param db - The closed strand's database (founder manager already seated).
560
+ * @param params - The authorizing manager's keypair and the new manager key.
561
+ * @throws If `Manager.Authorized` rejects; the insert rolls back.
562
+ */
563
+ export async function addManager(db: Database, params: AddManagerParams): Promise<void> {
564
+ const { byManagerKeyPair, newManagerKey } = params;
565
+ const signature = signStrandPayload(newManagerKey, byManagerKeyPair.privateKeyB64);
566
+ await db.exec(
567
+ `insert into Strand.Manager (MemberKey)
568
+ with context ManagerKey = ?, Signature = ?
569
+ values (?)`,
570
+ [byManagerKeyPair.publicKeyB64, signature, newManagerKey],
571
+ );
572
+ log('Added manager %s by %s', newManagerKey, byManagerKeyPair.publicKeyB64);
573
+ }
574
+
575
+ /** Parameters for {@link removeManager}. */
576
+ export interface RemoveManagerParams {
577
+ /**
578
+ * The keypair authorizing the removal, bound as `context.ManagerKey` and
579
+ * signing `digest(targetManagerKey)`. For an ADMIN removal this is a DIFFERENT
580
+ * existing manager (satisfying the existing-manager branch); for a
581
+ * SELF-resignation it is the target's OWN keypair (`publicKeyB64 ===
582
+ * targetManagerKey`, satisfying the former-manager self branch). The same
583
+ * context construction satisfies whichever branch applies — see
584
+ * {@link removeManager}.
585
+ */
586
+ byManagerKeyPair: Ed25519KeyPair;
587
+ /** The `Manager.MemberKey` row to delete. */
588
+ targetManagerKey: string;
589
+ }
590
+
591
+ /**
592
+ * Remove a `Manager` row — either an admin removing a different manager or a
593
+ * manager resigning itself.
594
+ *
595
+ * On a DELETE, `coalesce(new.MemberKey, old.MemberKey)` binds `old.MemberKey =
596
+ * targetManagerKey` (there is no `new` row), so the signed payload is the target
597
+ * key for BOTH accepting branches of `Manager.Authorized`, and a single context
598
+ * construction serves both:
599
+ * - **Admin removal**: `byManagerKeyPair` is a different existing manager; the
600
+ * existing-manager branch verifies the signature against `A.MemberKey =
601
+ * context.ManagerKey`.
602
+ * - **Self-resignation**: `byManagerKeyPair` IS the target (its `publicKeyB64`
603
+ * equals `targetManagerKey`); the former-manager self branch verifies
604
+ * `old.MemberKey = context.ManagerKey` and the self-signature over `old.MemberKey`.
605
+ *
606
+ * The caller selects the case purely by which keypair it passes — no branching here.
607
+ *
608
+ * KNOWN PLATFORM GAP (not fixable here): the optimystic bootstrap-mode transactor
609
+ * evaluates deferred (subquery-bearing) CHECK constraints only on INSERT, not on
610
+ * DELETE. `Manager.Authorized` is deferred, so the platform currently accepts ANY
611
+ * `Manager` delete regardless of signature — `removeManager`'s authorization is
612
+ * effectively unenforced at runtime until that gap is closed (filed as
613
+ * `optimystic-deferred-check-not-enforced-on-delete`). This writer still builds the
614
+ * correct, signed delete so it works unchanged once enforcement lands — exactly as
615
+ * the invite path issues correct inserts despite the open PK-uniqueness gap.
616
+ *
617
+ * KNOWN SCHEMA HAZARD (not guarded here): even with enforcement, `Manager.
618
+ * Authorized`'s `(select count(1) from Manager) <= 1` bootstrap branch is true at
619
+ * commit whenever a delete drops the count to ≤ 1, so removing the strand's LAST (or
620
+ * second-to-last) manager would be accepted regardless of signature — and removing
621
+ * the last one orphans the strand (no one can ever add another). The schema does not
622
+ * prevent this; a "min-one-manager" invariant is deferred to a future schema change
623
+ * rather than grown into this writer.
624
+ *
625
+ * @param db - The closed strand's database.
626
+ * @param params - The authorizing keypair and the target manager key.
627
+ * @throws If `Manager.Authorized` rejects (e.g. a non-manager admin removal
628
+ * while > 2 managers remain); the delete rolls back.
629
+ */
630
+ export async function removeManager(db: Database, params: RemoveManagerParams): Promise<void> {
631
+ const { byManagerKeyPair, targetManagerKey } = params;
632
+ const signature = signStrandPayload(targetManagerKey, byManagerKeyPair.privateKeyB64);
633
+ await db.exec(
634
+ `delete from Strand.Manager
635
+ with context ManagerKey = ?, Signature = ?
636
+ where MemberKey = ?`,
637
+ [byManagerKeyPair.publicKeyB64, signature, targetManagerKey],
638
+ );
639
+ log('Removed manager %s by %s', targetManagerKey, byManagerKeyPair.publicKeyB64);
640
+ }