@serfab/cadre-core 1.2.0 → 1.4.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 (46) hide show
  1. package/README.md +5 -3
  2. package/dist/cadre-node.d.ts +32 -19
  3. package/dist/cadre-node.js +121 -31
  4. package/dist/cadre-node.js.map +1 -1
  5. package/dist/control-formation-recorder.d.ts +4 -4
  6. package/dist/control-formation-recorder.js +6 -6
  7. package/dist/control-formation-recorder.js.map +1 -1
  8. package/dist/index.d.ts +2 -2
  9. package/dist/index.js +5 -3
  10. package/dist/index.js.map +1 -1
  11. package/dist/membership-connection-gater.d.ts +18 -5
  12. package/dist/membership-connection-gater.js +50 -10
  13. package/dist/membership-connection-gater.js.map +1 -1
  14. package/dist/storage-scope.d.ts +50 -12
  15. package/dist/storage-scope.js +85 -12
  16. package/dist/storage-scope.js.map +1 -1
  17. package/dist/strand-formation-manager.js +2 -1
  18. package/dist/strand-formation-manager.js.map +1 -1
  19. package/dist/strand-id.d.ts +35 -0
  20. package/dist/strand-id.js +46 -0
  21. package/dist/strand-id.js.map +1 -0
  22. package/dist/strand-instance-manager.js +20 -2
  23. package/dist/strand-instance-manager.js.map +1 -1
  24. package/dist/strand-network-config.d.ts +3 -3
  25. package/dist/strand-network-config.js +3 -3
  26. package/dist/strand-solicitation.js +2 -1
  27. package/dist/strand-solicitation.js.map +1 -1
  28. package/dist/strand-watcher.d.ts +45 -0
  29. package/dist/strand-watcher.js +74 -13
  30. package/dist/strand-watcher.js.map +1 -1
  31. package/dist/types.d.ts +118 -15
  32. package/dist/types.js +59 -0
  33. package/dist/types.js.map +1 -1
  34. package/package.json +7 -7
  35. package/src/cadre-node.ts +127 -31
  36. package/src/control-formation-recorder.ts +6 -6
  37. package/src/index.ts +563 -554
  38. package/src/membership-connection-gater.ts +56 -11
  39. package/src/storage-scope.ts +163 -78
  40. package/src/strand-formation-manager.ts +647 -646
  41. package/src/strand-id.ts +48 -0
  42. package/src/strand-instance-manager.ts +21 -2
  43. package/src/strand-network-config.ts +250 -250
  44. package/src/strand-solicitation.ts +500 -499
  45. package/src/strand-watcher.ts +388 -325
  46. package/src/types.ts +121 -15
package/dist/types.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ConnectionGater, Libp2p, PeerId, PrivateKey } from '@libp2p/interface';
2
- import type { IRawStorage, Libp2pTransports } from '@optimystic/db-p2p';
2
+ import type { IRawStorage, Libp2pConnectionMonitorInit, Libp2pTransports, NoiseCryptoInterface } from '@optimystic/db-p2p';
3
3
  import type { IPeerNetwork, IRepo } from '@optimystic/db-core';
4
4
  import type { PeerJoinBackfillConfig } from './peer-join-backfill.js';
5
5
  import type { StrandRevocationEnforcementConfig } from './strand-revocation-enforcer.js';
@@ -91,13 +91,13 @@ export declare const HIBERNATION_TIMEOUTS: Record<LatencyHint, HibernationTimeou
91
91
  * Factory functions are useful for creating per-scope storage instances.
92
92
  *
93
93
  * **The argument is an opaque scope key.** Use it directly as a file name, directory
94
- * name or database name: every key cadre-core mints stays within `[A-Za-z0-9._-]`, so
95
- * no escaping is needed and none should be assumed. Do not parse it;
96
- * `controlStorageScope` / `isControlStorageScope` (`storage-scope.ts`) are the
97
- * supported way to mint and recognize the control key. The strand arm of that charset
98
- * rule is intended but not yet checked — a strand id replicated in from another node
99
- * in the party reaches this callback verbatim; see
100
- * `tickets/backlog/bug-strand-scope-key-charset-unenforced`.
94
+ * name or database name: every key stays within `[A-Za-z0-9._-]`, so no escaping is
95
+ * needed and none should be assumed. Do not parse it; `controlStorageScope` /
96
+ * `isControlStorageScope` (`storage-scope.ts`) are the supported way to mint and
97
+ * recognize the control key. The control key holds the charset by base64url encoding;
98
+ * a strand's key — its strand id, which may have replicated in from another node in
99
+ * the party — holds it because `StrandInstanceManager.startStrand` runs
100
+ * `assertStrandScopeKey` on every launch and refuses a strand that fails it.
101
101
  *
102
102
  * **Called once per scope per runtime lifetime.** The scopes are the control database
103
103
  * (once per `CadreNode.start()`) and each strand id (once per `startStrand`).
