kczx-user-management 1.6.0 → 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/README.md +5 -3
- package/client/bundle.js +13 -5
- package/client/index.js +13 -5
- package/package.json +1 -1
- package/src/dsh-wire.js +140 -14
- package/src/gateway-core.js +108 -7
- package/src/index.js +4 -2
- 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/src/workspace-create.js +22 -5
package/README.md
CHANGED
|
@@ -88,7 +88,7 @@ node scripts/ensure-deps.mjs # 仅当上述解析不到 sche
|
|
|
88
88
|
|---|---|
|
|
89
89
|
| 用户增删改查 | 用户表每行一个「编辑」**全屏页**:基本资料(部门 / 角色)+ 账号操作(重置密码 / 重置两步验证 / 禁用启用 / 删除)+ 权限与配额,集中一屏;改动类走「保存」,破坏性操作仍是各自带确认的按钮 |
|
|
90
90
|
| 所属部门(1.6.0 起支持多层级) | 账号资料字段,支持**多层级路径**:用 `/` 分层,例如 `和平分局/刑侦大队/一中队`(`\` 也认,存的时候统一成 `/`)。新增用户时可填,编辑页可随时改或清空;编辑时框下会把层级摊开显示,并列出**已在用的层级**供一键接续;变更写进操作日志。规则(服务端为准,界面同款提示在前面):最多 **5 级**、每一级 ≤ **24 字**、整串 ≤ **64 字**(含分隔符);层级里不能有 `\ / : * ? " < > |`,也不能是 `.` / `..`——因为这些层级要变成目录名。写不进去的值**报错而不是悄悄改写**(理由同长度上限) |
|
|
91
|
-
| 新建账号即建工作区 | 新增用户时勾选「同时创建该用户的工作区」:路径预填 `<harness
|
|
91
|
+
| 新建账号即建工作区 | 新增用户时勾选「同时创建该用户的工作区」:路径预填 `<harness 根>/workspace/<部门各级>/<用户名>`,**一级部门一个目录**(`…\workspace\和平分局\刑侦大队\zhang`;可改成任意绝对路径);提交后目录按需创建、登记进 dsh 工作区列表,并把该路径写进这个账号的工作区白名单。部门不合法时**先报 400,再建目录**——不会留下半棵树 |
|
|
92
92
|
| 工作区白名单 | **留空 = 不允许任何工作区**(白名单默认拒绝);填写后只允许这些目录下的工作区;条目 `*` = 允许全部。Windows 路径折叠大小写,末尾斜杠自动忽略 |
|
|
93
93
|
| 工作区归属跟踪 | 谁创建的工作区归谁:别人看不到、也不能删改;删除后释放,账号删除后清理 |
|
|
94
94
|
| 会话可见性(1.6.0 起) | 会话按**会话自己的工作目录**判定:目录不在本账号白名单内、或落在别人认领的工作区里,这个会话就不下发。**为什么必须有**:前端把「没有被任何可见工作区认领」的会话统统塞进「未分组」那一组——只藏工作区行的话,里面的会话会带着标题、点开即读的历史原地复活。**三个入口一起堵**:① `session.list`(0.1.1)/ `session/list`(0.1.2+)的一元响应;② 0.1.1 的 `host/session-added` 实时推送(`GET /api/events.host` 的 SSE——每个新建会话都会被推给所有在线浏览器,只改一元响应的话,别人新建一条就会把行实时推回来);③ 0.1.2+ 的 `api-session/added`(`$events` mux 流上的 emit 帧)。`session.search` 沿用列表刚藏掉的 id 清单摘命中片段。判定只看 `cwd`:`blank` **不**豁免(别人目录里的「新建会话」行也还是别人的目录),只有子代理行(`origin: subagent`,前端本来就不列)和**读不到 cwd** 的行保留——「判断不了」不等于「不该看」 |
|
|
@@ -231,7 +231,8 @@ user-management:
|
|
|
231
231
|
|
|
232
232
|
### 部门层级(1.6.0 起)
|
|
233
233
|
|
|
234
|
-
部门的「多层级」不是一张独立的部门表,而是**一个字符串里的路径**:`和平分局/刑侦大队/一中队`。这么定有两个好处——老数据不用迁移(原来的 `平台组`
|
|
234
|
+
部门的「多层级」不是一张独立的部门表,而是**一个字符串里的路径**:`和平分局/刑侦大队/一中队`。这么定有两个好处——老数据不用迁移(原来的 `平台组` 就是单级),而且它正好就是工作区目录的形状:勾了「同时创建该用户的工作区」的账号,目录会一级一个文件夹地铺开
|
|
235
|
+
(都挂在 `<harness 根>/workspace/` 这一层下面)。
|
|
235
236
|
|
|
236
237
|
| 规则 | 值 |
|
|
237
238
|
|---|---|
|
|
@@ -248,7 +249,8 @@ user-management:
|
|
|
248
249
|
### 新建用户时一并创建工作区(1.5.0 起)
|
|
249
250
|
|
|
250
251
|
「用户 → 新增用户」多了一个复选框「同时创建该用户的工作区」。勾上后出现路径框,**预填**默认路径
|
|
251
|
-
`<harness
|
|
252
|
+
`<harness 根>/workspace/<部门各级>/<用户名>`(部门留空就少一层,只到用户名;所有账号都在同一个 `workspace` 目录下,
|
|
253
|
+
根目录因此不会被一堆部门目录铺满),可以直接改成任意绝对路径;下方一行小字写着默认值,
|
|
252
254
|
改过之后还有「恢复默认」。不勾选则一切照旧。
|
|
253
255
|
|
|
254
256
|
提交时服务端按这个顺序做事,**能失败的都在写账号之前失败**:
|
package/client/bundle.js
CHANGED
|
@@ -309,7 +309,7 @@ window.__ModuleLoader__.load({
|
|
|
309
309
|
cfgFieldOtpFailLimit: '两步验证失败上限',
|
|
310
310
|
cfgFieldOtpLockoutSeconds: '两步验证锁定时长(秒)',
|
|
311
311
|
cfgFieldWorkspaceRoot: '新账号工作区根路径',
|
|
312
|
-
cfgHintWorkspaceRoot: '新建用户勾选「同时创建该用户的工作区」时,默认路径为
|
|
312
|
+
cfgHintWorkspaceRoot: '新建用户勾选「同时创建该用户的工作区」时,默认路径为 <根路径>/workspace/<部门各级>/<用户名>。留空 = 用宿主提供的工作区根路径(dsh 默认是 harness 所在目录)。',
|
|
313
313
|
}
|
|
314
314
|
|
|
315
315
|
const EN = {
|
|
@@ -576,7 +576,7 @@ window.__ModuleLoader__.load({
|
|
|
576
576
|
cfgFieldOtpFailLimit: '2FA failure limit',
|
|
577
577
|
cfgFieldOtpLockoutSeconds: '2FA lockout (seconds)',
|
|
578
578
|
cfgFieldWorkspaceRoot: 'Workspace root for new accounts',
|
|
579
|
-
cfgHintWorkspaceRoot: 'When a new account is created with “also create its workspace”, the default path is <root
|
|
579
|
+
cfgHintWorkspaceRoot: 'When a new account is created with “also create its workspace”, the default path is <root>/workspace/<department levels>/<username>. Empty = use the host’s own workspace root (dsh defaults to the harness directory).',
|
|
580
580
|
}
|
|
581
581
|
|
|
582
582
|
/** Ledger event type → locale key (also the login-log type filter). */
|
|
@@ -634,6 +634,13 @@ window.__ModuleLoader__.load({
|
|
|
634
634
|
* one that decides.
|
|
635
635
|
*/
|
|
636
636
|
const DEPARTMENT_MAX_DEPTH = 5
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* The directory every default workspace lives under (mirror of
|
|
640
|
+
* WORKSPACE_SUBDIR in src/workspace-create.js): the preview must name the same
|
|
641
|
+
* folder the server will build, level for level.
|
|
642
|
+
*/
|
|
643
|
+
const WORKSPACE_SUBDIR = 'workspace'
|
|
637
644
|
const DEPARTMENT_MAX_LEVEL_LEN = 24
|
|
638
645
|
const DEPARTMENT_MAX_LEN = 64
|
|
639
646
|
|
|
@@ -676,7 +683,7 @@ window.__ModuleLoader__.load({
|
|
|
676
683
|
|
|
677
684
|
/**
|
|
678
685
|
* The default workspace path, as the create-user dialog shows it:
|
|
679
|
-
* `<root
|
|
686
|
+
* `<root>/workspace/<一级>/<二级>/…/<username>`.
|
|
680
687
|
*
|
|
681
688
|
* This is the DISPLAY twin of the host half's rule (src/workspace-create.js):
|
|
682
689
|
* one folder PER department level, each folded into a directory name, joined
|
|
@@ -689,7 +696,7 @@ window.__ModuleLoader__.load({
|
|
|
689
696
|
// No root = no preview: the dialog then asks for an absolute path instead of
|
|
690
697
|
// showing a path relative to nothing.
|
|
691
698
|
if (base === '') return ''
|
|
692
|
-
const parts = [base]
|
|
699
|
+
const parts = [base, WORKSPACE_SUBDIR]
|
|
693
700
|
// The username is folded by the same rule as a level: it is one segment too.
|
|
694
701
|
for (const raw of [...departmentLevels(department), username]) {
|
|
695
702
|
const segment = String(raw === undefined || raw === null ? '' : raw)
|
|
@@ -1576,7 +1583,8 @@ window.__ModuleLoader__.load({
|
|
|
1576
1583
|
*
|
|
1577
1584
|
* Split out from the tab that opens it because the workspace half is the part
|
|
1578
1585
|
* worth pinning down: the checkbox reveals a path box pre-filled with the
|
|
1579
|
-
* default (`<harness root
|
|
1586
|
+
* default (`<harness root>/workspace/<department levels>/<username>`) and left
|
|
1587
|
+
* editable, so the
|
|
1580
1588
|
* admin either accepts it or types another absolute path. As a component of its
|
|
1581
1589
|
* own it can be rendered on demand — the tab's dialog otherwise only exists
|
|
1582
1590
|
* after a click, which a contract test cannot perform.
|
package/client/index.js
CHANGED
|
@@ -299,7 +299,7 @@ const ZH = {
|
|
|
299
299
|
cfgFieldOtpFailLimit: '两步验证失败上限',
|
|
300
300
|
cfgFieldOtpLockoutSeconds: '两步验证锁定时长(秒)',
|
|
301
301
|
cfgFieldWorkspaceRoot: '新账号工作区根路径',
|
|
302
|
-
cfgHintWorkspaceRoot: '新建用户勾选「同时创建该用户的工作区」时,默认路径为
|
|
302
|
+
cfgHintWorkspaceRoot: '新建用户勾选「同时创建该用户的工作区」时,默认路径为 <根路径>/workspace/<部门各级>/<用户名>。留空 = 用宿主提供的工作区根路径(dsh 默认是 harness 所在目录)。',
|
|
303
303
|
}
|
|
304
304
|
|
|
305
305
|
const EN = {
|
|
@@ -566,7 +566,7 @@ const EN = {
|
|
|
566
566
|
cfgFieldOtpFailLimit: '2FA failure limit',
|
|
567
567
|
cfgFieldOtpLockoutSeconds: '2FA lockout (seconds)',
|
|
568
568
|
cfgFieldWorkspaceRoot: 'Workspace root for new accounts',
|
|
569
|
-
cfgHintWorkspaceRoot: 'When a new account is created with “also create its workspace”, the default path is <root
|
|
569
|
+
cfgHintWorkspaceRoot: 'When a new account is created with “also create its workspace”, the default path is <root>/workspace/<department levels>/<username>. Empty = use the host’s own workspace root (dsh defaults to the harness directory).',
|
|
570
570
|
}
|
|
571
571
|
|
|
572
572
|
/** Ledger event type → locale key (also the login-log type filter). */
|
|
@@ -624,6 +624,13 @@ function interpolate(template, vars) {
|
|
|
624
624
|
* one that decides.
|
|
625
625
|
*/
|
|
626
626
|
const DEPARTMENT_MAX_DEPTH = 5
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* The directory every default workspace lives under (mirror of
|
|
630
|
+
* WORKSPACE_SUBDIR in src/workspace-create.js): the preview must name the same
|
|
631
|
+
* folder the server will build, level for level.
|
|
632
|
+
*/
|
|
633
|
+
const WORKSPACE_SUBDIR = 'workspace'
|
|
627
634
|
const DEPARTMENT_MAX_LEVEL_LEN = 24
|
|
628
635
|
const DEPARTMENT_MAX_LEN = 64
|
|
629
636
|
|
|
@@ -666,7 +673,7 @@ function departmentProblem(levels, t) {
|
|
|
666
673
|
|
|
667
674
|
/**
|
|
668
675
|
* The default workspace path, as the create-user dialog shows it:
|
|
669
|
-
* `<root
|
|
676
|
+
* `<root>/workspace/<一级>/<二级>/…/<username>`.
|
|
670
677
|
*
|
|
671
678
|
* This is the DISPLAY twin of the host half's rule (src/workspace-create.js):
|
|
672
679
|
* one folder PER department level, each folded into a directory name, joined
|
|
@@ -679,7 +686,7 @@ function workspaceDefaultPath(root, sep, department, username) {
|
|
|
679
686
|
// No root = no preview: the dialog then asks for an absolute path instead of
|
|
680
687
|
// showing a path relative to nothing.
|
|
681
688
|
if (base === '') return ''
|
|
682
|
-
const parts = [base]
|
|
689
|
+
const parts = [base, WORKSPACE_SUBDIR]
|
|
683
690
|
// The username is folded by the same rule as a level: it is one segment too.
|
|
684
691
|
for (const raw of [...departmentLevels(department), username]) {
|
|
685
692
|
const segment = String(raw === undefined || raw === null ? '' : raw)
|
|
@@ -1566,7 +1573,8 @@ function EditUserPage({ user, me, departments, onClose, onDone, onTempPassword,
|
|
|
1566
1573
|
*
|
|
1567
1574
|
* Split out from the tab that opens it because the workspace half is the part
|
|
1568
1575
|
* worth pinning down: the checkbox reveals a path box pre-filled with the
|
|
1569
|
-
* default (`<harness root
|
|
1576
|
+
* default (`<harness root>/workspace/<department levels>/<username>`) and left
|
|
1577
|
+
* editable, so the
|
|
1570
1578
|
* admin either accepts it or types another absolute path. As a component of its
|
|
1571
1579
|
* own it can be rendered on demand — the tab's dialog otherwise only exists
|
|
1572
1580
|
* after a click, which a contract test cannot perform.
|
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/index.js
CHANGED
|
@@ -718,7 +718,8 @@ async function handleApi(req, res, deps) {
|
|
|
718
718
|
* The root the console builds per-account workspaces under.
|
|
719
719
|
*
|
|
720
720
|
* Read by the create-user form so it can prefill the default path
|
|
721
|
-
* (<root
|
|
721
|
+
* (<root>/workspace/<department levels>/<username>, one folder per level).
|
|
722
|
+
* Admin-only:
|
|
722
723
|
* the value is a path on the
|
|
723
724
|
* host, which is exactly what a plain account must not be handed.
|
|
724
725
|
*/
|
|
@@ -1112,7 +1113,8 @@ const Config = z.object({
|
|
|
1112
1113
|
lockoutSeconds: z.natural().min(1).default(60),
|
|
1113
1114
|
// ── per-account workspaces (the "create user" flow) ──────────────────────
|
|
1114
1115
|
// Root the console builds a new account's workspace under:
|
|
1115
|
-
// <workspaceRoot
|
|
1116
|
+
// <workspaceRoot>/workspace/<department levels>/<username>. Empty (the
|
|
1117
|
+
// default) asks the
|
|
1116
1118
|
// running host for it — the sandbox policy's own workspaceRoot, which dsh
|
|
1117
1119
|
// defaults to the harness's process cwd — and falls back to process.cwd().
|
|
1118
1120
|
// Set it explicitly to park every account folder on another volume.
|
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 }
|
package/src/workspace-create.js
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* Workspace provisioning for the console's "create user" flow.
|
|
5
5
|
*
|
|
6
6
|
* An account created by an administrator may come with its own workspace. The
|
|
7
|
-
* DEFAULT home is `<harness root
|
|
8
|
-
* per department level
|
|
7
|
+
* DEFAULT home is `<harness root>/workspace/<department levels>/<username>`
|
|
8
|
+
* (one folder per department level, all of them under a single `workspace`
|
|
9
|
+
* directory); the admin may type any other absolute path instead. This module owns the pure half of that — the
|
|
9
10
|
* path arithmetic and its validation — plus the directory step, so both are
|
|
10
11
|
* verifiable without a store, a request or a running dsh.
|
|
11
12
|
*
|
|
@@ -26,6 +27,16 @@ import { departmentLevels, StoreError } from './store.js'
|
|
|
26
27
|
|
|
27
28
|
/** Longest path this module will hand to the filesystem. */
|
|
28
29
|
const MAX_PATH_LEN = 1024
|
|
30
|
+
/**
|
|
31
|
+
* The single directory every default workspace lives under:
|
|
32
|
+
* `<root>/workspace/<department levels>/<username>`.
|
|
33
|
+
*
|
|
34
|
+
* One level between the harness root and the accounts, so the root keeps its
|
|
35
|
+
* own files (the checkout, its config, whatever else lives there) beside a
|
|
36
|
+
* single `workspace` folder instead of being mixed with one folder per
|
|
37
|
+
* department.
|
|
38
|
+
*/
|
|
39
|
+
const WORKSPACE_SUBDIR = 'workspace'
|
|
29
40
|
/** Longest directory name built out of free text (a department is capped at 64
|
|
30
41
|
* characters by the store, and a username at 32). */
|
|
31
42
|
const MAX_SEGMENT_LEN = 64
|
|
@@ -55,10 +66,10 @@ export function sanitizeSegment(value) {
|
|
|
55
66
|
|
|
56
67
|
/**
|
|
57
68
|
* The default workspace for one account:
|
|
58
|
-
* `<root
|
|
69
|
+
* `<root>/workspace/<一级>/<二级>/…/<username>`.
|
|
59
70
|
*
|
|
60
71
|
* A multi-level department becomes NESTED folders, one per level, so the tree on
|
|
61
|
-
* disk mirrors the organisation: `E:\work\和平分局\刑侦大队\zhang`. A blank
|
|
72
|
+
* disk mirrors the organisation: `E:\work\workspace\和平分局\刑侦大队\zhang`. A blank
|
|
62
73
|
* department drops its segments instead of inventing a placeholder — the username
|
|
63
74
|
* alone is already unique, and a folder named after nothing is worse than one
|
|
64
75
|
* level less.
|
|
@@ -75,7 +86,7 @@ export function sanitizeSegment(value) {
|
|
|
75
86
|
* @returns an absolute path built with the host separator.
|
|
76
87
|
*/
|
|
77
88
|
export function defaultWorkspacePath(root, department, username) {
|
|
78
|
-
const parts = [String(root === undefined || root === null ? '' : root).trim()]
|
|
89
|
+
const parts = [String(root === undefined || root === null ? '' : root).trim(), WORKSPACE_SUBDIR]
|
|
79
90
|
for (const level of departmentLevels(department)) {
|
|
80
91
|
const segment = sanitizeSegment(level)
|
|
81
92
|
if (segment !== '') parts.push(segment)
|
|
@@ -127,6 +138,12 @@ export function resolveWorkspaceTarget({ root, department, username, custom } =
|
|
|
127
138
|
if (sameWorkspacePath(path, base)) {
|
|
128
139
|
throw new StoreError('bad_workspace_path', '工作区不能就是 harness 根目录本身(该目录是所有账号工作区的父目录)')
|
|
129
140
|
}
|
|
141
|
+
// The container is off limits for the same reason: a username that sanitizes
|
|
142
|
+
// away would otherwise turn the folder every account lives under into one
|
|
143
|
+
// account's workspace.
|
|
144
|
+
if (sameWorkspacePath(path, join(base, WORKSPACE_SUBDIR))) {
|
|
145
|
+
throw new StoreError('bad_workspace_path', `工作区不能就是 ${WORKSPACE_SUBDIR} 目录本身(该目录是所有账号工作区的父目录)`)
|
|
146
|
+
}
|
|
130
147
|
return { path, root: base, source }
|
|
131
148
|
}
|
|
132
149
|
|