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 +1 -1
- package/src/dsh-wire.js +140 -14
- package/src/gateway-core.js +108 -7
- package/src/mux-filter.js +41 -11
- package/src/permissions.js +18 -2
- package/src/proxy.js +7 -0
- package/src/sse-filter.js +14 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kczx-user-management",
|
|
3
|
-
"version": "1.6.
|
|
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
|
-
*
|
|
274
|
-
*
|
|
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
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
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
|
-
* @
|
|
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
|
|
285
|
-
|
|
286
|
-
if (frame
|
|
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
|
|
289
|
-
|
|
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
|
-
|
|
601
|
+
planHostFrame,
|
|
477
602
|
planForwardedSessionAdded,
|
|
603
|
+
planMuxStreamFrame,
|
|
478
604
|
sessionRowVisible,
|
|
479
605
|
isApprovalGrant,
|
|
480
606
|
denyRemoteEventResult,
|
package/src/gateway-core.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
4
|
+
* The WebSocket frame transformer: the codec every live dsh stream is
|
|
5
|
+
* enforced through.
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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`
|
|
139
|
-
* frame alone, `{ action: 'drop' }` to
|
|
140
|
-
* `{ action: 'replace', value }` to send a different
|
|
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
|
-
|
|
250
|
+
const text = encodeReplacement(message, decision.value)
|
|
251
|
+
return text === null ? frame.raw : encodeTextFrame(text)
|
|
222
252
|
}
|
|
223
253
|
return frame.raw
|
|
224
254
|
}
|
package/src/permissions.js
CHANGED
|
@@ -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,
|
|
358
|
-
*
|
|
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) }
|
|
63
|
-
* event block
|
|
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
|
-
|
|
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 }
|