@serfab/cadre-core 1.6.0 → 1.7.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 (51) hide show
  1. package/dist/cadre-node.d.ts +204 -65
  2. package/dist/cadre-node.js +392 -95
  3. package/dist/cadre-node.js.map +1 -1
  4. package/dist/index.d.ts +5 -0
  5. package/dist/index.js +16 -0
  6. package/dist/index.js.map +1 -1
  7. package/dist/joined-strand-store.d.ts +112 -0
  8. package/dist/joined-strand-store.js +263 -0
  9. package/dist/joined-strand-store.js.map +1 -0
  10. package/dist/key-store.d.ts +6 -0
  11. package/dist/key-store.js.map +1 -1
  12. package/dist/node-local-snapshot.d.ts +5 -4
  13. package/dist/node-local-snapshot.js +6 -3
  14. package/dist/node-local-snapshot.js.map +1 -1
  15. package/dist/peer-join-backfill.d.ts +33 -14
  16. package/dist/peer-join-backfill.js +109 -50
  17. package/dist/peer-join-backfill.js.map +1 -1
  18. package/dist/strand-instance-manager.d.ts +39 -0
  19. package/dist/strand-instance-manager.js +74 -1
  20. package/dist/strand-instance-manager.js.map +1 -1
  21. package/dist/strand-peer-book-file.d.ts +12 -0
  22. package/dist/strand-peer-book-file.js +37 -0
  23. package/dist/strand-peer-book-file.js.map +1 -0
  24. package/dist/strand-peer-book-protocol.d.ts +159 -0
  25. package/dist/strand-peer-book-protocol.js +360 -0
  26. package/dist/strand-peer-book-protocol.js.map +1 -0
  27. package/dist/strand-peer-book-swap.d.ts +104 -0
  28. package/dist/strand-peer-book-swap.js +284 -0
  29. package/dist/strand-peer-book-swap.js.map +1 -0
  30. package/dist/strand-peer-book.d.ts +159 -0
  31. package/dist/strand-peer-book.js +354 -0
  32. package/dist/strand-peer-book.js.map +1 -0
  33. package/dist/strand-peer-observer.d.ts +88 -0
  34. package/dist/strand-peer-observer.js +180 -0
  35. package/dist/strand-peer-observer.js.map +1 -0
  36. package/dist/types.d.ts +58 -0
  37. package/dist/types.js.map +1 -1
  38. package/package.json +6 -2
  39. package/src/cadre-node.ts +7776 -7449
  40. package/src/index.ts +67 -0
  41. package/src/joined-strand-store.ts +308 -0
  42. package/src/key-store.ts +6 -0
  43. package/src/node-local-snapshot.ts +293 -289
  44. package/src/peer-join-backfill.ts +112 -51
  45. package/src/strand-instance-manager.ts +100 -1
  46. package/src/strand-peer-book-file.ts +39 -0
  47. package/src/strand-peer-book-protocol.ts +463 -0
  48. package/src/strand-peer-book-swap.ts +338 -0
  49. package/src/strand-peer-book.ts +468 -0
  50. package/src/strand-peer-observer.ts +235 -0
  51. package/src/types.ts +60 -0
package/src/index.ts CHANGED
@@ -117,6 +117,73 @@ export {
117
117
  type EnrolledMachineStore
118
118
  } from './enrolled-machine-store.js';
119
119
 
