dsh-cherry-bridge 0.1.11 → 0.2.1

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/src/index.ts CHANGED
@@ -3,7 +3,7 @@ import { join } from 'node:path'
3
3
  import { DeviceStore } from './deviceStore.js'
4
4
  import { loadOrCreateIdentity } from './identity.js'
5
5
  import { DeviceConnection } from './connection.js'
6
- import type { DeviceState } from './connection.js'
6
+ import type { DeviceState, WsDialOptions } from './connection.js'
7
7
  import { DEFAULT_PER_SESSION_CACHE_BYTES, DEFAULT_RATE_BUDGET_PER_SECOND } from './defaults.js'
8
8
  import { pairWithPayload } from './pairing.js'
9
9
  import { SessionSubscriptions } from './subscriptions.js'
@@ -21,6 +21,7 @@ import type {
21
21
  } from './httpApi.js'
22
22
  import type { DiagnosticsError } from './diagnostics.js'
23
23
  import { truncateErrorMessage } from './diagnostics.js'
24
+ import { boundedJoin, failureText } from './text.js'
24
25
 
25
26
  export const name = 'cherry-remote'
26
27
 
@@ -97,11 +98,25 @@ export interface CherryBridgeConfig {
97
98
  * (not a protocol value). Omitted uses {@link DEFAULT_PER_SESSION_CACHE_BYTES}.
98
99
  */
99
100
  perSessionCacheBytes?: number
101
+ /**
102
+ * FLAT `tls.connect` options for every `wss` dial (issue #133): the operator's
103
+ * TLS trust for a desktop behind a private CA or a self-signed TLS front.
104
+ * `ws` forwards this bag straight to Node's TLS layer, so `{ ca: <pem> }` and
105
+ * `{ rejectUnauthorized: false }` belong at the TOP level — the `{ tls: {…} }`
106
+ * shape (`https`' wrapper) is not recognised by `ws` and is refused here.
107
+ * Applied to BOTH dial sites: the steady-state pool and pairing.
108
+ *
109
+ * NEVER persisted in the record, never logged, never reported: a `ca` is PEM
110
+ * CONTENT, and trust is a grant the operator makes, not a fact the desktop
111
+ * asserts about itself.
112
+ */
113
+ wsTls?: WsDialOptions
100
114
  }
101
115
 
102
116
  /**
103
- * The two config values that reach constructor arguments, with their defaults
104
- * applied.
117
+ * The plugin-config values that reach constructor arguments, resolved once: the
118
+ * two numeric budgets with their defaults applied, and the validated TLS trust
119
+ * bag (see {@link dialOptions}).
105
120
  *
106
121
  * Resolved ONCE per `apply` and threaded explicitly, rather than re-read at each
107
122
  * construction site: a config surface that can change between two constructions
@@ -110,6 +125,7 @@ export interface CherryBridgeConfig {
110
125
  export interface ResolvedBridgeConfig {
111
126
  rateBudgetPerSecond: number
112
127
  perSessionCacheBytes: number
128
+ wsTls: WsDialOptions | undefined
113
129
  }
114
130
 
115
131
  /**
@@ -130,11 +146,12 @@ export {
130
146
  DEFAULT_RATE_BUDGET_PER_SECOND,
131
147
  } from './defaults.js'
132
148
 
133
- /** Applies the documented fallbacks to the plugin config's numeric keys. */
149
+ /** Applies the documented fallbacks and validates the plugin config's keys. */
134
150
  export function resolveBridgeConfig(config: CherryBridgeConfig = {}): ResolvedBridgeConfig {
135
151
  return {
136
152
  rateBudgetPerSecond: positive('rateBudgetPerSecond', config.rateBudgetPerSecond, DEFAULT_RATE_BUDGET_PER_SECOND),
137
153
  perSessionCacheBytes: positive('perSessionCacheBytes', config.perSessionCacheBytes, DEFAULT_PER_SESSION_CACHE_BYTES),
154
+ wsTls: dialOptions(config.wsTls),
138
155
  }
139
156
  }
140
157
 
@@ -164,6 +181,104 @@ function positive(key: string, value: unknown, fallback: number): number {
164
181
  return value
165
182
  }
166
183
 
