@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,7 +1,6 @@
1
1
  import debug from 'debug';
2
- import type { Libp2p, PrivateKey } from '@libp2p/interface';
2
+ import type { PrivateKey } from '@libp2p/interface';
3
3
  import { createLibp2pNode, type IRawStorage } from '@optimystic/db-p2p';
4
- import type { IRepo } from '@optimystic/db-core';
5
4
  import { StrandDatabase } from './strand-database.js';
6
5
  import { assertSchemaSignature } from './schema-verification.js';
7
6
  import type {
@@ -14,17 +13,10 @@ import type {
14
13
  SAppConfig,
15
14
  SAppInfo,
16
15
  RawStorageProvider,
17
- StrandMode
16
+ StrandMode,
17
+ Libp2pNodeWithRepo
18
18
  } from './types.js';
19
19
 
20
- /**
21
- * Extended Libp2p node with coordinatedRepo attached by createLibp2pNode.
22
- * The db-p2p createLibp2pNode function attaches these properties after node creation.
23
- */
24
- interface Libp2pNodeWithRepo extends Libp2p {
25
- coordinatedRepo: IRepo;
26
- }
27
-
28
20
  const log = debug('sereus:cadre:strand-manager');
29
21
  const timing = debug('sereus:cadre:timing');
30
22
 
@@ -40,19 +32,48 @@ export interface StartStrandConfig {
40
32
  profile: NodeProfile;
41
33
  defaultLatencyHint: LatencyHint;
42
34
  privateKey?: PrivateKey;
35
+ /** Cohort-derived discovery seed (multiaddr strings). Defaults to [] when omitted. */
36
+ bootstrapNodes?: string[];
43
37
  /**
44
38
  * Lifecycle mode for this strand; selects the default transactor used by the
45
- * StrandDatabase. Defaults to `'networked'` when omitted.
39
+ * StrandDatabase. When omitted, the StrandDatabase falls back to `'networked'`;
40
+ * callers (CadreNode) infer it from cohort membership before reaching here.
41
+ */
42
+ mode?: StrandMode;
43
+ /**
44
+ * Require a valid author signature on the sApp schema before bring-up.
45
+ * Defaults to true (fail closed) when omitted; set false only for dev/test
46
+ * with unsigned demo schemas. Mirrors {@link CadreNodeConfig.requireSignedSchemas}.
46
47
  */
48
+ requireSignedSchemas?: boolean;
49
+ /**
50
+ * Whether this node founds the strand (vs. joins it). Forwarded to the
51
+ * StrandDatabase so the founder bootstrap (Header, founding Member/Authority)
52
+ * runs once at bring-up. Joiners leave this unset and write nothing. See
53
+ * {@link StrandConfig.founder}.
54
+ */
55
+ founder?: boolean;
56
+ }
57
+
58
+ /**
59
+ * Volatile inputs re-resolved when resuming a quiesced strand. These may have
60
+ * changed since the strand first launched — the cohort discovery seed grows as
61
+ * peers are learned, and cohort membership can push a strand `bootstrap → networked`.
62
+ */
63
+ export interface ResumeStrandOverrides {
64
+ /** Freshly-resolved cohort discovery seed (multiaddr strings). */
65
+ bootstrapNodes?: string[];
66
+ /** Freshly-resolved lifecycle mode. */
47
67
  mode?: StrandMode;
48
68
  }
49
69
 
50
70
  /**
51
71
  * Get the isolated storage path for a specific strand.
52
72
  *
53
- * @deprecated This function uses Node.js path module which is not available in React Native.
54
- * Use a storage provider factory function instead, which receives the strandId and can
55
- * create strand-specific storage paths using platform-appropriate methods.
73
+ * @deprecated This helper is Node-only and throws in React Native (it assumes a
74
+ * filesystem layout). Use a storage provider factory function instead, which
75
+ * receives the strandId and can create strand-specific storage paths using
76
+ * platform-appropriate methods.
56
77
  *
57
78
  * @example
58
79
  * // Instead of using getStrandStoragePath, use a storage provider factory:
@@ -69,13 +90,16 @@ export function getStrandStoragePath(basePath: string, strandId: string): string
69
90
  );
70
91
  }
71
92
 
72
- // Dynamically require path only in Node.js
73
- // eslint-disable-next-line @typescript-eslint/no-require-imports
74
- const path = require('path');
75
-
76
93
  // Sanitize strandId for filesystem safety (UUIDs should be safe, but just in case)
77
94
  const safeId = strandId.replace(/[^a-zA-Z0-9-]/g, '_');
78
- return path.join(basePath, 'strands', safeId);
95
+
96
+ // Build the path with plain string joins rather than the Node `path` module.
97
+ // A static `require('path')` forces RN bundlers (e.g. Metro) to *resolve* the
98
+ // module at bundle time even though this Node-only helper throws above before
99
+ // ever reaching here — joining by hand keeps the module free of any Node
100
+ // built-in reference, so RN bundles need no `path` shim.
101
+ const trimmedBase = basePath.replace(/[\\/]+$/, '');
102
+ return `${trimmedBase}/strands/${safeId}`;
79
103
  }
80
104
 
81
105
  /**
@@ -102,6 +126,12 @@ function resolveStrandStorage(
102
126
  */
103
127
  export class StrandInstanceManager {
104
128
  private instances: Map<string, StrandInstance> = new Map();
129
+ /**
130
+ * Retained launch config per strand, captured in `startStrand` and cleared in
131
+ * `stopStrand`. `resumeStrand` reuses it to rebuild a quiesced strand's runtime
132
+ * without the caller re-threading storage/network/profile/key/sApp config.
133
+ */
134
+ private launchConfigs: Map<string, StartStrandConfig> = new Map();
105
135
  private stopping = false;
106
136
 
107
137
  constructor() {
@@ -148,8 +178,9 @@ export class StrandInstanceManager {
148
178
  log('Starting strand instance: %s (sApp: %s v%s)', strandId, sAppConfig.id, sAppConfig.version);
149
179
  const tTotal = performance.now();
150
180
 
151
- // Verify schema signature before proceeding
152
- assertSchemaSignature(sAppConfig);
181
+ // Verify schema signature before proceeding (fail-closed by default)
182
+ const requireSignature = config.requireSignedSchemas ?? true;
183
+ assertSchemaSignature(sAppConfig, { requireSignature });
153
184
  log('Strand %s sApp schema signature verified (author: %s)', strandId, sAppConfig.id);
154
185
 
155
186
  // Convert SAppConfig to SAppInfo for the instance
@@ -174,29 +205,54 @@ export class StrandInstanceManager {
174
205
  };
175
206
 
176
207
  this.instances.set(strandId, instance);
208
+ this.launchConfigs.set(strandId, config);
177
209
 
178
210
  try {
179
- // Resolve storage for this strand
180
- // If a factory function is provided, it will be called with the strandId
181
- // to create strand-specific storage (e.g., strand-isolated directories)
182
- const strandStorage = resolveStrandStorage(config.storage?.provider, strandId);
183
- if (strandStorage) {
184
- log('Strand %s using provided storage provider', strandId);
185
- }
186
-
187
- // Create isolated libp2p node for this strand
188
- // Note: protocolPrefix would be used by libp2p services, but createLibp2pNode
189
- // may not support it yet - tracked for future implementation
190
- const _protocolPrefix = `/sereus/strand/${strandId}`;
191
-
192
- // Determine relay mode: if explicitly set in config, use that;
193
- // otherwise default to true for storage profile nodes
194
- const enableRelay = config.network?.enableRelay ?? (config.profile === 'storage');
211
+ await this.buildStrandRuntime(instance, config);
212
+ timing('[startStrand:%s] total: %dms', strandId, Math.round(performance.now() - tTotal));
213
+ log('Strand %s started successfully with sApp %s', strandId, sAppConfig.id);
214
+ return instance;
215
+ } catch (error) {
216
+ instance.status = 'error';
217
+ instance.error = error instanceof Error ? error.message : String(error);
218
+ log('Failed to start strand %s: %s', strandId, instance.error);
219
+ throw error;
220
+ }
221
+ }
195
222
 
223
+ /**
224
+ * Build (or rebuild) the libp2p node + StrandDatabase for an instance and
225
+ * attach them, transitioning it to `active`. Shared by `startStrand` (fresh
226
+ * launch) and `resumeStrand` (rehydrating a quiesced instance). Reads all
227
+ * volatile inputs (bootstrapNodes, mode, storage, network, profile,
228
+ * privateKey, sApp config) from `config`, so the caller controls the
229
+ * cohort-derived values.
230
+ */
231
+ private async buildStrandRuntime(instance: StrandInstance, config: StartStrandConfig): Promise<void> {
232
+ const strandId = instance.strandId;
233
+ const { sAppConfig } = config;
234
+
235
+ // Resolve storage for this strand. If a factory function is provided, it is
236
+ // called with the strandId to create strand-specific storage (e.g.,
237
+ // strand-isolated directories).
238
+ const strandStorage = resolveStrandStorage(config.storage?.provider, strandId);
239
+ if (strandStorage) {
240
+ log('Strand %s using provided storage provider', strandId);
241
+ }
242
+
243
+ // Note: protocolPrefix would be used by libp2p services, but createLibp2pNode
244
+ // may not support it yet - tracked for future implementation.
245
+ const _protocolPrefix = `/sereus/strand/${strandId}`;
246
+
247
+ // Determine relay mode: if explicitly set in config, use that;
248
+ // otherwise default to true for storage profile nodes.
249
+ const enableRelay = config.network?.enableRelay ?? (config.profile === 'storage');
250
+
251
+ try {
196
252
  let t0 = performance.now();
197
253
  const node = await createLibp2pNode({
198
254
  port: 0, // Random port
199
- bootstrapNodes: [], // Will be populated from strand cohort
255
+ bootstrapNodes: config.bootstrapNodes ?? [],
200
256
  networkName: `strand-${strandId}`,
201
257
  storage: strandStorage,
202
258
  fretProfile: config.profile === 'storage' ? 'core' : 'edge',
@@ -211,9 +267,10 @@ export class StrandInstanceManager {
211
267
  },
212
268
  ...(config.privateKey && { privateKey: config.privateKey }),
213
269
  ...(config.network?.transports && { transports: config.network.transports }),
214
- ...(config.network?.listenAddrs && { listenAddrs: config.network.listenAddrs })
270
+ ...(config.network?.listenAddrs && { listenAddrs: config.network.listenAddrs }),
271
+ ...(config.network?.connectionGater && { connectionGater: config.network.connectionGater })
215
272
  }) as Libp2pNodeWithRepo;
216
- timing('[startStrand:%s] createLibp2pNode: %dms', strandId, Math.round(performance.now() - t0));
273
+ timing('[buildStrandRuntime:%s] createLibp2pNode: %dms', strandId, Math.round(performance.now() - t0));
217
274
 
218
275
  instance.libp2pNode = node;
219
276
 
@@ -222,6 +279,9 @@ export class StrandInstanceManager {
222
279
  // DML lands on the host's persistent storage (e.g. MMKV on RN). Sharing
223
280
  // the instance — not creating a second one over the same id+prefix —
224
281
  // avoids cache divergence between the libp2p and database paths.
282
+ //
283
+ // Attach before initialize so a failed init is cleaned up by
284
+ // releaseRuntime below (close() is safe on a partially-initialized db).
225
285
  t0 = performance.now();
226
286
  const strandDb = new StrandDatabase({
227
287
  strandId,
@@ -229,23 +289,121 @@ export class StrandInstanceManager {
229
289
  libp2pNode: node,
230
290
  coordinatedRepo: node.coordinatedRepo,
231
291
  mode: config.mode ?? 'networked',
232
- rawStorage: strandStorage
292
+ rawStorage: strandStorage,
293
+ // Founder bootstrap inputs: the strand's type drives which membership rows
294
+ // are written, and the closed-strand MemberPrivateKey derives the founding
295
+ // Member/Authority key. Both come off the control-network strand row.
296
+ strandType: config.strandRow.Type,
297
+ memberPrivateKey: config.strandRow.MemberPrivateKey ?? undefined,
298
+ founder: config.founder
233
299
  });
234
- await strandDb.initialize();
235
- timing('[startStrand:%s] strandDatabase.initialize: %dms', strandId, Math.round(performance.now() - t0));
236
300
  instance.database = strandDb;
301
+ await strandDb.initialize();
302
+ timing('[buildStrandRuntime:%s] strandDatabase.initialize: %dms', strandId, Math.round(performance.now() - t0));
237
303
 
238
304
  instance.status = 'active';
239
305
  instance.lastActivity = new Date();
306
+ } catch (error) {
307
+ // Roll back any partially-attached runtime so the instance is left with
308
+ // NEITHER handle. Otherwise the `libp2pNode || database` "already live"
309
+ // guard in resumeStrand/handleStrandWake would treat a half-built strand
310
+ // as healthy — leaking the libp2p node and never retrying the rebuild.
311
+ await this.releaseRuntime(instance).catch((cleanupErr) => {
312
+ log('buildStrandRuntime cleanup for strand %s also failed: %o', strandId, cleanupErr);
313
+ });
314
+ throw error;
315
+ }
316
+ }
240
317
 
241
- timing('[startStrand:%s] total: %dms', strandId, Math.round(performance.now() - tTotal));
242
- log('Strand %s started successfully with sApp %s', strandId, sAppConfig.id);
318
+ /**
319
+ * Release an instance's strand-network runtime: close the StrandDatabase, then
320
+ * stop the libp2p node (construction order in reverse), clearing both fields
321
+ * and zeroing connectedPeers. Tolerant of partially-built state — either handle
322
+ * may be absent — so it doubles as rollback for a failed `buildStrandRuntime`.
323
+ * Shared by `quiesceStrand`, `stopStrand`, and that rollback path.
324
+ */
325
+ private async releaseRuntime(instance: StrandInstance): Promise<void> {
326
+ if (instance.database) {
327
+ await instance.database.close();
328
+ instance.database = undefined;
329
+ }
330
+ if (instance.libp2pNode) {
331
+ await instance.libp2pNode.stop();
332
+ instance.libp2pNode = undefined;
333
+ }
334
+ instance.connectedPeers = 0;
335
+ }
336
+
337
+ /**
338
+ * Quiesce a strand: release its strand-network resources (stop the libp2p node,
339
+ * close the StrandDatabase) while RETAINING the instance record — identity,
340
+ * sAppInfo, keys, latency hint, metadata — and its launch config so it can be
341
+ * resumed later. Mechanically this is `stopStrand` minus the instance/config
342
+ * deletion. The caller sets the post-quiesce status (e.g. `hibernating`).
343
+ * No-ops when the strand is missing or already quiesced.
344
+ */
345
+ async quiesceStrand(strandId: string): Promise<void> {
346
+ const instance = this.instances.get(strandId);
347
+ if (!instance) {
348
+ log('quiesceStrand: strand %s not found', strandId);
349
+ return;
350
+ }
351
+ if (!instance.libp2pNode && !instance.database) {
352
+ log('quiesceStrand: strand %s already quiesced', strandId);
353
+ return;
354
+ }
355
+
356
+ log('Quiescing strand instance: %s', strandId);
357
+ await this.releaseRuntime(instance);
358
+ log('Strand %s quiesced (resources released, instance retained)', strandId);
359
+ }
360
+
361
+ /**
362
+ * Resume a previously-quiesced strand: rebuild its libp2p node + StrandDatabase
363
+ * from the retained launch config and re-attach them, transitioning it back to
364
+ * `active`. `overrides` re-applies volatile inputs that may have changed since
365
+ * launch (cohort `bootstrapNodes`, lifecycle `mode`) and updates the retained
366
+ * config so a later resume reuses the latest values. Returns the live instance
367
+ * unchanged if it is already running.
368
+ */
369
+ async resumeStrand(strandId: string, overrides?: ResumeStrandOverrides): Promise<StrandInstance> {
370
+ if (this.stopping) {
371
+ throw new Error('StrandInstanceManager is stopping');
372
+ }
373
+ const instance = this.instances.get(strandId);
374
+ if (!instance) {
375
+ throw new Error(`Cannot resume strand ${strandId}: not tracked`);
376
+ }
377
+ const launchConfig = this.launchConfigs.get(strandId);
378
+ if (!launchConfig) {
379
+ throw new Error(`Cannot resume strand ${strandId}: no retained launch config`);
380
+ }
381
+ if (instance.libp2pNode || instance.database) {
382
+ log('resumeStrand: strand %s already live', strandId);
243
383
  return instance;
384
+ }
244
385
 
386
+ log('Resuming strand instance: %s', strandId);
387
+ const tTotal = performance.now();
388
+
389
+ // Re-apply volatile inputs and persist them so a subsequent resume reuses them.
390
+ const resumeConfig: StartStrandConfig = {
391
+ ...launchConfig,
392
+ bootstrapNodes: overrides?.bootstrapNodes ?? launchConfig.bootstrapNodes,
393
+ mode: overrides?.mode ?? launchConfig.mode
394
+ };
395
+ this.launchConfigs.set(strandId, resumeConfig);
396
+
397
+ instance.status = 'starting';
398
+ try {
399
+ await this.buildStrandRuntime(instance, resumeConfig);
400
+ timing('[resumeStrand:%s] total: %dms', strandId, Math.round(performance.now() - tTotal));
401
+ log('Strand %s resumed successfully', strandId);
402
+ return instance;
245
403
  } catch (error) {
246
404
  instance.status = 'error';
247
405
  instance.error = error instanceof Error ? error.message : String(error);
248
- log('Failed to start strand %s: %s', strandId, instance.error);
406
+ log('Failed to resume strand %s: %s', strandId, instance.error);
249
407
  throw error;
250
408
  }
251
409
  }
@@ -264,17 +422,10 @@ export class StrandInstanceManager {
264
422
  instance.status = 'stopping';
265
423
 
266
424
  try {
267
- // Close the database before stopping libp2p
268
- if (instance.database) {
269
- await instance.database.close();
270
- instance.database = undefined;
271
- }
272
- if (instance.libp2pNode) {
273
- await instance.libp2pNode.stop();
274
- instance.libp2pNode = undefined;
275
- }
425
+ await this.releaseRuntime(instance);
276
426
  instance.status = 'stopped';
277
427
  this.instances.delete(strandId);
428
+ this.launchConfigs.delete(strandId);
278
429
  log('Strand %s stopped successfully', strandId);
279
430
  } catch (error) {
280
431
  instance.status = 'error';
@@ -0,0 +1,39 @@
1
+ import { generateKeyPair, privateKeyToProtobuf, privateKeyFromProtobuf } from '@libp2p/crypto/keys';
2
+ import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
3
+ import { authorityKeyFromLibp2p, type AuthorityKeyPair } from './authority-key.js';
4
+
5
+ /**
6
+ * Mint a fresh ed25519 strand member private key, encoded as base64 protobuf.
7
+ *
8
+ * Closed strands (`Type:'c'`) gate membership on a `MemberPrivateKey` column
9
+ * (see the `Strand` control-schema table). This produces one in the SAME
10
+ * encoding {@link StrandSolicitationService.formStrand} uses for its
11
+ * `invitePrivateKey`, so a host-minted key and a formation-issued key are
12
+ * interchangeable when attaching a closed strand.
13
+ */
14
+ export async function generateStrandMemberKey(): Promise<string> {
15
+ const privateKey = await generateKeyPair('Ed25519');
16
+ return uint8ArrayToString(privateKeyToProtobuf(privateKey), 'base64');
17
+ }
18
+
19
+ /**
20
+ * Bridge a strand's `MemberPrivateKey` (base64 protobuf libp2p ed25519 key, as
21
+ * minted by {@link generateStrandMemberKey} or issued by formation) into the
22
+ * base64url keypair the strand RBAC constraints consume.
23
+ *
24
+ * The `Strand` control-layer `MemberPrivateKey` is the closed-strand read-gating
25
+ * secret; the founding `Member.Key`/`Authority.MemberKey` (the strand RBAC layer)
26
+ * are *derived from it* — they are the `publicKeyB64` of the keypair this returns.
27
+ * Decode the protobuf to a libp2p private key, then reuse
28
+ * {@link authorityKeyFromLibp2p} so the same seed→public derivation used for node
29
+ * authority keys yields a stable `{ privateKeyB64, publicKeyB64 }` whose public
30
+ * key a later strand signature verifies against.
31
+ *
32
+ * @param memberPrivateKey - The strand's `MemberPrivateKey` (base64 protobuf).
33
+ * @returns The base64url seed/public-key pair for founding membership/authority.
34
+ * @throws If the decoded key is not Ed25519 or the raw bytes are malformed.
35
+ */
36
+ export function strandMemberKeyPair(memberPrivateKey: string): AuthorityKeyPair {
37
+ const privateKey = privateKeyFromProtobuf(uint8ArrayFromString(memberPrivateKey, 'base64'));
38
+ return authorityKeyFromLibp2p(privateKey);
39
+ }
@@ -0,0 +1,177 @@
1
+ import debug from 'debug';
2
+ import type { Database, SqlValue } from '@quereus/quereus';
3
+ import type { MemberRegistration } from './types.js';
4
+ import type { MemberRegistry, MemberVerifier } from './enrollment.js';
5
+ import type { AuthorityKeyPair } from './authority-key.js';
6
+ import {
7
+ verifyStrandPayload,
8
+ consumeInvite,
9
+ addMemberByAuthority,
10
+ } from './strand-membership-writer.js';
11
+ import { canonicalDatetime } from './canonical-datetime.js';
12
+
13
+ const log = debug('sereus:cadre:strand-member-registry');
14
+
15
+ /**
16
+ * The canonical payload a joining member signs to prove ownership of its strand
17
+ * member key, verified by {@link StrandMemberVerifier.verifyMember}.
18
+ *
19
+ * Binds the member key to the strand id so a registration signature cannot be
20
+ * replayed against a different strand. Peer-id binding is intentionally NOT part
21
+ * of this payload — `MemberPeer` rows carry their OWN per-peer self-proofs
22
+ * (`MemberKey || '|' || PeerId`, signed by the member) and are written by the
23
+ * dedicated `registerMemberPeer` writer; see {@link StrandMemberRegistry}.
24
+ */
25
+ export function memberRegistrationPayload(registration: MemberRegistration): string {
26
+ return `${registration.strandId}|${registration.key}`;
27
+ }
28
+
29
+ /** How a {@link StrandMemberRegistry} admits a member into the strand. */
30
+ export type StrandAdmission =
31
+ | {
32
+ /** Redeem a specific single-use invite (the invitee-side join). */
33
+ readonly mode: 'invite';
34
+ /** The `Invite.Key` (invite public key, base64url) to redeem. */
35
+ readonly inviteKey: string;
36
+ /** The matching invite private seed (base64url), received out-of-band. */
37
+ readonly invitePrivateKey: string;
38
+ }
39
+ | {
40
+ /** Admit directly by authority signature (the authority-side join). */
41
+ readonly mode: 'authority';
42
+ /** The admitting authority's strand keypair. */
43
+ readonly authorityKeyPair: AuthorityKeyPair;
44
+ };
45
+
46
+ /**
47
+ * Strand-`Database`-backed {@link MemberVerifier} — the reconciliation that lets
48
+ * {@link EnrollmentService} make real authorization decisions against a strand's
49
+ * `Invite`/`ConsumedInvite`/`Member` tables instead of a placeholder.
50
+ *
51
+ * Each instance is scoped to ONE strand's database (the `Strand.*` tables live
52
+ * inside the connected strand DB), so the `strandId` arguments on the interface
53
+ * are accepted for signature compatibility but the queries run against this db.
54
+ */
55
+ export class StrandMemberVerifier implements MemberVerifier {
56
+ constructor(private readonly db: Database) {}
57
+
58
+ /**
59
+ * Verify the member's self-proof over {@link memberRegistrationPayload} against
60
+ * the registration's own `key` (the member's ed25519 public key). This is the
61
+ * off-engine counterpart to the on-engine signature checks — a pre-flight gate
62
+ * before the constraint-enforced write.
63
+ */
64
+ async verifyMember(registration: MemberRegistration, signature: string): Promise<boolean> {
65
+ const ok = verifyStrandPayload(memberRegistrationPayload(registration), signature, registration.key);
66
+ if (!ok) {
67
+ log('verifyMember: bad self-proof for member %s on strand %s', registration.key, registration.strandId);
68
+ }
69
+ return ok;
70
+ }
71
+
72
+ /**
73
+ * A member is authorized to join when either it has already consumed an invite
74
+ * (a `ConsumedInvite` row bears its key) OR the strand has at least one
75
+ * outstanding `Invite` it could redeem.
76
+ *
77
+ * Strand invites are anonymous — `Invite.Key` is an invite public key, not a
78
+ * member key, so an un-consumed invite cannot be bound to a specific member
79
+ * here. This check is therefore a "door is open" pre-flight; the binding,
80
+ * single-use, and cryptographic gates are all enforced by the deferred
81
+ * `Strand.*` constraints when the member is actually written.
82
+ *
83
+ * Expired invites are filtered out so this pre-flight matches the on-engine
84
+ * `ConsumedInvite.NotExpired` gate — `Now` is canonicalised the same way the
85
+ * writer canonicalises it, so `Expiration > ?` compares like-for-like canonical
86
+ * strings. A member that already holds a `ConsumedInvite` short-circuits above,
87
+ * regardless of any invite's expiry.
88
+ */
89
+ async isAuthorizedToJoin(_strandId: string, memberKey: string): Promise<boolean> {
90
+ const consumed = await scalarCount(
91
+ this.db,
92
+ 'select count(1) as c from Strand.ConsumedInvite where MemberKey = ?',
93
+ [memberKey],
94
+ );
95
+ if (consumed > 0) {
96
+ return true;
97
+ }
98
+ const nowCanonical = await canonicalDatetime(this.db, Date.now());
99
+ const invites = await scalarCount(
100
+ this.db,
101
+ 'select count(1) as c from Strand.Invite where Expiration is null or Expiration > ?',
102
+ [nowCanonical],
103
+ );
104
+ return invites > 0;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Strand-`Database`-backed {@link MemberRegistry} — writes real `Strand.Member`
110
+ * (and, in invite mode, `Strand.ConsumedInvite`) rows through the signed writer
111
+ * primitives, so {@link EnrollmentService.registerMember} performs the actual
112
+ * per-strand join handshake rather than returning "MemberRegistry not configured".
113
+ *
114
+ * The {@link StrandAdmission} chosen at construction selects which branch of
115
+ * `Member.Authorized` is taken:
116
+ * - `invite` → {@link consumeInvite} (atomic `Member` + `ConsumedInvite`), the
117
+ * invitee-side flow that redeems a single-use invite.
118
+ * - `authority` → {@link addMemberByAuthority}, the authority-side flow that
119
+ * seats a member already trusted by an authority.
120
+ *
121
+ * Scoped to one strand's db; the `strandId` argument is accepted for interface
122
+ * compatibility but writes target this db's `Strand.*` tables.
123
+ *
124
+ * NOTE: `peerIds` are NOT written as `MemberPeer` rows here. The signed peer path
125
+ * now exists as the standalone `registerMemberPeer` writer (each peer self-proof is
126
+ * signed by the member's own key), but wiring the enrollment `peerIds` through it is
127
+ * a deliberate follow-on — the member self-proof available here does not carry the
128
+ * per-peer signatures `MemberPeer.Authorized` requires. A non-empty `peerIds` is
129
+ * logged and otherwise ignored so the member still lands; peer rows are reconciled
130
+ * separately via `registerMemberPeer`.
131
+ */
132
+ export class StrandMemberRegistry implements MemberRegistry {
133
+ constructor(
134
+ private readonly db: Database,
135
+ private readonly admission: StrandAdmission,
136
+ ) {}
137
+
138
+ async registerMember(strandId: string, memberKey: string, peerIds: string[]): Promise<void> {
139
+ if (peerIds.length > 0) {
140
+ log(
141
+ 'registerMember: %d peerId(s) for member %s on strand %s deferred to the registerMemberPeer path (needs per-peer self-proofs); registering member only',
142
+ peerIds.length, memberKey, strandId,
143
+ );
144
+ }
145
+
146
+ if (this.admission.mode === 'invite') {
147
+ await consumeInvite(this.db, {
148
+ inviteKey: this.admission.inviteKey,
149
+ invitePrivateKey: this.admission.invitePrivateKey,
150
+ memberKey,
151
+ });
152
+ return;
153
+ }
154
+
155
+ await addMemberByAuthority(this.db, {
156
+ authorityKeyPair: this.admission.authorityKeyPair,
157
+ memberKey,
158
+ });
159
+ }
160
+
161
+ async isMemberRegistered(_strandId: string, memberKey: string): Promise<boolean> {
162
+ const count = await scalarCount(
163
+ this.db,
164
+ 'select count(1) as c from Strand.Member where Key = ?',
165
+ [memberKey],
166
+ );
167
+ return count > 0;
168
+ }
169
+ }
170
+
171
+ /** Run a `select count(1) as c ...` and return the scalar (0 if no row). */
172
+ async function scalarCount(db: Database, sql: string, params: SqlValue[]): Promise<number> {
173
+ for await (const row of db.eval(sql, params)) {
174
+ return (row.c as number) ?? 0;
175
+ }
176
+ return 0;
177
+ }