@serfab/cadre-core 1.3.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.
- package/README.md +5 -3
- package/dist/cadre-node.d.ts +32 -19
- package/dist/cadre-node.js +120 -31
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-formation-recorder.d.ts +4 -4
- package/dist/control-formation-recorder.js +6 -6
- package/dist/control-formation-recorder.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/membership-connection-gater.d.ts +18 -5
- package/dist/membership-connection-gater.js +50 -10
- package/dist/membership-connection-gater.js.map +1 -1
- package/dist/storage-scope.d.ts +50 -12
- package/dist/storage-scope.js +85 -12
- package/dist/storage-scope.js.map +1 -1
- package/dist/strand-formation-manager.js +2 -1
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-id.d.ts +35 -0
- package/dist/strand-id.js +46 -0
- package/dist/strand-id.js.map +1 -0
- package/dist/strand-instance-manager.js +19 -2
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-network-config.d.ts +3 -3
- package/dist/strand-network-config.js +3 -3
- package/dist/strand-solicitation.js +2 -1
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-watcher.d.ts +45 -0
- package/dist/strand-watcher.js +74 -13
- package/dist/strand-watcher.js.map +1 -1
- package/dist/types.d.ts +94 -15
- package/dist/types.js +59 -0
- package/dist/types.js.map +1 -1
- package/package.json +7 -7
- package/src/cadre-node.ts +126 -31
- package/src/control-formation-recorder.ts +6 -6
- package/src/index.ts +563 -554
- package/src/membership-connection-gater.ts +56 -11
- package/src/storage-scope.ts +163 -78
- package/src/strand-formation-manager.ts +647 -646
- package/src/strand-id.ts +48 -0
- package/src/strand-instance-manager.ts +20 -2
- package/src/strand-network-config.ts +250 -250
- package/src/strand-solicitation.ts +500 -499
- package/src/strand-watcher.ts +388 -325
- package/src/types.ts +97 -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, NoiseCryptoInterface } 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
|
|
95
|
-
*
|
|
96
|
-
* `
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* `
|
|
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`).
|
|
@@ -358,6 +358,22 @@ export interface NetworkConfig {
|
|
|
358
358
|
* `NodeOptions.noiseCrypto`.
|
|
359
359
|
*/
|
|
360
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;
|
|
361
377
|
/**
|
|
362
378
|
* Optional async resolver returning the multiaddrs to embed in invites
|
|
363
379
|
* (and other owner-address contexts). When unset, `libp2pNode.getMultiaddrs()`
|
|
@@ -438,6 +454,63 @@ export interface NetworkConfig {
|
|
|
438
454
|
strandAddrRefreshMs?: number;
|
|
439
455
|
};
|
|
440
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
|
+
}>;
|
|
441
514
|
/**
|
|
442
515
|
* Hibernation configuration
|
|
443
516
|
*/
|
|
@@ -1164,13 +1237,19 @@ export interface CadreNodeEvents {
|
|
|
1164
1237
|
* Carries the full {@link StrandRow} so the app can join without re-querying
|
|
1165
1238
|
* the control DB.
|
|
1166
1239
|
*
|
|
1167
|
-
* **Fired
|
|
1168
|
-
*
|
|
1169
|
-
*
|
|
1170
|
-
*
|
|
1171
|
-
*
|
|
1172
|
-
*
|
|
1173
|
-
*
|
|
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.
|
|
1174
1253
|
*
|
|
1175
1254
|
* So an app that auto-joins discovered strands must **subscribe first, then
|
|
1176
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;
|
|
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.
|
|
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.
|
|
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.
|
|
92
|
-
"@optimystic/db-p2p": "^1.
|
|
93
|
-
"@optimystic/quereus-plugin-crypto": "^1.
|
|
94
|
-
"@optimystic/quereus-plugin-optimystic": "^1.
|
|
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.
|
|
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 {
|
|
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
|
|
537
|
-
*
|
|
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;
|
|
@@ -1650,6 +1669,11 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1650
1669
|
...(identityKey && { privateKey: identityKey }),
|
|
1651
1670
|
...(network?.transports && { transports: network.transports }),
|
|
1652
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,
|
|
1653
1677
|
// `{ wsPort }` when a listen entry names WebSocket, otherwise `{}` — and always
|
|
1654
1678
|
// `{}` when `network.transports` is set, since the embedder owns transport policy
|
|
1655
1679
|
// then. Spread NEXT to `transports` because the two answer the same question.
|
|
@@ -4069,6 +4093,24 @@ export class CadreNode implements SAppIdLookup {
|
|
|
4069
4093
|
private async handleStrandAdded(strand: StrandRow): Promise<void> {
|
|
4070
4094
|
log('Handling strand added from control network: %s', strand.Id);
|
|
4071
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
|
+
|
|
4072
4114
|
// Check if we have sApp config for this strand
|
|
4073
4115
|
const sAppConfig = this.sAppConfigs.get(strand.Id);
|
|
4074
4116
|
if (!sAppConfig) {
|
|
@@ -4080,10 +4122,12 @@ export class CadreNode implements SAppIdLookup {
|
|
|
4080
4122
|
// already present) keeps auto-starting below, unchanged.
|
|
4081
4123
|
//
|
|
4082
4124
|
// Recorded BEFORE the emit so a handler that synchronously drains
|
|
4083
|
-
// `getDiscoveredStrands()` sees this strand too. The watcher
|
|
4084
|
-
//
|
|
4085
|
-
//
|
|
4086
|
-
//
|
|
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.
|
|
4087
4131
|
this.discoveredStrands.set(strand.Id, strand);
|
|
4088
4132
|
this.emit('strand:discovered', { strandId: strand.Id, strand });
|
|
4089
4133
|
return;
|
|
@@ -4523,17 +4567,17 @@ export class CadreNode implements SAppIdLookup {
|
|
|
4523
4567
|
*
|
|
4524
4568
|
* A rejected call leaves nothing running but DOES leave the sApp config
|
|
4525
4569
|
* registered, deliberately: both an explicit retry and the {@link StrandWatcher}'s
|
|
4526
|
-
* automatic relaunch need it.
|
|
4527
|
-
*
|
|
4528
|
-
*
|
|
4529
|
-
*
|
|
4530
|
-
*
|
|
4531
|
-
*
|
|
4532
|
-
*
|
|
4533
|
-
*
|
|
4534
|
-
*
|
|
4535
|
-
*
|
|
4536
|
-
*
|
|
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.
|
|
4537
4581
|
*/
|
|
4538
4582
|
async addStrand(config: StrandConfig): Promise<StrandInstance> {
|
|
4539
4583
|
if (!this._running) {
|
|
@@ -4541,6 +4585,9 @@ export class CadreNode implements SAppIdLookup {
|
|
|
4541
4585
|
}
|
|
4542
4586
|
|
|
4543
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);
|
|
4544
4591
|
if (partyMemberPrivateKey !== undefined && strandRow.Type !== 'c') {
|
|
4545
4592
|
// Only a closed strand seats a Member/Manager, so an open launch would drop this
|
|
4546
4593
|
// key silently. Refuse instead: the caller has confused the party identity key
|
|
@@ -4556,6 +4603,9 @@ export class CadreNode implements SAppIdLookup {
|
|
|
4556
4603
|
// unclaimed backlog — a later `getDiscoveredStrands()` drain must not re-offer it.
|
|
4557
4604
|
this.sAppConfigs.set(strandRow.Id, sAppConfig);
|
|
4558
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);
|
|
4559
4609
|
log('Registered sAppConfig for strand %s (sApp: %s, founder: %s)',
|
|
4560
4610
|
strandRow.Id, sAppConfig.id, founder ?? 'derived');
|
|
4561
4611
|
|
|
@@ -4564,7 +4614,31 @@ export class CadreNode implements SAppIdLookup {
|
|
|
4564
4614
|
// formation/responder flows pass one deliberately, since their consent-seated rows
|
|
4565
4615
|
// carry a null column. See StrandConfig.founder. Same rule for the explicit
|
|
4566
4616
|
// partyMemberPrivateKey: it wins over the StrandPartyKey control-row read.
|
|
4567
|
-
|
|
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
|
+
}
|
|
4568
4642
|
|
|
4569
4643
|
// The first-sync write gate: a JOINING machine's database is withheld until it has
|
|
4570
4644
|
// received the strand's Header from another member, because a write before that
|
|
@@ -4656,7 +4730,8 @@ export class CadreNode implements SAppIdLookup {
|
|
|
4656
4730
|
* @returns The live `Strand` row — the one just inserted, or the matching one already
|
|
4657
4731
|
* there. A closed strand's caller should carry THIS row's `MemberPrivateKey` forward:
|
|
4658
4732
|
* on a repeat it is the stored key, not the argument.
|
|
4659
|
-
* @throws if the node is not started, exposes no owner signing key, the id is blank
|
|
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
|
|
4660
4735
|
* row with the same id holds different content, or the control DB rejects the
|
|
4661
4736
|
* (unauthorized) insert.
|
|
4662
4737
|
*/
|
|
@@ -4665,6 +4740,11 @@ export class CadreNode implements SAppIdLookup {
|
|
|
4665
4740
|
// Trim/reject here so the id that lands matches the one unpublishStrand looks up: it
|
|
4666
4741
|
// trims too, and an untrimmed row would be unreachable by the same string.
|
|
4667
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);
|
|
4668
4748
|
// FounderOwnerKey records THIS machine as the row's publisher (the schema pins it to
|
|
4669
4749
|
// the signing owner), which is what later lets a relaunch derive founder-ness.
|
|
4670
4750
|
const desired: StrandRow = {
|
|
@@ -4925,13 +5005,20 @@ export class CadreNode implements SAppIdLookup {
|
|
|
4925
5005
|
// one never cleared).
|
|
4926
5006
|
await this.strandManager.clearOwnMemberPeerBinding(trimmed);
|
|
4927
5007
|
// Converge locally now rather than waiting up to a poll interval. The watcher fires
|
|
4928
|
-
// onStrandRemoved for a strand it tracked; the explicit
|
|
4929
|
-
// strandFilter never admitted this strand (the watcher never knew it, so it will
|
|
4930
|
-
// fire). StrandInstanceManager.stopStrand no-ops on an unknown id, so the two
|
|
4931
|
-
// 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.)
|
|
4932
5019
|
await this.strandWatcher?.forcePoll();
|
|
4933
5020
|
if (this.strandManager.getInstance(trimmed)) {
|
|
4934
|
-
await this.
|
|
5021
|
+
await this.detachStrand(trimmed);
|
|
4935
5022
|
}
|
|
4936
5023
|
log('Unpublished strand %s from control DB under owner %s', trimmed, signingKey.publicKeyB64);
|
|
4937
5024
|
}
|
|
@@ -5862,17 +5949,21 @@ export class CadreNode implements SAppIdLookup {
|
|
|
5862
5949
|
* Stop a strand on THIS node only: untrack it from hibernation, drop its sApp config,
|
|
5863
5950
|
* stop the local instance, and emit `strand:stopped`. The shared `Strand` row is left
|
|
5864
5951
|
* intact, so on the next node RESTART the strand is rediscovered and surfaces as
|
|
5865
|
-
* `strand:discovered` again —
|
|
5866
|
-
*
|
|
5867
|
-
*
|
|
5868
|
-
*
|
|
5869
|
-
* {@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}.
|
|
5870
5957
|
*/
|
|
5871
5958
|
async stopStrand(strandId: string): Promise<void> {
|
|
5872
5959
|
if (!this._running) {
|
|
5873
5960
|
throw new Error('CadreNode not running');
|
|
5874
5961
|
}
|
|
5875
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);
|
|
5876
5967
|
await this.detachStrand(strandId);
|
|
5877
5968
|
}
|
|
5878
5969
|
|
|
@@ -5894,6 +5985,10 @@ export class CadreNode implements SAppIdLookup {
|
|
|
5894
5985
|
* readings are the intended one: `handleStrandRemoved` arrives because the control row
|
|
5895
5986
|
* is gone, and an explicit {@link stopStrand} is a deliberate abandonment — neither
|
|
5896
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.
|
|
5897
5992
|
*/
|
|
5898
5993
|
private async detachStrand(strandId: string): Promise<void> {
|
|
5899
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
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
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 =
|
|
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,
|