kczx-user-management 1.4.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # kczx-user-management
2
2
 
3
3
  > **本包是本地扩展版(1.0.0),已把 dsh-passwords 的账号 / 权限 / 配额 / 台账能力并入,不再是上游 0.9.5 的功能集。**
4
- > 上游同名包(npm 上的 `@weibaohui/user-management`)仅提供登录门禁与两步验证;本版额外提供工作区白名单与归属跟踪、
4
+ > 上游同名包(npm 上的 `@weibaohui/user-management`)仅提供登录门禁与两步验证;本版额外提供工作区白名单与归属跟踪、新建账号即建工作区、
5
5
  > token 与时长配额、沙盒档位下限、上传 / git / 运维面门控、历史沙盒降级与隐藏字符清洗。
6
6
  > 为便于从 registry 安装,本版以独立包名 **`kczx-user-management`** 发布,与上游互不覆盖。
7
7
 
@@ -87,9 +87,11 @@ node scripts/ensure-deps.mjs # 仅当上述解析不到 sche
87
87
  | 能力 | 说明 |
88
88
  |---|---|
89
89
  | 用户增删改查 | 用户表每行一个「编辑」**全屏页**:基本资料(部门 / 角色)+ 账号操作(重置密码 / 重置两步验证 / 禁用启用 / 删除)+ 权限与配额,集中一屏;改动类走「保存」,破坏性操作仍是各自带确认的按钮 |
90
- | 所属部门 | 账号资料字段(trim 后 ≤ 64 字,控制字符剔除):新增用户时可填,编辑页可随时改或清空;变更写进操作日志 |
90
+ | 所属部门(1.6.0 起支持多层级) | 账号资料字段,支持**多层级路径**:用 `/` 分层,例如 `和平分局/刑侦大队/一中队`(`\` 也认,存的时候统一成 `/`)。新增用户时可填,编辑页可随时改或清空;编辑时框下会把层级摊开显示,并列出**已在用的层级**供一键接续;变更写进操作日志。规则(服务端为准,界面同款提示在前面):最多 **5 级**、每一级 ≤ **24 字**、整串 ≤ **64 字**(含分隔符);层级里不能有 `\ / : * ? " < > |`,也不能是 `.` / `..`——因为这些层级要变成目录名。写不进去的值**报错而不是悄悄改写**(理由同长度上限) |
91
+ | 新建账号即建工作区 | 新增用户时勾选「同时创建该用户的工作区」:路径预填 `<harness 根>/<部门各级>/<用户名>`,**一级部门一个目录**(`…\和平分局\刑侦大队\zhang`;可改成任意绝对路径);提交后目录按需创建、登记进 dsh 工作区列表,并把该路径写进这个账号的工作区白名单。部门不合法时**先报 400,再建目录**——不会留下半棵树 |
91
92
  | 工作区白名单 | **留空 = 不允许任何工作区**(白名单默认拒绝);填写后只允许这些目录下的工作区;条目 `*` = 允许全部。Windows 路径折叠大小写,末尾斜杠自动忽略 |
92
93
  | 工作区归属跟踪 | 谁创建的工作区归谁:别人看不到、也不能删改;删除后释放,账号删除后清理 |
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** 的行保留——「判断不了」不等于「不该看」 |
93
95
  | 每小时 token 配额 | 由浏览器上报 dsh 自身的用量投影增量,服务端按小时窗口累计并拦截 |
94
96
  | 每日时长配额 | 按真实活跃时间累计(不是请求数),限流不因轮询膨胀 |
95
97
  | 沙盒档位下限 | read-only / workspace-write / danger-full-access:AI 无法通过设置、slash 命令或审批把自己提权 |
@@ -111,6 +113,43 @@ node scripts/ensure-deps.mjs # 仅当上述解析不到 sche
111
113
  默认 50)。筛选同样在服务端执行,所以「共 N 条」永远是**匹配总数**而不是当前页条数。账号表只有在调用方
112
114
  传了 `limit` 时才分页,不传仍然整份返回(后端脚本的老用法不受影响)。
113
115
 
