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