@serfab/cadre-core 0.9.0 → 0.10.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 (162) hide show
  1. package/README.md +4 -2
  2. package/dist/bootstrap-peer-store-file.d.ts +13 -0
  3. package/dist/bootstrap-peer-store-file.js +42 -0
  4. package/dist/bootstrap-peer-store-file.js.map +1 -0
  5. package/dist/bootstrap-peer-store.d.ts +73 -0
  6. package/dist/bootstrap-peer-store.js +128 -0
  7. package/dist/bootstrap-peer-store.js.map +1 -0
  8. package/dist/cadre-node.d.ts +918 -53
  9. package/dist/cadre-node.js +1843 -190
  10. package/dist/cadre-node.js.map +1 -1
  11. package/dist/control-authorization.d.ts +93 -0
  12. package/dist/control-authorization.js +57 -0
  13. package/dist/control-authorization.js.map +1 -0
  14. package/dist/control-database.d.ts +990 -50
  15. package/dist/control-database.js +1459 -108
  16. package/dist/control-database.js.map +1 -1
  17. package/dist/control-formation-recorder.d.ts +89 -10
  18. package/dist/control-formation-recorder.js +129 -14
  19. package/dist/control-formation-recorder.js.map +1 -1
  20. package/dist/control-schema.d.ts +1 -1
  21. package/dist/control-schema.js +774 -210
  22. package/dist/control-schema.js.map +1 -1
  23. package/dist/control-stream.d.ts +38 -4
  24. package/dist/control-stream.js +70 -4
  25. package/dist/control-stream.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +151 -0
  27. package/dist/control-write-retry.js +411 -0
  28. package/dist/control-write-retry.js.map +1 -0
  29. package/dist/delegate-admission.d.ts +134 -0
  30. package/dist/delegate-admission.js +244 -0
  31. package/dist/delegate-admission.js.map +1 -0
  32. package/dist/device-token.d.ts +11 -11
  33. package/dist/device-token.js +14 -13
  34. package/dist/device-token.js.map +1 -1
  35. package/dist/ed25519-key.d.ts +26 -0
  36. package/dist/ed25519-key.js +56 -1
  37. package/dist/ed25519-key.js.map +1 -1
  38. package/dist/file-durable-slot.d.ts +20 -0
  39. package/dist/file-durable-slot.js +53 -0
  40. package/dist/file-durable-slot.js.map +1 -0
  41. package/dist/formation-approval.d.ts +157 -0
  42. package/dist/formation-approval.js +375 -0
  43. package/dist/formation-approval.js.map +1 -0
  44. package/dist/fs-atomic.d.ts +38 -0
  45. package/dist/fs-atomic.js +97 -0
  46. package/dist/fs-atomic.js.map +1 -0
  47. package/dist/identity-key.d.ts +49 -0
  48. package/dist/identity-key.js +81 -0
  49. package/dist/identity-key.js.map +1 -0
  50. package/dist/index.d.ts +16 -6
  51. package/dist/index.js +39 -5
  52. package/dist/index.js.map +1 -1
  53. package/dist/key-store-file.d.ts +2 -24
  54. package/dist/key-store-file.js +10 -77
  55. package/dist/key-store-file.js.map +1 -1
  56. package/dist/membership-connection-gater.d.ts +146 -0
  57. package/dist/membership-connection-gater.js +180 -0
  58. package/dist/membership-connection-gater.js.map +1 -0
  59. package/dist/node-local-snapshot.d.ts +109 -0
  60. package/dist/node-local-snapshot.js +182 -0
  61. package/dist/node-local-snapshot.js.map +1 -0
  62. package/dist/peer-authorization.d.ts +135 -18
  63. package/dist/peer-authorization.js +152 -21
  64. package/dist/peer-authorization.js.map +1 -1
  65. package/dist/peer-record.d.ts +21 -8
  66. package/dist/peer-record.js +26 -10
  67. package/dist/peer-record.js.map +1 -1
  68. package/dist/relay-addrs.d.ts +45 -0
  69. package/dist/relay-addrs.js +103 -0
  70. package/dist/relay-addrs.js.map +1 -0
  71. package/dist/relay-reservation.d.ts +266 -0
  72. package/dist/relay-reservation.js +613 -0
  73. package/dist/relay-reservation.js.map +1 -0
  74. package/dist/seed-bootstrap.d.ts +221 -56
  75. package/dist/seed-bootstrap.js +328 -188
  76. package/dist/seed-bootstrap.js.map +1 -1
  77. package/dist/seed-trust-policy.d.ts +47 -21
  78. package/dist/seed-trust-policy.js +24 -15
  79. package/dist/seed-trust-policy.js.map +1 -1
  80. package/dist/strand-addr-protocol.d.ts +42 -13
  81. package/dist/strand-addr-protocol.js +58 -52
  82. package/dist/strand-addr-protocol.js.map +1 -1
  83. package/dist/strand-backfill.d.ts +109 -0
  84. package/dist/strand-backfill.js +279 -0
  85. package/dist/strand-backfill.js.map +1 -0
  86. package/dist/strand-formation-manager.d.ts +38 -9
  87. package/dist/strand-formation-manager.js +120 -17
  88. package/dist/strand-formation-manager.js.map +1 -1
  89. package/dist/strand-formation-protocol.d.ts +103 -4
  90. package/dist/strand-formation-protocol.js +232 -4
  91. package/dist/strand-formation-protocol.js.map +1 -1
  92. package/dist/strand-instance-manager.d.ts +39 -1
  93. package/dist/strand-instance-manager.js +92 -14
  94. package/dist/strand-instance-manager.js.map +1 -1
  95. package/dist/strand-member-registry.d.ts +5 -5
  96. package/dist/strand-member-registry.js +18 -10
  97. package/dist/strand-member-registry.js.map +1 -1
  98. package/dist/strand-membership-writer.d.ts +482 -88
  99. package/dist/strand-membership-writer.js +757 -136
  100. package/dist/strand-membership-writer.js.map +1 -1
  101. package/dist/strand-solicitation.d.ts +99 -33
  102. package/dist/strand-solicitation.js +100 -52
  103. package/dist/strand-solicitation.js.map +1 -1
  104. package/dist/strand-transport-key.d.ts +40 -0
  105. package/dist/strand-transport-key.js +56 -0
  106. package/dist/strand-transport-key.js.map +1 -0
  107. package/dist/strand-wake-protocol.d.ts +9 -7
  108. package/dist/strand-wake-protocol.js +22 -46
  109. package/dist/strand-wake-protocol.js.map +1 -1
  110. package/dist/strand-watcher.d.ts +17 -2
  111. package/dist/strand-watcher.js +47 -3
  112. package/dist/strand-watcher.js.map +1 -1
  113. package/dist/trusted-owner-store-file.d.ts +12 -0
  114. package/dist/trusted-owner-store-file.js +39 -0
  115. package/dist/trusted-owner-store-file.js.map +1 -0
  116. package/dist/trusted-owner-store.d.ts +64 -0
  117. package/dist/trusted-owner-store.js +126 -0
  118. package/dist/trusted-owner-store.js.map +1 -0
  119. package/dist/types.d.ts +275 -20
  120. package/dist/types.js +13 -0
  121. package/dist/types.js.map +1 -1
  122. package/package.json +19 -8
  123. package/src/bootstrap-peer-store-file.ts +44 -0
  124. package/src/bootstrap-peer-store.ts +172 -0
  125. package/src/cadre-node.ts +1992 -202
  126. package/src/control-authorization.ts +114 -0
  127. package/src/control-database.ts +1704 -146
  128. package/src/control-formation-recorder.ts +172 -19
  129. package/src/control-schema.ts +786 -222
  130. package/src/control-stream.ts +81 -4
  131. package/src/control-write-retry.ts +473 -0
  132. package/src/delegate-admission.ts +302 -0
  133. package/src/device-token.ts +18 -13
  134. package/src/ed25519-key.ts +134 -69
  135. package/src/file-durable-slot.ts +61 -0
  136. package/src/formation-approval.ts +604 -0
  137. package/src/fs-atomic.ts +107 -0
  138. package/src/identity-key.ts +109 -0
  139. package/src/index.ts +136 -5
  140. package/src/key-store-file.ts +10 -81
  141. package/src/membership-connection-gater.ts +204 -0
  142. package/src/node-local-snapshot.ts +260 -0
  143. package/src/peer-authorization.ts +199 -21
  144. package/src/peer-record.ts +28 -10
  145. package/src/relay-addrs.ts +111 -0
  146. package/src/relay-reservation.ts +823 -0
  147. package/src/seed-bootstrap.ts +397 -197
  148. package/src/seed-trust-policy.ts +58 -25
  149. package/src/strand-addr-protocol.ts +84 -53
  150. package/src/strand-backfill.ts +366 -0
  151. package/src/strand-formation-manager.ts +145 -19
  152. package/src/strand-formation-protocol.ts +309 -8
  153. package/src/strand-instance-manager.ts +111 -14
  154. package/src/strand-member-registry.ts +18 -13
  155. package/src/strand-membership-writer.ts +1430 -640
  156. package/src/strand-solicitation.ts +155 -90
  157. package/src/strand-transport-key.ts +58 -0
  158. package/src/strand-wake-protocol.ts +29 -46
  159. package/src/strand-watcher.ts +66 -3
  160. package/src/trusted-owner-store-file.ts +41 -0
  161. package/src/trusted-owner-store.ts +171 -0
  162. package/src/types.ts +292 -21
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Node-only file-backed {@link TrustedOwnerStore}: one JSON file per party under
3
+ * a configured directory (the node's state directory — `cadre-cli` passes
4
+ * `ResolvedConfig.nodeStateDir`), holding the node-local, non-replicated
5
+ * trusted-owner anchor.
6
+ *
7
+ * Nothing but the file itself lives here: the anchor's envelope, load policy,
8
+ * per-entry validation and serialised snapshot writes are cross-platform in
9
+ * `PersistentTrustedOwnerStore` / `node-local-snapshot.ts`, and the file is a
10
+ * `FileDurableSlot`. This module exists only so the `node:fs` edge stays out of
11
+ * the package's cross-platform default entry (`./index.js`) — import it from
12
+ * the dedicated subpath instead:
13
+ *
14
+ * ```ts
15
+ * import { FileTrustedOwnerStore } from '@serfab/cadre-core/trusted-owner-store-file';
16
+ * ```
17
+ *
18
+ * A React Native / browser entry graph never resolves this path, so the
19
+ * `node:fs` edge never reaches a bundler that cannot satisfy it (same
20
+ * isolation pattern as `key-store-file.ts`).
21
+ */
22
+ import { FileDurableSlot } from './file-durable-slot.js';
23
+ import { PersistentTrustedOwnerStore } from './trusted-owner-store.js';
24
+ import type { TrustedOwnerStore } from './trusted-owner-store.js';
25
+
26
+ /** Base name of the anchor file: `<dir>/trusted-owners.<encoded partyId>.json`. */
27
+ const SLOT_NAME = 'trusted-owners';
28
+
29
+ /**
30
+ * File-backed {@link TrustedOwnerStore}. Open via {@link open}, which loads the
31
+ * existing file and returns the cross-platform store over it; an absent, corrupt, or wrong-party file is a cold
32
+ * start (empty anchor), while a present-but-unreadable file throws. Those rules
33
+ * and their reasoning live on `NodeLocalSnapshot.open` — they are properties of
34
+ * every persistent backend, not of this one.
35
+ */
36
+ export const FileTrustedOwnerStore = {
37
+ /** Load (or cold-start) the party's anchor from a file in `dir`. */
38
+ async open(dir: string, partyId: string): Promise<TrustedOwnerStore> {
39
+ return PersistentTrustedOwnerStore.open(new FileDurableSlot(dir, SLOT_NAME, partyId), partyId);
40
+ }
41
+ };
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Node-local, NON-replicated trusted-owner anchor.
3
+ *
4
+ * The replicated `CadreControl.OwnerKey` table cannot anchor trust: any
5
+ * connecting node can genesis-insert its own key into its local table and that
6
+ * row replicates into every peer's copy, so "is this key in `OwnerKey`?" can be
7
+ * made true by a stranger. This store is the anchor that CAN be trusted — a
8
+ * per-party, on-device record of owner keys established OUT OF BAND: founding
9
+ * the party (genesis self-trust), the pinned keys carried by the `CadreInvite`
10
+ * that enrolled this node, or an explicit operator pin. It is never written
11
+ * from replicated control state; that is the whole point.
12
+ *
13
+ * Three implementations:
14
+ * - {@link MemoryTrustedOwnerStore} (this module, cross-platform) — ephemeral;
15
+ * the default when no store is injected via `CadreNodeConfig.trustedOwners`.
16
+ * - {@link PersistentTrustedOwnerStore} (this module, cross-platform) — durable
17
+ * over any `DurableSlot` the embedding app supplies (IndexedDB, SecureStore,
18
+ * SQLite, …). The load/persist policy lives in `node-local-snapshot.ts`; read
19
+ * {@link NodeLocalSnapshot.open} for what an absent, corrupt, foreign-party
20
+ * or unreadable slot does.
21
+ * - `FileTrustedOwnerStore` — the above over a Node file in the node's state
22
+ * directory; Node-only, behind the subpath
23
+ * `@serfab/cadre-core/trusted-owner-store-file` (same isolation pattern as
24
+ * `key-store-file`) so `node:fs` never lands in the RN/browser entry graph.
25
+ *
26
+ * Keys are additive: nothing in this interface removes a key (owner revocation
27
+ * is out of scope here and tracked with the broader control-sync design).
28
+ */
29
+ import debug from 'debug';
30
+ import { NodeLocalSnapshot, type DurableSlot, type NodeLocalSnapshotSpec } from './node-local-snapshot.js';
31
+
32
+ const log = debug('sereus:cadre:trusted-owner-store');
33
+
34
+ /** How an owner key entered the anchor (out-of-band provenance). */
35
+ export type TrustSource = 'genesis' | 'invite' | 'operator';
36
+
37
+ export interface TrustedOwnerStore {
38
+ /** Party this anchor is scoped to. */
39
+ readonly partyId: string;
40
+
41
+ /** Is this ed25519 (base64url) key one of my party's out-of-band-trusted owners? */
42
+ has(ownerKey: string): boolean;
43
+
44
+ /**
45
+ * All anchored owner keys (e.g. for seed-trust `knownOwnerKeys`).
46
+ *
47
+ * NOTE: every backend copies into a fresh Set per call (so the result is a
48
+ * snapshot decoupled from later `trust()` calls). Anchors hold a handful of
49
+ * keys and callers are per-seed, so the copy is free today; if a hot path
50
+ * ever calls this per message, prefer `has()` or cache the snapshot.
51
+ */
52
+ all(): ReadonlySet<string>;
53
+
54
+ /**
55
+ * Add a key established out of band (genesis self-trust / invite pin /
56
+ * operator pin). Idempotent: re-trusting a known key is a no-op that keeps
57
+ * the original source. Implementations MUST reflect the key in {@link has} /
58
+ * {@link all} synchronously; the returned promise tracks durability only
59
+ * (a persistent backend's write), so a synchronous caller may safely consult
60
+ * the store right after invoking this.
61
+ */
62
+ trust(ownerKey: string, source: TrustSource): Promise<void>;
63
+ }
64
+
65
+ /**
66
+ * Ephemeral in-memory anchor for nodes without durable storage (tests, browser
67
+ * demos, not-yet-persisted mobile). Same contract, no disk: trust established
68
+ * here must be re-supplied (invite / operator pin) on the next process.
69
+ */
70
+ export class MemoryTrustedOwnerStore implements TrustedOwnerStore {
71
+ private readonly keys = new Map<string, TrustSource>();
72
+
73
+ constructor(readonly partyId: string) {}
74
+
75
+ has(ownerKey: string): boolean {
76
+ return this.keys.has(ownerKey);
77
+ }
78
+
79
+ all(): ReadonlySet<string> {
80
+ return new Set(this.keys.keys());
81
+ }
82
+
83
+ async trust(ownerKey: string, source: TrustSource): Promise<void> {
84
+ if (this.keys.has(ownerKey)) {
85
+ return;
86
+ }
87
+ this.keys.set(ownerKey, source);
88
+ log('trusted owner key anchored (party=%s, source=%s)', this.partyId, source);
89
+ }
90
+ }
91
+
92
+ /** One anchored key as persisted: provenance + wall-clock trust time (ms). */
93
+ interface TrustedOwnerEntry {
94
+ source: TrustSource;
95
+ trustedAt: number;
96
+ }
97
+
98
+ const KNOWN_SOURCES: ReadonlySet<string> = new Set<TrustSource>(['genesis', 'invite', 'operator']);
99
+
100
+ /**
101
+ * What the anchor persists: `owners` maps ownerKey (base64url) -> provenance.
102
+ *
103
+ * One unusable entry discards the WHOLE record: trusting a subset of the keys a
104
+ * record claims is a silent, security-relevant downgrade, so a record that
105
+ * cannot be read in full is treated as no anchor at all (see
106
+ * `UnusableEntryPolicy` in `node-local-snapshot.ts`).
107
+ */
108
+ const TRUSTED_OWNER_SNAPSHOT: NodeLocalSnapshotSpec<TrustedOwnerEntry> = {
109
+ label: 'trusted-owner anchor',
110
+ payloadKey: 'owners',
111
+ unusableEntry: 'discard-all',
112
+ acceptEntry: (_ownerKey, entry) => {
113
+ if (typeof entry !== 'object' || entry === null) return undefined;
114
+ const { source, trustedAt } = entry as { source?: unknown; trustedAt?: unknown };
115
+ if (typeof source !== 'string' || !KNOWN_SOURCES.has(source)) return undefined;
116
+ if (typeof trustedAt !== 'number') return undefined;
117
+ return { source: source as TrustSource, trustedAt };
118
+ }
119
+ };
120
+
121
+ /**
122
+ * Durable {@link TrustedOwnerStore} over an app-supplied {@link DurableSlot} —
123
+ * the cross-platform half of every persistent backend (the Node
124
+ * `FileTrustedOwnerStore` is this class over a file slot).
125
+ *
126
+ * Construct via {@link open}. Load and persist policy — what an absent,
127
+ * corrupt, foreign-party or unreadable slot does, and what a failed persist
128
+ * does — is documented once on `NodeLocalSnapshot`; this class only supplies
129
+ * the payload shape and the anchor's whole-record-on-bad-entry policy.
130
+ */
131
+ export class PersistentTrustedOwnerStore implements TrustedOwnerStore {
132
+ private constructor(private readonly snapshot: NodeLocalSnapshot<TrustedOwnerEntry>) {}
133
+
134
+ /** Load (or cold-start) the party's anchor from `slot`. */
135
+ static async open(slot: DurableSlot, partyId: string): Promise<PersistentTrustedOwnerStore> {
136
+ return new PersistentTrustedOwnerStore(
137
+ await NodeLocalSnapshot.open(slot, partyId, TRUSTED_OWNER_SNAPSHOT)
138
+ );
139
+ }
140
+
141
+ get partyId(): string {
142
+ return this.snapshot.partyId;
143
+ }
144
+
145
+ has(ownerKey: string): boolean {
146
+ return this.snapshot.has(ownerKey);
147
+ }
148
+
149
+ all(): ReadonlySet<string> {
150
+ return this.snapshot.keySnapshot();
151
+ }
152
+
153
+ /**
154
+ * Anchor a key: visible via {@link has} / {@link all} synchronously, then the
155
+ * full snapshot is persisted (see `NodeLocalSnapshot.put`).
156
+ */
157
+ trust(ownerKey: string, source: TrustSource): Promise<void> {
158
+ if (this.snapshot.has(ownerKey)) {
159
+ // Idempotent: re-enrollment / restart re-seeding keeps the original
160
+ // provenance and skips the write entirely.
161
+ // NOTE: this resolves immediately rather than joining the write chain, so
162
+ // re-trusting a key whose first persist is still in flight does not await
163
+ // that persist. Harmless for the seeding callers (they re-trust only after
164
+ // the original settled, or do not await at all); chain it if a caller ever
165
+ // needs "durable by the time trust() resolves" for a repeat key.
166
+ return Promise.resolve();
167
+ }
168
+ log('trusted owner key anchored (party=%s, source=%s); persisting', this.partyId, source);
169
+ return this.snapshot.put(ownerKey, { source, trustedAt: Date.now() });
170
+ }
171
+ }
package/src/types.ts CHANGED
@@ -1,10 +1,14 @@
1
1
  import type { ConnectionGater, Libp2p, PeerId, PrivateKey } from '@libp2p/interface';