116
+ ## dsh 版本适配(1.6.0 起)
117
+
118
+ dsh 有两代 API,本插件两代都认——这曾经是个坑:老代码只认第一代的路径,换到新宿主上**不会报错**,
119
+ 只是那些请求永远匹配不上,于是三条强制悄悄失效。
120
+
121
+ | | 0.1.1-rc.2(第一代) | 0.1.2-rc.1+ / 0.1.5-rc.2(第二代) |
122
+ |---|---|---|
123
+ | 一元 RPC | `POST /api/<ns>.<method>` | `POST /api/<ns>/<method>` |
124
+ | 工作区列表 | `workspace.list` 一元响应 | `workspace/follow` **流**(走 `/api/remote.mux`) |
125
+ | 会话历史 | `session.history` 一元响应 | `session/page` 一元 + `session/follow` 的开场 `snapshot` |
126
+ | 审批应答 | `POST /api/respond` | `POST /api/$events/result`(事件 id 由 mux 上的 `waterfall` 帧给出) |
127
+ | 大文件外带 | —— | `workspaceFiles/readAll`、`readRelated`(整文件 base64) |
128
+
129
+ 因此在 0.1.2-rc.1 及更新的宿主上:
130
+
131
+ - **工作区白名单 / 归属**:过滤 `workspace/follow` 的 baseline / upsert / order 帧(别人的工作区不下发,
132
+ 且 upsert 会被整帧丢弃,否则被过滤掉的条目会立刻长回来);
133
+ - **沙盒档位下限**:钳制 `session/page` 的 records、`session/follow` 的 snapshot 与逐条事件、
134
+ 以及 `session/control` 上 `permissions` 投影的 `currentValue`;
135
+ - **审批一律拒绝**:把 `$events/result` 的 outcome 改写成 `{kind:'result', value:'rejected'}`。
136
+ 判断「这是不是审批」有两条独立依据:mux 上 `approval/request` 的 waterfall 帧记下的 eventId,
137
+ 以及 `allowed-once`(审批词表里唯一的放行值,问答的答案不可能是这个字符串);
138
+ - **整文件读取**:`workspaceFiles/readAll|readRelated` 归到「git / 下载」开关下。窗口化的 `read`/`readBytes`
139
+ 与 `stat`/`list` 不拦——文件浏览器要靠它们画那一页,否则没开 git 的账号连文件都看不了。
140
+ - **会话可见性**:三个入口共用一条判定(见上表)。两代的一元返回值都是 `{ items: SessionSummary[] }`,
141
+ 但**都包在连接信封里**(`{ type:'server-response', rpcId, result:{ ok, value } }`)——只看顶层 `items`
142
+ 在真流量上什么都匹配不到(这插件在对话内容台账上踩过一次,1.6.0 又踩了一次,所以
143
+ `dsh-wire.js` 现在带一个信封感知的定位器,测试也一律用真实信封而不是手写的裸形状)。
144
+ `session.search` 沿用同一份「刚被藏掉的 id」清单把命中片段一并去掉(列表是整份快照,每次响应
145
+ **覆盖**该清单而不是累加)。判定走 `workspaceVisibility`,与工作区列表同一套「白名单 + 归属」语义;
146
+ 归属按**包含**该目录的最深认领算,所以 `…/mty/src` 里的会话算 `…/mty` 的。
147
+ - **实时推送**:0.1.1 走 `src/sse-filter.js`(逐 SSE 块解析,只丢 `host/session-added` 且判定为不可见的那种块,
148
+ 其余字节原样放行);0.1.2+ 复用 mux 拦截器丢 `api-session/added` 帧。两条路都只在**确认识别**时动手,
149
+ 认不出来就放行——这条流里跑着整个实时会话(含助手增量),绝不能因为它变哑。
150
+
151
+ 宿主源码级的核对记录(每条形状的文件:行)在 [`docs/dsh-wire-notes.md`](docs/dsh-wire-notes.md)。
152
+
114
153
  ## 配置
115
154
 
