@serfab/cadre-core 0.6.0 → 0.8.1

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 (166) hide show
  1. package/README.md +9 -8
  2. package/dist/authority-key.d.ts +41 -0
  3. package/dist/authority-key.js +52 -0
  4. package/dist/authority-key.js.map +1 -0
  5. package/dist/cadre-node.d.ts +777 -11
  6. package/dist/cadre-node.js +1912 -82
  7. package/dist/cadre-node.js.map +1 -1
  8. package/dist/canonical-datetime.d.ts +33 -0
  9. package/dist/canonical-datetime.js +38 -0
  10. package/dist/canonical-datetime.js.map +1 -0
  11. package/dist/canonical-json.d.ts +12 -0
  12. package/dist/canonical-json.js +33 -0
  13. package/dist/canonical-json.js.map +1 -0
  14. package/dist/control-cohort.d.ts +63 -0
  15. package/dist/control-cohort.js +80 -0
  16. package/dist/control-cohort.js.map +1 -0
  17. package/dist/control-database.d.ts +259 -5
  18. package/dist/control-database.js +541 -128
  19. package/dist/control-database.js.map +1 -1
  20. package/dist/control-formation-recorder.d.ts +89 -0
  21. package/dist/control-formation-recorder.js +139 -0
  22. package/dist/control-formation-recorder.js.map +1 -0
  23. package/dist/control-schema.d.ts +12 -0
  24. package/dist/control-schema.js +199 -0
  25. package/dist/control-schema.js.map +1 -0
  26. package/dist/control-stream.d.ts +54 -0
  27. package/dist/control-stream.js +104 -0
  28. package/dist/control-stream.js.map +1 -0
  29. package/dist/device-token.d.ts +50 -0
  30. package/dist/device-token.js +66 -0
  31. package/dist/device-token.js.map +1 -0
  32. package/dist/diagnostics/connection-path.d.ts +118 -0
  33. package/dist/diagnostics/connection-path.js +204 -0
  34. package/dist/diagnostics/connection-path.js.map +1 -0
  35. package/dist/diagnostics/webrtc-turn-tracker.d.ts +50 -0
  36. package/dist/diagnostics/webrtc-turn-tracker.js +196 -0
  37. package/dist/diagnostics/webrtc-turn-tracker.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 +26 -0
  51. package/dist/peer-authorization.js +40 -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-notifier-apns.d.ts +53 -0
  63. package/dist/push-notifier-apns.js +195 -0
  64. package/dist/push-notifier-apns.js.map +1 -0
  65. package/dist/push-notifier-fcm.d.ts +38 -0
  66. package/dist/push-notifier-fcm.js +183 -0
  67. package/dist/push-notifier-fcm.js.map +1 -0
  68. package/dist/push-notifier-shared.d.ts +18 -0
  69. package/dist/push-notifier-shared.js +25 -0
  70. package/dist/push-notifier-shared.js.map +1 -0
  71. package/dist/push-notifier.d.ts +71 -0
  72. package/dist/push-notifier.js +50 -0
  73. package/dist/push-notifier.js.map +1 -0
  74. package/dist/schema-verification.d.ts +11 -2
  75. package/dist/schema-verification.js +15 -4
  76. package/dist/schema-verification.js.map +1 -1
  77. package/dist/seed-bootstrap.d.ts +210 -7
  78. package/dist/seed-bootstrap.js +429 -131
  79. package/dist/seed-bootstrap.js.map +1 -1
  80. package/dist/seed-trust-policy.d.ts +63 -0
  81. package/dist/seed-trust-policy.js +56 -0
  82. package/dist/seed-trust-policy.js.map +1 -0
  83. package/dist/strand-addr-protocol.d.ts +132 -0
  84. package/dist/strand-addr-protocol.js +313 -0
  85. package/dist/strand-addr-protocol.js.map +1 -0
  86. package/dist/strand-cohort.d.ts +54 -0
  87. package/dist/strand-cohort.js +38 -0
  88. package/dist/strand-cohort.js.map +1 -0
  89. package/dist/strand-database.d.ts +54 -6
  90. package/dist/strand-database.js +88 -89
  91. package/dist/strand-database.js.map +1 -1
  92. package/dist/strand-formation-manager.d.ts +79 -19
  93. package/dist/strand-formation-manager.js +196 -104
  94. package/dist/strand-formation-manager.js.map +1 -1
  95. package/dist/strand-formation-protocol.d.ts +162 -0
  96. package/dist/strand-formation-protocol.js +279 -0
  97. package/dist/strand-formation-protocol.js.map +1 -0
  98. package/dist/strand-instance-manager.d.ts +73 -4
  99. package/dist/strand-instance-manager.js +167 -41
  100. package/dist/strand-instance-manager.js.map +1 -1
  101. package/dist/strand-member-key.d.ts +29 -0
  102. package/dist/strand-member-key.js +38 -0
  103. package/dist/strand-member-key.js.map +1 -0
  104. package/dist/strand-member-registry.d.ts +98 -0
  105. package/dist/strand-member-registry.js +129 -0
  106. package/dist/strand-member-registry.js.map +1 -0
  107. package/dist/strand-membership-writer.d.ts +361 -0
  108. package/dist/strand-membership-writer.js +449 -0
  109. package/dist/strand-membership-writer.js.map +1 -0
  110. package/dist/strand-solicitation.d.ts +77 -2
  111. package/dist/strand-solicitation.js +25 -4
  112. package/dist/strand-solicitation.js.map +1 -1
  113. package/dist/strand-wake-payload.d.ts +30 -0
  114. package/dist/strand-wake-payload.js +20 -0
  115. package/dist/strand-wake-payload.js.map +1 -0
  116. package/dist/strand-wake-protocol.d.ts +114 -0
  117. package/dist/strand-wake-protocol.js +257 -0
  118. package/dist/strand-wake-protocol.js.map +1 -0
  119. package/dist/strand-watcher.d.ts +6 -2
  120. package/dist/strand-watcher.js +57 -17
  121. package/dist/strand-watcher.js.map +1 -1
  122. package/dist/types.d.ts +413 -23
  123. package/dist/types.js +22 -5
  124. package/dist/types.js.map +1 -1
  125. package/package.json +14 -8
  126. package/src/authority-key.ts +68 -0
  127. package/src/cadre-node.ts +2937 -929
  128. package/src/canonical-datetime.ts +39 -0
  129. package/src/canonical-json.ts +30 -0
  130. package/src/control-cohort.ts +103 -0
  131. package/src/control-database.ts +931 -383
  132. package/src/control-formation-recorder.ts +151 -0
  133. package/src/control-schema.ts +198 -0
  134. package/src/control-stream.ts +128 -0
  135. package/src/device-token.ts +89 -0
  136. package/src/diagnostics/connection-path.ts +277 -0
  137. package/src/diagnostics/webrtc-turn-tracker.ts +222 -0
  138. package/src/hibernation-manager.ts +197 -28
  139. package/src/index.ts +273 -79
  140. package/src/key-store-file.ts +203 -0
  141. package/src/key-store.ts +99 -0
  142. package/src/peer-authorization.ts +53 -0
  143. package/src/peer-record.ts +146 -0
  144. package/src/push-credentials.ts +84 -0
  145. package/src/push-fanout.ts +272 -0
  146. package/src/push-notifier-apns.ts +243 -0
  147. package/src/push-notifier-fcm.ts +221 -0
  148. package/src/push-notifier-shared.ts +28 -0
  149. package/src/push-notifier.ts +93 -0
  150. package/src/schema-verification.ts +18 -4
  151. package/src/seed-bootstrap.ts +504 -171
  152. package/src/seed-trust-policy.ts +107 -0
  153. package/src/strand-addr-protocol.ts +410 -0
  154. package/src/strand-cohort.ts +77 -0
  155. package/src/strand-database.ts +113 -126
  156. package/src/strand-formation-manager.ts +233 -142
  157. package/src/strand-formation-protocol.ts +391 -0
  158. package/src/strand-instance-manager.ts +207 -56
  159. package/src/strand-member-key.ts +39 -0
  160. package/src/strand-member-registry.ts +177 -0
  161. package/src/strand-membership-writer.ts +640 -0
  162. package/src/strand-solicitation.ts +93 -4
  163. package/src/strand-wake-payload.ts +32 -0
  164. package/src/strand-wake-protocol.ts +331 -0
  165. package/src/strand-watcher.ts +61 -17
  166. package/src/types.ts +1012 -575
