@serfab/cadre-core 0.10.0 → 0.11.0

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