116
155
  走 `~/.dsh/settings.yaml` 的 `user-management:` 段。改完即时生效的字段无需重启;只有监听器字段
@@ -145,6 +184,7 @@ user-management:
145
184
  # ── 会话与请求策略(全部即时生效) ──
146
185
  sessionDays: 7 # 会话有效期(天,滑动续期:过半后续一次)
147
186
  maxBodyBytes: 65536 # 本插件 API 的 JSON 请求体上限(字节)
187
+ workspaceRoot: '' # 新建账号工作区的根路径;'' = 问宿主(dsh 的工作区根,默认即 harness 所在目录)
148
188
  otpFailLimit: 5 # 两步验证连续错误几次后锁定
149
189
  otpLockoutSeconds: 60 # 锁定多久
150
190
  ```
@@ -189,6 +229,109 @@ user-management:
189
229
  - 隐私开关:`user-management.auditConversationText: false`(设置页「会话与请求策略」里也有这一项)关掉即完全不抓。台账是
190
230
  `$DSH_HOME/user-management/audit.jsonl` 明文文件,提示词可能含口令 / 密钥时请关闭。
191
231
 
232
+ ### 部门层级(1.6.0 起)
233
+
234
+ 部门的「多层级」不是一张独立的部门表,而是**一个字符串里的路径**:`和平分局/刑侦大队/一中队`。这么定有两个好处——老数据不用迁移(原来的 `平台组` 就是单级),而且它正好就是工作区目录的形状:勾了「同时创建该用户的工作区」的账号,目录会一级一个文件夹地铺开。
235
+
236
+ | 规则 | 值 |
237
+ |---|---|
238
+ | 层级分隔符 | `/`(`\` 也接受,存成 `/`) |
239
+ | 最多层级 | 5 |
240
+ | 每一级最长 | 24 字 |
241
+ | 整串最长 | 64 字(含分隔符) |
242
+ | 层级里禁止 | `\ / : * ? " < > |`,以及 `.` / `..` |
243
+
244
+ 空层级会被丢掉,两端空格会被裁掉:`" 和平分局 / 刑侦大队 // 一中队 "` 存成 `和平分局/刑侦大队/一中队`。**不合法就报错**,不会把非法字符悄悄折成 `-`:这条标签同时也是目录名,标签和目录不一致比报错更糟。
245
+
246
+ 编辑页与新建对话框里,部门框下面是层级摊开预览(`和平分局 › 刑侦大队`)、已在用层级的快捷前缀(点一下就把框变成 `该路径/`,接着往下敲一级即可),以及和上面同一套规则的即时校验提示。已用层级来自 `GET /user-management/api/departments`(管理员专用,返回**所有前缀**,不只是叶子节点——有人正好挂在中间层时,父级不能消失)。
247
+
248
+ ### 新建用户时一并创建工作区(1.5.0 起)
249
+
250
+ 「用户 → 新增用户」多了一个复选框「同时创建该用户的工作区」。勾上后出现路径框,**预填**默认路径
251
+ `<harness 根>/<部门>/<用户名>`(部门留空就少一层,只到用户名),可以直接改成任意绝对路径;下方一行小字写着默认值,
252
+ 改过之后还有「恢复默认」。不勾选则一切照旧。
253
+
254
+ 提交时服务端按这个顺序做事,**能失败的都在写账号之前失败**:
255
+
256
+ 1. **校验路径**:自定义路径必须以盘符或 `/` 开头(相对路径会报错,不会被悄悄拼到进程 cwd 上);根路径本身
257
+ (harness 目录)不能当工作区;路径长度 ≤ 1024。部门是管理员随手写的自由文本,所以它会被折成**一段**合法的目录名
258
+ (`\` `/` `:` `*` `?` `"` `<` `>` `|` 与控制字符 → `-`,纯点号(`..`)直接丢掉),绝不允许它多出一层目录或跳出根路径。
259
+ 2. **归属冲突**:这个路径已经属于别的账号 → 409,账号和目录都不会建。既有的目录(别人放的文件)会被**领养**而不是重新创建。
260
+ 3. **建目录**(`mkdir -p`,已存在则复用);失败则 400,**不建账号**。
261
+ 4. **建账号**,并把该路径写进它的**工作区白名单**(`allowedFolders`)。白名单是默认拒绝的:不放开的话,账号
262
+ 根本看不到自己那块地。第 3 步建出来的空目录在账号写入失败时会被回收(只删空目录)。
263
+ 5. **登记进 dsh 工作区列表**:通过宿主的 `workspaceRegistry` 服务(运行期 `ctx.inject`,不写死依赖)。
264
+ 这一步失败**不回滚**——目录和账号都是真的,接口把 `registered: false` 和原因一起返回,界面提示
265
+ 「用户已创建、目录已建好,但未登记进工作区列表」。dsh 的工作区列表用 realpath 后的规范路径,所以登记返回的
266
+ 规范路径也会一并加进白名单(软链接根目录 / Windows 大小写差异下,账号才看得到自己的那块地)。
267
+ 6. **认领归属**:把该路径记到这个账号名下——别人看不到、也不能删改(见「工作区归属跟踪」)。
268
+
269
+ 接口:`POST /user-management/api/users` 增加 `createWorkspace: true` 与可选的 `workspacePath`(留空 = 服务端按默认规则生成),
270
+ 响应多一个 `workspace` 报告(`{ ok, path, source, directory, registered, owner, error }`,没勾选时为 `null`);
271
+ `GET /user-management/api/workspace-root`(管理员)回 `{ root, sep }`,新增用户弹窗用它预填路径。
272
+ 根路径优先取 `user-management.workspaceRoot`,未配置则问宿主的 `sandboxPolicy.workspaceRoot`(dsh 默认就是进程 cwd,
273
+ 即 harness 所在目录),最后兜底 `process.cwd()`。
274
+
275
+ ### 被拒绝时提示什么(1.5.1 起)
276
+
277
+ 被网关拦下的请求(没有建工作区的权限、目录不在白名单、配额用完、上传 / git 被关、沙盒提权被拒……)过去一律回 HTTP 403。
278
+ 而 dsh 的浏览器客户端**把任何非 2xx 都当成传输故障**、根本不读响应体,于是用户看到的是:
279
+
280
+ > workspace create failed: internal: transport failure for /api/workspace.create: HTTP 403
281
+
282
+ 这读起来像程序坏了,而不像「你的账号没有这个权限」。现在这类拒绝改走 RPC 协议自带的错误位:**HTTP 200 + `result.ok: false`**,
283
+ 原因写在 `error.message` 里,各处 dsh 界面都会原样显示:
284
+
285
+ > workspace create failed: internal: 本账号没有创建 / 管理工作区的权限(请联系管理员在「用户管理 → 编辑用户 → 权限与配额」中开启「允许创建工作区」)
286
+
287
+ 只有**不是 RPC 调用**的请求(没有 `rpcId` 可回显的裸请求、GET)仍回原来的 403 JSON —— 给它们编一个 rpcId 只会把「被拒绝」
288
+ 变成客户端的协议错误,比原样回报更糟。
289
+
290
+ 顺带修好一个盲区:被拒绝的请求以前**完全不进操作日志**(它没到上游,写日志的钩子永远不会触发)。现在会记一行
291
+ **403 + `已拒绝:<原因>`**,管理员在「用户管理 → 操作日志」里直接看得到谁、想做什么、为什么被挡下来。
292
+
293
+ ### 控制台页头的位置(1.5.3 起)
294
+
295
+ 「用户管理」整页原来的页头是一条**横贯屏幕**的标题栏:宽屏上标题孤零零贴在左上角、按钮贴在右上角,和下面的
296
+ tab 条与内容列毫无关系。现在标题与 tab 收进**居中内容列**,而「退出登录」单独钉在**窗口的右上角**:
297
+
298
+ ```
299
+ [⏻ 退出登录] ← 贴窗口右上角(与「编辑用户」页那颗同一位置)
300
+ 用户管理 ← 内容列内,与 tab 条左对齐
301
+ [用户][登录记录][访问记录][操作日志][IP 封禁][证书][2FA] [返回设置]
302
+ …表格 / 台账…
303
+ ```
304
+
305
+ 「用户管理」这一行是「这一页是什么」,与内容左对齐;「返回设置」与 tab 条同一条线、右边缘对齐。退出登录之所以
306
+ 不跟着内容列走,是因为上一层「编辑用户」页把自己的退出登录放在窗口右上角——同一个按钮不能因为进出一层就横向挪位。
307
+ 普通用户没有 tab 条,但「返回设置」仍然落在同一条线上(右对齐),两个视图因此读起来还是同一页。观察者模式
308
+ (dsh-passwords 在前)没有退出登录,只有「返回设置」。
309
+
310
+ 「退出登录」现在**先问一句**(`确定退出登录?退出后需要重新输入用户名和密码。`):控制台页头和账号菜单里各有一个入口,
311
+ 且都离用户正在做的事只差一次误点——确认放在共用的 `doLogout()` 里,谁都绕不过去。
312
+
313
+ 「编辑用户」整页自带一个退出登录,钉在**页面最右上角**(关闭 / 保存之后隔一条竖线,不跟表单按钮混在一起):
314
+
315
+ ```
316
+ ┌ 编辑用户 · bob ─────────────────────────────────────────────┐
317
+ │ [关闭] [保存] │ [⏻ 退出登录] │
318
+ └─────────────────────────────────────────────────────────────┘
319
+ ```
320
+
321
+ 同一时刻屏幕上只有一个退出登录:打开这一页时,控制台那个**窗口右上角的退出登录收起来**(位置本来就是同一个,只是避免两颗同时存在)。不透明浮层确实盖住了它,
322
+ 但「盖住」不等于「不在文档里」——那时它离一次 Tab+Enter 只有一步,而这颗按钮结束的是整个会话。收放由一个模块级的
323
+ 小订阅(`setFullPageOverlay`)广播,编辑页挂载时置位、卸载时清除。
324
+
325
+ ### 欢迎页右上角的账号信息(1.5.4 起)
326
+
327
+ 会话头部只在**有会话**时才存在,于是落地页(欢迎页 / 新建会话页)成了唯一一个不说「你是谁」的页面——连账号菜单里
328
+ 仅有的两件事(改密码、退出登录)也没地方点。现在账号芯片(头像 + 用户名,点开即账号菜单)还会落在**shell 的全局
329
+ overlay 座位**(ui-layout 的 `shell.overlay`,一个可叠加的 list 槽)上,固定在窗口**右上角**;一旦有会话成为当前会话,
330
+ 这个芯片立刻消失,把位置让回会话头部那一个,不会出现两个。
331
+
332
+ 实现上用的是该座位给每个全局占用者注入的 `useSessions`:`state.current` 就是框架自己对「现在有没有打开会话」的回答,
333
+ 不需要去偷看 DOM。hero 上的三个座位(品牌标记 / 工作区选择器 / 代理预设)都是单占位且已被占用,所以走 overlay 座位。
334
+
192
335
  ### 在设置页里改(1.1.0 起)
193
336
 
194
337
  上面这些字段也可以在 **设置 → 通用设置** 里改,就在「语言」「外观」那几行下面:「登录页与策略配置」一行,
@@ -237,13 +380,25 @@ node scripts/import-from-dsh-passwords.mjs --from E:\path\to\dsh-passwords --ove
237
380
  ## 测试
238
381
 
239
382
  ```bash
240
- npm test # 138 项:策略纯函数、store、API 层、客户端契约、以及真实网关的端到端用例
383
+ npm test # 248 项:策略纯函数、store、API 层、客户端契约、以及真实网关的端到端用例
241
384
  npm run check # 全量语法检查
242
385
  ```
243
386
 
244
387
  端到端用例会起真实网关(明文模式 + 假上游)验证:工作区过滤含 gzip、目录门与请求体重放、沙盒提权拦截与审批改写、
245
388
  上传 / git / 运维面门控、历史降级与隐藏字符清洗、配额拦截、归属认领与释放。
246
389
 
390
+ `test/dsh-0.1.5-wire.test.mjs` 另外覆盖第二代 wire:假的 dsh 上游在 `/api/remote.mux` 上做真实的
391
+ WebSocket 握手,按 `streamId` 收发 JSON 文本帧,验证 `workspace/follow` 的 baseline / upsert / order 过滤、
392
+ `session/follow` 的 snapshot 与逐条事件钳制、`$events` waterfall 记录 + `$events/result` 审批拒绝、
393
+ `session/page` 改写、`session/control` 的权限投影钳制、以及 `workspaceFiles/readAll` 门控;
394
+ 纯函数部分(`dsh-wire.js` 的帧计划、`mux-filter.js` 的编解码)单独覆盖丢弃 / 透传 / 分片放行语义。
395
+
396
+ `test/session-visibility.test.mjs` 覆盖会话可见性:越界 cwd 的行被摘掉、没 cwd 与子代理行保留、
397
+ 别人认领目录(含其子目录)里的会话被摘掉(`blank` 也照摘)、自己认领的共享目录不摘、管理员原样、
398
+ 空白名单全摘、`session.search` 跟随列表的隐藏清单、以及 **SSE 那一路**——假上游按真实
399
+ `data: {…}\n\n` 写 `host/session-added`,且**故意从块中间切成两个 chunk** 写出来,验证缓冲与逐块判定。
400
+ 所有响应都用真实信封形状,裸 `{ items }` 只作为兼容形状另测一次。
401
+
247
402
  ## 卸载 / 回滚
248
403
 
249
404
  ```bash
@@ -264,6 +419,18 @@ dsh plugin --profile web remove kczx-user-management
264
419
  - **会话历史要缓冲后才能改写**:历史响应是**一个 JSON 文档**(长会话实测 9–16 MB),网关必须先缓冲才能做沙盒降级与隐藏字符
265
420
  清洗,因此有 `historyMaxBytes` 上限(默认 64 MiB)。超过上限的会话会 **fail-closed 返回 502**,UI 显示「历史加载失败」——
266
421
  调大该值即可;1.0.0 之前用代理默认的 8 MiB,长会话必然踩中。
422
+ - **第二代 wire 的强制有两条前提**:一是浏览器走本网关(直连宿主端口没有任何强制,见上),二是帧是
423
+ **单帧 JSON 文本**。分片的 WebSocket 消息、二进制帧、以及 ping/pong 控制帧一律原样放行——为了一个分片帧去缓冲
424
+ 别人整条流不划算;`ws` 宿主与浏览器发这些控制消息都不分片,所以这条边界在实践中碰不到。
425
+ - **SSE 过滤会「认输」**:一个块迟迟不结束、或缓冲超过 1 MiB,就原样转发并停止改写这条连接(宁可漏拦,不能把实时流卡住)。
426
+ - **A 方案只挡「看得到」**:知道 sessionId 仍然能通过 `session/page` / `session/follow`(0.1.1 是 `session.history`)
427
+ 读别人的会话——那是 B 方案的事。
428
+ - **审批记忆是进程内的**:网关在 mux 上看到 `approval/request` 的 waterfall 帧后记住它的 eventId。
429
+ 网关重启期间若浏览器重连,宿主会重放未决的 waterfall(同一个 eventId),记忆随之重建;万一两条依据都没命中
430
+ (既没记住、值也不是 `allowed-once`),该次应答按原样放行——这是有意的取舍:把 `$events/result` 一律拒掉
431
+ 会连 `ask_user_question` 的答案一起吃掉。
432
+ - **整文件读取按「整文件」划界**:`readAll` / `readRelated` 受开关管,窗口化的 `readBytes` 不受管——反复调它理论上
433
+ 也能把文件搬走,但它是图片预览与分页阅读的底座,关掉会连文件浏览器一起关掉。
267
434
  - 上游 `@weibaohui/user-management`(0.9.5)与本包 `kczx-user-management` 是两个不同的包,互不覆盖,`pnpm update` 不会把两者串在一起。
268
435
 
269
436
  ## License