2
2
  import type { IRawStorage, Libp2pTransports } from '@optimystic/db-p2p';
3
- import type { IRepo } from '@optimystic/db-core';
3
+ import type { IPeerNetwork, IRepo } from '@optimystic/db-core';
4
+ import type { StrandBackfillConfig } from './strand-backfill.js';
4
5
  import type { StrandDatabase } from './strand-database.js';
5
6
  import type { SeedTrustPolicy } from './seed-trust-policy.js';
6
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';
7
10
  import type { PushNotifier } from './push-notifier.js';
11
+ import type { RevocableTable } from './control-authorization.js';
8
12
 
9
13
  /**
10
14
  * Extended Libp2p node with the coordinatedRepo attached by db-p2p's
@@ -12,6 +16,14 @@ import type { PushNotifier } from './push-notifier.js';
12
16
  */
13
17
  export interface Libp2pNodeWithRepo extends Libp2p {
14
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;
15
27
  }
16
28
 
17
29
  /**
@@ -120,8 +132,9 @@ export interface StorageConfig {
120
132
  *
121
133
  * For React Native, use the appropriate storage from @optimystic/db-p2p-storage-rn:
122
134
  * ```typescript
123
- * import { RNRawStorage } from '@optimystic/db-p2p-storage-rn';
124
- * storage: { provider: (strandId) => new RNRawStorage(strandId) }
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}`)) }
125
138
  * ```
126
139
  *
127
140
  * For in-memory storage (testing):
@@ -139,7 +152,47 @@ export interface StorageConfig {
139
152
  */