@@ -334,6 +334,46 @@ export interface NetworkConfig {
334
334
  * ```
335
335
  */
336
336
  transports?: Libp2pTransports;
337
+ /**
338
+ * Crypto primitives for the Noise handshake and the encrypted connection that
339
+ * follows, threaded to both the control node and every strand's cohort node — every
340
+ * node pays the handshake, so one setting covers them all. When omitted, libp2p-noise
341
+ * picks its own default.
342
+ *
343
+ * React Native is the reason this exists: Metro resolves `@chainsafe/libp2p-noise`'s
344
+ * browser build, whose default is pure-JS crypto, and on Hermes (no JIT) that
345
+ * dominates connection setup on a slow phone. An app with native crypto supplies it
346
+ * here. It must implement every member of the interface; the usual shape spreads
347
+ * `noisePureJsCrypto` and overrides the hashing and ChaCha20-Poly1305 functions:
348
+ * ```typescript
349
+ * import { noisePureJsCrypto } from '@optimystic/db-p2p';
350
+ *
351
+ * network: {
352
+ * noiseCrypto: { ...noisePureJsCrypto, hashSHA256: nativeSha256, chaCha20Poly1305Encrypt: …, chaCha20Poly1305Decrypt: … }
353
+ * }
354
+ * ```
355
+ *
356
+ * Only local primitives change — the wire protocol does not, so a node with native
357
+ * crypto interoperates with one without. See `@optimystic/db-p2p`'s
358
+ * `NodeOptions.noiseCrypto`.
359
+ */
360
+ noiseCrypto?: NoiseCryptoInterface;
361
+ /**
362
+ * libp2p's connection monitor — the liveness ping it runs on every connection — for
363
+ * the control node and every strand node, as {@link noiseCrypto} is. When omitted,
364
+ * cadre-core applies {@link DEFAULT_CONNECTION_MONITOR} rather than leaving libp2p's
365
+ * own defaults in place; an explicit value REPLACES that default wholesale, so `{}`
366
+ * is how an app asks for libp2p's stock behaviour back.
367
+ *
368
+ * Typed from `@optimystic/db-p2p`'s re-export of libp2p's `ConnectionMonitorInit`, so
369
+ * an app does not need a direct `libp2p` dependency, and handed to db-p2p's
370
+ * `NodeOptions.connectionMonitor` unchanged.
371
+ *
372
+ * An app that widens the ping deadline itself must raise `pingInterval` above it in
373
+ * the same object, or libp2p aborts the connection on the second overlapping ping —
374
+ * see {@link DEFAULT_CONNECTION_MONITOR} for why.
375
+ */
376
+ connectionMonitor?: Libp2pConnectionMonitorInit;
337
377
  /**
338
378
  * Optional async resolver returning the multiaddrs to embed in invites
339
379
  * (and other owner-address contexts). When unset, `libp2pNode.getMultiaddrs()`
@@ -414,6 +454,63 @@ export interface NetworkConfig {
414
454
  strandAddrRefreshMs?: number;
415
455
  };
416
456
  }
457
+ /**
458
+ * The connection-monitor settings every cadre node runs with when
459
+ * {@link NetworkConfig.connectionMonitor} is unset: a 30 second ping deadline in place
460
+ * of libp2p's 5 second one, and a 35 second gap between pings so a ping is never
461
+ * outstanding when the next one starts.
462
+ *
463
+ * WHY this is a default and not opt-in. libp2p's monitor pings every connection and,
464
+ * with its own `abortConnectionOnPingFailure: true`, aborts the connection on the FIRST
465
+ * timeout. A peer whose event loop is saturated by pure-JS Noise crypto — a slow phone
466
+ * under React Native, see {@link NetworkConfig.noiseCrypto} — misses that deadline while
467
+ * perfectly healthy; the other end aborts, the peer redials, and the new handshake
468
+ * saturates it further. Measured on gotchoices/sereus#13 at a Galaxy S7's crypto cost, a
469
+ * two-party bring-up failed 3 of 3 runs on stock settings, with 30 `aborting connection
470
+ * due to ping failure` entries in the relay's log for one run, and passed 4 of 4 in about
471
+ * 90 seconds once the deadline was widened on every node. The monitor runs on BOTH ends
472
+ * of a connection and either end's abort closes it for both, so a setting the phone alone
473
+ * applies does not cover the peer dropping it. That is what rules out scoping this to
474
+ * React Native.
475
+ *
476
+ * WHY `pingInterval` MOVES WITH THE DEADLINE, and is not left at libp2p's 10 seconds.
477
+ * The monitor opens a ping stream per connection per interval whether or not the previous
478
+ * ping has answered, and `/ipfs/ping/1.0.0` is registered by `@libp2p/ping` with
479
+ * `maxOutboundStreams: 1`. A second concurrent ping stream on one connection therefore
480
+ * fails in `Connection.newStream` with `TooManyOutboundProtocolStreamsError`, which
481
+ * reaches the monitor's own catch and aborts the connection exactly as a timeout does. A
482
+ * widened deadline alone is thus capped by the ping interval: measured against two local
483
+ * libp2p 3.1.3 nodes whose ping handler answered 600ms late, an interval of 300ms with a
484
+ * 900ms deadline aborted the connection, while a 900ms interval with the same deadline
485
+ * kept it (the same pair aborted at a 200ms stall only when the deadline was 300ms). An
486
+ * interval strictly above the deadline is what makes the 30 seconds real.
487
+ *
488
+ * WHAT IT COSTS. A dead peer is reclaimed 30 to 65 seconds after it stops answering — the
489
+ * deadline, plus up to one interval of waiting for the ping that will fail — where
490
+ * libp2p's defaults took about 5 to 15 seconds. `db-p2p` caps a node at 16 connections,
491
+ * so the worst case is those slots held about a minute longer than before; at that scale
492
+ * it is not a starvation risk.
493
+ *
494
+ * WHY THE DEADLINE IS PINNED rather than given room to adapt. `pingTimeout` is an
495
+ * adaptive-timeout init, and equal `minTimeout`/`maxTimeout` clamp it to one value on
496
+ * every libp2p version. Under libp2p 3.1.3, which sereus resolves today, it is already
497
+ * flat at `minTimeout`: `ConnectionMonitor` asks its `AdaptiveTimeout` for a deadline but
498
+ * never calls `cleanUp` to report how long the ping took, so the moving average the
499
+ * deadline derives from stays at zero. libp2p 3.3 does report ping durations back, and a
500
+ * ceiling above `pingInterval` would then let the deadline grow past the interval and put
501
+ * the overlapping-ping abort above straight back. Pinning both ends keeps the interval's
502
+ * margin true on the version bump instead of making it something to remember. It also
503
+ * sidesteps 3.3's other surprise: the monitor keeps one `AdaptiveTimeout` for all of a
504
+ * node's connections, so one slow peer would otherwise lengthen the deadline for every
505
+ * connection on that node.
506
+ */
507
+ export declare const DEFAULT_CONNECTION_MONITOR: Readonly<{
508
+ pingInterval: number;
509
+ pingTimeout: Readonly<{
510
+ minTimeout: 30000;
511
+ maxTimeout: 30000;
512
+ }>;
513
+ }>;
417
514
  /**
418
515
  * Hibernation configuration
419
516
  */
@@ -1140,13 +1237,19 @@ export interface CadreNodeEvents {
1140
1237
  * Carries the full {@link StrandRow} so the app can join without re-querying
1141
1238
  * the control DB.
1142
1239
  *
1143
- * **Fired exactly ONCE per strand per session, and it can fire before your
1144
- * listener is attached.** The strand watcher's first poll runs inside
1145
- * `CadreNode.start()` (100 ms after the watcher starts), so every strand
1146
- * already stored for this party is normally offered while the embedding app is
1147
- * still inside its own `start()` await. The watcher then records the strand as
1148
- * seen and no later poll re-offers it, so a listener attached a moment late
1149
- * misses those strands for the life of the process.
1240
+ * **Fired once per strand per session, and it can fire before your listener is
1241
+ * attached.** The strand watcher's first poll runs inside `CadreNode.start()`
1242
+ * (100 ms after the watcher starts), so every strand already stored for this
1243
+ * party is normally offered while the embedding app is still inside its own
1244
+ * `start()` await. The watcher then records the strand as seen and no later poll
1245
+ * re-offers it, so a listener attached a moment late misses those strands for the
1246
+ * life of the process.
1247
+ *
1248
+ * One exception, and it does NOT produce a second event: when an `addStrand` that
1249
+ * claims a discovered strand FAILS, the watcher is told to forget the strand so a
1250
+ * later poll retries it. That claim left the sApp config registered, so the retry
1251
+ * takes the auto-launch branch — what the app sees is `strand:error` per failed
1252
+ * retry and `strand:started` when one succeeds.
1150
1253
  *
1151
1254
  * So an app that auto-joins discovered strands must **subscribe first, then
1152
1255
  * drain `CadreNode.getDiscoveredStrands()`** — the map of strands no local
package/dist/types.js CHANGED
@@ -35,6 +35,65 @@ export const HIBERNATION_TIMEOUTS = {
35
35
  checkInMaxInterval: 3 * 24 * 60 * 60 * 1000 // cap: ~3 days
36
36
  }
37
37
  };
38
+ /**
39
+ * The connection-monitor settings every cadre node runs with when
40
+ * {@link NetworkConfig.connectionMonitor} is unset: a 30 second ping deadline in place
41
+ * of libp2p's 5 second one, and a 35 second gap between pings so a ping is never
42
+ * outstanding when the next one starts.
43
+ *
44
+ * WHY this is a default and not opt-in. libp2p's monitor pings every connection and,
45
+ * with its own `abortConnectionOnPingFailure: true`, aborts the connection on the FIRST
46
+ * timeout. A peer whose event loop is saturated by pure-JS Noise crypto — a slow phone
47
+ * under React Native, see {@link NetworkConfig.noiseCrypto} — misses that deadline while
48
+ * perfectly healthy; the other end aborts, the peer redials, and the new handshake
49
+ * saturates it further. Measured on gotchoices/sereus#13 at a Galaxy S7's crypto cost, a
50
+ * two-party bring-up failed 3 of 3 runs on stock settings, with 30 `aborting connection
51
+ * due to ping failure` entries in the relay's log for one run, and passed 4 of 4 in about
52
+ * 90 seconds once the deadline was widened on every node. The monitor runs on BOTH ends
53
+ * of a connection and either end's abort closes it for both, so a setting the phone alone
54
+ * applies does not cover the peer dropping it. That is what rules out scoping this to
55
+ * React Native.
56
+ *
57
+ * WHY `pingInterval` MOVES WITH THE DEADLINE, and is not left at libp2p's 10 seconds.
58
+ * The monitor opens a ping stream per connection per interval whether or not the previous
59
+ * ping has answered, and `/ipfs/ping/1.0.0` is registered by `@libp2p/ping` with
60
+ * `maxOutboundStreams: 1`. A second concurrent ping stream on one connection therefore
61
+ * fails in `Connection.newStream` with `TooManyOutboundProtocolStreamsError`, which
62
+ * reaches the monitor's own catch and aborts the connection exactly as a timeout does. A
63
+ * widened deadline alone is thus capped by the ping interval: measured against two local
64
+ * libp2p 3.1.3 nodes whose ping handler answered 600ms late, an interval of 300ms with a
65
+ * 900ms deadline aborted the connection, while a 900ms interval with the same deadline
66
+ * kept it (the same pair aborted at a 200ms stall only when the deadline was 300ms). An
67
+ * interval strictly above the deadline is what makes the 30 seconds real.
68
+ *
69
+ * WHAT IT COSTS. A dead peer is reclaimed 30 to 65 seconds after it stops answering — the
70
+ * deadline, plus up to one interval of waiting for the ping that will fail — where
71
+ * libp2p's defaults took about 5 to 15 seconds. `db-p2p` caps a node at 16 connections,
72
+ * so the worst case is those slots held about a minute longer than before; at that scale
73
+ * it is not a starvation risk.
74
+ *
75
+ * WHY THE DEADLINE IS PINNED rather than given room to adapt. `pingTimeout` is an
76
+ * adaptive-timeout init, and equal `minTimeout`/`maxTimeout` clamp it to one value on
77
+ * every libp2p version. Under libp2p 3.1.3, which sereus resolves today, it is already
78
+ * flat at `minTimeout`: `ConnectionMonitor` asks its `AdaptiveTimeout` for a deadline but
79
+ * never calls `cleanUp` to report how long the ping took, so the moving average the
80
+ * deadline derives from stays at zero. libp2p 3.3 does report ping durations back, and a
81
+ * ceiling above `pingInterval` would then let the deadline grow past the interval and put
82
+ * the overlapping-ping abort above straight back. Pinning both ends keeps the interval's
83
+ * margin true on the version bump instead of making it something to remember. It also
84
+ * sidesteps 3.3's other surprise: the monitor keeps one `AdaptiveTimeout` for all of a
85
+ * node's connections, so one slow peer would otherwise lengthen the deadline for every
86
+ * connection on that node.
87
+ */
88
+ export const DEFAULT_CONNECTION_MONITOR = Object.freeze({
89
+ // Strictly greater than the deadline below, so the previous ping is always resolved or
90
+ // aborted before the next one opens a stream. `types.spec.ts` holds the two apart.
91
+ pingInterval: 35000,
92
+ // Frozen at both levels, as `CONTROL_CLUSTER_POLICY` is: one object reaches every
93
+ // libp2p node this process builds, so a mutation anywhere would move the deadline
94
+ // for all of them.
95
+ pingTimeout: Object.freeze({ minTimeout: 30000, maxTimeout: 30000 })
96
+ });
38
97
  /**
39
98
  * Default duration of the resume-and-probe window during a strand check-in.
40
99
  * See {@link HibernationConfig.checkInWindowMs}.
package/dist/types.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAgFA;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAA6C;IAC5E,QAAQ,EAAE;QACR,WAAW,EAAE,QAAQ,EAAS,aAAa;QAC3C,gBAAgB,EAAE,QAAQ,EAAI,kBAAkB;QAChD,eAAe,EAAE,QAAQ,EAAK,MAAM;QACpC,oBAAoB,EAAE,CAAC;QACvB,kBAAkB,EAAE,QAAQ,CAAE,MAAM;KACrC;IACD,WAAW,EAAE;QACX,WAAW,EAAE,CAAC,GAAG,EAAE,GAAG,IAAI,EAAI,YAAY;QAC1C,gBAAgB,EAAE,EAAE,GAAG,EAAE,GAAG,IAAI,EAAE,wBAAwB;QAC1D,eAAe,EAAE,EAAE,GAAG,IAAI,EAAI,mBAAmB;QACjD,oBAAoB,EAAE,CAAC;QACvB,kBAAkB,EAAE,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC,eAAe;KACnD;IACD,UAAU,EAAE;QACV,WAAW,EAAE,CAAC,GAAG,EAAE,GAAG,IAAI,EAAI,WAAW;QACzC,gBAAgB,EAAE,CAAC,GAAG,EAAE,GAAG,IAAI,EAAG,uBAAuB;QACzD,eAAe,EAAE,CAAC,GAAG,EAAE,GAAG,IAAI,EAAI,kBAAkB;QACpD,oBAAoB,EAAE,CAAC;QACvB,kBAAkB,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC,gBAAgB;KACxD;IACD,OAAO,EAAE;QACP,WAAW,EAAE,EAAE,GAAG,IAAI,EAAQ,aAAa;QAC3C,gBAAgB,EAAE,EAAE,GAAG,IAAI,EAAG,wBAAwB;QACtD,eAAe,EAAE,EAAE,GAAG,EAAE,GAAG,IAAI,EAAG,eAAe;QACjD,oBAAoB,EAAE,CAAC;QACvB,kBAAkB,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC,eAAe;KAC5D;CACF,CAAC;AA8VF;;;GAGG;AACH,MAAM,CAAC,MAAM,yBAAyB,GAAG,EAAE,GAAG,IAAI,CAAC;AAYnD;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,EACL,gBAAgB,EAChB,2BAA2B,EAC3B,sBAAsB,EACtB,2BAA2B,EAC3B,qBAAqB,EACrB,wBAAwB,EACxB,sBAAsB,EACtB,oBAAoB,EACpB,mBAAmB,EACpB,MAAM,+BAA+B,CAAC"}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAgFA;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAA6C;IAC5E,QAAQ,EAAE;QACR,WAAW,EAAE,QAAQ,EAAS,aAAa;QAC3C,gBAAgB,EAAE,QAAQ,EAAI,kBAAkB;QAChD,eAAe,EAAE,QAAQ,EAAK,MAAM;QACpC,oBAAoB,EAAE,CAAC;QACvB,kBAAkB,EAAE,QAAQ,CAAE,MAAM;KACrC;IACD,WAAW,EAAE;QACX,WAAW,EAAE,CAAC,GAAG,EAAE,GAAG,IAAI,EAAI,YAAY;QAC1C,gBAAgB,EAAE,EAAE,GAAG,EAAE,GAAG,IAAI,EAAE,wBAAwB;QAC1D,eAAe,EAAE,EAAE,GAAG,IAAI,EAAI,mBAAmB;QACjD,oBAAoB,EAAE,CAAC;QACvB,kBAAkB,EAAE,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC,eAAe;KACnD;IACD,UAAU,EAAE;QACV,WAAW,EAAE,CAAC,GAAG,EAAE,GAAG,IAAI,EAAI,WAAW;QACzC,gBAAgB,EAAE,CAAC,GAAG,EAAE,GAAG,IAAI,EAAG,uBAAuB;QACzD,eAAe,EAAE,CAAC,GAAG,EAAE,GAAG,IAAI,EAAI,kBAAkB;QACpD,oBAAoB,EAAE,CAAC;QACvB,kBAAkB,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC,gBAAgB;KACxD;IACD,OAAO,EAAE;QACP,WAAW,EAAE,EAAE,GAAG,IAAI,EAAQ,aAAa;QAC3C,gBAAgB,EAAE,EAAE,GAAG,IAAI,EAAG,wBAAwB;QACtD,eAAe,EAAE,EAAE,GAAG,EAAE,GAAG,IAAI,EAAG,eAAe;QACjD,oBAAoB,EAAE,CAAC;QACvB,kBAAkB,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC,eAAe;KAC5D;CACF,CAAC;AAqXF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AACH,MAAM,CAAC,MAAM,0BAA0B,GAAG,MAAM,CAAC,MAAM,CAAC;IACtD,uFAAuF;IACvF,mFAAmF;IACnF,YAAY,EAAE,KAAM;IACpB,kFAAkF;IAClF,kFAAkF;IAClF,mBAAmB;IACnB,WAAW,EAAE,MAAM,CAAC,MAAM,CAAC,EAAE,UAAU,EAAE,KAAM,EAAE,UAAU,EAAE,KAAM,EAAE,CAAC;CACjC,CAAC,CAAC;AAmBzC;;;GAGG;AACH,MAAM,CAAC,MAAM,yBAAyB,GAAG,EAAE,GAAG,IAAI,CAAC;AAYnD;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,EACL,gBAAgB,EAChB,2BAA2B,EAC3B,sBAAsB,EACtB,2BAA2B,EAC3B,qBAAqB,EACrB,wBAAwB,EACxB,sBAAsB,EACtB,oBAAoB,EACpB,mBAAmB,EACpB,MAAM,+BAA+B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serfab/cadre-core",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "type": "module",
5
5
  "description": "Core library for Sereus cadre nodes - manages control networks and strand participation",
6
6
  "main": "dist/index.js",
@@ -73,7 +73,7 @@
73
73
  "@libp2p/tcp": "^11.0.10",
74
74
  "@libp2p/webrtc": "6.0.14",
75
75
  "@libp2p/websockets": "^10.1.3",
76
- "@optimystic/db-p2p-storage-fs": "^1.1.0",
76
+ "@optimystic/db-p2p-storage-fs": "^1.5.0",
77
77
  "@types/debug": "^4.1.12",
78
78
  "@types/node": "^25.0.10",
79
79
  "@vitest/coverage-v8": "^4.0.17",
@@ -88,12 +88,12 @@
88
88
  "@libp2p/interface": "^3.1.0",
89
89
  "@libp2p/peer-id": "^6.0.4",
90
90
  "@multiformats/multiaddr": "^12.5.1",
91
- "@optimystic/db-core": "^1.1.0",
92
- "@optimystic/db-p2p": "^1.1.0",
93
- "@optimystic/quereus-plugin-crypto": "^1.1.0",
94
- "@optimystic/quereus-plugin-optimystic": "^1.1.0",
91
+ "@optimystic/db-core": "^1.5.0",
92
+ "@optimystic/db-p2p": "^1.5.0",
93
+ "@optimystic/quereus-plugin-crypto": "^1.5.0",
94
+ "@optimystic/quereus-plugin-optimystic": "^1.5.0",
95
95
  "@quereus/quereus": "^4.19.4",
96
- "@serfab/quereus-plugin-sereus": "^1.2.0",
96
+ "@serfab/quereus-plugin-sereus": "^1.4.0",
97
97
  "debug": "^4.4.3",
98
98
  "libp2p": "^3.1.3",
99
99
  "uint8arrays": "^5.1.0"
package/src/cadre-node.ts CHANGED
@@ -37,11 +37,17 @@ import type {
37
37
  PeerAddressRecord,
38
38
  ResolveDeviceTokenOpts
39
39
  } from './types.js';
40
- import { controlClusterPolicy, CONTROL_REPLICATION_BREADTH, DEFAULT_CHECKIN_WINDOW_MS } from './types.js';
40
+ import { controlClusterPolicy, CONTROL_REPLICATION_BREADTH, DEFAULT_CHECKIN_WINDOW_MS, DEFAULT_CONNECTION_MONITOR } from './types.js';
41
41
  import { sign } from '@optimystic/quereus-plugin-crypto';
42
42
  import { ed25519KeyPairFromLibp2p, ed25519PublicKeyFromPrivate, requireEd25519PublicKeyB64, type Ed25519KeyPair } from './ed25519-key.js';
43
43
  import { strandTransportKey } from './strand-transport-key.js';
44
- import { controlStorageScope } from './storage-scope.js';
44
+ import {
45
+ assertScopeKeyCharset,
46
+ assertStrandScopeKey,
47
+ controlStorageScope,
48
+ InvalidStrandIdError,
49
+ isValidStrandScopeKey
50
+ } from './storage-scope.js';
45
51
  import { generateStrandMemberKey, strandMemberKeyPair } from './strand-member-key.js';
46
52
  import { assertNotPreSplitStrand, issueInvite, PreSplitStrandIdentityError } from './strand-membership-writer.js';
47
53
  import { MEMBERSHIP_INVITE_TTL_MS } from './strand-formation-manager.js';
@@ -533,8 +539,10 @@ export class CadreNode implements SAppIdLookup {
533
539
  * dead entry behind; recovery today is a fresh invitation.
534
540
  *
535
541
  * NOTE: entries are kept for the node's lifetime, including across a
536
- * {@link stopStrand} — a stopped strand may be rediscovered by the watcher and must
537
- * still get its seed. Each strand's list is capped at `MAX_STRAND_ADDRS`, but the
542
+ * {@link stopStrand} — a stopped strand may be claimed again with {@link addStrand}
543
+ * (the watcher itself will not re-offer it: the stop suppresses the id for the rest of
544
+ * the session) and must still get its seed. Each strand's list is capped at
545
+ * `MAX_STRAND_ADDRS`, but the
538
546
  * number of KEYS is bounded only by the strands this node has ever formed as an
539
547
  * initiator (not by time); if a node ever forms strands at scale, evict on
540
548
  * `unpublishStrand` or cap the map.
@@ -1436,6 +1444,12 @@ export class CadreNode implements SAppIdLookup {
1436
1444
  * derivation from it (`db-p2p` namespaces all of the node's protocol ids as
1437
1445
  * `/optimystic/<networkName>/...`), so the node options and the block-transfer
1438
1446
  * protocol prefix the control backfill dials can never drift apart.
1447
+ *
1448
+ * NOTE: the party id goes in UNENCODED here, unlike in `controlStorageScope`. Safe
1449
+ * today because a party id is locally configured rather than replicated in, and both
1450
+ * ends of a connection derive this string identically — an odd party id yields an odd
1451
+ * but consistent protocol id, not a mismatch or an escaped name. If a party id ever
1452
+ * arrives from the network, encode it here as the storage scope key already does.
1439
1453
  */
1440
1454
  private controlNetworkName(): string {
1441
1455
  return `control-${this.config.controlNetwork.partyId}`;
@@ -1548,6 +1562,11 @@ export class CadreNode implements SAppIdLookup {
1548
1562
  // party's records, so two parties on one device must not land in one store.
1549
1563
  // Same key as the cache label, which also makes the shared pool's `stats()` readable.
1550
1564
  const scope = controlStorageScope(this.config.controlNetwork.partyId);
1565
+ // The second seam that hands a scope key to an embedder's provider; the first
1566
+ // (`StrandInstanceManager.startStrand`) asserts the stricter strand rule. Holds by
1567
+ // construction today — base64url is inside the charset — so this guards a future
1568
+ // edit to `controlStorageScope`, not a reachable input.
1569
+ assertScopeKeyCharset(scope);
1551
1570
  const resolved = typeof provider === 'function' ? provider(scope) : provider;
1552
1571
  this.controlStorage = wrapStorageWithCache(resolved, scope);
1553
1572
  return this.controlStorage;
@@ -1649,6 +1668,12 @@ export class CadreNode implements SAppIdLookup {
1649
1668
  arachnode: { enableRingZulu: profile === 'storage' },
1650
1669
  ...(identityKey && { privateKey: identityKey }),
1651
1670
  ...(network?.transports && { transports: network.transports }),
1671
+ ...(network?.noiseCrypto && { noiseCrypto: network.noiseCrypto }),
1672
+ // Always present, unlike the conditional spreads around it: an absent
1673
+ // `connectionMonitor` would leave libp2p's 5s ping deadline, which drops a peer
1674
+ // whose event loop is saturated by pure-JS Noise crypto. A configured value
1675
+ // replaces the default whole (see DEFAULT_CONNECTION_MONITOR).
1676
+ connectionMonitor: network?.connectionMonitor ?? DEFAULT_CONNECTION_MONITOR,
1652
1677
  // `{ wsPort }` when a listen entry names WebSocket, otherwise `{}` — and always
1653
1678
  // `{}` when `network.transports` is set, since the embedder owns transport policy
1654
1679
  // then. Spread NEXT to `transports` because the two answer the same question.
@@ -4068,6 +4093,24 @@ export class CadreNode implements SAppIdLookup {
4068
4093
  private async handleStrandAdded(strand: StrandRow): Promise<void> {
4069
4094
  log('Handling strand added from control network: %s', strand.Id);
4070
4095
 
4096
+ // A replicated row carries whatever id the founding node wrote, and the id becomes a
4097
+ // storage scope key and a libp2p protocol prefix. Checked FIRST — above the
4098
+ // discovery branch below — so a malformed id is never offered to the hosting app as
4099
+ // a strand it could join; that app's `addStrand` could only fail on it.
4100
+ //
4101
+ // Suppressed rather than retried: the id is a property of the row, so every later
4102
+ // attempt fails identically, and without this the watcher's ladder would re-emit
4103
+ // `strand:error` every five minutes for the life of the process. The throw still
4104
+ // matters — the watcher's catch runs `forgetStrand`, dropping the id from
4105
+ // `knownStrands`, so its removed-strand loop never detaches a strand that never ran.
4106
+ if (!isValidStrandScopeKey(strand.Id)) {
4107
+ const error = new InvalidStrandIdError(strand.Id);
4108
+ log('Refusing strand %s: %s', strand.Id, error.message);
4109
+ this.emit('strand:error', { strandId: strand.Id, error });
4110
+ this.strandWatcher?.suppressStrand(strand.Id);
4111
+ throw error;
4112
+ }
4113
+
4071
4114
  // Check if we have sApp config for this strand
4072
4115
  const sAppConfig = this.sAppConfigs.get(strand.Id);
4073
4116
  if (!sAppConfig) {
@@ -4079,10 +4122,12 @@ export class CadreNode implements SAppIdLookup {
4079
4122
  // already present) keeps auto-starting below, unchanged.
4080
4123
  //
4081
4124
  // Recorded BEFORE the emit so a handler that synchronously drains
4082
- // `getDiscoveredStrands()` sees this strand too. The watcher never offers
4083
- // the same strand twice (its `knownStrands` retains the id for the life of
4084
- // the process), so this map — not the event — is what a late subscriber
4085
- // reads. See the `strand:discovered` doc in types.ts.
4125
+ // `getDiscoveredStrands()` sees this strand too. The watcher does not offer the
4126
+ // same strand twice — its `knownStrands` retains the id, and the one thing that
4127
+ // un-retains it (a failed `addStrand`, via `StrandWatcher.forgetStrand`) leaves the
4128
+ // sApp config registered, so the retry takes the auto-launch branch below rather
4129
+ // than this one. So this map — not the event — is what a late subscriber reads.
4130
+ // See the `strand:discovered` doc in types.ts.
4086
4131
  this.discoveredStrands.set(strand.Id, strand);
4087
4132
  this.emit('strand:discovered', { strandId: strand.Id, strand });
4088
4133
  return;
@@ -4522,17 +4567,17 @@ export class CadreNode implements SAppIdLookup {
4522
4567
  *
4523
4568
  * A rejected call leaves nothing running but DOES leave the sApp config
4524
4569
  * registered, deliberately: both an explicit retry and the {@link StrandWatcher}'s
4525
- * automatic relaunch need it. That background relaunch covers only a strand the
4526
- * WATCHER launched: it forgets a row whose `onStrandAdded` threw and re-attempts it
4527
- * on a later poll (each failure re-emitting `strand:error`) until it succeeds. A
4528
- * strand claimed here after it was DISCOVERED gets no such retry — the watcher
4529
- * already recorded it in its `knownStrands` when it offered it as
4530
- * `strand:discovered`, and no later poll re-offers a strand it knows. This call has
4531
- * also already dropped it from {@link getDiscoveredStrands}, so a failed claim
4532
- * leaves the strand dormant until the caller retries or the node restarts (see
4533
- * `backlog/bug-discovered-strand-lost-when-claim-fails`).
4534
- * {@link detachStrand} (reached via {@link stopStrand}) is what abandons a strand
4535
- * for good.
4570
+ * automatic relaunch need it. A failed launch here hands the strand back to that
4571
+ * relaunch — `StrandWatcher.forgetStrand` drops the id from the watcher's
4572
+ * `knownStrands` and records the backoff, so a later poll re-attempts it on the same
4573
+ * `pollInterval * 2^(failures-1)` ladder a watcher-driven failure gets. Because the
4574
+ * config stays registered, the retry takes `handleStrandAdded`'s auto-launch branch:
4575
+ * what the app sees is `strand:error` per failed retry and `strand:started` when one
4576
+ * succeeds, never a second `strand:discovered`. {@link detachStrand} (reached via
4577
+ * {@link stopStrand}) is what abandons a strand for good, and its stop suppresses the
4578
+ * strand in the watcher so this retry cannot resurrect it — a call to THIS method lifts
4579
+ * that suppression again, since an explicit claim is a deliberate reversal of the
4580
+ * deliberate stop.
4536
4581
  */
4537
4582
  async addStrand(config: StrandConfig): Promise<StrandInstance> {
4538
4583
  if (!this._running) {
@@ -4540,6 +4585,9 @@ export class CadreNode implements SAppIdLookup {
4540
4585
  }
4541
4586
 
4542
4587
  const { strandRow, sAppConfig, founder, partyMemberPrivateKey } = config;
4588
+ // Before anything is recorded: an unusable id must not leave a registered sApp
4589
+ // config behind, and must not lift a suppression this node set deliberately.
4590
+ assertStrandScopeKey(strandRow.Id);
4543
4591
  if (partyMemberPrivateKey !== undefined && strandRow.Type !== 'c') {
4544
4592
  // Only a closed strand seats a Member/Manager, so an open launch would drop this
4545
4593
  // key silently. Refuse instead: the caller has confused the party identity key
@@ -4555,6 +4603,9 @@ export class CadreNode implements SAppIdLookup {
4555
4603
  // unclaimed backlog — a later `getDiscoveredStrands()` drain must not re-offer it.
4556
4604
  this.sAppConfigs.set(strandRow.Id, sAppConfig);
4557
4605
  this.discoveredStrands.delete(strandRow.Id);
4606
+ // A claim also overrides any earlier `stopStrand` of this id: the watcher must track
4607
+ // the strand again, or a party-wide removal would never stop it here.
4608
+ this.strandWatcher?.unsuppressStrand(strandRow.Id);
4558
4609
  log('Registered sAppConfig for strand %s (sApp: %s, founder: %s)',
4559
4610
  strandRow.Id, sAppConfig.id, founder ?? 'derived');
4560
4611
 
@@ -4563,7 +4614,31 @@ export class CadreNode implements SAppIdLookup {
4563
4614
  // formation/responder flows pass one deliberately, since their consent-seated rows
4564
4615
  // carry a null column. See StrandConfig.founder. Same rule for the explicit
4565
4616
  // partyMemberPrivateKey: it wins over the StrandPartyKey control-row read.
4566
- const instance = await this.launchStrand(strandRow, sAppConfig, founder, partyMemberPrivateKey);
4617
+ // Only the launch is wrapped, NOT the first-sync wait below: a wait that times out
4618
+ // rejects with the retryable StrandAwaitingFirstSyncError and deliberately leaves the
4619
+ // instance running, so there is nothing to re-offer and forgetting it would only have
4620
+ // the watcher re-enter a launch for a strand that is already up.
4621
+ let instance: StrandInstance;
4622
+ try {
4623
+ instance = await this.launchStrand(strandRow, sAppConfig, founder, partyMemberPrivateKey);
4624
+ } catch (error) {
4625
+ // Hand the strand back to the watcher's retry ladder. Harmless on the founder path
4626
+ // (`foundStrand` → `publishStrand` + this call), where the watcher may never have
4627
+ // offered the strand: the `knownStrands` delete is a no-op and the recorded backoff
4628
+ // only delays the watcher's own first attempt by one poll interval, which is what
4629
+ // should happen right after an attempt that just failed.
4630
+ //
4631
+ // NOTE: the retry relaunches from the CONTROL row plus the registered config, not
4632
+ // from the row and arguments passed here — so a caller that enriched either (a
4633
+ // synthetic `MemberPrivateKey`, an explicit `founder` or `partyMemberPrivateKey`)
4634
+ // is retried with less than it asked for. Inert today: founder-ness re-derives from
4635
+ // `FounderOwnerKey`, the party key re-reads from `StrandPartyKey`, and the row's
4636
+ // shared `MemberPrivateKey` is read only by the founder bootstrap. If a joiner
4637
+ // launch ever starts depending on that key, gate this hand-back on the passed row
4638
+ // matching the control one.
4639
+ this.strandWatcher?.forgetStrand(strandRow.Id);
4640
+ throw error;
4641
+ }
4567
4642
 
4568
4643
  // The first-sync write gate: a JOINING machine's database is withheld until it has
4569
4644
  // received the strand's Header from another member, because a write before that
@@ -4655,7 +4730,8 @@ export class CadreNode implements SAppIdLookup {
4655
4730
  * @returns The live `Strand` row — the one just inserted, or the matching one already
4656
4731
  * there. A closed strand's caller should carry THIS row's `MemberPrivateKey` forward:
4657
4732
  * on a repeat it is the stored key, not the argument.
4658
- * @throws if the node is not started, exposes no owner signing key, the id is blank, a
4733
+ * @throws if the node is not started, exposes no owner signing key, the id is blank or
4734
+ * unusable as a storage scope key (`InvalidStrandIdError` — see `storage-scope.ts`), a
4659
4735
  * row with the same id holds different content, or the control DB rejects the
4660
4736
  * (unauthorized) insert.
4661
4737
  */
@@ -4664,6 +4740,11 @@ export class CadreNode implements SAppIdLookup {
4664
4740
  // Trim/reject here so the id that lands matches the one unpublishStrand looks up: it
4665
4741
  // trims too, and an untrimmed row would be unreachable by the same string.
4666
4742
  const trimmed = requireNonBlank(strandId, 'strand id');
4743
+ // Refuse an unusable id HERE, not only at launch. `addStrand` already asserts, but it
4744
+ // runs after this row is written, and this row replicates: an id no node can turn into
4745
+ // a storage scope key would otherwise reach the whole party's control database and be
4746
+ // declined once per member. Asserted after the trim so the id checked is the id stored.
4747
+ assertStrandScopeKey(trimmed);
4667
4748
  // FounderOwnerKey records THIS machine as the row's publisher (the schema pins it to
4668
4749
  // the signing owner), which is what later lets a relaunch derive founder-ness.
4669
4750
  const desired: StrandRow = {
@@ -4924,13 +5005,20 @@ export class CadreNode implements SAppIdLookup {
4924
5005
  // one never cleared).
4925
5006
  await this.strandManager.clearOwnMemberPeerBinding(trimmed);
4926
5007
  // Converge locally now rather than waiting up to a poll interval. The watcher fires
4927
- // onStrandRemoved for a strand it tracked; the explicit stop below covers a node whose
4928
- // strandFilter never admitted this strand (the watcher never knew it, so it will never
4929
- // fire). StrandInstanceManager.stopStrand no-ops on an unknown id, so the two paths
4930
- // cannot double-stop into an error.
5008
+ // onStrandRemoved for a strand it tracked; the explicit teardown below covers a node
5009
+ // whose strandFilter never admitted this strand (the watcher never knew it, so it will
5010
+ // never fire). StrandInstanceManager.stopStrand no-ops on an unknown id, so the two
5011
+ // paths cannot double-stop into an error.
5012
+ //
5013
+ // detachStrand, NOT stopStrand: this is a party-wide removal, not a local abandonment
5014
+ // of a strand that still exists, so it must not SUPPRESS the id in the watcher. The
5015
+ // row is already gone — there is nothing to suppress — and an id re-published before
5016
+ // the next poll's suppression cleanup would then never be offered again this session.
5017
+ // (Same reasoning as handleStrandRemoved, which also avoids stopStrand's `_running`
5018
+ // guard; here the guard is already satisfied by requireOwnerSigningKey above.)
4931
5019
  await this.strandWatcher?.forcePoll();
4932
5020
  if (this.strandManager.getInstance(trimmed)) {
4933
- await this.stopStrand(trimmed);
5021
+ await this.detachStrand(trimmed);
4934
5022
  }
4935
5023
  log('Unpublished strand %s from control DB under owner %s', trimmed, signingKey.publicKeyB64);
4936
5024
  }
@@ -5861,17 +5949,21 @@ export class CadreNode implements SAppIdLookup {
5861
5949
  * Stop a strand on THIS node only: untrack it from hibernation, drop its sApp config,
5862
5950
  * stop the local instance, and emit `strand:stopped`. The shared `Strand` row is left
5863
5951
  * intact, so on the next node RESTART the strand is rediscovered and surfaces as
5864
- * `strand:discovered` again — not on the next watcher poll, which never re-offers a
5865
- * strand it has already seen (`StrandWatcher.knownStrands` retains the id for the life
5866
- * of the process). The stop also drops the strand from {@link getDiscoveredStrands},
5867
- * so a drain cannot undo a deliberate stop. Party-wide removal is
5868
- * {@link unpublishStrand}.
5952
+ * `strand:discovered` again — but never again in THIS session: the stop suppresses the
5953
+ * id in the watcher (`StrandWatcher.suppressStrand`) and drops it from
5954
+ * {@link getDiscoveredStrands}, so neither a later poll nor a drain can undo a
5955
+ * deliberate stop. Only an explicit {@link addStrand} does, which is the caller
5956
+ * reversing its own decision. Party-wide removal is {@link unpublishStrand}.
5869
5957
  */
5870
5958
  async stopStrand(strandId: string): Promise<void> {
5871
5959
  if (!this._running) {
5872
5960
  throw new Error('CadreNode not running');
5873
5961
  }
5874
5962
 
5963
+ // Suppressed HERE and not in detachStrand: the other caller of that method is
5964
+ // `handleStrandRemoved`, which arrives precisely because the control row is gone, and
5965
+ // a row that later reappears is a fresh strand that should be offered again.
5966
+ this.strandWatcher?.suppressStrand(strandId);
5875
5967
  await this.detachStrand(strandId);
5876
5968
  }
5877
5969
 
@@ -5893,6 +5985,10 @@ export class CadreNode implements SAppIdLookup {
5893
5985
  * readings are the intended one: `handleStrandRemoved` arrives because the control row
5894
5986
  * is gone, and an explicit {@link stopStrand} is a deliberate abandonment — neither
5895
5987
  * strand may be re-offered to a later `getDiscoveredStrands()` drain.
5988
+ *
5989
+ * The watcher-level suppression that makes a stop permanent is deliberately NOT here,
5990
+ * only in {@link stopStrand}: the two callers diverge on it. A vanished control row
5991
+ * that reappears is a fresh strand and must be offered again.
5896
5992
  */
5897
5993
  private async detachStrand(strandId: string): Promise<void> {
5898
5994
  this.hibernationManager.untrackStrand(strandId);
@@ -1,6 +1,6 @@
1
1
  import debug from 'debug';
2
- import { randomBytes } from '@optimystic/quereus-plugin-crypto';
3
2
  import { FormationAbortedError, type ControlDatabase } from './control-database.js';
3
+ import { mintStrandId } from './strand-id.js';
4
4
  import {
5
5
  createHttpFormationApprover,
6
6
  verifyFormationApproval,
@@ -252,10 +252,10 @@ export class ControlFormationUsageRecorder implements FormationUsageRecorder {
252
252
  * Provision a NEW strand for an UNBOUND invite and record consent against it in ONE
253
253
  * transaction (the responder-provisions fallback, now single-use-enforced).
254
254
  *
255
- * Mints a fresh, globally-unique strand id from {@link randomBytes} — the same
256
- * cross-platform CSPRNG `control-database`'s `generateStampId` uses, NOT
257
- * `crypto.randomUUID` / `Date.now` / `Math.random` (not uniformly available across
258
- * node/browser/RN) — then delegates to {@link ControlDatabase.redeemInvitation}, whose
255
+ * Mints a fresh, globally-unique strand id with {@link mintStrandId} — an
256
+ * unguessable CSPRNG id, and one `strand-id.ts` has already checked is usable as a
257
+ * storage scope key and network name — then delegates to
258
+ * {@link ControlDatabase.redeemInvitation}, whose
259
259
  * single `begin … commit` inserts the consent-authorized `Strand` row AND the matching
260
260
  * `FormationUsage` row together (both deferred CHECKs see both rows at commit). That one
261
261
  * `FormationUsage` row makes the unbound redemption single-use exactly like the bound
@@ -289,7 +289,7 @@ export class ControlFormationUsageRecorder implements FormationUsageRecorder {
289
289
  if (signal?.aborted) {
290
290
  throw new FormationAbortedError(token, 'redemption');
291
291
  }
292
- const strandId = `strand-${randomBytes(128, 'hex') as string}`;
292
+ const strandId = mintStrandId();
293
293
  const invite = await this.controlDatabase.queryFormationInvite(token);
294
294
  const approval = await this.obtainApproval({
295
295
  token, usageStampId, strandId, peerKey, disclosure,