@serfab/cadre-core 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +3 -2
  2. package/dist/cadre-node.d.ts +328 -25
  3. package/dist/cadre-node.js +760 -59
  4. package/dist/cadre-node.js.map +1 -1
  5. package/dist/control-database.d.ts +19 -0
  6. package/dist/control-database.js +51 -9
  7. package/dist/control-database.js.map +1 -1
  8. package/dist/control-schema.d.ts +1 -1
  9. package/dist/control-schema.js +30 -2
  10. package/dist/control-schema.js.map +1 -1
  11. package/dist/enrolled-machine-store-file.d.ts +13 -0
  12. package/dist/enrolled-machine-store-file.js +41 -0
  13. package/dist/enrolled-machine-store-file.js.map +1 -0
  14. package/dist/enrolled-machine-store.d.ts +81 -0
  15. package/dist/enrolled-machine-store.js +225 -0
  16. package/dist/enrolled-machine-store.js.map +1 -0
  17. package/dist/file-durable-slot.js +5 -3
  18. package/dist/file-durable-slot.js.map +1 -1
  19. package/dist/index.d.ts +5 -3
  20. package/dist/index.js +23 -5
  21. package/dist/index.js.map +1 -1
  22. package/dist/membership-connection-gater.d.ts +15 -1
  23. package/dist/membership-connection-gater.js +9 -3
  24. package/dist/membership-connection-gater.js.map +1 -1
  25. package/dist/node-local-snapshot.d.ts +6 -0
  26. package/dist/node-local-snapshot.js +7 -1
  27. package/dist/node-local-snapshot.js.map +1 -1
  28. package/dist/{strand-backfill.d.ts → peer-join-backfill.d.ts} +49 -20
  29. package/dist/{strand-backfill.js → peer-join-backfill.js} +133 -52
  30. package/dist/peer-join-backfill.js.map +1 -0
  31. package/dist/relay-addrs.d.ts +73 -0
  32. package/dist/relay-addrs.js +195 -0
  33. package/dist/relay-addrs.js.map +1 -1
  34. package/dist/strand-database.d.ts +12 -0
  35. package/dist/strand-database.js +16 -0
  36. package/dist/strand-database.js.map +1 -1
  37. package/dist/strand-formation-manager.d.ts +12 -0
  38. package/dist/strand-formation-manager.js +10 -3
  39. package/dist/strand-formation-manager.js.map +1 -1
  40. package/dist/strand-formation-protocol.d.ts +76 -2
  41. package/dist/strand-formation-protocol.js +78 -2
  42. package/dist/strand-formation-protocol.js.map +1 -1
  43. package/dist/strand-instance-manager.d.ts +147 -14
  44. package/dist/strand-instance-manager.js +225 -45
  45. package/dist/strand-instance-manager.js.map +1 -1
  46. package/dist/strand-membership-writer.d.ts +12 -0
  47. package/dist/strand-membership-writer.js +23 -1
  48. package/dist/strand-membership-writer.js.map +1 -1
  49. package/dist/strand-network-config.d.ts +84 -0
  50. package/dist/strand-network-config.js +136 -0
  51. package/dist/strand-network-config.js.map +1 -0
  52. package/dist/strand-revocation-enforcer.d.ts +392 -0
  53. package/dist/strand-revocation-enforcer.js +533 -0
  54. package/dist/strand-revocation-enforcer.js.map +1 -0
  55. package/dist/strand-solicitation.d.ts +7 -0
  56. package/dist/strand-solicitation.js +8 -2
  57. package/dist/strand-solicitation.js.map +1 -1
  58. package/dist/strand-watcher.js +6 -1
  59. package/dist/strand-watcher.js.map +1 -1
  60. package/dist/types.d.ts +210 -16
  61. package/dist/types.js +7 -1
  62. package/dist/types.js.map +1 -1
  63. package/package.json +16 -8
  64. package/src/cadre-node.ts +6353 -5603
  65. package/src/control-database.ts +53 -9
  66. package/src/control-schema.ts +30 -2
  67. package/src/enrolled-machine-store-file.ts +43 -0
  68. package/src/enrolled-machine-store.ts +280 -0
  69. package/src/file-durable-slot.ts +5 -3
  70. package/src/index.ts +52 -10
  71. package/src/membership-connection-gater.ts +9 -3
  72. package/src/node-local-snapshot.ts +13 -1
  73. package/src/peer-addr-book.ts +251 -250
  74. package/src/{strand-backfill.ts → peer-join-backfill.ts} +478 -389
  75. package/src/relay-addrs.ts +218 -0
  76. package/src/strand-database.ts +17 -0
  77. package/src/strand-formation-manager.ts +22 -3
  78. package/src/strand-formation-protocol.ts +808 -692
  79. package/src/strand-instance-manager.ts +317 -54
  80. package/src/strand-membership-writer.ts +23 -1
  81. package/src/strand-network-config.ts +172 -0
  82. package/src/strand-revocation-enforcer.ts +653 -0
  83. package/src/strand-solicitation.ts +484 -471
  84. package/src/strand-watcher.ts +319 -314
  85. package/src/types.ts +1554 -1354
  86. package/dist/strand-backfill.js.map +0 -1
