@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.
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 +120 -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 +19 -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 +94 -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 +126 -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 +20 -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 +97 -15
@@ -88,9 +88,10 @@
88
88
  * annex the party's relay capacity.
89
89
  * - An `'admit-for-relay'` connection that is NOT reserving is dropped: it has
90
90
  * {@link RELAY_ADMISSION_RESERVE_DEADLINE_MS} to get a reservation ADMITTED
91
- * at that hook, after which the underlying connection is aborted. The
92
- * guarantee above thus weakens on relay-enabled nodes from "never in the
93
- * conversation" to "in it briefly, and can speak nothing".
91
+ * at that hook, after which the gate CLOSES the underlying connection so
92
+ * both ends let go of the socket. The guarantee above thus weakens on
93
+ * relay-enabled nodes from "never in the conversation" to "in it briefly,
94
+ * and can speak nothing".
94
95
  *
95
96
  * The deadline clears on reservation ADMISSION, not on the reservation's own
96
97
  * success: this gate cannot observe the server-side `reserve()` outcome, so a
@@ -158,6 +159,7 @@ import debug from 'debug';
158
159
  import type { ConnectionGater, PeerId, MultiaddrConnection } from '@libp2p/interface';
159
160
  import { SEED_PROTOCOL } from './seed-bootstrap.js';
160
161
  import { FORMATION_PROTOCOL } from './strand-formation-protocol.js';
162
+ import { withDeadline } from './control-stream.js';
161
163
 
162
164
  const log = debug('sereus:cadre:connection-gater');
163
165
 
@@ -197,7 +199,7 @@ export const ADMISSION_DECISION_TIMEOUT_MS = 2_000;
197
199
  /**
198
200
  * How long an `'admit-for-relay'` connection may exist without a relay
199
201
  * reservation being ADMITTED at the `denyInboundRelayReservation` hook, after
200
- * which the gate aborts the underlying connection. A reserving client asks for
202
+ * which the gate closes the underlying connection. A reserving client asks for
201
203
  * its slot immediately after the connection upgrades (`relay-reservation.ts`
202
204
  * dials and requests in one drive; a strand node's configured circuit listener
203
205
  * does the same from inside its own `listen()`), so a connection idle past this
@@ -205,6 +207,19 @@ export const ADMISSION_DECISION_TIMEOUT_MS = 2_000;
205
207
  */
206
208
  export const RELAY_ADMISSION_RESERVE_DEADLINE_MS = 5_000;
207
209
 
