@serfab/cadre-core 1.10.0 → 1.11.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 (61) hide show
  1. package/dist/cadre-node.d.ts +0 -27
  2. package/dist/cadre-node.js +2 -94
  3. package/dist/cadre-node.js.map +1 -1
  4. package/dist/control-schema.js +1066 -1066
  5. package/dist/diagnostics/connection-path.d.ts +1 -18
  6. package/dist/diagnostics/connection-path.js +1 -20
  7. package/dist/diagnostics/connection-path.js.map +1 -1
  8. package/dist/identity-key.d.ts +0 -30
  9. package/dist/identity-key.js +7 -40
  10. package/dist/identity-key.js.map +1 -1
  11. package/dist/index.d.ts +3 -2
  12. package/dist/index.js +6 -4
  13. package/dist/index.js.map +1 -1
  14. package/dist/relay-stun.d.ts +24 -0
  15. package/dist/relay-stun.js +51 -0
  16. package/dist/relay-stun.js.map +1 -0
  17. package/dist/seed-bootstrap.js +4 -4
  18. package/dist/strand-membership-writer.js +32 -32
  19. package/package.json +2 -2
  20. package/src/cadre-node.ts +8654 -8753
  21. package/src/control-authorization.ts +117 -117
  22. package/src/control-cohort.ts +128 -128
  23. package/src/control-formation-recorder.ts +353 -353
  24. package/src/control-read-retry.ts +216 -216
  25. package/src/control-schema.ts +1078 -1078
  26. package/src/control-write-retry.ts +567 -567
  27. package/src/diagnostics/connection-path.ts +1 -29
  28. package/src/formation-approval.ts +610 -610
  29. package/src/hibernation-manager.ts +638 -638
  30. package/src/identity-key.ts +7 -62
  31. package/src/index.ts +644 -647
  32. package/src/invitation-bootstrap.ts +40 -40
  33. package/src/joined-strand-store.ts +498 -498
  34. package/src/link-budget.ts +684 -684
  35. package/src/membership-connection-gater.ts +570 -570
  36. package/src/node-local-snapshot.ts +298 -298
  37. package/src/peer-dial.ts +315 -315
  38. package/src/peer-join-backfill.ts +710 -710
  39. package/src/pending-join-runner.ts +735 -735
  40. package/src/relay-reservation.ts +1121 -1121
  41. package/src/relay-server.ts +98 -98
  42. package/src/relay-stun.ts +58 -0
  43. package/src/seed-bootstrap.ts +1448 -1448
  44. package/src/strand-addr-protocol.ts +547 -547
  45. package/src/strand-database.ts +287 -287
  46. package/src/strand-first-sync-gate.ts +391 -391
  47. package/src/strand-formation-deadlines.ts +227 -227
  48. package/src/strand-formation-manager.ts +725 -725
  49. package/src/strand-fret-addrs.ts +145 -145
  50. package/src/strand-instance-manager.ts +1789 -1789
  51. package/src/strand-membership-writer.ts +1974 -1974
  52. package/src/strand-network-state-file.ts +40 -40
  53. package/src/strand-network-state.ts +243 -243
  54. package/src/strand-revocation-enforcer.ts +654 -654
  55. package/src/strand-solicitation.ts +532 -532
  56. package/src/strand-wake-protocol.ts +401 -401
  57. package/src/types.ts +2222 -2222
  58. package/dist/diagnostics/webrtc-turn-tracker.d.ts +0 -50
  59. package/dist/diagnostics/webrtc-turn-tracker.js +0 -196
  60. package/dist/diagnostics/webrtc-turn-tracker.js.map +0 -1
  61. package/src/diagnostics/webrtc-turn-tracker.ts +0 -222