package/src/types.ts CHANGED
@@ -1,1354 +1,1554 @@
1
- import type { ConnectionGater, Libp2p, PeerId, PrivateKey } from '@libp2p/interface';
2
- import type { IRawStorage, Libp2pTransports } from '@optimystic/db-p2p';
3
- import type { IPeerNetwork, IRepo } from '@optimystic/db-core';
4
- import type { StrandBackfillConfig } from './strand-backfill.js';
5
- import type { StrandDatabase } from './strand-database.js';
6
- import type { SeedTrustPolicy } from './seed-trust-policy.js';
7
- import type { KeyStore, KeyId } from './key-store.js';
8
- import type { TrustedOwnerStore, TrustSource } from './trusted-owner-store.js';
9
- import type { BootstrapPeerStore } from './bootstrap-peer-store.js';
10
- import type { PushNotifier } from './push-notifier.js';
11
- import type { RevocableTable } from './control-authorization.js';
12
-
13
- /**
14
- * Extended Libp2p node with the coordinatedRepo attached by db-p2p's
15
- * createLibp2pNode after node creation (not surfaced on the base Libp2p type).
16
- */
17
- export interface Libp2pNodeWithRepo extends Libp2p {
18
- coordinatedRepo: IRepo;
19
- /**
20
- * The node's key-addressed peer network, attached by db-p2p's factory alongside
21
- * `coordinatedRepo` (`libp2p-node-base.ts`). Optional deliberately: the base
22
- * factory always assigns it, but it is not part of the upstream node type, so
23
- * declaring it required would be a claim this repo cannot enforce. The strand
24
- * backfill (`strand-backfill.ts`) logs once and stays inert when it is absent.
25
- */
26
- keyNetwork?: IPeerNetwork;
27
- }
28
-
29
- /**
30
- * Node profile determines storage participation
31
- */
32
- export type NodeProfile = 'transaction' | 'storage';
33
-
34
- /**
35
- * Strand filter configuration - determines which strands this node participates in
36
- */
37
- export type StrandFilter =
38
- | { mode: 'all' } // All strands (default for servers)
39
- | { mode: 'sAppId'; sAppId: string } // Only strands for specific sApp
40
- | { mode: 'strandId'; strandId: string } // Single specific strand
41
- | { mode: 'none' }; // Control network only
42
-
43
- /**
44
- * Latency hint for strand hibernation behavior
45
- */
46
- export type LatencyHint = 'realtime' | 'interactive' | 'background' | 'archive';
47
-
48
- /**
49
- * Hibernation timeout configuration per latency hint (in milliseconds)
50
- */
51
- export interface HibernationTimeouts {
52
- /** Time before transitioning from active to idle */
53
- idleTimeout: number;
54
- /** Time before transitioning from idle to hibernating */
55
- hibernateTimeout: number;
56
- /**
57
- * Base interval for the FIRST check-in after hibernating. Successive
58
- * no-activity check-ins escalate this delay by {@link checkInBackoffFactor}
59
- * (see {@link HibernationManager}'s self-rescheduling check-in chain).
60
- */
61
- checkInInterval: number;
62
- /**
63
- * Multiplier applied to the check-in delay after each check-in that finds no
64
- * pending activity (must be >= 1; 1 disables escalation). Default 2 →
65
- * doubling: base, base×2, base×4, … until {@link checkInMaxInterval}.
66
- */
67
- checkInBackoffFactor: number;
68
- /**
69
- * Upper bound on the escalating check-in delay — the concrete realization of
70
- * the architecture's "minutes → hours → days" ceiling for this hint. Backoff
71
- * never schedules a delay longer than this.
72
- */
73
- checkInMaxInterval: number;
74
- }
75
-
76
- /**
77
- * Default hibernation timeouts per latency hint.
78
- *
79
- * `checkInInterval` is the BASE delay; `checkInMaxInterval` is the per-hint
80
- * ceiling the exponential backoff escalates toward (interactive minutes→~1h,
81
- * background minutes→~6h, archive ~1h→~3 days).
82
- */
83
- export const HIBERNATION_TIMEOUTS: Record<LatencyHint, HibernationTimeouts> = {
84
- realtime: {
85
- idleTimeout: Infinity, // Never idle
86
- hibernateTimeout: Infinity, // Never hibernate
87
- checkInInterval: Infinity, // N/A
88
- checkInBackoffFactor: 2,
89
- checkInMaxInterval: Infinity // N/A
90
- },
91
- interactive: {
92
- idleTimeout: 5 * 60 * 1000, // 5 minutes
93
- hibernateTimeout: 15 * 60 * 1000, // 15 minutes after idle
94
- checkInInterval: 30 * 1000, // base: 30 seconds
95
- checkInBackoffFactor: 2,
96
- checkInMaxInterval: 60 * 60 * 1000 // cap: ~1 hour
97
- },
98
- background: {
99
- idleTimeout: 1 * 60 * 1000, // 1 minute
100
- hibernateTimeout: 5 * 60 * 1000, // 5 minutes after idle
101
- checkInInterval: 5 * 60 * 1000, // base: 5 minutes
102
- checkInBackoffFactor: 2,
103
- checkInMaxInterval: 6 * 60 * 60 * 1000 // cap: ~6 hours
104
- },
105
- archive: {
106
- idleTimeout: 10 * 1000, // 10 seconds
107
- hibernateTimeout: 30 * 1000, // 30 seconds after idle
108
- checkInInterval: 60 * 60 * 1000, // base: 1 hour
109
- checkInBackoffFactor: 2,
110
- checkInMaxInterval: 3 * 24 * 60 * 60 * 1000 // cap: ~3 days
111
- }
112
- };
113
-
114
- /**
115
- * Storage provider - either an `IRawStorage` instance or a factory function.
116
- * Factory functions are useful for creating strand-specific storage instances.
117
- *
118
- * **Called once per scope per runtime lifetime.** The scopes are the literal string
119
- * `'control'` (once per `CadreNode.start()`) and each strand id (once per
120
- * `startStrand`). Hibernation — `quiesceStrand` then `resumeStrand` — reuses the
121
- * store already resolved for that strand and does NOT re-enter this callback.
122
- *
123
- * It IS re-entered for a scope after that scope's runtime has stopped
124
- * (`stopStrand`, or a `stop()` then `start()` cycle on one `CadreNode`), so it must
125
- * be able to hand back a store over the same durable backend a second time. A
126
- * factory may mint a fresh object per call; it must not *need* to.
127
- *
128
- * cadre-core disposes only its own cache wrapper — it never closes the store you
129
- * returned. Closing the underlying handle stays the embedder's job.
130
- *
131
- * Either form is safe with a persistent backend. One instance handed to every scope
132
- * also shares ONE cache wrapper (`wrapStorageWithCache` memoizes per inner instance),
133
- * which counts its holders: a scope stopping releases only its own claim, and the
134
- * wrapper is retired when the last scope releases it. The factory form is still the
135
- * better default for a different reason — it partitions each strand's data, which
136
- * simplifies cleanup.
137
- */
138
- export type RawStorageProvider = IRawStorage | ((strandId: string) => IRawStorage);
139
-
140
- /**
141
- * Storage configuration for storage profile nodes
142
- */
143
- export interface StorageConfig {
144
- /**
145
- * Storage provider - either an IRawStorage instance or a factory function.
146
- * See {@link RawStorageProvider} for how often cadre-core calls it.
147
- *
148
- * For Node.js environments, use FileRawStorage from @optimystic/db-p2p-storage-fs:
149
- * ```typescript
150
- * import { FileRawStorage } from '@optimystic/db-p2p-storage-fs';
151
- * storage: { provider: (strandId) => new FileRawStorage(`./data/${strandId}`) }
152
- * ```
153
- *
154
- * For React Native, use the appropriate storage from @optimystic/db-p2p-storage-rn:
155
- * ```typescript
156
- * import { LevelDBRawStorage } from '@optimystic/db-p2p-storage-rn';
157
- * // `db` is an open LevelDB handle (see `openOptimysticRNDb` over `rn-leveldb`)
158
- * storage: { provider: (strandId) => new LevelDBRawStorage(openDb(`sereus-${strandId}`)) }
159
- * ```
160
- *
161
- * For in-memory storage (testing):
162
- * ```typescript
163
- * import { MemoryRawStorage } from '@optimystic/db-p2p';
164
- * storage: { provider: () => new MemoryRawStorage() }
165
- * ```
166
- */
167
- provider: RawStorageProvider;
168
- quotaBytes?: number;
169
- }
170
-
171
- /**
172
- * Network configuration for libp2p
173
- */
174
- export interface NetworkConfig {
175
- listenAddrs?: string[];
176
- /**
177
- * Addresses to advertise to peers **instead of** `listenAddrs` — for a node behind
178
- * NAT or a reverse proxy that is reachable at a different address than it binds
179
- * (e.g. `mynode.example.com:4001` in front of a `0.0.0.0:4001` listener). Reaches
180
- * libp2p's `addresses.announce` via `@optimystic/db-p2p`'s `NodeOptions.announceAddrs`,
181
- * on this node's control node and on every strand node it runs.
182
- *
183
- * **A non-empty value REPLACES the advertised set entirely.** Observed addresses,
184
- * and the `/p2p-circuit` address earned from a {@link relayAddrs} reservation, are
185
- * all dropped from what peers are told — so a node configured with both this and
186
- * `relayAddrs` stops being reachable through its relay. `CadreNode.start()` warns
187
- * when it sees that combination; it does not refuse it, since a node whose relay
188
- * slot is decorative may legitimately want only the announced address.
189
- *
190
- * Reach for {@link appendAnnounceAddrs} instead in the common case — one publicly
191
- * reachable address ADDED to everything else the node advertises. Or for
192
- * {@link relayAddrs} alone, when the node has no stable public address to name and
193
- * a relay reservation is what makes it dialable. (`inviteAddressResolver` is a
194
- * narrower tool again: it substitutes a reachable address into invite payloads
195
- * only, and changes nothing about what this node advertises on the wire.)
196
- *
197
- * An empty array means "unset" — it is dropped rather than forwarded, so it cannot
198
- * land as an explicit empty announce set. An empty *entry* is a different thing and
199
- * is rejected: a list of one blank string is non-empty, so it would replace every
200
- * advertised address with nothing. Malformed entries are rejected on the same terms,
201
- * both at node start — libp2p itself would not notice either until its first address
202
- * lookup (see `announce-addrs.ts`), so this repo checks each entry up front.
203
- */
204
- announceAddrs?: string[];
205
- /**
206
- * Addresses to advertise **in addition to** `listenAddrs` — the field most
207
- * deployments actually want, since it makes a node reachable at a public address
208
- * without discarding the observed and `/p2p-circuit` addresses it would otherwise
209
- * advertise. Reaches libp2p's `addresses.appendAnnounce` via
210
- * `@optimystic/db-p2p`'s `NodeOptions.appendAnnounceAddrs`.
211
- *
212
- * **Ignored while {@link announceAddrs} is non-empty** — that is libp2p's own
213
- * precedence, applied upstream; this repo does not merge the two locally. Set one
214
- * or the other, not both.
215
- *
216
- * Empty-array, blank-entry and malformed-entry handling all match {@link announceAddrs}.
217
- */
218
- appendAnnounceAddrs?: string[];
219
- /**
220
- * Circuit-relay servers this node reserves a slot on, so peers can reach it
221
- * from behind NAT. Each entry is the relay's DIRECT dial multiaddr ending in
222
- * its peerId — `/dns4/relay.example.com/tcp/4001/p2p/12D3KooW…` — with no
223
- * `/p2p-circuit` suffix (one is appended for you; an entry that already has
224
- * one is passed through unchanged).
225
- *
226
- * On the CONTROL node this adds libp2p's bare `/p2p-circuit` SEARCH listener to
227
- * {@link listenAddrs} (`relay-addrs.ts`) and `CadreNode.start()` drives the
228
- * reservation itself once the control database is up — the ordering matters,
229
- * because a listener that dials its relay from inside `libp2p.start()` put a
230
- * sibling in this node's cohort before its own database existed, and a sibling
231
- * that had not yet replicated this node's membership row refused the bring-up.
232
- * STRAND nodes inherit the older per-relay `<relay>/p2p-circuit` listener, which
233
- * has no such ordering hazard. A relay named here also becomes a
234
- * delegate-announce target, so this node's strand nodes may reserve on it too
235
- * (see `delegate-admission.ts`).
236
- *
237
- * Setting this gives the node a listener even when `listenAddrs` is `[]` —
238
- * that is the point of naming a relay, but note that React Native hosts
239
- * (`reference-app-rn`'s `cadre-phone.ts`) deliberately do NOT listen, and
240
- * must not acquire a listener by accident.
241
- *
242
- * FAIL-FAST both ways: a malformed entry throws at node start rather than being
243
- * silently dropped, and a first reservation attempt that lands no
244
- * `/p2p-circuit` address throws `RelayReservationFailedError` out of `start()`.
245
- * The fail-soft posture over the same machinery is `CadreNode.reserveRelays()`.
246
- */
247
- relayAddrs?: string[];
248
- /**
249
- * Enable circuit relay server - allows this node to relay connections for other peers.
250
- * When undefined, defaults to true for storage profile nodes (they typically have
251
- * better connectivity and uptime), false for transaction profile nodes.
252
- */
253
- enableRelay?: boolean;
254
- /**
255
- * Cap on concurrent circuit-relay reservations this node's relay server grants to
256
- * peers it cannot (yet) recognize as authorized members — the boot-ordering window
257
- * where a genuine member reserves its relay slot before its membership row has
258
- * replicated here (see `membership-connection-gater.ts` → "The relay-reservation
259
- * seam"). Authorized members and announced delegate peers are never counted against
260
- * it. Default `MAX_UNAUTHORIZED_RELAY_RESERVATIONS` (8); 0 refuses every
261
- * unauthorized reservation. Only meaningful while {@link enableRelay} is on.
262
- */
263
- unauthorizedRelayReservationCap?: number;
264
- /**
265
- * Custom libp2p transports. When omitted, the default transports from
266
- * `@optimystic/db-p2p` are used (TCP + circuit relay for Node.js).
267
- *
268
- * React Native apps must supply WebSocket-based transports because TCP
269
- * is not available in the RN runtime:
270
- * ```typescript
271
- * import { webSockets } from '@libp2p/websockets';
272
- * import { circuitRelayTransport } from '@libp2p/circuit-relay-v2';
273
- *
274
- * network: {
275
- * transports: [webSockets(), circuitRelayTransport()],
276
- * listenAddrs: [] // RN nodes typically cannot listen
277
- * }
278
- * ```
279
- */
280
- transports?: Libp2pTransports;
281
- /**
282
- * Optional async resolver returning the multiaddrs to embed in invites
283
- * (and other owner-address contexts). When unset, `libp2pNode.getMultiaddrs()`
284
- * is used. Hosts behind NAT supply this to substitute their DDNS hostname
285
- * and externally-mapped port — see `@serfab/cadre-host`'s NatService.
286
- *
287
- * Returned addresses need NOT carry a `/p2p/<peerId>` suffix: `CadreNode`
288
- * appends its own before publishing or dialing any of them, so a resolver
289
- * cannot leave a sibling with the mixed suffixed/unsuffixed list that
290
- * `libp2p.dial` rejects. Returning them already suffixed is equally fine.
291
- */
292
- inviteAddressResolver?: () => Promise<string[]>;
293
- /**
294
- * Optional libp2p connection gater, threaded to both the control node and every
295
- * strand's cohort node. libp2p's browser default denies dialing insecure
296
- * WebSockets and private/loopback addresses; callers that must dial local or
297
- * unsecured peers (the web reference dialing a `127.0.0.1/.../ws` responder,
298
- * Playwright e2e, RN simulators) supply a permissive gater here — e.g.
299
- * `{ denyDialMultiaddr: () => false }`. See `@optimystic/db-p2p`'s
300
- * `libp2p-node-base` for the underlying option.
301
- *
302
- * On the CONTROL node this gater is composed with the built-in membership
303
- * admission gate (`membership-connection-gater.ts`): every hook supplied here
304
- * is honored unchanged, and on inbound encrypted connections a deny from
305
- * either this gater or the membership policy denies. Strand cohort nodes
306
- * receive this gater as-is (their peers are legitimately cross-party).
307
- */
308
- connectionGater?: ConnectionGater;
309
- /**
310
- * Tuning for the proactive control-cohort dial routine
311
- * ({@link CadreNode.reconcileControlCohort}), which keeps a party's control
312
- * nodes connected so the `CadreControl` collections form a replicating cohort.
313
- * Every field is optional; omit for the defaults
314
- * ({@link DEFAULT_CONTROL_COHORT_TARGET_DEGREE} /
315
- * {@link DEFAULT_CONTROL_COHORT_RECONCILE_MS} /
316
- * {@link DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS} / `STRAND_PEER_ADDR_REFRESH_MS`).
317
- */
318
- controlCohort?: {
319
- /**
320
- * Cap on the number of NON-owner siblings dialed per reconcile pass
321
- * (backbone/owner members are always dialed and do not count). Defaults
322
- * to {@link DEFAULT_CONTROL_COHORT_TARGET_DEGREE}.
323
- */
324
- targetDegree?: number;
325
- /**
326
- * Recurring reconcile cadence in ms. Defaults to
327
- * {@link DEFAULT_CONTROL_COHORT_RECONCILE_MS}.
328
- */
329
- reconcileMs?: number;
330
- /**
331
- * Budget for ONE sibling's proactive dial, in ms. Bounds a pass at
332
- * (dialed siblings) × this, instead of leaving it to each address's own
333
- * libp2p attempt timeout multiplied by address fan-out and relay hops.
334
- * Defaults to {@link DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS}.
335
- */
336
- dialTimeoutMs?: number;
337
- /**
338
- * How often each running strand re-resolves its siblings' strand-network
339
- * addresses into its own libp2p address book — a step of the reconcile pass,
340
- * on its own much longer throttle. Defaults to `STRAND_PEER_ADDR_REFRESH_MS`
341
- * (10 min); must stay well under the peerStore's one-hour address expiry.
342
- */
343
- strandAddrRefreshMs?: number;
344
- };
345
- }
346
-
347
- /**
348
- * Hibernation configuration
349
- */
350
- export interface HibernationConfig {
351
- enabled: boolean;
352
- defaultLatencyHint?: LatencyHint;
353
- /** Custom timeouts per latency hint (overrides defaults) */
354
- customTimeouts?: Partial<Record<LatencyHint, Partial<HibernationTimeouts>>>;
355
- /**
356
- * How long a hibernating strand stays resumed during a check-in to let its
357
- * strand network connect to reachable cohort peers (and the app drive
358
- * pull-on-read activity) before re-hibernating if still idle. Defaults to
359
- * {@link DEFAULT_CHECKIN_WINDOW_MS}.
360
- */
361
- checkInWindowMs?: number;
362
- }
363
-
364
- /**
365
- * Default duration of the resume-and-probe window during a strand check-in.
366
- * See {@link HibernationConfig.checkInWindowMs}.
367
- */
368
- export const DEFAULT_CHECKIN_WINDOW_MS = 15 * 1000;
369
-
370
- /**
371
- * Control network configuration
372
- */
373
- export interface ControlNetworkConfig {
374
- partyId: string;
375
- bootstrapNodes: string[];
376
- /** Optional path to the control schema file (defaults to bundled schema) */
377
- schemaPath?: string;
378
- }
379
-
380
- /**
381
- * Replication-breadth constants, both networks' cluster policies, and the strand
382
- * resolver, re-exported so cadre embedders, the SQL plugin and the integration
383
- * harness share one definition. Defined in `@serfab/quereus-plugin-sereus`
384
- * because that package also creates libp2p nodes and cannot depend on this one.
385
- * The control network's breadth is fixed ({@link CONTROL_REPLICATION_BREADTH},
386
- * not configurable) and so is its consensus policy ({@link CONTROL_CLUSTER_POLICY}
387
- * — notably the super-majority threshold, which it leaves at Optimystic's default
388
- * by omission); the strand breadth defaults to
389
- * {@link CadreNodeConfig.strandClusterSize} while its policy is the fixed
390
- * {@link STRAND_CLUSTER_POLICY}.
391
- */
392
- export {
393
- MIN_CLUSTER_SIZE,
394
- CONTROL_REPLICATION_BREADTH,
395
- CONTROL_CLUSTER_POLICY,
396
- DEFAULT_STRAND_CLUSTER_SIZE,
397
- STRAND_CLUSTER_POLICY,
398
- resolveStrandClusterSize
399
- } from '@serfab/quereus-plugin-sereus';
400
-
401
- /**
402
- * Main configuration for a CadreNode
403
- */
404
- export interface CadreNodeConfig {
405
- /**
406
- * If provided, use this keypair for the node identity (direct injection).
407
- * Mutually exclusive with {@link keyStore} — supplying both is a configuration
408
- * error (fail closed) thrown by `start()` before any network bring-up.
409
- */
410
- privateKey?: PrivateKey;
411
-
412
- /**
413
- * Pluggable secure store for node key material. When set, the node loads its
414
- * identity from `keyStore` under {@link identityKeyId}, generating + persisting
415
- * a fresh Ed25519 key on first run (protobuf bytes are the canonical stored
416
- * form). Mutually exclusive with {@link privateKey}. Absent ⇒ use `privateKey`
417
- * when set, else libp2p generates an ephemeral key.
418
- *
419
- * In the single-key reference model the owner signing key is *derived from*
420
- * the node identity (see {@link CadreNode.getIdentityOwnerKey}), so
421
- * protecting the identity in a secure enclave protects the owner key too.
422
- * A future separate-owner slot (`ownerKeyId`) is anticipated but not
423
- * built here.
424
- */
425
- keyStore?: KeyStore;
426
-
427
- /** Slot id for the node identity in {@link keyStore}. Default: `'cadre/identity'`. */
428
- identityKeyId?: KeyId;
429
-
430
- /** Control network connection settings */
431
- controlNetwork: ControlNetworkConfig;
432
-
433
- /** Node profile: transaction-only or storage */
434
- profile: NodeProfile;
435
-
436
- /** Which strands to participate in */
437
- strandFilter?: StrandFilter;
438
-
439
- /** Storage configuration (only for storage profile) */
440
- storage?: StorageConfig;
441
-
442
- /** Network configuration */
443
- network?: NetworkConfig;
444
-
445
- /**
446
- * Number of nodes Optimystic is told a **strand** network's replication cluster
447
- * should have, for every strand this node starts. The control network is not
448
- * configurable — it always replicates to {@link CONTROL_REPLICATION_BREADTH}
449
- * nodes, which is above any party's node count, because every member reads the
450
- * whole control database and a member that misses a write may never learn the
451
- * fact (see that constant for the full reasoning).
452
- *
453
- * Every node on the same strand should use the same value: the number bounds the
454
- * cohort a member independently derives, and Optimystic's cluster-membership gate
455
- * can reject a coordinator's smaller declared set as a downsize against a larger
456
- * derived view.
457
- *
458
- * Frozen when the strand's libp2p node is created; a change takes effect on
459
- * restart, not when cadre membership grows. Defaults to
460
- * {@link DEFAULT_STRAND_CLUSTER_SIZE} (4 — the smallest breadth whose 0.75
461
- * super-majority still commits with one holder offline); anything below
462
- * {@link MIN_CLUSTER_SIZE} is rejected before a node is created. The cost of a
463
- * small value is replication breadth and reliance on read repair, not commit
464
- * correctness — but at 2 that read repair cannot converge, because a lone
465
- * corroborator's stale answer is accepted as the cluster's truth.
466
- */
467
- strandClusterSize?: number;
468
-
469
- /**
470
- * Tuning for the strand peer-join block catch-up (see `strand-backfill.ts`):
471
- * when a strand's libp2p node connects to a peer this runtime has not yet
472
- * caught up, every block in the strand's own raw store is pushed to that peer,
473
- * so a machine that joined the strand after blocks were committed still ends
474
- * up physically holding them. Applies to every strand this node starts;
475
- * strand-only — the control network has its own row-level re-issue queue
476
- * (`drainPendingControlReplication`). Omit for the defaults
477
- * (`DEFAULT_STRAND_BACKFILL`); `{ enabled: false }` restores the
478
- * no-backfill behaviour.
479
- */
480
- strandBackfill?: StrandBackfillConfig;
481
-
482
- /** Hibernation configuration */
483
- hibernation?: HibernationConfig;
484
-
485
- /** Polling interval for strand watcher in ms (default: 5000) */
486
- strandWatchInterval?: number;
487
-
488
- /**
489
- * Require a valid author signature on every sApp schema before a strand is
490
- * formed or joined. Defaults to true (fail closed): an unsigned schema is
491
- * rejected before any libp2p node or schema DDL is brought up. Set false ONLY
492
- * for dev/test where unsigned demo schemas are used.
493
- */
494
- requireSignedSchemas?: boolean;
495
-
496
- /**
497
- * Node-wide default trust anchor for INBOUND control-network seeds. Forwarded
498
- * into every SeedBootstrapService this node constructs (owner, receive-only
499
- * listener, and the temp service used by applySeed when no service exists), and
500
- * used as the service-level default the libp2p seed-protocol handler relies on
501
- * (that path has no per-call override seam). Defaults to anchoredTrustPolicy()
502
- * inside SeedBootstrapService when unset — a node whose node-local trusted-owner
503
- * anchor was never seeded then rejects every seed. A per-call
504
- * `applySeed(seed, { trustPolicy })` override still wins over this default for
505
- * callers that hold out-of-band material (e.g. a pinned key from a CadreInvite).
506
- */
507
- seedTrustPolicy?: SeedTrustPolicy;
508
-
509
- /**
510
- * Node-local trusted-owner anchor (see `trusted-owner-store.ts`): the
511
- * NON-replicated, per-party record of owner keys established out of band —
512
- * the anchor membership/seed trust can rest on, since the replicated
513
- * `OwnerKey` table can be polluted by a stranger's self-genesis. Absent ⇒ an
514
- * in-memory store is created at start() (ephemeral: anchored trust does not
515
- * survive the process).
516
- */
517
- trustedOwners?: {
518
- /**
519
- * Injected store instance — e.g. a `FileTrustedOwnerStore` from the
520
- * Node-only subpath `@serfab/cadre-core/trusted-owner-store-file`,
521
- * persisted in the node's state directory (same injection/isolation pattern
522
- * as {@link keyStore}). Its `partyId` must match `controlNetwork.partyId`;
523
- * start() fails closed on a mismatch.
524
- */
525
- store?: TrustedOwnerStore;
526
- /**
527
- * Owner keys (base64url ed25519) established out of band and seeded into
528
- * the store during start(): operator pins (e.g. cadre-cli
529
- * `--pin-owner-key` / `CADRE_OWNER_KEYS`) or a `CadreInvite.ownerKeys`
530
- * already known at config time. Seeding is idempotent across restarts.
531
- */
532
- pinnedKeys?: string[];
533
- /**
534
- * Provenance recorded for {@link pinnedKeys}. Default: 'operator'.
535
- * ('genesis' is reserved for the node's own founding key, seeded
536
- * internally by `initializeSeedBootstrap`.)
537
- */
538
- pinnedSource?: Exclude<TrustSource, 'genesis'>;
539
- };
540
-
541
- /**
542
- * Node-local cold-start bootstrap-peer store (see `bootstrap-peer-store.ts`):
543
- * the owner addresses an applied seed nominated, kept so
544
- * `reconcileControlCohort`'s cold-start branch can keep retrying a node that
545
- * was seeded but never managed to connect. Sibling of {@link trustedOwners} —
546
- * same NON-replicated, per-party, injected-backend shape — but nothing here is
547
- * trust-bearing: these are dial hints only, and every address is re-bound to
548
- * its peer id before the dial. Absent ⇒ an in-memory store is created at
549
- * start() (ephemeral: the retry set does not survive the process, and such a
550
- * node is stranded permanently if it restarts before connecting).
551
- *
552
- * The Node CLI, the browser, and React Native all inject a durable backend
553
- * today; only NativeScript stays ephemeral, tracked by the
554
- * `ns-durable-node-local-stores` ticket (which covers the same gap for
555
- * {@link trustedOwners}). A new platform needs no new store class: supply a
556
- * `DurableSlot` for the platform's storage and inject
557
- * `PersistentBootstrapPeerStore.open(slot, partyId)` /
558
- * `PersistentTrustedOwnerStore.open(slot, partyId)`.
559
- */
560
- bootstrapPeers?: {
561
- /**
562
- * Injected store instance — e.g. a `FileBootstrapPeerStore` from the
563
- * Node-only subpath `@serfab/cadre-core/bootstrap-peer-store-file`, persisted
564
- * in the node's state directory (same injection/isolation pattern as
565
- * {@link keyStore} / {@link trustedOwners}). Its `partyId` must match
566
- * `controlNetwork.partyId`; start() fails closed on a mismatch.
567
- */
568
- store?: BootstrapPeerStore;
569
- };
570
-
571
- /**
572
- * Platform push-delivery for suspended mobile peers. When present, the node's
573
- * server fan-out can deliver strand-wake data messages over the platform push
574
- * channel (FCM/APNs). Absent ⇒ no platform push (control-network push-wake only).
575
- *
576
- * The `notifier` is **injected**, not constructed here: a Node host builds it
577
- * from `@serfab/cadre-core/push-node` (`createPushNotifier(credentials)`) and
578
- * passes the instance, so the FCM/APNs modules (`node:crypto`/`node:http2`)
579
- * stay out of the cross-platform core graph. Ownership transfers to the node on
580
- * injection — `CadreNode.stop` closes the notifier (via `PushFanoutService`),
581
- * releasing its APNs HTTP/2 session; a host must NOT also close an injected
582
- * notifier itself. `cooldownMs`/`debounceMs` carry the fan-out anti-spam policy.
583
- */
584
- push?: {
585
- /** The Node-constructed push router (see `@serfab/cadre-core/push-node`). */
586
- notifier: PushNotifier;
587
- /**
588
- * Per-`(peer, strand)` minimum gap between push-wakes the fan-out emits.
589
- * Default {@link DEFAULT_PUSH_COOLDOWN_MS} (5 min).
590
- */
591
- cooldownMs?: number;
592
- /**
593
- * Per-strand burst-coalescing window: a second trigger within it is dropped.
594
- * Default {@link DEFAULT_PUSH_DEBOUNCE_MS} (10 s).
595
- */
596
- debounceMs?: number;
597
- };
598
- }
599
-
600
- /**
601
- * Status of a strand instance
602
- */
603
- export type StrandStatus =
604
- | 'starting'
605
- | 'active'
606
- | 'idle'
607
- | 'hibernating'
608
- | 'stopping'
609
- | 'stopped'
610
- | 'error';
611
-
612
- /**
613
- * App information from strand header
614
- */
615
- export interface SAppInfo {
616
- id: string;
617
- version: string;
618
- schema: string;
619
- signature?: string;
620
- }
621
-
622
- /**
623
- * Strand instance state
624
- */
625
- export interface StrandInstance {
626
- strandId: string;
627
- status: StrandStatus;
628
- sAppInfo?: SAppInfo;
629
-
630
- /** The libp2p node for this strand (only when active/idle) */
631
- libp2pNode?: Libp2p;
632
-
633
- /** The Quereus database for this strand (only when active/idle) */
634
- database?: StrandDatabase;
635
-
636
- /** Membership info for closed strands */
637
- memberKey?: string;
638
- memberPrivateKey?: string;
639
-
640
- /** Activity tracking */
641
- connectedPeers: number;
642
- lastActivity: Date;
643
- nextCheckIn?: Date;
644
-
645
- /** Latency hint from app or override */
646
- latencyHint: LatencyHint;
647
-
648
- /** Error message if status is 'error' */
649
- error?: string;
650
- }
651
-
652
- /**
653
- * Outcome of an on-demand {@link CadreNode.serviceWake} — the mobile push-wake
654
- * cycle (resume → bounded window → re-hibernate-if-idle) run imperatively for a
655
- * single strand.
656
- */
657
- export interface ServiceWakeResult {
658
- /** The strand the wake was requested for. */
659
- strandId: string;
660
- /**
661
- * Whether the wake was actually serviced. `false` when the node is not
662
- * running, or the strand is unknown to this node (not a member-participated
663
- * strand) — never a thrown error, so a background task can branch on it.
664
- */
665
- serviced: boolean;
666
- /**
667
- * Whether activity landed during the wake window. `true` leaves the strand
668
- * active; `false` re-hibernates it (or the strand was already live, in which
669
- * case it reflects current liveness). Always `false` when `serviced` is `false`.
670
- */
671
- hadActivity: boolean;
672
- }
673
-
674
- /**
675
- * Strand row from control network - basic membership info
676
- */
677
- export interface StrandRow {
678
- Id: string;
679
- MemberPrivateKey: string | null;
680
- Type: 'o' | 'c';
681
- }
682
-
683
- /**
684
- * sApp configuration provided by the hosting application when creating a strand.
685
- * This is what the app developer provides - NOT loaded from the network.
686
- */
687
- export interface SAppConfig {
688
- /** Public key of the sApp author */
689
- id: string;
690
- /** Version of the sApp */
691
- version: string;
692
- /** The declarative schema DDL */
693
- schema: string;
694
- /**
695
- * Author's signature over the schema for verification. Required under the
696
- * default `requireSignedSchemas` node policy — a config without it is rejected
697
- * before strand bring-up. Optional in the type because authoring/serialization
698
- * must represent an as-yet-unsigned config; omitting it is honored only when
699
- * the node explicitly relaxes the policy (`requireSignedSchemas: false`, dev/test).
700
- */
701
- signature?: string;
702
- /** Latency hint for hibernation behavior (optional, defaults to config) */
703
- latencyHint?: LatencyHint;
704
- }
705
-
706
- /**
707
- * Full strand configuration when adding a strand via the API
708
- */
709
- export interface StrandConfig {
710
- /** Strand row from control network */
711
- strandRow: StrandRow;
712
- /** sApp configuration provided by the hosting application */
713
- sAppConfig: SAppConfig;
714
- /**
715
- * Whether THIS node is the strand's founder — the party that provisioned and
716
- * published the strand (the responder in formation, the creator in host/solo
717
- * paths; the same party that calls {@link CadreNode.publishStrand}). The founder
718
- * runs the one-time membership bootstrap at bring-up (writes `Strand.Header`, and
719
- * for a closed strand the founding `Member`+`Manager`). A joiner leaves this
720
- * `false`/unset and writes nothing — it receives those rows via Optimystic sync.
721
- * Defaults to `false`.
722
- */
723
- founder?: boolean;
724
- }
725
-
726
- /**
727
- * Result of creating a new cadre peer
728
- */
729
- export interface CreatePeerResult {
730
- peerId: PeerId;
731
- privateKey: Uint8Array;
732
- }
733
-
734
- // ============================================================================
735
- // Member Registration API Types (from api.md)
736
- // ============================================================================
737
-
738
- /**
739
- * Registration data for joining a strand as a member.
740
- * Sent from invited member to any cadre member to accept invitation.
741
- */
742
- export interface MemberRegistration {
743
- /** The strand being joined */
744
- strandId: string;
745
- /** The member's public key for this strand */
746
- key: string;
747
- /** Peer IDs of the member's cadre nodes that will participate */
748
- peerIds: string[];
749
- }
750
-
751
- /**
752
- * Result of member registration
753
- */
754
- export interface MemberRegistrationResult {
755
- success: boolean;
756
- reason?: string;
757
- }
758
-
759
- // ============================================================================
760
- // Strand Solicitation API Types (from api.md)
761
- // ============================================================================
762
-
763
- /**
764
- * Open invitation for strand formation.
765
- * Shared out-of-band to allow strangers to form strands.
766
- */
767
- export interface OpenInvitation {
768
- /** Unique token identifying this invitation */
769
- token: string;
770
- /** The sApp that will be used for the strand */
771
- sAppId: string;
772
- /** When this invitation expires */
773
- expiration: Date;
774
- /** Bootstrap addresses to contact the inviter's cadre */
775
- bootstrap: string[];
776
- }
777
-
778
- /**
779
- * Result of forming a strand via open invitation
780
- */
781
- export interface FormStrandResult {
782
- /** The member key assigned to the initiator for this strand */
783
- memberKey: string;
784
- /** Private key for the invitation (for signing future messages) */
785
- invitePrivateKey: string;
786
- /** The strand that was created */
787
- strandId: string;
788
- /**
789
- * The strand's membership key (closed-strand read-gating secret), delivered through
790
- * the formation protocol after consent (provision-then-record). Present only for a
791
- * closed strand the responder returned with its key; undefined otherwise. Distinct
792
- * from {@link invitePrivateKey} (the initiator's own generated signing key).
793
- */
794
- memberPrivateKey?: string;
795
- }
796
-
797
- /**
798
- * Disclosure object provided during strand formation.
799
- * Contains identity and context information from the initiator.
800
- */
801
- export interface StrandFormationDisclosure {
802
- /** Initiator's party identifier */
803
- partyId?: string;
804
- /** Additional identity bundle (app-specific) */
805
- identityBundle?: unknown;
806
- /** Human-readable purpose/reason for forming the strand */
807
- purpose?: string;
808
- /** Additional app-specific metadata */
809
- metadata?: Record<string, unknown>;
810
- }
811
-
812
- /**
813
- * Arachnode ring participation stub - will be implemented when arachnode is built
814
- */
815
- export interface ArachnodeConfig {
816
- /** Enable Arachnode subsystem (Ring Zulu + storage rings). Storage-profile only. */
817
- enableRingZulu: boolean;
818
- /** Storage ring participation (storage profile only) */
819
- storageRing?: {
820
- /** Which ring to participate in based on capacity */
821
- ring: number;
822
- /** Partition within the ring */
823
- partition?: number;
824
- };
825
- }
826
-
827
- /**
828
- * Events emitted by CadreNode
829
- */
830
- export interface CadreNodeEvents {
831
- 'strand:started': { strandId: string };
832
- 'strand:stopped': { strandId: string };
833
- 'strand:error': { strandId: string; error: Error };
834
- 'strand:idle': { strandId: string };
835
- 'strand:hibernating': { strandId: string };
836
- 'strand:waking': { strandId: string };
837
- /**
838
- * Emitted when the control network advertises a strand this node has no
839
- * registered `sAppConfig` for — i.e. a strand created by another member. The
840
- * hosting app decides whether to join it (register a config + `addStrand`,
841
- * e.g. via a chat `joinChatStrand` helper). Carries the full {@link StrandRow}
842
- * so the app can join without re-querying the control DB.
843
- */
844
- 'strand:discovered': { strandId: string; strand: StrandRow };
845
- 'control:connected': void;
846
- 'control:disconnected': void;
847
- /** Emitted when a seed is received via the seed protocol */
848
- 'seed:received': { partyId: string; peerId: string };
849
- /** Emitted when a seed is successfully applied */
850
- 'seed:applied': { partyId: string; peersAdded: number };
851
- /** Emitted when seed application fails */
852
- 'seed:error': { partyId: string; error: string };
853
- }
854
-
855
- // ============================================================================
856
- // Peer Address Record / Resolution Types
857
- // ============================================================================
858
-
859
- /**
860
- * A peer's self-published, freshness-stamped, self-signed address record.
861
- *
862
- * This is the logical shape of a `CadreControl.CadrePeer` row. A node publishes
863
- * its own record (signing with the ed25519 key behind its PeerId); any node can
864
- * resolve another member's current signaling/relay multiaddrs from only its
865
- * PeerId by reading the record, re-verifying the self-signature against
866
- * `publicKey`, checking freshness, and applying a trust gate.
867
- *
868
- * The signature covers `peerId`, the comma-joined `addrs`, and `updatedAt` — see
869
- * `peerRecordSignedPayload`. `publicKey` is NOT inside the signed payload: it is
870
- * the key the signature is verified *with*, and its libp2p-identity binding to
871
- * `peerId` is checked separately at resolve time, so re-signing it would be
872
- * redundant.
873
- */
874
- export interface PeerAddressRecord {
875
- /** libp2p peer ID (base58btc) — the row key. */
876
- peerId: string;
877
- /** ed25519 public key (base64url) whose libp2p identity IS `peerId`. */
878
- publicKey: string;
879
- /** Current dialable multiaddrs, signaling (`/p2p-circuit`) first. */
880
- addrs: string[];
881
- /** epoch ms — freshness; strictly increasing per peer. */
882
- updatedAt: number;
883
- /** ed25519 self-signature over the signed payload, base64url. */
884
- sig: string;
885
- }
886
-
887
- /**
888
- * One `CadreControl.CadrePeer` row as read by `ControlDatabase.queryCadrePeers`:
889
- * the addressing columns plus the persisted membership voucher.
890
- *
891
- * The voucher triple (`stampId`, `vouchOwner`, `vouchSig`) is what
892
- * {@link CadreNode.listAuthorizedMembers} re-checks against the node-local
893
- * trusted-owner anchor — it is null on a row written before the voucher columns
894
- * existed, and all three must be present for the row to be authorizable. Named
895
- * (rather than restated inline at each read site) so a future column addition
896
- * cannot reach the query without the predicate seeing it.
897
- */
898
- export interface CadrePeerRow {
899
- /** libp2p peer ID (base58btc) — the row key. */
900
- peerId: string;
901
- /** Comma-joined dialable multiaddrs as stored, or null when unpublished. */
902
- multiaddr: string | null;
903
- /** Single-use anti-replay nonce the voucher signature is bound to. */
904
- stampId: string | null;
905
- /** ed25519 public key (base64url) of the owner that vouched this row. */
906
- vouchOwner: string | null;
907
- /** That owner's signature over `digest('CadreControl.CadrePeer', 'vouch', peerId, stampId)`, base64url. */
908
- vouchSig: string | null;
909
- }
910
-
911
- /** The voucher-bearing subset of a {@link CadrePeerRow} — what the authorized-membership predicate reads. */
912
- export type CadrePeerVoucherFields = Pick<CadrePeerRow, 'peerId' | 'stampId' | 'vouchOwner' | 'vouchSig'>;
913
-
914
- /**
915
- * One `CadreControl.Revocation` tombstone as read by
916
- * `ControlDatabase.queryRevocations`: the identity triple plus the `ReissuedAt`
917
- * counter an owner bumps (`ControlDatabase.reissueRevocations`) to re-write — and
918
- * therefore re-broadcast — a tombstone that committed while the node was alone.
919
- */
920
- export interface RevocationRow {
921
- /** Which guarded table's stamp was retired. */
922
- tableName: RevocableTable;
923
- /** Primary key of the removed row (peer id / key / strand id). */
924
- rowKey: string;
925
- /** The retired single-use nonce — the tombstone's identity. */
926
- stampId: string;
927
- /** Monotonic re-issue counter; carries no semantics (see the schema comment). */
928
- reissuedAt: number;
929
- }
930
-
931
- /**
932
- * Outcome of {@link CadreNode.registerSelf}, surfaced so callers (e.g. the CLI
933
- * `--owner` branch) can log what happened:
934
- * - `inserted` — the first owner-signed INSERT of this node's `CadrePeer` row.
935
- * - `refreshed` — a self-signed UPDATE of an existing row (heartbeat / addr change).
936
- * Also reported when a first publish found its row seated by a concurrent
937
- * `authorizePeer` of this node's own id and fell through to the UPDATE — the write
938
- * really was an UPDATE, even though the caller's intent was a first publish.
939
- * - `skipped` — nothing written (no self-signing key, not yet a member with no
940
- * owner service to self-insert, or the row was removed mid-publish).
941
- */
942
- export type SelfRegistrationOutcome = 'inserted' | 'refreshed' | 'skipped';
943
-
944
- /**
945
- * Options for {@link CadreNode.resolvePeerAddrs}.
946
- */
947
- export interface ResolveOpts {
948
- /**
949
- * Freshness ceiling in ms. A record older than this is filtered out (a
950
- * resolver must never hand back a dead relay reservation). Defaults to
951
- * {@link DEFAULT_PEER_RECORD_MAX_AGE_MS}.
952
- */
953
- maxAgeMs?: number;
954
- /** Return only `/p2p-circuit` signaling addrs (the WebRTC dial input). */
955
- signalingOnly?: boolean;
956
- /**
957
- * Pluggable trust gate, evaluated after signature + freshness pass. Defaults
958
- * to {@link currentMemberTrustPolicy} (any existing — therefore
959
- * owner-vouched — CadrePeer member is trusted). Inject a stricter policy
960
- * (e.g. from `seed-signerkey-trust-policy`) to reject otherwise-valid records
961
- * before dialing.
962
- */
963
- trustPolicy?: PeerResolveTrustPolicy;
964
- }
965
-
966
- /**
967
- * Context handed to a {@link PeerResolveTrustPolicy} when gating a resolution.
968
- */
969
- export interface PeerResolveContext {
970
- /** The peer being resolved. */
971
- peerId: string;
972
- /** The record's ed25519 public key (already bound to `peerId` and verified). */
973
- publicKey: string;
974
- /** The party whose control network produced the record. */
975
- partyId: string;
976
- /** The full verified, fresh record. */
977
- record: PeerAddressRecord;
978
- }
979
-
980
- /**
981
- * Decides whether a signature-verified, fresh peer-address record should be
982
- * trusted enough to dial. The seam through which a richer trust model
983
- * (trust circle, pinned keys) composes with `resolvePeerAddrs`.
984
- */
985
- export interface PeerResolveTrustPolicy {
986
- evaluate(ctx: PeerResolveContext): Promise<boolean> | boolean;
987
- }
988
-
989
- // ============================================================================
990
- // Device Token Registry Types
991
- // ============================================================================
992
-
993
- /** Platform push channel a {@link DeviceTokenRecord} targets. */
994
- export type PushPlatform = 'fcm' | 'apns';
995
-
996
- /**
997
- * A mobile cadre peer's self-published, freshness-stamped, self-signed platform
998
- * push token.
999
- *
1000
- * This is the logical shape of a `CadreControl.DeviceToken` row. A phone publishes
1001
- * its own record (signing with the ed25519 key behind its PeerId); a server peer
1002
- * resolves it from the phone's PeerId to deliver a push-wake over FCM/APNs when a
1003
- * control-network dial cannot reach the OS-suspended process. The signature covers
1004
- * `peerId`, `platform`, `token`, and `updatedAt` — see `deviceTokenSignedPayload`.
1005
- *
1006
- * Unlike {@link PeerAddressRecord}, the record carries NO public key: it is verified
1007
- * against the `CadrePeer.PublicKey` bound to the same `peerId`, so a `DeviceToken`
1008
- * with no backing `CadrePeer` row can never be resolved.
1009
- */
1010
- export interface DeviceTokenRecord {
1011
- /** libp2p peer ID (base58btc) — the row key and the CadrePeer this token belongs to. */
1012
- peerId: string;
1013
- /** Push channel: `'fcm'` (Android/Firebase) or `'apns'` (Apple). */
1014
- platform: PushPlatform;
1015
- /** Opaque platform device/registration token. */
1016
- token: string;
1017
- /** epoch ms — freshness; strictly increasing per self-update (replay guard). */
1018
- updatedAt: number;
1019
- /** ed25519 self-signature over the signed payload, base64url. */
1020
- sig: string;
1021
- }
1022
-
1023
- /**
1024
- * A stored `CadreControl.DeviceToken` row: the self-signed
1025
- * {@link DeviceTokenRecord} plus the row's single-use `StampId` nonce.
1026
- *
1027
- * The stamp is deliberately OUTSIDE `DeviceTokenRecord`: the peer's own `Sig`
1028
- * covers only (peerId, platform, token, updatedAt), while the stamp is bound by
1029
- * the OWNER's insert/delete approvals. Readers need it to drop a row whose stamp
1030
- * has been retired into `CadreControl.Revocation` (a token resurrected by an
1031
- * approval replay on a node that had not yet converged on the tombstone).
1032
- */
1033
- export interface DeviceTokenRow extends DeviceTokenRecord {
1034
- /** Single-use authorization nonce; rotates on every (re)insert. */
1035
- stampId: string;
1036
- }
1037
-
1038
- /**
1039
- * Options for {@link CadreNode.resolveDeviceToken}.
1040
- */
1041
- export interface ResolveDeviceTokenOpts {
1042
- /**
1043
- * Optional freshness ceiling in ms. Defaults to NO ceiling (a record is fresh
1044
- * as long as its `updatedAt` is positive): unlike a relay reservation, a device
1045
- * push token stays valid until it rotates, so a long-suspended phone must remain
1046
- * resolvable for push-wake. Set this to bound staleness explicitly.
1047
- */
1048
- maxAgeMs?: number;
1049
- }
1050
-
1051
- // ============================================================================
1052
- // Push Credentials (platform push delivery — see push-notifier.ts)
1053
- // ============================================================================
1054
-
1055
- /**
1056
- * Platform push-delivery credentials injected into a participating node's config
1057
- * (`CadreNodeConfig.push`). Provisioned per spawned node by `cadre-host` and read
1058
- * by `createPushNotifier` to construct the FCM and/or APNs senders. A platform
1059
- * whose block is absent is simply not deliverable (the router returns a
1060
- * best-effort "no credentials" failure rather than throwing).
1061
- *
1062
- * Secret hygiene: every `privateKey` field is a secret — never logged (treated
1063
- * like the node's startup/seed tokens).
1064
- */
1065
- export interface PushCredentials {
1066
- /** FCM (Android / Firebase) service-account credentials. */
1067
- fcm?: FcmCredentials;
1068
- /** APNs (Apple) auth-key credentials. */
1069
- apns?: ApnsCredentials;
1070
- /**
1071
- * Per-`(peer, strand)` minimum gap between push-wakes the server fan-out emits —
1072
- * the anti-spam cooldown a chatty strand needs. Default
1073
- * {@link DEFAULT_PUSH_COOLDOWN_MS} (5 min). In-memory, acceptably lossy across
1074
- * restarts (`serviceWake` is idempotent, so a duplicate wake is harmless).
1075
- */
1076
- cooldownMs?: number;
1077
- /**
1078
- * Per-strand burst-coalescing window for the fan-out: a second activity trigger
1079
- * within this window is dropped (one wake is enough). Default
1080
- * {@link DEFAULT_PUSH_DEBOUNCE_MS} (10 s).
1081
- */
1082
- debounceMs?: number;
1083
- }
1084
-
1085
- /** Google service-account fields needed for FCM HTTP v1 OAuth2. */
1086
- export interface FcmCredentials {
1087
- /** GCP project id → `POST https://fcm.googleapis.com/v1/projects/{projectId}/messages:send`. */
1088
- projectId: string;
1089
- /** Service-account email (JWT `iss`/`sub`). */
1090
- clientEmail: string;
1091
- /** Service-account RSA private key, PEM. Secret — never logged. */
1092
- privateKey: string;
1093
- }
1094
-
1095
- /** Apple APNs auth-key (.p8) fields. */
1096
- export interface ApnsCredentials {
1097
- /** APNs key id (JWT header `kid`). */
1098
- keyId: string;
1099
- /** Apple team id (JWT `iss`). */
1100
- teamId: string;
1101
- /** App bundle id → `apns-topic` header. */
1102
- bundleId: string;
1103
- /** `.p8` ES256 private key, PEM. Secret — never logged. */
1104
- privateKey: string;
1105
- /** `true` → `api.push.apple.com`; false/undefined → `api.sandbox.push.apple.com`. */
1106
- production?: boolean;
1107
- }
1108
-
1109
- // ============================================================================
1110
- // Seed Bootstrap API Types (from architecture.md)
1111
- // ============================================================================
1112
-
1113
- /**
1114
- * A peer entry in a control network seed.
1115
- * Contains connection information for a cadre peer.
1116
- */
1117
- export interface SeedPeer {
1118
- /** Peer ID (libp2p identity) */
1119
- peerId: string;
1120
- /** Multiaddrs for connecting to this peer */
1121
- multiaddrs: string[];
1122
- /** Whether this peer holds an owner key */
1123
- isOwner: boolean;
1124
- /** ed25519 public key (base64url) — present on owner peers for signerKey verification */
1125
- publicKey?: string;
1126
- }
1127
-
1128
- /**
1129
- * Control network seed for bootstrapping new nodes.
1130
- * Pre-populates a new node's cache to solve the cold-start problem:
1131
- * new nodes need control data to validate connections, but can't get
1132
- * data without connecting first.
1133
- */
1134
- export interface ControlNetworkSeed {
1135
- /** Party ID this seed belongs to */
1136
- partyId: string;
1137
- /** Known peers in the cadre */
1138
- peers: SeedPeer[];
1139
- /** Signature over the seed by an owner key */
1140
- signature: string;
1141
- /** The owner key that signed this seed */
1142
- signerKey: string;
1143
- }
1144
-
1145
- /**
1146
- * Message sent from instigator to new node during seed delivery.
1147
- * Delivered via /sereus/seed/1.0.0 libp2p protocol.
1148
- */
1149
- export interface SeedMessage {
1150
- /** Party ID for the cadre */
1151
- partyId: string;
1152
- /** Known peers in the cadre */
1153
- peers: SeedPeer[];
1154
- /** Signature by an owner key */
1155
- signature: string;
1156
- /** The owner key that signed this message */
1157
- signerKey: string;
1158
- }
1159
-
1160
- /**
1161
- * Acknowledgment message from new node to instigator.
1162
- */
1163
- export interface SeedAckMessage {
1164
- /** Whether the seed was accepted */
1165
- accepted: boolean;
1166
- /** Reason for rejection (if not accepted) */
1167
- reason?: string;
1168
- }
1169
-
1170
- // ============================================================================
1171
- // Strand Wake (control-network push-wake protocol) — see strand-wake-protocol.ts
1172
- // ============================================================================
1173
-
1174
- /**
1175
- * Request sent over the control network's `WAKE_PROTOCOL` from a same-cadre peer
1176
- * to a hibernating peer, asking it to bring a strand online and pull pending
1177
- * activity. The receiver gates this on cadre membership before honoring it.
1178
- */
1179
- export interface WakeRequest {
1180
- /** The strand the sender knows has pending activity. */
1181
- strandId: string;
1182
- /** Optional cause hint, e.g. `"activity"` (a server saw new traffic) or `"manual"`. */
1183
- reason?: string;
1184
- }
1185
-
1186
- /**
1187
- * Acknowledgment of a {@link WakeRequest}, returned on the same stream.
1188
- */
1189
- export interface WakeAck {
1190
- /** Whether the receiver honored the wake (member + participated strand). */
1191
- accepted: boolean;
1192
- /** The strand's status after the wake (present when `accepted`). */
1193
- status?: StrandStatus;
1194
- /** Reason for rejection (non-member, unknown strand, malformed frame). */
1195
- reason?: string;
1196
- }
1197
-
1198
- // ============================================================================
1199
- // Strand Address (control-network strand-address RPC) — see strand-addr-protocol.ts
1200
- // ============================================================================
1201
-
1202
- /**
1203
- * Control-network request from one of a party's own cadre nodes to a sibling,
1204
- * asking "what are your live multiaddrs for this strand's separate network?".
1205
- * Used to seed a strand mesh from co-cadre nodes that already run that strand.
1206
- * The receiver gates this on cadre membership before answering.
1207
- */
1208
- export interface StrandAddrRequest {
1209
- /** The strand whose strand-network address the requester wants to seed from. */
1210
- strandId: string;
1211
- /**
1212
- * The derived transport peerId the requester's own strand-`strandId` node
1213
- * runs as (see `strand-transport-key.ts`). Present → the responder records a
1214
- * delegate admission grant for it, so its own connection gate (and thus its
1215
- * circuit-relay server, when it runs one) admits that peerId; absent →
1216
- * today's behavior exactly. See `delegate-admission.ts` for the trust model.
1217
- */
1218
- delegatePeerId?: string;
1219
- }
1220
-
1221
- /**
1222
- * Response to a {@link StrandAddrRequest}, carrying the responder's strand-node
1223
- * multiaddrs, returned on the same stream.
1224
- */
1225
- export interface StrandAddrResponse {
1226
- /** Echoes the requested strand id (empty on a reject/error reply). */
1227
- strandId: string;
1228
- /**
1229
- * Dialable strand-network multiaddr strings (signaling/`p2p-circuit` first);
1230
- * empty when the responder is a non-member, or does not currently run that
1231
- * strand, or the exchange failed.
1232
- */
1233
- multiaddrs: string[];
1234
- }
1235
-
1236
- /**
1237
- * Options for authorizing a new peer in the cadre.
1238
- */
1239
- export interface AuthorizePeerOptions {
1240
- /** Peer ID to authorize */
1241
- peerId: string;
1242
- /** Optional multiaddrs for the peer */
1243
- multiaddrs?: string[];
1244
- }
1245
-
1246
- /**
1247
- * Result of applying a seed to a node.
1248
- */
1249
- export interface ApplySeedResult {
1250
- /** Whether the seed was successfully applied */
1251
- success: boolean;
1252
- /** Number of peers added to the peer store */
1253
- peersAdded: number;
1254
- /** Error message if not successful */
1255
- error?: string;
1256
- /**
1257
- * Owner-flagged seed peers this apply attempted to dial. Zero when the seed
1258
- * carried no owner peer with an address, or when the seed was rejected before
1259
- * the dial loop ran.
1260
- */
1261
- ownerDialsAttempted: number;
1262
- /**
1263
- * How many of those dials threw. `ownerDialsAttempted > 0 && ownerDialsFailed
1264
- * === ownerDialsAttempted` means "seeded but stranded": the seed itself was
1265
- * accepted (`success: true`) but no owner could be reached, so this node has
1266
- * no connection to bootstrap its control database from. The node retries those
1267
- * addresses on every control-cohort reconcile pass (see
1268
- * `CadreNode.dialColdStartBootstrap`), so this is a signal, not a fatal error.
1269
- *
1270
- * Zero failures is NOT proof of a live connection: the receiving node's
1271
- * membership gate denies AFTER the dialer's upgrade completes (see
1272
- * `createMembershipConnectionGater`), so a dial can resolve and then be torn
1273
- * down. Treat a non-zero value as certain failure and zero as "no failure
1274
- * observed".
1275
- */
1276
- ownerDialsFailed: number;
1277
- }
1278
-
1279
- // ============================================================================
1280
- // Seed Helper Types
1281
- // ============================================================================
1282
-
1283
- /**
1284
- * Node network topology type for seed helper scenarios.
1285
- */
1286
- export type NodeTopology = 'public' | 'nat';
1287
-
1288
- /**
1289
- * Invite code for phone-to-server enrollment.
1290
- * Used when a server invites a phone to join the cadre.
1291
- */
1292
- export interface CadreInvite {
1293
- /** Party ID of the cadre */
1294
- partyId: string;
1295
- /** Multiaddrs to dial the owner */
1296
- ownerAddrs: string[];
1297
- /**
1298
- * Owner ed25519 public keys (base64url) of the cadre, carried out-of-band
1299
- * so a cold-start invitee can pin the trusted owner set before applying
1300
- * any seed (the seed itself cannot vouch for its own signer). Populated by
1301
- * `createInvite` from the issuer's OWN node-local trusted-owner anchor —
1302
- * not its replicated `OwnerKey` table, because the invitee anchors whatever
1303
- * arrives here and a stranger's genesis-inserted key must not ride an
1304
- * otherwise-legitimate invite into a fresh node's anchor.
1305
- */
1306
- ownerKeys?: string[];
1307
- /** Optional invite token for validation */
1308
- token?: string;
1309
- /** Timestamp when invite was created */
1310
- createdAt: number;
1311
- /** Optional expiration timestamp */
1312
- expiresAt?: number;
1313
- }
1314
-
1315
- /**
1316
- * Options for adding a drone via provider API.
1317
- */
1318
- export interface AddDroneOptions {
1319
- /** Peer ID of the new drone (returned from provider) */
1320
- dronePeerId: string;
1321
- /** Multiaddrs of the drone (returned from provider) */
1322
- droneMultiaddrs: string[];
1323
- }
1324
-
1325
- /**
1326
- * Options for adding a phone via invite flow.
1327
- */
1328
- export interface AddPhoneOptions {
1329
- /** Peer ID of the phone (sent by phone when it connects) */
1330
- phonePeerId: string;
1331
- /** Invite token for validation (must match issued invite) */
1332
- token?: string;
1333
- }
1334
-
1335
- /**
1336
- * Result of preparing a seed for drone initialization.
1337
- */
1338
- export interface DroneInitResult {
1339
- /** The seed to send to the provider API */
1340
- seed: ControlNetworkSeed;
1341
- /** Base64url encoded seed for transport */
1342
- encodedSeed: string;
1343
- }
1344
-
1345
- /**
1346
- * Result of creating an invite for a phone.
1347
- */
1348
- export interface InviteResult {
1349
- /** The invite to share out-of-band */
1350
- invite: CadreInvite;
1351
- /** Base64url encoded invite for QR/link */
1352
- encodedInvite: string;
1353
- }
1354
-
1
+ import type { ConnectionGater, Libp2p, PeerId, PrivateKey } from '@libp2p/interface';
2
+ import type { IRawStorage, Libp2pTransports } from '@optimystic/db-p2p';
3
+ import type { IPeerNetwork, IRepo } from '@optimystic/db-core';
4
+ import type { PeerJoinBackfillConfig } from './peer-join-backfill.js';
5
+ import type { StrandRevocationEnforcementConfig } from './strand-revocation-enforcer.js';
6
+ import type { StrandDatabase } from './strand-database.js';
7
+ import type { SeedTrustPolicy } from './seed-trust-policy.js';
8
+ import type { KeyStore, KeyId } from './key-store.js';
9
+ import type { TrustedOwnerStore, TrustSource } from './trusted-owner-store.js';
10
+ import type { BootstrapPeerStore } from './bootstrap-peer-store.js';
11
+ import type { EnrolledMachineStore } from './enrolled-machine-store.js';
12
+ import type { PushNotifier } from './push-notifier.js';
13
+ import type { RevocableTable } from './control-authorization.js';
14
+
15
+ /**
16
+ * Extended Libp2p node with the coordinatedRepo attached by db-p2p's
17
+ * createLibp2pNode after node creation (not surfaced on the base Libp2p type).
18
+ */
19
+ export interface Libp2pNodeWithRepo extends Libp2p {
20
+ coordinatedRepo: IRepo;
21
+ /**
22
+ * The node's key-addressed peer network, attached by db-p2p's factory alongside
23
+ * `coordinatedRepo` (`libp2p-node-base.ts`). Optional deliberately: the base
24
+ * factory always assigns it, but it is not part of the upstream node type, so
25
+ * declaring it required would be a claim this repo cannot enforce. The strand
26
+ * backfill (`peer-join-backfill.ts`) logs once and stays inert when it is absent.
27
+ */
28
+ keyNetwork?: IPeerNetwork;
29
+ }
30
+
31
+ /**
32
+ * Node profile determines storage participation
33
+ */
34
+ export type NodeProfile = 'transaction' | 'storage';
35
+
36
+ /**
37
+ * Strand filter configuration - determines which strands this node participates in
38
+ */
39
+ export type StrandFilter =
40
+ | { mode: 'all' } // All strands (default for servers)
41
+ | { mode: 'sAppId'; sAppId: string } // Only strands for specific sApp
42
+ | { mode: 'strandId'; strandId: string } // Single specific strand
43
+ | { mode: 'none' }; // Control network only
44
+
45
+ /**
46
+ * Latency hint for strand hibernation behavior
47
+ */
48
+ export type LatencyHint = 'realtime' | 'interactive' | 'background' | 'archive';
49
+
50
+ /**
51
+ * Hibernation timeout configuration per latency hint (in milliseconds)
52
+ */
53
+ export interface HibernationTimeouts {
54
+ /** Time before transitioning from active to idle */
55
+ idleTimeout: number;
56
+ /** Time before transitioning from idle to hibernating */
57
+ hibernateTimeout: number;
58
+ /**
59
+ * Base interval for the FIRST check-in after hibernating. Successive
60
+ * no-activity check-ins escalate this delay by {@link checkInBackoffFactor}
61
+ * (see {@link HibernationManager}'s self-rescheduling check-in chain).
62
+ */
63
+ checkInInterval: number;
64
+ /**
65
+ * Multiplier applied to the check-in delay after each check-in that finds no
66
+ * pending activity (must be >= 1; 1 disables escalation). Default 2 →
67
+ * doubling: base, base×2, base×4, … until {@link checkInMaxInterval}.
68
+ */
69
+ checkInBackoffFactor: number;
70
+ /**
71
+ * Upper bound on the escalating check-in delay — the concrete realization of
72
+ * the architecture's "minutes → hours → days" ceiling for this hint. Backoff
73
+ * never schedules a delay longer than this.
74
+ */
75
+ checkInMaxInterval: number;
76
+ }
77
+
78
+ /**
79
+ * Default hibernation timeouts per latency hint.
80
+ *
81
+ * `checkInInterval` is the BASE delay; `checkInMaxInterval` is the per-hint
82
+ * ceiling the exponential backoff escalates toward (interactive minutes→~1h,
83
+ * background minutes→~6h, archive ~1h→~3 days).
84
+ */
85
+ export const HIBERNATION_TIMEOUTS: Record<LatencyHint, HibernationTimeouts> = {
86
+ realtime: {
87
+ idleTimeout: Infinity, // Never idle
88
+ hibernateTimeout: Infinity, // Never hibernate
89
+ checkInInterval: Infinity, // N/A
90
+ checkInBackoffFactor: 2,
91
+ checkInMaxInterval: Infinity // N/A
92
+ },
93
+ interactive: {
94
+ idleTimeout: 5 * 60 * 1000, // 5 minutes
95
+ hibernateTimeout: 15 * 60 * 1000, // 15 minutes after idle
96
+ checkInInterval: 30 * 1000, // base: 30 seconds
97
+ checkInBackoffFactor: 2,
98
+ checkInMaxInterval: 60 * 60 * 1000 // cap: ~1 hour
99
+ },
100
+ background: {
101
+ idleTimeout: 1 * 60 * 1000, // 1 minute
102
+ hibernateTimeout: 5 * 60 * 1000, // 5 minutes after idle
103
+ checkInInterval: 5 * 60 * 1000, // base: 5 minutes
104
+ checkInBackoffFactor: 2,
105
+ checkInMaxInterval: 6 * 60 * 60 * 1000 // cap: ~6 hours
106
+ },
107
+ archive: {
108
+ idleTimeout: 10 * 1000, // 10 seconds
109
+ hibernateTimeout: 30 * 1000, // 30 seconds after idle
110
+ checkInInterval: 60 * 60 * 1000, // base: 1 hour
111
+ checkInBackoffFactor: 2,
112
+ checkInMaxInterval: 3 * 24 * 60 * 60 * 1000 // cap: ~3 days
113
+ }
114
+ };
115
+
116
+ /**
117
+ * Storage provider - either an `IRawStorage` instance or a factory function.
118
+ * Factory functions are useful for creating strand-specific storage instances.
119
+ *
120
+ * **Called once per scope per runtime lifetime.** The scopes are the literal string
121
+ * `'control'` (once per `CadreNode.start()`) and each strand id (once per
122
+ * `startStrand`). Hibernation — `quiesceStrand` then `resumeStrand` — reuses the
123
+ * store already resolved for that strand and does NOT re-enter this callback.
124
+ *
125
+ * It IS re-entered for a scope after that scope's runtime has stopped
126
+ * (`stopStrand`, or a `stop()` then `start()` cycle on one `CadreNode`), so it must
127
+ * be able to hand back a store over the same durable backend a second time. A
128
+ * factory may mint a fresh object per call; it must not *need* to.
129
+ *
130
+ * cadre-core disposes only its own cache wrapper — it never closes the store you
131
+ * returned. Closing the underlying handle stays the embedder's job.
132
+ *
133
+ * Either form is safe with a persistent backend. One instance handed to every scope
134
+ * also shares ONE cache wrapper (`wrapStorageWithCache` memoizes per inner instance),
135
+ * which counts its holders: a scope stopping releases only its own claim, and the
136
+ * wrapper is retired when the last scope releases it. The factory form is still the
137
+ * better default for a different reason — it partitions each strand's data, which
138
+ * simplifies cleanup.
139
+ */
140
+ export type RawStorageProvider = IRawStorage | ((strandId: string) => IRawStorage);
141
+
142
+ /**
143
+ * Storage configuration for storage profile nodes
144
+ */
145
+ export interface StorageConfig {
146
+ /**
147
+ * Storage provider - either an IRawStorage instance or a factory function.
148
+ * See {@link RawStorageProvider} for how often cadre-core calls it.
149
+ *
150
+ * For Node.js environments, use FileRawStorage from @optimystic/db-p2p-storage-fs:
151
+ * ```typescript
152
+ * import { FileRawStorage } from '@optimystic/db-p2p-storage-fs';
153
+ * storage: { provider: (strandId) => new FileRawStorage(`./data/${strandId}`) }
154
+ * ```
155
+ *
156
+ * For React Native, use the appropriate storage from @optimystic/db-p2p-storage-rn:
157
+ * ```typescript
158
+ * import { LevelDBRawStorage } from '@optimystic/db-p2p-storage-rn';
159
+ * // `db` is an open LevelDB handle (see `openOptimysticRNDb` over `rn-leveldb`)
160
+ * storage: { provider: (strandId) => new LevelDBRawStorage(openDb(`sereus-${strandId}`)) }
161
+ * ```
162
+ *
163
+ * For in-memory storage (testing):
164
+ * ```typescript
165
+ * import { MemoryRawStorage } from '@optimystic/db-p2p';
166
+ * storage: { provider: () => new MemoryRawStorage() }
167
+ * ```
168
+ */
169
+ provider: RawStorageProvider;
170
+ quotaBytes?: number;
171
+ }
172
+
173
+ /**
174
+ * Network configuration for libp2p
175
+ */
176
+ export interface NetworkConfig {
177
+ /**
178
+ * Addresses this machine's libp2p nodes bind. Omitted, `@optimystic/db-p2p`'s own
179
+ * default (`/ip4/0.0.0.0/tcp/0`) applies; `[]` means "cannot listen" and is honored
180
+ * as written (React Native hosts rely on that).
181
+ *
182
+ * The machine runs one control node plus one node per strand, so a FIXED port here
183
+ * can only belong to one of them. The control node binds it as configured; each
184
+ * strand node binds the same entry with its port rewritten to `0`, keeping the
185
+ * interface and transport (`/ws`, a specific-interface bind). A
186
+ * `<relay>/p2p-circuit` entry is untouched — the port inside it is the relay's, not
187
+ * a local bind. See `strand-network-config.ts`.
188
+ *
189
+ * **Only TCP, WebSocket (`/ws`, `/wss`) and circuit-relay addresses are bindable**
190
+ * unless {@link transports} supplies the factories for something else. A `/ws` entry
191
+ * switches the WebSocket transport on by itself; anything else — `/quic-v1`,
192
+ * `/webrtc`, `/webtransport` — refuses node start naming the address and the libp2p
193
+ * package it would need, rather than being dropped in silence by libp2p's transport
194
+ * manager. See `relay-addrs.ts` → `resolveTransportOptions`.
195
+ */
196
+ listenAddrs?: string[];
197
+ /**
198
+ * Addresses to advertise to peers **instead of** `listenAddrs` — for a node behind
199
+ * NAT or a reverse proxy that is reachable at a different address than it binds
200
+ * (e.g. `mynode.example.com:4001` in front of a `0.0.0.0:4001` listener). Reaches
201
+ * libp2p's `addresses.announce` via `@optimystic/db-p2p`'s `NodeOptions.announceAddrs`.
202
+ *
203
+ * **Applies to this machine's CONTROL node only.** Strand nodes drop it: any concrete
204
+ * entry names a port, and that port is the control node's, so a strand node
205
+ * advertising it would send peers to the wrong node (`strand-network-config.ts`).
206
+ * Entries are still validated at node start whichever node ends up using them.
207
+ *
208
+ * **A non-empty value REPLACES the advertised set entirely.** Observed addresses,
209
+ * and the `/p2p-circuit` address earned from a {@link relayAddrs} reservation, are
210
+ * all dropped from what peers are told — so a node configured with both this and
211
+ * `relayAddrs` stops being reachable through its relay. `CadreNode.start()` warns
212
+ * when it sees that combination; it does not refuse it, since a node whose relay
213
+ * slot is decorative may legitimately want only the announced address.
214
+ *
215
+ * Reach for {@link appendAnnounceAddrs} instead in the common case — one publicly
216
+ * reachable address ADDED to everything else the node advertises. Or for
217
+ * {@link relayAddrs} alone, when the node has no stable public address to name and
218
+ * a relay reservation is what makes it dialable. (`inviteAddressResolver` is a
219
+ * narrower tool again: it substitutes a reachable address into invite payloads
220
+ * only, and changes nothing about what this node advertises on the wire.)
221
+ *
222
+ * An empty array means "unset" — it is dropped rather than forwarded, so it cannot
223
+ * land as an explicit empty announce set. An empty *entry* is a different thing and
224
+ * is rejected: a list of one blank string is non-empty, so it would replace every
225
+ * advertised address with nothing. Malformed entries are rejected on the same terms,
226
+ * both at node start — libp2p itself would not notice either until its first address
227
+ * lookup (see `announce-addrs.ts`), so this repo checks each entry up front.
228
+ */
229
+ announceAddrs?: string[];
230
+ /**
231
+ * Addresses to advertise **in addition to** `listenAddrs` — the field most
232
+ * deployments actually want, since it makes a node reachable at a public address
233
+ * without discarding the observed and `/p2p-circuit` addresses it would otherwise
234
+ * advertise. Reaches libp2p's `addresses.appendAnnounce` via
235
+ * `@optimystic/db-p2p`'s `NodeOptions.appendAnnounceAddrs`.
236
+ *
237
+ * **Control node only**, on the same terms as {@link announceAddrs}.
238
+ *
239
+ * **Ignored while {@link announceAddrs} is non-empty** — that is libp2p's own
240
+ * precedence, applied upstream; this repo does not merge the two locally. Set one
241
+ * or the other, not both.
242
+ *
243
+ * Empty-array, blank-entry and malformed-entry handling all match {@link announceAddrs}.
244
+ */
245
+ appendAnnounceAddrs?: string[];
246
+ /**
247
+ * Circuit-relay servers this node reserves a slot on, so peers can reach it
248
+ * from behind NAT. Each entry is the relay's DIRECT dial multiaddr ending in
249
+ * its peerId — `/dns4/relay.example.com/tcp/4001/p2p/12D3KooW…` — with no
250
+ * `/p2p-circuit` suffix (one is appended for you; an entry that already has
251
+ * one is passed through unchanged).
252
+ *
253
+ * On the CONTROL node this adds libp2p's bare `/p2p-circuit` SEARCH listener to
254
+ * {@link listenAddrs} (`relay-addrs.ts`) and `CadreNode.start()` drives the
255
+ * reservation itself once the control database is up — the ordering matters,
256
+ * because a listener that dials its relay from inside `libp2p.start()` put a
257
+ * sibling in this node's cohort before its own database existed, and a sibling
258
+ * that had not yet replicated this node's membership row refused the bring-up.
259
+ * STRAND nodes inherit the older per-relay `<relay>/p2p-circuit` listener, which
260
+ * has no such ordering hazard. A relay named here also becomes a
261
+ * delegate-announce target, so this node's strand nodes may reserve on it too
262
+ * (see `delegate-admission.ts`).
263
+ *
264
+ * Setting this gives the node a listener even when `listenAddrs` is `[]` —
265
+ * that is the point of naming a relay, but note that React Native hosts
266
+ * (`reference-app-rn`'s `cadre-phone.ts`) deliberately do NOT listen, and
267
+ * must not acquire a listener by accident.
268
+ *
269
+ * FAIL-FAST both ways: a malformed entry throws at node start rather than being
270
+ * silently dropped, and a first reservation attempt that lands no
271
+ * `/p2p-circuit` address throws `RelayReservationFailedError` out of `start()`.
272
+ * The fail-soft posture over the same machinery is `CadreNode.reserveRelays()`.
273
+ */
274
+ relayAddrs?: string[];
275
+ /**
276
+ * Enable circuit relay server - allows this node to relay connections for other peers.
277
+ * When undefined, defaults to true for storage profile nodes (they typically have
278
+ * better connectivity and uptime), false for transaction profile nodes.
279
+ */
280
+ enableRelay?: boolean;
281
+ /**
282
+ * Cap on concurrent circuit-relay reservations this node's relay server grants to
283
+ * peers it cannot (yet) recognize as authorized members — the boot-ordering window
284
+ * where a genuine member reserves its relay slot before its membership row has
285
+ * replicated here (see `membership-connection-gater.ts` → "The relay-reservation
286
+ * seam"). Authorized members and announced delegate peers are never counted against
287
+ * it. Default `MAX_UNAUTHORIZED_RELAY_RESERVATIONS` (8); 0 refuses every
288
+ * unauthorized reservation. Only meaningful while {@link enableRelay} is on.
289
+ */
290
+ unauthorizedRelayReservationCap?: number;
291
+ /**
292
+ * Custom libp2p transports. When omitted, the default transports from
293
+ * `@optimystic/db-p2p` are used (TCP + circuit relay for Node.js).
294
+ *
295
+ * React Native apps must supply WebSocket-based transports because TCP
296
+ * is not available in the RN runtime:
297
+ * ```typescript
298
+ * import { webSockets } from '@libp2p/websockets';
299
+ * import { circuitRelayTransport } from '@libp2p/circuit-relay-v2';
300
+ *
301
+ * network: {
302
+ * transports: [webSockets(), circuitRelayTransport()],
303
+ * listenAddrs: [] // RN nodes typically cannot listen
304
+ * }
305
+ * ```
306
+ */
307
+ transports?: Libp2pTransports;
308
+ /**
309
+ * Optional async resolver returning the multiaddrs to embed in invites
310
+ * (and other owner-address contexts). When unset, `libp2pNode.getMultiaddrs()`
311
+ * is used. Hosts behind NAT supply this to substitute their DDNS hostname
312
+ * and externally-mapped port — see `@serfab/cadre-host`'s NatService.
313
+ *
314
+ * Returned addresses need NOT carry a `/p2p/<peerId>` suffix: `CadreNode`
315
+ * appends its own before publishing or dialing any of them, so a resolver
316
+ * cannot leave a sibling with the mixed suffixed/unsuffixed list that
317
+ * `libp2p.dial` rejects. Returning them already suffixed is equally fine.
318
+ */
319
+ inviteAddressResolver?: () => Promise<string[]>;
320
+ /**
321
+ * Optional libp2p connection gater, threaded to both the control node and every
322
+ * strand's cohort node. libp2p's browser default denies dialing insecure
323
+ * WebSockets and private/loopback addresses; callers that must dial local or
324
+ * unsecured peers (the web reference dialing a `127.0.0.1/.../ws` responder,
325
+ * Playwright e2e, RN simulators) supply a permissive gater here — e.g.
326
+ * `{ denyDialMultiaddr: () => false }`. See `@optimystic/db-p2p`'s
327
+ * `libp2p-node-base` for the underlying option.
328
+ *
329
+ * On the CONTROL node this gater is composed with the built-in membership
330
+ * admission gate (`membership-connection-gater.ts`): every hook supplied here
331
+ * is honored unchanged, and on inbound encrypted connections a deny from
332
+ * either this gater or the membership policy denies.
333
+ *
334
+ * Strand cohort nodes: an OPEN strand's node receives this gater as-is (its
335
+ * peers are legitimately cross-party). A CLOSED strand's node composes
336
+ * revoked-peer denial onto it (`strand-revocation-enforcer.ts`) — every hook
337
+ * supplied here is still honored unchanged, and on the composed hooks
338
+ * (inbound encrypted connection, outbound peer dial, relay reservation) a
339
+ * deny from either this gater or the revocation check denies.
340
+ */
341
+ connectionGater?: ConnectionGater;
342
+ /**
343
+ * Tuning for the proactive control-cohort dial routine
344
+ * ({@link CadreNode.reconcileControlCohort}), which keeps a party's control
345
+ * nodes connected so the `CadreControl` collections form a replicating cohort.
346
+ * Every field is optional; omit for the defaults
347
+ * ({@link DEFAULT_CONTROL_COHORT_TARGET_DEGREE} /
348
+ * {@link DEFAULT_CONTROL_COHORT_RECONCILE_MS} /
349
+ * {@link DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS} / `STRAND_PEER_ADDR_REFRESH_MS`).
350
+ */
351
+ controlCohort?: {
352
+ /**
353
+ * Cap on the number of NON-owner siblings dialed per reconcile pass
354
+ * (backbone/owner members are always dialed and do not count). Defaults
355
+ * to {@link DEFAULT_CONTROL_COHORT_TARGET_DEGREE}.
356
+ */
357
+ targetDegree?: number;
358
+ /**
359
+ * Recurring reconcile cadence in ms. Defaults to
360
+ * {@link DEFAULT_CONTROL_COHORT_RECONCILE_MS}.
361
+ */
362
+ reconcileMs?: number;
363
+ /**
364
+ * Budget for ONE sibling's proactive dial, in ms. Bounds a pass at
365
+ * (dialed siblings) × this, instead of leaving it to each address's own
366
+ * libp2p attempt timeout multiplied by address fan-out and relay hops.
367
+ * Defaults to {@link DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS}.
368
+ */
369
+ dialTimeoutMs?: number;
370
+ /**
371
+ * How often each running strand re-resolves its siblings' strand-network
372
+ * addresses into its own libp2p address book — a step of the reconcile pass,
373
+ * on its own much longer throttle. Defaults to `STRAND_PEER_ADDR_REFRESH_MS`
374
+ * (10 min); must stay well under the peerStore's one-hour address expiry.
375
+ */
376
+ strandAddrRefreshMs?: number;
377
+ };
378
+ }
379
+
380
+ /**
381
+ * Hibernation configuration
382
+ */
383
+ export interface HibernationConfig {
384
+ enabled: boolean;
385
+ defaultLatencyHint?: LatencyHint;
386
+ /** Custom timeouts per latency hint (overrides defaults) */
387
+ customTimeouts?: Partial<Record<LatencyHint, Partial<HibernationTimeouts>>>;
388
+ /**
389
+ * How long a hibernating strand stays resumed during a check-in to let its
390
+ * strand network connect to reachable cohort peers (and the app drive
391
+ * pull-on-read activity) before re-hibernating if still idle. Defaults to
392
+ * {@link DEFAULT_CHECKIN_WINDOW_MS}.
393
+ */
394
+ checkInWindowMs?: number;
395
+ }
396
+
397
+ /**
398
+ * Default duration of the resume-and-probe window during a strand check-in.
399
+ * See {@link HibernationConfig.checkInWindowMs}.
400
+ */
401
+ export const DEFAULT_CHECKIN_WINDOW_MS = 15 * 1000;
402
+
403
+ /**
404
+ * Control network configuration
405
+ */
406
+ export interface ControlNetworkConfig {
407
+ partyId: string;
408
+ bootstrapNodes: string[];
409
+ /** Optional path to the control schema file (defaults to bundled schema) */
410
+ schemaPath?: string;
411
+ }
412
+
413
+ /**
414
+ * Replication-breadth constants, both networks' cluster policies, and the strand
415
+ * resolver, re-exported so cadre embedders, the SQL plugin and the integration
416
+ * harness share one definition. Defined in `@serfab/quereus-plugin-sereus`
417
+ * because that package also creates libp2p nodes and cannot depend on this one.
418
+ * The control network's breadth is fixed ({@link CONTROL_REPLICATION_BREADTH},
419
+ * not configurable) and so is its consensus policy ({@link CONTROL_CLUSTER_POLICY}
420
+ * — notably the super-majority threshold, which it leaves at Optimystic's default
421
+ * by omission); the strand breadth defaults to
422
+ * {@link CadreNodeConfig.strandClusterSize} while its policy is the fixed
423
+ * {@link STRAND_CLUSTER_POLICY}.
424
+ *
425
+ * The two `*ClusterPolicy` BUILDERS are the same objects with the block-repair
426
+ * corroboration yardstick declared from the machines enrolled in this party
427
+ * ({@link resolveRepairYardstick}); handed no count, each returns its frozen base
428
+ * constant unchanged. A network picks the derived number up when its libp2p node is
429
+ * built, which for a strand is every wake from hibernation.
430
+ */
431
+ export {
432
+ MIN_CLUSTER_SIZE,
433
+ CONTROL_REPLICATION_BREADTH,
434
+ CONTROL_CLUSTER_POLICY,
435
+ DEFAULT_STRAND_CLUSTER_SIZE,
436
+ STRAND_CLUSTER_POLICY,
437
+ resolveStrandClusterSize,
438
+ resolveRepairYardstick,
439
+ controlClusterPolicy,
440
+ strandClusterPolicy
441
+ } from '@serfab/quereus-plugin-sereus';
442
+
443
+ /**
444
+ * Main configuration for a CadreNode
445
+ */
446
+ export interface CadreNodeConfig {
447
+ /**
448
+ * If provided, use this keypair for the node identity (direct injection).
449
+ * Mutually exclusive with {@link keyStore} — supplying both is a configuration
450
+ * error (fail closed) thrown by `start()` before any network bring-up.
451
+ */
452
+ privateKey?: PrivateKey;
453
+
454
+ /**
455
+ * Pluggable secure store for node key material. When set, the node loads its
456
+ * identity from `keyStore` under {@link identityKeyId}, generating + persisting
457
+ * a fresh Ed25519 key on first run (protobuf bytes are the canonical stored
458
+ * form). Mutually exclusive with {@link privateKey}. Absent ⇒ use `privateKey`
459
+ * when set, else libp2p generates an ephemeral key.
460
+ *
461
+ * In the single-key reference model the owner signing key is *derived from*
462
+ * the node identity (see {@link CadreNode.getIdentityOwnerKey}), so
463
+ * protecting the identity in a secure enclave protects the owner key too.
464
+ * A future separate-owner slot (`ownerKeyId`) is anticipated but not
465
+ * built here.
466
+ */
467
+ keyStore?: KeyStore;
468
+
469
+ /** Slot id for the node identity in {@link keyStore}. Default: `'cadre/identity'`. */
470
+ identityKeyId?: KeyId;
471
+
472
+ /** Control network connection settings */
473
+ controlNetwork: ControlNetworkConfig;
474
+
475
+ /** Node profile: transaction-only or storage */
476
+ profile: NodeProfile;
477
+
478
+ /** Which strands to participate in */
479
+ strandFilter?: StrandFilter;
480
+
481
+ /** Storage configuration (only for storage profile) */
482
+ storage?: StorageConfig;
483
+
484
+ /** Network configuration */
485
+ network?: NetworkConfig;
486
+
487
+ /**
488
+ * Number of nodes Optimystic is told a **strand** network's replication cluster
489
+ * should have, for every strand this node starts. The control network is not
490
+ * configurable — it always replicates to {@link CONTROL_REPLICATION_BREADTH}
491
+ * nodes, which is above any party's node count, because every member reads the
492
+ * whole control database and a member that misses a write may never learn the
493
+ * fact (see that constant for the full reasoning).
494
+ *
495
+ * Every node on the same strand should use the same value: the number bounds the
496
+ * cohort a member independently derives, and Optimystic's cluster-membership gate
497
+ * can reject a coordinator's smaller declared set as a downsize against a larger
498
+ * derived view.
499
+ *
500
+ * Frozen when the strand's libp2p node is created; a change takes effect on
501
+ * restart, not when cadre membership grows. Defaults to
502
+ * {@link DEFAULT_STRAND_CLUSTER_SIZE} (4 — the smallest breadth whose 0.75
503
+ * super-majority still commits with one holder offline); anything below
504
+ * {@link MIN_CLUSTER_SIZE} is rejected before a node is created. The cost of a
505
+ * small value is replication breadth and reliance on read repair, not commit
506
+ * correctness — but at 2 that read repair cannot converge, because a lone
507
+ * corroborator's stale answer is accepted as the cluster's truth.
508
+ */
509
+ strandClusterSize?: number;
510
+
511
+ /**
512
+ * Tuning for the strand peer-join block catch-up (see `peer-join-backfill.ts`):
513
+ * when a strand's libp2p node connects to a peer this runtime has not yet
514
+ * caught up, every block in the strand's own raw store is pushed to that peer,
515
+ * so a machine that joined the strand after blocks were committed still ends
516
+ * up physically holding them. Applies to every strand this node starts.
517
+ * Omit for the defaults (`DEFAULT_PEER_JOIN_BACKFILL`); `{ enabled: false }`
518
+ * restores the no-backfill behaviour. The control network runs its own,
519
+ * separately tuned catch-up — see {@link controlBackfill}.
520
+ */
521
+ strandBackfill?: PeerJoinBackfillConfig;
522
+
523
+ /**
524
+ * Tuning for the CONTROL network's peer-join block catch-up (same module,
525
+ * `peer-join-backfill.ts`, wired in `CadreNode.start`): when the control
526
+ * libp2p node connects to an AUTHORIZED party member this runtime has not yet
527
+ * caught up, every block in the control database's own raw store is pushed to
528
+ * it. This is what makes a control block committed while the writer was alone
529
+ * (collection headers written at genesis above all) physically reach members
530
+ * that joined later — without it, such a member that restarts offline reads
531
+ * the affected tables as empty. Pushes are gated on
532
+ * `CadreNode.isAuthorizedMember`, checked at push time, because the control
533
+ * network's inbound gate deliberately admits non-members in several states
534
+ * (seed delivery, enrollment, bootstrap/relay peers). Omit for the defaults
535
+ * (`DEFAULT_PEER_JOIN_BACKFILL`); `{ enabled: false }` disables it.
536
+ */
537
+ controlBackfill?: PeerJoinBackfillConfig;
538
+
539
+ /**
540
+ * Tuning for the CLOSED-strand revoked-peer gate
541
+ * (`strand-revocation-enforcer.ts`), applied to every closed strand this node
542
+ * starts: each strand node materializes the peer ids bound to REMOVED members
543
+ * (orphaned `Strand.MemberPeer` rows) and refuses them at the stream and
544
+ * connection layers, so removing a party cuts the network to its machines
545
+ * rather than only deleting its row. Open strands never arm it. Omit for the
546
+ * defaults (`DEFAULT_REVOCATION_POLL_INTERVAL_MS` refresh cadence);
547
+ * `{ enabled: false }` restores the pre-existing behaviour.
548
+ */
549
+ strandRevocationEnforcement?: StrandRevocationEnforcementConfig;
550
+
551
+ /** Hibernation configuration */
552
+ hibernation?: HibernationConfig;
553
+
554
+ /** Polling interval for strand watcher in ms (default: 5000) */
555
+ strandWatchInterval?: number;
556
+
557
+ /**
558
+ * Require a valid author signature on every sApp schema before a strand is
559
+ * formed or joined. Defaults to true (fail closed): an unsigned schema is
560
+ * rejected before any libp2p node or schema DDL is brought up. Set false ONLY
561
+ * for dev/test where unsigned demo schemas are used.
562
+ */
563
+ requireSignedSchemas?: boolean;
564
+
565
+ /**
566
+ * Node-wide default trust anchor for INBOUND control-network seeds. Forwarded
567
+ * into every SeedBootstrapService this node constructs (owner, receive-only
568
+ * listener, and the temp service used by applySeed when no service exists), and
569
+ * used as the service-level default the libp2p seed-protocol handler relies on
570
+ * (that path has no per-call override seam). Defaults to anchoredTrustPolicy()
571
+ * inside SeedBootstrapService when unset — a node whose node-local trusted-owner
572
+ * anchor was never seeded then rejects every seed. A per-call
573
+ * `applySeed(seed, { trustPolicy })` override still wins over this default for
574
+ * callers that hold out-of-band material (e.g. a pinned key from a CadreInvite).
575
+ */
576
+ seedTrustPolicy?: SeedTrustPolicy;
577
+
578
+ /**
579
+ * Node-local trusted-owner anchor (see `trusted-owner-store.ts`): the
580
+ * NON-replicated, per-party record of owner keys established out of band —
581
+ * the anchor membership/seed trust can rest on, since the replicated
582
+ * `OwnerKey` table can be polluted by a stranger's self-genesis. Absent ⇒ an
583
+ * in-memory store is created at start() (ephemeral: anchored trust does not
584
+ * survive the process).
585
+ */
586
+ trustedOwners?: {
587
+ /**
588
+ * Injected store instance — e.g. a `FileTrustedOwnerStore` from the
589
+ * Node-only subpath `@serfab/cadre-core/trusted-owner-store-file`,
590
+ * persisted in the node's state directory (same injection/isolation pattern
591
+ * as {@link keyStore}). Its `partyId` must match `controlNetwork.partyId`;
592
+ * start() fails closed on a mismatch.
593
+ */
594
+ store?: TrustedOwnerStore;
595
+ /**
596
+ * Owner keys (base64url ed25519) established out of band and seeded into
597
+ * the store during start(): operator pins (e.g. cadre-cli
598
+ * `--pin-owner-key` / `CADRE_OWNER_KEYS`) or a `CadreInvite.ownerKeys`
599
+ * already known at config time. Seeding is idempotent across restarts.
600
+ */
601
+ pinnedKeys?: string[];
602
+ /**
603
+ * Provenance recorded for {@link pinnedKeys}. Default: 'operator'.
604
+ * ('genesis' is reserved for the node's own founding key, seeded
605
+ * internally by `initializeSeedBootstrap`.)
606
+ */
607
+ pinnedSource?: Exclude<TrustSource, 'genesis'>;
608
+ };
609
+
610
+ /**
611
+ * Node-local cold-start bootstrap-peer store (see `bootstrap-peer-store.ts`):
612
+ * the owner addresses an applied seed nominated, kept so
613
+ * `reconcileControlCohort`'s cold-start branch can keep retrying a node that
614
+ * was seeded but never managed to connect. Sibling of {@link trustedOwners} —
615
+ * same NON-replicated, per-party, injected-backend shape — but nothing here is
616
+ * trust-bearing: these are dial hints only, and every address is re-bound to
617
+ * its peer id before the dial. Absent ⇒ an in-memory store is created at
618
+ * start() (ephemeral: the retry set does not survive the process, and such a
619
+ * node is stranded permanently if it restarts before connecting).
620
+ *
621
+ * The Node CLI, the browser, and React Native all inject a durable backend
622
+ * today; only NativeScript stays ephemeral, tracked by the
623
+ * `ns-durable-node-local-stores` ticket (which covers the same gap for
624
+ * {@link trustedOwners}). A new platform needs no new store class: supply a
625
+ * `DurableSlot` for the platform's storage and inject
626
+ * `PersistentBootstrapPeerStore.open(slot, partyId)` /
627
+ * `PersistentTrustedOwnerStore.open(slot, partyId)`.
628
+ */
629
+ bootstrapPeers?: {
630
+ /**
631
+ * Injected store instance — e.g. a `FileBootstrapPeerStore` from the
632
+ * Node-only subpath `@serfab/cadre-core/bootstrap-peer-store-file`, persisted
633
+ * in the node's state directory (same injection/isolation pattern as
634
+ * {@link keyStore} / {@link trustedOwners}). Its `partyId` must match
635
+ * `controlNetwork.partyId`; start() fails closed on a mismatch.
636
+ */
637
+ store?: BootstrapPeerStore;
638
+ };
639
+
640
+ /**
641
+ * Node-local record of how many machines this party had enrolled the last time
642
+ * this node looked (see `enrolled-machine-store.ts`): the block-repair
643
+ * corroboration yardstick the CONTROL node declares when its libp2p node is
644
+ * built. Sibling of {@link trustedOwners} / {@link bootstrapPeers} — same
645
+ * NON-replicated, per-party, injected-backend shape — and, like
646
+ * {@link bootstrapPeers}, nothing here is trust-bearing: it is a repair hint,
647
+ * recomputed from `CadrePeer` rows the moment the control database is up.
648
+ *
649
+ * It exists only because the control node is created BEFORE that database, so
650
+ * the live count is unreadable at the one moment it is needed; remembering it
651
+ * across the restart is what breaks that deadlock. Absent ⇒ an in-memory store
652
+ * is created at start(), which cold-starts on every launch and therefore
653
+ * declares nothing — byte-for-byte the behaviour before this record existed.
654
+ * No embedder is forced to change.
655
+ *
656
+ * A party that grows applies the larger number on each node's NEXT launch,
657
+ * deliberately: Optimystic freezes the policy at node construction and offers
658
+ * no runtime setter, and a node still holding the old value runs at exactly
659
+ * today's behaviour, so there is no forced rebuild.
660
+ *
661
+ * A new platform needs no new store class: supply a `DurableSlot` for the
662
+ * platform's storage and inject `PersistentEnrolledMachineStore.open(slot, partyId)`.
663
+ */
664
+ enrolledMachines?: {
665
+ /**
666
+ * Injected store instance — e.g. a `FileEnrolledMachineStore` from the
667
+ * Node-only subpath `@serfab/cadre-core/enrolled-machine-store-file`,
668
+ * persisted in the node's state directory. Its `partyId` must match
669
+ * `controlNetwork.partyId`; start() fails closed on a mismatch.
670
+ */
671
+ store?: EnrolledMachineStore;
672
+ };
673
+
674
+ /**
675
+ * Platform push-delivery for suspended mobile peers. When present, the node's
676
+ * server fan-out can deliver strand-wake data messages over the platform push
677
+ * channel (FCM/APNs). Absent ⇒ no platform push (control-network push-wake only).
678
+ *
679
+ * The `notifier` is **injected**, not constructed here: a Node host builds it
680
+ * from `@serfab/cadre-core/push-node` (`createPushNotifier(credentials)`) and
681
+ * passes the instance, so the FCM/APNs modules (`node:crypto`/`node:http2`)
682
+ * stay out of the cross-platform core graph. Ownership transfers to the node on
683
+ * injection — `CadreNode.stop` closes the notifier (via `PushFanoutService`),
684
+ * releasing its APNs HTTP/2 session; a host must NOT also close an injected
685
+ * notifier itself. `cooldownMs`/`debounceMs` carry the fan-out anti-spam policy.
686
+ */
687
+ push?: {
688
+ /** The Node-constructed push router (see `@serfab/cadre-core/push-node`). */
689
+ notifier: PushNotifier;
690
+ /**
691
+ * Per-`(peer, strand)` minimum gap between push-wakes the fan-out emits.
692
+ * Default {@link DEFAULT_PUSH_COOLDOWN_MS} (5 min).
693
+ */
694
+ cooldownMs?: number;
695
+ /**
696
+ * Per-strand burst-coalescing window: a second trigger within it is dropped.
697
+ * Default {@link DEFAULT_PUSH_DEBOUNCE_MS} (10 s).
698
+ */
699
+ debounceMs?: number;
700
+ };
701
+ }
702
+
703
+ /**
704
+ * Status of a strand instance
705
+ */
706
+ export type StrandStatus =
707
+ | 'starting'
708
+ | 'active'
709
+ | 'idle'
710
+ | 'hibernating'
711
+ | 'stopping'
712
+ | 'stopped'
713
+ | 'error';
714
+
715
+ /**
716
+ * App information from strand header
717
+ */
718
+ export interface SAppInfo {
719
+ id: string;
720
+ version: string;
721
+ schema: string;
722
+ signature?: string;
723
+ }
724
+
725
+ /**
726
+ * Strand instance state
727
+ */
728
+ export interface StrandInstance {
729
+ strandId: string;
730
+ status: StrandStatus;
731
+ sAppInfo?: SAppInfo;
732
+
733
+ /** The libp2p node for this strand (only when active/idle) */
734
+ libp2pNode?: Libp2p;
735
+
736
+ /** The Quereus database for this strand (only when active/idle) */
737
+ database?: StrandDatabase;
738
+
739
+ /** Membership info for closed strands */
740
+ memberKey?: string;
741
+ memberPrivateKey?: string;
742
+
743
+ /** Activity tracking */
744
+ connectedPeers: number;
745
+ lastActivity: Date;
746
+ nextCheckIn?: Date;
747
+
748
+ /** Latency hint from app or override */
749
+ latencyHint: LatencyHint;
750
+
751
+ /** Error message if status is 'error' */
752
+ error?: string;
753
+ }
754
+
755
+ /**
756
+ * Outcome of an on-demand {@link CadreNode.serviceWake} — the mobile push-wake
757
+ * cycle (resume → bounded window → re-hibernate-if-idle) run imperatively for a
758
+ * single strand.
759
+ */
760
+ export interface ServiceWakeResult {
761
+ /** The strand the wake was requested for. */
762
+ strandId: string;
763
+ /**
764
+ * Whether the wake was actually serviced. `false` when the node is not
765
+ * running, or the strand is unknown to this node (not a member-participated
766
+ * strand) — never a thrown error, so a background task can branch on it.
767
+ */
768
+ serviced: boolean;
769
+ /**
770
+ * Whether activity landed during the wake window. `true` leaves the strand
771
+ * active; `false` re-hibernates it (or the strand was already live, in which
772
+ * case it reflects current liveness). Always `false` when `serviced` is `false`.
773
+ */
774
+ hadActivity: boolean;
775
+ }
776
+
777
+ /**
778
+ * Strand row from control network - basic membership info
779
+ */
780
+ export interface StrandRow {
781
+ Id: string;
782
+ MemberPrivateKey: string | null;
783
+ Type: 'o' | 'c';
784
+ /**
785
+ * ed25519 (base64url) owner key of the MACHINE that published this row — the
786
+ * signer of the owner-signed `Strand` insert (`== context.OwnerKey`, pinned by
787
+ * the schema's `AuthorizedInsert`). Identifies the founding machine, so a
788
+ * launch with no explicit `founder` flag derives "am I the founder?" by
789
+ * comparing it to the node's own owner key (see `CadreNode.launchStrand`).
790
+ * `null` on a consent-seated strand (the unsigned `FormationUsage` branch
791
+ * records no trustworthy signer). Provenance, not content —
792
+ * `strandRowMismatches` excludes it from the identical-content comparison.
793
+ */
794
+ FounderOwnerKey: string | null;
795
+ }
796
+
797
+ /**
798
+ * sApp configuration provided by the hosting application when creating a strand.
799
+ * This is what the app developer provides - NOT loaded from the network.
800
+ */
801
+ export interface SAppConfig {
802
+ /** Public key of the sApp author */
803
+ id: string;
804
+ /** Version of the sApp */
805
+ version: string;
806
+ /** The declarative schema DDL */
807
+ schema: string;
808
+ /**
809
+ * Author's signature over the schema for verification. Required under the
810
+ * default `requireSignedSchemas` node policy — a config without it is rejected
811
+ * before strand bring-up. Optional in the type because authoring/serialization
812
+ * must represent an as-yet-unsigned config; omitting it is honored only when
813
+ * the node explicitly relaxes the policy (`requireSignedSchemas: false`, dev/test).
814
+ */
815
+ signature?: string;
816
+ /** Latency hint for hibernation behavior (optional, defaults to config) */
817
+ latencyHint?: LatencyHint;
818
+ }
819
+
820
+ /**
821
+ * Full strand configuration when adding a strand via the API
822
+ */
823
+ export interface StrandConfig {
824
+ /** Strand row from control network */
825
+ strandRow: StrandRow;
826
+ /** sApp configuration provided by the hosting application */
827
+ sAppConfig: SAppConfig;
828
+ /**
829
+ * Whether THIS node is the strand's founder — the party that provisioned and
830
+ * published the strand (the responder in formation, the creator in host/solo
831
+ * paths; the same party that calls {@link CadreNode.publishStrand}). The founder
832
+ * runs the one-time membership bootstrap at bring-up (writes `Strand.Header`, and
833
+ * for a closed strand the founding `Member`+`Manager`). A joiner leaves this
834
+ * unset and writes nothing — it receives those rows via Optimystic sync.
835
+ *
836
+ * When unset, founder-ness is DERIVED from the row: this node founds iff
837
+ * {@link StrandRow.FounderOwnerKey} equals its own owner key. An explicit
838
+ * `true`/`false` wins over the derivation — the formation/responder flows pass
839
+ * it deliberately, since a consent-seated row carries a null column.
840
+ */
841
+ founder?: boolean;
842
+ }
843
+
844
+ /**
845
+ * What {@link CadreNode.foundStrand} needs to publish a strand's control row AND start it
846
+ * locally as its founder — the resumable one-call form of `publishStrand` + `addStrand`.
847
+ *
848
+ * No `strandRow`: the row is the OUTPUT of founding, either freshly published or read back
849
+ * from an interrupted earlier attempt. `StrandConfig` (the join/attach shape) takes one
850
+ * because there the row already exists and came from the control network.
851
+ */
852
+ export interface FoundStrandConfig {
853
+ /** Unique strand identifier. Trimmed; blank is rejected. */
854
+ strandId: string;
855
+ /** `'o'` for open (default) or `'c'` for closed. */
856
+ type?: 'o' | 'c';
857
+ /**
858
+ * Membership key gating a closed strand. Required in practice for `type: 'c'` (the
859
+ * founder bootstrap rejects a null key), and IGNORED when the strand is already
860
+ * published — the stored key wins, so read the resolved key back off the returned
861
+ * instance rather than trusting a freshly minted one.
862
+ */
863
+ memberPrivateKey?: string;
864
+ /** sApp configuration the hosting application provides, as for {@link StrandConfig}. */
865
+ sAppConfig: SAppConfig;
866
+ }
867
+
868
+ /**
869
+ * What {@link CadreNode.foundStrand} hands back: the running instance PLUS the `Strand` row
870
+ * the strand actually runs under.
871
+ *
872
+ * The row is returned separately rather than left for the caller to read off the instance
873
+ * because on a resumed founding it is NOT the caller's input — a closed strand adopts the
874
+ * stored `MemberPrivateKey`, discarding a freshly minted one. A caller that must carry the
875
+ * membership key onward (to mint an invitation, say) reads it from HERE.
876
+ */
877
+ export interface FoundStrandResult {
878
+ /** The active local instance, as {@link CadreNode.addStrand} would have returned. */
879
+ instance: StrandInstance;
880
+ /** The live control-plane row: freshly published, or the one already there. */
881
+ strandRow: StrandRow;
882
+ /**
883
+ * Whether THIS machine actually founded (ran / will have run the one-time
884
+ * founder bootstrap). `false` when the resolved row was published by a
885
+ * DIFFERENT machine's owner key — e.g. a sibling won a concurrent founding
886
+ * race — in which case the call attached as a joiner instead, which is the
887
+ * correct outcome (two machines bootstrapping one strand on separate replicas
888
+ * is the double-`Header` hazard).
889
+ */
890
+ founded: boolean;
891
+ }
892
+
893
+ /**
894
+ * Result of creating a new cadre peer
895
+ */
896
+ export interface CreatePeerResult {
897
+ peerId: PeerId;
898
+ privateKey: Uint8Array;
899
+ }
900
+
901
+ // ============================================================================
902
+ // Member Registration API Types (from api.md)
903
+ // ============================================================================
904
+
905
+ /**
906
+ * Registration data for joining a strand as a member.
907
+ * Sent from invited member to any cadre member to accept invitation.
908
+ */
909
+ export interface MemberRegistration {
910
+ /** The strand being joined */
911
+ strandId: string;
912
+ /** The member's public key for this strand */
913
+ key: string;
914
+ /** Peer IDs of the member's cadre nodes that will participate */
915
+ peerIds: string[];
916
+ }
917
+
918
+ /**
919
+ * Result of member registration
920
+ */
921
+ export interface MemberRegistrationResult {
922
+ success: boolean;
923
+ reason?: string;
924
+ }
925
+
926
+ // ============================================================================
927
+ // Strand Solicitation API Types (from api.md)
928
+ // ============================================================================
929
+
930
+ /**
931
+ * Open invitation for strand formation.
932
+ * Shared out-of-band to allow strangers to form strands.
933
+ */
934
+ export interface OpenInvitation {
935
+ /** Unique token identifying this invitation */
936
+ token: string;
937
+ /** The sApp that will be used for the strand */
938
+ sAppId: string;
939
+ /** When this invitation expires */
940
+ expiration: Date;
941
+ /** Bootstrap addresses to contact the inviter's cadre */
942
+ bootstrap: string[];
943
+ }
944
+
945
+ /**
946
+ * Result of forming a strand via open invitation
947
+ */
948
+ export interface FormStrandResult {
949
+ /** The member key assigned to the initiator for this strand */
950
+ memberKey: string;
951
+ /** Private key for the invitation (for signing future messages) */
952
+ invitePrivateKey: string;
953
+ /** The strand that was created */
954
+ strandId: string;
955
+ /**
956
+ * The strand's membership key (closed-strand read-gating secret), delivered through
957
+ * the formation protocol after consent (provision-then-record). Present only for a
958
+ * closed strand the responder returned with its key; undefined otherwise. Distinct
959
+ * from {@link invitePrivateKey} (the initiator's own generated signing key).
960
+ */
961
+ memberPrivateKey?: string;
962
+ /**
963
+ * The responder's live STRAND-network multiaddrs for the formed strand — the only
964
+ * cross-party discovery seed there is, since the strand-addr RPC that resolves a
965
+ * sibling's strand addresses answers own-party callers only.
966
+ *
967
+ * Always an array, frequently EMPTY: the responder discloses none when it holds no
968
+ * live strand node for the strand it just provisioned (the responder-provisions path
969
+ * mints a strand that has not launched yet). `CadreNode.formStrand` records these
970
+ * itself, so an embedding app only needs them to seed a strand it launches OUTSIDE
971
+ * that node.
972
+ *
973
+ * One-shot and in-memory: they are the responder's addresses at the moment of
974
+ * formation and are never re-resolved — see `docs/strands.md`.
975
+ */
976
+ strandAddrs: string[];
977
+ }
978
+
979
+ /**
980
+ * Disclosure object provided during strand formation.
981
+ * Contains identity and context information from the initiator.
982
+ */
983
+ export interface StrandFormationDisclosure {
984
+ /** Initiator's party identifier */
985
+ partyId?: string;
986
+ /** Additional identity bundle (app-specific) */
987
+ identityBundle?: unknown;
988
+ /** Human-readable purpose/reason for forming the strand */
989
+ purpose?: string;
990
+ /** Additional app-specific metadata */
991
+ metadata?: Record<string, unknown>;
992
+ }
993
+
994
+ /**
995
+ * Arachnode ring participation stub - will be implemented when arachnode is built
996
+ */
997
+ export interface ArachnodeConfig {
998
+ /** Enable Arachnode subsystem (Ring Zulu + storage rings). Storage-profile only. */
999
+ enableRingZulu: boolean;
1000
+ /** Storage ring participation (storage profile only) */
1001
+ storageRing?: {
1002
+ /** Which ring to participate in based on capacity */
1003
+ ring: number;
1004
+ /** Partition within the ring */
1005
+ partition?: number;
1006
+ };
1007
+ }
1008
+
1009
+ /**
1010
+ * Events emitted by CadreNode
1011
+ */
1012
+ export interface CadreNodeEvents {
1013
+ 'strand:started': { strandId: string };
1014
+ 'strand:stopped': { strandId: string };
1015
+ 'strand:error': { strandId: string; error: Error };
1016
+ 'strand:idle': { strandId: string };
1017
+ 'strand:hibernating': { strandId: string };
1018
+ 'strand:waking': { strandId: string };
1019
+ /**
1020
+ * Emitted when this node discovers it is no longer a member of a CLOSED
1021
+ * strand — its party was removed by a manager, or it left. Detected by the
1022
+ * strand's revoked-peer gate (`strand-revocation-enforcer.ts`) seeing this
1023
+ * node's own peer id in the revoked set, so it is BEST-EFFORT: it fires only
1024
+ * if the removal replicated here before the rest of the strand cut us off,
1025
+ * and a node that was already offline may never see it at all.
1026
+ *
1027
+ * Nothing is stopped or torn down for you. Treat it as "this strand will no
1028
+ * longer sync": remaining members refuse this node's streams, dials, and
1029
+ * connections, so reads keep working against whatever is stored locally while
1030
+ * writes stop propagating. Stopping the strand (`stopStrand`) or deleting its
1031
+ * data is the app's decision. Emitted once per removal — re-armed if a manager
1032
+ * re-admits this party, so a second removal is reported again — and a
1033
+ * hibernation wake rebuilds the gate, which may re-emit for a strand still
1034
+ * revoked.
1035
+ */
1036
+ 'strand:revoked': { strandId: string };
1037
+ /**
1038
+ * Emitted when the control network advertises a strand this node has no
1039
+ * registered `sAppConfig` for — i.e. a strand created by another member. The
1040
+ * hosting app decides whether to join it (register a config + `addStrand`,
1041
+ * e.g. via a chat `joinChatStrand` helper). Carries the full {@link StrandRow}
1042
+ * so the app can join without re-querying the control DB.
1043
+ */
1044
+ 'strand:discovered': { strandId: string; strand: StrandRow };
1045
+ 'control:connected': void;
1046
+ 'control:disconnected': void;
1047
+ /** Emitted when a seed is received via the seed protocol */
1048
+ 'seed:received': { partyId: string; peerId: string };
1049
+ /** Emitted when a seed is successfully applied */
1050
+ 'seed:applied': { partyId: string; peersAdded: number };
1051
+ /** Emitted when seed application fails */
1052
+ 'seed:error': { partyId: string; error: string };
1053
+ }
1054
+
1055
+ // ============================================================================
1056
+ // Peer Address Record / Resolution Types
1057
+ // ============================================================================
1058
+
1059
+ /**
1060
+ * A peer's self-published, freshness-stamped, self-signed address record.
1061
+ *
1062
+ * This is the logical shape of a `CadreControl.CadrePeer` row. A node publishes
1063
+ * its own record (signing with the ed25519 key behind its PeerId); any node can
1064
+ * resolve another member's current signaling/relay multiaddrs from only its
1065
+ * PeerId by reading the record, re-verifying the self-signature against
1066
+ * `publicKey`, checking freshness, and applying a trust gate.
1067
+ *
1068
+ * The signature covers `peerId`, the comma-joined `addrs`, and `updatedAt` — see
1069
+ * `peerRecordSignedPayload`. `publicKey` is NOT inside the signed payload: it is
1070
+ * the key the signature is verified *with*, and its libp2p-identity binding to
1071
+ * `peerId` is checked separately at resolve time, so re-signing it would be
1072
+ * redundant.
1073
+ */
1074
+ export interface PeerAddressRecord {
1075
+ /** libp2p peer ID (base58btc) — the row key. */
1076
+ peerId: string;
1077
+ /** ed25519 public key (base64url) whose libp2p identity IS `peerId`. */
1078
+ publicKey: string;
1079
+ /** Current dialable multiaddrs, signaling (`/p2p-circuit`) first. */
1080
+ addrs: string[];
1081
+ /** epoch ms — freshness; strictly increasing per peer. */
1082
+ updatedAt: number;
1083
+ /** ed25519 self-signature over the signed payload, base64url. */
1084
+ sig: string;
1085
+ }
1086
+
1087
+ /**
1088
+ * One `CadreControl.CadrePeer` row as read by `ControlDatabase.queryCadrePeers`:
1089
+ * the addressing columns plus the persisted membership voucher.
1090
+ *
1091
+ * The voucher triple (`stampId`, `vouchOwner`, `vouchSig`) is what
1092
+ * {@link CadreNode.listAuthorizedMembers} re-checks against the node-local
1093
+ * trusted-owner anchor — it is null on a row written before the voucher columns
1094
+ * existed, and all three must be present for the row to be authorizable. Named
1095
+ * (rather than restated inline at each read site) so a future column addition
1096
+ * cannot reach the query without the predicate seeing it.
1097
+ */
1098
+ export interface CadrePeerRow {
1099
+ /** libp2p peer ID (base58btc) — the row key. */
1100
+ peerId: string;
1101
+ /** Comma-joined dialable multiaddrs as stored, or null when unpublished. */
1102
+ multiaddr: string | null;
1103
+ /** Single-use anti-replay nonce the voucher signature is bound to. */
1104
+ stampId: string | null;
1105
+ /** ed25519 public key (base64url) of the owner that vouched this row. */
1106
+ vouchOwner: string | null;
1107
+ /** That owner's signature over `digest('CadreControl.CadrePeer', 'vouch', peerId, stampId)`, base64url. */
1108
+ vouchSig: string | null;
1109
+ }
1110
+
1111
+ /** The voucher-bearing subset of a {@link CadrePeerRow} — what the authorized-membership predicate reads. */
1112
+ export type CadrePeerVoucherFields = Pick<CadrePeerRow, 'peerId' | 'stampId' | 'vouchOwner' | 'vouchSig'>;
1113
+
1114
+ /**
1115
+ * One `CadreControl.Revocation` tombstone as read by
1116
+ * `ControlDatabase.queryRevocations`: the identity triple plus the `ReissuedAt`
1117
+ * counter an owner bumps (`ControlDatabase.reissueRevocations`) to re-write — and
1118
+ * therefore re-broadcast — a tombstone that committed while the node was alone.
1119
+ */
1120
+ export interface RevocationRow {
1121
+ /** Which guarded table's stamp was retired. */
1122
+ tableName: RevocableTable;
1123
+ /** Primary key of the removed row (peer id / key / strand id). */
1124
+ rowKey: string;
1125
+ /** The retired single-use nonce — the tombstone's identity. */
1126
+ stampId: string;
1127
+ /** Monotonic re-issue counter; carries no semantics (see the schema comment). */
1128
+ reissuedAt: number;
1129
+ }
1130
+
1131
+ /**
1132
+ * Outcome of {@link CadreNode.registerSelf}, surfaced so callers (e.g. the CLI
1133
+ * `--owner` branch) can log what happened:
1134
+ * - `inserted` — the first owner-signed INSERT of this node's `CadrePeer` row.
1135
+ * - `refreshed` — a self-signed UPDATE of an existing row (heartbeat / addr change).
1136
+ * Also reported when a first publish found its row seated by a concurrent
1137
+ * `authorizePeer` of this node's own id and fell through to the UPDATE — the write
1138
+ * really was an UPDATE, even though the caller's intent was a first publish.
1139
+ * - `skipped` — nothing written (no self-signing key, not yet a member with no
1140
+ * owner service to self-insert, or the row was removed mid-publish).
1141
+ */
1142
+ export type SelfRegistrationOutcome = 'inserted' | 'refreshed' | 'skipped';
1143
+
1144
+ /**
1145
+ * Options for {@link CadreNode.resolvePeerAddrs}.
1146
+ */
1147
+ export interface ResolveOpts {
1148
+ /**
1149
+ * Freshness ceiling in ms. A record older than this is filtered out (a
1150
+ * resolver must never hand back a dead relay reservation). Defaults to
1151
+ * {@link DEFAULT_PEER_RECORD_MAX_AGE_MS}.
1152
+ */
1153
+ maxAgeMs?: number;
1154
+ /** Return only `/p2p-circuit` signaling addrs (the WebRTC dial input). */
1155
+ signalingOnly?: boolean;
1156
+ /**
1157
+ * Pluggable trust gate, evaluated after signature + freshness pass. Defaults
1158
+ * to {@link currentMemberTrustPolicy} (any existing — therefore
1159
+ * owner-vouched — CadrePeer member is trusted). Inject a stricter policy
1160
+ * (e.g. from `seed-signerkey-trust-policy`) to reject otherwise-valid records
1161
+ * before dialing.
1162
+ */
1163
+ trustPolicy?: PeerResolveTrustPolicy;
1164
+ }
1165
+
1166
+ /**
1167
+ * Context handed to a {@link PeerResolveTrustPolicy} when gating a resolution.
1168
+ */
1169
+ export interface PeerResolveContext {
1170
+ /** The peer being resolved. */
1171
+ peerId: string;
1172
+ /** The record's ed25519 public key (already bound to `peerId` and verified). */
1173
+ publicKey: string;
1174
+ /** The party whose control network produced the record. */
1175
+ partyId: string;
1176
+ /** The full verified, fresh record. */
1177
+ record: PeerAddressRecord;
1178
+ }
1179
+
1180
+ /**
1181
+ * Decides whether a signature-verified, fresh peer-address record should be
1182
+ * trusted enough to dial. The seam through which a richer trust model
1183
+ * (trust circle, pinned keys) composes with `resolvePeerAddrs`.
1184
+ */
1185
+ export interface PeerResolveTrustPolicy {
1186
+ evaluate(ctx: PeerResolveContext): Promise<boolean> | boolean;
1187
+ }
1188
+
1189
+ // ============================================================================
1190
+ // Device Token Registry Types
1191
+ // ============================================================================
1192
+
1193
+ /** Platform push channel a {@link DeviceTokenRecord} targets. */
1194
+ export type PushPlatform = 'fcm' | 'apns';
1195
+
1196
+ /**
1197
+ * A mobile cadre peer's self-published, freshness-stamped, self-signed platform
1198
+ * push token.
1199
+ *
1200
+ * This is the logical shape of a `CadreControl.DeviceToken` row. A phone publishes
1201
+ * its own record (signing with the ed25519 key behind its PeerId); a server peer
1202
+ * resolves it from the phone's PeerId to deliver a push-wake over FCM/APNs when a
1203
+ * control-network dial cannot reach the OS-suspended process. The signature covers
1204
+ * `peerId`, `platform`, `token`, and `updatedAt` — see `deviceTokenSignedPayload`.
1205
+ *
1206
+ * Unlike {@link PeerAddressRecord}, the record carries NO public key: it is verified
1207
+ * against the `CadrePeer.PublicKey` bound to the same `peerId`, so a `DeviceToken`
1208
+ * with no backing `CadrePeer` row can never be resolved.
1209
+ */
1210
+ export interface DeviceTokenRecord {
1211
+ /** libp2p peer ID (base58btc) — the row key and the CadrePeer this token belongs to. */
1212
+ peerId: string;
1213
+ /** Push channel: `'fcm'` (Android/Firebase) or `'apns'` (Apple). */
1214
+ platform: PushPlatform;
1215
+ /** Opaque platform device/registration token. */
1216
+ token: string;
1217
+ /** epoch ms — freshness; strictly increasing per self-update (replay guard). */
1218
+ updatedAt: number;
1219
+ /** ed25519 self-signature over the signed payload, base64url. */
1220
+ sig: string;
1221
+ }
1222
+
1223
+ /**
1224
+ * A stored `CadreControl.DeviceToken` row: the self-signed
1225
+ * {@link DeviceTokenRecord} plus the row's single-use `StampId` nonce.
1226
+ *
1227
+ * The stamp is deliberately OUTSIDE `DeviceTokenRecord`: the peer's own `Sig`
1228
+ * covers only (peerId, platform, token, updatedAt), while the stamp is bound by
1229
+ * the OWNER's insert/delete approvals. Readers need it to drop a row whose stamp
1230
+ * has been retired into `CadreControl.Revocation` (a token resurrected by an
1231
+ * approval replay on a node that had not yet converged on the tombstone).
1232
+ */
1233
+ export interface DeviceTokenRow extends DeviceTokenRecord {
1234
+ /** Single-use authorization nonce; rotates on every (re)insert. */
1235
+ stampId: string;
1236
+ }
1237
+
1238
+ /**
1239
+ * Options for {@link CadreNode.resolveDeviceToken}.
1240
+ */
1241
+ export interface ResolveDeviceTokenOpts {
1242
+ /**
1243
+ * Optional freshness ceiling in ms. Defaults to NO ceiling (a record is fresh
1244
+ * as long as its `updatedAt` is positive): unlike a relay reservation, a device
1245
+ * push token stays valid until it rotates, so a long-suspended phone must remain
1246
+ * resolvable for push-wake. Set this to bound staleness explicitly.
1247
+ */
1248
+ maxAgeMs?: number;
1249
+ }
1250
+
1251
+ // ============================================================================
1252
+ // Push Credentials (platform push delivery — see push-notifier.ts)
1253
+ // ============================================================================
1254
+
1255
+ /**
1256
+ * Platform push-delivery credentials injected into a participating node's config
1257
+ * (`CadreNodeConfig.push`). Provisioned per spawned node by `cadre-host` and read
1258
+ * by `createPushNotifier` to construct the FCM and/or APNs senders. A platform
1259
+ * whose block is absent is simply not deliverable (the router returns a
1260
+ * best-effort "no credentials" failure rather than throwing).
1261
+ *
1262
+ * Secret hygiene: every `privateKey` field is a secret — never logged (treated
1263
+ * like the node's startup/seed tokens).
1264
+ */
1265
+ export interface PushCredentials {
1266
+ /** FCM (Android / Firebase) service-account credentials. */
1267
+ fcm?: FcmCredentials;
1268
+ /** APNs (Apple) auth-key credentials. */
1269
+ apns?: ApnsCredentials;
1270
+ /**
1271
+ * Per-`(peer, strand)` minimum gap between push-wakes the server fan-out emits —
1272
+ * the anti-spam cooldown a chatty strand needs. Default
1273
+ * {@link DEFAULT_PUSH_COOLDOWN_MS} (5 min). In-memory, acceptably lossy across
1274
+ * restarts (`serviceWake` is idempotent, so a duplicate wake is harmless).
1275
+ */
1276
+ cooldownMs?: number;
1277
+ /**
1278
+ * Per-strand burst-coalescing window for the fan-out: a second activity trigger
1279
+ * within this window is dropped (one wake is enough). Default
1280
+ * {@link DEFAULT_PUSH_DEBOUNCE_MS} (10 s).
1281
+ */
1282
+ debounceMs?: number;
1283
+ }
1284
+
1285
+ /** Google service-account fields needed for FCM HTTP v1 OAuth2. */
1286
+ export interface FcmCredentials {
1287
+ /** GCP project id → `POST https://fcm.googleapis.com/v1/projects/{projectId}/messages:send`. */
1288
+ projectId: string;
1289
+ /** Service-account email (JWT `iss`/`sub`). */
1290
+ clientEmail: string;
1291
+ /** Service-account RSA private key, PEM. Secret — never logged. */
1292
+ privateKey: string;
1293
+ }
1294
+
1295
+ /** Apple APNs auth-key (.p8) fields. */
1296
+ export interface ApnsCredentials {
1297
+ /** APNs key id (JWT header `kid`). */
1298
+ keyId: string;
1299
+ /** Apple team id (JWT `iss`). */
1300
+ teamId: string;
1301
+ /** App bundle id → `apns-topic` header. */
1302
+ bundleId: string;
1303
+ /** `.p8` ES256 private key, PEM. Secret — never logged. */
1304
+ privateKey: string;
1305
+ /** `true` → `api.push.apple.com`; false/undefined → `api.sandbox.push.apple.com`. */
1306
+ production?: boolean;
1307
+ }
1308
+
1309
+ // ============================================================================
1310
+ // Seed Bootstrap API Types (from architecture.md)
1311
+ // ============================================================================
1312
+
1313
+ /**
1314
+ * A peer entry in a control network seed.
1315
+ * Contains connection information for a cadre peer.
1316
+ */
1317
+ export interface SeedPeer {
1318
+ /** Peer ID (libp2p identity) */
1319
+ peerId: string;
1320
+ /** Multiaddrs for connecting to this peer */
1321
+ multiaddrs: string[];
1322
+ /** Whether this peer holds an owner key */
1323
+ isOwner: boolean;
1324
+ /** ed25519 public key (base64url) — present on owner peers for signerKey verification */
1325
+ publicKey?: string;
1326
+ }
1327
+
1328
+ /**
1329
+ * Control network seed for bootstrapping new nodes.
1330
+ * Pre-populates a new node's cache to solve the cold-start problem:
1331
+ * new nodes need control data to validate connections, but can't get
1332
+ * data without connecting first.
1333
+ */
1334
+ export interface ControlNetworkSeed {
1335
+ /** Party ID this seed belongs to */
1336
+ partyId: string;
1337
+ /** Known peers in the cadre */
1338
+ peers: SeedPeer[];
1339
+ /** Signature over the seed by an owner key */
1340
+ signature: string;
1341
+ /** The owner key that signed this seed */
1342
+ signerKey: string;
1343
+ }
1344
+
1345
+ /**
1346
+ * Message sent from instigator to new node during seed delivery.
1347
+ * Delivered via /sereus/seed/1.0.0 libp2p protocol.
1348
+ */
1349
+ export interface SeedMessage {
1350
+ /** Party ID for the cadre */
1351
+ partyId: string;
1352
+ /** Known peers in the cadre */
1353
+ peers: SeedPeer[];
1354
+ /** Signature by an owner key */
1355
+ signature: string;
1356
+ /** The owner key that signed this message */
1357
+ signerKey: string;
1358
+ }
1359
+
1360
+ /**
1361
+ * Acknowledgment message from new node to instigator.
1362
+ */
1363
+ export interface SeedAckMessage {
1364
+ /** Whether the seed was accepted */
1365
+ accepted: boolean;
1366
+ /** Reason for rejection (if not accepted) */
1367
+ reason?: string;
1368
+ }
1369
+
1370
+ // ============================================================================
1371
+ // Strand Wake (control-network push-wake protocol) — see strand-wake-protocol.ts
1372
+ // ============================================================================
1373
+
1374
+ /**
1375
+ * Request sent over the control network's `WAKE_PROTOCOL` from a same-cadre peer
1376
+ * to a hibernating peer, asking it to bring a strand online and pull pending
1377
+ * activity. The receiver gates this on cadre membership before honoring it.
1378
+ */
1379
+ export interface WakeRequest {
1380
+ /** The strand the sender knows has pending activity. */
1381
+ strandId: string;
1382
+ /** Optional cause hint, e.g. `"activity"` (a server saw new traffic) or `"manual"`. */
1383
+ reason?: string;
1384
+ }
1385
+
1386
+ /**
1387
+ * Acknowledgment of a {@link WakeRequest}, returned on the same stream.
1388
+ */
1389
+ export interface WakeAck {
1390
+ /** Whether the receiver honored the wake (member + participated strand). */
1391
+ accepted: boolean;
1392
+ /** The strand's status after the wake (present when `accepted`). */
1393
+ status?: StrandStatus;
1394
+ /** Reason for rejection (non-member, unknown strand, malformed frame). */
1395
+ reason?: string;
1396
+ }
1397
+
1398
+ // ============================================================================
1399
+ // Strand Address (control-network strand-address RPC) — see strand-addr-protocol.ts
1400
+ // ============================================================================
1401
+
1402
+ /**
1403
+ * Control-network request from one of a party's own cadre nodes to a sibling,
1404
+ * asking "what are your live multiaddrs for this strand's separate network?".
1405
+ * Used to seed a strand mesh from co-cadre nodes that already run that strand.
1406
+ * The receiver gates this on cadre membership before answering.
1407
+ */
1408
+ export interface StrandAddrRequest {
1409
+ /** The strand whose strand-network address the requester wants to seed from. */
1410
+ strandId: string;
1411
+ /**
1412
+ * The derived transport peerId the requester's own strand-`strandId` node
1413
+ * runs as (see `strand-transport-key.ts`). Present → the responder records a
1414
+ * delegate admission grant for it, so its own connection gate (and thus its
1415
+ * circuit-relay server, when it runs one) admits that peerId; absent →
1416
+ * today's behavior exactly. See `delegate-admission.ts` for the trust model.
1417
+ */
1418
+ delegatePeerId?: string;
1419
+ }
1420
+
1421
+ /**
1422
+ * Response to a {@link StrandAddrRequest}, carrying the responder's strand-node
1423
+ * multiaddrs, returned on the same stream.
1424
+ */
1425
+ export interface StrandAddrResponse {
1426
+ /** Echoes the requested strand id (empty on a reject/error reply). */
1427
+ strandId: string;
1428
+ /**
1429
+ * Dialable strand-network multiaddr strings (signaling/`p2p-circuit` first);
1430
+ * empty when the responder is a non-member, or does not currently run that
1431
+ * strand, or the exchange failed.
1432
+ */
1433
+ multiaddrs: string[];
1434
+ }
1435
+
1436
+ /**
1437
+ * Options for authorizing a new peer in the cadre.
1438
+ */
1439
+ export interface AuthorizePeerOptions {
1440
+ /** Peer ID to authorize */
1441
+ peerId: string;
1442
+ /** Optional multiaddrs for the peer */
1443
+ multiaddrs?: string[];
1444
+ }
1445
+
1446
+ /**
1447
+ * Result of applying a seed to a node.
1448
+ */
1449
+ export interface ApplySeedResult {
1450
+ /** Whether the seed was successfully applied */
1451
+ success: boolean;
1452
+ /** Number of peers added to the peer store */
1453
+ peersAdded: number;
1454
+ /** Error message if not successful */
1455
+ error?: string;
1456
+ /**
1457
+ * Owner-flagged seed peers this apply attempted to dial. Zero when the seed
1458
+ * carried no owner peer with an address, or when the seed was rejected before
1459
+ * the dial loop ran.
1460
+ */
1461
+ ownerDialsAttempted: number;
1462
+ /**
1463
+ * How many of those dials threw. `ownerDialsAttempted > 0 && ownerDialsFailed
1464
+ * === ownerDialsAttempted` means "seeded but stranded": the seed itself was
1465
+ * accepted (`success: true`) but no owner could be reached, so this node has
1466
+ * no connection to bootstrap its control database from. The node retries those
1467
+ * addresses on every control-cohort reconcile pass (see
1468
+ * `CadreNode.dialColdStartBootstrap`), so this is a signal, not a fatal error.
1469
+ *
1470
+ * Zero failures is NOT proof of a live connection: the receiving node's
1471
+ * membership gate denies AFTER the dialer's upgrade completes (see
1472
+ * `createMembershipConnectionGater`), so a dial can resolve and then be torn
1473
+ * down. Treat a non-zero value as certain failure and zero as "no failure
1474
+ * observed".
1475
+ */
1476
+ ownerDialsFailed: number;
1477
+ }
1478
+
1479
+ // ============================================================================
1480
+ // Seed Helper Types
1481
+ // ============================================================================
1482
+
1483
+ /**
1484
+ * Node network topology type for seed helper scenarios.
1485
+ */
1486
+ export type NodeTopology = 'public' | 'nat';
1487
+
1488
+ /**
1489
+ * Invite code for phone-to-server enrollment.
1490
+ * Used when a server invites a phone to join the cadre.
1491
+ */
1492
+ export interface CadreInvite {
1493
+ /** Party ID of the cadre */
1494
+ partyId: string;
1495
+ /** Multiaddrs to dial the owner */
1496
+ ownerAddrs: string[];
1497
+ /**
1498
+ * Owner ed25519 public keys (base64url) of the cadre, carried out-of-band
1499
+ * so a cold-start invitee can pin the trusted owner set before applying
1500
+ * any seed (the seed itself cannot vouch for its own signer). Populated by
1501
+ * `createInvite` from the issuer's OWN node-local trusted-owner anchor —
1502
+ * not its replicated `OwnerKey` table, because the invitee anchors whatever
1503
+ * arrives here and a stranger's genesis-inserted key must not ride an
1504
+ * otherwise-legitimate invite into a fresh node's anchor.
1505
+ */
1506
+ ownerKeys?: string[];
1507
+ /** Optional invite token for validation */
1508
+ token?: string;
1509
+ /** Timestamp when invite was created */
1510
+ createdAt: number;
1511
+ /** Optional expiration timestamp */
1512
+ expiresAt?: number;
1513
+ }
1514
+
1515
+ /**
1516
+ * Options for adding a drone via provider API.
1517
+ */
1518
+ export interface AddDroneOptions {
1519
+ /** Peer ID of the new drone (returned from provider) */
1520
+ dronePeerId: string;
1521
+ /** Multiaddrs of the drone (returned from provider) */
1522
+ droneMultiaddrs: string[];
1523
+ }
1524
+
1525
+ /**
1526
+ * Options for adding a phone via invite flow.
1527
+ */
1528
+ export interface AddPhoneOptions {
1529
+ /** Peer ID of the phone (sent by phone when it connects) */
1530
+ phonePeerId: string;
1531
+ /** Invite token for validation (must match issued invite) */
1532
+ token?: string;
1533
+ }
1534
+
1535
+ /**
1536
+ * Result of preparing a seed for drone initialization.
1537
+ */
1538
+ export interface DroneInitResult {
1539
+ /** The seed to send to the provider API */
1540
+ seed: ControlNetworkSeed;
1541
+ /** Base64url encoded seed for transport */
1542
+ encodedSeed: string;
1543
+ }
1544
+
1545
+ /**
1546
+ * Result of creating an invite for a phone.
1547
+ */
1548
+ export interface InviteResult {
1549
+ /** The invite to share out-of-band */
1550
+ invite: CadreInvite;
1551
+ /** Base64url encoded invite for QR/link */
1552
+ encodedInvite: string;
1553
+ }
1554
+