210
+ /**
211
+ * Bound on the graceful close of an expired relay-only connection. It exists
212
+ * because `AbstractMultiaddrConnection.close()` awaits an `idle`/`drain` event
213
+ * when the connection still has unsent bytes, and that wait has no timeout of
214
+ * its own — an unsignalled one never ends. This gate writes nothing to a
215
+ * stranger, so the wait is not reachable today; the bound is here so a timer
216
+ * callback can never hold an unending await.
217
+ *
218
+ * Applied through {@link withDeadline}, not `AbortSignal.timeout`: the latter is
219
+ * not reliably present on React Native/Hermes, which loads this same module.
220
+ */
221
+ export const RELAY_ADMISSION_CLOSE_TIMEOUT_MS = 2_000;
222
+
208
223
  /**
209
224
  * Default cap on concurrent relay reservations held by peers the membership
210
225
  * check could not place (see {@link UnauthorizedReservationBudget}). Small on
@@ -351,7 +366,7 @@ export class UnauthorizedReservationBudget {
351
366
  *
352
367
  * An `'admit-for-relay'` verdict admits the connection and arms a
353
368
  * `reserveDeadlineMs` timer against it; the timer is disarmed when a
354
- * reservation for that peer is ADMITTED at the reservation hook, and aborts the
369
+ * reservation for that peer is ADMITTED at the reservation hook, and closes the
355
370
  * underlying `MultiaddrConnection` when it fires first.
356
371
  *
357
372
  * NOTE: `base` is spread, so a gater passed as a CLASS INSTANCE would lose its
@@ -470,7 +485,7 @@ export async function decideWithinDeadline<T>(
470
485
  }
471
486
  }
472
487
 
473
- /** One armed not-reserving deadline: the connection it will abort, and its timer. */
488
+ /** One armed not-reserving deadline: the connection it will close, and its timer. */
474
489
  interface PendingReserveDeadline {
475
490
  maConn: MultiaddrConnection;
476
491
  timer: ReturnType<typeof setTimeout>;
@@ -479,11 +494,11 @@ interface PendingReserveDeadline {
479
494
  /**
480
495
  * The not-reserving deadlines for `'admit-for-relay'` connections, keyed by
481
496
  * remote peerId (a Set per peer — one peer can hold several in-flight
482
- * connections). `arm` starts a timer that aborts the connection; `disarm`
497
+ * connections). `arm` starts a timer that closes the connection; `disarm`
483
498
  * (called when a reservation for that peer is admitted) cancels every pending
484
499
  * timer for the peer. Timers are unref'd so an armed deadline never holds a
485
500
  * process open, and a timer that fires against an already-closed connection is
486
- * a swallowed no-op.
501
+ * a no-op (`close()` returns at once on any status but `open`).
487
502
  *
488
503
  * Disarming is final for the connections it cancelled: a peer that reserved
489
504
  * once and then lets its reservation lapse keeps a mute connection (the
@@ -528,11 +543,41 @@ class PendingReserveDeadlines {
528
543
  if (entries?.size === 0) {
529
544
  this.byPeer.delete(remotePeerId);
530
545
  }
531
- log('Relay-only admission expired for %s — no reservation admitted within %dms, aborting the connection', remotePeerId, this.deadlineMs);
546
+ log('Relay-only admission expired for %s — no reservation admitted within %dms, closing the connection', remotePeerId, this.deadlineMs);
547
+ void this.drop(remotePeerId, entry.maConn);
548
+ }
549
+
550
+ /**
551
+ * End an expired relay-only connection: a bounded graceful close, with
552
+ * `abort()` only as the escalation. The order cannot be reversed — `abort()`
553
+ * marks the connection `aborted`, and `close()` returns immediately on any
554
+ * status that is not `open`, so aborting first would make the close a silent
555
+ * no-op. Closing a connection the peer already dropped returns at once
556
+ * without throwing, so the fallback stays unreached in the ordinary case.
557
+ */
558
+ private async drop(remotePeerId: string, maConn: MultiaddrConnection): Promise<void> {
559
+ try {
560
+ await withDeadline(
561
+ RELAY_ADMISSION_CLOSE_TIMEOUT_MS,
562
+ `relay-only close for ${remotePeerId}`,
563
+ (signal) => maConn.close({ signal })
564
+ );
565
+ return;
566
+ } catch (error) {
567
+ log('Closing the expired relay-only connection from %s failed — aborting: %o', remotePeerId, error);
568
+ }
569
+ // NOTE: this fallback cannot actually free a WebSocket. `@libp2p/websockets`'
570
+ // `sendReset()` calls `websocket.close(1006)`, and 1006 is a reserved code
571
+ // RFC 6455 forbids an endpoint from sending, so `ws` throws and
572
+ // `AbstractMessageStream.abort()` swallows it — the local end flips to
573
+ // `aborted` while the socket stays up. Measured against
574
+ // @libp2p/websockets@10.1.3 (see tickets/blocked/report-libp2p-websockets-abort-close-code.md).
575
+ // `MultiaddrConnection` exposes no way down to the raw socket, so this is as
576
+ // far as this layer can go. Revisit — drop the fallback, or go back to a
577
+ // plain `abort()` — once that transport sends a legal reset code.
532
578
  try {
533
- entry.maConn.abort(new Error(`relay-only admission expired: no relay reservation admitted within ${this.deadlineMs}ms`));
579
+ maConn.abort(new Error(`relay-only admission expired: no relay reservation admitted within ${this.deadlineMs}ms`));
534
580
  } catch (error) {
535
- // Aborting a connection that already closed on its own is not a failure.
536
581
  log('Aborting the expired relay-only connection from %s threw: %o', remotePeerId, error);
537
582
  }
538
583
  }
@@ -1,78 +1,163 @@
1
- /**
2
- * storage-scope.ts — the scope keys `CadreNodeConfig.storage.provider` is called with.
3
- *
4
- * A provider is a factory cadre-core invokes once per **scope**: once for the control
5
- * database and once for each strand. A strand's scope key is its strand id; the control
6
- * database's scope key is what this module mints.
7
- *
8
- * WHY THE CONTROL SCOPE CARRIES THE PARTY ID. The control database holds the party's
9
- * own records — its strands, owner keys, peers, invitations, revocations. Two parties
10
- * on one device must therefore not share one control store, or a node started for
11
- * party B reads party A's rows as if party A had written them for B. Every other
12
- * party-scoped store in this codebase already knows this: `PersistentTrustedOwnerStore`,
13
- * `PersistentBootstrapPeerStore` and `PersistentEnrolledMachineStore` each take a
14
- * `partyId` and fail closed on a mismatch. Putting the party id in the key here means
15
- * no embedder has to know it belongs there, and none can get it wrong.
16
- *
17
- * WHY IT IS ENCODED. A party id is arbitrary text — nothing in cadre-core validates
18
- * its shape, and the React Native reference app lets a user type one into Settings.
19
- * Scope keys reach real namespaces unescaped: cadre-cli builds `${config.path}/${scope}`,
20
- * React Native a LevelDB filename, the browser an IndexedDB database name. A party id
21
- * containing `/` or `..` would escape the CLI's storage directory. Base64url keeps every
22
- * key inside `[A-Za-z0-9._-]` — the charset embedders already rely on, and the same
23
- * encoding the React Native app's `secureStoreKeySegment` uses on a party id for the
24
- * same reason.
25
- *
26
- * THE STRAND ARM OF THAT CHARSET RULE IS NOT YET ENFORCED. The strand scope key is
27
- * `StrandRow.Id`, and the ids cadre-core itself mints are `strand-<digits>-<base36>`
28
- * / `strand-<hex>`, which satisfy the charset. But a strand row replicated into the
29
- * control database by another node in the party carries whatever id THAT node wrote,
30
- * and nothing validates its shape before `resolveStrandStorage` hands it to the
31
- * provider. See `tickets/backlog/bug-strand-scope-key-charset-unenforced`.
32
- */
33
-
34
- import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
35
-
36
- /**
37
- * Prefix marking a scope key as a control database rather than a strand.
38
- *
39
- * Not exported: `controlStorageScope` mints these keys and `isControlStorageScope`
40
- * recognizes them, which is the whole surface a caller needs.
41
- */
42
- const CONTROL_SCOPE_PREFIX = 'control-';
43
-
44
- /**
45
- * The storage scope key for a party's control database.
46
- *
47
- * Returns `control-` followed by the base64url encoding of `partyId`'s UTF-8 bytes,
48
- * so the whole key stays within `[A-Za-z0-9._-]` and is safe to use directly as a
49
- * file name, directory name or database name. See the module comment for why the
50
- * encoding is load-bearing rather than decorative.
51
- *
52
- * To read a party id back off a device — from, say, a LevelDB file named
53
- * `sereus-control-MTExMTExMTEtMjIyMi00MzMzLTg0NDQtNTU1NTU1NTU1NTU1` — in a browser or
54
- * Node console, with no dependency on this package:
55
- * ```js
56
- * const b64 = key.slice('control-'.length).replace(/-/g, '+').replace(/_/g, '/');
57
- * new TextDecoder().decode(Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)))
58
- * ```
59
- * The `TextDecoder` step is not optional: `atob` alone yields one character per BYTE,
60
- * so a party id with any non-ASCII character in it decodes to mojibake.
61
- */
62
- export function controlStorageScope(partyId: string): string {
63
- return CONTROL_SCOPE_PREFIX + uint8ArrayToString(uint8ArrayFromString(partyId, 'utf8'), 'base64url');
64
- }
65
-
66
- /**
67
- * Whether a scope key names a control database rather than a strand.
68
- *
69
- * A prefix test. Safe as one against the strand ids cadre-core mints, which are
70
- * `strand-`-prefixed (`strand-formation-manager.ts`, `strand-solicitation.ts`,
71
- * `control-formation-recorder.ts`) and so can never collide. It is NOT a security
72
- * boundary: a strand id replicated in from another node is unvalidated text that
73
- * could begin `control-`, which is one more reason to close the gap the module
74
- * comment names.
75
- */
76
- export function isControlStorageScope(scope: string): boolean {
77
- return scope.startsWith(CONTROL_SCOPE_PREFIX);
78
- }
1
+ /**
2
+ * storage-scope.ts — the scope keys `CadreNodeConfig.storage.provider` is called with.
3
+ *
4
+ * A provider is a factory cadre-core invokes once per **scope**: once for the control
5
+ * database and once for each strand. A strand's scope key is its strand id; the control
6
+ * database's scope key is what this module mints.
7
+ *
8
+ * WHY THE CONTROL SCOPE CARRIES THE PARTY ID. The control database holds the party's
9
+ * own records — its strands, owner keys, peers, invitations, revocations. Two parties
10
+ * on one device must therefore not share one control store, or a node started for
11
+ * party B reads party A's rows as if party A had written them for B. Every other
12
+ * party-scoped store in this codebase already knows this: `PersistentTrustedOwnerStore`,
13
+ * `PersistentBootstrapPeerStore` and `PersistentEnrolledMachineStore` each take a
14
+ * `partyId` and fail closed on a mismatch. Putting the party id in the key here means
15
+ * no embedder has to know it belongs there, and none can get it wrong.
16
+ *
17
+ * WHY IT IS ENCODED. A party id is arbitrary text — nothing in cadre-core validates
18
+ * its shape, and the React Native reference app lets a user type one into Settings.
19
+ * Scope keys reach real namespaces unescaped: cadre-cli builds `${config.path}/${scope}`,
20
+ * React Native a LevelDB filename, the browser an IndexedDB database name. A party id
21
+ * containing `/` or `..` would escape the CLI's storage directory. Base64url keeps every
22
+ * key inside `[A-Za-z0-9._-]` — the charset embedders already rely on, and the same
23
+ * encoding the React Native app's `secureStoreKeySegment` uses on a party id for the
24
+ * same reason.
25
+ *
26
+ * THE STRAND ARM OF THAT CHARSET RULE IS ENFORCED HERE. A strand's scope key is
27
+ * `StrandRow.Id`, and a strand row replicated into the control database by another
28
+ * node in the party carries whatever id THAT node wrote. `assertStrandScopeKey`
29
+ * (below) is the check that makes the charset true of strand keys too; it runs
30
+ * unconditionally at the top of `StrandInstanceManager.startStrand`, so no strand id
31
+ * reaches an embedder's provider — or a libp2p protocol prefix — unvalidated. Ids
32
+ * cadre-core mints come from `strand-id.ts`, which is built on the same predicate.
33
+ */
34
+
35
+ import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
36
+
37
+ /**
38
+ * Prefix marking a scope key as a control database rather than a strand.
39
+ *
40
+ * Not exported: `controlStorageScope` mints these keys and `isControlStorageScope`
41
+ * recognizes them, which is the whole surface a caller needs.
42
+ */
43
+ const CONTROL_SCOPE_PREFIX = 'control-';
44
+
45
+ /**
46
+ * The storage scope key for a party's control database.
47
+ *
48
+ * Returns `control-` followed by the base64url encoding of `partyId`'s UTF-8 bytes,
49
+ * so the whole key stays within `[A-Za-z0-9._-]` and is safe to use directly as a
50
+ * file name, directory name or database name. See the module comment for why the
51
+ * encoding is load-bearing rather than decorative.
52
+ *
53
+ * To read a party id back off a device — from, say, a LevelDB file named
54
+ * `sereus-control-MTExMTExMTEtMjIyMi00MzMzLTg0NDQtNTU1NTU1NTU1NTU1` — in a browser or
55
+ * Node console, with no dependency on this package:
56
+ * ```js
57
+ * const b64 = key.slice('control-'.length).replace(/-/g, '+').replace(/_/g, '/');
58
+ * new TextDecoder().decode(Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)))
59
+ * ```
60
+ * The `TextDecoder` step is not optional: `atob` alone yields one character per BYTE,
61
+ * so a party id with any non-ASCII character in it decodes to mojibake.
62
+ */
63
+ export function controlStorageScope(partyId: string): string {
64
+ return CONTROL_SCOPE_PREFIX + uint8ArrayToString(uint8ArrayFromString(partyId, 'utf8'), 'base64url');
65
+ }
66
+
67
+ /**
68
+ * Whether a scope key names a control database rather than a strand.
69
+ *
70
+ * A prefix test, and a sound one: a strand scope key can never begin `control-`,
71
+ * because {@link isValidStrandScopeKey} rejects that prefix and
72
+ * {@link assertStrandScopeKey} runs on every strand launch.
73
+ */
74
+ export function isControlStorageScope(scope: string): boolean {
75
+ return scope.startsWith(CONTROL_SCOPE_PREFIX);
76
+ }
77
+
78
+ /**
79
+ * The longest strand scope key accepted.
80
+ *
81
+ * The longest id cadre-core mints is 39 characters (`strand-` plus the 32 hex
82
+ * characters of `randomBytes(128, 'hex')` — 128 BITS, so 16 bytes), so this leaves
83
+ * ample room while keeping a key clear of the 255-BYTE limit every mainstream
84
+ * filesystem puts on one path component. The charset is ASCII, so characters and
85
+ * bytes are the same count here.
86
+ */
87
+ const MAX_STRAND_SCOPE_KEY_LENGTH = 128;
88
+
89
+ /** The charset every scope key stays within — see the module comment. */
90
+ const SCOPE_KEY_CHARSET = /^[A-Za-z0-9._-]+$/;
91
+
92
+ /**
93
+ * Whether a strand id is usable as a storage scope key and as a libp2p network name.
94
+ *
95
+ * A strand id is not minted locally in the general case — a strand row replicates in
96
+ * from another node in the party carrying whatever id THAT node wrote — and both
97
+ * places it lands turn it straight into a name: the embedder's storage provider
98
+ * (a directory under cadre-cli's storage path, a LevelDB filename, an IndexedDB
99
+ * database name) and the strand node's protocol prefix `/optimystic/strand-<id>`.
100
+ *
101
+ * Valid means all of:
102
+ * - non-empty, and at most {@link MAX_STRAND_SCOPE_KEY_LENGTH} characters;
103
+ * - within `[A-Za-z0-9._-]`, so no separator, drive letter, NUL or non-ASCII text;
104
+ * - not `.` or `..`, which the charset admits but every filesystem reads as a
105
+ * directory rather than a name;
106
+ * - not `control-`-prefixed, which would let a strand's store masquerade as a
107
+ * party's control database (see {@link isControlStorageScope}).
108
+ */
109
+ export function isValidStrandScopeKey(strandId: string): boolean {
110
+ return strandId.length > 0
111
+ && strandId.length <= MAX_STRAND_SCOPE_KEY_LENGTH
112
+ && SCOPE_KEY_CHARSET.test(strandId)
113
+ && strandId !== '.'
114
+ && strandId !== '..'
115
+ && !isControlStorageScope(strandId);
116
+ }
117
+
118
+ /**
119
+ * Thrown when a strand id cannot be used as a storage scope key or network name.
120
+ *
121
+ * NOT retryable: the id is a property of the strand row, so every later attempt on
122
+ * the same row fails identically. Callers that poll — `CadreNode.handleStrandAdded`
123
+ * via `StrandWatcher` — suppress the strand rather than scheduling a retry.
124
+ */
125
+ export class InvalidStrandIdError extends Error {
126
+ readonly strandId: string;
127
+
128
+ constructor(strandId: string) {
129
+ super(
130
+ `Strand id ${JSON.stringify(strandId)} cannot be used as a storage scope key: ` +
131
+ `a strand id must be 1-${MAX_STRAND_SCOPE_KEY_LENGTH} characters within [A-Za-z0-9._-], ` +
132
+ "must not be '.' or '..', and must not begin 'control-'. " +
133
+ 'This strand was created by a node that does not mint conforming ids; it cannot be ' +
134
+ 'started here, because the id becomes a file, directory or database name.'
135
+ );
136
+ this.name = 'InvalidStrandIdError';
137
+ this.strandId = strandId;
138
+ }
139
+ }
140
+
141
+ /** {@link isValidStrandScopeKey} as an assertion, throwing {@link InvalidStrandIdError}. */
142
+ export function assertStrandScopeKey(strandId: string): void {
143
+ if (!isValidStrandScopeKey(strandId)) {
144
+ throw new InvalidStrandIdError(strandId);
145
+ }
146
+ }
147
+
148
+ /**
149
+ * The charset half of the rule on its own, for the CONTROL key.
150
+ *
151
+ * {@link assertStrandScopeKey} cannot serve here: it rejects the `control-` prefix by
152
+ * design. Asserted at the seam that calls the provider (`CadreNode.resolveControlStorage`)
153
+ * rather than inside {@link controlStorageScope}, so the guarantee belongs to the call
154
+ * that hands a key out and not to one particular way of minting one.
155
+ */
156
+ export function assertScopeKeyCharset(scope: string): void {
157
+ if (!SCOPE_KEY_CHARSET.test(scope)) {
158
+ throw new Error(
159
+ `Storage scope key ${JSON.stringify(scope)} leaves the [A-Za-z0-9._-] charset ` +
160
+ 'embedders rely on to use it directly as a file, directory or database name.'
161
+ );
162
+ }
163
+ }