184
+ /** The keys {@link WsDialOptions} carries; anything else is a config mistake. */
185
+ const WS_TLS_KEYS: ReadonlySet<string> = new Set(['rejectUnauthorized', 'ca'])
186
+
187
+ /** The shape a non-object config value arrived as, for a value-free warning. */
188
+ function shapeOf(value: unknown): string {
189
+ if (value === null) return 'null'
190
+ if (Array.isArray(value)) return 'array'
191
+ return typeof value
192
+ }
193
+
194
+ /**
195
+ * One `ca` entry: PEM CONTENT for a string, non-empty bytes for a Buffer.
196
+ *
197
+ * A string is the certificate TEXT itself, NEVER a file path: `tls.connect`
198
+ * would happily read whatever path it is handed, so accepting `'/etc/ssl/ca.pem'`
199
+ * would load bytes the operator never inspected. The header check is a SHAPE
200
+ * check only — whether the PEM parses beyond `-----BEGIN` is Node's business,
201
+ * and an unparseable body fails the handshake loudly, which is the honest
202
+ * verdict. Buffers keep the non-empty test with no header requirement: DER and
203
+ * programmatically built PEM bytes are legitimate `ca` values.
204
+ */
205
+ function certificateEntry(value: unknown): boolean {
206
+ if (typeof value === 'string') return value.trimStart().startsWith('-----BEGIN')
207
+ if (Buffer.isBuffer(value)) return value.length > 0
208
+ return false
209
+ }
210
+
211
+ /**
212
+ * A configured TLS trust bag, or `undefined` when absent/unusable.
213
+ *
214
+ * ABSENT is the documented default (Node's trust store) and says nothing —
215
+ * exactly like {@link positive}. A key that is PRESENT but unusable is a
216
+ * deployment mistake that silently changes which peers are reachable, so it is
217
+ * warned about by name rather than swallowed — but NEVER by value: a `ca` is
218
+ * PEM CONTENT, and a certificate must not reach a log.
219
+ *
220
+ * FAIL CLOSED, WHOLE OBJECT. One unknown or malformed entry discards the WHOLE
221
+ * bag rather than forwarding the parts that happen to look right. Trust must be
222
+ * applied AS WRITTEN: a partially applied bag would reach some peers and not
223
+ * others with nothing telling the operator which half took effect, and their
224
+ * `ca` intention would be silently replaced by Node's default store. The
225
+ * fallback is "no override" — the same state as absent.
226
+ */
227
+ function dialOptions(value: unknown): WsDialOptions | undefined {
228
+ if (value === undefined) return undefined
229
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
230
+ console.warn(
231
+ `[cherry-remote] config "wsTls" must be a plain object of flat tls.connect options ` +
232
+ `(got ${shapeOf(value)}); ignoring it`,
233
+ )
234
+ return undefined
235
+ }
236
+ const record = value as Record<string, unknown>
237
+ const unknownKeys = Object.keys(record).filter((key) => !WS_TLS_KEYS.has(key))
238
+ if (unknownKeys.length > 0) {
239
+ // The `{ tls: {…} }` wrapper is the common mistake (`https`' shape, which
240
+ // `ws` ignores), so naming the offending keys points straight at it.
241
+ console.warn(
242
+ `[cherry-remote] config "wsTls" carries unsupported key(s) ${unknownKeys.join(', ')}; ` +
243
+ 'only flat rejectUnauthorized / ca are accepted — ignoring the whole object',
244
+ )
245
+ return undefined
246
+ }
247
+
248
+ const rejectUnauthorized = record['rejectUnauthorized']
249
+ if (rejectUnauthorized !== undefined && typeof rejectUnauthorized !== 'boolean') {
250
+ // FIELD only — a config value never enters the message.
251
+ console.warn(
252
+ '[cherry-remote] config "wsTls.rejectUnauthorized" must be a boolean; ignoring the whole object',
253
+ )
254
+ return undefined
255
+ }
256
+
257
+ const ca = record['ca']
258
+ if (ca !== undefined) {
259
+ const usable = Array.isArray(ca)
260
+ ? ca.length > 0 && ca.every((entry) => certificateEntry(entry))
261
+ : certificateEntry(ca)
262
+ if (!usable) {
263
+ // FIELD only — never the value: a `ca` is certificate text.
264
+ console.warn(
265
+ '[cherry-remote] config "wsTls.ca" must be PEM CONTENT (the certificate text itself, ' +
266
+ 'not a file path) — a "-----BEGIN" string, a non-empty Buffer, or a non-empty array ' +
267
+ 'of those; ignoring the whole object',
268
+ )
269
+ return undefined
270
+ }
271
+ }
272
+
273
+ // `{}` (or an explicit `undefined` value) is the documented default written
274
+ // out: no override, and no warning.
275
+ if (rejectUnauthorized === undefined && ca === undefined) return undefined
276
+ return {
277
+ ...(rejectUnauthorized === undefined ? {} : { rejectUnauthorized }),
278
+ ...(ca === undefined ? {} : { ca: ca as WsDialOptions['ca'] }),
279
+ }
280
+ }
281
+
167
282
  /** The context slice `apply` touches beyond the HTTP face's own services. */