140
153
  export interface NetworkConfig {
141
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
+ */
142
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
+ */
143
196
  relayAddrs?: string[];
144
197
  /**
145
198
  * Enable circuit relay server - allows this node to relay connections for other peers.
@@ -179,6 +232,12 @@ export interface NetworkConfig {
179
232
  * Playwright e2e, RN simulators) supply a permissive gater here — e.g.
180
233
  * `{ denyDialMultiaddr: () => false }`. See `@optimystic/db-p2p`'s
181
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).
182
241
  */
183
242
  connectionGater?: ConnectionGater;
184
243
  /**
@@ -237,6 +296,27 @@ export interface ControlNetworkConfig {
237
296
  schemaPath?: string;
238
297
  }
239
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
+
240
320
  /**
241
321
  * Main configuration for a CadreNode
242
322
  */
@@ -281,6 +361,43 @@ export interface CadreNodeConfig {
281
361
  /** Network configuration */
282
362
  network?: NetworkConfig;
283
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
+
284
401
  /** Hibernation configuration */
285
402
  hibernation?: HibernationConfig;
286
403
 
@@ -300,14 +417,76 @@ export interface CadreNodeConfig {
300
417
  * into every SeedBootstrapService this node constructs (owner, receive-only
301
418
  * listener, and the temp service used by applySeed when no service exists), and
302
419
  * used as the service-level default the libp2p seed-protocol handler relies on
303
- * (that path has no per-call override seam). Defaults to dbAnchoredTrustPolicy()
304
- * inside SeedBootstrapService when unset — a cold-start node then rejects every
305
- * seed. A per-call `applySeed(seed, { trustPolicy })` override still wins over
306
- * this default for callers that hold out-of-band material (e.g. a pinned key
307
- * from a CadreInvite).
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).
308
425
  */
309
426
  seedTrustPolicy?: SeedTrustPolicy;
310
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
+
311
490
  /**
312
491
  * Platform push-delivery for suspended mobile peers. When present, the node's
313
492
  * server fan-out can deliver strand-wake data messages over the platform push
@@ -367,6 +546,14 @@ export interface StrandInstance {
367
546
  status: StrandStatus;
368
547
  sAppInfo?: SAppInfo;
369
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
+
370
557
  /** The libp2p node for this strand (only when active/idle) */
371
558
  libp2pNode?: Libp2p;
372
559
 
@@ -556,16 +743,6 @@ export interface FormStrandResult {
556
743
  memberPrivateKey?: string;
557
744
  }
558
745
 
559
- /**
560
- * Result of validating a strand formation request
561
- */
562
- export interface ValidateFormationResult {
563
- /** Public key for validating this formation */
564
- validationKey: string;
565
- /** Signature over the formation approval */
566
- validationSignature: string;
567
- }
568
-
569
746
  /**
570
747
  * Disclosure object provided during strand formation.
571
748
  * Contains identity and context information from the initiator.
@@ -656,13 +833,60 @@ export interface PeerAddressRecord {
656
833
  sig: string;
657
834
  }
658
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
+
659
880
  /**
660
881
  * Outcome of {@link CadreNode.registerSelf}, surfaced so callers (e.g. the CLI
661
882
  * `--owner` branch) can log what happened:
662
883
  * - `inserted` — the first owner-signed INSERT of this node's `CadrePeer` row.
663
884
  * - `refreshed` — a self-signed UPDATE of an existing row (heartbeat / addr change).
664
- * - `skipped` — nothing written (no self-signing key, or not yet a member with no
665
- * owner service to self-insert).
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).
666
890
  */
667
891
  export type SelfRegistrationOutcome = 'inserted' | 'refreshed' | 'skipped';
668
892
 
@@ -745,6 +969,21 @@ export interface DeviceTokenRecord {
745
969
  sig: string;
746
970
  }
747
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
+
748
987
  /**
749
988
  * Options for {@link CadreNode.resolveDeviceToken}.
750
989
  */
@@ -918,6 +1157,14 @@ export interface WakeAck {
918
1157
  export interface StrandAddrRequest {
919
1158
  /** The strand whose strand-network address the requester wants to seed from. */
920
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;
921
1168
  }
922
1169
 
923
1170
  /**
@@ -955,6 +1202,27 @@ export interface ApplySeedResult {
955
1202
  peersAdded: number;
956
1203
  /** Error message if not successful */
957
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;
958
1226
  }
959
1227
 
960
1228
  // ============================================================================
@@ -979,7 +1247,10 @@ export interface CadreInvite {
979
1247
  * Owner ed25519 public keys (base64url) of the cadre, carried out-of-band
980
1248
  * so a cold-start invitee can pin the trusted owner set before applying
981
1249
  * any seed (the seed itself cannot vouch for its own signer). Populated by
982
- * `createInvite` from the issuer's `OwnerKey` table.
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.
983
1254
  */
984
1255
  ownerKeys?: string[];
985
1256
  /** Optional invite token for validation */