@@ -1,383 +1,931 @@
1
- import debug from 'debug';
2
- import { toString as uint8ArrayToString } from 'uint8arrays';
3
- import { Database, registerPlugin } from '@quereus/quereus';
4
- import cryptoPlugin from '@optimystic/quereus-plugin-crypto/plugin';
5
- import optimysticPlugin from '@optimystic/quereus-plugin-optimystic/plugin';
6
- import { digest, randomBytes } from '@optimystic/quereus-plugin-crypto';
7
- import type { Libp2p } from '@libp2p/interface';
8
- import type { IRepo } from '@optimystic/db-core';
9
- import type { StrandRow } from './types.js';
10
-
11
- const log = debug('sereus:cadre:control-db');
12
- const timing = debug('sereus:cadre:timing');
13
-
14
- /**
15
- * Embedded control schema for cross-platform compatibility.
16
- * This schema is embedded directly in the code to work in React Native and other
17
- * environments where file system access is not available.
18
- */
19
- const CONTROL_SCHEMA = `-- This manages a Sereus party's cadre, or set of nodes, and their participation in strands (networks)
20
- declare schema CadreControl {
21
- -- A key that can authorize various control changes
22
- table AuthorityKey (
23
- Key text primary key,
24
- constraint Authorized check (
25
- -- Bootstrap: first authority key needs no existing authorization
26
- (select count(1) from AuthorityKey) <= 1
27
-
28
- -- Old authority can authorize by signature and transaction stamp id (not repeatable)
29
- or (old.Key is not null and old.Key = context.AuthorityKey and verify(digest(context.StampId, 'sha256', 'utf8'), context.Signature, old.Key, 'ed25519'))
30
-
31
- -- or other authorities can authorize by signature and transaction stamp id (not repeatable)
32
- or exists (select 1 from AuthorityKey A where A.Key = context.AuthorityKey and verify(digest(context.StampId, 'sha256', 'utf8'), context.Signature, A.Key, 'ed25519'))
33
- )
34
- ) with context (AuthorityKey text null, Signature text null, StampId text);
35
-
36
- -- A key that can validate a strand formation disclosure
37
- table ValidationKey (
38
- Key text primary key,
39
- constraint Authorized check (
40
- -- Authorities can authorize by signature and transaction stamp id (not repeatable)
41
- exists (select 1 from AuthorityKey A where A.Key = context.AuthorityKey and verify(digest(context.StampId, 'sha256', 'utf8'), context.Signature, A.Key, 'ed25519'))
42
- )
43
- ) with context (AuthorityKey text, StampId text, Signature text);
44
-
45
- -- A network of members sharing an sApp database, each contributing peer nodes (their cadre) to the overall cohort
46
- -- Cadre peers should participate in each of these strands
47
- table Strand (
48
- Id text primary key, -- UUID
49
- MemberPrivateKey text null unique, -- Our private key as a member of this strand
50
- Type text, -- Types: 'o' = Open, 'c' = Closed -- Open can still control writes in the sApp, but only Closed controls reads
51
- constraint Authorized check (
52
- -- Authorized by authority signature and transaction stamp id (not repeatable)
53
- exists (select 1 from AuthorityKey A where A.Key = context.AuthorityKey and verify(digest(context.StampId, 'sha256', 'utf8'), context.Signature, A.Key, 'ed25519'))
54
-
55
- -- or authorized by a cadre peer who has received a valid formation invitation
56
- or exists (select 1 from FormationUsage FU where FU.StrandId = new.Id)
57
- ),
58
- -- TODO: constraint to ensure member private key only if closed
59
- ) with context (AuthorityKey text, StampId text, Signature text);
60
-
61
- -- A peer (node) that is part of the cadre
62
- table CadrePeer (
63
- PeerId text primary key,
64
- Multiaddr text,
65
- constraint AuthorizedInsert check on insert, delete (
66
- -- Authorized by an authority key
67
- -- Use utf8 input encoding since peer IDs are base58btc strings, not base64url
68
- exists (select 1 from AuthorityKey A where A.Key = context.AuthorityKey and verify(digest(coalesce(new.PeerId, old.PeerId), 'sha256', 'utf8'), context.Signature, A.Key, 'ed25519'))
69
- ),
70
- constraint AuthorizedUpdate check on update (
71
- -- Peer can change its own multiaddr (using utf8 encoding for both values)
72
- verify(digest(new.PeerId, 'sha256', 'utf8') || digest(new.Multiaddr, 'sha256', 'utf8'), context.Signature, new.PeerId, 'ed25519')
73
- -- or authorized by an authority key
74
- or exists (select 1 from AuthorityKey A where A.Key = context.AuthorityKey and verify(digest(new.PeerId, 'sha256', 'utf8'), context.Signature, A.Key, 'ed25519'))
75
- )
76
- ) with context (AuthorityKey text null, Signature text);
77
-
78
- -- An open invitation to form a strand with this party
79
- table FormationInvite (
80
- Token text primary key, -- Just a random string
81
- sAppId text, -- The app for the strand that will be formed
82
- ExpiresAt datetime null,
83
- TotalUses int null check (TotalUses >= 0),
84
- ValidationUrl text null, -- Web hook - send disclosure, IP address...
85
- constraint AuthorizedAddOrRemove check on insert, delete (
86
- -- Authorized by an authority key to add or remove this invite
87
- exists (select 1 from AuthorityKey A where A.Key = context.AuthorityKey and verify(digest(context.StampId), context.Signature, A.Key))
88
- )
89
- ) with context (AuthorityKey text, StampId text, Signature text);
90
-
91
- table FormationUsage (
92
- Token text,
93
- UseNumber int,
94
- Disclosure text,
95
- StrandId text,
96
- primary key (Token, UseNumber),
97
- constraint InsertOnly check on update, delete (false),
98
- constraint Monotonic check (
99
- new.UseNumber = coalesce((select max(UseNumber) from FormationUsage U where U.Token = new.Token), 0) + 1
100
- ),
101
- constraint Authorized check on insert (
102
- -- Satisfies an invitation
103
- exists (
104
- select 1 from FormationInvite FI
105
- where FI.Token = new.Token
106
- and (FI.TotalUses is null or FI.TotalUses >= new.UseNumber)
107
- and (FI.ExpiresAt is null or FI.ExpiresAt > context.Now)
108
- and (FI.ValidationUrl is null or verify(digest(new.Token, new.Disclosure), context.ValidationSignature, context.ValidationKey))
109
- )
110
- ),
111
- constraint StrandExists check (exists (select 1 from Strand S where S.Id = new.StrandId)),
112
- ) with context (PeerId text, PeerSignature text, Now datetime, ValidationKey text null, ValidationSignature text null);
113
- }
114
-
115
- apply schema CadreControl;`;
116
-
117
- /**
118
- * Generate a unique stamp ID for transaction authorization.
119
- * Format: 32 bytes base64url encoded
120
- * - First 16 bytes: SHA-256 hash of peer ID (for distributed uniqueness)
121
- * - Last 16 bytes: Random bytes (for collision resistance)
122
- */
123
- function generateStampId(peerId: string): string {
124
- // Hash the peer ID and get first 16 bytes (128 bits)
125
- const peerIdHash = digest(peerId, 'sha256', 'utf8', 'bytes') as Uint8Array;
126
- const peerIdHashPart = peerIdHash.slice(0, 16);
127
-
128
- // Generate 16 random bytes
129
- const randomPart = randomBytes(128, 'bytes') as Uint8Array;
130
-
131
- // Combine peer ID hash and random bytes
132
- const combined = new Uint8Array(32);
133
- combined.set(peerIdHashPart, 0);
134
- combined.set(randomPart, 16);
135
-
136
- // Convert to base64url
137
- return uint8ArrayToString(combined, 'base64url');
138
- }
139
-
140
- /**
141
- * Minimal interface for the CollectionFactory returned by the optimystic plugin.
142
- * We only need the methods we actually use.
143
- */
144
- interface CollectionFactory {
145
- registerLibp2pNode(networkName: string, node: Libp2p, coordinatedRepo: IRepo): void;
146
- shutdown(): Promise<void>;
147
- }
148
-
149
- /** Result of registering the optimystic plugin */
150
- interface OptimysticPluginResult {
151
- collectionFactory: CollectionFactory;
152
- vtables: Array<{ name: string; module: unknown; auxData: unknown }>;
153
- functions: Array<{ schema: unknown }>;
154
- [key: string]: unknown;
155
- }
156
-
157
- export interface ControlDatabaseConfig {
158
- /** Party ID for the control network */
159
- partyId: string;
160
- /**
161
- * Optional path to the control schema file.
162
- * If not provided, uses the embedded schema for cross-platform compatibility.
163
- * Only use this if you need to override the default schema (e.g., for testing).
164
- */
165
- schemaPath?: string;
166
- /** Libp2p node for the control network (injected) */
167
- libp2pNode: Libp2p;
168
- /** Coordinated repo from the libp2p node */
169
- coordinatedRepo: IRepo;
170
- }
171
-
172
- /**
173
- * ControlDatabase manages the CadreControl schema using Quereus with Optimystic backend.
174
- * It provides typed query methods for accessing control network data.
175
- */
176
- export class ControlDatabase {
177
- private db: Database | null = null;
178
- private collectionFactory: CollectionFactory | null = null;
179
- private readonly config: ControlDatabaseConfig;
180
- private initialized = false;
181
-
182
- constructor(config: ControlDatabaseConfig) {
183
- this.config = config;
184
- }
185
-
186
- /**
187
- * Initialize the database - load schema and register plugins
188
- */
189
- async initialize(): Promise<void> {
190
- if (this.initialized) {
191
- log('ControlDatabase already initialized');
192
- return;
193
- }
194
-
195
- log('Initializing ControlDatabase for party: %s', this.config.partyId);
196
-
197
- // Create database instance
198
- this.db = new Database();
199
-
200
- // Register crypto plugin (provides digest, sign, verify functions)
201
- let t0 = performance.now();
202
- await registerPlugin(this.db, cryptoPlugin);
203
- timing('[controlDb] cryptoPlugin: %dms', Math.round(performance.now() - t0));
204
- log('Registered crypto plugin');
205
-
206
- // Register optimystic plugin with network transactor as default
207
- t0 = performance.now();
208
- const networkName = `control-${this.config.partyId}`;
209
- const pluginResult = optimysticPlugin(this.db, {
210
- default_transactor: 'network',
211
- default_key_network: 'libp2p',
212
- default_network_name: networkName,
213
- enable_cache: true,
214
- }) as OptimysticPluginResult;
215
-
216
- // Register vtables and functions manually since we need access to collectionFactory
217
- for (const vtable of pluginResult.vtables as Array<{ name: string; module: unknown; auxData: unknown }>) {
218
- this.db.registerModule(vtable.name, vtable.module as any, vtable.auxData);
219
- }
220
- for (const func of pluginResult.functions as Array<{ schema: unknown }>) {
221
- this.db.registerFunction(func.schema as any);
222
- }
223
- timing('[controlDb] optimysticPlugin: %dms', Math.round(performance.now() - t0));
224
-
225
- this.collectionFactory = pluginResult.collectionFactory;
226
-
227
- // Inject the libp2p node into the collection factory
228
- t0 = performance.now();
229
- this.collectionFactory.registerLibp2pNode(
230
- networkName,
231
- this.config.libp2pNode,
232
- this.config.coordinatedRepo
233
- );
234
- timing('[controlDb] registerLibp2pNode: %dms', Math.round(performance.now() - t0));
235
- log('Registered libp2p node with collection factory');
236
-
237
- // Load and execute the schema
238
- t0 = performance.now();
239
- await this.loadSchema();
240
- timing('[controlDb] loadSchema: %dms', Math.round(performance.now() - t0));
241
-
242
- this.initialized = true;
243
- log('ControlDatabase initialized successfully');
244
- }
245
-
246
- private async loadSchema(): Promise<void> {
247
- let schemaContent: string;
248
-
249
- if (this.config.schemaPath) {
250
- // Load from file if explicitly provided (for testing or custom schemas)
251
- // This only works in Node.js environments
252
- log('Loading schema from file: %s', this.config.schemaPath);
253
-
254
- // Check if we're in a Node.js environment
255
- if (typeof process !== 'undefined' && process.versions?.node) {
256
- try {
257
- // Use require to conditionally load fs only in Node.js
258
- // This won't be bundled by React Native's Metro bundler
259
- const fs = require('fs/promises');
260
- schemaContent = await fs.readFile(this.config.schemaPath, 'utf-8');
261
- } catch (error) {
262
- throw new Error(
263
- `Failed to load schema from ${this.config.schemaPath}: ${error instanceof Error ? error.message : String(error)}`
264
- );
265
- }
266
- } else {
267
- throw new Error(
268
- 'Loading schema from file is not supported in React Native. ' +
269
- 'Remove the schemaPath option to use the embedded schema instead.'
270
- );
271
- }
272
- } else {
273
- // Use embedded schema for cross-platform compatibility
274
- log('Using embedded control schema');
275
- schemaContent = CONTROL_SCHEMA;
276
- }
277
-
278
- await this.db!.exec(schemaContent);
279
- log('Schema loaded and executed');
280
- }
281
-
282
- /**
283
- * Query all strands from the control database
284
- */
285
- async queryStrands(): Promise<StrandRow[]> {
286
- this.ensureInitialized();
287
- const results: StrandRow[] = [];
288
- for await (const row of this.db!.eval('select Id, MemberPrivateKey, Type from CadreControl.Strand')) {
289
- results.push({
290
- Id: row.Id as string,
291
- MemberPrivateKey: row.MemberPrivateKey as string | null,
292
- Type: row.Type as 'o' | 'c',
293
- });
294
- }
295
- return results;
296
- }
297
-
298
- /**
299
- * Get the underlying database for advanced queries
300
- */
301
- getDatabase(): Database {
302
- this.ensureInitialized();
303
- return this.db!;
304
- }
305
-
306
- /**
307
- * Insert the initial authority key (bootstrap - no existing authorities required)
308
- */
309
- async insertAuthorityKey(key: string): Promise<void> {
310
- this.ensureInitialized();
311
- log('Inserting authority key: %s', key);
312
-
313
- // For bootstrap, context values are not verified since no existing authorities
314
- // Use fully qualified table name since schema is CadreControl
315
- // StampId() provides the transaction's unique stamp from the optimystic transaction
316
- await this.db!.exec(`
317
- insert into CadreControl.AuthorityKey (Key)
318
- with context AuthorityKey = null, Signature = null, StampId = StampId()
319
- values (?)
320
- `, [key]);
321
- log('Authority key inserted');
322
- }
323
-
324
- /**
325
- * Insert a strand into the control database using authority signature.
326
- * This starts a transaction, gets the StampId, signs it, and inserts the strand.
327
- *
328
- * @param strandId - Unique identifier for the strand
329
- * @param type - Strand type: 'o' for open, 'c' for closed
330
- * @param authorityKey - Public key of the authorizing authority
331
- * @param signStampId - Function to sign the stamp ID with the authority's private key
332
- * @param memberPrivateKey - Optional private key for membership in closed strands
333
- */
334
- async insertStrand(
335
- strandId: string,
336
- type: 'o' | 'c',
337
- authorityKey: string,
338
- signStampId: (stampId: string) => string,
339
- memberPrivateKey?: string
340
- ): Promise<void> {
341
- this.ensureInitialized();
342
- log('Inserting strand: %s (type: %s)', strandId, type);
343
-
344
- // Generate a unique stamp ID using the peer ID for distributed uniqueness
345
- const peerId = this.config.libp2pNode.peerId.toString();
346
- const stampId = generateStampId(peerId);
347
-
348
- // Sign the stamp ID with the authority key
349
- const signature = signStampId(stampId);
350
-
351
- // Insert with the signed stamp ID
352
- await this.db!.exec(`
353
- insert into CadreControl.Strand (Id, Type, MemberPrivateKey)
354
- with context AuthorityKey = ?, Signature = ?, StampId = ?
355
- values (?, ?, ?)
356
- `, [authorityKey, signature, stampId, strandId, type, memberPrivateKey ?? null]);
357
-
358
- log('Strand inserted: %s', strandId);
359
- }
360
-
361
- /**
362
- * Close the database and cleanup resources
363
- */
364
- async close(): Promise<void> {
365
- if (this.collectionFactory) {
366
- await this.collectionFactory.shutdown();
367
- this.collectionFactory = null;
368
- }
369
- if (this.db) {
370
- this.db.close();
371
- this.db = null;
372
- }
373
- this.initialized = false;
374
- log('ControlDatabase closed');
375
- }
376
-
377
- private ensureInitialized(): void {
378
- if (!this.initialized || !this.db) {
379
- throw new Error('ControlDatabase not initialized. Call initialize() first.');
380
- }
381
- }
382
- }
383
-
1
+ import debug from 'debug';
2
+ import { toString as uint8ArrayToString } from 'uint8arrays';
3
+ import { Database, registerPlugin } from '@quereus/quereus';
4
+ import type { VTablePluginInfo, FunctionPluginInfo } from '@quereus/quereus';
5
+ import cryptoPlugin from '@optimystic/quereus-plugin-crypto/plugin';
6
+ import optimysticPlugin from '@optimystic/quereus-plugin-optimystic/plugin';
7
+ import { digest, randomBytes } from '@optimystic/quereus-plugin-crypto';
8
+ import type { Libp2p } from '@libp2p/interface';
9
+ import type { IRepo } from '@optimystic/db-core';
10
+ import type { StrandRow, PeerAddressRecord, DeviceTokenRecord, PushPlatform } from './types.js';
11
+ import { CONTROL_SCHEMA } from './control-schema.js';
12
+ import { canonicalDatetime } from './canonical-datetime.js';
13
+
14
+ const log = debug('sereus:cadre:control-db');
15
+ const timing = debug('sereus:cadre:timing');
16
+
17
+ /**
18
+ * Generate a unique stamp ID for transaction authorization.
19
+ * Format: 32 bytes base64url encoded
20
+ * - First 16 bytes: SHA-256 hash of peer ID (for distributed uniqueness)
21
+ * - Last 16 bytes: Random bytes (for collision resistance)
22
+ */
23
+ function generateStampId(peerId: string): string {
24
+ // Hash the peer ID and get first 16 bytes (128 bits). A purely local ID
25
+ // generator: never signed/verified against SQL, so the framed single-field
26
+ // digest is fine (the changed framing is not cross-checked anywhere).
27
+ const peerIdHash = digest([peerId], 'sha256', 'bytes') as Uint8Array;
28
+ const peerIdHashPart = peerIdHash.slice(0, 16);
29
+
30
+ // Generate 16 random bytes
31
+ const randomPart = randomBytes(128, 'bytes') as Uint8Array;
32
+
33
+ // Combine peer ID hash and random bytes
34
+ const combined = new Uint8Array(32);
35
+ combined.set(peerIdHashPart, 0);
36
+ combined.set(randomPart, 16);
37
+
38
+ // Convert to base64url
39
+ return uint8ArrayToString(combined, 'base64url');
40
+ }
41
+
42
+ /**
43
+ * Parse a stored Quereus `datetime` value into epoch milliseconds.
44
+ *
45
+ * Quereus canonicalises a `datetime` column to a bare UTC `PlainDateTime` string
46
+ * (e.g. `2026-06-04T12:34:56`, no `Z`). JS `Date` reads a timezone-less
47
+ * date-time as LOCAL, so we append `Z` when no offset/zone is present to keep the
48
+ * value anchored to UTC. Numbers (already epoch ms) pass through.
49
+ */
50
+ function parseStoredDatetimeMs(value: string | number): number {
51
+ if (typeof value === 'number') return value;
52
+ const hasZone = /[zZ]$|[+-]\d\d:?\d\d$/.test(value);
53
+ return new Date(hasZone ? value : `${value}Z`).getTime();
54
+ }
55
+
56
+ /**
57
+ * Build the canonical authorization message that authority signatures are bound to.
58
+ *
59
+ * The message is a SINGLE framed SHA-256 digest over the ordered tuple of fields (the
60
+ * crypto plugin's injective multi-field encoding), in a fixed field order, with the
61
+ * single-use StampId as the final field:
62
+ *
63
+ * message = sha256(encodeFields([field_1, field_2, ..., StampId])) // raw digest bytes
64
+ *
65
+ * ed25519 signs these raw digest bytes DIRECTLY (no second hash). The SQL constraints
66
+ * verify the identical bytes with one variadic call
67
+ * (`verify(digest(field_1, ..., StampId), context.Signature, A.Key, 'ed25519')`): SQL
68
+ * `digest(...)` returns the base64url string of the same digest, which `verify`'s default
69
+ * base64url input encoding decodes back to those raw bytes — so signer and verifier
70
+ * operate on the same bytes. Every field is TEXT on both sides (the SQL columns are
71
+ * `cast(... as text)` / `coalesce(...,'')`; the TS args are strings), so the per-field
72
+ * type tags agree. This binds the signature to the row contents (not a bare stamp),
73
+ * closing the captured-stamp replay / privilege-escalation hole. Single source of truth:
74
+ * every signed writer (and every test/harness signer) MUST build the message through this
75
+ * function in the schema's field order, or `verify` will reject the row.
76
+ */
77
+ export function buildAuthorizationMessage(fields: string[]): Uint8Array {
78
+ return digest([...fields], 'sha256', 'bytes') as Uint8Array;
79
+ }
80
+
81
+ /**
82
+ * Minimal interface for the CollectionFactory returned by the optimystic plugin.
83
+ * We only need the methods we actually use.
84
+ */
85
+ interface CollectionFactory {
86
+ registerLibp2pNode(networkName: string, node: Libp2p, coordinatedRepo: IRepo): void;
87
+ shutdown(): Promise<void>;
88
+ }
89
+
90
+ /** Result of registering the optimystic plugin */
91
+ interface OptimysticPluginResult {
92
+ collectionFactory: CollectionFactory;
93
+ vtables: VTablePluginInfo[];
94
+ functions: FunctionPluginInfo[];
95
+ /**
96
+ * Hydrate Quereus's in-memory catalog from persisted optimystic vtab schemas.
97
+ * Must run BEFORE applying CONTROL_SCHEMA on a warm restart so the declarative
98
+ * diff sees the existing tables and skips re-emitting CREATE TABLE / CREATE INDEX
99
+ * for each persisted control object. Idempotent; `{ tables: 0, indexes: 0 }` on a
100
+ * cold/in-memory start. Mirrors the strand path's hydrate-before-apply (see
101
+ * compose-strand.ts).
102
+ */
103
+ hydrate: (db: Database) => Promise<{ tables: number; indexes: number }>;
104
+ [key: string]: unknown;
105
+ }
106
+
107
+ /**
108
+ * Names of the CadreControl tables that {@link ControlDatabase.countRows} can
109
+ * read. Constraining to this union keeps the dynamic `from` clause off the
110
+ * SQL-injection surface and hands callers (e.g. the integration harness's
111
+ * `waitForControlSync`) a typo-proof table argument.
112
+ */
113
+ export type ControlTable =
114
+ | 'AuthorityKey'
115
+ | 'ValidationKey'
116
+ | 'Strand'
117
+ | 'CadrePeer'
118
+ | 'DeviceToken'
119
+ | 'FormationInvite'
120
+ | 'FormationUsage';
121
+
122
+ /** Runtime guard mirroring {@link ControlTable} for the dynamic-`from` count. */
123
+ const CONTROL_TABLES: ReadonlySet<ControlTable> = new Set<ControlTable>([
124
+ 'AuthorityKey',
125
+ 'ValidationKey',
126
+ 'Strand',
127
+ 'CadrePeer',
128
+ 'DeviceToken',
129
+ 'FormationInvite',
130
+ 'FormationUsage',
131
+ ]);
132
+
133
+ export interface ControlDatabaseConfig {
134
+ /** Party ID for the control network */
135
+ partyId: string;
136
+ /**
137
+ * Optional path to the control schema file.
138
+ * If not provided, uses the embedded schema for cross-platform compatibility.
139
+ * Only use this if you need to override the default schema (e.g., for testing).
140
+ */
141
+ schemaPath?: string;
142
+ /** Libp2p node for the control network (injected) */
143
+ libp2pNode: Libp2p;
144
+ /** Coordinated repo from the libp2p node */
145
+ coordinatedRepo: IRepo;
146
+ }
147
+
148
+ /**
149
+ * ControlDatabase manages the CadreControl schema using Quereus with Optimystic backend.
150
+ * It provides typed query methods for accessing control network data.
151
+ */
152
+ export class ControlDatabase {
153
+ private db: Database | null = null;
154
+ private collectionFactory: CollectionFactory | null = null;
155
+ private readonly config: ControlDatabaseConfig;
156
+ private initialized = false;
157
+
158
+ constructor(config: ControlDatabaseConfig) {
159
+ this.config = config;
160
+ }
161
+
162
+ /**
163
+ * Initialize the database - load schema and register plugins
164
+ */
165
+ async initialize(): Promise<void> {
166
+ if (this.initialized) {
167
+ log('ControlDatabase already initialized');
168
+ return;
169
+ }
170
+
171
+ log('Initializing ControlDatabase for party: %s', this.config.partyId);
172
+
173
+ // Create database instance
174
+ this.db = new Database();
175
+
176
+ // Register crypto plugin (provides digest, sign, verify functions)
177
+ let t0 = performance.now();
178
+ await registerPlugin(this.db, cryptoPlugin);
179
+ timing('[controlDb] cryptoPlugin: %dms', Math.round(performance.now() - t0));
180
+ log('Registered crypto plugin');
181
+
182
+ // Register optimystic plugin with network transactor as default
183
+ t0 = performance.now();
184
+ const networkName = `control-${this.config.partyId}`;
185
+ const pluginResult = optimysticPlugin(this.db, {
186
+ default_transactor: 'network',
187
+ default_key_network: 'libp2p',
188
+ default_network_name: networkName,
189
+ enable_cache: true,
190
+ }) as unknown as OptimysticPluginResult;
191
+
192
+ // Register vtables and functions manually since we need access to collectionFactory
193
+ for (const vtable of pluginResult.vtables) {
194
+ this.db.registerModule(vtable.name, vtable.module, vtable.auxData);
195
+ }
196
+ for (const func of pluginResult.functions) {
197
+ this.db.registerFunction(func.schema);
198
+ }
199
+ timing('[controlDb] optimysticPlugin: %dms', Math.round(performance.now() - t0));
200
+
201
+ this.collectionFactory = pluginResult.collectionFactory;
202
+
203
+ // Inject the libp2p node into the collection factory
204
+ t0 = performance.now();
205
+ this.collectionFactory.registerLibp2pNode(
206
+ networkName,
207
+ this.config.libp2pNode,
208
+ this.config.coordinatedRepo
209
+ );
210
+ timing('[controlDb] registerLibp2pNode: %dms', Math.round(performance.now() - t0));
211
+ log('Registered libp2p node with collection factory');
212
+
213
+ // Network-back the control tables. CONTROL_SCHEMA's `declare schema CadreControl
214
+ // { table ... }` tables carry NO per-table `using optimystic(...)`, so storage is
215
+ // chosen by the database's DEFAULT vtab. Routing the default to optimystic (with
216
+ // the network transactor + this party's control network) is what makes a control
217
+ // write replicate peer-to-peer — exactly what connectToStrand does for strand
218
+ // tables (compose-strand.ts). Without these two calls the tables fall back to
219
+ // Quereus's in-memory vtab and never converge across the cadre.
220
+ this.db.setDefaultVtabName('optimystic');
221
+ this.db.setDefaultVtabArgs({
222
+ networkName,
223
+ transactor: 'network',
224
+ keyNetwork: 'libp2p',
225
+ });
226
+ log('Set default vtab to optimystic (networkName=%s, transactor=network)', networkName);
227
+
228
+ // Hydrate Quereus's catalog from any persisted optimystic vtab schemas BEFORE
229
+ // applying CONTROL_SCHEMA, so a warm restart with persistent storage diffs the
230
+ // control DDL against the already-present tables and re-emits nothing — the same
231
+ // warm-start regression connectToStrand's hydrate-before-apply guards against.
232
+ // No-op on a cold / in-memory start (`{ tables: 0, indexes: 0 }`).
233
+ t0 = performance.now();
234
+ const hydrated = await pluginResult.hydrate(this.db);
235
+ timing('[controlDb] hydrate: %dms (tables=%d, indexes=%d)',
236
+ Math.round(performance.now() - t0), hydrated.tables, hydrated.indexes);
237
+ log('Hydrated control catalog (tables=%d, indexes=%d)', hydrated.tables, hydrated.indexes);
238
+
239
+ // Load and execute the schema
240
+ t0 = performance.now();
241
+ await this.loadSchema();
242
+ timing('[controlDb] loadSchema: %dms', Math.round(performance.now() - t0));
243
+
244
+ this.initialized = true;
245
+ log('ControlDatabase initialized successfully');
246
+ }
247
+
248
+ private async loadSchema(): Promise<void> {
249
+ let schemaContent: string;
250
+
251
+ if (this.config.schemaPath) {
252
+ // Load from file if explicitly provided (for testing or custom schemas)
253
+ // This only works in Node.js environments
254
+ log('Loading schema from file: %s', this.config.schemaPath);
255
+
256
+ // Check if we're in a Node.js environment
257
+ if (typeof process !== 'undefined' && process.versions?.node) {
258
+ try {
259
+ // Use require to conditionally load fs only in Node.js.
260
+ // This won't be bundled by React Native's Metro bundler — a dynamic
261
+ // import() would be statically picked up by Metro, so require is
262
+ // intentional here (cross-platform constraint, not lazy CommonJS).
263
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
264
+ const fs = require('fs/promises');
265
+ schemaContent = await fs.readFile(this.config.schemaPath, 'utf-8');
266
+ } catch (error) {
267
+ throw new Error(
268
+ `Failed to load schema from ${this.config.schemaPath}: ${error instanceof Error ? error.message : String(error)}`,
269
+ { cause: error }
270
+ );
271
+ }
272
+ } else {
273
+ throw new Error(
274
+ 'Loading schema from file is not supported in React Native. ' +
275
+ 'Remove the schemaPath option to use the embedded schema instead.'
276
+ );
277
+ }
278
+ } else {
279
+ // Use embedded schema for cross-platform compatibility
280
+ log('Using embedded control schema');
281
+ schemaContent = CONTROL_SCHEMA;
282
+ }
283
+
284
+ await this.db!.exec(schemaContent);
285
+ log('Schema loaded and executed');
286
+ }
287
+
288
+ /**
289
+ * Query all strands from the control database
290
+ */
291
+ async queryStrands(): Promise<StrandRow[]> {
292
+ this.ensureInitialized();
293
+ const results: StrandRow[] = [];
294
+ for await (const row of this.db!.eval('select Id, MemberPrivateKey, Type from CadreControl.Strand')) {
295
+ results.push({
296
+ Id: row.Id as string,
297
+ MemberPrivateKey: row.MemberPrivateKey as string | null,
298
+ Type: row.Type as 'o' | 'c',
299
+ });
300
+ }
301
+ return results;
302
+ }
303
+
304
+ /**
305
+ * Read a single strand row by id, or null when absent. Single-row sibling of
306
+ * {@link queryStrands}; the responder uses it to read a host strand's
307
+ * `MemberPrivateKey` (the closed-strand read-gating secret) for delivery to a
308
+ * validated invitee during provision-then-record formation.
309
+ */
310
+ async queryStrand(strandId: string): Promise<StrandRow | null> {
311
+ this.ensureInitialized();
312
+ for await (const row of this.db!.eval(
313
+ 'select Id, MemberPrivateKey, Type from CadreControl.Strand where Id = ?',
314
+ [strandId]
315
+ )) {
316
+ return {
317
+ Id: row.Id as string,
318
+ MemberPrivateKey: row.MemberPrivateKey as string | null,
319
+ Type: row.Type as 'o' | 'c',
320
+ };
321
+ }
322
+ return null;
323
+ }
324
+
325
+ /**
326
+ * Count rows in a CadreControl table as seen by THIS database instance.
327
+ *
328
+ * `table` is validated against {@link CONTROL_TABLES} before it is interpolated
329
+ * into the `from` clause: the names are not user input, but the check keeps the
330
+ * dynamic query off the injection surface and fails loudly on a typo instead of
331
+ * emitting a malformed statement. The count reflects only the rows this node's
332
+ * control DB has converged on — in the integration harness that is the authority
333
+ * node (one ControlDatabase per party), i.e. the authoritative control-network
334
+ * view, not a per-drone convergence guarantee.
335
+ */
336
+ async countRows(table: ControlTable): Promise<number> {
337
+ this.ensureInitialized();
338
+ if (!CONTROL_TABLES.has(table)) {
339
+ throw new Error(`Unknown CadreControl table: ${table}`);
340
+ }
341
+ for await (const row of this.db!.eval(`select count(1) as Count from CadreControl.${table}`)) {
342
+ return (row.Count as number) ?? 0;
343
+ }
344
+ return 0;
345
+ }
346
+
347
+ /**
348
+ * Get the underlying database for advanced queries
349
+ */
350
+ getDatabase(): Database {
351
+ this.ensureInitialized();
352
+ return this.db!;
353
+ }
354
+
355
+ /**
356
+ * Check whether any authority key exists in the control database.
357
+ * Used to decide whether a fresh-party genesis insert is required.
358
+ */
359
+ async hasAuthorityKey(): Promise<boolean> {
360
+ this.ensureInitialized();
361
+ for await (const row of this.db!.eval('select count(1) as Count from CadreControl.AuthorityKey')) {
362
+ return (row.Count as number) > 0;
363
+ }
364
+ return false;
365
+ }
366
+
367
+ /**
368
+ * Idempotent genesis: insert `key` as the founding authority key only when
369
+ * the party has none yet. Returns true if it inserted, false if an authority
370
+ * key already existed (so a repeat `--authority` start is a no-op).
371
+ */
372
+ async ensureAuthorityKey(key: string): Promise<boolean> {
373
+ this.ensureInitialized();
374
+ if (await this.hasAuthorityKey()) {
375
+ log('Authority key already present; skipping genesis insert');
376
+ return false;
377
+ }
378
+ await this.insertAuthorityKey(key);
379
+ return true;
380
+ }
381
+
382
+ /**
383
+ * Collect every authority key (`CadreControl.AuthorityKey.Key`) as a set.
384
+ *
385
+ * This is the steady-state trust anchor for seeds: a seed's signer key is
386
+ * trusted only if it is already enrolled here (see `SeedTrustPolicy`). It is
387
+ * also the authority-identity source for `queryPeers`, decoupling authority
388
+ * status from the libp2p transport peer ID.
389
+ */
390
+ async getAuthorityKeys(): Promise<Set<string>> {
391
+ this.ensureInitialized();
392
+ const keys = new Set<string>();
393
+ for await (const row of this.db!.eval('select Key from CadreControl.AuthorityKey')) {
394
+ keys.add(row.Key as string);
395
+ }
396
+ return keys;
397
+ }
398
+
399
+ /**
400
+ * Enumerate the CadrePeer rows (cadre membership) for admin/membership reads.
401
+ */
402
+ async queryCadrePeers(): Promise<Array<{ peerId: string; multiaddr: string | null }>> {
403
+ this.ensureInitialized();
404
+ const rows: Array<{ peerId: string; multiaddr: string | null }> = [];
405
+ for await (const row of this.db!.eval('select PeerId, Multiaddr from CadreControl.CadrePeer')) {
406
+ rows.push({
407
+ peerId: row.PeerId as string,
408
+ multiaddr: (row.Multiaddr as string | null) ?? null,
409
+ });
410
+ }
411
+ return rows;
412
+ }
413
+
414
+ /**
415
+ * Read a single peer's address record (the full `CadrePeer` row) by PeerId.
416
+ *
417
+ * Returns null when no row exists. Missing/legacy column values are coalesced
418
+ * to their empty form (`''` key/sig, `[]` addrs, `0` stamp) so the caller's
419
+ * verify/freshness gates uniformly reject an unpublished or malformed row.
420
+ * The split `addrs` re-join to the exact stored `Multiaddr` (split-on-`,` is
421
+ * the inverse of join-on-`,`), so the resolver re-verifies over the same bytes
422
+ * the publisher signed.
423
+ */
424
+ async queryPeerRecord(peerId: string): Promise<PeerAddressRecord | null> {
425
+ this.ensureInitialized();
426
+ for await (const row of this.db!.eval(
427
+ 'select PeerId, PublicKey, Multiaddr, UpdatedAt, Sig from CadreControl.CadrePeer where PeerId = ?',
428
+ [peerId]
429
+ )) {
430
+ const multiaddr = (row.Multiaddr as string | null) ?? '';
431
+ return {
432
+ peerId: row.PeerId as string,
433
+ publicKey: (row.PublicKey as string | null) ?? '',
434
+ addrs: multiaddr.length > 0 ? multiaddr.split(',') : [],
435
+ updatedAt: (row.UpdatedAt as number | null) ?? 0,
436
+ sig: (row.Sig as string | null) ?? '',
437
+ };
438
+ }
439
+ return null;
440
+ }
441
+
442
+ /**
443
+ * Apply a peer's own self-signed address-record update to an existing row.
444
+ *
445
+ * Authorization is carried entirely by the record: the `Sig` column (verified
446
+ * by the `AuthorizedUpdate` self-branch against the stored `PublicKey`) plus
447
+ * the strictly-increasing `UpdatedAt`. No authority key is involved, so this
448
+ * is the refresh path for any member — authority or drone — once its row
449
+ * exists. `PublicKey` is intentionally not in the SET list (it is immutable on
450
+ * self-update and the constraint enforces `new.PublicKey = old.PublicKey`).
451
+ */
452
+ async updateSelfPeerRecord(record: PeerAddressRecord): Promise<void> {
453
+ this.ensureInitialized();
454
+ const multiaddr = record.addrs.join(',');
455
+ await this.db!.exec(`
456
+ update CadreControl.CadrePeer
457
+ with context AuthorityKey = null, Signature = ?
458
+ set Multiaddr = ?, UpdatedAt = ?, Sig = ?
459
+ where PeerId = ?
460
+ `, [record.sig, multiaddr, record.updatedAt, record.sig, record.peerId]);
461
+ log('Self peer record updated: %s (updatedAt=%d)', record.peerId, record.updatedAt);
462
+ }
463
+
464
+ /**
465
+ * Read a single peer's device push token (the full `DeviceToken` row) by PeerId.
466
+ *
467
+ * Returns null when no row exists. Missing/legacy column values are coalesced to
468
+ * their empty form (`''` token/sig, `0` stamp) so the caller's verify/freshness
469
+ * gates uniformly reject an unpublished or malformed row. `platform` is returned
470
+ * verbatim (the resolver validates it against {@link PushPlatform} and re-verifies
471
+ * the self-signature, which covers the platform field).
472
+ */
473
+ async queryDeviceToken(peerId: string): Promise<DeviceTokenRecord | null> {
474
+ this.ensureInitialized();
475
+ for await (const row of this.db!.eval(
476
+ 'select PeerId, Platform, Token, UpdatedAt, Sig from CadreControl.DeviceToken where PeerId = ?',
477
+ [peerId]
478
+ )) {
479
+ return {
480
+ peerId: row.PeerId as string,
481
+ platform: (row.Platform as string ?? '') as PushPlatform,
482
+ token: (row.Token as string | null) ?? '',
483
+ updatedAt: (row.UpdatedAt as number | null) ?? 0,
484
+ sig: (row.Sig as string | null) ?? '',
485
+ };
486
+ }
487
+ return null;
488
+ }
489
+
490
+ /**
491
+ * Apply a peer's own self-signed device-token update to an existing row.
492
+ *
493
+ * Authorization is carried entirely by the record: the `Sig` column (verified by
494
+ * the `DeviceToken.AuthorizedUpdate` self-branch against the stored
495
+ * `CadrePeer.PublicKey`) plus the strictly-increasing `UpdatedAt`. No authority key
496
+ * is involved, so this is the refresh / rotation path for any member once both its
497
+ * `CadrePeer` row (for the PublicKey) and its `DeviceToken` row exist. `PeerId` is
498
+ * intentionally not in the SET list (immutable; the constraint enforces
499
+ * `new.PeerId = old.PeerId`). Mirrors {@link updateSelfPeerRecord}.
500
+ */
501
+ async updateSelfDeviceToken(record: DeviceTokenRecord): Promise<void> {
502
+ this.ensureInitialized();
503
+ await this.db!.exec(`
504
+ update CadreControl.DeviceToken
505
+ with context AuthorityKey = null, Signature = ?
506
+ set Platform = ?, Token = ?, UpdatedAt = ?, Sig = ?
507
+ where PeerId = ?
508
+ `, [record.sig, record.platform, record.token, record.updatedAt, record.sig, record.peerId]);
509
+ log('Self device token updated: %s (platform=%s, updatedAt=%d)', record.peerId, record.platform, record.updatedAt);
510
+ }
511
+
512
+ /**
513
+ * Insert the initial authority key (bootstrap - no existing authorities required)
514
+ */
515
+ async insertAuthorityKey(key: string): Promise<void> {
516
+ this.ensureInitialized();
517
+ log('Inserting authority key: %s', key);
518
+
519
+ // Bootstrap is authorized by the schema's `(select count(1) from AuthorityKey) <= 1`
520
+ // branch, so no signature is needed. We still persist a fresh, unique StampId in the
521
+ // row's own column to satisfy the not-null/unique anti-replay constraint — the StampId
522
+ // is now a real column value, not the optimystic `StampId()` SQL function.
523
+ const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
524
+ await this.db!.exec(`
525
+ insert into CadreControl.AuthorityKey (Key, StampId)
526
+ with context AuthorityKey = null, Signature = null
527
+ values (?, ?)
528
+ `, [key, stampId]);
529
+ log('Authority key inserted');
530
+ }
531
+
532
+ /**
533
+ * Insert a strand into the control database using an authority signature.
534
+ *
535
+ * The authority signs the canonical row-bound authorization message (see
536
+ * {@link buildAuthorizationMessage}) — NOT a bare stamp — so the signature is bound to
537
+ * this strand's contents and cannot be transplanted onto an attacker-chosen row. The
538
+ * StampId is persisted as a unique column for single-use anti-replay.
539
+ *
540
+ * @param strandId - Unique identifier for the strand
541
+ * @param type - Strand type: 'o' for open, 'c' for closed
542
+ * @param authorityKey - Public key of the authorizing authority
543
+ * @param signMessage - Function that ed25519-signs the raw message bytes (no pre-hash)
544
+ * with the authority's private key, returning a base64url signature
545
+ * @param memberPrivateKey - Optional private key for membership in closed strands
546
+ */
547
+ async insertStrand(
548
+ strandId: string,
549
+ type: 'o' | 'c',
550
+ authorityKey: string,
551
+ signMessage: (message: Uint8Array) => string,
552
+ memberPrivateKey?: string
553
+ ): Promise<void> {
554
+ this.ensureInitialized();
555
+ log('Inserting strand: %s (type: %s)', strandId, type);
556
+
557
+ // Generate a unique stamp ID using the peer ID for distributed uniqueness
558
+ const peerId = this.config.libp2pNode.peerId.toString();
559
+ const stampId = generateStampId(peerId);
560
+
561
+ // Field order MUST match the schema's Strand `Authorized` verify:
562
+ // Id, Type, MemberPrivateKey ('' when null), StampId.
563
+ const message = buildAuthorizationMessage([strandId, type, memberPrivateKey ?? '', stampId]);
564
+ const signature = signMessage(message);
565
+
566
+ // StampId is a real, unique column (single-use anti-replay), no longer a context value.
567
+ await this.db!.exec(`
568
+ insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId)
569
+ with context AuthorityKey = ?, Signature = ?
570
+ values (?, ?, ?, ?)
571
+ `, [authorityKey, signature, strandId, type, memberPrivateKey ?? null, stampId]);
572
+
573
+ log('Strand inserted: %s', strandId);
574
+ }
575
+
576
+ /**
577
+ * Insert a validation key into the control database using an authority signature.
578
+ *
579
+ * Mirrors {@link insertStrand}: the authority signs the canonical row-bound
580
+ * authorization message over (Key, StampId), and the StampId is persisted as a unique
581
+ * column for single-use anti-replay. A `ValidationKey` authorizes verifying strand
582
+ * formation disclosures.
583
+ *
584
+ * @param key - The validation public key to enroll
585
+ * @param authorityKey - Public key of the authorizing authority
586
+ * @param signMessage - Function that ed25519-signs the raw message bytes (no pre-hash)
587
+ * with the authority's private key, returning a base64url signature
588
+ */
589
+ async insertValidationKey(
590
+ key: string,
591
+ authorityKey: string,
592
+ signMessage: (message: Uint8Array) => string
593
+ ): Promise<void> {
594
+ this.ensureInitialized();
595
+ log('Inserting validation key: %s', key);
596
+
597
+ const peerId = this.config.libp2pNode.peerId.toString();
598
+ const stampId = generateStampId(peerId);
599
+
600
+ // Field order MUST match the schema's ValidationKey `Authorized` verify: Key, StampId.
601
+ const message = buildAuthorizationMessage([key, stampId]);
602
+ const signature = signMessage(message);
603
+
604
+ await this.db!.exec(`
605
+ insert into CadreControl.ValidationKey (Key, StampId)
606
+ with context AuthorityKey = ?, Signature = ?
607
+ values (?, ?)
608
+ `, [authorityKey, signature, key, stampId]);
609
+
610
+ log('Validation key inserted: %s', key);
611
+ }
612
+
613
+ /**
614
+ * Insert an authority-signed `FormationInvite` (open invitation token).
615
+ *
616
+ * The invite is the on-network record that later authorizes an
617
+ * authority-signature-FREE `Strand` creation: an invited cadre peer redeems it
618
+ * by inserting a matching `FormationUsage` row (see {@link redeemInvitation}),
619
+ * which satisfies the consent branch of `Strand.Authorized`.
620
+ *
621
+ * Like {@link insertStrand}/{@link insertValidationKey}, the authority signs the
622
+ * canonical row-bound authorization message (see {@link buildAuthorizationMessage})
623
+ * over (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId) — NOT a
624
+ * bare stamp — so the signature is bound to this invite's contents and cannot be
625
+ * transplanted onto an attacker-chosen row. The StampId is persisted as a unique
626
+ * column for single-use anti-replay. `FormationInvite.AuthorizedAddOrRemove` gates
627
+ * both insert and delete; its `coalesce(new, old)` verify binds the NEW row on
628
+ * insert and the OLD row on delete.
629
+ *
630
+ * `StrandId` binds the invite to a pre-existing host strand (provision-then-record):
631
+ * when set, a responder redeeming this token records a `FormationUsage` against that
632
+ * strand and returns it (see {@link ControlFormationUsageRecorder.resolveStrand}); a
633
+ * null `StrandId` (the default) leaves the legacy responder-provisions path in place.
634
+ * Like ValidationUrl it is a nullable bound field, signed as `''` when absent.
635
+ *
636
+ * The ExpiresAt and TotalUses message fields must byte-match what the (auto-deferred,
637
+ * because it has a subquery) CHECK sees AFTER column coercion: TotalUses becomes a
638
+ * decimal string (`String(totalUses)` ⇔ `cast(new.TotalUses as text)`) and ExpiresAt
639
+ * becomes the engine's canonical `PlainDateTime` string — sourced here from
640
+ * {@link canonicalDatetime} (a `select datetime(?)` round-trip) rather than a hand-rolled
641
+ * ISO formatter, so signer and verifier agree exactly. A null ExpiresAt / TotalUses /
642
+ * ValidationUrl signs as `''`, matching the schema's `coalesce(..., '')`.
643
+ *
644
+ * @param token - Invitation token (the `FormationInvite` primary key)
645
+ * @param sAppId - The sApp a redeemed strand will use
646
+ * @param authorityKey - Public key of the authorizing authority
647
+ * @param signMessage - ed25519-signs the raw message bytes (no pre-hash),
648
+ * returning a base64url signature — the same callback shape {@link insertStrand} uses
649
+ * @param options - Optional `expiresAtMs` (epoch ms), `totalUses`, `validationUrl`,
650
+ * `strandId` (bind to a pre-existing host strand for provision-then-record)
651
+ */
652
+ async insertFormationInvite(
653
+ token: string,
654
+ sAppId: string,
655
+ authorityKey: string,
656
+ signMessage: (message: Uint8Array) => string,
657
+ options: { expiresAtMs?: number; totalUses?: number; validationUrl?: string; strandId?: string } = {}
658
+ ): Promise<void> {
659
+ this.ensureInitialized();
660
+ log('Inserting formation invite: %s', token);
661
+
662
+ const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
663
+
664
+ // Build each bound field exactly as the deferred CHECK sees it post-coercion:
665
+ // - ExpiresAt: engine-canonical datetime string (or '' when absent), sourced from
666
+ // the engine so it byte-matches the column's stored/coerced form.
667
+ // - TotalUses: decimal string via String(...) (⇔ cast(new.TotalUses as text)), '' when absent.
668
+ // - ValidationUrl: the url or '' when absent.
669
+ // - StrandId: the host strand id or '' when absent (text column, no coercion).
670
+ const expiresAtCanonical = options.expiresAtMs == null
671
+ ? null
672
+ : await canonicalDatetime(this.db!, options.expiresAtMs);
673
+ const expiresAtField = expiresAtCanonical ?? '';
674
+ const totalUsesField = options.totalUses == null ? '' : String(options.totalUses);
675
+ const validationUrlField = options.validationUrl ?? '';
676
+ const strandIdField = options.strandId ?? '';
677
+
678
+ // Field order MUST match the schema's FormationInvite `AuthorizedAddOrRemove` verify:
679
+ // Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId.
680
+ const message = buildAuthorizationMessage([
681
+ token, sAppId, expiresAtField, totalUsesField, validationUrlField, strandIdField, stampId,
682
+ ]);
683
+ const signature = signMessage(message);
684
+
685
+ // Persist the canonical ExpiresAt string (datetime parse is idempotent on it) so the
686
+ // signed source-of-truth and the stored value are produced once. StampId is a real,
687
+ // unique column (single-use anti-replay), no longer a context value.
688
+ await this.db!.exec(`
689
+ insert into CadreControl.FormationInvite (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId)
690
+ with context AuthorityKey = ?, Signature = ?
691
+ values (?, ?, ?, ?, ?, ?, ?)
692
+ `, [
693
+ authorityKey, signature,
694
+ token, sAppId,
695
+ expiresAtCanonical,
696
+ options.totalUses ?? null,
697
+ options.validationUrl ?? null,
698
+ options.strandId ?? null,
699
+ stampId,
700
+ ]);
701
+
702
+ log('Formation invite inserted: %s', token);
703
+ }
704
+
705
+ /**
706
+ * Redeem a `FormationInvite` by inserting the `Strand` row and a matching
707
+ * `FormationUsage` row **atomically, in one transaction**.
708
+ *
709
+ * The two CHECK constraints are mutually circular under immediate evaluation:
710
+ * `Strand.Authorized`'s consent branch requires the `FormationUsage` row, while
711
+ * `FormationUsage.StrandExists` requires the `Strand` row. Both CHECKs contain
712
+ * subqueries, so Quereus auto-defers them to transaction commit — wrapping both
713
+ * inserts in a single explicit `begin … commit` lets both deferred CHECKs see
714
+ * both rows at commit. The strand is authorised WITHOUT an authority signature
715
+ * (the `FormationUsage` branch of `Strand.Authorized`) but still gets a fresh,
716
+ * unique `StampId` column to satisfy the not-null/unique anti-replay column.
717
+ *
718
+ * `UseNumber` is computed as `max(UseNumber)+1` for the token (the `Monotonic`
719
+ * constraint); callers redeeming concurrently against the same token must
720
+ * serialise, since the next use number is read before the insert.
721
+ */
722
+ async redeemInvitation(params: {
723
+ token: string;
724
+ strandId: string;
725
+ type?: 'o' | 'c';
726
+ memberPrivateKey?: string;
727
+ disclosure?: string;
728
+ peerId?: string;
729
+ peerSignature?: string;
730
+ nowMs?: number;
731
+ validationKey?: string;
732
+ validationSignature?: string;
733
+ }): Promise<void> {
734
+ this.ensureInitialized();
735
+ const {
736
+ token, strandId, type = 'o',
737
+ memberPrivateKey, disclosure = '',
738
+ peerId, peerSignature,
739
+ nowMs, validationKey, validationSignature,
740
+ } = params;
741
+ log('Redeeming invitation %s -> strand %s', token, strandId);
742
+
743
+ const localPeerId = this.config.libp2pNode.peerId.toString();
744
+ const strandStampId = generateStampId(localPeerId);
745
+ const useNumber = await this.nextUseNumber(token);
746
+
747
+ await this.db!.beginTransaction();
748
+ try {
749
+ // 1. Strand row — authorised by the FormationUsage branch (no authority sig),
750
+ // still carrying a fresh unique StampId for the anti-replay column.
751
+ await this.db!.exec(`
752
+ insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId)
753
+ with context AuthorityKey = null, Signature = null
754
+ values (?, ?, ?, ?)
755
+ `, [strandId, type, memberPrivateKey ?? null, strandStampId]);
756
+
757
+ // 2. FormationUsage row — authorised by the matching FormationInvite.
758
+ await this.execFormationUsageInsert({
759
+ token, useNumber, disclosure, strandId,
760
+ peerId: peerId ?? localPeerId, peerSignature: peerSignature ?? null, nowMs: nowMs ?? Date.now(),
761
+ validationKey: validationKey ?? null, validationSignature: validationSignature ?? null,
762
+ });
763
+
764
+ await this.db!.commit();
765
+ log('Redeemed invitation %s -> strand %s (use #%d)', token, strandId, useNumber);
766
+ } catch (error) {
767
+ // A failed commit() already tears down the transaction, so rollback() would
768
+ // throw "No transaction active" and mask the real cause — swallow only that.
769
+ try {
770
+ await this.db!.rollback();
771
+ } catch (rollbackError) {
772
+ log('Rollback after redemption failure was a no-op: %s', rollbackError);
773
+ }
774
+ throw error;
775
+ }
776
+ }
777
+
778
+ /**
779
+ * Record a `FormationUsage` against an **already-existing** `Strand` (no strand
780
+ * insert). This is the redemption path when the strand was provisioned
781
+ * separately (e.g. authority-signed) and the consent record is added after the
782
+ * fact: the single insert auto-commits, and the deferred `StrandExists` CHECK
783
+ * is satisfied by the pre-existing committed strand row. Returns the assigned
784
+ * `UseNumber`.
785
+ *
786
+ * Use {@link redeemInvitation} instead when the strand must be created by
787
+ * consent atomically with the usage.
788
+ */
789
+ async recordFormationUsage(params: {
790
+ token: string;
791
+ strandId: string;
792
+ disclosure?: string;
793
+ peerId?: string;
794
+ peerSignature?: string;
795
+ nowMs?: number;
796
+ validationKey?: string;
797
+ validationSignature?: string;
798
+ }): Promise<number> {
799
+ this.ensureInitialized();
800
+ const {
801
+ token, strandId, disclosure = '',
802
+ peerId, peerSignature, nowMs, validationKey, validationSignature,
803
+ } = params;
804
+
805
+ const localPeerId = this.config.libp2pNode.peerId.toString();
806
+ const useNumber = await this.nextUseNumber(token);
807
+
808
+ await this.execFormationUsageInsert({
809
+ token, useNumber, disclosure, strandId,
810
+ peerId: peerId ?? localPeerId, peerSignature: peerSignature ?? null, nowMs: nowMs ?? Date.now(),
811
+ validationKey: validationKey ?? null, validationSignature: validationSignature ?? null,
812
+ });
813
+
814
+ log('Recorded formation usage: token=%s strand=%s (use #%d)', token, strandId, useNumber);
815
+ return useNumber;
816
+ }
817
+
818
+ /** Parameterised `FormationUsage` insert shared by redeem + record paths. */
819
+ private async execFormationUsageInsert(opts: {
820
+ token: string;
821
+ useNumber: number;
822
+ disclosure: string;
823
+ strandId: string;
824
+ peerId: string;
825
+ peerSignature: string | null;
826
+ nowMs: number;
827
+ validationKey: string | null;
828
+ validationSignature: string | null;
829
+ }): Promise<void> {
830
+ // Derive `context.Now` through the same `canonicalDatetime` transform that
831
+ // produced the stored `ExpiresAt`, so the deferred CHECK's `FI.ExpiresAt >
832
+ // context.Now` compares two byte-identical engine-`datetime` strings. The
833
+ // previous `new Date(nowMs).toISOString()` form differed only by a trailing
834
+ // `.000Z` (the engine `datetime()` separator is `T`, not a space), which never
835
+ // flipped the strict `>` against a second-granular `ExpiresAt` — so this is a
836
+ // robustness/consistency change, matching the strand layer's `consumeInvite`,
837
+ // not a fix for an observable mis-ordering.
838
+ const nowCanonical = await canonicalDatetime(this.db!, opts.nowMs);
839
+ await this.db!.exec(`
840
+ insert into CadreControl.FormationUsage (Token, UseNumber, Disclosure, StrandId)
841
+ with context PeerId = ?, PeerSignature = ?, Now = ?, ValidationKey = ?, ValidationSignature = ?
842
+ values (?, ?, ?, ?)
843
+ `, [
844
+ opts.peerId, opts.peerSignature, nowCanonical,
845
+ opts.validationKey, opts.validationSignature,
846
+ opts.token, opts.useNumber, opts.disclosure, opts.strandId,
847
+ ]);
848
+ }
849
+
850
+ /**
851
+ * Read a `FormationInvite` row by token, or null when absent. `expiresAtMs` is
852
+ * the parsed epoch-ms of the stored `datetime` (null when the invite never
853
+ * expires); the caller compares it against the wall clock for freshness.
854
+ */
855
+ async queryFormationInvite(token: string): Promise<{
856
+ token: string;
857
+ sAppId: string;
858
+ expiresAtMs: number | null;
859
+ totalUses: number | null;
860
+ validationUrl: string | null;
861
+ strandId: string | null;
862
+ } | null> {
863
+ this.ensureInitialized();
864
+ for await (const row of this.db!.eval(
865
+ 'select Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId from CadreControl.FormationInvite where Token = ?',
866
+ [token]
867
+ )) {
868
+ const expiresAt = row.ExpiresAt as string | number | null;
869
+ const expiresAtMs = expiresAt === null || expiresAt === undefined
870
+ ? null
871
+ : parseStoredDatetimeMs(expiresAt);
872
+ return {
873
+ token: row.Token as string,
874
+ sAppId: row.sAppId as string,
875
+ expiresAtMs: expiresAtMs !== null && Number.isNaN(expiresAtMs) ? null : expiresAtMs,
876
+ totalUses: (row.TotalUses as number | null) ?? null,
877
+ validationUrl: (row.ValidationUrl as string | null) ?? null,
878
+ strandId: (row.StrandId as string | null) ?? null,
879
+ };
880
+ }
881
+ return null;
882
+ }
883
+
884
+ /**
885
+ * Count `FormationUsage` rows recorded against a token (uses consumed so far).
886
+ */
887
+ async countFormationUsage(token: string): Promise<number> {
888
+ this.ensureInitialized();
889
+ for await (const row of this.db!.eval(
890
+ 'select count(1) as Count from CadreControl.FormationUsage where Token = ?',
891
+ [token]
892
+ )) {
893
+ return (row.Count as number) ?? 0;
894
+ }
895
+ return 0;
896
+ }
897
+
898
+ /** Next `UseNumber` for a token = max(existing)+1, per the `Monotonic` constraint. */
899
+ private async nextUseNumber(token: string): Promise<number> {
900
+ for await (const row of this.db!.eval(
901
+ 'select coalesce(max(UseNumber), 0) as MaxUse from CadreControl.FormationUsage where Token = ?',
902
+ [token]
903
+ )) {
904
+ return (row.MaxUse as number) + 1;
905
+ }
906
+ return 1;
907
+ }
908
+
909
+ /**
910
+ * Close the database and cleanup resources
911
+ */
912
+ async close(): Promise<void> {
913
+ if (this.collectionFactory) {
914
+ await this.collectionFactory.shutdown();
915
+ this.collectionFactory = null;
916
+ }
917
+ if (this.db) {
918
+ void this.db.close();
919
+ this.db = null;
920
+ }
921
+ this.initialized = false;
922
+ log('ControlDatabase closed');
923
+ }
924
+
925
+ private ensureInitialized(): void {
926
+ if (!this.initialized || !this.db) {
927
+ throw new Error('ControlDatabase not initialized. Call initialize() first.');
928
+ }
929
+ }
930
+ }
931
+