kczx-user-management 1.6.2 → 1.6.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kczx-user-management",
3
- "version": "1.6.2",
3
+ "version": "1.6.3",
4
4
  "type": "module",
5
5
  "description": "dsh 插件 · 登录门 + 账号 / 权限 / 配额 / 审计台账(dsh-passwords 的功能已并入):HTTPS 登录网关与自签证书、用户增删改查(可一并创建工作区)、工作区白名单与归属跟踪、每小时 token 与每日时长配额、沙盒档位下限、上传/git 开关、三本审计台账(登录/访问/操作)、IP 封禁、TOTP 两步验证。",
6
6
  "license": "MIT",
package/src/dsh-wire.js CHANGED
@@ -66,6 +66,9 @@ const SESSION_SEARCH_RE = /^\/api\/session[.\/]search$/
66
66
  /** 0.1.1's host event stream (SSE) — the live session-list push rides here. */
67
67
  const HOST_EVENT_STREAM_RE = /^\/api\/events[.\/]host$/
68
68
 
69
+ /** 0.1.1's session event stream (SSE) — the live permission projection rides here. */
70
+ const MUX_EVENT_STREAM_RE = /^\/api\/events[.\/]mux$/
71
+
69
72
  /** The forwarded event that asks a human for an allow/reject decision. */
70
73
  const APPROVAL_EVENT = 'approval/request'
71
74
 
@@ -152,6 +155,39 @@ function parseJsonObject(text) {
152
155
  return decoded
153
156
  }
154
157
 
158
+ /**
159
+ * Parse one host→browser message of the 0.1.1 event-stream WebSocket.
160
+ *
161
+ * The generation difference that matters here: /api/events.mux and
162
+ * /api/events.host are not SSE responses to a browser — the host upgrades
163
+ * them to a WebSocket and sends ONE JSON ServerRequest envelope per text
164
+ * message ({ type:'server-request', rpcId, method, payload }, where method
165
+ * mirrors payload.type). The browser sends no business data on either
166
+ * socket; a client message makes the host close the connection.
167
+ *
168
+ * A validating reader, for the same reason as the mux half: an unparsed
169
+ * message is forwarded byte for byte, so a false positive would mean
170
+ * mis-reading somebody else's traffic.
171
+ */
172
+ function parseServerRequestMessage(text) {
173
+ const value = parseJsonObject(text)
174
+ if (value === null) return null
175
+ if (value.type !== 'server-request') return null
176
+ if (value.payload === null || typeof value.payload !== 'object' || Array.isArray(value.payload)) return null
177
+ return value
178
+ }
179
+
180
+ /**
181
+ * Re-encode one replaced message of the same stream.
182
+ *
183
+ * `method` is kept in step with `payload.type` because the host writes both
184
+ * from one frame, and a client that reads `method` would otherwise be told
185
+ * about a frame the message is not carrying.
186
+ */
187
+ function encodeServerRequestMessage(message, payload) {
188
+ return JSON.stringify({ ...message, method: payload.type, payload })
189
+ }
190
+
155
191
  /** One workspace entry as the registry projects it. */