168
283
  export interface CherryBridgeContext extends CherryHttpContext, CherryToolsContext {
169
284
  effect?(callback: () => () => void): unknown
@@ -213,6 +328,13 @@ interface AssemblyState {
213
328
  */
214
329
  rateBudgetPerSecond: number
215
330
  perSessionCacheBytes: number
331
+ /**
332
+ * The current apply's TLS trust bag (issue #133), handed to BOTH dial sites —
333
+ * the pooled connection and pairing's temporary channel. Held on the state for
334
+ * the same reason as the two numeric values: a re-apply must move both faces
335
+ * onto the new config. `undefined` = Node's default trust store.
336
+ */
337
+ wsTls: WsDialOptions | undefined
216
338
  readonly connections: Map<string, DeviceConnection>
217
339
  /**
218
340
  * One subscription layer per device, sharing nothing with its siblings: the
@@ -275,6 +397,7 @@ const state: AssemblyState = {
275
397
  identityError: undefined,
276
398
  rateBudgetPerSecond: DEFAULT_RATE_BUDGET_PER_SECOND,
277
399
  perSessionCacheBytes: DEFAULT_PER_SESSION_CACHE_BYTES,
400
+ wsTls: undefined,
278
401
  connections: new Map(),
279
402
  subscriptions: new Map(),
280
403
  deviceStateListeners: new Set(),
@@ -363,6 +486,22 @@ function connectionFor(deviceId: string): DeviceConnection {
363
486
  identity: requireIdentity(),
364
487
  record,
365
488
  rateBudgetPerSecond: state.rateBudgetPerSecond,
489
+ // Spread-if-present, never `wsOptions: undefined`: the option is optional,
490
+ // and an absent key is the honest "no override" (Node's trust store).
491
+ ...(state.wsTls === undefined ? {} : { wsOptions: state.wsTls }),
492
+ // The archive write-back (issue #143). The connection has already merged the
493
+ // addresses into its OWN record — this instance's next reconnect dials them
494
+ // — so the only job here is durability. It goes through
495
+ // `DeviceStore.learnEndpoints`, NOT `upsert(record)`: the store's record
496
+ // carries the token of the PAIRING while the connection's mirrors the live
497
+ // one, and an `upsert` of the connection's copy would put a
498
+ // connection-scoped credential into the archive.
499
+ onAdvertisedEndpoints: (endpoints) => {
500
+ // `record` is THIS connection's clone: its `desktopIdentity` is the pin the
501
+ // answer was validated against, so a re-pair under the same deviceId cannot
502
+ // receive an address learned from the previous pairing.
503
+ state.store.learnEndpoints(deviceId, endpoints, record.desktopIdentity)
504
+ },
366
505
  })
367
506
  state.connectionsBuilt += 1
368
507
  connection.onState((deviceState: DeviceState) => {
@@ -393,7 +532,18 @@ function connectionFor(deviceId: string): DeviceConnection {
393
532
  try {
394
533
  subsFor(deviceId).handleNotification(method, params)
395
534
  } catch (error) {
396
- console.warn(`[cherry-remote] notification "${method}" for ${deviceId} failed`, error)
535
+ // ONE bounded STRING (issue #161): the old form passed the error OBJECT,
536
+ // so the console printed the error's name/message/stack with nothing
537
+ // bounding it. The layer's handler throws for no schema-valid
538
+ // notification today, but THIS try also calls `subsFor(deviceId)` →
539
+ // `connectionFor()`, which does throw today (`no device …` when the
540
+ // record was removed mid-race) — and a future handler throw must not hand
541
+ // the console an object either. Ceiling: `[cherry-remote] notification "`
542
+ // (30) + the notification schema's `method` cap (128) + `" for ` (6) +
543
+ // deviceId + ` failed: ` (9) + LOG_TEXT_MAX (240).
544
+ console.warn(
545
+ `[cherry-remote] notification "${method}" for ${deviceId} failed: ${failureText(error)}`,
546
+ )
397
547
  }
398
548
  })
399
549
  state.connections.set(deviceId, connection)
@@ -526,6 +676,31 @@ function subsFor(deviceId: string): SessionSubscriptions {
526
676
  return created
527
677
  }
528
678
 
679
+ /**
680
+ * Bounds the link-recovery line's session-id list (issue #155).
681
+ *
682
+ * The number of ENTRIES is bounded only by the peer-advertised hello
683
+ * `subscriptions` — a number the peer chooses, not one this assembly can size —
684
+ * and a session id is the protocol's `opaqueId` (≤ 256 characters). The old
685
+ * `sessions.join(', ')` therefore emitted `entries × 258` characters into one
686
+ * info line: the protocol's own `remoteLimits.subscriptions` = 8 still allows
687
+ * ~2 KB, and a peer advertising more grows the line with no ceiling at all. The
688
+ * text is peer-controlled (the ids are echoed back from what the peer accepted
689
+ * as subscriptions), so it needs a LOCAL bound like every other such log.
690
+ *
691
+ * `RECOVERY_SESSIONS_MAX` = 320 is the joined ids' budget: eight normal
692
+ * 36-char UUIDs render as 8 × 36 + 7 × 2 = 302 characters, so the protocol's
693
+ * own subscription cap fits WHOLE — the bound bites only on a peer that
694
+ * advertises more room, or on a malformed over-long id.
695
+ * `RECOVERY_SESSION_ID_MAX` = 256 is that `opaqueId` cap, so a schema-valid id
696
+ * is never clipped; only a malformed one is. A list that stops early names the
697
+ * count (`(+K more)`) and rides ON TOP of the budget; entries are never dropped
698
+ * silently. The bound is on the LOG only — the loop still re-ensures every
699
+ * session.
700
+ */
701
+ const RECOVERY_SESSIONS_MAX = 320
702
+ const RECOVERY_SESSION_ID_MAX = 256
703
+
529
704
  /**
530
705
  * The reconnect recovery line (ruling 8): every session this device had
531
706
  * subscribed is re-ensured at its LAST cursor, so the peer replays only what
@@ -546,10 +721,16 @@ async function recoverSubscriptions(deviceId: string): Promise<void> {
546
721
  const sessions = subscriptions.activeSessions()
547
722
  // INFO (spec §5 「订阅建立与恢复」): the recovery line announces what it is
548
723
  // about to re-ensure, once per link recovery rather than once per session.
549
- // Ids only — a session id is whitelisted, an event payload is not.
724
+ // Ids only — a session id is whitelisted, an event payload is not. The id
725
+ // list is bounded by {@link RECOVERY_SESSIONS_MAX} (issue #155); the
726
+ // `subscription(s)` count above it is the TRUE total, so a clipped list still
727
+ // says how many sessions are being recovered.
550
728
  if (sessions.length > 0) {
551
729
  console.info(
552
- `[cherry-remote] recovering ${sessions.length} subscription(s) for ${deviceId}: ${sessions.join(', ')}`,
730
+ `[cherry-remote] recovering ${sessions.length} subscription(s) for ${deviceId}: ${boundedJoin(
731
+ sessions,
732
+ { max: RECOVERY_SESSIONS_MAX, itemMax: RECOVERY_SESSION_ID_MAX },
733
+ )}`,
553
734
  )
554
735
  }
555
736
 
@@ -566,7 +747,15 @@ async function recoverSubscriptions(deviceId: string): Promise<void> {
566
747
  (cursor === undefined ? '' : ` at seq ${cursor.seq}`),
567
748
  )
568
749
  } catch (error) {
569
- console.warn(`[cherry-remote] re-subscribing session ${sessionId} of ${deviceId} failed`, error)
750
+ // ONE bounded STRING (issue #161). The catch is peer-triggered:
751
+ // `ensure` subscribes over the live link, and its failure message is the
752
+ // peer's JSON-RPC `error.message` verbatim (up to the 512-char failure
753
+ // schema cap) or a transport message. The old form handed the error
754
+ // OBJECT to the console, which then printed the stack plus every own
755
+ // property unbounded.
756
+ console.warn(
757
+ `[cherry-remote] re-subscribing session ${sessionId} of ${deviceId} failed: ${failureText(error)}`,
758
+ )
570
759
  }
571
760
  }
572
761
  }
@@ -603,6 +792,11 @@ function assemblyDeps(): CherryHttpDeps & CherryToolsDeps {
603
792
  payload: request.payload,
604
793
  store: state.store,
605
794
  ...(request.name === undefined ? {} : { name: request.name }),
795
+ // The same operator TLS trust the steady-state dial uses (issue #133):
796
+ // a desktop behind a private CA must be pairABLE over `wss` as well as
797
+ // reconnectable. Read from the assembly state, never from `request`: a
798
+ // caller cannot supply trust.
799
+ ...(state.wsTls === undefined ? {} : { wsOptions: state.wsTls }),
606
800
  onVerificationCode: request.onVerificationCode,
607
801
  // Pair bridging (ruling 10): the call site's stages reach the assembly's
608
802
  // progress fan-out, which is what the SSE route consumes.
@@ -715,6 +909,7 @@ export function apply(ctx: CherryBridgeContext, config: CherryBridgeConfig = {})
715
909
  const bridgeConfig = resolveBridgeConfig(config)
716
910
  state.rateBudgetPerSecond = bridgeConfig.rateBudgetPerSecond
717
911
  state.perSessionCacheBytes = bridgeConfig.perSessionCacheBytes
912
+ state.wsTls = bridgeConfig.wsTls
718
913
  state.store = new DeviceStore(join(dataDir, 'devices.json'))
719
914
 
720
915
  // A re-apply is a fresh assembly: the previous run's error summaries and