@serfab/cadre-core 0.7.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -10
- package/dist/cadre-node.d.ts +818 -19
- package/dist/cadre-node.js +1952 -99
- package/dist/cadre-node.js.map +1 -1
- package/dist/canonical-datetime.d.ts +33 -0
- package/dist/canonical-datetime.js +38 -0
- package/dist/canonical-datetime.js.map +1 -0
- package/dist/canonical-json.d.ts +12 -0
- package/dist/canonical-json.js +33 -0
- package/dist/canonical-json.js.map +1 -0
- package/dist/control-cohort.d.ts +63 -0
- package/dist/control-cohort.js +80 -0
- package/dist/control-cohort.js.map +1 -0
- package/dist/control-database.d.ts +279 -8
- package/dist/control-database.js +563 -134
- package/dist/control-database.js.map +1 -1
- package/dist/control-formation-recorder.d.ts +89 -0
- package/dist/control-formation-recorder.js +139 -0
- package/dist/control-formation-recorder.js.map +1 -0
- package/dist/control-schema.d.ts +12 -0
- package/dist/control-schema.js +223 -0
- package/dist/control-schema.js.map +1 -0
- package/dist/control-stream.d.ts +54 -0
- package/dist/control-stream.js +104 -0
- package/dist/control-stream.js.map +1 -0
- package/dist/device-token.d.ts +50 -0
- package/dist/device-token.js +66 -0
- package/dist/device-token.js.map +1 -0
- package/dist/diagnostics/connection-path.d.ts +118 -0
- package/dist/diagnostics/connection-path.js +204 -0
- package/dist/diagnostics/connection-path.js.map +1 -0
- package/dist/diagnostics/webrtc-turn-tracker.d.ts +50 -0
- package/dist/diagnostics/webrtc-turn-tracker.js +196 -0
- package/dist/diagnostics/webrtc-turn-tracker.js.map +1 -0
- package/dist/ed25519-key.d.ts +42 -0
- package/dist/ed25519-key.js +51 -0
- package/dist/ed25519-key.js.map +1 -0
- package/dist/hibernation-manager.d.ts +79 -0
- package/dist/hibernation-manager.js +173 -23
- package/dist/hibernation-manager.js.map +1 -1
- package/dist/index.d.ts +25 -4
- package/dist/index.js +59 -4
- package/dist/index.js.map +1 -1
- package/dist/key-store-file.d.ts +52 -0
- package/dist/key-store-file.js +194 -0
- package/dist/key-store-file.js.map +1 -0
- package/dist/key-store.d.ts +75 -0
- package/dist/key-store.js +66 -0
- package/dist/key-store.js.map +1 -0
- package/dist/peer-authorization.d.ts +48 -0
- package/dist/peer-authorization.js +66 -0
- package/dist/peer-authorization.js.map +1 -0
- package/dist/peer-record.d.ts +89 -0
- package/dist/peer-record.js +117 -0
- package/dist/peer-record.js.map +1 -0
- package/dist/push-credentials.d.ts +34 -0
- package/dist/push-credentials.js +81 -0
- package/dist/push-credentials.js.map +1 -0
- package/dist/push-fanout.d.ts +137 -0
- package/dist/push-fanout.js +197 -0
- package/dist/push-fanout.js.map +1 -0
- package/dist/push-node.d.ts +38 -0
- package/dist/push-node.js +48 -0
- package/dist/push-node.js.map +1 -0
- package/dist/push-notifier-apns.d.ts +61 -0
- package/dist/push-notifier-apns.js +199 -0
- package/dist/push-notifier-apns.js.map +1 -0
- package/dist/push-notifier-fcm.d.ts +44 -0
- package/dist/push-notifier-fcm.js +182 -0
- package/dist/push-notifier-fcm.js.map +1 -0
- package/dist/push-notifier-shared.d.ts +18 -0
- package/dist/push-notifier-shared.js +25 -0
- package/dist/push-notifier-shared.js.map +1 -0
- package/dist/push-notifier.d.ts +52 -0
- package/dist/push-notifier.js +22 -0
- package/dist/push-notifier.js.map +1 -0
- package/dist/schema-verification.d.ts +11 -2
- package/dist/schema-verification.js +15 -4
- package/dist/schema-verification.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +228 -21
- package/dist/seed-bootstrap.js +489 -168
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/seed-trust-policy.d.ts +63 -0
- package/dist/seed-trust-policy.js +56 -0
- package/dist/seed-trust-policy.js.map +1 -0
- package/dist/strand-addr-protocol.d.ts +132 -0
- package/dist/strand-addr-protocol.js +313 -0
- package/dist/strand-addr-protocol.js.map +1 -0
- package/dist/strand-cohort.d.ts +54 -0
- package/dist/strand-cohort.js +38 -0
- package/dist/strand-cohort.js.map +1 -0
- package/dist/strand-database.d.ts +54 -6
- package/dist/strand-database.js +88 -100
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-formation-manager.d.ts +79 -19
- package/dist/strand-formation-manager.js +196 -104
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +162 -0
- package/dist/strand-formation-protocol.js +279 -0
- package/dist/strand-formation-protocol.js.map +1 -0
- package/dist/strand-instance-manager.d.ts +73 -4
- package/dist/strand-instance-manager.js +167 -41
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-member-key.d.ts +29 -0
- package/dist/strand-member-key.js +38 -0
- package/dist/strand-member-key.js.map +1 -0
- package/dist/strand-member-registry.d.ts +98 -0
- package/dist/strand-member-registry.js +129 -0
- package/dist/strand-member-registry.js.map +1 -0
- package/dist/strand-membership-writer.d.ts +361 -0
- package/dist/strand-membership-writer.js +449 -0
- package/dist/strand-membership-writer.js.map +1 -0
- package/dist/strand-solicitation.d.ts +77 -2
- package/dist/strand-solicitation.js +25 -4
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-wake-payload.d.ts +30 -0
- package/dist/strand-wake-payload.js +20 -0
- package/dist/strand-wake-payload.js.map +1 -0
- package/dist/strand-wake-protocol.d.ts +114 -0
- package/dist/strand-wake-protocol.js +257 -0
- package/dist/strand-wake-protocol.js.map +1 -0
- package/dist/strand-watcher.d.ts +6 -2
- package/dist/strand-watcher.js +57 -17
- package/dist/strand-watcher.js.map +1 -1
- package/dist/types.d.ts +436 -33
- package/dist/types.js +22 -5
- package/dist/types.js.map +1 -1
- package/package.json +18 -8
- package/src/cadre-node.ts +2981 -948
- package/src/canonical-datetime.ts +39 -0
- package/src/canonical-json.ts +30 -0
- package/src/control-cohort.ts +103 -0
- package/src/control-database.ts +948 -383
- package/src/control-formation-recorder.ts +151 -0
- package/src/control-schema.ts +222 -0
- package/src/control-stream.ts +128 -0
- package/src/device-token.ts +89 -0
- package/src/diagnostics/connection-path.ts +277 -0
- package/src/diagnostics/webrtc-turn-tracker.ts +222 -0
- package/src/ed25519-key.ts +69 -0
- package/src/hibernation-manager.ts +197 -28
- package/src/index.ts +275 -79
- package/src/key-store-file.ts +203 -0
- package/src/key-store.ts +99 -0
- package/src/peer-authorization.ts +81 -0
- package/src/peer-record.ts +146 -0
- package/src/push-credentials.ts +84 -0
- package/src/push-fanout.ts +272 -0
- package/src/push-node.ts +64 -0
- package/src/push-notifier-apns.ts +247 -0
- package/src/push-notifier-fcm.ts +220 -0
- package/src/push-notifier-shared.ts +28 -0
- package/src/push-notifier.ts +52 -0
- package/src/schema-verification.ts +18 -4
- package/src/seed-bootstrap.ts +563 -223
- package/src/seed-trust-policy.ts +107 -0
- package/src/strand-addr-protocol.ts +410 -0
- package/src/strand-cohort.ts +77 -0
- package/src/strand-database.ts +113 -141
- package/src/strand-formation-manager.ts +233 -142
- package/src/strand-formation-protocol.ts +391 -0
- package/src/strand-instance-manager.ts +207 -56
- package/src/strand-member-key.ts +39 -0
- package/src/strand-member-registry.ts +177 -0
- package/src/strand-membership-writer.ts +640 -0
- package/src/strand-solicitation.ts +93 -4
- package/src/strand-wake-payload.ts +32 -0
- package/src/strand-wake-protocol.ts +331 -0
- package/src/strand-watcher.ts +61 -17
- package/src/types.ts +1032 -582
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import debug from 'debug';
|
|
2
|
-
import type {
|
|
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.
|
|
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/Manager)
|
|
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
|
|
54
|
-
* Use a storage provider factory function instead, which
|
|
55
|
-
* create strand-specific storage paths using
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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: [],
|
|
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('[
|
|
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/Manager 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
|
-
|
|
242
|
-
|
|
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
|
|
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
|
-
|
|
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 { ed25519KeyPairFromLibp2p, type Ed25519KeyPair } from './ed25519-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`/`Manager.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 ed25519KeyPairFromLibp2p} so the same seed→public derivation used for node
|
|
29
|
+
* owner 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/manager.
|
|
34
|
+
* @throws If the decoded key is not Ed25519 or the raw bytes are malformed.
|
|
35
|
+
*/
|
|
36
|
+
export function strandMemberKeyPair(memberPrivateKey: string): Ed25519KeyPair {
|
|
37
|
+
const privateKey = privateKeyFromProtobuf(uint8ArrayFromString(memberPrivateKey, 'base64'));
|
|
38
|
+
return ed25519KeyPairFromLibp2p(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 { Ed25519KeyPair } from './ed25519-key.js';
|
|
6
|
+
import {
|
|
7
|
+
verifyStrandPayload,
|
|
8
|
+
consumeInvite,
|
|
9
|
+
addMemberByManager,
|
|
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 manager signature (the manager-side join). */
|
|
41
|
+
readonly mode: 'manager';
|
|
42
|
+
/** The admitting manager's strand keypair. */
|
|
43
|
+
readonly managerKeyPair: Ed25519KeyPair;
|
|
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
|
+
* - `manager` → {@link addMemberByManager}, the manager-side flow that
|
|
119
|
+
* seats a member already trusted by a manager.
|
|
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 addMemberByManager(this.db, {
|
|
156
|
+
managerKeyPair: this.admission.managerKeyPair,
|
|
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
|
+
}
|