156
192
  function isWorkspaceView(value) {
157
193
  return value !== null && typeof value === 'object'
@@ -270,25 +306,82 @@ function sessionRowVisible(row, decide) {
270
306
  }
271
307
 
272
308
  /**
273
- * One `host/session-added` frame (0.1.1's host event stream), or null when the
274
- * frame is something else.
309
+ * Decide what to do with one frame of the host event stream.
310
+ *
311
+ * Why this is not the unary list rewrite alone: the 0.1.1 host PUSHES its
312
+ * registry news to every connected client over one stream — /api/events.host,
313
+ * which the host upgrades to a WebSocket (one ServerRequest text message per
314
+ * frame). A sidebar therefore grows rows for somebody else's conversation and
315
+ * GROUPS for somebody else's workspace without a single workspace.list or
316
+ * session.list call, and re-fetching the list (a page refresh) makes the
317
+ * leaked row vanish again: the unary answer was filtered, the push was not.
318
+ * That asymmetry is the whole reason this planner exists.
319
+ *
320
+ * Every frame carrying a workspace is judged by the SAME rules the list
321
+ * rewrite uses (visibleView / visibleId), so the two can never disagree:
322
+ * - host/session-added — judged by its cwd, exactly like a list row;
323
+ * - host/workspace-changed — the full new WorkspaceView after every durable
324
+ * mutation, which includes ATTACHING a session to a workspace (creating a
325
+ * session in it is what makes an invisible workspace reappear);
326
+ * - host/workspace-removed — nothing to hide (it removes a row), but the
327
+ * id→path harvest is dropped with it;
328
+ * - host/workspace-order-changed — the registry order, filtered to the ids
329
+ * this account may see, so a hidden workspace is not even named.
275
330
  *
276
- * The session list is not only a unary answer: the host also PUSHES every newly
277
- * created session to every connected client, which is how a row for somebody
278
- * else's conversation appears in a sidebar without a single `session.list`
279
- * call. The frame carries the same `cwd` the list row does, so it is judged by
280
- * the same rule.
331
+ * `views` carries EVERY workspace the frame described, visible or not, so the
332
+ * caller can harvest the id→path pairs a later session.create needs; the same
333
+ * posture as the workspace/follow stream of the newer generation.
281
334
  *
282
- * @returns 'drop' when the frame must not reach the browser, else null.
335
+ * @param frame - one ServerRequest envelope as the host writes it.
336
+ * @param decide - `{ visibleView(view), visibleId(id), sessionVisible(cwd) }`.
337
+ * @returns `{ action, value, views, removedId }` where action is
338
+ * 'pass' | 'drop' | 'replace' and `value` is the replacement payload
339
+ * (only for 'replace').
283
340
  */
284
- function planHostSessionAdded(frame, decide) {
285
- if (frame === null || typeof frame !== 'object') return null
286
- if (frame.type !== 'server-request') return null
341
+ function planHostFrame(frame, decide) {
342
+ const none = { action: 'pass', value: null, views: [], removedId: null }
343
+ if (frame === null || typeof frame !== 'object') return none
344
+ if (frame.type !== 'server-request') return none
287
345
  const payload = frame.payload
288
- if (payload === null || typeof payload !== 'object' || payload.type !== 'host/session-added') return null
289
- return sessionRowVisible(payload, decide) ? null : 'drop'
346
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return none
347
+
348
+ if (payload.type === 'host/session-added') {
349
+ return sessionRowVisible(payload, decide) ? none : { ...none, action: 'drop' }
350
+ }
351
+
352
+ if (payload.type === 'host/workspace-changed') {
353
+ const workspace = payload.workspace
354
+ if (!isWorkspaceView(workspace)) return none
355
+ const views = [workspace]
356
+ // Dropping the push is what keeps an invisible workspace out of the
357
+ // client's list: the browser upserts whatever arrives, and its own list is
358
+ // never re-filtered until the next full fetch.
359
+ return decide.visibleView(workspace)
360
+ ? { ...none, views }
361
+ : { ...none, action: 'drop', views }
362
+ }
363
+
364
+ if (payload.type === 'host/workspace-removed') {
365
+ return {
366
+ ...none,
367
+ removedId: typeof payload.workspaceId === 'string' ? payload.workspaceId : null,
368
+ }
369
+ }
370
+
371
+ if (payload.type === 'host/workspace-order-changed') {
372
+ if (!Array.isArray(payload.workspaceIds)) return none
373
+ const kept = payload.workspaceIds.filter((id) => typeof id !== 'string' || decide.visibleId(id))
374
+ if (kept.length === payload.workspaceIds.length) return none
375
+ return { ...none, action: 'replace', value: { ...payload, workspaceIds: kept } }
376
+ }
377
+
378
+ // host/session-removed, host/session-status, host/agent-error,
379
+ // host/archived-sessions-changed, host/remote-event and anything a later
380
+ // host adds: no workspace and no session ROW to judge, so they pass.
381
+ return none
290
382
  }
291
383
 
384
+
292
385
  /**
293
386
  * One `api-session/added` forwarded event (0.1.2+), or null when the item is
294
387
  * something else.
@@ -306,6 +399,35 @@ function planForwardedSessionAdded(value, decide) {
306
399
  return sessionRowVisible(summary, decide) ? null : 'drop'
307
400
  }
308
401
 
402
+ /**
403
+ * Clamp one `session/projection` or `session/event` frame from 0.1.1's session
404
+ * event stream, or null when the frame is something else (or needed no change).
405
+ *
406
+ * The live half of the sandbox cap. The unary history response carries the
407
+ * projection once, but every later change — including the one a picker click
408
+ * causes — arrives as a push on `/api/events.mux`, so a gateway that rewrites
409
+ * only the response leaves the browser showing a mode the account was refused.
410
+ *
411
+ * @param frame - one parsed SSE frame (the `server-request` envelope).
412
+ * @param clamp - the caller's `clampSessionHistorySandbox`; it mutates in place
413
+ * and returns whether anything changed.
414
+ * @returns the same frame when it changed, else null (caller keeps the bytes).
415
+ */
416
+ function planMuxStreamFrame(frame, clamp) {
417
+ if (frame === null || typeof frame !== 'object' || frame.type !== 'server-request') return null
418
+ const payload = frame.payload
419
+ if (payload === null || typeof payload !== 'object') return null
420
+ if (payload.type === 'session/projection' && payload.key === 'permissions') {
421
+ return clamp(payload) ? frame : null
422
+ }
423
+ if (payload.type === 'session/event' && payload.event !== null && typeof payload.event === 'object') {
424
+ const kind = payload.event.type
425
+ if (kind !== 'permission/preset' && kind !== 'sandbox/mode' && kind !== 'approval/policy') return null
426
+ return clamp(payload) ? frame : null
427
+ }
428
+ return null
429
+ }
430
+
309
431
  /**
310
432
  * Drop the session rows an account may not see.
311
433
  *
@@ -458,6 +580,7 @@ export {
458
580
  SESSION_LIST_RE,
459
581
  SESSION_SEARCH_RE,
460
582
  HOST_EVENT_STREAM_RE,
583
+ MUX_EVENT_STREAM_RE,
461
584
  APPROVAL_EVENT,
462
585
  APPROVAL_GRANT,
463
586
  APPROVAL_DENIAL,
@@ -470,11 +593,14 @@ export {
470
593
  isWorkspaceView,
471
594
  parseMuxClientMessage,
472
595
  parseMuxServerMessage,
596
+ parseServerRequestMessage,
597
+ encodeServerRequestMessage,
473
598
  planWorkspaceFollowFrame,
474
599
  planSessionListFilter,
475
600
  planSessionSearchFilter,
476
- planHostSessionAdded,
601
+ planHostFrame,
477
602
  planForwardedSessionAdded,
603
+ planMuxStreamFrame,
478
604
  sessionRowVisible,
479
605
  isApprovalGrant,
480
606
  denyRemoteEventResult,
@@ -60,6 +60,7 @@ import {
60
60
  APPROVAL_EVENT,
61
61
  EVENTS_RESULT_PATH,
62
62
  HOST_EVENT_STREAM_RE,
63
+ MUX_EVENT_STREAM_RE,
63
64
  REMOTE_MUX_PATH,
64
65
  SESSION_LIST_RE,
65
66
  SESSION_PAGE_RE,
@@ -74,8 +75,11 @@ import {
74
75
  isWorkspaceFollowEndpoint,
75
76
  parseMuxClientMessage,
76
77
  parseMuxServerMessage,
78
+ parseServerRequestMessage,
79
+ encodeServerRequestMessage,
77
80
  planForwardedSessionAdded,
78
- planHostSessionAdded,
81
+ planHostFrame,
82
+ planMuxStreamFrame,
79
83
  planSessionListFilter,
80
84
  planSessionSearchFilter,
81
85
  planWorkspaceFollowFrame,
@@ -407,6 +411,14 @@ function createGateway(options) {
407
411
  * unary `session.list` answer alone still lets another account's conversation
408
412
  * appear as a row (under "未分组", since its workspace is hidden too).
409
413
  *
414
+ * This is the SSE carrier only, and no browser ever reaches it: the host
415
+ * answers `/api/events.host` to a browser with a WebSocket UPGRADE, which
416
+ * never becomes an HTTP response here (see createEventStreamInterceptor for
417
+ * that half, and note that both halves run the same planner). It is kept
418
+ * because the path is an SSE GET on the in-process fetch carrier, and because
419
+ * a non-upgrading client — a probe, a test fixture — must not be the reason
420
+ * the filter has a hole in it.
421
+ *
410
422
  * Returns null for every other request — in particular for a deployment on
411
423
  * 0.1.2+, which has no host event stream at all (its pushes ride the mux, see
412
424
  * createMuxInterceptor).
@@ -417,13 +429,26 @@ function createGateway(options) {
417
429
  const visibility = workspaceVisibility(req.umSession, permissionsFor(req.umSession))
418
430
  if (visibility === null) return null
419
431
  return createSseFilter({
420
- onFrame: (frame) => {
421
- visibility.refresh()
422
- return planHostSessionAdded(frame, visibility)
423
- },
432
+ onFrame: (frame) => planHostStreamDecision(frame, visibility),
424
433
  })
425
434
  }
426
435
 
436
+ /**
437
+ * One host-stream frame as the SSE carrier wants it: the bytes to keep, or a
438
+ * replacement document. The WebSocket carrier (createEventStreamInterceptor)
439
+ * asks the same planner and re-encodes it its own way — the two must never
440
+ * disagree about WHICH frames a restricted account may receive.
441
+ */
442
+ function planHostStreamDecision(frame, visibility) {
443
+ visibility.refresh()
444
+ const plan = planHostFrame(frame, visibility)
445
+ if (plan.action === 'drop') return 'drop'
446
+ if (plan.action === 'replace') {
447
+ return { ...frame, method: plan.value.type, payload: plan.value }
448
+ }
449
+ return null
450
+ }
451
+
427
452
  /**
428
453
  * Frame-level enforcement for one accepted Remote mux connection.
429
454
  *
@@ -434,10 +459,15 @@ function createGateway(options) {
434
459
  *
435
460
  * Returns null — leaving the connection a plain byte pipe — when the account
436
461
  * has neither a workspace restriction nor a sandbox cap, or when the upgrade
437
- * is not the mux at all (a 0.1.1-rc.2 host has no mux route).
462
+ * is not one of this layer's streams: a 0.1.1-rc.2 host has no mux route at
463
+ * all, and its own two event streams are handled by
464
+ * createEventStreamInterceptor.
438
465
  */
439
466
  function createMuxInterceptor(req) {
440
- if (requestPath(req) !== REMOTE_MUX_PATH) return null
467
+ const path = requestPath(req)
468
+ // Two generations, two WebSocket shapes: the multiplexed mux above, and
469
+ // the 0.1.1 event streams the host upgrades one-per-path.
470
+ if (path !== REMOTE_MUX_PATH) return createEventStreamInterceptor(req, path)
441
471
  const session = req.umSession || null
442
472
  const perms = permissionsFor(session)
443
473
  const visibility = workspaceVisibility(session, perms)
@@ -495,6 +525,77 @@ function createGateway(options) {
495
525
  })
496
526
  }
497
527
 
528
+ /**
529
+ * Frame-level enforcement for one accepted 0.1.1-rc.2 event-stream upgrade.
530
+ *
531
+ * The transport difference that hid this door: `/api/events.host` and
532
+ * `/api/events.mux` LOOK like the SSE responses streamFilterFor rewrites, and
533
+ * on the in-process (fetch) carrier they are. A BROWSER never sees that
534
+ * shape — the host upgrades both paths to a WebSocket and sends one
535
+ * `ServerRequest` text message per frame — so the SSE filter never ran for a
536
+ * real client and every live push arrived unfiltered while the unary answers
537
+ * stayed filtered. That asymmetry is exactly what a person sees as "the
538
+ * workspace appears in the other account's sidebar, and a refresh takes it
539
+ * away again": workspace.list was rewritten, host/workspace-changed was not.
540
+ *
541
+ * One rule per stream:
542
+ * - `/api/events.host` carries the registry news — a session was added, a
543
+ * workspace was changed (the host pushes the whole new WorkspaceView
544
+ * after every durable mutation, and attaching a session to a workspace
545
+ * IS one), removed, or reordered — judged by the same plan the unary
546
+ * rewrites use;
547
+ * - `/api/events.mux` carries live session frames, clamped to the
548
+ * account's sandbox cap exactly like the history response.
549
+ *
550
+ * Returns null — leaving the connection a plain byte pipe — for an account
551
+ * that hides nothing and has no sandbox cap, and for every other upgrade.
552
+ */
553
+ function createEventStreamInterceptor(req, path) {
554
+ const isHostStream = HOST_EVENT_STREAM_RE.test(path)
555
+ const isMuxStream = MUX_EVENT_STREAM_RE.test(path)
556
+ if (!isHostStream && !isMuxStream) return null
557
+
558
+ const session = req.umSession || null
559
+ const perms = permissionsFor(session)
560
+ const visibility = isHostStream ? workspaceVisibility(session, perms) : null
561
+ const allowedMode = isMuxStream && perms && typeof perms.sandboxMode === 'string' && perms.sandboxMode !== ''
562
+ ? perms.sandboxMode
563
+ : null
564
+ if (visibility === null && allowedMode === null) return null
565
+
566
+ return createMuxFilter({
567
+ // Downlink only: the host closes the socket on any client message, so
568
+ // there is nothing to read and nothing to remember.
569
+ parseClientMessage: () => null,
570
+ parseServerMessage: parseServerRequestMessage,
571
+ encodeServerMessage: encodeServerRequestMessage,
572
+ onServerMessage: (message) => {
573
+ if (visibility !== null) {
574
+ visibility.refresh()
575
+ const plan = planHostFrame(message, visibility)
576
+ // Harvest first, filter second — including workspaces this account
577
+ // cannot see, so a later session.create that names one by id still
578
+ // resolves to a path and is refused for the right reason.
579
+ for (const view of plan.views) workspacePathById.set(view.workspaceId, view.path)
580
+ if (plan.removedId !== null) workspacePathById.delete(plan.removedId)
581
+ if (plan.action === 'drop') return { action: 'drop' }
582
+ if (plan.action === 'replace') return { action: 'replace', value: plan.value }
583
+ }
584
+ if (allowedMode !== null) {
585
+ // planMuxStreamFrame knows the 0.1.1 frame vocabulary (session/event,
586
+ // session/projection) that clampStreamItem does not: it serves the
587
+ // 0.1.2 session/follow items instead.
588
+ const planned = planMuxStreamFrame(
589
+ message,
590
+ (payload) => clampSessionHistorySandbox(payload, allowedMode),
591
+ )
592
+ if (planned !== null) return { action: 'replace', value: planned.payload }
593
+ }
594
+ return null
595
+ },
596
+ })
597
+ }
598
+
498
599
  /**
499
600
  * The workspace-visibility rules for one account, in the shape BOTH the
500
601
  * unary list rewrite and the 0.1.2+ follow stream need.
package/src/mux-filter.js CHANGED
@@ -1,14 +1,24 @@
1
1
  'use strict'
2
2
 
3
3
  /**
4
- * The Remote-mux frame transformer: the WebSocket half of the 0.1.2+ API.
4
+ * The WebSocket frame transformer: the codec every live dsh stream is
5
+ * enforced through.
5
6
  *
6
- * dsh multiplexes every Remote stream over one WebSocket (`/api/remote.mux`),
7
- * where each logical stream is a `streamId` the browser opened with an
8
- * `{ type:'open', streamId, endpoint, payload }` text message. Enforcement
9
- * that used to be a response rewrite (the workspace list) or a request rewrite
10
- * (an approval answer) therefore has to happen inside the frames — which is
11
- * what this module does, and why it owns a WebSocket codec at all.
7
+ * Two generations share it, and they differ only in vocabulary:
8
+ * - 0.1.2+ multiplexes every Remote stream over one WebSocket
9
+ * (`/api/remote.mux`), where each logical stream is a `streamId` the
10
+ * browser opened with an `{ type:'open', streamId, endpoint, payload }`
11
+ * text message;
12
+ * - 0.1.1-rc.2 upgrades `/api/events.host` and `/api/events.mux` to a
13
+ * WebSocket of their own and sends one `ServerRequest` envelope per text
14
+ * message (`{ type:'server-request', rpcId, method, payload }`), with no
15
+ * client traffic at all.
16
+ *
17
+ * Enforcement that used to be a response rewrite (the workspace list) or a
18
+ * request rewrite (an approval answer) therefore has to happen inside the
19
+ * frames — which is what this module does, and why it owns a WebSocket codec
20
+ * at all. The parsers and the replacement encoder are injected by the caller,
21
+ * so this file stays transport mechanics and the policy stays in gateway-core.
12
22
  *
13
23
  * Scope, deliberately narrow:
14
24
  * - only server→browser frames are ever rewritten; the browser→host
@@ -135,9 +145,11 @@ function encodeTextFrame(text) {
135
145
  * Build the per-connection transformer.
136
146
  *
137
147
  * @param handlers - `{ onClientMessage(message), onServerMessage(message,
138
- * endpoint) }`. `onServerMessage` returns null/'pass' to leave the
139
- * frame alone, `{ action: 'drop' }` to swallow it, or
140
- * `{ action: 'replace', value }` to send a different item value.
148
+ * endpoint), encodeServerMessage(message, value) }`. `onServerMessage`
149
+ * returns null/'pass' to leave the frame alone, `{ action: 'drop' }` to
150
+ * swallow it, or `{ action: 'replace', value }` to send a different
151
+ * value. `encodeServerMessage` is optional and owns the replacement's
152
+ * wire shape; without it the Remote mux item envelope is assumed.
141
153
  * @returns `{ observeClientChunk, filterServerChunk, streamEndpoint,
142
154
  * pendingBytes, disabled }`.
143
155
  */
@@ -209,6 +221,23 @@ function createMuxFilter(handlers) {
209
221
  return out.length === 1 ? out[0] : Buffer.concat(out)
210
222
  }
211
223
 
224
+ /**
225
+ * Re-encode one replaced message. The default is the Remote mux's own item
226
+ * shape; the 0.1.1 event streams carry a different envelope and pass their
227
+ * own encoder, because the two generations share this codec but not their
228
+ * wire vocabulary. An encoder that throws or returns nothing keeps the
229
+ * original bytes: a replacement nobody could encode is not worth a frame.
230
+ */
231
+ function encodeReplacement(message, value) {
232
+ if (typeof options.encodeServerMessage === 'function') {
233
+ try {
234
+ const text = options.encodeServerMessage(message, value)
235
+ return typeof text === 'string' && text !== '' ? text : null
236
+ } catch { return null }
237
+ }
238
+ return JSON.stringify({ type: 'item', streamId: message.streamId, value })
239
+ }
240
+
212
241
  function transformServerFrame(frame) {
213
242
  if (!frame.fin || frame.opcode !== OPCODE_TEXT) return frame.raw
214
243
  const message = parseServer(frame)
@@ -218,7 +247,8 @@ function createMuxFilter(handlers) {
218
247
  if (decision === null) return frame.raw
219
248
  if (decision.action === 'drop') return null
220
249
  if (decision.action === 'replace') {
221
- return encodeTextFrame(JSON.stringify({ type: 'item', streamId: message.streamId, value: decision.value }))
250
+ const text = encodeReplacement(message, decision.value)
251
+ return text === null ? frame.raw : encodeTextFrame(text)
222
252
  }
223
253
  return frame.raw
224
254
  }
@@ -354,8 +354,17 @@ function sanitizeHiddenUnicodeJson(value, depth = 0) {
354
354
  * wider mode without a single action from anyone — an escalation with no
355
355
  * approval step to intercept.
356
356
  *
357
- * Rewrites in place: `permission/preset` events, `sandbox/mode` events, and
358
- * the projected `currentValue` the client displays as the active preset.
357
+ * Rewrites in place: `permission/preset` events, `sandbox/mode` events, the
358
+ * projected `currentValue` the client displays as the active preset, and the
359
+ * `options` list the picker renders.
360
+ *
361
+ * Why the OPTIONS list matters: clamping only the active value leaves the wider
362
+ * presets on the menu. They stay clickable, and the click is refused a round
363
+ * trip later — which reads as "the cap does not work" even though the mode never
364
+ * changed. Only presets the rank table KNOWS to be wider are removed; anything
365
+ * unrecognised (a deployment's own preset name) is left alone, because hiding a
366
+ * narrow custom preset would be worse than listing it, and the request gate
367
+ * still refuses it fail-closed if it really is wider.
359
368
  *
360
369
  * @returns whether anything changed.
361
370
  */
@@ -383,6 +392,13 @@ function clampSessionHistorySandbox(value, allowedMode, depth = 0) {
383
392
  value.currentValue = allowedMode
384
393
  changed = true
385
394
  }
395
+ if (Array.isArray(value.options) && typeof value.currentValue === 'string') {
396
+ const kept = value.options.filter((option) => !widerThan(option && option.value))
397
+ if (kept.length !== value.options.length) {
398
+ value.options = kept
399
+ changed = true
400
+ }
401
+ }
386
402
 
387
403
  for (const key of Object.keys(value)) {
388
404
  const nested = value[key]
package/src/proxy.js CHANGED
@@ -553,6 +553,13 @@ function createProxy(upstream, getAuthenticatedUrl, hooks) {
553
553
  socket.write(split.header)
554
554
  writeFiltered(upstreamSocket, socket, mux, split.rest)
555
555
  upstreamSocket.on('data', (next) => writeFiltered(upstreamSocket, socket, mux, next))
556
+ // The plain path lets pipe() carry the upstream's FIN to the
557
+ // browser; once frames go through a filter there is no pipe, so the
558
+ // end has to be forwarded by hand. Without it the browser answers
559
+ // the close frame and then waits for a transport close that never
560
+ // comes: the socket (and the tab's half of it) hangs around forever.
561
+ upstreamSocket.on('end', () => { if (!socket.writableEnded) socket.end() })
562
+ upstreamSocket.on('error', () => { if (!socket.destroyed) socket.destroy() })
556
563
  // Observational only: the browser's bytes reach the host
557
564
  // unchanged, so a parse miss costs the connection nothing.
558
565
  socket.on('data', (browserChunk) => mux.observeClientChunk(browserChunk))
package/src/sse-filter.js CHANGED
@@ -56,11 +56,18 @@ function dataPayload(block) {
56
56
  return parts.length === 0 ? null : parts.join('\n')
57
57
  }
58
58
 
59
+ /** Re-encode one event block from a replaced payload. */
60
+ function encodeBlock(payload) {
61
+ return Buffer.from(`data: ${JSON.stringify(payload)}\n\n`, 'utf8')
62
+ }
63
+
59
64
  /**
60
65
  * Build the per-connection filter.
61
66
  *
62
- * @param handlers - `{ onFrame(parsedJson) }`, returning 'drop' to swallow the
63
- * event block it was given (anything else keeps it).
67
+ * @param handlers - `{ onFrame(parsedJson) }`. Return 'drop' to swallow the
68
+ * event block, a different object to REPLACE it (re-encoded as one
69
+ * `data:` block — the caller mutated it in place), or anything else to
70
+ * keep the original bytes.
64
71
  * @returns `{ push(chunk), disabled(), pendingBytes() }`.
65
72
  */
66
73
  function createSseFilter(handlers) {
@@ -90,7 +97,10 @@ function createSseFilter(handlers) {
90
97
  const taken = takeBlock(buffer)
91
98
  if (taken === null) break
92
99
  buffer = taken.rest
93
- if (decide(taken.block) !== 'drop') out.push(taken.block)
100
+ const decision = decide(taken.block)
101
+ if (decision === 'drop') continue
102
+ if (decision !== null && typeof decision === 'object') out.push(encodeBlock(decision))
103
+ else out.push(taken.block)
94
104
  }
95
105
  if (buffer.length > MAX_PENDING_BYTES) {
96
106
  out.push(buffer)
@@ -110,4 +120,4 @@ function createSseFilter(handlers) {
110
120
  }
111
121
  }
112
122
 
113
- export { createSseFilter, MAX_PENDING_BYTES }
123
+ export { createSseFilter, encodeBlock, MAX_PENDING_BYTES }