package/src/types.ts CHANGED
@@ -1,2222 +1,2222 @@
1
- import type { ConnectionGater, Libp2p, PeerId, PrivateKey } from '@libp2p/interface';
2
- import type { CircuitRelayServerInit } from '@libp2p/circuit-relay-v2';
3
- import type { IRawStorage, Libp2pConnectionMonitorInit, Libp2pTransports, NoiseCryptoInterface } from '@optimystic/db-p2p';
4
- import type { IPeerNetwork, IRepo } from '@optimystic/db-core';
5
- import type { PeerJoinBackfillConfig } from './peer-join-backfill.js';
6
- import type { StrandRevocationEnforcementConfig } from './strand-revocation-enforcer.js';
7
- import type { StrandMembershipReconciliationConfig } from './strand-membership-reconciler.js';
8
- import type { StrandFirstSyncConfig } from './strand-first-sync-gate.js';
9
- import type { StrandDatabase } from './strand-database.js';
10
- import type { SeedTrustPolicy } from './seed-trust-policy.js';
11
- import type { KeyStore, KeyId } from './key-store.js';
12
- import type { TrustedOwnerStore, TrustSource } from './trusted-owner-store.js';
13
- import type { BootstrapPeerStore } from './bootstrap-peer-store.js';
14
- import type { StrandNetworkStateStore } from './strand-network-state.js';
15
- import type { EnrolledMachineStore } from './enrolled-machine-store.js';
16
- import type { JoinedStrandStore } from './joined-strand-store.js';
17
- import type { PushNotifier } from './push-notifier.js';
18
- import type { RevocableTable } from './control-authorization.js';
19
- import type { ControlRetryAbandonment } from './control-retry.js';
20
- import type { FormationRejectionCode } from './strand-formation-rejection.js';
21
-
22
- /**
23
- * Extended Libp2p node with the coordinatedRepo attached by db-p2p's
24
- * createLibp2pNode after node creation (not surfaced on the base Libp2p type).
25
- */
26
- export interface Libp2pNodeWithRepo extends Libp2p {
27
- coordinatedRepo: IRepo;
28
- /**
29
- * The node's key-addressed peer network, attached by db-p2p's factory alongside
30
- * `coordinatedRepo` (`libp2p-node-base.ts`). Optional deliberately: the base
31
- * factory always assigns it, but it is not part of the upstream node type, so
32
- * declaring it required would be a claim this repo cannot enforce. The strand
33
- * backfill (`peer-join-backfill.ts`) logs once and stays inert when it is absent.
34
- */
35
- keyNetwork?: IPeerNetwork;
36
- }
37
-
38
- /**
39
- * Node profile determines storage participation
40
- */
41
- export type NodeProfile = 'transaction' | 'storage';
42
-
43
- /**
44
- * Strand filter configuration - determines which strands this node participates in
45
- */
46
- export type StrandFilter =
47
- | { mode: 'all' } // All strands (default for servers)
48
- | { mode: 'sAppId'; sAppId: string } // Only strands for specific sApp
49
- | { mode: 'strandId'; strandId: string } // Single specific strand
50
- | { mode: 'none' }; // Control network only
51
-
52
- /**
53
- * Latency hint for strand hibernation behavior
54
- */
55
- export type LatencyHint = 'realtime' | 'interactive' | 'background' | 'archive';
56
-
57
- /**
58
- * Hibernation timeout configuration per latency hint (in milliseconds)
59
- */
60
- export interface HibernationTimeouts {
61
- /** Time before transitioning from active to idle */
62
- idleTimeout: number;
63
- /** Time before transitioning from idle to hibernating */
64
- hibernateTimeout: number;
65
- /**
66
- * Base interval for the FIRST check-in after hibernating. Successive
67
- * no-activity check-ins escalate this delay by {@link checkInBackoffFactor}
68
- * (see {@link HibernationManager}'s self-rescheduling check-in chain).
69
- */
70
- checkInInterval: number;
71
- /**
72
- * Multiplier applied to the check-in delay after each check-in that finds no
73
- * pending activity (must be >= 1; 1 disables escalation). Default 2 →
74
- * doubling: base, base×2, base×4, … until {@link checkInMaxInterval}.
75
- */
76
- checkInBackoffFactor: number;
77
- /**
78
- * Upper bound on the escalating check-in delay — the concrete realization of
79
- * the architecture's "minutes → hours → days" ceiling for this hint. Backoff
80
- * never schedules a delay longer than this.
81
- */
82
- checkInMaxInterval: number;
83
- }
84
-
85
- /**
86
- * Default hibernation timeouts per latency hint.
87
- *
88
- * `checkInInterval` is the BASE delay; `checkInMaxInterval` is the per-hint
89
- * ceiling the exponential backoff escalates toward (interactive minutes→~1h,
90
- * background minutes→~6h, archive ~1h→~3 days).
91
- */
92
- export const HIBERNATION_TIMEOUTS: Record<LatencyHint, HibernationTimeouts> = {
93
- realtime: {
94
- idleTimeout: Infinity, // Never idle
95
- hibernateTimeout: Infinity, // Never hibernate
96
- checkInInterval: Infinity, // N/A
97
- checkInBackoffFactor: 2,
98
- checkInMaxInterval: Infinity // N/A
99
- },
100
- interactive: {
101
- idleTimeout: 5 * 60 * 1000, // 5 minutes
102
- hibernateTimeout: 15 * 60 * 1000, // 15 minutes after idle
103
- checkInInterval: 30 * 1000, // base: 30 seconds
104
- checkInBackoffFactor: 2,
105
- checkInMaxInterval: 60 * 60 * 1000 // cap: ~1 hour
106
- },
107
- background: {
108
- idleTimeout: 1 * 60 * 1000, // 1 minute
109
- hibernateTimeout: 5 * 60 * 1000, // 5 minutes after idle
110
- checkInInterval: 5 * 60 * 1000, // base: 5 minutes
111
- checkInBackoffFactor: 2,
112
- checkInMaxInterval: 6 * 60 * 60 * 1000 // cap: ~6 hours
113
- },
114
- archive: {
115
- idleTimeout: 10 * 1000, // 10 seconds
116
- hibernateTimeout: 30 * 1000, // 30 seconds after idle
117
- checkInInterval: 60 * 60 * 1000, // base: 1 hour
118
- checkInBackoffFactor: 2,
119
- checkInMaxInterval: 3 * 24 * 60 * 60 * 1000 // cap: ~3 days
120
- }
121
- };
122
-
123
- /**
124
- * Storage provider - either an `IRawStorage` instance or a factory function.
125
- * Factory functions are useful for creating per-scope storage instances.
126
- *
127
- * **The argument is an opaque scope key.** Use it directly as a file name, directory
128
- * name or database name: every key stays within `[a-z0-9._-]`, so no escaping is
129
- * needed and none should be assumed, and two different keys are two different names
130
- * even on a filesystem that ignores case (Windows, macOS). Do not parse it;
131
- * `controlStorageScope` / `isControlStorageScope` (`storage-scope.ts`) are the supported
132
- * way to mint and recognize the control key. The control key holds the charset by
133
- * lowercase hex encoding;
134
- * a strand's key — its strand id, which may have replicated in from another node in
135
- * the party — holds it because `StrandInstanceManager.startStrand` runs
136
- * `assertStrandScopeKey` on every launch and refuses a strand that fails it.
137
- *
138
- * **Called once per scope per runtime lifetime.** The scopes are the control database
139
- * (once per `CadreNode.start()`) and each strand id (once per `startStrand`).
140
- * Hibernation — `quiesceStrand` then `resumeStrand` — reuses the store already
141
- * resolved for that strand and does NOT re-enter this callback.
142
- *
143
- * **The control scope is party-specific.** It is `controlStorageScope(partyId)`, not a
144
- * fixed string, because the control database holds one party's own records — its
145
- * strands, owner keys, peers, invitations, revocations. Two parties on one device ask
146
- * for two different keys and MUST get two different stores, or a node started for one
147
- * party reads the other's rows as its own.
148
- *
149
- * It IS re-entered for a scope after that scope's runtime has stopped
150
- * (`stopStrand`, or a `stop()` then `start()` cycle on one `CadreNode`), so it must
151
- * be able to hand back a store over the same durable backend a second time. A
152
- * factory may mint a fresh object per call; it must not *need* to.
153
- *
154
- * cadre-core disposes only its own cache wrapper — it never closes the store you
155
- * returned. Closing the underlying handle stays the embedder's job.
156
- *
157
- * **The single-instance form shares data across scopes by construction** — one store
158
- * for every strand AND for every party's control database. An embedder that can serve
159
- * more than one party must use the factory form. One instance handed to every scope
160
- * also shares ONE cache wrapper (`wrapStorageWithCache` memoizes per inner instance),
161
- * which counts its holders: a scope stopping releases only its own claim, and the
162
- * wrapper is retired when the last scope releases it. The factory form is the better
163
- * default for the further reason that it partitions each strand's data, which
164
- * simplifies cleanup.
165
- */
166
- export type RawStorageProvider = IRawStorage | ((scope: string) => IRawStorage);
167
-
168
- /**
169
- * Storage configuration for storage profile nodes
170
- */
171
- export interface StorageConfig {
172
- /**
173
- * Storage provider - either an IRawStorage instance or a factory function.
174
- * See {@link RawStorageProvider} for how often cadre-core calls it, and for why
175
- * the single-instance form cannot serve more than one party.
176
- *
177
- * The `scope` a factory receives is an opaque key already safe as a path or
178
- * database-name segment (always within `[a-z0-9._-]`), so each example below
179
- * interpolates it directly.
180
- *
181
- * For Node.js environments, use FileRawStorage from @optimystic/db-p2p-storage-fs:
182
- * ```typescript
183
- * import { FileRawStorage } from '@optimystic/db-p2p-storage-fs';
184
- * storage: { provider: (scope) => new FileRawStorage(`./data/${scope}`) }
185
- * ```
186
- *
187
- * For React Native, use the appropriate storage from @optimystic/db-p2p-storage-rn:
188
- * ```typescript
189
- * import { LevelDBRawStorage } from '@optimystic/db-p2p-storage-rn';
190
- * // `db` is an open LevelDB handle (see `openOptimysticRNDb` over `rn-leveldb`)
191
- * storage: { provider: (scope) => new LevelDBRawStorage(openDb(`sereus-${scope}`)) }
192
- * ```
193
- *
194
- * For in-memory storage (testing):
195
- * ```typescript
196
- * import { MemoryRawStorage } from '@optimystic/db-p2p';
197
- * storage: { provider: () => new MemoryRawStorage() }
198
- * ```
199
- */
200
- provider: RawStorageProvider;
201
- quotaBytes?: number;
202
- }
203
-
204
- /**
205
- * Network configuration for libp2p
206
- */
207
- export interface NetworkConfig {
208
- /**
209
- * Addresses this machine's libp2p nodes bind. Omitted, `@optimystic/db-p2p`'s own
210
- * default (`/ip4/0.0.0.0/tcp/0`) applies; `[]` means "cannot listen" and is honored
211
- * as written (React Native hosts rely on that).
212
- *
213
- * The machine runs one control node plus one node per strand, so a FIXED port here
214
- * can only belong to one of them. The control node binds it as configured; each
215
- * strand node binds the same entry with its port rewritten to `0`, keeping the
216
- * interface and transport (`/ws`, a specific-interface bind). A
217
- * `<relay>/p2p-circuit` entry is untouched — the port inside it is the relay's, not
218
- * a local bind. See `strand-network-config.ts`.
219
- *
220
- * **Only TCP, WebSocket (`/ws`, `/wss`) and circuit-relay addresses are bindable**
221
- * unless {@link transports} supplies the factories for something else. A `/ws` entry
222
- * switches the WebSocket transport on by itself; anything else — `/quic-v1`,
223
- * `/webrtc`, `/webtransport` — refuses node start naming the address and the libp2p
224
- * package it would need, rather than being dropped in silence by libp2p's transport
225
- * manager. See `relay-addrs.ts` → `resolveTransportOptions`.
226
- */
227
- listenAddrs?: string[];
228
- /**
229
- * Addresses to advertise to peers **instead of** `listenAddrs` — for a node behind
230
- * NAT or a reverse proxy that is reachable at a different address than it binds
231
- * (e.g. `mynode.example.com:4001` in front of a `0.0.0.0:4001` listener). Reaches
232
- * libp2p's `addresses.announce` via `@optimystic/db-p2p`'s `NodeOptions.announceAddrs`.
233
- *
234
- * **Applies to this machine's CONTROL node only.** Strand nodes drop it: any concrete
235
- * entry names a port, and that port is the control node's, so a strand node
236
- * advertising it would send peers to the wrong node (`strand-network-config.ts`).
237
- * Entries are still validated at node start whichever node ends up using them.
238
- *
239
- * **A non-empty value REPLACES the advertised set entirely.** Observed addresses,
240
- * and the `/p2p-circuit` address earned from a {@link relayAddrs} reservation, are
241
- * all dropped from what peers are told — so a node configured with both this and
242
- * `relayAddrs` stops being reachable through its relay. `CadreNode.start()` warns
243
- * when it sees that combination; it does not refuse it, since a node whose relay
244
- * slot is decorative may legitimately want only the announced address.
245
- *
246
- * Reach for {@link appendAnnounceAddrs} instead in the common case — one publicly
247
- * reachable address ADDED to everything else the node advertises. Or for
248
- * {@link relayAddrs} alone, when the node has no stable public address to name and
249
- * a relay reservation is what makes it dialable. (`inviteAddressResolver` is a
250
- * narrower tool again: it substitutes a reachable address into invite payloads
251
- * only, and changes nothing about what this node advertises on the wire.)
252
- *
253
- * An empty array means "unset" — it is dropped rather than forwarded, so it cannot
254
- * land as an explicit empty announce set. An empty *entry* is a different thing and
255
- * is rejected: a list of one blank string is non-empty, so it would replace every
256
- * advertised address with nothing. Malformed entries are rejected on the same terms,
257
- * both at node start — libp2p itself would not notice either until its first address
258
- * lookup (see `announce-addrs.ts`), so this repo checks each entry up front.
259
- */
260
- announceAddrs?: string[];
261
- /**
262
- * Addresses to advertise **in addition to** `listenAddrs` — the field most
263
- * deployments actually want, since it makes a node reachable at a public address
264
- * without discarding the observed and `/p2p-circuit` addresses it would otherwise
265
- * advertise. Reaches libp2p's `addresses.appendAnnounce` via
266
- * `@optimystic/db-p2p`'s `NodeOptions.appendAnnounceAddrs`.
267
- *
268
- * **Control node only**, on the same terms as {@link announceAddrs}.
269
- *
270
- * **Ignored while {@link announceAddrs} is non-empty** — that is libp2p's own
271
- * precedence, applied upstream; this repo does not merge the two locally. Set one
272
- * or the other, not both.
273
- *
274
- * Empty-array, blank-entry and malformed-entry handling all match {@link announceAddrs}.
275
- */
276
- appendAnnounceAddrs?: string[];
277
- /**
278
- * Circuit-relay servers this node reserves a slot on, so peers can reach it
279
- * from behind NAT. Each entry is the relay's DIRECT dial multiaddr ending in
280
- * its peerId — `/dns4/relay.example.com/tcp/4001/p2p/12D3KooW…` — with no
281
- * `/p2p-circuit` suffix (one is appended for you; an entry that already has
282
- * one is passed through unchanged).
283
- *
284
- * On the CONTROL node this adds libp2p's bare `/p2p-circuit` SEARCH listener to
285
- * {@link listenAddrs} (`relay-addrs.ts`) and `CadreNode.start()` drives the
286
- * reservation itself once the control database is up — the ordering matters,
287
- * because a listener that dials its relay from inside `libp2p.start()` put a
288
- * sibling in this node's cohort before its own database existed, and a sibling
289
- * that had not yet replicated this node's membership row refused the bring-up.
290
- * STRAND nodes take the same search shape, one bare listener plus one reservation
291
- * supervisor PER relay, fail-soft (`strand-network-config.ts`,
292
- * `strand-instance-manager.ts`). A relay named here also becomes a
293
- * delegate-announce target, so this node's strand nodes may reserve on it too
294
- * (see `delegate-admission.ts`).
295
- *
296
- * Setting this gives the node a listener even when `listenAddrs` is `[]` —
297
- * that is the point of naming a relay, but note that React Native hosts
298
- * (`reference-app-rn`'s `cadre-phone.ts`) deliberately do NOT listen, and
299
- * must not acquire a listener by accident.
300
- *
301
- * FAIL-FAST both ways: a malformed entry throws at node start rather than being
302
- * silently dropped, and — while {@link requireRelay} stays at its default —
303
- * a first reservation attempt that lands no `/p2p-circuit` address throws
304
- * `RelayReservationFailedError` out of `start()`. The fail-soft posture over
305
- * the same machinery is `CadreNode.reserveRelays()`.
306
- */
307
- relayAddrs?: string[];
308
- /**
309
- * Must a relay named in {@link relayAddrs} have granted a reservation before
310
- * `start()` is allowed to succeed? Default `true`: an operator who names a
311
- * relay is telling this machine it has no other reachability, so a relay
312
- * that will not have us on the first attempt should stop the boot loudly
313
- * rather than leave a node nobody can reach.
314
- *
315
- * Set `false` for a node that must still boot with no network — a phone or a
316
- * browser tab. The first attempt is still driven at the same point in
317
- * `start()`; a lost reservation just logs instead of throwing, and the
318
- * retry supervisor keeps trying in the background exactly as it does after a
319
- * `CadreNode.reserveRelays()` call. A caller on this posture cannot infer
320
- * dialability from `start()` resolving — read
321
- * {@link CadreNode.getRelayReservationState} to find out.
322
- *
323
- * It buys a node that BOOTS, not a node that boots fast: `start()` still waits
324
- * out that first attempt, which costs the drive's whole timeout
325
- * (`DEFAULT_RELAY_RESERVE_TIMEOUT_MS`, four link round trips at
326
- * {@link linkRoundTripMs} plus two admission decisions — 18 s at its default) against a relay
327
- * that is unreachable
328
- * rather than merely refusing (`relay-reservation.ts` polls to the deadline, in
329
- * case libp2p's own discovery lands a reservation independently).
330
- *
331
- * Softens only the RESERVATION half of {@link relayAddrs}'s fail-fast
332
- * contract. A malformed `relayAddrs` entry still throws at config
333
- * resolution regardless of this setting — a typo is an operator error
334
- * whatever the posture — and a hand-written `<relay>/p2p-circuit` entry in
335
- * {@link listenAddrs} is still rejected on the control node, since that
336
- * rejection is about the listener shape, not about whether reachability is
337
- * required.
338
- *
339
- * Has no effect on STRAND nodes, which are already fail-soft over
340
- * `relayAddrs` regardless of this field (`strand-network-config.ts`,
341
- * `strand-instance-manager.ts`).
342
- */
343
- requireRelay?: boolean;
344
- /**
345
- * Enable circuit relay server - allows this node to relay connections for other peers.
346
- * When undefined, defaults to true for storage profile nodes (they typically have
347
- * better connectivity and uptime), false for transaction profile nodes.
348
- */
349
- enableRelay?: boolean;
350
- /**
351
- * Settings for this machine's circuit-relay SERVER, handed to `@libp2p/circuit-relay-v2`'s
352
- * `circuitRelayServer(...)` (through db-p2p's `NodeOptions.relayServerInit`) on the control
353
- * node and on every strand node. Only meaningful while {@link enableRelay} is on.
354
- *
355
- * Omitted, a party-run relay FORWARDS WITHOUT LIMIT: `reservations.applyDefaultLimit` is
356
- * `false`, where libp2p's own default caps every relayed connection at 128 KiB or two
357
- * minutes and resets it — which cut off any database sync or chat history forwarded
358
- * through a party's own always-on machine. The store also holds
359
- * `PARTY_RELAY_MAX_RESERVATIONS` (128) reservations instead of libp2p's 15, and each lives
360
- * `PARTY_RELAY_RESERVATION_TTL_MS` (2 h) unrefreshed. See `relay-server.ts`.
361
- *
362
- * A value is MERGED over those defaults, not substituted for them: `reservations` key by
363
- * key, every other top-level key as given. Setting only `reservations.maxReservations`
364
- * keeps the limit off; `reservations: { applyDefaultLimit: true }` is how to turn libp2p's
365
- * cap back on.
366
- *
367
- * The accepted tradeoff of the unlimited default: once a slot is granted, nothing caps what
368
- * is forwarded through it, and a holder that keeps refreshing keeps it indefinitely — the
369
- * 2 h lifetime only reclaims a slot whose holder went away. On the control node a peer it
370
- * cannot place as a party member is bounded by COUNT ({@link unauthorizedRelayReservationCap}).
371
- * A strand node's relay server has no such budget (its peers are cross-party by design), so
372
- * any peer that reaches it is bounded only by `maxReservations`.
373
- */
374
- relayServerInit?: CircuitRelayServerInit;
375
- /**
376
- * Cap on concurrent circuit-relay reservations this node's relay server grants to
377
- * peers it cannot (yet) recognize as authorized members — the boot-ordering window
378
- * where a genuine member reserves its relay slot before its membership row has
379
- * replicated here (see `membership-connection-gater.ts` → "The relay-reservation
380
- * seam"). Authorized members and announced delegate peers are never counted against
381
- * it. Default `MAX_UNAUTHORIZED_RELAY_RESERVATIONS` (8); 0 refuses every
382
- * unauthorized reservation. Only meaningful while {@link enableRelay} is on.
383
- */
384
- unauthorizedRelayReservationCap?: number;
385
- /**
386
- * Custom libp2p transports. When omitted, the default transports from
387
- * `@optimystic/db-p2p` are used (TCP + circuit relay for Node.js).
388
- *
389
- * React Native apps must supply WebSocket-based transports because TCP
390
- * is not available in the RN runtime:
391
- * ```typescript
392
- * import { webSockets } from '@libp2p/websockets';
393
- * import { circuitRelayTransport } from '@libp2p/circuit-relay-v2';
394
- *
395
- * network: {
396
- * transports: [webSockets(), circuitRelayTransport()],
397
- * listenAddrs: [] // RN nodes typically cannot listen
398
- * }
399
- * ```
400
- */
401
- transports?: Libp2pTransports;
402
- /**
403
- * Crypto primitives for the Noise handshake and the encrypted connection that
404
- * follows, threaded to both the control node and every strand's cohort node — every
405
- * node pays the handshake, so one setting covers them all. When omitted, libp2p-noise
406
- * picks its own default.
407
- *
408
- * React Native is the reason this exists: Metro resolves `@chainsafe/libp2p-noise`'s
409
- * browser build, whose default is pure-JS crypto, and on Hermes (no JIT) that
410
- * dominates connection setup on a slow phone. An app with native crypto supplies it
411
- * here. It must implement every member of the interface; the usual shape spreads
412
- * `noisePureJsCrypto` and overrides the hashing and ChaCha20-Poly1305 functions:
413
- * ```typescript
414
- * import { noisePureJsCrypto } from '@optimystic/db-p2p';
415
- *
416
- * network: {
417
- * noiseCrypto: { ...noisePureJsCrypto, hashSHA256: nativeSha256, chaCha20Poly1305Encrypt: …, chaCha20Poly1305Decrypt: … }
418
- * }
419
- * ```
420
- *
421
- * Only local primitives change — the wire protocol does not, so a node with native
422
- * crypto interoperates with one without. See `@optimystic/db-p2p`'s
423
- * `NodeOptions.noiseCrypto`.
424
- */
425
- noiseCrypto?: NoiseCryptoInterface;
426
- /**
427
- * libp2p's connection monitor — the liveness ping it runs on every connection — for
428
- * the control node and every strand node, as {@link noiseCrypto} is. When omitted,
429
- * cadre-core applies {@link DEFAULT_CONNECTION_MONITOR} rather than leaving libp2p's
430
- * own defaults in place; an explicit value REPLACES that default wholesale, so `{}`
431
- * is how an app asks for libp2p's stock behaviour back.
432
- *
433
- * Typed from `@optimystic/db-p2p`'s re-export of libp2p's `ConnectionMonitorInit`, so
434
- * an app does not need a direct `libp2p` dependency, and handed to db-p2p's
435
- * `NodeOptions.connectionMonitor` unchanged.
436
- *
437
- * An app that widens the ping deadline itself must raise `pingInterval` above it in
438
- * the same object, or libp2p aborts the connection on the second overlapping ping —
439
- * see {@link DEFAULT_CONNECTION_MONITOR} for why.
440
- */
441
- connectionMonitor?: Libp2pConnectionMonitorInit;
442
- /**
443
- * How long ONE cohort peer gets to answer ONE read-path request, in milliseconds, for the
444
- * control node and every strand node — as {@link connectionMonitor} is, and for the same
445
- * reason: the setting describes the LINK, and a phone's control node and its strand nodes
446
- * ride the same one. Omitted, the deadline is DERIVED from {@link linkRoundTripMs}: one
447
- * request and its answer over an open circuit costs two link round trips, so 7 000 ms at the
448
- * default declaration (`cohortReadDeadlineMs` in `link-budget.ts`; the plugin's
449
- * {@link COHORT_READ_DEADLINE_MS} is the same number). Optimystic's own default is 1000 ms.
450
- *
451
- * Set this only to break the derivation on purpose: an explicit value wins over whatever
452
- * {@link linkRoundTripMs} would derive, so a deployment that is all LAN can make a departed
453
- * peer stop holding up a read sooner without declaring a faster link (which would also
454
- * shorten the dial budgets). A deployment on a slower link should raise
455
- * {@link linkRoundTripMs} instead, which moves this and every dial budget together. The
456
- * cost of a larger value is that a peer which is truly gone holds a read of a block missing
457
- * locally for that long before the read is declined and retried, and a joining machine's
458
- * first sync runs several such consults — so a change here should be weighed against
459
- * {@link CadreNodeConfig.strandFirstSync}'s budget. The derivation, its history and what it
460
- * costs are on {@link COHORT_READ_DEADLINE_MS}.
461
- *
462
- * Handed to db-p2p's `clusterPolicy.cohortQueryTimeoutMs` unchanged and NOT re-validated
463
- * here. Optimystic refuses a value that is not a finite number above zero, or is above its
464
- * `MAX_COHORT_QUERY_TIMEOUT_MS` (about 4.97 days — the ceiling exists because a unit mix-up
465
- * is the one way to exceed it), by throwing where the libp2p node is built: inside
466
- * `CadreNode.start()` for the control network, and inside `CadreNode.addStrand` for a strand.
467
- * Fractional values are accepted — this is a duration, not a count of peers.
468
- */
469
- cohortQueryTimeoutMs?: number;
470
- /**
471
- * The round trip this node assumes between itself and another machine, in milliseconds, for
472
- * the control node and every strand node — as {@link cohortQueryTimeoutMs} is, and for the
473
- * same reason: the setting describes the LINK, and a phone's control node and its strand
474
- * nodes ride the same one. Omitted takes {@link DECLARED_LINK_ROUND_TRIP_MS} (3500 ms).
475
- *
476
- * This is NOT a timeout. It is the one stated assumption that cadre's own dial and
477
- * reservation deadlines are DERIVED from, each by the number of round trips that operation
478
- * was measured to cost: a peer-join catch-up's dial to one peer, its push response, one relay
479
- * reservation drive, the control-cohort dial budgets, and the per-peer cohort read deadline
480
- * ({@link cohortQueryTimeoutMs}, unless set explicitly). It is also handed to Optimystic
481
- * (`NodeOptions.linkRoundTripMs`), default included, which derives its own deadlines from it:
482
- * its request dials and responses, its block pushes, and libp2p's `addressDialTimeout`,
483
- * `dialTimeout` and `inboundUpgradeTimeout` on every node, cadre adding its admission decisions
484
- * to the three that open a connection. Reaching another machine through a
485
- * relay costs a fixed number of exchanges, so a deadline written as milliseconds has a link
486
- * speed above which it can never open a connection — which is the defect this declaration
487
- * exists to make impossible to reintroduce one budget at a time. The counts, the measurement behind them,
488
- * and what still fails at the supported link are in `link-budget.ts`.
489
- *
490
- * The default covers the slowest link sereus supports, a 3-second round trip through a relay.
491
- * Raise it for a link slower still; the cost is the ordinary cost of longer deadlines, a peer
492
- * that is genuinely gone holding each operation that much longer before it is abandoned and
493
- * retried. Lower it only if EVERY machine of the party is that close: this node is also the
494
- * listener for connections other machines open to it, and its `inboundUpgradeTimeout` comes
495
- * from this value, so a node declaring a faster link than its peers discards their
496
- * half-built connections — silently, from the dialer's side.
497
- *
498
- * Refused where the libp2p node is built — inside `CadreNode.start()` for the control
499
- * network, inside `CadreNode.addStrand` for a strand — if it is not a finite number above
500
- * zero, because every consumer multiplies it into a deadline where a zero means "give up at
501
- * once" and a `NaN` means "never". A value above about 10.8 hours is refused in the same place,
502
- * by Optimystic: the request dial cadre states from it (eleven round trips plus two admission
503
- * decisions, `optimysticDialLimits` in `link-budget.ts`) then exceeds its
504
- * `MAX_RPC_DIAL_TIMEOUT_MS` (about 4.97 days), and its error names `rpcDeadlines.dialTimeoutMs`.
505
- * That is below Optimystic's own `MAX_LINK_ROUND_TRIP_MS` (about 13.3 hours) and far above any
506
- * real link, so cadre adds no check of its own.
507
- */
508
- linkRoundTripMs?: number;
509
- /**
510
- * Optional async resolver returning the multiaddrs to embed in invites
511
- * (and other owner-address contexts). When unset, `libp2pNode.getMultiaddrs()`
512
- * is used. Hosts behind NAT supply this to substitute their DDNS hostname
513
- * and externally-mapped port — see `@serfab/cadre-host`'s NatService.
514
- *
515
- * Returned addresses need NOT carry a `/p2p/<peerId>` suffix: `CadreNode`
516
- * appends its own before publishing or dialing any of them, so a resolver
517
- * cannot leave a sibling with the mixed suffixed/unsuffixed list that
518
- * `libp2p.dial` rejects. Returning them already suffixed is equally fine.
519
- */
520
- inviteAddressResolver?: () => Promise<string[]>;
521
- /**
522
- * Optional libp2p connection gater, threaded to both the control node and every
523
- * strand's cohort node. libp2p's browser default denies dialing insecure
524
- * WebSockets and private/loopback addresses; callers that must dial local or
525
- * unsecured peers (the web reference dialing a `127.0.0.1/.../ws` responder,
526
- * Playwright e2e, RN simulators) supply a permissive gater here — e.g.
527
- * `{ denyDialMultiaddr: () => false }`. See `@optimystic/db-p2p`'s
528
- * `libp2p-node-base` for the underlying option.
529
- *
530
- * On the CONTROL node this gater is composed with the built-in membership
531
- * admission gate (`membership-connection-gater.ts`): every hook supplied here
532
- * is honored unchanged, and on inbound encrypted connections a deny from
533
- * either this gater or the membership policy denies.
534
- *
535
- * Strand cohort nodes: an OPEN strand's node receives this gater as-is (its
536
- * peers are legitimately cross-party). A CLOSED strand's node composes
537
- * revoked-peer denial onto it (`strand-revocation-enforcer.ts`) — every hook
538
- * supplied here is still honored unchanged, and on the composed hooks
539
- * (inbound encrypted connection, outbound peer dial, relay reservation) a
540
- * deny from either this gater or the revocation check denies.
541
- */
542
- connectionGater?: ConnectionGater;
543
- /**
544
- * Tuning for the proactive control-cohort dial routine
545
- * ({@link CadreNode.reconcileControlCohort}), which keeps a party's control
546
- * nodes connected so the `CadreControl` collections form a replicating cohort.
547
- * Every field is optional; omit for the defaults
548
- * ({@link DEFAULT_CONTROL_COHORT_TARGET_DEGREE} /
549
- * {@link DEFAULT_CONTROL_COHORT_RECONCILE_MS} /
550
- * {@link DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS} /
551
- * {@link DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS} / `STRAND_PEER_ADDR_REFRESH_MS`).
552
- */
553
- controlCohort?: {
554
- /**
555
- * Cap on the number of NON-owner siblings dialed per reconcile pass
556
- * (backbone/owner members are always dialed and do not count). Defaults
557
- * to {@link DEFAULT_CONTROL_COHORT_TARGET_DEGREE}.
558
- */
559
- targetDegree?: number;
560
- /**
561
- * Recurring reconcile cadence in ms. Defaults to
562
- * {@link DEFAULT_CONTROL_COHORT_RECONCILE_MS}.
563
- */
564
- reconcileMs?: number;
565
- /**
566
- * Limit on dialing ONE peer — all of its candidate addresses together — in
567
- * ms. Bounds a reconcile pass at (dialed siblings) × this. The same limit
568
- * applies to the owner dials of `applySeed` and to `dialInvite`. Defaults to
569
- * {@link DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS}.
570
- */
571
- dialTimeoutMs?: number;
572
- /**
573
- * Limit on ONE candidate address's dial attempt, in ms, inside
574
- * {@link dialTimeoutMs}. Each address is dialed on its own under this limit,
575
- * so an address that never answers cannot use up the time the peer's other
576
- * addresses needed. Defaults to
577
- * {@link DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS}. A value given
578
- * here replaces that whole derived budget, including its allowances for the
579
- * relay's and the called machine's admission decisions (`relayedDialBudgetMs`).
580
- */
581
- perAddressDialTimeoutMs?: number;
582
- /**
583
- * How long a running strand waits, after a sibling answers its strand-addr
584
- * RPC, before asking that sibling again for its strand-network addresses — a
585
- * step of the reconcile pass, scheduled per (sibling, strand). A sibling that
586
- * did not answer is retried sooner, on `STRAND_PEER_ADDR_RETRY_MS` (or this
587
- * interval, when it is set shorter). Defaults to
588
- * `STRAND_PEER_ADDR_REFRESH_MS` (10 min); must stay well under the peerStore's
589
- * one-hour address expiry.
590
- */
591
- strandAddrRefreshMs?: number;
592
- };
593
- }
594
-
595
- /**
596
- * The connection-monitor settings every cadre node runs with when
597
- * {@link NetworkConfig.connectionMonitor} is unset: a 30 second ping deadline in place
598
- * of libp2p's 5 second one, and a 35 second gap between pings so a ping is never
599
- * outstanding when the next one starts.
600
- *
601
- * WHY this is a default and not opt-in. libp2p's monitor pings every connection and,
602
- * with its own `abortConnectionOnPingFailure: true`, aborts the connection on the FIRST
603
- * timeout. A peer whose event loop is saturated by pure-JS Noise crypto — a slow phone
604
- * under React Native, see {@link NetworkConfig.noiseCrypto} — misses that deadline while
605
- * perfectly healthy; the other end aborts, the peer redials, and the new handshake
606
- * saturates it further. Measured on gotchoices/sereus#13 at a Galaxy S7's crypto cost, a
607
- * two-party bring-up failed 3 of 3 runs on stock settings, with 30 `aborting connection
608
- * due to ping failure` entries in the relay's log for one run, and passed 4 of 4 in about
609
- * 90 seconds once the deadline was widened on every node. The monitor runs on BOTH ends
610
- * of a connection and either end's abort closes it for both, so a setting the phone alone
611
- * applies does not cover the peer dropping it. That is what rules out scoping this to
612
- * React Native.
613
- *
614
- * WHY `pingInterval` MOVES WITH THE DEADLINE, and is not left at libp2p's 10 seconds.
615
- * The monitor opens a ping stream per connection per interval whether or not the previous
616
- * ping has answered, and `/ipfs/ping/1.0.0` is registered by `@libp2p/ping` with
617
- * `maxOutboundStreams: 1`. A second concurrent ping stream on one connection therefore
618
- * fails in `Connection.newStream` with `TooManyOutboundProtocolStreamsError`, which
619
- * reaches the monitor's own catch and aborts the connection exactly as a timeout does. A
620
- * widened deadline alone is thus capped by the ping interval: measured against two local
621
- * libp2p 3.1.3 nodes whose ping handler answered 600ms late, an interval of 300ms with a
622
- * 900ms deadline aborted the connection, while a 900ms interval with the same deadline
623
- * kept it (the same pair aborted at a 200ms stall only when the deadline was 300ms). An
624
- * interval strictly above the deadline is what makes the 30 seconds real.
625
- *
626
- * WHAT THE LINK NEEDS OF IT. One ping opens a fresh stream and echoes over it: a protocol
627
- * negotiation plus the echo, two link round trips by `link-budget.ts`'s counts (a `newStream`
628
- * over a relayed circuit measured 3016 ms at 1500 ms one-way). At the slowest link sereus
629
- * supports, a 3-second relayed round trip, that is about 6 s — well inside the 30 s deadline,
630
- * which exists for the phone's CPU rather than the link.
631
- *
632
- * WHAT IT COSTS. A dead peer is reclaimed 30 to 65 seconds after it stops answering — the
633
- * deadline, plus up to one interval of waiting for the ping that will fail — where
634
- * libp2p's defaults took about 5 to 15 seconds. `db-p2p` caps a node at 16 connections,
635
- * so the worst case is those slots held about a minute longer than before; at that scale
636
- * it is not a starvation risk.
637
- *
638
- * WHY THE DEADLINE IS PINNED rather than given room to adapt. `pingTimeout` is an
639
- * adaptive-timeout init, and equal `minTimeout`/`maxTimeout` clamp it to one value on
640
- * every libp2p version. libp2p 3.3 reports each ping's duration back to the monitor's
641
- * `AdaptiveTimeout`, so a ceiling above `pingInterval` would let the deadline grow past the
642
- * interval and put the overlapping-ping abort above straight back. Pinning both ends keeps
643
- * the interval's margin true. It also sidesteps the monitor keeping one `AdaptiveTimeout`
644
- * for all of a node's connections, so one slow peer cannot lengthen the deadline for every
645
- * connection on that node.
646
- */
647
- export const DEFAULT_CONNECTION_MONITOR = Object.freeze({
648
- // Strictly greater than the deadline below, so the previous ping is always resolved or
649
- // aborted before the next one opens a stream. `types.spec.ts` holds the two apart.
650
- pingInterval: 35_000,
651
- // Frozen at both levels, as `CONTROL_CLUSTER_POLICY` is: one object reaches every
652
- // libp2p node this process builds, so a mutation anywhere would move the deadline
653
- // for all of them.
654
- pingTimeout: Object.freeze({ minTimeout: 30_000, maxTimeout: 30_000 })
655
- } satisfies Libp2pConnectionMonitorInit);
656
-
657
- /**
658
- * Hibernation configuration
659
- */
660
- export interface HibernationConfig {
661
- enabled: boolean;
662
- defaultLatencyHint?: LatencyHint;
663
- /** Custom timeouts per latency hint (overrides defaults) */
664
- customTimeouts?: Partial<Record<LatencyHint, Partial<HibernationTimeouts>>>;
665
- /**
666
- * How long a hibernating strand stays resumed during a check-in to let its
667
- * strand network connect to reachable cohort peers (and the app drive
668
- * pull-on-read activity) before re-hibernating if still idle. Defaults to
669
- * {@link DEFAULT_CHECKIN_WINDOW_MS}.
670
- */
671
- checkInWindowMs?: number;
672
- }
673
-
674
- /**
675
- * Default duration of the resume-and-probe window during a strand check-in.
676
- * See {@link HibernationConfig.checkInWindowMs}.
677
- */
678
- export const DEFAULT_CHECKIN_WINDOW_MS = 15 * 1000;
679
-
680
- /**
681
- * Control network configuration
682
- */
683
- export interface ControlNetworkConfig {
684
- partyId: string;
685
- bootstrapNodes: string[];
686
- /** Optional path to the control schema file (defaults to bundled schema) */
687
- schemaPath?: string;
688
- }
689
-
690
- /**
691
- * Replication-breadth constants, both networks' cluster policies, and the strand
692
- * resolver, re-exported so cadre embedders, the SQL plugin and the integration
693
- * harness share one definition. Defined in `@serfab/quereus-plugin-sereus`
694
- * because that package also creates libp2p nodes and cannot depend on this one.
695
- * The control network's breadth is fixed ({@link CONTROL_REPLICATION_BREADTH},
696
- * not configurable) and so is its consensus policy ({@link CONTROL_CLUSTER_POLICY}
697
- * — notably the super-majority threshold, which it leaves at Optimystic's default
698
- * by omission); the strand breadth defaults to
699
- * {@link CadreNodeConfig.strandClusterSize} while its policy is the fixed
700
- * {@link STRAND_CLUSTER_POLICY}.
701
- *
702
- * The two `*ClusterPolicy` BUILDERS are the same objects with the block-repair
703
- * corroboration yardstick declared from the machines enrolled in this party
704
- * ({@link resolveRepairYardstick}) and, if the host set one or declared its link, a
705
- * per-peer read deadline in place of {@link COHORT_READ_DEADLINE_MS}
706
- * (`declaredCohortReadDeadlineMs` in `link-budget.ts` settles which). Both arrive in one
707
- * named declarations object, because both are plain numbers meaning unrelated things;
708
- * declaring neither returns the frozen base constant unchanged. A network picks the
709
- * derived numbers up when its libp2p node is built, which for a strand is every wake
710
- * from hibernation.
711
- */
712
- export {
713
- MIN_CLUSTER_SIZE,
714
- COHORT_READ_DEADLINE_MS,
715
- CONTROL_REPLICATION_BREADTH,
716
- CONTROL_CLUSTER_POLICY,
717
- DEFAULT_STRAND_CLUSTER_SIZE,
718
- STRAND_CLUSTER_POLICY,
719
- resolveStrandClusterSize,
720
- resolveRepairYardstick,
721
- controlClusterPolicy,
722
- strandClusterPolicy
723
- } from '@serfab/quereus-plugin-sereus';
724
-
725
- /**
726
- * The declared link round trip every cadre-owned dial and reservation deadline is derived from,
727
- * re-exported beside {@link NetworkConfig.linkRoundTripMs} so a host reading the setting finds
728
- * the default it replaces. The counts, the measurement and the derivation live in
729
- * `link-budget.ts`.
730
- */
731
- export { DECLARED_LINK_ROUND_TRIP_MS } from './link-budget.js';
732
-
733
- /**
734
- * Main configuration for a CadreNode
735
- */
736
- export interface CadreNodeConfig {
737
- /**
738
- * If provided, use this keypair for the node identity (direct injection).
739
- * Mutually exclusive with {@link keyStore} — supplying both is a configuration
740
- * error (fail closed) thrown by `start()` before any network bring-up.
741
- */
742
- privateKey?: PrivateKey;
743
-
744
- /**
745
- * Pluggable secure store for node key material. When set, the node loads its
746
- * identity from `keyStore` under {@link identityKeyId}, generating + persisting
747
- * a fresh Ed25519 key on first run (protobuf bytes are the canonical stored
748
- * form). Mutually exclusive with {@link privateKey}. Absent ⇒ use `privateKey`
749
- * when set, else libp2p generates an ephemeral key.
750
- *
751
- * In the single-key reference model the owner signing key is *derived from*
752
- * the node identity (see {@link CadreNode.getIdentityOwnerKey}), so
753
- * protecting the identity in a secure enclave protects the owner key too.
754
- * A future separate-owner slot (`ownerKeyId`) is anticipated but not
755
- * built here.
756
- */
757
- keyStore?: KeyStore;
758
-
759
- /** Slot id for the node identity in {@link keyStore}. Default: `'cadre/identity'`. */
760
- identityKeyId?: KeyId;
761
-
762
- /** Control network connection settings */
763
- controlNetwork: ControlNetworkConfig;
764
-
765
- /** Node profile: transaction-only or storage */
766
- profile: NodeProfile;
767
-
768
- /** Which strands to participate in */
769
- strandFilter?: StrandFilter;
770
-
771
- /**
772
- * Run every strand {@link strandFilter} admits, including ones no app on this machine
773
- * has claimed with `addStrand`, as a **storage replica**: the strand's own libp2p node,
774
- * storage and `Strand` membership schema, storing and serving its blocks, without the
775
- * app's schema (no sApp config needed, none of the app's code or schema runs). This is
776
- * how an always-on machine keeps a copy of every shared workspace its party runs, so a
777
- * lost phone loses nothing that had reached it.
778
- *
779
- * The strand is still announced as `strand:discovered` (once) and stays in
780
- * `getDiscoveredStrands()` until an app claims it. A replica is always a joiner.
781
- *
782
- * Default: `profile === 'storage'` — always-on machines host replicas, phones
783
- * (`'transaction'`) keep today's announce-only behaviour.
784
- */
785
- hostUnclaimedStrands?: boolean;
786
-
787
- /** Storage configuration (only for storage profile) */
788
- storage?: StorageConfig;
789
-
790
- /** Network configuration */
791
- network?: NetworkConfig;
792
-
793
- /**
794
- * Number of nodes Optimystic is told a **strand** network's replication cluster
795
- * should have, for every strand this node starts. The control network is not
796
- * configurable — it always replicates to {@link CONTROL_REPLICATION_BREADTH}
797
- * nodes, which is above any party's node count, because every member reads the
798
- * whole control database and a member that misses a write may never learn the
799
- * fact (see that constant for the full reasoning).
800
- *
801
- * Every node on the same strand should use the same value: the number bounds the
802
- * cohort a member independently derives, and Optimystic's cluster-membership gate
803
- * can reject a coordinator's smaller declared set as a downsize against a larger
804
- * derived view.
805
- *
806
- * Frozen when the strand's libp2p node is created; a change takes effect on
807
- * restart, not when cadre membership grows. Defaults to
808
- * {@link DEFAULT_STRAND_CLUSTER_SIZE} (4 — the smallest breadth whose 0.75
809
- * super-majority still commits with one holder offline); anything below
810
- * {@link MIN_CLUSTER_SIZE} is rejected before a node is created. The cost of a
811
- * small value is replication breadth and reliance on read repair, not commit
812
- * correctness — but at 2 that read repair cannot converge, because a lone
813
- * corroborator's stale answer is accepted as the cluster's truth.
814
- */
815
- strandClusterSize?: number;
816
-
817
- /**
818
- * Tuning for the strand peer-join block catch-up (see `peer-join-backfill.ts`):
819
- * when a strand's libp2p node connects to a peer this runtime has not yet
820
- * caught up, every block in the strand's own raw store is pushed to that peer,
821
- * so a machine that joined the strand after blocks were committed still ends
822
- * up physically holding them. Applies to every strand this node starts.
823
- * Omit for the defaults (`DEFAULT_PEER_JOIN_BACKFILL`); `{ enabled: false }`
824
- * restores the no-backfill behaviour. The control network runs its own,
825
- * separately tuned catch-up — see {@link controlBackfill}.
826
- */
827
- strandBackfill?: PeerJoinBackfillConfig;
828
-
829
- /**
830
- * Tuning for the CONTROL network's peer-join block catch-up (same module,
831
- * `peer-join-backfill.ts`, wired in `CadreNode.start`): when the control
832
- * libp2p node connects to an AUTHORIZED party member this runtime has not yet
833
- * caught up, every block in the control database's own raw store is pushed to
834
- * it. This is what makes a control block committed while the writer was alone
835
- * (collection headers written at genesis above all) physically reach members
836
- * that joined later — without it, such a member that restarts offline reads
837
- * the affected tables as empty. Pushes are gated on
838
- * `CadreNode.isAuthorizedMember`, checked at push time, because the control
839
- * network's inbound gate deliberately admits non-members in several states
840
- * (seed delivery, enrollment, bootstrap/relay peers). Omit for the defaults
841
- * (`DEFAULT_PEER_JOIN_BACKFILL`); `{ enabled: false }` disables it.
842
- */
843
- controlBackfill?: PeerJoinBackfillConfig;
844
-
845
- /**
846
- * Tuning for the CLOSED-strand revoked-peer gate
847
- * (`strand-revocation-enforcer.ts`), applied to every closed strand this node
848
- * starts: each strand node materializes the peer ids bound to REMOVED members
849
- * (orphaned `Strand.MemberPeer` rows) and refuses them at the stream and
850
- * connection layers, so removing a party cuts the network to its machines
851
- * rather than only deleting its row. Open strands never arm it. Omit for the
852
- * defaults (`DEFAULT_REVOCATION_POLL_INTERVAL_MS` refresh cadence);
853
- * `{ enabled: false }` restores the pre-existing behaviour.
854
- */
855
- strandRevocationEnforcement?: StrandRevocationEnforcementConfig;
856
-
857
- /**
858
- * Tuning for the CLOSED-strand membership reconciler
859
- * (`strand-membership-reconciler.ts`), applied to every closed strand this
860
- * node launches with a party identity key: at bring-up each machine redeems a
861
- * pending formation invitation (seating the party's `Strand.Member` row) and
862
- * writes its own machine→party `Strand.MemberPeer` binding, retrying in the
863
- * background without blocking bring-up. `pollIntervalMs` is the IDLE cadence
864
- * (how often a machine nobody has admitted re-checks) and the cap of the
865
- * unfinished-join retry ladder, NOT the retry cadence itself — an unfinished
866
- * join retries from 1 s, doubling. Omit for the defaults (it mirrors
867
- * {@link strandRevocationEnforcement}'s `pollIntervalMs`);
868
- * `{ enabled: false }` disarms the loop — for test fixtures that hand-drive
869
- * the membership writers and assert exact row sets.
870
- */
871
- strandMembershipReconciliation?: StrandMembershipReconciliationConfig;
872
-
873
- /**
874
- * Tuning for the JOINING machine's first-sync write gate
875
- * (`strand-first-sync-gate.ts`), applied to every strand this node launches as a
876
- * non-founder: the strand's database is withheld from the app (`StrandInstance.database`
877
- * unset, status `'syncing'`) until the strand's `Strand.Header` has been received from
878
- * another member and every `App` table has been read once, because a machine that writes
879
- * to a table it has never fetched creates a private copy of it that never merges.
880
- * `timeoutMs` bounds how long {@link CadreNode.addStrand} waits before rejecting with
881
- * `StrandAwaitingFirstSyncError` (retryable — the launch stays up and keeps probing);
882
- * `pollIntervalMs` is the probe cadence. Omit for the defaults (`DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS`,
883
- * sized for a machine re-attaching through a relay on a slow link, the slowest attach
884
- * measured; that constant's doc comment carries the number, the measurement behind it and
885
- * what the budget costs, and is the only copy of them; `DEFAULT_STRAND_FIRST_SYNC_POLL_MS`,
886
- * 500 ms). There is deliberately no way to disable the gate: a machine whose store already
887
- * holds the Header is never gated, so nothing that works today is blocked by it.
888
- */
889
- strandFirstSync?: StrandFirstSyncConfig;
890
-
891
- /** Hibernation configuration */
892
- hibernation?: HibernationConfig;
893
-
894
- /** Polling interval for strand watcher in ms (default: 5000) */
895
- strandWatchInterval?: number;
896
-
897
- /**
898
- * Require a valid author signature on every sApp schema before a strand is
899
- * formed or joined. Defaults to true (fail closed): an unsigned schema is
900
- * rejected before any libp2p node or schema DDL is brought up. Set false ONLY
901
- * for dev/test where unsigned demo schemas are used.
902
- */
903
- requireSignedSchemas?: boolean;
904
-
905
- /**
906
- * Node-wide default trust anchor for INBOUND control-network seeds. Forwarded
907
- * into every SeedBootstrapService this node constructs (owner, receive-only
908
- * listener, and the temp service used by applySeed when no service exists), and
909
- * used as the service-level default the libp2p seed-protocol handler relies on
910
- * (that path has no per-call override seam). Defaults to anchoredTrustPolicy()
911
- * inside SeedBootstrapService when unset — a node whose node-local trusted-owner
912
- * anchor was never seeded then rejects every seed. A per-call
913
- * `applySeed(seed, { trustPolicy })` override still wins over this default for
914
- * callers that hold out-of-band material (e.g. a pinned key from a CadreInvite).
915
- */
916
- seedTrustPolicy?: SeedTrustPolicy;
917
-
918
- /**
919
- * Node-local trusted-owner anchor (see `trusted-owner-store.ts`): the
920
- * NON-replicated, per-party record of owner keys established out of band —
921
- * the anchor membership/seed trust can rest on, since the replicated
922
- * `OwnerKey` table can be polluted by a stranger's self-genesis. Absent ⇒ an
923
- * in-memory store is created at start() (ephemeral: anchored trust does not
924
- * survive the process).
925
- */
926
- trustedOwners?: {
927
- /**
928
- * Injected store instance — e.g. a `FileTrustedOwnerStore` from the
929
- * Node-only subpath `@serfab/cadre-core/trusted-owner-store-file`,
930
- * persisted in the node's state directory (same injection/isolation pattern
931
- * as {@link keyStore}). Its `partyId` must match `controlNetwork.partyId`;
932
- * start() fails closed on a mismatch.
933
- */
934
- store?: TrustedOwnerStore;
935
- /**
936
- * Owner keys (base64url ed25519) established out of band and seeded into
937
- * the store during start(): operator pins (e.g. cadre-cli
938
- * `--pin-owner-key` / `CADRE_OWNER_KEYS`) or a `CadreInvite.ownerKeys`
939
- * already known at config time. Seeding is idempotent across restarts.
940
- */
941
- pinnedKeys?: string[];
942
- /**
943
- * Provenance recorded for {@link pinnedKeys}. Default: 'operator'.
944
- * ('genesis' is reserved for the node's own founding key, seeded
945
- * internally by `initializeSeedBootstrap`.)
946
- */
947
- pinnedSource?: Exclude<TrustSource, 'genesis'>;
948
- };
949
-
950
- /**
951
- * Node-local cold-start bootstrap-peer store (see `bootstrap-peer-store.ts`):
952
- * the owner addresses an applied seed nominated, kept so
953
- * `reconcileControlCohort`'s cold-start branch can keep retrying a node that
954
- * was seeded but never managed to connect. Sibling of {@link trustedOwners} —
955
- * same NON-replicated, per-party, injected-backend shape — but nothing here is
956
- * trust-bearing: these are dial hints only, and every address is re-bound to
957
- * its peer id before the dial. Absent ⇒ an in-memory store is created at
958
- * start() (ephemeral: the retry set does not survive the process, and such a
959
- * node is stranded permanently if it restarts before connecting).
960
- *
961
- * The Node CLI, the browser, and React Native all inject a durable backend
962
- * today; only NativeScript stays ephemeral, tracked by the
963
- * `ns-durable-node-local-stores` ticket (which covers the same gap for
964
- * {@link trustedOwners}). A new platform needs no new store class: supply a
965
- * `DurableSlot` for the platform's storage and inject
966
- * `PersistentBootstrapPeerStore.open(slot, partyId)` /
967
- * `PersistentTrustedOwnerStore.open(slot, partyId)`.
968
- */
969
- bootstrapPeers?: {
970
- /**
971
- * Injected store instance — e.g. a `FileBootstrapPeerStore` from the
972
- * Node-only subpath `@serfab/cadre-core/bootstrap-peer-store-file`, persisted
973
- * in the node's state directory (same injection/isolation pattern as
974
- * {@link keyStore} / {@link trustedOwners}). Its `partyId` must match
975
- * `controlNetwork.partyId`; start() fails closed on a mismatch.
976
- */
977
- store?: BootstrapPeerStore;
978
- };
979
-
980
- /**
981
- * Node-local record of how many machines this party had enrolled the last time
982
- * this node looked (see `enrolled-machine-store.ts`): the block-repair
983
- * corroboration yardstick the CONTROL node declares when its libp2p node is
984
- * built. Sibling of {@link trustedOwners} / {@link bootstrapPeers} — same
985
- * NON-replicated, per-party, injected-backend shape — and, like
986
- * {@link bootstrapPeers}, nothing here is trust-bearing: it is a repair hint,
987
- * recomputed from `CadrePeer` rows the moment the control database is up.
988
- *
989
- * It exists only because the control node is created BEFORE that database, so
990
- * the live count is unreadable at the one moment it is needed; remembering it
991
- * across the restart is what breaks that deadlock. Absent ⇒ an in-memory store
992
- * is created at start(), which cold-starts on every launch and therefore
993
- * declares nothing — byte-for-byte the behaviour before this record existed.
994
- * No embedder is forced to change.
995
- *
996
- * A party that grows applies the larger number on each node's NEXT launch,
997
- * deliberately: Optimystic freezes the policy at node construction and offers
998
- * no runtime setter, and a node still holding the old value runs at exactly
999
- * today's behaviour, so there is no forced rebuild.
1000
- *
1001
- * A new platform needs no new store class: supply a `DurableSlot` for the
1002
- * platform's storage and inject `PersistentEnrolledMachineStore.open(slot, partyId)`.
1003
- */
1004
- enrolledMachines?: {
1005
- /**
1006
- * Injected store instance — e.g. a `FileEnrolledMachineStore` from the
1007
- * Node-only subpath `@serfab/cadre-core/enrolled-machine-store-file`,
1008
- * persisted in the node's state directory. Its `partyId` must match
1009
- * `controlNetwork.partyId`; start() fails closed on a mismatch.
1010
- */
1011
- store?: EnrolledMachineStore;
1012
- };
1013
-
1014
- /**
1015
- * Node-local record of the strands this node joined from ANOTHER party and has not yet
1016
- * published to the party-wide `JoinedStrand` table (see `joined-strand-store.ts`): id,
1017
- * type and the closed strand's read secret, recorded by `formStrand` and by a joining
1018
- * (not `founder: true`) `addStrand` whose row this party's control database does not
1019
- * name, offered on every start as `strand:discovered` until an owner machine's
1020
- * reconcile pass publishes it — so an embedding app keeps no list of its own.
1021
- *
1022
- * Absent ⇒ a `KeyStoreJoinedStrandStore` over {@link keyStore} when one is
1023
- * configured (the record carries a secret, and the KeyStore is where every platform
1024
- * already keeps secrets), else an in-memory store that warns once and forgets every
1025
- * join on restart. An embedder that passes {@link privateKey} rather than a
1026
- * `keyStore` injects a durable store here, e.g.
1027
- * `new KeyStoreJoinedStrandStore(new FileKeyStore(dir), partyId)`.
1028
- */
1029
- joinedStrands?: {
1030
- /** Its `partyId` must match `controlNetwork.partyId`; start() fails closed on a mismatch. */
1031
- store?: JoinedStrandStore;
1032
- };
1033
-
1034
- /**
1035
- * Node-local strand network state (see `strand-network-state.ts`): per strand, the
1036
- * state Optimystic's db-p2p saves for the strand's libp2p node — its FRET routing
1037
- * table, each entry carrying the peer's signed address record. A strand node reads
1038
- * it back when it is built and re-imports the table, so a restarted machine already
1039
- * holds addresses for the strand peers it was talking to (gotchoices/sereus#18).
1040
- * Sibling of {@link bootstrapPeers} — same NON-replicated, per-party,
1041
- * injected-backend shape — and, like it, nothing here is trust-bearing: FRET
1042
- * verifies every record at import.
1043
- *
1044
- * Absent ⇒ an in-memory store is created at start() (ephemeral: a restarted process
1045
- * starts every strand with an empty routing table). Every reference embedder
1046
- * injects a durable backend over the same slot kind as its bootstrap-peer store. A
1047
- * new platform needs no new store class: supply a `DurableSlot` and inject
1048
- * `PersistentStrandNetworkStateStore.open(slot, partyId)`.
1049
- */
1050
- strandNetworkState?: {
1051
- /**
1052
- * Injected store instance — e.g. a `FileStrandNetworkStateStore` from the
1053
- * Node-only subpath `@serfab/cadre-core/strand-network-state-file`, persisted in
1054
- * the node's state directory. Its `partyId` must match
1055
- * `controlNetwork.partyId`; start() fails closed on a mismatch.
1056
- */
1057
- store?: StrandNetworkStateStore;
1058
- };
1059
-
1060
- /**
1061
- * Platform push-delivery for suspended mobile peers. When present, the node's
1062
- * server fan-out can deliver strand-wake data messages over the platform push
1063
- * channel (FCM/APNs). Absent ⇒ no platform push (control-network push-wake only).
1064
- *
1065
- * The `notifier` is **injected**, not constructed here: a Node host builds it
1066
- * from `@serfab/cadre-core/push-node` (`createPushNotifier(credentials)`) and
1067
- * passes the instance, so the FCM/APNs modules (`node:crypto`/`node:http2`)
1068
- * stay out of the cross-platform core graph. Ownership transfers to the node on
1069
- * injection — `CadreNode.stop` closes the notifier (via `PushFanoutService`),
1070
- * releasing its APNs HTTP/2 session; a host must NOT also close an injected
1071
- * notifier itself. `cooldownMs`/`debounceMs` carry the fan-out anti-spam policy.
1072
- */
1073
- push?: {
1074
- /** The Node-constructed push router (see `@serfab/cadre-core/push-node`). */
1075
- notifier: PushNotifier;
1076
- /**
1077
- * Per-`(peer, strand)` minimum gap between push-wakes the fan-out emits.
1078
- * Default {@link DEFAULT_PUSH_COOLDOWN_MS} (5 min).
1079
- */
1080
- cooldownMs?: number;
1081
- /**
1082
- * Per-strand burst-coalescing window: a second trigger within it is dropped.
1083
- * Default {@link DEFAULT_PUSH_DEBOUNCE_MS} (10 s).
1084
- */
1085
- debounceMs?: number;
1086
- };
1087
- }
1088
-
1089
- /**
1090
- * Status of a strand instance.
1091
- *
1092
- * `'syncing'` is a JOINING machine whose runtime is up (strand libp2p node, background
1093
- * loops) but whose database is held back from the app until the strand's `Strand.Header`
1094
- * has been received from another member — see `strand-first-sync-gate.ts`. In that state
1095
- * `StrandInstance.database` is unset; the instance goes `'active'` (and `CadreNode` emits
1096
- * `strand:writable`) the moment the Header is readable. A founder, or a machine that has
1097
- * synced this strand before (restart, hibernation resume), never passes through it.
1098
- */
1099
- export type StrandStatus =
1100
- | 'starting'
1101
- | 'syncing'
1102
- | 'active'
1103
- | 'idle'
1104
- | 'hibernating'
1105
- | 'stopping'
1106
- | 'stopped'
1107
- | 'error';
1108
-
1109
- /**
1110
- * App information from strand header
1111
- */
1112
- export interface SAppInfo {
1113
- id: string;
1114
- version: string;
1115
- schema: string;
1116
- signature?: string;
1117
- }
1118
-
1119
- /**
1120
- * Strand instance state
1121
- */
1122
- export interface StrandInstance {
1123
- strandId: string;
1124
- status: StrandStatus;
1125
- sAppInfo?: SAppInfo;
1126
-
1127
- /** The libp2p node for this strand (only when live: syncing/active/idle) */
1128
- libp2pNode?: Libp2p;
1129
-
1130
- /**
1131
- * The Quereus database for this strand — set only while the strand is live AND
1132
- * this machine may write to it. A joining machine that has not yet received the
1133
- * strand's `Strand.Header` from another member runs with `libp2pNode` set and this
1134
- * unset (status `'syncing'`); it is published when the Header arrives
1135
- * (`strand:writable`). Unset while hibernating.
1136
- */
1137
- database?: StrandDatabase;
1138
-
1139
- /** Membership info for closed strands */
1140
- memberKey?: string;
1141
- memberPrivateKey?: string;
1142
-
1143
- /** Activity tracking */
1144
- connectedPeers: number;
1145
- /**
1146
- * When the strand last saw activity: the app's (`recordStrandActivity`), a requested wake
1147
- * (`wakeStrand`), or a peer delivering a gated joiner's `Strand.Header`. Always a fresh
1148
- * `Date`. Bringing the runtime up is NOT activity — a check-in window decides whether to
1149
- * re-hibernate by whether this changed since before its resume.
1150
- */
1151
- lastActivity: Date;
1152
- nextCheckIn?: Date;
1153
-
1154
- /** Latency hint from app or override */
1155
- latencyHint: LatencyHint;
1156
-
1157
- /** Error message if status is 'error' */
1158
- error?: string;
1159
- }
1160
-
1161
- /**
1162
- * Outcome of an on-demand {@link CadreNode.serviceWake} — the mobile push-wake
1163
- * cycle (resume → bounded window → re-hibernate-if-idle) run imperatively for a
1164
- * single strand.
1165
- */
1166
- export interface ServiceWakeResult {
1167
- /** The strand the wake was requested for. */
1168
- strandId: string;
1169
- /**
1170
- * Whether the wake was actually serviced. `false` when the node is not
1171
- * running, or the strand is unknown to this node (not a member-participated
1172
- * strand) — never a thrown error, so a background task can branch on it.
1173
- */
1174
- serviced: boolean;
1175
- /**
1176
- * Whether activity landed during the wake window. `true` leaves the strand
1177
- * active; `false` re-hibernates it (or the strand was already live, in which
1178
- * case it reflects current liveness). Always `false` when `serviced` is `false`.
1179
- */
1180
- hadActivity: boolean;
1181
- }
1182
-
1183
- /**
1184
- * Strand row from control network - basic membership info
1185
- */
1186
- export interface StrandRow {
1187
- Id: string;
1188
- MemberPrivateKey: string | null;
1189
- Type: 'o' | 'c';
1190
- /**
1191
- * ed25519 (base64url) owner key of the MACHINE that published this row — the
1192
- * signer of the owner-signed `Strand` insert (`== context.OwnerKey`, pinned by
1193
- * the schema's `AuthorizedInsert`). Identifies the founding machine, so a
1194
- * launch with no explicit `founder` flag derives "am I the founder?" by
1195
- * comparing it to the node's own owner key (see `CadreNode.launchStrand`).
1196
- * `null` on a consent-seated strand (the unsigned `FormationUsage` branch
1197
- * records no trustworthy signer). Provenance, not content —
1198
- * `strandRowMismatches` excludes it from the identical-content comparison.
1199
- */
1200
- FounderOwnerKey: string | null;
1201
- }
1202
-
1203
- /**
1204
- * A `CadreControl.PendingJoin` row: a join this party asked for through another party's
1205
- * invitation, readable by every machine of the party. Each outcome carries only its own
1206
- * columns (the schema's `OutcomeShape` check): `'joined'` sets `StrandId` (and, for a
1207
- * closed strand, `MembershipInvite`), `'failed'` sets `FailureCode`, and a pending row
1208
- * (`Outcome: null`) sets neither.
1209
- */
1210
- export interface PendingJoinRow {
1211
- /** base64url sha256 of the invitation token (`pendingJoinId`). */
1212
- Id: string;
1213
- /** `CadreNode.encodeInvitation` of the invitation. A bearer credential. */
1214
- Invitation: string;
1215
- /** `canonicalJson` of the disclosure the requester gave. */
1216
- Disclosure: string;
1217
- /** Epoch ms. */
1218
- RequestedAt: number;
1219
- /** Epoch ms; no attempt starts at or after it. */
1220
- ExpiresAt: number;
1221
- Outcome: null | 'joined' | 'failed';
1222
- /** Epoch ms the outcome was recorded; null while pending. */
1223
- OutcomeAt: number | null;
1224
- /** `'joined'`: the strand the formation returned. */
1225
- StrandId: string | null;
1226
- /** `'joined'`, closed strand: JSON of the {@link StrandMembershipInvite} the formation delivered. */
1227
- MembershipInvite: string | null;
1228
- /** `'failed'`: a `FormationRejectionCode`, `'expired'` or `'local'`. Not constrained by the schema. */
1229
- FailureCode: string | null;
1230
- /** `'failed'`: human-readable text. */
1231
- FailureReason: string | null;
1232
- /** Single-use nonce of this row incarnation; `ControlDatabase.replacePendingJoin` names it as the row it replaces. */
1233
- StampId: string;
1234
- }
1235
-
1236
- /**
1237
- * A join this party asked for through `CadreNode.requestJoin`, as one machine sees it.
1238
- *
1239
- * `pending`, `joined` and `failed` come from the party-wide `PendingJoin` row and read the same
1240
- * on every machine. `trying` (an attempt is running on this machine) and `waiting` (this
1241
- * machine's last attempt failed in a way worth retrying) are this machine's own view of a
1242
- * pending row, so two machines can report different states for it.
1243
- */
1244
- export interface PendingJoinStatus {
1245
- /** `PendingJoin.Id`: the sha256 of the invitation token. */
1246
- id: string;
1247
- sAppId: string;
1248
- /** Epoch ms. */
1249
- requestedAt: number;
1250
- /** Epoch ms; no attempt starts at or after it. */
1251
- expiresAt: number;
1252
- state: 'pending' | 'trying' | 'waiting' | 'joined' | 'failed';
1253
- /** `'waiting'`: when this machine tries again, epoch ms. */
1254
- nextAttemptAt?: number;
1255
- /**
1256
- * `'waiting'`: why this machine's last attempt failed. `'unreachable'` means no answer came
1257
- * back; `'local'` means the attempt failed on this machine before an answer.
1258
- */
1259
- lastError?: { code: FormationRejectionCode | 'unrecognized' | 'unreachable' | 'local'; reason: string };
1260
- /** `'joined'`: the strand the join produced. */
1261
- strandId?: string;
1262
- /** `'failed'`: a `FormationRejectionCode`, `'expired'`, or `'local'` (approved, then a step on the joining machine failed). */
1263
- failure?: { code: string; reason: string };
1264
- }
1265
-
1266
- /**
1267
- * sApp configuration provided by the hosting application when creating a strand.
1268
- * This is what the app developer provides - NOT loaded from the network.
1269
- */
1270
- export interface SAppConfig {
1271
- /** Public key of the sApp author */
1272
- id: string;
1273
- /** Version of the sApp */
1274
- version: string;
1275
- /** The declarative schema DDL */
1276
- schema: string;
1277
- /**
1278
- * Author's signature over the schema for verification. Required under the
1279
- * default `requireSignedSchemas` node policy — a config without it is rejected
1280
- * before strand bring-up. Optional in the type because authoring/serialization
1281
- * must represent an as-yet-unsigned config; omitting it is honored only when
1282
- * the node explicitly relaxes the policy (`requireSignedSchemas: false`, dev/test).
1283
- */
1284
- signature?: string;
1285
- /** Latency hint for hibernation behavior (optional, defaults to config) */
1286
- latencyHint?: LatencyHint;
1287
- }
1288
-
1289
- /**
1290
- * Full strand configuration when adding a strand via the API
1291
- */
1292
- export interface StrandConfig {
1293
- /** Strand row from control network */
1294
- strandRow: StrandRow;
1295
- /** sApp configuration provided by the hosting application */
1296
- sAppConfig: SAppConfig;
1297
- /**
1298
- * Whether THIS node is the strand's founder — the party that provisioned and
1299
- * published the strand (the responder in formation, the creator in host/solo
1300
- * paths; the same party that calls {@link CadreNode.publishStrand}). The founder
1301
- * runs the one-time membership bootstrap at bring-up (writes `Strand.Header`, and
1302
- * for a closed strand the founding `Member`+`Manager`). A joiner leaves this
1303
- * unset and writes nothing — it receives those rows via Optimystic sync.
1304
- *
1305
- * When unset, founder-ness is DERIVED from the row: this node founds iff
1306
- * {@link StrandRow.FounderOwnerKey} equals its own owner key. An explicit
1307
- * `true`/`false` wins over the derivation — the formation/responder flows pass
1308
- * it deliberately, since a consent-seated row carries a null column.
1309
- */
1310
- founder?: boolean;
1311
- /**
1312
- * THIS party's own strand membership private key (base64 protobuf, as
1313
- * `generateStrandMemberKey` mints) for a CLOSED strand — the identity whose public
1314
- * key seats the founding `Member`/`Manager`. The explicit sibling of
1315
- * {@link founder}: normally the launch reads it from the party's control-layer
1316
- * `StrandPartyKey` row (minting one on the founding machine when absent), and an
1317
- * explicit value here WINS over that read — for callers whose row carries no
1318
- * founder provenance to heal against (an explicit `founder: true` over a hand-built
1319
- * row, as the test harness does). Deliberately NOT
1320
- * {@link StrandRow.MemberPrivateKey}, the strand-wide read secret every joining
1321
- * party receives — that key derives nobody's identity.
1322
- */
1323
- partyMemberPrivateKey?: string;
1324
- /**
1325
- * Whether {@link CadreNode.addStrand} waits for a JOINING machine's first sync before it
1326
- * resolves. Default `true`: the returned instance is writable (`database` set, status
1327
- * `'active'`), or the call rejects with `StrandAwaitingFirstSyncError` after
1328
- * `CadreNodeConfig.strandFirstSync.timeoutMs` — retryable, the launch stays up. `false`
1329
- * returns as soon as the runtime is launched, possibly `'syncing'` with no `database`;
1330
- * the caller then wires its own peers (a hand-dialed test fixture) and awaits
1331
- * {@link CadreNode.whenStrandWritable} or the `strand:writable` event. A founder, or a
1332
- * machine that already holds the strand's `Strand.Header`, is never gated either way.
1333
- */
1334
- awaitFirstSync?: boolean;
1335
- }
1336
-
1337
- /**
1338
- * What {@link CadreNode.foundStrand} needs to publish a strand's control row AND start it
1339
- * locally as its founder — the resumable one-call form of `publishStrand` + `addStrand`.
1340
- *
1341
- * No `strandRow`: the row is the OUTPUT of founding, either freshly published or read back
1342
- * from an interrupted earlier attempt. `StrandConfig` (the join/attach shape) takes one
1343
- * because there the row already exists and came from the control network.
1344
- */
1345
- export interface FoundStrandConfig {
1346
- /** Unique strand identifier. Trimmed; blank is rejected. */
1347
- strandId: string;
1348
- /** `'o'` for open (default) or `'c'` for closed. */
1349
- type?: 'o' | 'c';
1350
- /**
1351
- * Membership key gating a closed strand. Required in practice for `type: 'c'` (the
1352
- * founder bootstrap rejects a null key), and IGNORED when the strand is already
1353
- * published — the stored key wins, so read the resolved key back off the returned
1354
- * instance rather than trusting a freshly minted one.
1355
- */
1356
- memberPrivateKey?: string;
1357
- /** sApp configuration the hosting application provides, as for {@link StrandConfig}. */
1358
- sAppConfig: SAppConfig;
1359
- /**
1360
- * Forwarded to the attach ({@link StrandConfig.awaitFirstSync}). Matters only when the
1361
- * call ATTACHES rather than founds — the stored row was published by a sibling machine
1362
- * that won the founding race — in which case this machine is a joiner whose database
1363
- * is withheld until that sibling's `Strand.Header` reaches it; default `true` waits
1364
- * for that inside the call. A founding launch is never gated.
1365
- */
1366
- awaitFirstSync?: boolean;
1367
- }
1368
-
1369
- /**
1370
- * What {@link CadreNode.foundStrand} hands back: the running instance PLUS the `Strand` row
1371
- * the strand actually runs under.
1372
- *
1373
- * The row is returned separately rather than left for the caller to read off the instance
1374
- * because on a resumed founding it is NOT the caller's input — a closed strand adopts the
1375
- * stored `MemberPrivateKey`, discarding a freshly minted one. A caller that must carry the
1376
- * membership key onward (to mint an invitation, say) reads it from HERE.
1377
- */
1378
- export interface FoundStrandResult {
1379
- /** The active local instance, as {@link CadreNode.addStrand} would have returned. */
1380
- instance: StrandInstance;
1381
- /** The live control-plane row: freshly published, or the one already there. */
1382
- strandRow: StrandRow;
1383
- /**
1384
- * Whether THIS machine actually founded (ran / will have run the one-time
1385
- * founder bootstrap). `false` when the resolved row was published by a
1386
- * DIFFERENT machine's owner key — e.g. a sibling won a concurrent founding
1387
- * race — in which case the call attached as a joiner instead, which is the
1388
- * correct outcome (two machines bootstrapping one strand on separate replicas
1389
- * is the double-`Header` hazard).
1390
- */
1391
- founded: boolean;
1392
- }
1393
-
1394
- /**
1395
- * Result of creating a new cadre peer
1396
- */
1397
- export interface CreatePeerResult {
1398
- peerId: PeerId;
1399
- privateKey: Uint8Array;
1400
- }
1401
-
1402
- // ============================================================================
1403
- // Member Registration API Types (from api.md)
1404
- // ============================================================================
1405
-
1406
- /**
1407
- * Registration data for joining a strand as a member.
1408
- * Sent from invited member to any cadre member to accept invitation.
1409
- */
1410
- export interface MemberRegistration {
1411
- /** The strand being joined */
1412
- strandId: string;
1413
- /** The member's public key for this strand */
1414
- key: string;
1415
- /** Peer IDs of the member's cadre nodes that will participate */
1416
- peerIds: string[];
1417
- }
1418
-
1419
- /**
1420
- * Result of member registration
1421
- */
1422
- export interface MemberRegistrationResult {
1423
- success: boolean;
1424
- reason?: string;
1425
- }
1426
-
1427
- // ============================================================================
1428
- // Strand Solicitation API Types (from api.md)
1429
- // ============================================================================
1430
-
1431
- /**
1432
- * Open invitation for strand formation.
1433
- * Shared out-of-band to allow strangers to form strands.
1434
- */
1435
- export interface OpenInvitation {
1436
- /** Unique token identifying this invitation */
1437
- token: string;
1438
- /** The sApp that will be used for the strand */
1439
- sAppId: string;
1440
- /** When this invitation expires */
1441
- expiration: Date;
1442
- /**
1443
- * Addresses of the inviting party's machines, each ending in `/p2p/<peerId>`: the minting
1444
- * machine's own first, then up to three of its siblings with up to four addresses each
1445
- * (`CadreNode.createOpenInvitation`). A joiner tries the machines in order, one formation
1446
- * session each, so an entry may be dead by the time it is used; one that names no machine
1447
- * is ignored.
1448
- */
1449
- bootstrap: string[];
1450
- }
1451
-
1452
- /**
1453
- * A single-use strand membership invitation carried on a closed-strand formation
1454
- * result: the `Strand.Invite` keypair the responder (the host strand's founder party)
1455
- * issued for this redemption. The joiner spends it — `consumeInvite` on its own strand
1456
- * replica — to seat a `Strand.Member` row under its OWN party key, so joining no longer
1457
- * hands out the founder's identity. Structurally identical to
1458
- * `strand-membership-writer`'s `IssuedInvite`; declared here so the wire/type layer does
1459
- * not import the writer.
1460
- */
1461
- export interface StrandMembershipInvite {
1462
- /** The invite ed25519 PUBLIC key (base64url) — the `Strand.Invite.Key` row. */
1463
- inviteKey: string;
1464
- /**
1465
- * The invite ed25519 PRIVATE seed (base64url). A single-use bearer credential:
1466
- * whoever holds it can `consumeInvite` exactly once. Same sensitivity class and
1467
- * handling as `memberPrivateKey` — delivered only inside the validated,
1468
- * post-approval formation result. The inviting side never writes it to its control
1469
- * DB. The joining side may copy it into its own `PendingJoin` row
1470
- * (`MembershipInvite`), owner-signed and party-private like
1471
- * `JoinedStrand.MemberPrivateKey`, so a join finished on one machine of the party
1472
- * can be seated by whichever machine launches the strand first.
1473
- */
1474
- invitePrivateKey: string;
1475
- }
1476
-
1477
- /**
1478
- * Result of forming a strand via open invitation
1479
- */
1480
- export interface FormStrandResult {
1481
- /** The member key assigned to the initiator for this strand */
1482
- memberKey: string;
1483
- /** Private key for the invitation (for signing future messages) */
1484
- invitePrivateKey: string;
1485
- /** The strand that was created */
1486
- strandId: string;
1487
- /**
1488
- * The strand's membership key (closed-strand read-gating secret), delivered through
1489
- * the formation protocol after consent (provision-then-record). Present only for a
1490
- * closed strand the responder returned with its key; undefined otherwise. Distinct
1491
- * from {@link invitePrivateKey} (the initiator's own generated signing key).
1492
- */
1493
- memberPrivateKey?: string;
1494
- /**
1495
- * The joiner's own single-use membership invitation into the (closed, bound) host
1496
- * strand — see {@link StrandMembershipInvite}. Present only when the responder's
1497
- * host strand is closed and its runtime issued one; absent for open strands and the
1498
- * responder-provisions (unbound) path. `CadreNode.formStrand` itself persists the
1499
- * joiner's party key and caches this invitation for the strand bring-up to redeem,
1500
- * so an embedding app normally never touches it.
1501
- */
1502
- membershipInvite?: StrandMembershipInvite;
1503
- /**
1504
- * The responder's live STRAND-network multiaddrs for the formed strand — the only
1505
- * cross-party discovery seed there is, since the strand-addr RPC that resolves a
1506
- * sibling's strand addresses answers own-party callers only.
1507
- *
1508
- * Always an array, frequently EMPTY: the responder discloses none when it holds no
1509
- * live strand node for the strand it just provisioned (the responder-provisions path
1510
- * mints a strand that has not launched yet). `CadreNode.formStrand` records these
1511
- * itself, so an embedding app only needs them to seed a strand it launches OUTSIDE
1512
- * that node.
1513
- *
1514
- * One-shot and in-memory: they are the responder's addresses at the moment of
1515
- * formation and are never re-resolved — see `docs/strands.md`.
1516
- */
1517
- strandAddrs: string[];
1518
- }
1519
-
1520
- /**
1521
- * Disclosure object provided during strand formation.
1522
- * Contains identity and context information from the initiator.
1523
- */
1524
- export interface StrandFormationDisclosure {
1525
- /** Initiator's party identifier */
1526
- partyId?: string;
1527
- /** Additional identity bundle (app-specific) */
1528
- identityBundle?: unknown;
1529
- /** Human-readable purpose/reason for forming the strand */
1530
- purpose?: string;
1531
- /** Additional app-specific metadata */
1532
- metadata?: Record<string, unknown>;
1533
- }
1534
-
1535
- /**
1536
- * Arachnode ring participation stub - will be implemented when arachnode is built
1537
- */
1538
- export interface ArachnodeConfig {
1539
- /** Enable Arachnode subsystem (Ring Zulu + storage rings). Storage-profile only. */
1540
- enableRingZulu: boolean;
1541
- /** Storage ring participation (storage profile only) */
1542
- storageRing?: {
1543
- /** Which ring to participate in based on capacity */
1544
- ring: number;
1545
- /** Partition within the ring */
1546
- partition?: number;
1547
- };
1548
- }
1549
-
1550
- /**
1551
- * Events emitted by CadreNode
1552
- */
1553
- export interface CadreNodeEvents {
1554
- 'strand:started': { strandId: string };
1555
- 'strand:stopped': { strandId: string };
1556
- 'strand:error': { strandId: string; error: Error };
1557
- 'strand:idle': { strandId: string };
1558
- 'strand:hibernating': { strandId: string };
1559
- 'strand:waking': { strandId: string };
1560
- /**
1561
- * Emitted when a strand that came up `'syncing'` — a joining machine whose database was
1562
- * withheld until it received the strand's `Strand.Header` from another member — becomes
1563
- * writable: `StrandInstance.database` is now set and the status is `'active'`. A strand
1564
- * whose `strand:started` already reported `'active'` (a founder, or a machine that had
1565
- * synced before) never emits this. The event form of what {@link CadreNode.addStrand}
1566
- * awaits; an app that attaches without waiting (`awaitFirstSync: false`, or a strand the
1567
- * watcher auto-launched) hangs its "waiting for the other member" screen on the pair.
1568
- */
1569
- 'strand:writable': { strandId: string };
1570
- /**
1571
- * Emitted when this node discovers it is no longer a member of a CLOSED
1572
- * strand — its party was removed by a manager, or it left. Detected by the
1573
- * strand's revoked-peer gate (`strand-revocation-enforcer.ts`) seeing this
1574
- * node's own peer id in the revoked set, so it is BEST-EFFORT: it fires only
1575
- * if the removal replicated here before the rest of the strand cut us off,
1576
- * and a node that was already offline may never see it at all.
1577
- *
1578
- * Nothing is stopped or torn down for you. Treat it as "this strand will no
1579
- * longer sync": remaining members refuse this node's streams, dials, and
1580
- * connections, so reads keep working against whatever is stored locally while
1581
- * writes stop propagating. Stopping the strand (`stopStrand`) or deleting its
1582
- * data is the app's decision. Emitted once per removal — re-armed if a manager
1583
- * re-admits this party, so a second removal is reported again — and a
1584
- * hibernation wake rebuilds the gate, which may re-emit for a strand still
1585
- * revoked.
1586
- *
1587
- * For a strand joined from another party, the node also forgets its remembered
1588
- * join (see {@link CadreNodeConfig.joinedStrands}): it keeps running this session
1589
- * and is not re-offered after the next start.
1590
- */
1591
- 'strand:revoked': { strandId: string };
1592
- /**
1593
- * Emitted when this node holds a staged membership invitation for a CLOSED strand
1594
- * that its own writes cannot redeem — the shape a REMOVED party hits when a manager
1595
- * hands it a fresh invitation. Redeeming means writing this party's `Strand.Member`
1596
- * row into the strand, and the machines that would carry that write are the ones the
1597
- * remaining members refuse, so the attempt is made and fails. Two triggers, reported
1598
- * at most once per re-arm of the membership loop (a further invitation staged while the
1599
- * loop is still running does not reset the report):
1600
- *
1601
- * - CONFIRMED: the revoked-peer gate already flags this node as removed (the
1602
- * `strand:revoked` case) and an invitation is staged.
1603
- * - PROBABLE: `UNFINISHED_PASSES_BEFORE_ESCALATION` consecutive attempts left the
1604
- * invitation staged. This one is a SUSPICION, not a verdict — the invitation's row
1605
- * may simply not have replicated here yet on a slow strand — and the accompanying
1606
- * warning names both causes.
1607
- *
1608
- * Nothing is stopped or torn down. A fresh invitation cannot re-admit a removed
1609
- * party by itself: the remedy is a remaining manager admitting this party's member
1610
- * key directly (`addMemberByManager`), after which the membership loop, which keeps
1611
- * retrying, finishes the join on its own.
1612
- */
1613
- 'strand:rejoin-blocked': { strandId: string };
1614
- /**
1615
- * Emitted when the control network advertises a strand this node has no
1616
- * registered `sAppConfig` for — i.e. a strand created by another member, or
1617
- * one this node ran in a previous session (sApp configs are in-memory only and
1618
- * do not survive `stop()`). The hosting app decides whether to join it
1619
- * (register a config + `addStrand`, e.g. via a chat `joinChatStrand` helper).
1620
- * Carries the full {@link StrandRow} so the app can join without re-querying
1621
- * the control DB.
1622
- *
1623
- * Also emitted for a strand this node joined from ANOTHER party, which no control
1624
- * row names: the node remembers every such join (see
1625
- * {@link CadreNodeConfig.joinedStrands}) and offers it here on each start, as a row
1626
- * with `FounderOwnerKey: null` carrying the closed strand's `MemberPrivateKey`. That
1627
- * row is the product of the formation's consent, so claiming it needs no second one.
1628
- *
1629
- * **Fired once per strand per session, and it can fire before your listener is
1630
- * attached.** The strand watcher's first poll runs inside `CadreNode.start()`
1631
- * (100 ms after the watcher starts), so every strand already stored for this
1632
- * party is normally offered while the embedding app is still inside its own
1633
- * `start()` await. The watcher then records the strand as seen and no later poll
1634
- * re-offers it, so a listener attached a moment late misses those strands for the
1635
- * life of the process.
1636
- *
1637
- * One exception, and it does NOT produce a second event: when an `addStrand` that
1638
- * claims a discovered strand FAILS, the watcher is told to forget the strand so a
1639
- * later poll retries it. That claim left the sApp config registered, so the retry
1640
- * takes the auto-launch branch — what the app sees is `strand:error` per failed
1641
- * retry and `strand:started` when one succeeds.
1642
- *
1643
- * So an app that auto-joins discovered strands must **subscribe first, then
1644
- * drain `CadreNode.getDiscoveredStrands()`** — the map of strands no local
1645
- * config claims. In that order a strand discovered between the two steps is
1646
- * handled twice rather than not at all, which is why the join handler needs to
1647
- * be idempotent (guard on an in-flight set, not only on
1648
- * `getStrands().has(id)` — the strand manager tracks an instance only once
1649
- * `addStrand` has resolved).
1650
- *
1651
- * On a node that hosts storage replicas ({@link CadreNodeConfig.hostUnclaimedStrands},
1652
- * the default for `profile: 'storage'`) the event still fires once, and the node launches
1653
- * the strand as a replica right after it — so there `getStrands().has(id)` soon turns
1654
- * true for a strand no app has claimed; an unclaimed instance has no `sAppInfo`. A
1655
- * launch that fails is retried by the watcher without a second `strand:discovered`.
1656
- */
1657
- 'strand:discovered': { strandId: string; strand: StrandRow };
1658
- 'control:connected': void;
1659
- 'control:disconnected': void;
1660
- /**
1661
- * Emitted when the control-write retry funnel GAVE UP on a local control write — the
1662
- * classifier declined the failure as non-transient, or every attempt (or the elapsed
1663
- * budget) ran out. One event per abandoned write, carrying the operation label, how far
1664
- * it got, why it stopped and the error.
1665
- *
1666
- * Exists because an abandoned BACKGROUND write is otherwise invisible: the node's own
1667
- * self-address republish, the post-connect drain and the replication drain all fire
1668
- * their writes unawaited with a `debug`-only catch, and that namespace is off unless
1669
- * something enabled it — so the write is lost and nobody is told. A FOREGROUND write's
1670
- * caller sees the rethrown error as well as this event.
1671
- *
1672
- * What an app does with it is its own call; the node itself only escalates ONE case to
1673
- * the operator (a self-address republish that has been failing longer than a peer
1674
- * record stays fresh, at which point other machines are already discarding this node's
1675
- * address). Treat the rest as telemetry — a control write that was lost, not a strand
1676
- * or connection state change.
1677
- */
1678
- 'control:write-abandoned': ControlRetryAbandonment;
1679
- /**
1680
- * Emitted when this machine's view of a join asked for with `CadreNode.requestJoin` changes:
1681
- * its own attempt started or ended, or a pass read an outcome another owner machine wrote.
1682
- * Owner machines only, since only they run the retry loop. A dismissed join emits nothing
1683
- * further; `listPendingJoins` no longer names it.
1684
- */
1685
- 'pendingJoin:changed': PendingJoinStatus;
1686
- /** Emitted when a seed is received via the seed protocol */
1687
- 'seed:received': { partyId: string; peerId: string };
1688
- /** Emitted when a seed is successfully applied */
1689
- 'seed:applied': { partyId: string; peersAdded: number };
1690
- /** Emitted when seed application fails */
1691
- 'seed:error': { partyId: string; error: string };
1692
- }
1693
-
1694
- // ============================================================================
1695
- // Peer Address Record / Resolution Types
1696
- // ============================================================================
1697
-
1698
- /**
1699
- * A peer's self-published, freshness-stamped, self-signed address record.
1700
- *
1701
- * This is the logical shape of a `CadreControl.CadrePeer` row. A node publishes
1702
- * its own record (signing with the ed25519 key behind its PeerId); any node can
1703
- * resolve another member's current signaling/relay multiaddrs from only its
1704
- * PeerId by reading the record, re-verifying the self-signature against
1705
- * `publicKey`, checking freshness, and applying a trust gate.
1706
- *
1707
- * The signature covers `peerId`, the comma-joined `addrs`, and `updatedAt` — see
1708
- * `peerRecordSignedPayload`. `publicKey` is NOT inside the signed payload: it is
1709
- * the key the signature is verified *with*, and its libp2p-identity binding to
1710
- * `peerId` is checked separately at resolve time, so re-signing it would be
1711
- * redundant.
1712
- */
1713
- export interface PeerAddressRecord {
1714
- /** libp2p peer ID (base58btc) — the row key. */
1715
- peerId: string;
1716
- /** ed25519 public key (base64url) whose libp2p identity IS `peerId`. */
1717
- publicKey: string;
1718
- /** Current dialable multiaddrs, signaling (`/p2p-circuit`) first. */
1719
- addrs: string[];
1720
- /** epoch ms — freshness; strictly increasing per peer. */
1721
- updatedAt: number;
1722
- /** ed25519 self-signature over the signed payload, base64url. */
1723
- sig: string;
1724
- }
1725
-
1726
- /**
1727
- * One `CadreControl.CadrePeer` row as read by `ControlDatabase.queryCadrePeers`:
1728
- * the addressing columns plus the persisted membership voucher.
1729
- *
1730
- * The voucher triple (`stampId`, `vouchOwner`, `vouchSig`) is what
1731
- * {@link CadreNode.listAuthorizedMembers} re-checks against the node-local
1732
- * trusted-owner anchor — it is null on a row written before the voucher columns
1733
- * existed, and all three must be present for the row to be authorizable. Named
1734
- * (rather than restated inline at each read site) so a future column addition
1735
- * cannot reach the query without the predicate seeing it.
1736
- */
1737
- export interface CadrePeerRow {
1738
- /** libp2p peer ID (base58btc) — the row key. */
1739
- peerId: string;
1740
- /** Comma-joined dialable multiaddrs as stored, or null when unpublished. */
1741
- multiaddr: string | null;
1742
- /** Single-use anti-replay nonce the voucher signature is bound to. */
1743
- stampId: string | null;
1744
- /** ed25519 public key (base64url) of the owner that vouched this row. */
1745
- vouchOwner: string | null;
1746
- /** That owner's signature over `digest('CadreControl.CadrePeer', 'vouch', peerId, stampId)`, base64url. */
1747
- vouchSig: string | null;
1748
- }
1749
-
1750
- /** The voucher-bearing subset of a {@link CadrePeerRow} — what the authorized-membership predicate reads. */
1751
- export type CadrePeerVoucherFields = Pick<CadrePeerRow, 'peerId' | 'stampId' | 'vouchOwner' | 'vouchSig'>;
1752
-
1753
- /**
1754
- * One `CadreControl.Revocation` tombstone as read by
1755
- * `ControlDatabase.queryRevocations`: the identity triple plus the `ReissuedAt`
1756
- * counter an owner bumps (`ControlDatabase.reissueRevocations`) to re-write — and
1757
- * therefore re-broadcast — a tombstone that committed while the node was alone.
1758
- * Never the ledger marker: that row names no guarded table, and `queryRevocations`
1759
- * skips it, which is what keeps `tableName` a {@link RevocableTable}.
1760
- */
1761
- export interface RevocationRow {
1762
- /** Which guarded table's stamp was retired. */
1763
- tableName: RevocableTable;
1764
- /** Primary key of the removed row (peer id / key / strand id). */
1765
- rowKey: string;
1766
- /** The retired single-use nonce — the tombstone's identity. */
1767
- stampId: string;
1768
- /** Monotonic re-issue counter; carries no semantics (see the schema comment). */
1769
- reissuedAt: number;
1770
- }
1771
-
1772
- /**
1773
- * Outcome of `ControlDatabase.openRevocationLedger`: `'opened'` when this call filed the
1774
- * singleton `Revocation` ledger marker, `'already-open'` when it was already filed (seen
1775
- * by the in-lock guard, or filed first by another owner and refused on the primary key).
1776
- * Either way the marker exists afterwards.
1777
- */
1778
- export type RevocationLedgerOpenResult = 'opened' | 'already-open';
1779
-
1780
- /**
1781
- * Outcome of {@link CadreNode.registerSelf}, surfaced so callers (e.g. the CLI
1782
- * `--owner` branch) can log what happened:
1783
- * - `inserted` — the first owner-signed INSERT of this node's `CadrePeer` row.
1784
- * - `refreshed` — a self-signed UPDATE of an existing row (heartbeat / addr change).
1785
- * Also reported when a first publish found its row seated by a concurrent
1786
- * `authorizePeer` of this node's own id and fell through to the UPDATE — the write
1787
- * really was an UPDATE, even though the caller's intent was a first publish.
1788
- * - `skipped` — nothing written (no self-signing key, not yet a member with no
1789
- * owner service to self-insert, or the row was removed mid-publish).
1790
- */
1791
- export type SelfRegistrationOutcome = 'inserted' | 'refreshed' | 'skipped';
1792
-
1793
- /**
1794
- * Options for {@link CadreNode.resolvePeerAddrs}.
1795
- */
1796
- export interface ResolveOpts {
1797
- /**
1798
- * Freshness ceiling in ms. A record older than this is filtered out (a
1799
- * resolver must never hand back a dead relay reservation). Defaults to
1800
- * {@link DEFAULT_PEER_RECORD_MAX_AGE_MS}.
1801
- */
1802
- maxAgeMs?: number;
1803
- /** Return only `/p2p-circuit` signaling addrs (the WebRTC dial input). */
1804
- signalingOnly?: boolean;
1805
- /**
1806
- * Pluggable trust gate, evaluated after signature + freshness pass. Defaults
1807
- * to {@link currentMemberTrustPolicy} (any existing — therefore
1808
- * owner-vouched — CadrePeer member is trusted). Inject a stricter policy
1809
- * (e.g. from `seed-signerkey-trust-policy`) to reject otherwise-valid records
1810
- * before dialing.
1811
- */
1812
- trustPolicy?: PeerResolveTrustPolicy;
1813
- }
1814
-
1815
- /**
1816
- * Context handed to a {@link PeerResolveTrustPolicy} when gating a resolution.
1817
- */
1818
- export interface PeerResolveContext {
1819
- /** The peer being resolved. */
1820
- peerId: string;
1821
- /** The record's ed25519 public key (already bound to `peerId` and verified). */
1822
- publicKey: string;
1823
- /** The party whose control network produced the record. */
1824
- partyId: string;
1825
- /** The full verified, fresh record. */
1826
- record: PeerAddressRecord;
1827
- }
1828
-
1829
- /**
1830
- * Decides whether a signature-verified, fresh peer-address record should be
1831
- * trusted enough to dial. The seam through which a richer trust model
1832
- * (trust circle, pinned keys) composes with `resolvePeerAddrs`.
1833
- */
1834
- export interface PeerResolveTrustPolicy {
1835
- evaluate(ctx: PeerResolveContext): Promise<boolean> | boolean;
1836
- }
1837
-
1838
- // ============================================================================
1839
- // Device Token Registry Types
1840
- // ============================================================================
1841
-
1842
- /** Platform push channel a {@link DeviceTokenRecord} targets. */
1843
- export type PushPlatform = 'fcm' | 'apns';
1844
-
1845
- /**
1846
- * A mobile cadre peer's self-published, freshness-stamped, self-signed platform
1847
- * push token.
1848
- *
1849
- * This is the logical shape of a `CadreControl.DeviceToken` row. A phone publishes
1850
- * its own record (signing with the ed25519 key behind its PeerId); a server peer
1851
- * resolves it from the phone's PeerId to deliver a push-wake over FCM/APNs when a
1852
- * control-network dial cannot reach the OS-suspended process. The signature covers
1853
- * `peerId`, `platform`, `token`, and `updatedAt` — see `deviceTokenSignedPayload`.
1854
- *
1855
- * Unlike {@link PeerAddressRecord}, the record carries NO public key: it is verified
1856
- * against the `CadrePeer.PublicKey` bound to the same `peerId`, so a `DeviceToken`
1857
- * with no backing `CadrePeer` row can never be resolved.
1858
- */
1859
- export interface DeviceTokenRecord {
1860
- /** libp2p peer ID (base58btc) — the row key and the CadrePeer this token belongs to. */
1861
- peerId: string;
1862
- /** Push channel: `'fcm'` (Android/Firebase) or `'apns'` (Apple). */
1863
- platform: PushPlatform;
1864
- /** Opaque platform device/registration token. */
1865
- token: string;
1866
- /** epoch ms — freshness; strictly increasing per self-update (replay guard). */
1867
- updatedAt: number;
1868
- /** ed25519 self-signature over the signed payload, base64url. */
1869
- sig: string;
1870
- }
1871
-
1872
- /**
1873
- * A stored `CadreControl.DeviceToken` row: the self-signed
1874
- * {@link DeviceTokenRecord} plus the row's single-use `StampId` nonce.
1875
- *
1876
- * The stamp is deliberately OUTSIDE `DeviceTokenRecord`: the peer's own `Sig`
1877
- * covers only (peerId, platform, token, updatedAt), while the stamp is bound by
1878
- * the OWNER's insert/delete approvals. Readers need it to drop a row whose stamp
1879
- * has been retired into `CadreControl.Revocation` (a token resurrected by an
1880
- * approval replay on a node that had not yet converged on the tombstone).
1881
- */
1882
- export interface DeviceTokenRow extends DeviceTokenRecord {
1883
- /** Single-use authorization nonce; rotates on every (re)insert. */
1884
- stampId: string;
1885
- }
1886
-
1887
- /**
1888
- * Options for {@link CadreNode.resolveDeviceToken}.
1889
- */
1890
- export interface ResolveDeviceTokenOpts {
1891
- /**
1892
- * Optional freshness ceiling in ms. Defaults to NO ceiling (a record is fresh
1893
- * as long as its `updatedAt` is positive): unlike a relay reservation, a device
1894
- * push token stays valid until it rotates, so a long-suspended phone must remain
1895
- * resolvable for push-wake. Set this to bound staleness explicitly.
1896
- */
1897
- maxAgeMs?: number;
1898
- }
1899
-
1900
- // ============================================================================
1901
- // Push Credentials (platform push delivery — see push-notifier.ts)
1902
- // ============================================================================
1903
-
1904
- /**
1905
- * Platform push-delivery credentials injected into a participating node's config
1906
- * (`CadreNodeConfig.push`). Provisioned per spawned node by `cadre-host` and read
1907
- * by `createPushNotifier` to construct the FCM and/or APNs senders. A platform
1908
- * whose block is absent is simply not deliverable (the router returns a
1909
- * best-effort "no credentials" failure rather than throwing).
1910
- *
1911
- * Secret hygiene: every `privateKey` field is a secret — never logged (treated
1912
- * like the node's startup/seed tokens).
1913
- */
1914
- export interface PushCredentials {
1915
- /** FCM (Android / Firebase) service-account credentials. */
1916
- fcm?: FcmCredentials;
1917
- /** APNs (Apple) auth-key credentials. */
1918
- apns?: ApnsCredentials;
1919
- /**
1920
- * Per-`(peer, strand)` minimum gap between push-wakes the server fan-out emits —
1921
- * the anti-spam cooldown a chatty strand needs. Default
1922
- * {@link DEFAULT_PUSH_COOLDOWN_MS} (5 min). In-memory, acceptably lossy across
1923
- * restarts (`serviceWake` is idempotent, so a duplicate wake is harmless).
1924
- */
1925
- cooldownMs?: number;
1926
- /**
1927
- * Per-strand burst-coalescing window for the fan-out: a second activity trigger
1928
- * within this window is dropped (one wake is enough). Default
1929
- * {@link DEFAULT_PUSH_DEBOUNCE_MS} (10 s).
1930
- */
1931
- debounceMs?: number;
1932
- }
1933
-
1934
- /** Google service-account fields needed for FCM HTTP v1 OAuth2. */
1935
- export interface FcmCredentials {
1936
- /** GCP project id → `POST https://fcm.googleapis.com/v1/projects/{projectId}/messages:send`. */
1937
- projectId: string;
1938
- /** Service-account email (JWT `iss`/`sub`). */
1939
- clientEmail: string;
1940
- /** Service-account RSA private key, PEM. Secret — never logged. */
1941
- privateKey: string;
1942
- }
1943
-
1944
- /** Apple APNs auth-key (.p8) fields. */
1945
- export interface ApnsCredentials {
1946
- /** APNs key id (JWT header `kid`). */
1947
- keyId: string;
1948
- /** Apple team id (JWT `iss`). */
1949
- teamId: string;
1950
- /** App bundle id → `apns-topic` header. */
1951
- bundleId: string;
1952
- /** `.p8` ES256 private key, PEM. Secret — never logged. */
1953
- privateKey: string;
1954
- /** `true` → `api.push.apple.com`; false/undefined → `api.sandbox.push.apple.com`. */
1955
- production?: boolean;
1956
- }
1957
-
1958
- // ============================================================================
1959
- // Seed Bootstrap API Types (from architecture.md)
1960
- // ============================================================================
1961
-
1962
- /**
1963
- * A peer entry in a control network seed.
1964
- * Contains connection information for a cadre peer.
1965
- */
1966
- export interface SeedPeer {
1967
- /** Peer ID (libp2p identity) */
1968
- peerId: string;
1969
- /** Multiaddrs for connecting to this peer */
1970
- multiaddrs: string[];
1971
- /** Whether this peer holds an owner key */
1972
- isOwner: boolean;
1973
- /** ed25519 public key (base64url) — present on owner peers for signerKey verification */
1974
- publicKey?: string;
1975
- }
1976
-
1977
- /**
1978
- * Control network seed for bootstrapping new nodes.
1979
- * Pre-populates a new node's cache to solve the cold-start problem:
1980
- * new nodes need control data to validate connections, but can't get
1981
- * data without connecting first.
1982
- */
1983
- export interface ControlNetworkSeed {
1984
- /** Party ID this seed belongs to */
1985
- partyId: string;
1986
- /** Known peers in the cadre */
1987
- peers: SeedPeer[];
1988
- /** Signature over the seed by an owner key */
1989
- signature: string;
1990
- /** The owner key that signed this seed */
1991
- signerKey: string;
1992
- }
1993
-
1994
- /**
1995
- * Message sent from instigator to new node during seed delivery.
1996
- * Delivered via /sereus/seed/1.0.0 libp2p protocol.
1997
- */
1998
- export interface SeedMessage {
1999
- /** Party ID for the cadre */
2000
- partyId: string;
2001
- /** Known peers in the cadre */
2002
- peers: SeedPeer[];
2003
- /** Signature by an owner key */
2004
- signature: string;
2005
- /** The owner key that signed this message */
2006
- signerKey: string;
2007
- }
2008
-
2009
- /**
2010
- * Acknowledgment message from new node to instigator.
2011
- */
2012
- export interface SeedAckMessage {
2013
- /** Whether the seed was accepted */
2014
- accepted: boolean;
2015
- /** Reason for rejection (if not accepted) */
2016
- reason?: string;
2017
- }
2018
-
2019
- // ============================================================================
2020
- // Strand Wake (control-network push-wake protocol) — see strand-wake-protocol.ts
2021
- // ============================================================================
2022
-
2023
- /**
2024
- * Request sent over the control network's `WAKE_PROTOCOL` from a same-cadre peer
2025
- * to a hibernating peer, asking it to bring a strand online and pull pending
2026
- * activity. The receiver gates this on cadre membership before honoring it.
2027
- */
2028
- export interface WakeRequest {
2029
- /** The strand the sender knows has pending activity. */
2030
- strandId: string;
2031
- /** Optional cause hint, e.g. `"activity"` (a server saw new traffic) or `"manual"`. */
2032
- reason?: string;
2033
- }
2034
-
2035
- /**
2036
- * Acknowledgment of a {@link WakeRequest}, returned on the same stream.
2037
- */
2038
- export interface WakeAck {
2039
- /** Whether the receiver honored the wake (member + participated strand). */
2040
- accepted: boolean;
2041
- /**
2042
- * The strand's status when the receiver accepted the wake (present when `accepted`). The
2043
- * receiver replies before the wake runs, so `hibernating` or `idle` here means "a wake was
2044
- * started", not "the strand is now up".
2045
- */
2046
- status?: StrandStatus;
2047
- /** Reason for rejection (non-member, unknown strand, malformed frame). */
2048
- reason?: string;
2049
- }
2050
-
2051
- // ============================================================================
2052
- // Strand Address (control-network strand-address RPC) — see strand-addr-protocol.ts
2053
- // ============================================================================
2054
-
2055
- /**
2056
- * Control-network request from one of a party's own cadre nodes to a sibling,
2057
- * asking "what are your live multiaddrs for this strand's separate network?".
2058
- * Used to seed a strand mesh from co-cadre nodes that already run that strand.
2059
- * The receiver gates this on cadre membership before answering.
2060
- */
2061
- export interface StrandAddrRequest {
2062
- /** The strand whose strand-network address the requester wants to seed from. */
2063
- strandId: string;
2064
- /**
2065
- * The derived transport peerId the requester's own strand-`strandId` node
2066
- * runs as (see `strand-transport-key.ts`). Present → the responder records a
2067
- * delegate admission grant for it, so its own connection gate (and thus its
2068
- * circuit-relay server, when it runs one) admits that peerId; absent →
2069
- * today's behavior exactly. See `delegate-admission.ts` for the trust model.
2070
- */
2071
- delegatePeerId?: string;
2072
- }
2073
-
2074
- /**
2075
- * How a strand-addr responder handled a {@link StrandAddrRequest}. Carried so the
2076
- * asker can tell "I have nothing" from "I could not answer" — the two need
2077
- * different retry timing, and an empty address list alone cannot say which.
2078
- *
2079
- * - `ok` — looked up; `multiaddrs` is the truth, possibly empty (strand not running here).
2080
- * - `unavailable` — the responder could not answer: over its concurrency cap, the
2081
- * request was unreadable, or its own lookup threw (e.g. a control-database read failed).
2082
- * - `refused` — the requester is not an authorized member in the responder's
2083
- * current view (possibly because its `CadrePeer` row has not replicated there yet);
2084
- * any `delegatePeerId` it carried was NOT recorded.
2085
- */
2086
- export type StrandAddrStatus = 'ok' | 'unavailable' | 'refused';
2087
-
2088
- /**
2089
- * Response to a {@link StrandAddrRequest}, carrying the responder's strand-node
2090
- * multiaddrs, returned on the same stream.
2091
- */
2092
- export interface StrandAddrResponse {
2093
- /** How the responder handled the request; a reply without one is malformed. */
2094
- status: StrandAddrStatus;
2095
- /** Echoes the requested strand id; always empty on an `unavailable` reply. */
2096
- strandId: string;
2097
- /**
2098
- * Dialable strand-network multiaddr strings (signaling/`p2p-circuit` first);
2099
- * empty unless `status` is `ok` and the responder runs that strand.
2100
- */
2101
- multiaddrs: string[];
2102
- }
2103
-
2104
- /**
2105
- * Options for authorizing a new peer in the cadre.
2106
- */
2107
- export interface AuthorizePeerOptions {
2108
- /** Peer ID to authorize */
2109
- peerId: string;
2110
- /** Optional multiaddrs for the peer */
2111
- multiaddrs?: string[];
2112
- }
2113
-
2114
- /**
2115
- * Result of applying a seed to a node.
2116
- */
2117
- export interface ApplySeedResult {
2118
- /** Whether the seed was successfully applied */
2119
- success: boolean;
2120
- /** Number of peers added to the peer store */
2121
- peersAdded: number;
2122
- /** Error message if not successful */
2123
- error?: string;
2124
- /**
2125
- * Owner-flagged seed peers this apply attempted to dial. Zero when the seed
2126
- * carried no owner peer with an address, or when the seed was rejected before
2127
- * the dial loop ran.
2128
- */
2129
- ownerDialsAttempted: number;
2130
- /**
2131
- * How many of those dials threw. `ownerDialsAttempted > 0 && ownerDialsFailed
2132
- * === ownerDialsAttempted` means "seeded but stranded": the seed itself was
2133
- * accepted (`success: true`) but no owner could be reached, so this node has
2134
- * no connection to bootstrap its control database from. The node retries those
2135
- * addresses on every control-cohort reconcile pass (see
2136
- * `CadreNode.dialColdStartBootstrap`), so this is a signal, not a fatal error.
2137
- *
2138
- * Zero failures is NOT proof of a live connection: the receiving node's
2139
- * membership gate denies AFTER the dialer's upgrade completes (see
2140
- * `createMembershipConnectionGater`), so a dial can resolve and then be torn
2141
- * down. Treat a non-zero value as certain failure and zero as "no failure
2142
- * observed".
2143
- */
2144
- ownerDialsFailed: number;
2145
- }
2146
-
2147
- // ============================================================================
2148
- // Seed Helper Types
2149
- // ============================================================================
2150
-
2151
- /**
2152
- * Node network topology type for seed helper scenarios.
2153
- */
2154
- export type NodeTopology = 'public' | 'nat';
2155
-
2156
- /**
2157
- * Invite code for phone-to-server enrollment.
2158
- * Used when a server invites a phone to join the cadre.
2159
- */
2160
- export interface CadreInvite {
2161
- /** Party ID of the cadre */
2162
- partyId: string;
2163
- /** Multiaddrs to dial the owner */
2164
- ownerAddrs: string[];
2165
- /**
2166
- * Owner ed25519 public keys (base64url) of the cadre, carried out-of-band
2167
- * so a cold-start invitee can pin the trusted owner set before applying
2168
- * any seed (the seed itself cannot vouch for its own signer). Populated by
2169
- * `createInvite` from the issuer's OWN node-local trusted-owner anchor —
2170
- * not its replicated `OwnerKey` table, because the invitee anchors whatever
2171
- * arrives here and a stranger's genesis-inserted key must not ride an
2172
- * otherwise-legitimate invite into a fresh node's anchor.
2173
- */
2174
- ownerKeys?: string[];
2175
- /** Optional invite token for validation */
2176
- token?: string;
2177
- /** Timestamp when invite was created */
2178
- createdAt: number;
2179
- /** Optional expiration timestamp */
2180
- expiresAt?: number;
2181
- }
2182
-
2183
- /**
2184
- * Options for adding a drone via provider API.
2185
- */
2186
- export interface AddDroneOptions {
2187
- /** Peer ID of the new drone (returned from provider) */
2188
- dronePeerId: string;
2189
- /** Multiaddrs of the drone (returned from provider) */
2190
- droneMultiaddrs: string[];
2191
- }
2192
-
2193
- /**
2194
- * Options for adding a phone via invite flow.
2195
- */
2196
- export interface AddPhoneOptions {
2197
- /** Peer ID of the phone (sent by phone when it connects) */
2198
- phonePeerId: string;
2199
- /** Invite token for validation (must match issued invite) */
2200
- token?: string;
2201
- }
2202
-
2203
- /**
2204
- * Result of preparing a seed for drone initialization.
2205
- */
2206
- export interface DroneInitResult {
2207
- /** The seed to send to the provider API */
2208
- seed: ControlNetworkSeed;
2209
- /** Base64url encoded seed for transport */
2210
- encodedSeed: string;
2211
- }
2212
-
2213
- /**
2214
- * Result of creating an invite for a phone.
2215
- */
2216
- export interface InviteResult {
2217
- /** The invite to share out-of-band */
2218
- invite: CadreInvite;
2219
- /** Base64url encoded invite for QR/link */
2220
- encodedInvite: string;
2221
- }
2222
-
1
+ import type { ConnectionGater, Libp2p, PeerId, PrivateKey } from '@libp2p/interface';
2
+ import type { CircuitRelayServerInit } from '@libp2p/circuit-relay-v2';
3
+ import type { IRawStorage, Libp2pConnectionMonitorInit, Libp2pTransports, NoiseCryptoInterface } from '@optimystic/db-p2p';
4
+ import type { IPeerNetwork, IRepo } from '@optimystic/db-core';
5
+ import type { PeerJoinBackfillConfig } from './peer-join-backfill.js';
6
+ import type { StrandRevocationEnforcementConfig } from './strand-revocation-enforcer.js';
7
+ import type { StrandMembershipReconciliationConfig } from './strand-membership-reconciler.js';
8
+ import type { StrandFirstSyncConfig } from './strand-first-sync-gate.js';
9
+ import type { StrandDatabase } from './strand-database.js';
10
+ import type { SeedTrustPolicy } from './seed-trust-policy.js';
11
+ import type { KeyStore, KeyId } from './key-store.js';
12
+ import type { TrustedOwnerStore, TrustSource } from './trusted-owner-store.js';
13
+ import type { BootstrapPeerStore } from './bootstrap-peer-store.js';
14
+ import type { StrandNetworkStateStore } from './strand-network-state.js';
15
+ import type { EnrolledMachineStore } from './enrolled-machine-store.js';
16
+ import type { JoinedStrandStore } from './joined-strand-store.js';
17
+ import type { PushNotifier } from './push-notifier.js';
18
+ import type { RevocableTable } from './control-authorization.js';
19
+ import type { ControlRetryAbandonment } from './control-retry.js';
20
+ import type { FormationRejectionCode } from './strand-formation-rejection.js';
21
+
22
+ /**
23
+ * Extended Libp2p node with the coordinatedRepo attached by db-p2p's
24
+ * createLibp2pNode after node creation (not surfaced on the base Libp2p type).
25
+ */
26
+ export interface Libp2pNodeWithRepo extends Libp2p {
27
+ coordinatedRepo: IRepo;
28
+ /**
29
+ * The node's key-addressed peer network, attached by db-p2p's factory alongside
30
+ * `coordinatedRepo` (`libp2p-node-base.ts`). Optional deliberately: the base
31
+ * factory always assigns it, but it is not part of the upstream node type, so
32
+ * declaring it required would be a claim this repo cannot enforce. The strand
33
+ * backfill (`peer-join-backfill.ts`) logs once and stays inert when it is absent.
34
+ */
35
+ keyNetwork?: IPeerNetwork;
36
+ }
37
+
38
+ /**
39
+ * Node profile determines storage participation
40
+ */
41
+ export type NodeProfile = 'transaction' | 'storage';
42
+
43
+ /**
44
+ * Strand filter configuration - determines which strands this node participates in
45
+ */
46
+ export type StrandFilter =
47
+ | { mode: 'all' } // All strands (default for servers)
48
+ | { mode: 'sAppId'; sAppId: string } // Only strands for specific sApp
49
+ | { mode: 'strandId'; strandId: string } // Single specific strand
50
+ | { mode: 'none' }; // Control network only
51
+
52
+ /**
53
+ * Latency hint for strand hibernation behavior
54
+ */
55
+ export type LatencyHint = 'realtime' | 'interactive' | 'background' | 'archive';
56
+
57
+ /**
58
+ * Hibernation timeout configuration per latency hint (in milliseconds)
59
+ */
60
+ export interface HibernationTimeouts {
61
+ /** Time before transitioning from active to idle */
62
+ idleTimeout: number;
63
+ /** Time before transitioning from idle to hibernating */
64
+ hibernateTimeout: number;
65
+ /**
66
+ * Base interval for the FIRST check-in after hibernating. Successive
67
+ * no-activity check-ins escalate this delay by {@link checkInBackoffFactor}
68
+ * (see {@link HibernationManager}'s self-rescheduling check-in chain).
69
+ */
70
+ checkInInterval: number;
71
+ /**
72
+ * Multiplier applied to the check-in delay after each check-in that finds no
73
+ * pending activity (must be >= 1; 1 disables escalation). Default 2 →
74
+ * doubling: base, base×2, base×4, … until {@link checkInMaxInterval}.
75
+ */
76
+ checkInBackoffFactor: number;
77
+ /**
78
+ * Upper bound on the escalating check-in delay — the concrete realization of
79
+ * the architecture's "minutes → hours → days" ceiling for this hint. Backoff
80
+ * never schedules a delay longer than this.
81
+ */
82
+ checkInMaxInterval: number;
83
+ }
84
+
85
+ /**
86
+ * Default hibernation timeouts per latency hint.
87
+ *
88
+ * `checkInInterval` is the BASE delay; `checkInMaxInterval` is the per-hint
89
+ * ceiling the exponential backoff escalates toward (interactive minutes→~1h,
90
+ * background minutes→~6h, archive ~1h→~3 days).
91
+ */
92
+ export const HIBERNATION_TIMEOUTS: Record<LatencyHint, HibernationTimeouts> = {
93
+ realtime: {
94
+ idleTimeout: Infinity, // Never idle
95
+ hibernateTimeout: Infinity, // Never hibernate
96
+ checkInInterval: Infinity, // N/A
97
+ checkInBackoffFactor: 2,
98
+ checkInMaxInterval: Infinity // N/A
99
+ },
100
+ interactive: {
101
+ idleTimeout: 5 * 60 * 1000, // 5 minutes
102
+ hibernateTimeout: 15 * 60 * 1000, // 15 minutes after idle
103
+ checkInInterval: 30 * 1000, // base: 30 seconds
104
+ checkInBackoffFactor: 2,
105
+ checkInMaxInterval: 60 * 60 * 1000 // cap: ~1 hour
106
+ },
107
+ background: {
108
+ idleTimeout: 1 * 60 * 1000, // 1 minute
109
+ hibernateTimeout: 5 * 60 * 1000, // 5 minutes after idle
110
+ checkInInterval: 5 * 60 * 1000, // base: 5 minutes
111
+ checkInBackoffFactor: 2,
112
+ checkInMaxInterval: 6 * 60 * 60 * 1000 // cap: ~6 hours
113
+ },
114
+ archive: {
115
+ idleTimeout: 10 * 1000, // 10 seconds
116
+ hibernateTimeout: 30 * 1000, // 30 seconds after idle
117
+ checkInInterval: 60 * 60 * 1000, // base: 1 hour
118
+ checkInBackoffFactor: 2,
119
+ checkInMaxInterval: 3 * 24 * 60 * 60 * 1000 // cap: ~3 days
120
+ }
121
+ };
122
+
123
+ /**
124
+ * Storage provider - either an `IRawStorage` instance or a factory function.
125
+ * Factory functions are useful for creating per-scope storage instances.
126
+ *
127
+ * **The argument is an opaque scope key.** Use it directly as a file name, directory
128
+ * name or database name: every key stays within `[a-z0-9._-]`, so no escaping is
129
+ * needed and none should be assumed, and two different keys are two different names
130
+ * even on a filesystem that ignores case (Windows, macOS). Do not parse it;
131
+ * `controlStorageScope` / `isControlStorageScope` (`storage-scope.ts`) are the supported
132
+ * way to mint and recognize the control key. The control key holds the charset by
133
+ * lowercase hex encoding;
134
+ * a strand's key — its strand id, which may have replicated in from another node in
135
+ * the party — holds it because `StrandInstanceManager.startStrand` runs
136
+ * `assertStrandScopeKey` on every launch and refuses a strand that fails it.
137
+ *
138
+ * **Called once per scope per runtime lifetime.** The scopes are the control database
139
+ * (once per `CadreNode.start()`) and each strand id (once per `startStrand`).
140
+ * Hibernation — `quiesceStrand` then `resumeStrand` — reuses the store already
141
+ * resolved for that strand and does NOT re-enter this callback.
142
+ *
143
+ * **The control scope is party-specific.** It is `controlStorageScope(partyId)`, not a
144
+ * fixed string, because the control database holds one party's own records — its
145
+ * strands, owner keys, peers, invitations, revocations. Two parties on one device ask
146
+ * for two different keys and MUST get two different stores, or a node started for one
147
+ * party reads the other's rows as its own.
148
+ *
149
+ * It IS re-entered for a scope after that scope's runtime has stopped
150
+ * (`stopStrand`, or a `stop()` then `start()` cycle on one `CadreNode`), so it must
151
+ * be able to hand back a store over the same durable backend a second time. A
152
+ * factory may mint a fresh object per call; it must not *need* to.
153
+ *
154
+ * cadre-core disposes only its own cache wrapper — it never closes the store you
155
+ * returned. Closing the underlying handle stays the embedder's job.
156
+ *
157
+ * **The single-instance form shares data across scopes by construction** — one store
158
+ * for every strand AND for every party's control database. An embedder that can serve
159
+ * more than one party must use the factory form. One instance handed to every scope
160
+ * also shares ONE cache wrapper (`wrapStorageWithCache` memoizes per inner instance),
161
+ * which counts its holders: a scope stopping releases only its own claim, and the
162
+ * wrapper is retired when the last scope releases it. The factory form is the better
163
+ * default for the further reason that it partitions each strand's data, which
164
+ * simplifies cleanup.
165
+ */
166
+ export type RawStorageProvider = IRawStorage | ((scope: string) => IRawStorage);
167
+
168
+ /**
169
+ * Storage configuration for storage profile nodes
170
+ */
171
+ export interface StorageConfig {
172
+ /**
173
+ * Storage provider - either an IRawStorage instance or a factory function.
174
+ * See {@link RawStorageProvider} for how often cadre-core calls it, and for why
175
+ * the single-instance form cannot serve more than one party.
176
+ *
177
+ * The `scope` a factory receives is an opaque key already safe as a path or
178
+ * database-name segment (always within `[a-z0-9._-]`), so each example below
179
+ * interpolates it directly.
180
+ *
181
+ * For Node.js environments, use FileRawStorage from @optimystic/db-p2p-storage-fs:
182
+ * ```typescript
183
+ * import { FileRawStorage } from '@optimystic/db-p2p-storage-fs';
184
+ * storage: { provider: (scope) => new FileRawStorage(`./data/${scope}`) }
185
+ * ```
186
+ *
187
+ * For React Native, use the appropriate storage from @optimystic/db-p2p-storage-rn:
188
+ * ```typescript
189
+ * import { LevelDBRawStorage } from '@optimystic/db-p2p-storage-rn';
190
+ * // `db` is an open LevelDB handle (see `openOptimysticRNDb` over `rn-leveldb`)
191
+ * storage: { provider: (scope) => new LevelDBRawStorage(openDb(`sereus-${scope}`)) }
192
+ * ```
193
+ *
194
+ * For in-memory storage (testing):
195
+ * ```typescript
196
+ * import { MemoryRawStorage } from '@optimystic/db-p2p';
197
+ * storage: { provider: () => new MemoryRawStorage() }
198
+ * ```
199
+ */
200
+ provider: RawStorageProvider;
201
+ quotaBytes?: number;
202
+ }
203
+
204
+ /**
205
+ * Network configuration for libp2p
206
+ */
207
+ export interface NetworkConfig {
208
+ /**
209
+ * Addresses this machine's libp2p nodes bind. Omitted, `@optimystic/db-p2p`'s own
210
+ * default (`/ip4/0.0.0.0/tcp/0`) applies; `[]` means "cannot listen" and is honored
211
+ * as written (React Native hosts rely on that).
212
+ *
213
+ * The machine runs one control node plus one node per strand, so a FIXED port here
214
+ * can only belong to one of them. The control node binds it as configured; each
215
+ * strand node binds the same entry with its port rewritten to `0`, keeping the
216
+ * interface and transport (`/ws`, a specific-interface bind). A
217
+ * `<relay>/p2p-circuit` entry is untouched — the port inside it is the relay's, not
218
+ * a local bind. See `strand-network-config.ts`.
219
+ *
220
+ * **Only TCP, WebSocket (`/ws`, `/wss`) and circuit-relay addresses are bindable**
221
+ * unless {@link transports} supplies the factories for something else. A `/ws` entry
222
+ * switches the WebSocket transport on by itself; anything else — `/quic-v1`,
223
+ * `/webrtc`, `/webtransport` — refuses node start naming the address and the libp2p
224
+ * package it would need, rather than being dropped in silence by libp2p's transport
225
+ * manager. See `relay-addrs.ts` → `resolveTransportOptions`.
226
+ */
227
+ listenAddrs?: string[];
228
+ /**
229
+ * Addresses to advertise to peers **instead of** `listenAddrs` — for a node behind
230
+ * NAT or a reverse proxy that is reachable at a different address than it binds
231
+ * (e.g. `mynode.example.com:4001` in front of a `0.0.0.0:4001` listener). Reaches
232
+ * libp2p's `addresses.announce` via `@optimystic/db-p2p`'s `NodeOptions.announceAddrs`.
233
+ *
234
+ * **Applies to this machine's CONTROL node only.** Strand nodes drop it: any concrete
235
+ * entry names a port, and that port is the control node's, so a strand node
236
+ * advertising it would send peers to the wrong node (`strand-network-config.ts`).
237
+ * Entries are still validated at node start whichever node ends up using them.
238
+ *
239
+ * **A non-empty value REPLACES the advertised set entirely.** Observed addresses,
240
+ * and the `/p2p-circuit` address earned from a {@link relayAddrs} reservation, are
241
+ * all dropped from what peers are told — so a node configured with both this and
242
+ * `relayAddrs` stops being reachable through its relay. `CadreNode.start()` warns
243
+ * when it sees that combination; it does not refuse it, since a node whose relay
244
+ * slot is decorative may legitimately want only the announced address.
245
+ *
246
+ * Reach for {@link appendAnnounceAddrs} instead in the common case — one publicly
247
+ * reachable address ADDED to everything else the node advertises. Or for
248
+ * {@link relayAddrs} alone, when the node has no stable public address to name and
249
+ * a relay reservation is what makes it dialable. (`inviteAddressResolver` is a
250
+ * narrower tool again: it substitutes a reachable address into invite payloads
251
+ * only, and changes nothing about what this node advertises on the wire.)
252
+ *
253
+ * An empty array means "unset" — it is dropped rather than forwarded, so it cannot
254
+ * land as an explicit empty announce set. An empty *entry* is a different thing and
255
+ * is rejected: a list of one blank string is non-empty, so it would replace every
256
+ * advertised address with nothing. Malformed entries are rejected on the same terms,
257
+ * both at node start — libp2p itself would not notice either until its first address
258
+ * lookup (see `announce-addrs.ts`), so this repo checks each entry up front.
259
+ */
260
+ announceAddrs?: string[];
261
+ /**
262
+ * Addresses to advertise **in addition to** `listenAddrs` — the field most
263
+ * deployments actually want, since it makes a node reachable at a public address
264
+ * without discarding the observed and `/p2p-circuit` addresses it would otherwise
265
+ * advertise. Reaches libp2p's `addresses.appendAnnounce` via
266
+ * `@optimystic/db-p2p`'s `NodeOptions.appendAnnounceAddrs`.
267
+ *
268
+ * **Control node only**, on the same terms as {@link announceAddrs}.
269
+ *
270
+ * **Ignored while {@link announceAddrs} is non-empty** — that is libp2p's own
271
+ * precedence, applied upstream; this repo does not merge the two locally. Set one
272
+ * or the other, not both.
273
+ *
274
+ * Empty-array, blank-entry and malformed-entry handling all match {@link announceAddrs}.
275
+ */
276
+ appendAnnounceAddrs?: string[];
277
+ /**
278
+ * Circuit-relay servers this node reserves a slot on, so peers can reach it
279
+ * from behind NAT. Each entry is the relay's DIRECT dial multiaddr ending in
280
+ * its peerId — `/dns4/relay.example.com/tcp/4001/p2p/12D3KooW…` — with no
281
+ * `/p2p-circuit` suffix (one is appended for you; an entry that already has
282
+ * one is passed through unchanged).
283
+ *
284
+ * On the CONTROL node this adds libp2p's bare `/p2p-circuit` SEARCH listener to
285
+ * {@link listenAddrs} (`relay-addrs.ts`) and `CadreNode.start()` drives the
286
+ * reservation itself once the control database is up — the ordering matters,
287
+ * because a listener that dials its relay from inside `libp2p.start()` put a
288
+ * sibling in this node's cohort before its own database existed, and a sibling
289
+ * that had not yet replicated this node's membership row refused the bring-up.
290
+ * STRAND nodes take the same search shape, one bare listener plus one reservation
291
+ * supervisor PER relay, fail-soft (`strand-network-config.ts`,
292
+ * `strand-instance-manager.ts`). A relay named here also becomes a
293
+ * delegate-announce target, so this node's strand nodes may reserve on it too
294
+ * (see `delegate-admission.ts`).
295
+ *
296
+ * Setting this gives the node a listener even when `listenAddrs` is `[]` —
297
+ * that is the point of naming a relay, but note that React Native hosts
298
+ * (`reference-app-rn`'s `cadre-phone.ts`) deliberately do NOT listen, and
299
+ * must not acquire a listener by accident.
300
+ *
301
+ * FAIL-FAST both ways: a malformed entry throws at node start rather than being
302
+ * silently dropped, and — while {@link requireRelay} stays at its default —
303
+ * a first reservation attempt that lands no `/p2p-circuit` address throws
304
+ * `RelayReservationFailedError` out of `start()`. The fail-soft posture over
305
+ * the same machinery is `CadreNode.reserveRelays()`.
306
+ */
307
+ relayAddrs?: string[];
308
+ /**
309
+ * Must a relay named in {@link relayAddrs} have granted a reservation before
310
+ * `start()` is allowed to succeed? Default `true`: an operator who names a
311
+ * relay is telling this machine it has no other reachability, so a relay
312
+ * that will not have us on the first attempt should stop the boot loudly
313
+ * rather than leave a node nobody can reach.
314
+ *
315
+ * Set `false` for a node that must still boot with no network — a phone or a
316
+ * browser tab. The first attempt is still driven at the same point in
317
+ * `start()`; a lost reservation just logs instead of throwing, and the
318
+ * retry supervisor keeps trying in the background exactly as it does after a
319
+ * `CadreNode.reserveRelays()` call. A caller on this posture cannot infer
320
+ * dialability from `start()` resolving — read
321
+ * {@link CadreNode.getRelayReservationState} to find out.
322
+ *
323
+ * It buys a node that BOOTS, not a node that boots fast: `start()` still waits
324
+ * out that first attempt, which costs the drive's whole timeout
325
+ * (`DEFAULT_RELAY_RESERVE_TIMEOUT_MS`, four link round trips at
326
+ * {@link linkRoundTripMs} plus two admission decisions — 18 s at its default) against a relay
327
+ * that is unreachable
328
+ * rather than merely refusing (`relay-reservation.ts` polls to the deadline, in
329
+ * case libp2p's own discovery lands a reservation independently).
330
+ *
331
+ * Softens only the RESERVATION half of {@link relayAddrs}'s fail-fast
332
+ * contract. A malformed `relayAddrs` entry still throws at config
333
+ * resolution regardless of this setting — a typo is an operator error
334
+ * whatever the posture — and a hand-written `<relay>/p2p-circuit` entry in
335
+ * {@link listenAddrs} is still rejected on the control node, since that
336
+ * rejection is about the listener shape, not about whether reachability is
337
+ * required.
338
+ *
339
+ * Has no effect on STRAND nodes, which are already fail-soft over
340
+ * `relayAddrs` regardless of this field (`strand-network-config.ts`,
341
+ * `strand-instance-manager.ts`).
342
+ */
343
+ requireRelay?: boolean;
344
+ /**
345
+ * Enable circuit relay server - allows this node to relay connections for other peers.
346
+ * When undefined, defaults to true for storage profile nodes (they typically have
347
+ * better connectivity and uptime), false for transaction profile nodes.
348
+ */
349
+ enableRelay?: boolean;
350
+ /**
351
+ * Settings for this machine's circuit-relay SERVER, handed to `@libp2p/circuit-relay-v2`'s
352
+ * `circuitRelayServer(...)` (through db-p2p's `NodeOptions.relayServerInit`) on the control
353
+ * node and on every strand node. Only meaningful while {@link enableRelay} is on.
354
+ *
355
+ * Omitted, a party-run relay FORWARDS WITHOUT LIMIT: `reservations.applyDefaultLimit` is
356
+ * `false`, where libp2p's own default caps every relayed connection at 128 KiB or two
357
+ * minutes and resets it — which cut off any database sync or chat history forwarded
358
+ * through a party's own always-on machine. The store also holds
359
+ * `PARTY_RELAY_MAX_RESERVATIONS` (128) reservations instead of libp2p's 15, and each lives
360
+ * `PARTY_RELAY_RESERVATION_TTL_MS` (2 h) unrefreshed. See `relay-server.ts`.
361
+ *
362
+ * A value is MERGED over those defaults, not substituted for them: `reservations` key by
363
+ * key, every other top-level key as given. Setting only `reservations.maxReservations`
364
+ * keeps the limit off; `reservations: { applyDefaultLimit: true }` is how to turn libp2p's
365
+ * cap back on.
366
+ *
367
+ * The accepted tradeoff of the unlimited default: once a slot is granted, nothing caps what
368
+ * is forwarded through it, and a holder that keeps refreshing keeps it indefinitely — the
369
+ * 2 h lifetime only reclaims a slot whose holder went away. On the control node a peer it
370
+ * cannot place as a party member is bounded by COUNT ({@link unauthorizedRelayReservationCap}).
371
+ * A strand node's relay server has no such budget (its peers are cross-party by design), so
372
+ * any peer that reaches it is bounded only by `maxReservations`.
373
+ */
374
+ relayServerInit?: CircuitRelayServerInit;
375
+ /**
376
+ * Cap on concurrent circuit-relay reservations this node's relay server grants to
377
+ * peers it cannot (yet) recognize as authorized members — the boot-ordering window
378
+ * where a genuine member reserves its relay slot before its membership row has
379
+ * replicated here (see `membership-connection-gater.ts` → "The relay-reservation
380
+ * seam"). Authorized members and announced delegate peers are never counted against
381
+ * it. Default `MAX_UNAUTHORIZED_RELAY_RESERVATIONS` (8); 0 refuses every
382
+ * unauthorized reservation. Only meaningful while {@link enableRelay} is on.
383
+ */
384
+ unauthorizedRelayReservationCap?: number;
385
+ /**
386
+ * Custom libp2p transports. When omitted, the default transports from
387
+ * `@optimystic/db-p2p` are used (TCP + circuit relay for Node.js).
388
+ *
389
+ * React Native apps must supply WebSocket-based transports because TCP
390
+ * is not available in the RN runtime:
391
+ * ```typescript
392
+ * import { webSockets } from '@libp2p/websockets';
393
+ * import { circuitRelayTransport } from '@libp2p/circuit-relay-v2';
394
+ *
395
+ * network: {
396
+ * transports: [webSockets(), circuitRelayTransport()],
397
+ * listenAddrs: [] // RN nodes typically cannot listen
398
+ * }
399
+ * ```
400
+ */
401
+ transports?: Libp2pTransports;
402
+ /**
403
+ * Crypto primitives for the Noise handshake and the encrypted connection that
404
+ * follows, threaded to both the control node and every strand's cohort node — every
405
+ * node pays the handshake, so one setting covers them all. When omitted, libp2p-noise
406
+ * picks its own default.
407
+ *
408
+ * React Native is the reason this exists: Metro resolves `@chainsafe/libp2p-noise`'s
409
+ * browser build, whose default is pure-JS crypto, and on Hermes (no JIT) that
410
+ * dominates connection setup on a slow phone. An app with native crypto supplies it
411
+ * here. It must implement every member of the interface; the usual shape spreads
412
+ * `noisePureJsCrypto` and overrides the hashing and ChaCha20-Poly1305 functions:
413
+ * ```typescript
414
+ * import { noisePureJsCrypto } from '@optimystic/db-p2p';
415
+ *
416
+ * network: {
417
+ * noiseCrypto: { ...noisePureJsCrypto, hashSHA256: nativeSha256, chaCha20Poly1305Encrypt: …, chaCha20Poly1305Decrypt: … }
418
+ * }
419
+ * ```
420
+ *
421
+ * Only local primitives change — the wire protocol does not, so a node with native
422
+ * crypto interoperates with one without. See `@optimystic/db-p2p`'s
423
+ * `NodeOptions.noiseCrypto`.
424
+ */
425
+ noiseCrypto?: NoiseCryptoInterface;
426
+ /**
427
+ * libp2p's connection monitor — the liveness ping it runs on every connection — for
428
+ * the control node and every strand node, as {@link noiseCrypto} is. When omitted,
429
+ * cadre-core applies {@link DEFAULT_CONNECTION_MONITOR} rather than leaving libp2p's
430
+ * own defaults in place; an explicit value REPLACES that default wholesale, so `{}`
431
+ * is how an app asks for libp2p's stock behaviour back.
432
+ *
433
+ * Typed from `@optimystic/db-p2p`'s re-export of libp2p's `ConnectionMonitorInit`, so
434
+ * an app does not need a direct `libp2p` dependency, and handed to db-p2p's
435
+ * `NodeOptions.connectionMonitor` unchanged.
436
+ *
437
+ * An app that widens the ping deadline itself must raise `pingInterval` above it in
438
+ * the same object, or libp2p aborts the connection on the second overlapping ping —
439
+ * see {@link DEFAULT_CONNECTION_MONITOR} for why.
440
+ */
441
+ connectionMonitor?: Libp2pConnectionMonitorInit;
442
+ /**
443
+ * How long ONE cohort peer gets to answer ONE read-path request, in milliseconds, for the
444
+ * control node and every strand node — as {@link connectionMonitor} is, and for the same
445
+ * reason: the setting describes the LINK, and a phone's control node and its strand nodes
446
+ * ride the same one. Omitted, the deadline is DERIVED from {@link linkRoundTripMs}: one
447
+ * request and its answer over an open circuit costs two link round trips, so 7 000 ms at the
448
+ * default declaration (`cohortReadDeadlineMs` in `link-budget.ts`; the plugin's
449
+ * {@link COHORT_READ_DEADLINE_MS} is the same number). Optimystic's own default is 1000 ms.
450
+ *
451
+ * Set this only to break the derivation on purpose: an explicit value wins over whatever
452
+ * {@link linkRoundTripMs} would derive, so a deployment that is all LAN can make a departed
453
+ * peer stop holding up a read sooner without declaring a faster link (which would also
454
+ * shorten the dial budgets). A deployment on a slower link should raise
455
+ * {@link linkRoundTripMs} instead, which moves this and every dial budget together. The
456
+ * cost of a larger value is that a peer which is truly gone holds a read of a block missing
457
+ * locally for that long before the read is declined and retried, and a joining machine's
458
+ * first sync runs several such consults — so a change here should be weighed against
459
+ * {@link CadreNodeConfig.strandFirstSync}'s budget. The derivation, its history and what it
460
+ * costs are on {@link COHORT_READ_DEADLINE_MS}.
461
+ *
462
+ * Handed to db-p2p's `clusterPolicy.cohortQueryTimeoutMs` unchanged and NOT re-validated
463
+ * here. Optimystic refuses a value that is not a finite number above zero, or is above its
464
+ * `MAX_COHORT_QUERY_TIMEOUT_MS` (about 4.97 days — the ceiling exists because a unit mix-up
465
+ * is the one way to exceed it), by throwing where the libp2p node is built: inside
466
+ * `CadreNode.start()` for the control network, and inside `CadreNode.addStrand` for a strand.
467
+ * Fractional values are accepted — this is a duration, not a count of peers.
468
+ */
469
+ cohortQueryTimeoutMs?: number;
470
+ /**
471
+ * The round trip this node assumes between itself and another machine, in milliseconds, for
472
+ * the control node and every strand node — as {@link cohortQueryTimeoutMs} is, and for the
473
+ * same reason: the setting describes the LINK, and a phone's control node and its strand
474
+ * nodes ride the same one. Omitted takes {@link DECLARED_LINK_ROUND_TRIP_MS} (3500 ms).
475
+ *
476
+ * This is NOT a timeout. It is the one stated assumption that cadre's own dial and
477
+ * reservation deadlines are DERIVED from, each by the number of round trips that operation
478
+ * was measured to cost: a peer-join catch-up's dial to one peer, its push response, one relay
479
+ * reservation drive, the control-cohort dial budgets, and the per-peer cohort read deadline
480
+ * ({@link cohortQueryTimeoutMs}, unless set explicitly). It is also handed to Optimystic
481
+ * (`NodeOptions.linkRoundTripMs`), default included, which derives its own deadlines from it:
482
+ * its request dials and responses, its block pushes, and libp2p's `addressDialTimeout`,
483
+ * `dialTimeout` and `inboundUpgradeTimeout` on every node, cadre adding its admission decisions
484
+ * to the three that open a connection. Reaching another machine through a
485
+ * relay costs a fixed number of exchanges, so a deadline written as milliseconds has a link
486
+ * speed above which it can never open a connection — which is the defect this declaration
487
+ * exists to make impossible to reintroduce one budget at a time. The counts, the measurement behind them,
488
+ * and what still fails at the supported link are in `link-budget.ts`.
489
+ *
490
+ * The default covers the slowest link sereus supports, a 3-second round trip through a relay.
491
+ * Raise it for a link slower still; the cost is the ordinary cost of longer deadlines, a peer
492
+ * that is genuinely gone holding each operation that much longer before it is abandoned and
493
+ * retried. Lower it only if EVERY machine of the party is that close: this node is also the
494
+ * listener for connections other machines open to it, and its `inboundUpgradeTimeout` comes
495
+ * from this value, so a node declaring a faster link than its peers discards their
496
+ * half-built connections — silently, from the dialer's side.
497
+ *
498
+ * Refused where the libp2p node is built — inside `CadreNode.start()` for the control
499
+ * network, inside `CadreNode.addStrand` for a strand — if it is not a finite number above
500
+ * zero, because every consumer multiplies it into a deadline where a zero means "give up at
501
+ * once" and a `NaN` means "never". A value above about 10.8 hours is refused in the same place,
502
+ * by Optimystic: the request dial cadre states from it (eleven round trips plus two admission
503
+ * decisions, `optimysticDialLimits` in `link-budget.ts`) then exceeds its
504
+ * `MAX_RPC_DIAL_TIMEOUT_MS` (about 4.97 days), and its error names `rpcDeadlines.dialTimeoutMs`.
505
+ * That is below Optimystic's own `MAX_LINK_ROUND_TRIP_MS` (about 13.3 hours) and far above any
506
+ * real link, so cadre adds no check of its own.
507
+ */
508
+ linkRoundTripMs?: number;
509
+ /**
510
+ * Optional async resolver returning the multiaddrs to embed in invites
511
+ * (and other owner-address contexts). When unset, `libp2pNode.getMultiaddrs()`
512
+ * is used. Hosts behind NAT supply this to substitute their DDNS hostname
513
+ * and externally-mapped port — see `@serfab/cadre-host`'s NatService.
514
+ *
515
+ * Returned addresses need NOT carry a `/p2p/<peerId>` suffix: `CadreNode`
516
+ * appends its own before publishing or dialing any of them, so a resolver
517
+ * cannot leave a sibling with the mixed suffixed/unsuffixed list that
518
+ * `libp2p.dial` rejects. Returning them already suffixed is equally fine.
519
+ */
520
+ inviteAddressResolver?: () => Promise<string[]>;
521
+ /**
522
+ * Optional libp2p connection gater, threaded to both the control node and every
523
+ * strand's cohort node. libp2p's browser default denies dialing insecure
524
+ * WebSockets and private/loopback addresses; callers that must dial local or
525
+ * unsecured peers (the web reference dialing a `127.0.0.1/.../ws` responder,
526
+ * Playwright e2e, RN simulators) supply a permissive gater here — e.g.
527
+ * `{ denyDialMultiaddr: () => false }`. See `@optimystic/db-p2p`'s
528
+ * `libp2p-node-base` for the underlying option.
529
+ *
530
+ * On the CONTROL node this gater is composed with the built-in membership
531
+ * admission gate (`membership-connection-gater.ts`): every hook supplied here
532
+ * is honored unchanged, and on inbound encrypted connections a deny from
533
+ * either this gater or the membership policy denies.
534
+ *
535
+ * Strand cohort nodes: an OPEN strand's node receives this gater as-is (its
536
+ * peers are legitimately cross-party). A CLOSED strand's node composes
537
+ * revoked-peer denial onto it (`strand-revocation-enforcer.ts`) — every hook
538
+ * supplied here is still honored unchanged, and on the composed hooks
539
+ * (inbound encrypted connection, outbound peer dial, relay reservation) a
540
+ * deny from either this gater or the revocation check denies.
541
+ */
542
+ connectionGater?: ConnectionGater;
543
+ /**
544
+ * Tuning for the proactive control-cohort dial routine
545
+ * ({@link CadreNode.reconcileControlCohort}), which keeps a party's control
546
+ * nodes connected so the `CadreControl` collections form a replicating cohort.
547
+ * Every field is optional; omit for the defaults
548
+ * ({@link DEFAULT_CONTROL_COHORT_TARGET_DEGREE} /
549
+ * {@link DEFAULT_CONTROL_COHORT_RECONCILE_MS} /
550
+ * {@link DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS} /
551
+ * {@link DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS} / `STRAND_PEER_ADDR_REFRESH_MS`).
552
+ */
553
+ controlCohort?: {
554
+ /**
555
+ * Cap on the number of NON-owner siblings dialed per reconcile pass
556
+ * (backbone/owner members are always dialed and do not count). Defaults
557
+ * to {@link DEFAULT_CONTROL_COHORT_TARGET_DEGREE}.
558
+ */
559
+ targetDegree?: number;
560
+ /**
561
+ * Recurring reconcile cadence in ms. Defaults to
562
+ * {@link DEFAULT_CONTROL_COHORT_RECONCILE_MS}.
563
+ */
564
+ reconcileMs?: number;
565
+ /**
566
+ * Limit on dialing ONE peer — all of its candidate addresses together — in
567
+ * ms. Bounds a reconcile pass at (dialed siblings) × this. The same limit
568
+ * applies to the owner dials of `applySeed` and to `dialInvite`. Defaults to
569
+ * {@link DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS}.
570
+ */
571
+ dialTimeoutMs?: number;
572
+ /**
573
+ * Limit on ONE candidate address's dial attempt, in ms, inside
574
+ * {@link dialTimeoutMs}. Each address is dialed on its own under this limit,
575
+ * so an address that never answers cannot use up the time the peer's other
576
+ * addresses needed. Defaults to
577
+ * {@link DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS}. A value given
578
+ * here replaces that whole derived budget, including its allowances for the
579
+ * relay's and the called machine's admission decisions (`relayedDialBudgetMs`).
580
+ */
581
+ perAddressDialTimeoutMs?: number;
582
+ /**
583
+ * How long a running strand waits, after a sibling answers its strand-addr
584
+ * RPC, before asking that sibling again for its strand-network addresses — a
585
+ * step of the reconcile pass, scheduled per (sibling, strand). A sibling that
586
+ * did not answer is retried sooner, on `STRAND_PEER_ADDR_RETRY_MS` (or this
587
+ * interval, when it is set shorter). Defaults to
588
+ * `STRAND_PEER_ADDR_REFRESH_MS` (10 min); must stay well under the peerStore's
589
+ * one-hour address expiry.
590
+ */
591
+ strandAddrRefreshMs?: number;
592
+ };
593
+ }
594
+
595
+ /**
596
+ * The connection-monitor settings every cadre node runs with when
597
+ * {@link NetworkConfig.connectionMonitor} is unset: a 30 second ping deadline in place
598
+ * of libp2p's 5 second one, and a 35 second gap between pings so a ping is never
599
+ * outstanding when the next one starts.
600
+ *
601
+ * WHY this is a default and not opt-in. libp2p's monitor pings every connection and,
602
+ * with its own `abortConnectionOnPingFailure: true`, aborts the connection on the FIRST
603
+ * timeout. A peer whose event loop is saturated by pure-JS Noise crypto — a slow phone
604
+ * under React Native, see {@link NetworkConfig.noiseCrypto} — misses that deadline while
605
+ * perfectly healthy; the other end aborts, the peer redials, and the new handshake
606
+ * saturates it further. Measured on gotchoices/sereus#13 at a Galaxy S7's crypto cost, a
607
+ * two-party bring-up failed 3 of 3 runs on stock settings, with 30 `aborting connection
608
+ * due to ping failure` entries in the relay's log for one run, and passed 4 of 4 in about
609
+ * 90 seconds once the deadline was widened on every node. The monitor runs on BOTH ends
610
+ * of a connection and either end's abort closes it for both, so a setting the phone alone
611
+ * applies does not cover the peer dropping it. That is what rules out scoping this to
612
+ * React Native.
613
+ *
614
+ * WHY `pingInterval` MOVES WITH THE DEADLINE, and is not left at libp2p's 10 seconds.
615
+ * The monitor opens a ping stream per connection per interval whether or not the previous
616
+ * ping has answered, and `/ipfs/ping/1.0.0` is registered by `@libp2p/ping` with
617
+ * `maxOutboundStreams: 1`. A second concurrent ping stream on one connection therefore
618
+ * fails in `Connection.newStream` with `TooManyOutboundProtocolStreamsError`, which
619
+ * reaches the monitor's own catch and aborts the connection exactly as a timeout does. A
620
+ * widened deadline alone is thus capped by the ping interval: measured against two local
621
+ * libp2p 3.1.3 nodes whose ping handler answered 600ms late, an interval of 300ms with a
622
+ * 900ms deadline aborted the connection, while a 900ms interval with the same deadline
623
+ * kept it (the same pair aborted at a 200ms stall only when the deadline was 300ms). An
624
+ * interval strictly above the deadline is what makes the 30 seconds real.
625
+ *
626
+ * WHAT THE LINK NEEDS OF IT. One ping opens a fresh stream and echoes over it: a protocol
627
+ * negotiation plus the echo, two link round trips by `link-budget.ts`'s counts (a `newStream`
628
+ * over a relayed circuit measured 3016 ms at 1500 ms one-way). At the slowest link sereus
629
+ * supports, a 3-second relayed round trip, that is about 6 s — well inside the 30 s deadline,
630
+ * which exists for the phone's CPU rather than the link.
631
+ *
632
+ * WHAT IT COSTS. A dead peer is reclaimed 30 to 65 seconds after it stops answering — the
633
+ * deadline, plus up to one interval of waiting for the ping that will fail — where
634
+ * libp2p's defaults took about 5 to 15 seconds. `db-p2p` caps a node at 16 connections,
635
+ * so the worst case is those slots held about a minute longer than before; at that scale
636
+ * it is not a starvation risk.
637
+ *
638
+ * WHY THE DEADLINE IS PINNED rather than given room to adapt. `pingTimeout` is an
639
+ * adaptive-timeout init, and equal `minTimeout`/`maxTimeout` clamp it to one value on
640
+ * every libp2p version. libp2p 3.3 reports each ping's duration back to the monitor's
641
+ * `AdaptiveTimeout`, so a ceiling above `pingInterval` would let the deadline grow past the
642
+ * interval and put the overlapping-ping abort above straight back. Pinning both ends keeps
643
+ * the interval's margin true. It also sidesteps the monitor keeping one `AdaptiveTimeout`
644
+ * for all of a node's connections, so one slow peer cannot lengthen the deadline for every
645
+ * connection on that node.
646
+ */
647
+ export const DEFAULT_CONNECTION_MONITOR = Object.freeze({
648
+ // Strictly greater than the deadline below, so the previous ping is always resolved or
649
+ // aborted before the next one opens a stream. `types.spec.ts` holds the two apart.
650
+ pingInterval: 35_000,
651
+ // Frozen at both levels, as `CONTROL_CLUSTER_POLICY` is: one object reaches every
652
+ // libp2p node this process builds, so a mutation anywhere would move the deadline
653
+ // for all of them.
654
+ pingTimeout: Object.freeze({ minTimeout: 30_000, maxTimeout: 30_000 })
655
+ } satisfies Libp2pConnectionMonitorInit);
656
+
657
+ /**
658
+ * Hibernation configuration
659
+ */
660
+ export interface HibernationConfig {
661
+ enabled: boolean;
662
+ defaultLatencyHint?: LatencyHint;
663
+ /** Custom timeouts per latency hint (overrides defaults) */
664
+ customTimeouts?: Partial<Record<LatencyHint, Partial<HibernationTimeouts>>>;
665
+ /**
666
+ * How long a hibernating strand stays resumed during a check-in to let its
667
+ * strand network connect to reachable cohort peers (and the app drive
668
+ * pull-on-read activity) before re-hibernating if still idle. Defaults to
669
+ * {@link DEFAULT_CHECKIN_WINDOW_MS}.
670
+ */
671
+ checkInWindowMs?: number;
672
+ }
673
+
674
+ /**
675
+ * Default duration of the resume-and-probe window during a strand check-in.
676
+ * See {@link HibernationConfig.checkInWindowMs}.
677
+ */
678
+ export const DEFAULT_CHECKIN_WINDOW_MS = 15 * 1000;
679
+
680
+ /**
681
+ * Control network configuration
682
+ */
683
+ export interface ControlNetworkConfig {
684
+ partyId: string;
685
+ bootstrapNodes: string[];
686
+ /** Optional path to the control schema file (defaults to bundled schema) */
687
+ schemaPath?: string;
688
+ }
689
+
690
+ /**
691
+ * Replication-breadth constants, both networks' cluster policies, and the strand
692
+ * resolver, re-exported so cadre embedders, the SQL plugin and the integration
693
+ * harness share one definition. Defined in `@serfab/quereus-plugin-sereus`
694
+ * because that package also creates libp2p nodes and cannot depend on this one.
695
+ * The control network's breadth is fixed ({@link CONTROL_REPLICATION_BREADTH},
696
+ * not configurable) and so is its consensus policy ({@link CONTROL_CLUSTER_POLICY}
697
+ * — notably the super-majority threshold, which it leaves at Optimystic's default
698
+ * by omission); the strand breadth defaults to
699
+ * {@link CadreNodeConfig.strandClusterSize} while its policy is the fixed
700
+ * {@link STRAND_CLUSTER_POLICY}.
701
+ *
702
+ * The two `*ClusterPolicy` BUILDERS are the same objects with the block-repair
703
+ * corroboration yardstick declared from the machines enrolled in this party
704
+ * ({@link resolveRepairYardstick}) and, if the host set one or declared its link, a
705
+ * per-peer read deadline in place of {@link COHORT_READ_DEADLINE_MS}
706
+ * (`declaredCohortReadDeadlineMs` in `link-budget.ts` settles which). Both arrive in one
707
+ * named declarations object, because both are plain numbers meaning unrelated things;
708
+ * declaring neither returns the frozen base constant unchanged. A network picks the
709
+ * derived numbers up when its libp2p node is built, which for a strand is every wake
710
+ * from hibernation.
711
+ */
712
+ export {
713
+ MIN_CLUSTER_SIZE,
714
+ COHORT_READ_DEADLINE_MS,
715
+ CONTROL_REPLICATION_BREADTH,
716
+ CONTROL_CLUSTER_POLICY,
717
+ DEFAULT_STRAND_CLUSTER_SIZE,
718
+ STRAND_CLUSTER_POLICY,
719
+ resolveStrandClusterSize,
720
+ resolveRepairYardstick,
721
+ controlClusterPolicy,
722
+ strandClusterPolicy
723
+ } from '@serfab/quereus-plugin-sereus';
724
+
725
+ /**
726
+ * The declared link round trip every cadre-owned dial and reservation deadline is derived from,
727
+ * re-exported beside {@link NetworkConfig.linkRoundTripMs} so a host reading the setting finds
728
+ * the default it replaces. The counts, the measurement and the derivation live in
729
+ * `link-budget.ts`.
730
+ */
731
+ export { DECLARED_LINK_ROUND_TRIP_MS } from './link-budget.js';
732
+
733
+ /**
734
+ * Main configuration for a CadreNode
735
+ */
736
+ export interface CadreNodeConfig {
737
+ /**
738
+ * If provided, use this keypair for the node identity (direct injection).
739
+ * Mutually exclusive with {@link keyStore} — supplying both is a configuration
740
+ * error (fail closed) thrown by `start()` before any network bring-up.
741
+ */
742
+ privateKey?: PrivateKey;
743
+
744
+ /**
745
+ * Pluggable secure store for node key material. When set, the node loads its
746
+ * identity from `keyStore` under {@link identityKeyId}, generating + persisting
747
+ * a fresh Ed25519 key on first run (protobuf bytes are the canonical stored
748
+ * form). Mutually exclusive with {@link privateKey}. Absent ⇒ use `privateKey`
749
+ * when set, else libp2p generates an ephemeral key.
750
+ *
751
+ * In the single-key reference model the owner signing key is *derived from*
752
+ * the node identity (see {@link CadreNode.getIdentityOwnerKey}), so
753
+ * protecting the identity in a secure enclave protects the owner key too.
754
+ * A future separate-owner slot (`ownerKeyId`) is anticipated but not
755
+ * built here.
756
+ */
757
+ keyStore?: KeyStore;
758
+
759
+ /** Slot id for the node identity in {@link keyStore}. Default: `'cadre/identity'`. */
760
+ identityKeyId?: KeyId;
761
+
762
+ /** Control network connection settings */
763
+ controlNetwork: ControlNetworkConfig;
764
+
765
+ /** Node profile: transaction-only or storage */
766
+ profile: NodeProfile;
767
+
768
+ /** Which strands to participate in */
769
+ strandFilter?: StrandFilter;
770
+
771
+ /**
772
+ * Run every strand {@link strandFilter} admits, including ones no app on this machine
773
+ * has claimed with `addStrand`, as a **storage replica**: the strand's own libp2p node,
774
+ * storage and `Strand` membership schema, storing and serving its blocks, without the
775
+ * app's schema (no sApp config needed, none of the app's code or schema runs). This is
776
+ * how an always-on machine keeps a copy of every shared workspace its party runs, so a
777
+ * lost phone loses nothing that had reached it.
778
+ *
779
+ * The strand is still announced as `strand:discovered` (once) and stays in
780
+ * `getDiscoveredStrands()` until an app claims it. A replica is always a joiner.
781
+ *
782
+ * Default: `profile === 'storage'` — always-on machines host replicas, phones
783
+ * (`'transaction'`) keep today's announce-only behaviour.
784
+ */
785
+ hostUnclaimedStrands?: boolean;
786
+
787
+ /** Storage configuration (only for storage profile) */
788
+ storage?: StorageConfig;
789
+
790
+ /** Network configuration */
791
+ network?: NetworkConfig;
792
+
793
+ /**
794
+ * Number of nodes Optimystic is told a **strand** network's replication cluster
795
+ * should have, for every strand this node starts. The control network is not
796
+ * configurable — it always replicates to {@link CONTROL_REPLICATION_BREADTH}
797
+ * nodes, which is above any party's node count, because every member reads the
798
+ * whole control database and a member that misses a write may never learn the
799
+ * fact (see that constant for the full reasoning).
800
+ *
801
+ * Every node on the same strand should use the same value: the number bounds the
802
+ * cohort a member independently derives, and Optimystic's cluster-membership gate
803
+ * can reject a coordinator's smaller declared set as a downsize against a larger
804
+ * derived view.
805
+ *
806
+ * Frozen when the strand's libp2p node is created; a change takes effect on
807
+ * restart, not when cadre membership grows. Defaults to
808
+ * {@link DEFAULT_STRAND_CLUSTER_SIZE} (4 — the smallest breadth whose 0.75
809
+ * super-majority still commits with one holder offline); anything below
810
+ * {@link MIN_CLUSTER_SIZE} is rejected before a node is created. The cost of a
811
+ * small value is replication breadth and reliance on read repair, not commit
812
+ * correctness — but at 2 that read repair cannot converge, because a lone
813
+ * corroborator's stale answer is accepted as the cluster's truth.
814
+ */
815
+ strandClusterSize?: number;
816
+
817
+ /**
818
+ * Tuning for the strand peer-join block catch-up (see `peer-join-backfill.ts`):
819
+ * when a strand's libp2p node connects to a peer this runtime has not yet
820
+ * caught up, every block in the strand's own raw store is pushed to that peer,
821
+ * so a machine that joined the strand after blocks were committed still ends
822
+ * up physically holding them. Applies to every strand this node starts.
823
+ * Omit for the defaults (`DEFAULT_PEER_JOIN_BACKFILL`); `{ enabled: false }`
824
+ * restores the no-backfill behaviour. The control network runs its own,
825
+ * separately tuned catch-up — see {@link controlBackfill}.
826
+ */
827
+ strandBackfill?: PeerJoinBackfillConfig;
828
+
829
+ /**
830
+ * Tuning for the CONTROL network's peer-join block catch-up (same module,
831
+ * `peer-join-backfill.ts`, wired in `CadreNode.start`): when the control
832
+ * libp2p node connects to an AUTHORIZED party member this runtime has not yet
833
+ * caught up, every block in the control database's own raw store is pushed to
834
+ * it. This is what makes a control block committed while the writer was alone
835
+ * (collection headers written at genesis above all) physically reach members
836
+ * that joined later — without it, such a member that restarts offline reads
837
+ * the affected tables as empty. Pushes are gated on
838
+ * `CadreNode.isAuthorizedMember`, checked at push time, because the control
839
+ * network's inbound gate deliberately admits non-members in several states
840
+ * (seed delivery, enrollment, bootstrap/relay peers). Omit for the defaults
841
+ * (`DEFAULT_PEER_JOIN_BACKFILL`); `{ enabled: false }` disables it.
842
+ */
843
+ controlBackfill?: PeerJoinBackfillConfig;
844
+
845
+ /**
846
+ * Tuning for the CLOSED-strand revoked-peer gate
847
+ * (`strand-revocation-enforcer.ts`), applied to every closed strand this node
848
+ * starts: each strand node materializes the peer ids bound to REMOVED members
849
+ * (orphaned `Strand.MemberPeer` rows) and refuses them at the stream and
850
+ * connection layers, so removing a party cuts the network to its machines
851
+ * rather than only deleting its row. Open strands never arm it. Omit for the
852
+ * defaults (`DEFAULT_REVOCATION_POLL_INTERVAL_MS` refresh cadence);
853
+ * `{ enabled: false }` restores the pre-existing behaviour.
854
+ */
855
+ strandRevocationEnforcement?: StrandRevocationEnforcementConfig;
856
+
857
+ /**
858
+ * Tuning for the CLOSED-strand membership reconciler
859
+ * (`strand-membership-reconciler.ts`), applied to every closed strand this
860
+ * node launches with a party identity key: at bring-up each machine redeems a
861
+ * pending formation invitation (seating the party's `Strand.Member` row) and
862
+ * writes its own machine→party `Strand.MemberPeer` binding, retrying in the
863
+ * background without blocking bring-up. `pollIntervalMs` is the IDLE cadence
864
+ * (how often a machine nobody has admitted re-checks) and the cap of the
865
+ * unfinished-join retry ladder, NOT the retry cadence itself — an unfinished
866
+ * join retries from 1 s, doubling. Omit for the defaults (it mirrors
867
+ * {@link strandRevocationEnforcement}'s `pollIntervalMs`);
868
+ * `{ enabled: false }` disarms the loop — for test fixtures that hand-drive
869
+ * the membership writers and assert exact row sets.
870
+ */
871
+ strandMembershipReconciliation?: StrandMembershipReconciliationConfig;
872
+
873
+ /**
874
+ * Tuning for the JOINING machine's first-sync write gate
875
+ * (`strand-first-sync-gate.ts`), applied to every strand this node launches as a
876
+ * non-founder: the strand's database is withheld from the app (`StrandInstance.database`
877
+ * unset, status `'syncing'`) until the strand's `Strand.Header` has been received from
878
+ * another member and every `App` table has been read once, because a machine that writes
879
+ * to a table it has never fetched creates a private copy of it that never merges.
880
+ * `timeoutMs` bounds how long {@link CadreNode.addStrand} waits before rejecting with
881
+ * `StrandAwaitingFirstSyncError` (retryable — the launch stays up and keeps probing);
882
+ * `pollIntervalMs` is the probe cadence. Omit for the defaults (`DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS`,
883
+ * sized for a machine re-attaching through a relay on a slow link, the slowest attach
884
+ * measured; that constant's doc comment carries the number, the measurement behind it and
885
+ * what the budget costs, and is the only copy of them; `DEFAULT_STRAND_FIRST_SYNC_POLL_MS`,
886
+ * 500 ms). There is deliberately no way to disable the gate: a machine whose store already
887
+ * holds the Header is never gated, so nothing that works today is blocked by it.
888
+ */
889
+ strandFirstSync?: StrandFirstSyncConfig;
890
+
891
+ /** Hibernation configuration */
892
+ hibernation?: HibernationConfig;
893
+
894
+ /** Polling interval for strand watcher in ms (default: 5000) */
895
+ strandWatchInterval?: number;
896
+
897
+ /**
898
+ * Require a valid author signature on every sApp schema before a strand is
899
+ * formed or joined. Defaults to true (fail closed): an unsigned schema is
900
+ * rejected before any libp2p node or schema DDL is brought up. Set false ONLY
901
+ * for dev/test where unsigned demo schemas are used.
902
+ */
903
+ requireSignedSchemas?: boolean;
904
+
905
+ /**
906
+ * Node-wide default trust anchor for INBOUND control-network seeds. Forwarded
907
+ * into every SeedBootstrapService this node constructs (owner, receive-only
908
+ * listener, and the temp service used by applySeed when no service exists), and
909
+ * used as the service-level default the libp2p seed-protocol handler relies on
910
+ * (that path has no per-call override seam). Defaults to anchoredTrustPolicy()
911
+ * inside SeedBootstrapService when unset — a node whose node-local trusted-owner
912
+ * anchor was never seeded then rejects every seed. A per-call
913
+ * `applySeed(seed, { trustPolicy })` override still wins over this default for
914
+ * callers that hold out-of-band material (e.g. a pinned key from a CadreInvite).
915
+ */
916
+ seedTrustPolicy?: SeedTrustPolicy;
917
+
918
+ /**
919
+ * Node-local trusted-owner anchor (see `trusted-owner-store.ts`): the
920
+ * NON-replicated, per-party record of owner keys established out of band —
921
+ * the anchor membership/seed trust can rest on, since the replicated
922
+ * `OwnerKey` table can be polluted by a stranger's self-genesis. Absent ⇒ an
923
+ * in-memory store is created at start() (ephemeral: anchored trust does not
924
+ * survive the process).
925
+ */
926
+ trustedOwners?: {
927
+ /**
928
+ * Injected store instance — e.g. a `FileTrustedOwnerStore` from the
929
+ * Node-only subpath `@serfab/cadre-core/trusted-owner-store-file`,
930
+ * persisted in the node's state directory (same injection/isolation pattern
931
+ * as {@link keyStore}). Its `partyId` must match `controlNetwork.partyId`;
932
+ * start() fails closed on a mismatch.
933
+ */
934
+ store?: TrustedOwnerStore;
935
+ /**
936
+ * Owner keys (base64url ed25519) established out of band and seeded into
937
+ * the store during start(): operator pins (e.g. cadre-cli
938
+ * `--pin-owner-key` / `CADRE_OWNER_KEYS`) or a `CadreInvite.ownerKeys`
939
+ * already known at config time. Seeding is idempotent across restarts.
940
+ */
941
+ pinnedKeys?: string[];
942
+ /**
943
+ * Provenance recorded for {@link pinnedKeys}. Default: 'operator'.
944
+ * ('genesis' is reserved for the node's own founding key, seeded
945
+ * internally by `initializeSeedBootstrap`.)
946
+ */
947
+ pinnedSource?: Exclude<TrustSource, 'genesis'>;
948
+ };
949
+
950
+ /**
951
+ * Node-local cold-start bootstrap-peer store (see `bootstrap-peer-store.ts`):
952
+ * the owner addresses an applied seed nominated, kept so
953
+ * `reconcileControlCohort`'s cold-start branch can keep retrying a node that
954
+ * was seeded but never managed to connect. Sibling of {@link trustedOwners} —
955
+ * same NON-replicated, per-party, injected-backend shape — but nothing here is
956
+ * trust-bearing: these are dial hints only, and every address is re-bound to
957
+ * its peer id before the dial. Absent ⇒ an in-memory store is created at
958
+ * start() (ephemeral: the retry set does not survive the process, and such a
959
+ * node is stranded permanently if it restarts before connecting).
960
+ *
961
+ * The Node CLI, the browser, and React Native all inject a durable backend
962
+ * today; only NativeScript stays ephemeral, tracked by the
963
+ * `ns-durable-node-local-stores` ticket (which covers the same gap for
964
+ * {@link trustedOwners}). A new platform needs no new store class: supply a
965
+ * `DurableSlot` for the platform's storage and inject
966
+ * `PersistentBootstrapPeerStore.open(slot, partyId)` /
967
+ * `PersistentTrustedOwnerStore.open(slot, partyId)`.
968
+ */
969
+ bootstrapPeers?: {
970
+ /**
971
+ * Injected store instance — e.g. a `FileBootstrapPeerStore` from the
972
+ * Node-only subpath `@serfab/cadre-core/bootstrap-peer-store-file`, persisted
973
+ * in the node's state directory (same injection/isolation pattern as
974
+ * {@link keyStore} / {@link trustedOwners}). Its `partyId` must match
975
+ * `controlNetwork.partyId`; start() fails closed on a mismatch.
976
+ */
977
+ store?: BootstrapPeerStore;
978
+ };
979
+
980
+ /**
981
+ * Node-local record of how many machines this party had enrolled the last time
982
+ * this node looked (see `enrolled-machine-store.ts`): the block-repair
983
+ * corroboration yardstick the CONTROL node declares when its libp2p node is
984
+ * built. Sibling of {@link trustedOwners} / {@link bootstrapPeers} — same
985
+ * NON-replicated, per-party, injected-backend shape — and, like
986
+ * {@link bootstrapPeers}, nothing here is trust-bearing: it is a repair hint,
987
+ * recomputed from `CadrePeer` rows the moment the control database is up.
988
+ *
989
+ * It exists only because the control node is created BEFORE that database, so
990
+ * the live count is unreadable at the one moment it is needed; remembering it
991
+ * across the restart is what breaks that deadlock. Absent ⇒ an in-memory store
992
+ * is created at start(), which cold-starts on every launch and therefore
993
+ * declares nothing — byte-for-byte the behaviour before this record existed.
994
+ * No embedder is forced to change.
995
+ *
996
+ * A party that grows applies the larger number on each node's NEXT launch,
997
+ * deliberately: Optimystic freezes the policy at node construction and offers
998
+ * no runtime setter, and a node still holding the old value runs at exactly
999
+ * today's behaviour, so there is no forced rebuild.
1000
+ *
1001
+ * A new platform needs no new store class: supply a `DurableSlot` for the
1002
+ * platform's storage and inject `PersistentEnrolledMachineStore.open(slot, partyId)`.
1003
+ */
1004
+ enrolledMachines?: {
1005
+ /**
1006
+ * Injected store instance — e.g. a `FileEnrolledMachineStore` from the
1007
+ * Node-only subpath `@serfab/cadre-core/enrolled-machine-store-file`,
1008
+ * persisted in the node's state directory. Its `partyId` must match
1009
+ * `controlNetwork.partyId`; start() fails closed on a mismatch.
1010
+ */
1011
+ store?: EnrolledMachineStore;
1012
+ };
1013
+
1014
+ /**
1015
+ * Node-local record of the strands this node joined from ANOTHER party and has not yet
1016
+ * published to the party-wide `JoinedStrand` table (see `joined-strand-store.ts`): id,
1017
+ * type and the closed strand's read secret, recorded by `formStrand` and by a joining
1018
+ * (not `founder: true`) `addStrand` whose row this party's control database does not
1019
+ * name, offered on every start as `strand:discovered` until an owner machine's
1020
+ * reconcile pass publishes it — so an embedding app keeps no list of its own.
1021
+ *
1022
+ * Absent ⇒ a `KeyStoreJoinedStrandStore` over {@link keyStore} when one is
1023
+ * configured (the record carries a secret, and the KeyStore is where every platform
1024
+ * already keeps secrets), else an in-memory store that warns once and forgets every
1025
+ * join on restart. An embedder that passes {@link privateKey} rather than a
1026
+ * `keyStore` injects a durable store here, e.g.
1027
+ * `new KeyStoreJoinedStrandStore(new FileKeyStore(dir), partyId)`.
1028
+ */
1029
+ joinedStrands?: {
1030
+ /** Its `partyId` must match `controlNetwork.partyId`; start() fails closed on a mismatch. */
1031
+ store?: JoinedStrandStore;
1032
+ };
1033
+
1034
+ /**
1035
+ * Node-local strand network state (see `strand-network-state.ts`): per strand, the
1036
+ * state Optimystic's db-p2p saves for the strand's libp2p node — its FRET routing
1037
+ * table, each entry carrying the peer's signed address record. A strand node reads
1038
+ * it back when it is built and re-imports the table, so a restarted machine already
1039
+ * holds addresses for the strand peers it was talking to (gotchoices/sereus#18).
1040
+ * Sibling of {@link bootstrapPeers} — same NON-replicated, per-party,
1041
+ * injected-backend shape — and, like it, nothing here is trust-bearing: FRET
1042
+ * verifies every record at import.
1043
+ *
1044
+ * Absent ⇒ an in-memory store is created at start() (ephemeral: a restarted process
1045
+ * starts every strand with an empty routing table). Every reference embedder
1046
+ * injects a durable backend over the same slot kind as its bootstrap-peer store. A
1047
+ * new platform needs no new store class: supply a `DurableSlot` and inject
1048
+ * `PersistentStrandNetworkStateStore.open(slot, partyId)`.
1049
+ */
1050
+ strandNetworkState?: {
1051
+ /**
1052
+ * Injected store instance — e.g. a `FileStrandNetworkStateStore` from the
1053
+ * Node-only subpath `@serfab/cadre-core/strand-network-state-file`, persisted in
1054
+ * the node's state directory. Its `partyId` must match
1055
+ * `controlNetwork.partyId`; start() fails closed on a mismatch.
1056
+ */
1057
+ store?: StrandNetworkStateStore;
1058
+ };
1059
+
1060
+ /**
1061
+ * Platform push-delivery for suspended mobile peers. When present, the node's
1062
+ * server fan-out can deliver strand-wake data messages over the platform push
1063
+ * channel (FCM/APNs). Absent ⇒ no platform push (control-network push-wake only).
1064
+ *
1065
+ * The `notifier` is **injected**, not constructed here: a Node host builds it
1066
+ * from `@serfab/cadre-core/push-node` (`createPushNotifier(credentials)`) and
1067
+ * passes the instance, so the FCM/APNs modules (`node:crypto`/`node:http2`)
1068
+ * stay out of the cross-platform core graph. Ownership transfers to the node on
1069
+ * injection — `CadreNode.stop` closes the notifier (via `PushFanoutService`),
1070
+ * releasing its APNs HTTP/2 session; a host must NOT also close an injected
1071
+ * notifier itself. `cooldownMs`/`debounceMs` carry the fan-out anti-spam policy.
1072
+ */
1073
+ push?: {
1074
+ /** The Node-constructed push router (see `@serfab/cadre-core/push-node`). */
1075
+ notifier: PushNotifier;
1076
+ /**
1077
+ * Per-`(peer, strand)` minimum gap between push-wakes the fan-out emits.
1078
+ * Default {@link DEFAULT_PUSH_COOLDOWN_MS} (5 min).
1079
+ */
1080
+ cooldownMs?: number;
1081
+ /**
1082
+ * Per-strand burst-coalescing window: a second trigger within it is dropped.
1083
+ * Default {@link DEFAULT_PUSH_DEBOUNCE_MS} (10 s).
1084
+ */
1085
+ debounceMs?: number;
1086
+ };
1087
+ }
1088
+
1089
+ /**
1090
+ * Status of a strand instance.
1091
+ *
1092
+ * `'syncing'` is a JOINING machine whose runtime is up (strand libp2p node, background
1093
+ * loops) but whose database is held back from the app until the strand's `Strand.Header`
1094
+ * has been received from another member — see `strand-first-sync-gate.ts`. In that state
1095
+ * `StrandInstance.database` is unset; the instance goes `'active'` (and `CadreNode` emits
1096
+ * `strand:writable`) the moment the Header is readable. A founder, or a machine that has
1097
+ * synced this strand before (restart, hibernation resume), never passes through it.
1098
+ */
1099
+ export type StrandStatus =
1100
+ | 'starting'
1101
+ | 'syncing'
1102
+ | 'active'
1103
+ | 'idle'
1104
+ | 'hibernating'
1105
+ | 'stopping'
1106
+ | 'stopped'
1107
+ | 'error';
1108
+
1109
+ /**
1110
+ * App information from strand header
1111
+ */
1112
+ export interface SAppInfo {
1113
+ id: string;
1114
+ version: string;
1115
+ schema: string;
1116
+ signature?: string;
1117
+ }
1118
+
1119
+ /**
1120
+ * Strand instance state
1121
+ */
1122
+ export interface StrandInstance {
1123
+ strandId: string;
1124
+ status: StrandStatus;
1125
+ sAppInfo?: SAppInfo;
1126
+
1127
+ /** The libp2p node for this strand (only when live: syncing/active/idle) */
1128
+ libp2pNode?: Libp2p;
1129
+
1130
+ /**
1131
+ * The Quereus database for this strand — set only while the strand is live AND
1132
+ * this machine may write to it. A joining machine that has not yet received the
1133
+ * strand's `Strand.Header` from another member runs with `libp2pNode` set and this
1134
+ * unset (status `'syncing'`); it is published when the Header arrives
1135
+ * (`strand:writable`). Unset while hibernating.
1136
+ */
1137
+ database?: StrandDatabase;
1138
+
1139
+ /** Membership info for closed strands */
1140
+ memberKey?: string;
1141
+ memberPrivateKey?: string;
1142
+
1143
+ /** Activity tracking */
1144
+ connectedPeers: number;
1145
+ /**
1146
+ * When the strand last saw activity: the app's (`recordStrandActivity`), a requested wake
1147
+ * (`wakeStrand`), or a peer delivering a gated joiner's `Strand.Header`. Always a fresh
1148
+ * `Date`. Bringing the runtime up is NOT activity — a check-in window decides whether to
1149
+ * re-hibernate by whether this changed since before its resume.
1150
+ */
1151
+ lastActivity: Date;
1152
+ nextCheckIn?: Date;
1153
+
1154
+ /** Latency hint from app or override */
1155
+ latencyHint: LatencyHint;
1156
+
1157
+ /** Error message if status is 'error' */
1158
+ error?: string;
1159
+ }
1160
+
1161
+ /**
1162
+ * Outcome of an on-demand {@link CadreNode.serviceWake} — the mobile push-wake
1163
+ * cycle (resume → bounded window → re-hibernate-if-idle) run imperatively for a
1164
+ * single strand.
1165
+ */
1166
+ export interface ServiceWakeResult {
1167
+ /** The strand the wake was requested for. */
1168
+ strandId: string;
1169
+ /**
1170
+ * Whether the wake was actually serviced. `false` when the node is not
1171
+ * running, or the strand is unknown to this node (not a member-participated
1172
+ * strand) — never a thrown error, so a background task can branch on it.
1173
+ */
1174
+ serviced: boolean;
1175
+ /**
1176
+ * Whether activity landed during the wake window. `true` leaves the strand
1177
+ * active; `false` re-hibernates it (or the strand was already live, in which
1178
+ * case it reflects current liveness). Always `false` when `serviced` is `false`.
1179
+ */
1180
+ hadActivity: boolean;
1181
+ }
1182
+
1183
+ /**
1184
+ * Strand row from control network - basic membership info
1185
+ */
1186
+ export interface StrandRow {
1187
+ Id: string;
1188
+ MemberPrivateKey: string | null;
1189
+ Type: 'o' | 'c';
1190
+ /**
1191
+ * ed25519 (base64url) owner key of the MACHINE that published this row — the
1192
+ * signer of the owner-signed `Strand` insert (`== context.OwnerKey`, pinned by
1193
+ * the schema's `AuthorizedInsert`). Identifies the founding machine, so a
1194
+ * launch with no explicit `founder` flag derives "am I the founder?" by
1195
+ * comparing it to the node's own owner key (see `CadreNode.launchStrand`).
1196
+ * `null` on a consent-seated strand (the unsigned `FormationUsage` branch
1197
+ * records no trustworthy signer). Provenance, not content —
1198
+ * `strandRowMismatches` excludes it from the identical-content comparison.
1199
+ */
1200
+ FounderOwnerKey: string | null;
1201
+ }
1202
+
1203
+ /**
1204
+ * A `CadreControl.PendingJoin` row: a join this party asked for through another party's
1205
+ * invitation, readable by every machine of the party. Each outcome carries only its own
1206
+ * columns (the schema's `OutcomeShape` check): `'joined'` sets `StrandId` (and, for a
1207
+ * closed strand, `MembershipInvite`), `'failed'` sets `FailureCode`, and a pending row
1208
+ * (`Outcome: null`) sets neither.
1209
+ */
1210
+ export interface PendingJoinRow {
1211
+ /** base64url sha256 of the invitation token (`pendingJoinId`). */
1212
+ Id: string;
1213
+ /** `CadreNode.encodeInvitation` of the invitation. A bearer credential. */
1214
+ Invitation: string;
1215
+ /** `canonicalJson` of the disclosure the requester gave. */
1216
+ Disclosure: string;
1217
+ /** Epoch ms. */
1218
+ RequestedAt: number;
1219
+ /** Epoch ms; no attempt starts at or after it. */
1220
+ ExpiresAt: number;
1221
+ Outcome: null | 'joined' | 'failed';
1222
+ /** Epoch ms the outcome was recorded; null while pending. */
1223
+ OutcomeAt: number | null;
1224
+ /** `'joined'`: the strand the formation returned. */
1225
+ StrandId: string | null;
1226
+ /** `'joined'`, closed strand: JSON of the {@link StrandMembershipInvite} the formation delivered. */
1227
+ MembershipInvite: string | null;
1228
+ /** `'failed'`: a `FormationRejectionCode`, `'expired'` or `'local'`. Not constrained by the schema. */
1229
+ FailureCode: string | null;
1230
+ /** `'failed'`: human-readable text. */
1231
+ FailureReason: string | null;
1232
+ /** Single-use nonce of this row incarnation; `ControlDatabase.replacePendingJoin` names it as the row it replaces. */
1233
+ StampId: string;
1234
+ }
1235
+
1236
+ /**
1237
+ * A join this party asked for through `CadreNode.requestJoin`, as one machine sees it.
1238
+ *
1239
+ * `pending`, `joined` and `failed` come from the party-wide `PendingJoin` row and read the same
1240
+ * on every machine. `trying` (an attempt is running on this machine) and `waiting` (this
1241
+ * machine's last attempt failed in a way worth retrying) are this machine's own view of a
1242
+ * pending row, so two machines can report different states for it.
1243
+ */
1244
+ export interface PendingJoinStatus {
1245
+ /** `PendingJoin.Id`: the sha256 of the invitation token. */
1246
+ id: string;
1247
+ sAppId: string;
1248
+ /** Epoch ms. */
1249
+ requestedAt: number;
1250
+ /** Epoch ms; no attempt starts at or after it. */
1251
+ expiresAt: number;
1252
+ state: 'pending' | 'trying' | 'waiting' | 'joined' | 'failed';
1253
+ /** `'waiting'`: when this machine tries again, epoch ms. */
1254
+ nextAttemptAt?: number;
1255
+ /**
1256
+ * `'waiting'`: why this machine's last attempt failed. `'unreachable'` means no answer came
1257
+ * back; `'local'` means the attempt failed on this machine before an answer.
1258
+ */
1259
+ lastError?: { code: FormationRejectionCode | 'unrecognized' | 'unreachable' | 'local'; reason: string };
1260
+ /** `'joined'`: the strand the join produced. */
1261
+ strandId?: string;
1262
+ /** `'failed'`: a `FormationRejectionCode`, `'expired'`, or `'local'` (approved, then a step on the joining machine failed). */
1263
+ failure?: { code: string; reason: string };
1264
+ }
1265
+
1266
+ /**
1267
+ * sApp configuration provided by the hosting application when creating a strand.
1268
+ * This is what the app developer provides - NOT loaded from the network.
1269
+ */
1270
+ export interface SAppConfig {
1271
+ /** Public key of the sApp author */
1272
+ id: string;
1273
+ /** Version of the sApp */
1274
+ version: string;
1275
+ /** The declarative schema DDL */
1276
+ schema: string;
1277
+ /**
1278
+ * Author's signature over the schema for verification. Required under the
1279
+ * default `requireSignedSchemas` node policy — a config without it is rejected
1280
+ * before strand bring-up. Optional in the type because authoring/serialization
1281
+ * must represent an as-yet-unsigned config; omitting it is honored only when
1282
+ * the node explicitly relaxes the policy (`requireSignedSchemas: false`, dev/test).
1283
+ */
1284
+ signature?: string;
1285
+ /** Latency hint for hibernation behavior (optional, defaults to config) */
1286
+ latencyHint?: LatencyHint;
1287
+ }
1288
+
1289
+ /**
1290
+ * Full strand configuration when adding a strand via the API
1291
+ */
1292
+ export interface StrandConfig {
1293
+ /** Strand row from control network */
1294
+ strandRow: StrandRow;
1295
+ /** sApp configuration provided by the hosting application */
1296
+ sAppConfig: SAppConfig;
1297
+ /**
1298
+ * Whether THIS node is the strand's founder — the party that provisioned and
1299
+ * published the strand (the responder in formation, the creator in host/solo
1300
+ * paths; the same party that calls {@link CadreNode.publishStrand}). The founder
1301
+ * runs the one-time membership bootstrap at bring-up (writes `Strand.Header`, and
1302
+ * for a closed strand the founding `Member`+`Manager`). A joiner leaves this
1303
+ * unset and writes nothing — it receives those rows via Optimystic sync.
1304
+ *
1305
+ * When unset, founder-ness is DERIVED from the row: this node founds iff
1306
+ * {@link StrandRow.FounderOwnerKey} equals its own owner key. An explicit
1307
+ * `true`/`false` wins over the derivation — the formation/responder flows pass
1308
+ * it deliberately, since a consent-seated row carries a null column.
1309
+ */
1310
+ founder?: boolean;
1311
+ /**
1312
+ * THIS party's own strand membership private key (base64 protobuf, as
1313
+ * `generateStrandMemberKey` mints) for a CLOSED strand — the identity whose public
1314
+ * key seats the founding `Member`/`Manager`. The explicit sibling of
1315
+ * {@link founder}: normally the launch reads it from the party's control-layer
1316
+ * `StrandPartyKey` row (minting one on the founding machine when absent), and an
1317
+ * explicit value here WINS over that read — for callers whose row carries no
1318
+ * founder provenance to heal against (an explicit `founder: true` over a hand-built
1319
+ * row, as the test harness does). Deliberately NOT
1320
+ * {@link StrandRow.MemberPrivateKey}, the strand-wide read secret every joining
1321
+ * party receives — that key derives nobody's identity.
1322
+ */
1323
+ partyMemberPrivateKey?: string;
1324
+ /**
1325
+ * Whether {@link CadreNode.addStrand} waits for a JOINING machine's first sync before it
1326
+ * resolves. Default `true`: the returned instance is writable (`database` set, status
1327
+ * `'active'`), or the call rejects with `StrandAwaitingFirstSyncError` after
1328
+ * `CadreNodeConfig.strandFirstSync.timeoutMs` — retryable, the launch stays up. `false`
1329
+ * returns as soon as the runtime is launched, possibly `'syncing'` with no `database`;
1330
+ * the caller then wires its own peers (a hand-dialed test fixture) and awaits
1331
+ * {@link CadreNode.whenStrandWritable} or the `strand:writable` event. A founder, or a
1332
+ * machine that already holds the strand's `Strand.Header`, is never gated either way.
1333
+ */
1334
+ awaitFirstSync?: boolean;
1335
+ }
1336
+
1337
+ /**
1338
+ * What {@link CadreNode.foundStrand} needs to publish a strand's control row AND start it
1339
+ * locally as its founder — the resumable one-call form of `publishStrand` + `addStrand`.
1340
+ *
1341
+ * No `strandRow`: the row is the OUTPUT of founding, either freshly published or read back
1342
+ * from an interrupted earlier attempt. `StrandConfig` (the join/attach shape) takes one
1343
+ * because there the row already exists and came from the control network.
1344
+ */
1345
+ export interface FoundStrandConfig {
1346
+ /** Unique strand identifier. Trimmed; blank is rejected. */
1347
+ strandId: string;
1348
+ /** `'o'` for open (default) or `'c'` for closed. */
1349
+ type?: 'o' | 'c';
1350
+ /**
1351
+ * Membership key gating a closed strand. Required in practice for `type: 'c'` (the
1352
+ * founder bootstrap rejects a null key), and IGNORED when the strand is already
1353
+ * published — the stored key wins, so read the resolved key back off the returned
1354
+ * instance rather than trusting a freshly minted one.
1355
+ */
1356
+ memberPrivateKey?: string;
1357
+ /** sApp configuration the hosting application provides, as for {@link StrandConfig}. */
1358
+ sAppConfig: SAppConfig;
1359
+ /**
1360
+ * Forwarded to the attach ({@link StrandConfig.awaitFirstSync}). Matters only when the
1361
+ * call ATTACHES rather than founds — the stored row was published by a sibling machine
1362
+ * that won the founding race — in which case this machine is a joiner whose database
1363
+ * is withheld until that sibling's `Strand.Header` reaches it; default `true` waits
1364
+ * for that inside the call. A founding launch is never gated.
1365
+ */
1366
+ awaitFirstSync?: boolean;
1367
+ }
1368
+
1369
+ /**
1370
+ * What {@link CadreNode.foundStrand} hands back: the running instance PLUS the `Strand` row
1371
+ * the strand actually runs under.
1372
+ *
1373
+ * The row is returned separately rather than left for the caller to read off the instance
1374
+ * because on a resumed founding it is NOT the caller's input — a closed strand adopts the
1375
+ * stored `MemberPrivateKey`, discarding a freshly minted one. A caller that must carry the
1376
+ * membership key onward (to mint an invitation, say) reads it from HERE.
1377
+ */
1378
+ export interface FoundStrandResult {
1379
+ /** The active local instance, as {@link CadreNode.addStrand} would have returned. */
1380
+ instance: StrandInstance;
1381
+ /** The live control-plane row: freshly published, or the one already there. */
1382
+ strandRow: StrandRow;
1383
+ /**
1384
+ * Whether THIS machine actually founded (ran / will have run the one-time
1385
+ * founder bootstrap). `false` when the resolved row was published by a
1386
+ * DIFFERENT machine's owner key — e.g. a sibling won a concurrent founding
1387
+ * race — in which case the call attached as a joiner instead, which is the
1388
+ * correct outcome (two machines bootstrapping one strand on separate replicas
1389
+ * is the double-`Header` hazard).
1390
+ */
1391
+ founded: boolean;
1392
+ }
1393
+
1394
+ /**
1395
+ * Result of creating a new cadre peer
1396
+ */
1397
+ export interface CreatePeerResult {
1398
+ peerId: PeerId;
1399
+ privateKey: Uint8Array;
1400
+ }
1401
+
1402
+ // ============================================================================
1403
+ // Member Registration API Types (from api.md)
1404
+ // ============================================================================
1405
+
1406
+ /**
1407
+ * Registration data for joining a strand as a member.
1408
+ * Sent from invited member to any cadre member to accept invitation.
1409
+ */
1410
+ export interface MemberRegistration {
1411
+ /** The strand being joined */
1412
+ strandId: string;
1413
+ /** The member's public key for this strand */
1414
+ key: string;
1415
+ /** Peer IDs of the member's cadre nodes that will participate */
1416
+ peerIds: string[];
1417
+ }
1418
+
1419
+ /**
1420
+ * Result of member registration
1421
+ */
1422
+ export interface MemberRegistrationResult {
1423
+ success: boolean;
1424
+ reason?: string;
1425
+ }
1426
+
1427
+ // ============================================================================
1428
+ // Strand Solicitation API Types (from api.md)
1429
+ // ============================================================================
1430
+
1431
+ /**
1432
+ * Open invitation for strand formation.
1433
+ * Shared out-of-band to allow strangers to form strands.
1434
+ */
1435
+ export interface OpenInvitation {
1436
+ /** Unique token identifying this invitation */
1437
+ token: string;
1438
+ /** The sApp that will be used for the strand */
1439
+ sAppId: string;
1440
+ /** When this invitation expires */
1441
+ expiration: Date;
1442
+ /**
1443
+ * Addresses of the inviting party's machines, each ending in `/p2p/<peerId>`: the minting
1444
+ * machine's own first, then up to three of its siblings with up to four addresses each
1445
+ * (`CadreNode.createOpenInvitation`). A joiner tries the machines in order, one formation
1446
+ * session each, so an entry may be dead by the time it is used; one that names no machine
1447
+ * is ignored.
1448
+ */
1449
+ bootstrap: string[];
1450
+ }
1451
+
1452
+ /**
1453
+ * A single-use strand membership invitation carried on a closed-strand formation
1454
+ * result: the `Strand.Invite` keypair the responder (the host strand's founder party)
1455
+ * issued for this redemption. The joiner spends it — `consumeInvite` on its own strand
1456
+ * replica — to seat a `Strand.Member` row under its OWN party key, so joining no longer
1457
+ * hands out the founder's identity. Structurally identical to
1458
+ * `strand-membership-writer`'s `IssuedInvite`; declared here so the wire/type layer does
1459
+ * not import the writer.
1460
+ */
1461
+ export interface StrandMembershipInvite {
1462
+ /** The invite ed25519 PUBLIC key (base64url) — the `Strand.Invite.Key` row. */
1463
+ inviteKey: string;
1464
+ /**
1465
+ * The invite ed25519 PRIVATE seed (base64url). A single-use bearer credential:
1466
+ * whoever holds it can `consumeInvite` exactly once. Same sensitivity class and
1467
+ * handling as `memberPrivateKey` — delivered only inside the validated,
1468
+ * post-approval formation result. The inviting side never writes it to its control
1469
+ * DB. The joining side may copy it into its own `PendingJoin` row
1470
+ * (`MembershipInvite`), owner-signed and party-private like
1471
+ * `JoinedStrand.MemberPrivateKey`, so a join finished on one machine of the party
1472
+ * can be seated by whichever machine launches the strand first.
1473
+ */
1474
+ invitePrivateKey: string;
1475
+ }
1476
+
1477
+ /**
1478
+ * Result of forming a strand via open invitation
1479
+ */
1480
+ export interface FormStrandResult {
1481
+ /** The member key assigned to the initiator for this strand */
1482
+ memberKey: string;
1483
+ /** Private key for the invitation (for signing future messages) */
1484
+ invitePrivateKey: string;
1485
+ /** The strand that was created */
1486
+ strandId: string;
1487
+ /**
1488
+ * The strand's membership key (closed-strand read-gating secret), delivered through
1489
+ * the formation protocol after consent (provision-then-record). Present only for a
1490
+ * closed strand the responder returned with its key; undefined otherwise. Distinct
1491
+ * from {@link invitePrivateKey} (the initiator's own generated signing key).
1492
+ */
1493
+ memberPrivateKey?: string;
1494
+ /**
1495
+ * The joiner's own single-use membership invitation into the (closed, bound) host
1496
+ * strand — see {@link StrandMembershipInvite}. Present only when the responder's
1497
+ * host strand is closed and its runtime issued one; absent for open strands and the
1498
+ * responder-provisions (unbound) path. `CadreNode.formStrand` itself persists the
1499
+ * joiner's party key and caches this invitation for the strand bring-up to redeem,
1500
+ * so an embedding app normally never touches it.
1501
+ */
1502
+ membershipInvite?: StrandMembershipInvite;
1503
+ /**
1504
+ * The responder's live STRAND-network multiaddrs for the formed strand — the only
1505
+ * cross-party discovery seed there is, since the strand-addr RPC that resolves a
1506
+ * sibling's strand addresses answers own-party callers only.
1507
+ *
1508
+ * Always an array, frequently EMPTY: the responder discloses none when it holds no
1509
+ * live strand node for the strand it just provisioned (the responder-provisions path
1510
+ * mints a strand that has not launched yet). `CadreNode.formStrand` records these
1511
+ * itself, so an embedding app only needs them to seed a strand it launches OUTSIDE
1512
+ * that node.
1513
+ *
1514
+ * One-shot and in-memory: they are the responder's addresses at the moment of
1515
+ * formation and are never re-resolved — see `docs/strands.md`.
1516
+ */
1517
+ strandAddrs: string[];
1518
+ }
1519
+
1520
+ /**
1521
+ * Disclosure object provided during strand formation.
1522
+ * Contains identity and context information from the initiator.
1523
+ */
1524
+ export interface StrandFormationDisclosure {
1525
+ /** Initiator's party identifier */
1526
+ partyId?: string;
1527
+ /** Additional identity bundle (app-specific) */
1528
+ identityBundle?: unknown;
1529
+ /** Human-readable purpose/reason for forming the strand */
1530
+ purpose?: string;
1531
+ /** Additional app-specific metadata */
1532
+ metadata?: Record<string, unknown>;
1533
+ }
1534
+
1535
+ /**
1536
+ * Arachnode ring participation stub - will be implemented when arachnode is built
1537
+ */
1538
+ export interface ArachnodeConfig {
1539
+ /** Enable Arachnode subsystem (Ring Zulu + storage rings). Storage-profile only. */
1540
+ enableRingZulu: boolean;
1541
+ /** Storage ring participation (storage profile only) */
1542
+ storageRing?: {
1543
+ /** Which ring to participate in based on capacity */
1544
+ ring: number;
1545
+ /** Partition within the ring */
1546
+ partition?: number;
1547
+ };
1548
+ }
1549
+
1550
+ /**
1551
+ * Events emitted by CadreNode
1552
+ */
1553
+ export interface CadreNodeEvents {
1554
+ 'strand:started': { strandId: string };
1555
+ 'strand:stopped': { strandId: string };
1556
+ 'strand:error': { strandId: string; error: Error };
1557
+ 'strand:idle': { strandId: string };
1558
+ 'strand:hibernating': { strandId: string };
1559
+ 'strand:waking': { strandId: string };
1560
+ /**
1561
+ * Emitted when a strand that came up `'syncing'` — a joining machine whose database was
1562
+ * withheld until it received the strand's `Strand.Header` from another member — becomes
1563
+ * writable: `StrandInstance.database` is now set and the status is `'active'`. A strand
1564
+ * whose `strand:started` already reported `'active'` (a founder, or a machine that had
1565
+ * synced before) never emits this. The event form of what {@link CadreNode.addStrand}
1566
+ * awaits; an app that attaches without waiting (`awaitFirstSync: false`, or a strand the
1567
+ * watcher auto-launched) hangs its "waiting for the other member" screen on the pair.
1568
+ */
1569
+ 'strand:writable': { strandId: string };
1570
+ /**
1571
+ * Emitted when this node discovers it is no longer a member of a CLOSED
1572
+ * strand — its party was removed by a manager, or it left. Detected by the
1573
+ * strand's revoked-peer gate (`strand-revocation-enforcer.ts`) seeing this
1574
+ * node's own peer id in the revoked set, so it is BEST-EFFORT: it fires only
1575
+ * if the removal replicated here before the rest of the strand cut us off,
1576
+ * and a node that was already offline may never see it at all.
1577
+ *
1578
+ * Nothing is stopped or torn down for you. Treat it as "this strand will no
1579
+ * longer sync": remaining members refuse this node's streams, dials, and
1580
+ * connections, so reads keep working against whatever is stored locally while
1581
+ * writes stop propagating. Stopping the strand (`stopStrand`) or deleting its
1582
+ * data is the app's decision. Emitted once per removal — re-armed if a manager
1583
+ * re-admits this party, so a second removal is reported again — and a
1584
+ * hibernation wake rebuilds the gate, which may re-emit for a strand still
1585
+ * revoked.
1586
+ *
1587
+ * For a strand joined from another party, the node also forgets its remembered
1588
+ * join (see {@link CadreNodeConfig.joinedStrands}): it keeps running this session
1589
+ * and is not re-offered after the next start.
1590
+ */
1591
+ 'strand:revoked': { strandId: string };
1592
+ /**
1593
+ * Emitted when this node holds a staged membership invitation for a CLOSED strand
1594
+ * that its own writes cannot redeem — the shape a REMOVED party hits when a manager
1595
+ * hands it a fresh invitation. Redeeming means writing this party's `Strand.Member`
1596
+ * row into the strand, and the machines that would carry that write are the ones the
1597
+ * remaining members refuse, so the attempt is made and fails. Two triggers, reported
1598
+ * at most once per re-arm of the membership loop (a further invitation staged while the
1599
+ * loop is still running does not reset the report):
1600
+ *
1601
+ * - CONFIRMED: the revoked-peer gate already flags this node as removed (the
1602
+ * `strand:revoked` case) and an invitation is staged.
1603
+ * - PROBABLE: `UNFINISHED_PASSES_BEFORE_ESCALATION` consecutive attempts left the
1604
+ * invitation staged. This one is a SUSPICION, not a verdict — the invitation's row
1605
+ * may simply not have replicated here yet on a slow strand — and the accompanying
1606
+ * warning names both causes.
1607
+ *
1608
+ * Nothing is stopped or torn down. A fresh invitation cannot re-admit a removed
1609
+ * party by itself: the remedy is a remaining manager admitting this party's member
1610
+ * key directly (`addMemberByManager`), after which the membership loop, which keeps
1611
+ * retrying, finishes the join on its own.
1612
+ */
1613
+ 'strand:rejoin-blocked': { strandId: string };
1614
+ /**
1615
+ * Emitted when the control network advertises a strand this node has no
1616
+ * registered `sAppConfig` for — i.e. a strand created by another member, or
1617
+ * one this node ran in a previous session (sApp configs are in-memory only and
1618
+ * do not survive `stop()`). The hosting app decides whether to join it
1619
+ * (register a config + `addStrand`, e.g. via a chat `joinChatStrand` helper).
1620
+ * Carries the full {@link StrandRow} so the app can join without re-querying
1621
+ * the control DB.
1622
+ *
1623
+ * Also emitted for a strand this node joined from ANOTHER party, which no control
1624
+ * row names: the node remembers every such join (see
1625
+ * {@link CadreNodeConfig.joinedStrands}) and offers it here on each start, as a row
1626
+ * with `FounderOwnerKey: null` carrying the closed strand's `MemberPrivateKey`. That
1627
+ * row is the product of the formation's consent, so claiming it needs no second one.
1628
+ *
1629
+ * **Fired once per strand per session, and it can fire before your listener is
1630
+ * attached.** The strand watcher's first poll runs inside `CadreNode.start()`
1631
+ * (100 ms after the watcher starts), so every strand already stored for this
1632
+ * party is normally offered while the embedding app is still inside its own
1633
+ * `start()` await. The watcher then records the strand as seen and no later poll
1634
+ * re-offers it, so a listener attached a moment late misses those strands for the
1635
+ * life of the process.
1636
+ *
1637
+ * One exception, and it does NOT produce a second event: when an `addStrand` that
1638
+ * claims a discovered strand FAILS, the watcher is told to forget the strand so a
1639
+ * later poll retries it. That claim left the sApp config registered, so the retry
1640
+ * takes the auto-launch branch — what the app sees is `strand:error` per failed
1641
+ * retry and `strand:started` when one succeeds.
1642
+ *
1643
+ * So an app that auto-joins discovered strands must **subscribe first, then
1644
+ * drain `CadreNode.getDiscoveredStrands()`** — the map of strands no local
1645
+ * config claims. In that order a strand discovered between the two steps is
1646
+ * handled twice rather than not at all, which is why the join handler needs to
1647
+ * be idempotent (guard on an in-flight set, not only on
1648
+ * `getStrands().has(id)` — the strand manager tracks an instance only once
1649
+ * `addStrand` has resolved).
1650
+ *
1651
+ * On a node that hosts storage replicas ({@link CadreNodeConfig.hostUnclaimedStrands},
1652
+ * the default for `profile: 'storage'`) the event still fires once, and the node launches
1653
+ * the strand as a replica right after it — so there `getStrands().has(id)` soon turns
1654
+ * true for a strand no app has claimed; an unclaimed instance has no `sAppInfo`. A
1655
+ * launch that fails is retried by the watcher without a second `strand:discovered`.
1656
+ */
1657
+ 'strand:discovered': { strandId: string; strand: StrandRow };
1658
+ 'control:connected': void;
1659
+ 'control:disconnected': void;
1660
+ /**
1661
+ * Emitted when the control-write retry funnel GAVE UP on a local control write — the
1662
+ * classifier declined the failure as non-transient, or every attempt (or the elapsed
1663
+ * budget) ran out. One event per abandoned write, carrying the operation label, how far
1664
+ * it got, why it stopped and the error.
1665
+ *
1666
+ * Exists because an abandoned BACKGROUND write is otherwise invisible: the node's own
1667
+ * self-address republish, the post-connect drain and the replication drain all fire
1668
+ * their writes unawaited with a `debug`-only catch, and that namespace is off unless
1669
+ * something enabled it — so the write is lost and nobody is told. A FOREGROUND write's
1670
+ * caller sees the rethrown error as well as this event.
1671
+ *
1672
+ * What an app does with it is its own call; the node itself only escalates ONE case to
1673
+ * the operator (a self-address republish that has been failing longer than a peer
1674
+ * record stays fresh, at which point other machines are already discarding this node's
1675
+ * address). Treat the rest as telemetry — a control write that was lost, not a strand
1676
+ * or connection state change.
1677
+ */
1678
+ 'control:write-abandoned': ControlRetryAbandonment;
1679
+ /**
1680
+ * Emitted when this machine's view of a join asked for with `CadreNode.requestJoin` changes:
1681
+ * its own attempt started or ended, or a pass read an outcome another owner machine wrote.
1682
+ * Owner machines only, since only they run the retry loop. A dismissed join emits nothing
1683
+ * further; `listPendingJoins` no longer names it.
1684
+ */
1685
+ 'pendingJoin:changed': PendingJoinStatus;
1686
+ /** Emitted when a seed is received via the seed protocol */
1687
+ 'seed:received': { partyId: string; peerId: string };
1688
+ /** Emitted when a seed is successfully applied */
1689
+ 'seed:applied': { partyId: string; peersAdded: number };
1690
+ /** Emitted when seed application fails */
1691
+ 'seed:error': { partyId: string; error: string };
1692
+ }
1693
+
1694
+ // ============================================================================
1695
+ // Peer Address Record / Resolution Types
1696
+ // ============================================================================
1697
+
1698
+ /**
1699
+ * A peer's self-published, freshness-stamped, self-signed address record.
1700
+ *
1701
+ * This is the logical shape of a `CadreControl.CadrePeer` row. A node publishes
1702
+ * its own record (signing with the ed25519 key behind its PeerId); any node can
1703
+ * resolve another member's current signaling/relay multiaddrs from only its
1704
+ * PeerId by reading the record, re-verifying the self-signature against
1705
+ * `publicKey`, checking freshness, and applying a trust gate.
1706
+ *
1707
+ * The signature covers `peerId`, the comma-joined `addrs`, and `updatedAt` — see
1708
+ * `peerRecordSignedPayload`. `publicKey` is NOT inside the signed payload: it is
1709
+ * the key the signature is verified *with*, and its libp2p-identity binding to
1710
+ * `peerId` is checked separately at resolve time, so re-signing it would be
1711
+ * redundant.
1712
+ */
1713
+ export interface PeerAddressRecord {
1714
+ /** libp2p peer ID (base58btc) — the row key. */
1715
+ peerId: string;
1716
+ /** ed25519 public key (base64url) whose libp2p identity IS `peerId`. */
1717
+ publicKey: string;
1718
+ /** Current dialable multiaddrs, signaling (`/p2p-circuit`) first. */
1719
+ addrs: string[];
1720
+ /** epoch ms — freshness; strictly increasing per peer. */
1721
+ updatedAt: number;
1722
+ /** ed25519 self-signature over the signed payload, base64url. */
1723
+ sig: string;
1724
+ }
1725
+
1726
+ /**
1727
+ * One `CadreControl.CadrePeer` row as read by `ControlDatabase.queryCadrePeers`:
1728
+ * the addressing columns plus the persisted membership voucher.
1729
+ *
1730
+ * The voucher triple (`stampId`, `vouchOwner`, `vouchSig`) is what
1731
+ * {@link CadreNode.listAuthorizedMembers} re-checks against the node-local
1732
+ * trusted-owner anchor — it is null on a row written before the voucher columns
1733
+ * existed, and all three must be present for the row to be authorizable. Named
1734
+ * (rather than restated inline at each read site) so a future column addition
1735
+ * cannot reach the query without the predicate seeing it.
1736
+ */
1737
+ export interface CadrePeerRow {
1738
+ /** libp2p peer ID (base58btc) — the row key. */
1739
+ peerId: string;
1740
+ /** Comma-joined dialable multiaddrs as stored, or null when unpublished. */
1741
+ multiaddr: string | null;
1742
+ /** Single-use anti-replay nonce the voucher signature is bound to. */
1743
+ stampId: string | null;
1744
+ /** ed25519 public key (base64url) of the owner that vouched this row. */
1745
+ vouchOwner: string | null;
1746
+ /** That owner's signature over `digest('CadreControl.CadrePeer', 'vouch', peerId, stampId)`, base64url. */
1747
+ vouchSig: string | null;
1748
+ }
1749
+
1750
+ /** The voucher-bearing subset of a {@link CadrePeerRow} — what the authorized-membership predicate reads. */
1751
+ export type CadrePeerVoucherFields = Pick<CadrePeerRow, 'peerId' | 'stampId' | 'vouchOwner' | 'vouchSig'>;
1752
+
1753
+ /**
1754
+ * One `CadreControl.Revocation` tombstone as read by
1755
+ * `ControlDatabase.queryRevocations`: the identity triple plus the `ReissuedAt`
1756
+ * counter an owner bumps (`ControlDatabase.reissueRevocations`) to re-write — and
1757
+ * therefore re-broadcast — a tombstone that committed while the node was alone.
1758
+ * Never the ledger marker: that row names no guarded table, and `queryRevocations`
1759
+ * skips it, which is what keeps `tableName` a {@link RevocableTable}.
1760
+ */
1761
+ export interface RevocationRow {
1762
+ /** Which guarded table's stamp was retired. */
1763
+ tableName: RevocableTable;
1764
+ /** Primary key of the removed row (peer id / key / strand id). */
1765
+ rowKey: string;
1766
+ /** The retired single-use nonce — the tombstone's identity. */
1767
+ stampId: string;
1768
+ /** Monotonic re-issue counter; carries no semantics (see the schema comment). */
1769
+ reissuedAt: number;
1770
+ }
1771
+
1772
+ /**
1773
+ * Outcome of `ControlDatabase.openRevocationLedger`: `'opened'` when this call filed the
1774
+ * singleton `Revocation` ledger marker, `'already-open'` when it was already filed (seen
1775
+ * by the in-lock guard, or filed first by another owner and refused on the primary key).
1776
+ * Either way the marker exists afterwards.
1777
+ */
1778
+ export type RevocationLedgerOpenResult = 'opened' | 'already-open';
1779
+
1780
+ /**
1781
+ * Outcome of {@link CadreNode.registerSelf}, surfaced so callers (e.g. the CLI
1782
+ * `--owner` branch) can log what happened:
1783
+ * - `inserted` — the first owner-signed INSERT of this node's `CadrePeer` row.
1784
+ * - `refreshed` — a self-signed UPDATE of an existing row (heartbeat / addr change).
1785
+ * Also reported when a first publish found its row seated by a concurrent
1786
+ * `authorizePeer` of this node's own id and fell through to the UPDATE — the write
1787
+ * really was an UPDATE, even though the caller's intent was a first publish.
1788
+ * - `skipped` — nothing written (no self-signing key, not yet a member with no
1789
+ * owner service to self-insert, or the row was removed mid-publish).
1790
+ */
1791
+ export type SelfRegistrationOutcome = 'inserted' | 'refreshed' | 'skipped';
1792
+
1793
+ /**
1794
+ * Options for {@link CadreNode.resolvePeerAddrs}.
1795
+ */
1796
+ export interface ResolveOpts {
1797
+ /**
1798
+ * Freshness ceiling in ms. A record older than this is filtered out (a
1799
+ * resolver must never hand back a dead relay reservation). Defaults to
1800
+ * {@link DEFAULT_PEER_RECORD_MAX_AGE_MS}.
1801
+ */
1802
+ maxAgeMs?: number;
1803
+ /** Return only `/p2p-circuit` signaling addrs (the WebRTC dial input). */
1804
+ signalingOnly?: boolean;
1805
+ /**
1806
+ * Pluggable trust gate, evaluated after signature + freshness pass. Defaults
1807
+ * to {@link currentMemberTrustPolicy} (any existing — therefore
1808
+ * owner-vouched — CadrePeer member is trusted). Inject a stricter policy
1809
+ * (e.g. from `seed-signerkey-trust-policy`) to reject otherwise-valid records
1810
+ * before dialing.
1811
+ */
1812
+ trustPolicy?: PeerResolveTrustPolicy;
1813
+ }
1814
+
1815
+ /**
1816
+ * Context handed to a {@link PeerResolveTrustPolicy} when gating a resolution.
1817
+ */
1818
+ export interface PeerResolveContext {
1819
+ /** The peer being resolved. */
1820
+ peerId: string;
1821
+ /** The record's ed25519 public key (already bound to `peerId` and verified). */
1822
+ publicKey: string;
1823
+ /** The party whose control network produced the record. */
1824
+ partyId: string;
1825
+ /** The full verified, fresh record. */
1826
+ record: PeerAddressRecord;
1827
+ }
1828
+
1829
+ /**
1830
+ * Decides whether a signature-verified, fresh peer-address record should be
1831
+ * trusted enough to dial. The seam through which a richer trust model
1832
+ * (trust circle, pinned keys) composes with `resolvePeerAddrs`.
1833
+ */
1834
+ export interface PeerResolveTrustPolicy {
1835
+ evaluate(ctx: PeerResolveContext): Promise<boolean> | boolean;
1836
+ }
1837
+
1838
+ // ============================================================================
1839
+ // Device Token Registry Types
1840
+ // ============================================================================
1841
+
1842
+ /** Platform push channel a {@link DeviceTokenRecord} targets. */
1843
+ export type PushPlatform = 'fcm' | 'apns';
1844
+
1845
+ /**
1846
+ * A mobile cadre peer's self-published, freshness-stamped, self-signed platform
1847
+ * push token.
1848
+ *
1849
+ * This is the logical shape of a `CadreControl.DeviceToken` row. A phone publishes
1850
+ * its own record (signing with the ed25519 key behind its PeerId); a server peer
1851
+ * resolves it from the phone's PeerId to deliver a push-wake over FCM/APNs when a
1852
+ * control-network dial cannot reach the OS-suspended process. The signature covers
1853
+ * `peerId`, `platform`, `token`, and `updatedAt` — see `deviceTokenSignedPayload`.
1854
+ *
1855
+ * Unlike {@link PeerAddressRecord}, the record carries NO public key: it is verified
1856
+ * against the `CadrePeer.PublicKey` bound to the same `peerId`, so a `DeviceToken`
1857
+ * with no backing `CadrePeer` row can never be resolved.
1858
+ */
1859
+ export interface DeviceTokenRecord {
1860
+ /** libp2p peer ID (base58btc) — the row key and the CadrePeer this token belongs to. */
1861
+ peerId: string;
1862
+ /** Push channel: `'fcm'` (Android/Firebase) or `'apns'` (Apple). */
1863
+ platform: PushPlatform;
1864
+ /** Opaque platform device/registration token. */
1865
+ token: string;
1866
+ /** epoch ms — freshness; strictly increasing per self-update (replay guard). */
1867
+ updatedAt: number;
1868
+ /** ed25519 self-signature over the signed payload, base64url. */
1869
+ sig: string;
1870
+ }
1871
+
1872
+ /**
1873
+ * A stored `CadreControl.DeviceToken` row: the self-signed
1874
+ * {@link DeviceTokenRecord} plus the row's single-use `StampId` nonce.
1875
+ *
1876
+ * The stamp is deliberately OUTSIDE `DeviceTokenRecord`: the peer's own `Sig`
1877
+ * covers only (peerId, platform, token, updatedAt), while the stamp is bound by
1878
+ * the OWNER's insert/delete approvals. Readers need it to drop a row whose stamp
1879
+ * has been retired into `CadreControl.Revocation` (a token resurrected by an
1880
+ * approval replay on a node that had not yet converged on the tombstone).
1881
+ */
1882
+ export interface DeviceTokenRow extends DeviceTokenRecord {
1883
+ /** Single-use authorization nonce; rotates on every (re)insert. */
1884
+ stampId: string;
1885
+ }
1886
+
1887
+ /**
1888
+ * Options for {@link CadreNode.resolveDeviceToken}.
1889
+ */
1890
+ export interface ResolveDeviceTokenOpts {
1891
+ /**
1892
+ * Optional freshness ceiling in ms. Defaults to NO ceiling (a record is fresh
1893
+ * as long as its `updatedAt` is positive): unlike a relay reservation, a device
1894
+ * push token stays valid until it rotates, so a long-suspended phone must remain
1895
+ * resolvable for push-wake. Set this to bound staleness explicitly.
1896
+ */
1897
+ maxAgeMs?: number;
1898
+ }
1899
+
1900
+ // ============================================================================
1901
+ // Push Credentials (platform push delivery — see push-notifier.ts)
1902
+ // ============================================================================
1903
+
1904
+ /**
1905
+ * Platform push-delivery credentials injected into a participating node's config
1906
+ * (`CadreNodeConfig.push`). Provisioned per spawned node by `cadre-host` and read
1907
+ * by `createPushNotifier` to construct the FCM and/or APNs senders. A platform
1908
+ * whose block is absent is simply not deliverable (the router returns a
1909
+ * best-effort "no credentials" failure rather than throwing).
1910
+ *
1911
+ * Secret hygiene: every `privateKey` field is a secret — never logged (treated
1912
+ * like the node's startup/seed tokens).
1913
+ */
1914
+ export interface PushCredentials {
1915
+ /** FCM (Android / Firebase) service-account credentials. */
1916
+ fcm?: FcmCredentials;
1917
+ /** APNs (Apple) auth-key credentials. */
1918
+ apns?: ApnsCredentials;
1919
+ /**
1920
+ * Per-`(peer, strand)` minimum gap between push-wakes the server fan-out emits —
1921
+ * the anti-spam cooldown a chatty strand needs. Default
1922
+ * {@link DEFAULT_PUSH_COOLDOWN_MS} (5 min). In-memory, acceptably lossy across
1923
+ * restarts (`serviceWake` is idempotent, so a duplicate wake is harmless).
1924
+ */
1925
+ cooldownMs?: number;
1926
+ /**
1927
+ * Per-strand burst-coalescing window for the fan-out: a second activity trigger
1928
+ * within this window is dropped (one wake is enough). Default
1929
+ * {@link DEFAULT_PUSH_DEBOUNCE_MS} (10 s).
1930
+ */
1931
+ debounceMs?: number;
1932
+ }
1933
+
1934
+ /** Google service-account fields needed for FCM HTTP v1 OAuth2. */
1935
+ export interface FcmCredentials {
1936
+ /** GCP project id → `POST https://fcm.googleapis.com/v1/projects/{projectId}/messages:send`. */
1937
+ projectId: string;
1938
+ /** Service-account email (JWT `iss`/`sub`). */
1939
+ clientEmail: string;
1940
+ /** Service-account RSA private key, PEM. Secret — never logged. */
1941
+ privateKey: string;
1942
+ }
1943
+
1944
+ /** Apple APNs auth-key (.p8) fields. */
1945
+ export interface ApnsCredentials {
1946
+ /** APNs key id (JWT header `kid`). */
1947
+ keyId: string;
1948
+ /** Apple team id (JWT `iss`). */
1949
+ teamId: string;
1950
+ /** App bundle id → `apns-topic` header. */
1951
+ bundleId: string;
1952
+ /** `.p8` ES256 private key, PEM. Secret — never logged. */
1953
+ privateKey: string;
1954
+ /** `true` → `api.push.apple.com`; false/undefined → `api.sandbox.push.apple.com`. */
1955
+ production?: boolean;
1956
+ }
1957
+
1958
+ // ============================================================================
1959
+ // Seed Bootstrap API Types (from architecture.md)
1960
+ // ============================================================================
1961
+
1962
+ /**
1963
+ * A peer entry in a control network seed.
1964
+ * Contains connection information for a cadre peer.
1965
+ */
1966
+ export interface SeedPeer {
1967
+ /** Peer ID (libp2p identity) */
1968
+ peerId: string;
1969
+ /** Multiaddrs for connecting to this peer */
1970
+ multiaddrs: string[];
1971
+ /** Whether this peer holds an owner key */
1972
+ isOwner: boolean;
1973
+ /** ed25519 public key (base64url) — present on owner peers for signerKey verification */
1974
+ publicKey?: string;
1975
+ }
1976
+
1977
+ /**
1978
+ * Control network seed for bootstrapping new nodes.
1979
+ * Pre-populates a new node's cache to solve the cold-start problem:
1980
+ * new nodes need control data to validate connections, but can't get
1981
+ * data without connecting first.
1982
+ */
1983
+ export interface ControlNetworkSeed {
1984
+ /** Party ID this seed belongs to */
1985
+ partyId: string;
1986
+ /** Known peers in the cadre */
1987
+ peers: SeedPeer[];
1988
+ /** Signature over the seed by an owner key */
1989
+ signature: string;
1990
+ /** The owner key that signed this seed */
1991
+ signerKey: string;
1992
+ }
1993
+
1994
+ /**
1995
+ * Message sent from instigator to new node during seed delivery.
1996
+ * Delivered via /sereus/seed/1.0.0 libp2p protocol.
1997
+ */
1998
+ export interface SeedMessage {
1999
+ /** Party ID for the cadre */
2000
+ partyId: string;
2001
+ /** Known peers in the cadre */
2002
+ peers: SeedPeer[];
2003
+ /** Signature by an owner key */
2004
+ signature: string;
2005
+ /** The owner key that signed this message */
2006
+ signerKey: string;
2007
+ }
2008
+
2009
+ /**
2010
+ * Acknowledgment message from new node to instigator.
2011
+ */
2012
+ export interface SeedAckMessage {
2013
+ /** Whether the seed was accepted */
2014
+ accepted: boolean;
2015
+ /** Reason for rejection (if not accepted) */
2016
+ reason?: string;
2017
+ }
2018
+
2019
+ // ============================================================================
2020
+ // Strand Wake (control-network push-wake protocol) — see strand-wake-protocol.ts
2021
+ // ============================================================================
2022
+
2023
+ /**
2024
+ * Request sent over the control network's `WAKE_PROTOCOL` from a same-cadre peer
2025
+ * to a hibernating peer, asking it to bring a strand online and pull pending
2026
+ * activity. The receiver gates this on cadre membership before honoring it.
2027
+ */
2028
+ export interface WakeRequest {
2029
+ /** The strand the sender knows has pending activity. */
2030
+ strandId: string;
2031
+ /** Optional cause hint, e.g. `"activity"` (a server saw new traffic) or `"manual"`. */
2032
+ reason?: string;
2033
+ }
2034
+
2035
+ /**
2036
+ * Acknowledgment of a {@link WakeRequest}, returned on the same stream.
2037
+ */
2038
+ export interface WakeAck {
2039
+ /** Whether the receiver honored the wake (member + participated strand). */
2040
+ accepted: boolean;
2041
+ /**
2042
+ * The strand's status when the receiver accepted the wake (present when `accepted`). The
2043
+ * receiver replies before the wake runs, so `hibernating` or `idle` here means "a wake was
2044
+ * started", not "the strand is now up".
2045
+ */
2046
+ status?: StrandStatus;
2047
+ /** Reason for rejection (non-member, unknown strand, malformed frame). */
2048
+ reason?: string;
2049
+ }
2050
+
2051
+ // ============================================================================
2052
+ // Strand Address (control-network strand-address RPC) — see strand-addr-protocol.ts
2053
+ // ============================================================================
2054
+
2055
+ /**
2056
+ * Control-network request from one of a party's own cadre nodes to a sibling,
2057
+ * asking "what are your live multiaddrs for this strand's separate network?".
2058
+ * Used to seed a strand mesh from co-cadre nodes that already run that strand.
2059
+ * The receiver gates this on cadre membership before answering.
2060
+ */
2061
+ export interface StrandAddrRequest {
2062
+ /** The strand whose strand-network address the requester wants to seed from. */
2063
+ strandId: string;
2064
+ /**
2065
+ * The derived transport peerId the requester's own strand-`strandId` node
2066
+ * runs as (see `strand-transport-key.ts`). Present → the responder records a
2067
+ * delegate admission grant for it, so its own connection gate (and thus its
2068
+ * circuit-relay server, when it runs one) admits that peerId; absent →
2069
+ * today's behavior exactly. See `delegate-admission.ts` for the trust model.
2070
+ */
2071
+ delegatePeerId?: string;
2072
+ }
2073
+
2074
+ /**
2075
+ * How a strand-addr responder handled a {@link StrandAddrRequest}. Carried so the
2076
+ * asker can tell "I have nothing" from "I could not answer" — the two need
2077
+ * different retry timing, and an empty address list alone cannot say which.
2078
+ *
2079
+ * - `ok` — looked up; `multiaddrs` is the truth, possibly empty (strand not running here).
2080
+ * - `unavailable` — the responder could not answer: over its concurrency cap, the
2081
+ * request was unreadable, or its own lookup threw (e.g. a control-database read failed).
2082
+ * - `refused` — the requester is not an authorized member in the responder's
2083
+ * current view (possibly because its `CadrePeer` row has not replicated there yet);
2084
+ * any `delegatePeerId` it carried was NOT recorded.
2085
+ */
2086
+ export type StrandAddrStatus = 'ok' | 'unavailable' | 'refused';
2087
+
2088
+ /**
2089
+ * Response to a {@link StrandAddrRequest}, carrying the responder's strand-node
2090
+ * multiaddrs, returned on the same stream.
2091
+ */
2092
+ export interface StrandAddrResponse {
2093
+ /** How the responder handled the request; a reply without one is malformed. */
2094
+ status: StrandAddrStatus;
2095
+ /** Echoes the requested strand id; always empty on an `unavailable` reply. */
2096
+ strandId: string;
2097
+ /**
2098
+ * Dialable strand-network multiaddr strings (signaling/`p2p-circuit` first);
2099
+ * empty unless `status` is `ok` and the responder runs that strand.
2100
+ */
2101
+ multiaddrs: string[];
2102
+ }
2103
+
2104
+ /**
2105
+ * Options for authorizing a new peer in the cadre.
2106
+ */
2107
+ export interface AuthorizePeerOptions {
2108
+ /** Peer ID to authorize */
2109
+ peerId: string;
2110
+ /** Optional multiaddrs for the peer */
2111
+ multiaddrs?: string[];
2112
+ }
2113
+
2114
+ /**
2115
+ * Result of applying a seed to a node.
2116
+ */
2117
+ export interface ApplySeedResult {
2118
+ /** Whether the seed was successfully applied */
2119
+ success: boolean;
2120
+ /** Number of peers added to the peer store */
2121
+ peersAdded: number;
2122
+ /** Error message if not successful */
2123
+ error?: string;
2124
+ /**
2125
+ * Owner-flagged seed peers this apply attempted to dial. Zero when the seed
2126
+ * carried no owner peer with an address, or when the seed was rejected before
2127
+ * the dial loop ran.
2128
+ */
2129
+ ownerDialsAttempted: number;
2130
+ /**
2131
+ * How many of those dials threw. `ownerDialsAttempted > 0 && ownerDialsFailed
2132
+ * === ownerDialsAttempted` means "seeded but stranded": the seed itself was
2133
+ * accepted (`success: true`) but no owner could be reached, so this node has
2134
+ * no connection to bootstrap its control database from. The node retries those
2135
+ * addresses on every control-cohort reconcile pass (see
2136
+ * `CadreNode.dialColdStartBootstrap`), so this is a signal, not a fatal error.
2137
+ *
2138
+ * Zero failures is NOT proof of a live connection: the receiving node's
2139
+ * membership gate denies AFTER the dialer's upgrade completes (see
2140
+ * `createMembershipConnectionGater`), so a dial can resolve and then be torn
2141
+ * down. Treat a non-zero value as certain failure and zero as "no failure
2142
+ * observed".
2143
+ */
2144
+ ownerDialsFailed: number;
2145
+ }
2146
+
2147
+ // ============================================================================
2148
+ // Seed Helper Types
2149
+ // ============================================================================
2150
+
2151
+ /**
2152
+ * Node network topology type for seed helper scenarios.
2153
+ */
2154
+ export type NodeTopology = 'public' | 'nat';
2155
+
2156
+ /**
2157
+ * Invite code for phone-to-server enrollment.
2158
+ * Used when a server invites a phone to join the cadre.
2159
+ */
2160
+ export interface CadreInvite {
2161
+ /** Party ID of the cadre */
2162
+ partyId: string;
2163
+ /** Multiaddrs to dial the owner */
2164
+ ownerAddrs: string[];
2165
+ /**
2166
+ * Owner ed25519 public keys (base64url) of the cadre, carried out-of-band
2167
+ * so a cold-start invitee can pin the trusted owner set before applying
2168
+ * any seed (the seed itself cannot vouch for its own signer). Populated by
2169
+ * `createInvite` from the issuer's OWN node-local trusted-owner anchor —
2170
+ * not its replicated `OwnerKey` table, because the invitee anchors whatever
2171
+ * arrives here and a stranger's genesis-inserted key must not ride an
2172
+ * otherwise-legitimate invite into a fresh node's anchor.
2173
+ */
2174
+ ownerKeys?: string[];
2175
+ /** Optional invite token for validation */
2176
+ token?: string;
2177
+ /** Timestamp when invite was created */
2178
+ createdAt: number;
2179
+ /** Optional expiration timestamp */
2180
+ expiresAt?: number;
2181
+ }
2182
+
2183
+ /**
2184
+ * Options for adding a drone via provider API.
2185
+ */
2186
+ export interface AddDroneOptions {
2187
+ /** Peer ID of the new drone (returned from provider) */
2188
+ dronePeerId: string;
2189
+ /** Multiaddrs of the drone (returned from provider) */
2190
+ droneMultiaddrs: string[];
2191
+ }
2192
+
2193
+ /**
2194
+ * Options for adding a phone via invite flow.
2195
+ */
2196
+ export interface AddPhoneOptions {
2197
+ /** Peer ID of the phone (sent by phone when it connects) */
2198
+ phonePeerId: string;
2199
+ /** Invite token for validation (must match issued invite) */
2200
+ token?: string;
2201
+ }
2202
+
2203
+ /**
2204
+ * Result of preparing a seed for drone initialization.
2205
+ */
2206
+ export interface DroneInitResult {
2207
+ /** The seed to send to the provider API */
2208
+ seed: ControlNetworkSeed;
2209
+ /** Base64url encoded seed for transport */
2210
+ encodedSeed: string;
2211
+ }
2212
+
2213
+ /**
2214
+ * Result of creating an invite for a phone.
2215
+ */
2216
+ export interface InviteResult {
2217
+ /** The invite to share out-of-band */
2218
+ invite: CadreInvite;
2219
+ /** Base64url encoded invite for QR/link */
2220
+ encodedInvite: string;
2221
+ }
2222
+