120
+ // Node-local record of the strands this node joined from ANOTHER party, re-offered on
121
+ // every start as `strand:discovered`. Cross-platform: its durable form rides the
122
+ // KeyStore seam above (the record carries a closed strand's read secret), so there is
123
+ // no file-backed subpath.
124
+ export {
125
+ MemoryJoinedStrandStore,
126
+ KeyStoreJoinedStrandStore,
127
+ type JoinedStrandStore,
128
+ type JoinedStrandRecord
129
+ } from './joined-strand-store.js';
130
+
131
+ // Node-local strand peer book: per strand, the strand peers this node has met and
132
+ // their last-known addresses, dialed first on every launch so a restarted machine
133
+ // re-finds the other parties. Same cross-platform split as the bootstrap-peer store
134
+ // above — interface + in-memory + slot-backed stores here, Node-only file backend
135
+ // behind the subpath '@serfab/cadre-core/strand-peer-book-file'.
136
+ export {
137
+ MemoryStrandPeerBookStore,
138
+ PersistentStrandPeerBookStore,
139
+ mergeStrandPeerEntry,
140
+ sanitizeStrandPeerEntry,
141
+ strandPeerFreshness,
142
+ MAX_STRAND_PEERS,
143
+ STRAND_PEER_MAX_AGE_MS,
144
+ type StrandPeerBookStore,
145
+ type StrandPeerBookOptions,
146
+ type StrandPeerEntry
147
+ } from './strand-peer-book.js';
148
+ export {
149
+ StrandPeerObserver,
150
+ dialableAddrs,
151
+ connectedIdentifiedPeers,
152
+ STRAND_PEER_OBSERVE_THROTTLE_MS,
153
+ type StrandPeerObservation,
154
+ type StrandPeerObserverDeps,
155
+ type StrandPeerObserverOptions,
156
+ type ConnectedIdentifiedPeer
157
+ } from './strand-peer-observer.js';
158
+ // The signed book SWAP between strand peers (`/sereus/strand-peers/1.0.0`): the wire
159
+ // protocol, signing and verification, and the per-strand driver that arms it.
160
+ export {
161
+ STRAND_PEER_BOOK_PROTOCOL,
162
+ SIGNED_STRAND_PEER_ENTRY_VERSION,
163
+ STRAND_PEER_ISSUED_AT_SKEW_MS,
164
+ MAX_BOOK_FRAME_SIZE,
165
+ StrandPeerBookService,
166
+ exchangeStrandPeerBook,
167
+ signStrandPeerEntry,
168
+ signedStrandPeerEntryPayload,
169
+ verifySignedStrandPeerEntry,
170
+ verifyStrandPeerBookFrame,
171
+ trimBookFrameToFit,
172
+ assertBookFrameFits,
173
+ type SignedStrandPeerEntry,
174
+ type StrandPeerBookFrame,
175
+ type StrandPeerEntryVerdict,
176
+ type StrandPeerBookServiceDeps,
177
+ type ExchangeStrandPeerBookOptions
178
+ } from './strand-peer-book-protocol.js';
179
+ export {
180
+ StrandPeerBookSwap,
181
+ STRAND_PEER_BOOK_SWAP_THROTTLE_MS,
182
+ OWN_ENTRY_RESIGN_DEBOUNCE_MS,
183
+ type StrandPeerBookSwapDeps,
184
+ type StrandPeerBookSwapOptions
185
+ } from './strand-peer-book-swap.js';
186
+
120
187
  // Storage scope keys — what `CadreNodeConfig.storage.provider` is called with. A
121
188
  // strand's key is its strand id; the control database's key carries the party id,
122
189
  // so two parties on one device never share a control store. Every key stays within
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Node-local record of the strands this node joined from ANOTHER party.
3
+ *
4
+ * A party's own strands come back after a restart through its control database: the
5
+ * `Strand` table holds their rows, the strand watcher polls it, and each row no sApp
6
+ * config claims is offered as `strand:discovered`. A strand joined through formation with
7
+ * another party (`CadreNode.formStrand`) has no row there — the other party's control
8
+ * database holds it — so nothing re-offered it after a restart, and every embedding app
9
+ * kept its own list of joins, read secret included (gotchoices/sereus#18).
10
+ *
11
+ * `CadreNode` records each such join here, hands the records to its strand watcher beside
12
+ * the control rows ({@link JoinedStrandRows}), and forgets one on `forgetJoinedStrand` or
13
+ * when this party is removed from the strand.
14
+ *
15
+ * Two implementations:
16
+ * - {@link MemoryJoinedStrandStore} — joins die with the process. The default only for a
17
+ * node configured with no `keyStore`.
18
+ * - {@link KeyStoreJoinedStrandStore} — one {@link KeyStore} slot per join. A record holds
19
+ * the closed strand's shared read secret, and the KeyStore is the one seam every
20
+ * platform already routes secrets through (the platform enclave on React Native, the
21
+ * state directory for `FileKeyStore`), so the record goes there rather than into a
22
+ * `DurableSlot` like the bootstrap-peer store.
23
+ *
24
+ * Party-scoped like every other node-local store, because one KeyStore can serve several
25
+ * parties: the React Native app keeps one per device and lets the user switch party. A
26
+ * join made for one party must never be offered to a node started for another.
27
+ *
28
+ * Dependency-free beyond the KeyStore seam, so safe in every entry graph.
29
+ */
30
+ import debug from 'debug';
31
+ import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
32
+ import type { KeyId, KeyStore } from './key-store.js';
33
+ import { assertStrandScopeKey, isValidStrandScopeKey } from './storage-scope.js';
34
+ import type { StrandRow } from './types.js';
35
+
36
+ const log = debug('sereus:cadre:joined-strand-store');
37
+
38
+ /** One strand this node joined from ANOTHER party — nothing in this party's control DB names it. */
39
+ export interface JoinedStrandRecord {
40
+ /** The strand id (a valid scope key, see storage-scope.ts). */
41
+ Id: string;
42
+ Type: 'o' | 'c';
43
+ /** The closed strand's shared read secret the formation delivered; null for an open strand. */
44
+ MemberPrivateKey: string | null;
45
+ /** Wall-clock ms this node first recorded the join. Diagnostics only. */
46
+ joinedAt: number;
47
+ }
48
+
49
+ export interface JoinedStrandStore {
50
+ /** Party this store is scoped to. */
51
+ readonly partyId: string;
52
+
53
+ /** Every remembered join. A call reflects every {@link record}/{@link forget} that resolved before it was made. */
54
+ list(): Promise<JoinedStrandRecord[]>;
55
+
56
+ /** Remember a join, REPLACING any record with the same `Id`. */
57
+ record(record: JoinedStrandRecord): Promise<void>;
58
+
59
+ /** Forget a join. A no-op when there is no record for `strandId`. */
60
+ forget(strandId: string): Promise<void>;
61
+ }
62
+
63
+ /**
64
+ * The {@link StrandRow} a record stands for — the shape `addStrand` and `strand:discovered`
65
+ * carry. `FounderOwnerKey` is null because a joiner never founds; `CadreNode.launchStrand`
66
+ * reads null as "not mine" and joins.
67
+ */
68
+ export function joinedStrandRow(record: JoinedStrandRecord): StrandRow {
69
+ return { Id: record.Id, Type: record.Type, MemberPrivateKey: record.MemberPrivateKey, FounderOwnerKey: null };
70
+ }
71
+
72
+ /**
73
+ * Ephemeral store for a node without a `keyStore`. Warns once, at the first join it is
74
+ * asked to remember, because a join held only here is not re-offered after a restart —
75
+ * exactly the gotchoices/sereus#18 shape, and it should show in a log rather than pass
76
+ * silently.
77
+ */
78
+ export class MemoryJoinedStrandStore implements JoinedStrandStore {
79
+ private readonly records = new Map<string, JoinedStrandRecord>();
80
+ private warned = false;
81
+
82
+ constructor(readonly partyId: string) {}
83
+
84
+ async list(): Promise<JoinedStrandRecord[]> {
85
+ return [...this.records.values()];
86
+ }
87
+
88
+ async record(record: JoinedStrandRecord): Promise<void> {
89
+ this.warnOnce();
90
+ this.records.set(record.Id, { ...record });
91
+ }
92
+
93
+ async forget(strandId: string): Promise<void> {
94
+ this.records.delete(strandId);
95
+ }
96
+
97
+ private warnOnce(): void {
98
+ if (this.warned) {
99
+ return;
100
+ }
101
+ this.warned = true;
102
+ console.warn(
103
+ 'CadreNode has no keyStore and no joinedStrands.store, so strands joined from another party ' +
104
+ 'are remembered in memory only and will NOT be re-offered after a restart. Configure a keyStore, ' +
105
+ 'or inject joinedStrands: { store: new KeyStoreJoinedStrandStore(<a durable KeyStore>, partyId) }.'
106
+ );
107
+ }
108
+ }
109
+
110
+ /** Slot id prefix for every joined-strand record; the party segment and the strand id follow. */
111
+ const SLOT_PREFIX = 'cadre/joined-strand/';
112
+
113
+ /**
114
+ * Durable store over a {@link KeyStore}: one slot per join, id
115
+ * `cadre/joined-strand/<base64url party id>/<strand id>`, holding the record's UTF-8 JSON.
116
+ * The party segment is base64url for the reason `controlStorageScope` gives: a party id is
117
+ * arbitrary text, and base64url never contains the `/` that ends the segment.
118
+ *
119
+ * Loads every slot once, at the first {@link list}, then answers from memory and writes
120
+ * through, so the strand watcher's five-second poll does not read the enclave on every
121
+ * pass. A load that fails — `KeyStoreAccessError` from a refused unlock prompt, say —
122
+ * is rethrown rather than read as "no joins" and retried at the next call, exactly as
123
+ * `loadOrCreateIdentityKey` treats the identity slot. A slot that does not parse as a
124
+ * record for its own id is dropped with a log and its siblings kept: refusing to start
125
+ * repairs nothing, and the other joins are still good.
126
+ */
127
+ export class KeyStoreJoinedStrandStore implements JoinedStrandStore {
128
+ private readonly slotPrefix: string;
129
+ /** The loaded records; null until a load succeeds. */
130
+ private records: Map<string, JoinedStrandRecord> | null = null;
131
+ /** Runs every KeyStore access in call order, so a load never interleaves with a write. */
132
+ private queue: Promise<unknown> = Promise.resolve();
133
+
134
+ constructor(private readonly keyStore: KeyStore, readonly partyId: string) {
135
+ this.slotPrefix = SLOT_PREFIX + uint8ArrayToString(uint8ArrayFromString(partyId, 'utf8'), 'base64url') + '/';
136
+ }
137
+
138
+ list(): Promise<JoinedStrandRecord[]> {
139
+ return this.inOrder(async () => [...(await this.loaded()).values()]);
140
+ }
141
+
142
+ /** Throws `InvalidStrandIdError` for an id that could never load back, before anything is written. */
143
+ record(record: JoinedStrandRecord): Promise<void> {
144
+ assertStrandScopeKey(record.Id);
145
+ const stored: JoinedStrandRecord = { ...record };
146
+ return this.inOrder(async () => {
147
+ await this.keyStore.set(this.slotId(record.Id), uint8ArrayFromString(JSON.stringify(stored), 'utf8'));
148
+ this.records?.set(record.Id, stored);
149
+ log('joined strand remembered (party=%s, strand=%s, type=%s)', this.partyId, record.Id, record.Type);
150
+ });
151
+ }
152
+
153
+ forget(strandId: string): Promise<void> {
154
+ return this.inOrder(async () => {
155
+ await this.keyStore.delete(this.slotId(strandId));
156
+ this.records?.delete(strandId);
157
+ log('joined strand forgotten (party=%s, strand=%s)', this.partyId, strandId);
158
+ });
159
+ }
160
+
161
+ private slotId(strandId: string): KeyId {
162
+ return this.slotPrefix + strandId;
163
+ }
164
+
165
+ private inOrder<T>(op: () => Promise<T>): Promise<T> {
166
+ const result = this.queue.then(op);
167
+ // The caller receives `result`, rejection included; the queue only needs to know it settled.
168
+ this.queue = result.catch(() => undefined);
169
+ return result;
170
+ }
171
+
172
+ // NOTE: a failed load is retried at the next list(), which is every strand-watcher poll;
173
+ // over a backend that gates reads behind an unlock prompt the user keeps declining, that
174
+ // is a prompt every five seconds. Every KeyStore shipped today is ungated for this reason
175
+ // (see `SecureStoreKeyStoreOptions.requireAuthentication`); if one is ever gated, back
176
+ // off the retry here.
177
+ private async loaded(): Promise<Map<string, JoinedStrandRecord>> {
178
+ if (this.records) {
179
+ return this.records;
180
+ }
181
+ const records = new Map<string, JoinedStrandRecord>();
182
+ for (const slot of await this.keyStore.list()) {
183
+ if (!slot.startsWith(this.slotPrefix)) {
184
+ continue;
185
+ }
186
+ const strandId = slot.slice(this.slotPrefix.length);
187
+ const bytes = await this.keyStore.get(slot);
188
+ if (!bytes) {
189
+ continue;
190
+ }
191
+ const record = parseRecord(strandId, bytes);
192
+ if (record) {
193
+ records.set(strandId, record);
194
+ }
195
+ }
196
+ this.records = records;
197
+ log('joined strands loaded (party=%s, count=%d)', this.partyId, records.size);
198
+ return records;
199
+ }
200
+ }
201
+
202
+ /** The record a slot holds, or undefined (logged) when it is not one for `strandId`. */
203
+ function parseRecord(strandId: string, bytes: Uint8Array): JoinedStrandRecord | undefined {
204
+ let parsed: unknown;
205
+ try {
206
+ parsed = JSON.parse(uint8ArrayToString(bytes, 'utf8'));
207
+ } catch (error) {
208
+ log('dropping joined-strand slot for %s: not JSON (%o)', strandId, error);
209
+ return undefined;
210
+ }
211
+ const { Id, Type, MemberPrivateKey, joinedAt } = (typeof parsed === 'object' && parsed !== null ? parsed : {}) as Record<string, unknown>;
212
+ const valid = Id === strandId
213
+ && isValidStrandScopeKey(strandId)
214
+ && (Type === 'o' || Type === 'c')
215
+ && (MemberPrivateKey === null || typeof MemberPrivateKey === 'string')
216
+ && typeof joinedAt === 'number';
217
+ if (!valid) {
218
+ log('dropping joined-strand slot for %s: not a joined-strand record for that id', strandId);
219
+ return undefined;
220
+ }
221
+ return { Id: strandId, Type: Type as 'o' | 'c', MemberPrivateKey: MemberPrivateKey as string | null, joinedAt: joinedAt as number };
222
+ }
223
+
224
+ /**
225
+ * What the strand watcher sees of the joined-strand store: the control rows plus one row per
226
+ * remembered join, which is how a join comes back as `strand:discovered` (or auto-launches)
227
+ * after a restart with no second offer path. One instance per node session.
228
+ *
229
+ * Holds the two pieces of state a plain union would get wrong, both of which would otherwise
230
+ * make the watcher detach running strands, since a joined row missing from a poll reads to it
231
+ * as a removal:
232
+ * - the last list the store answered, reused when a later list fails, so a read failure is
233
+ * not "every join was forgotten";
234
+ * - joins forgotten because this party was removed from the strand
235
+ * ({@link forgetAfterThisSession}): gone from the store, so the next start does not
236
+ * re-attach them, but still offered until this session ends, because `strand:revoked`
237
+ * promises that nothing is torn down for the app.
238
+ */
239
+ export class JoinedStrandRows {
240
+ private lastListed: JoinedStrandRecord[] = [];
241
+ private readonly keptForSession = new Map<string, JoinedStrandRecord>();
242
+
243
+ constructor(private readonly store: JoinedStrandStore) {}
244
+
245
+ /**
246
+ * `control` plus a row for every remembered join. A control row wins an id collision: the
247
+ * strand is this party's own now (its founding party enrolled this machine, say), the
248
+ * control database remembers it, and the record is stale — so it is forgotten here.
249
+ */
250
+ async withControlRows(control: readonly StrandRow[]): Promise<StrandRow[]> {
251
+ const controlIds = new Set(control.map((row) => row.Id));
252
+ const joined = new Map<string, JoinedStrandRecord>(this.keptForSession);
253
+ for (const record of await this.listed()) {
254
+ joined.set(record.Id, record);
255
+ }
256
+ const rows = [...control];
257
+ for (const record of joined.values()) {
258
+ if (controlIds.has(record.Id)) {
259
+ await this.forgetSuperseded(record.Id);
260
+ } else {
261
+ rows.push(joinedStrandRow(record));
262
+ }
263
+ }
264
+ return rows;
265
+ }
266
+
267
+ /** Forget a join for good: the app is leaving the strand. */
268
+ async forget(strandId: string): Promise<void> {
269
+ this.keptForSession.delete(strandId);
270
+ await this.store.forget(strandId);
271
+ }
272
+
273
+ /**
274
+ * Forget a join durably, but keep offering it until this session ends. For a strand this
275
+ * party was removed from: it must not re-attach on every launch, and it must not be torn
276
+ * down underneath the app either. A no-op for a strand with no record.
277
+ */
278
+ async forgetAfterThisSession(strandId: string): Promise<void> {
279
+ const record = (await this.listed()).find((listed) => listed.Id === strandId);
280
+ if (!record) {
281
+ return;
282
+ }
283
+ // Kept BEFORE the store forgets, so no poll can list the store without it and union
284
+ // without the kept copy.
285
+ this.keptForSession.set(strandId, record);
286
+ await this.store.forget(strandId);
287
+ }
288
+
289
+ private async listed(): Promise<JoinedStrandRecord[]> {
290
+ try {
291
+ this.lastListed = await this.store.list();
292
+ } catch (error) {
293
+ log('joined-strand store list failed; offering the last list it answered (%d join(s)): %o',
294
+ this.lastListed.length, error);
295
+ }
296
+ return this.lastListed;
297
+ }
298
+
299
+ private async forgetSuperseded(strandId: string): Promise<void> {
300
+ this.keptForSession.delete(strandId);
301
+ try {
302
+ await this.store.forget(strandId);
303
+ log('joined strand %s now has a control row — record forgotten', strandId);
304
+ } catch (error) {
305
+ log('forgetting superseded joined strand %s failed; the next poll retries: %o', strandId, error);
306
+ }
307
+ }
308
+ }
package/src/key-store.ts CHANGED
@@ -32,6 +32,12 @@ export const DEFAULT_IDENTITY_KEY_ID: KeyId = 'cadre/identity';
32
32
  * Key material is raw bytes (e.g. libp2p protobuf-serialized private keys).
33
33
  * Text-only backends encode/decode (base64) internally; byte-native backends
34
34
  * (LevelDB) store as-is.
35
+ *
36
+ * Not only keys: `KeyStoreJoinedStrandStore` keeps one small UTF-8 JSON record
37
+ * (about 200 bytes, a closed strand's read secret among its fields) per strand
38
+ * this node joined from another party, under ids beginning
39
+ * `cadre/joined-strand/`. {@link list} therefore returns those ids too, and a
40
+ * backend must accept `/` in an id and values of that size.
35
41
  */
36
42
  export interface KeyStore {
37
43
  /** Resolves to the stored material, or undefined if the slot is empty. */