@modesto_ma/dsh-channels 0.0.0-stage → 0.1.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.
Files changed (73) hide show
  1. package/README.md +302 -2
  2. package/cordis.patch.yml +3 -0
  3. package/docs/access-control-design.md +550 -0
  4. package/docs/assets/web-settings-panel.png +0 -0
  5. package/docs/assets/web-settings-restart-pending.png +0 -0
  6. package/docs/feasibility-and-design.md +213 -0
  7. package/docs/feishu-onboarding.md +268 -0
  8. package/docs/persistence.md +199 -0
  9. package/docs/refactor-plan.md +56 -0
  10. package/docs/review-dsh-0.2.0.md +389 -0
  11. package/docs/slash-commands-implementation-plan.md +410 -0
  12. package/docs/web-display-only-surface-roadmap.md +363 -0
  13. package/lib/client.js +2226 -0
  14. package/lib/client.js.map +1 -0
  15. package/lib/index.js +10217 -0
  16. package/lib/index.js.map +1 -0
  17. package/package.json +98 -4
  18. package/src/access/cards.ts +188 -0
  19. package/src/access/controller.ts +201 -0
  20. package/src/access/index.ts +13 -0
  21. package/src/access/policy.ts +103 -0
  22. package/src/access/requests.ts +512 -0
  23. package/src/access/store.ts +220 -0
  24. package/src/approvals.ts +8 -0
  25. package/src/cards/approval.ts +114 -0
  26. package/src/cards/index.ts +11 -0
  27. package/src/cards/primitives.ts +82 -0
  28. package/src/cards/question.ts +141 -0
  29. package/src/cards/streaming.ts +563 -0
  30. package/src/cards.ts +8 -0
  31. package/src/cleanup.ts +398 -0
  32. package/src/client/api.ts +262 -0
  33. package/src/client/catalog.ts +92 -0
  34. package/src/client/index.ts +61 -0
  35. package/src/client/panel.tsx +1475 -0
  36. package/src/client/styles.ts +98 -0
  37. package/src/commands/registry.ts +155 -0
  38. package/src/commands.ts +335 -0
  39. package/src/config.ts +206 -0
  40. package/src/domain.ts +428 -0
  41. package/src/dsh-types.ts +11 -0
  42. package/src/feishu/agent-context.ts +62 -0
  43. package/src/feishu/delivery.ts +188 -0
  44. package/src/feishu/inbound-attachments.ts +436 -0
  45. package/src/feishu-channel.ts +339 -0
  46. package/src/health.ts +233 -0
  47. package/src/host/agent.ts +181 -0
  48. package/src/host/commands.ts +63 -0
  49. package/src/host/core.ts +345 -0
  50. package/src/host/index.ts +12 -0
  51. package/src/host/interaction.ts +101 -0
  52. package/src/host/services.ts +320 -0
  53. package/src/index.ts +122 -0
  54. package/src/interactions/approvals.ts +154 -0
  55. package/src/interactions/pending.ts +162 -0
  56. package/src/interactions/questions.ts +274 -0
  57. package/src/questions.ts +8 -0
  58. package/src/routing/inbound.ts +524 -0
  59. package/src/service.ts +2138 -0
  60. package/src/session/manager.ts +352 -0
  61. package/src/session/repair.ts +157 -0
  62. package/src/session/request-policy.ts +192 -0
  63. package/src/session/routes.ts +165 -0
  64. package/src/session-commands.ts +310 -0
  65. package/src/session-export.ts +112 -0
  66. package/src/settings/catalog.ts +309 -0
  67. package/src/settings/contract.ts +508 -0
  68. package/src/settings/overlay.ts +164 -0
  69. package/src/settings/routes.ts +409 -0
  70. package/src/settings/surface.ts +773 -0
  71. package/src/stats.ts +485 -0
  72. package/src/streamer.ts +1108 -0
  73. package/src/workspaces.ts +199 -0
@@ -0,0 +1,409 @@
1
+ /**
2
+ * Web 设置面板的 HTTP 路由:status / panel / catalog / config(PUT,DELETE) /
3
+ * credentials(PUT,DELETE) / reconnect / cleanup / access。
4
+ *
5
+ * 路由注册进宿主可选的 `webServer` 服务(headless profile 没有它时面板自然
6
+ * 不存在,飞书通道不受影响)。写操作额外做同源校验 —— 本机非回环绑定下
7
+ * 防止跨站页面改写插件配置或授权白名单;读取不敏感,保持只读 GET。凭据值
8
+ * 只走写路径,响应永远不包含它们。`catalog` 只读宿主 LLM / preset roster 的
9
+ * 枚举结果,用于面板的 provider → model → 推理档位级联选择。`access` 提供
10
+ * 准入快照与白名单/授权申请的写入口(与飞书卡片共用同一结算函数)。
11
+ *
12
+ * @module dsh-channels/settings/routes
13
+ */
14
+
15
+ import type { IncomingMessage, ServerResponse } from 'node:http'
16
+ import type { HostContext } from '../dsh-types.ts'
17
+ import {
18
+ ACCESS_GROUPS_ROUTE,
19
+ ACCESS_REQUESTS_ROUTE,
20
+ ACCESS_ROUTE,
21
+ ACCESS_USERS_ROUTE,
22
+ CATALOG_ROUTE,
23
+ CLEANUP_ROUTE,
24
+ CONFIG_ROUTE,
25
+ CREDENTIALS_ROUTE,
26
+ PANEL_ROUTE,
27
+ RECONNECT_ROUTE,
28
+ STATUS_ROUTE,
29
+ } from './contract.ts'
30
+ import type { CredentialField, EditableValues, SaveCredentialsRequest } from './contract.ts'
31
+ import type { SettingsSurface } from './surface.ts'
32
+
33
+ /** The narrow `ctx.webServer` surface this module registers into. */
34
+ export interface HostWebServer {
35
+ register(route: {
36
+ kind: 'exact'
37
+ path: string
38
+ handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
39
+ }): () => void
40
+ }
41
+
42
+ type WebHandler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
43
+
44
+ /** Request body cap: the panel payload is a handful of primitives. */
45
+ const MAX_BODY_BYTES = 64 * 1024
46
+
47
+ function messageOf(error: unknown): string {
48
+ return error instanceof Error ? error.message : String(error)
49
+ }
50
+
51
+ class HttpError extends Error {
52
+ constructor(readonly status: number, message: string) {
53
+ super(message)
54
+ }
55
+ }
56
+
57
+ function methodOf(req: IncomingMessage): string {
58
+ return (req.method ?? 'GET').toUpperCase()
59
+ }
60
+
61
+ function sendJson(res: ServerResponse, status: number, payload: unknown): void {
62
+ const body = JSON.stringify(payload)
63
+ res.writeHead(status, {
64
+ 'content-type': 'application/json; charset=utf-8',
65
+ 'cache-control': 'no-store',
66
+ 'content-length': Buffer.byteLength(body),
67
+ })
68
+ res.end(body)
69
+ }
70
+
71
+ /** Reject non-allowed methods with 405 + Allow, returning false. */
72
+ function allowOnly(req: IncomingMessage, res: ServerResponse, allowed: readonly string[]): boolean {
73
+ const method = methodOf(req)
74
+ if (allowed.includes(method)) return true
75
+ const body = JSON.stringify({ ok: false, error: `方法 ${method} 不受支持` })
76
+ res.writeHead(405, {
77
+ allow: allowed.join(', '),
78
+ 'content-type': 'application/json; charset=utf-8',
79
+ 'cache-control': 'no-store',
80
+ 'content-length': Buffer.byteLength(body),
81
+ })
82
+ res.end(body)
83
+ return false
84
+ }
85
+
86
+ /**
87
+ * Mutating requests must come from this same origin. Missing Origin is allowed
88
+ * (non-browser local clients, e.g. curl); a present-but-mismatched Origin is
89
+ * a cross-site write and is refused.
90
+ */
91
+ export function isSameOriginRequest(req: IncomingMessage): boolean {
92
+ const origin = req.headers.origin
93
+ if (origin === undefined || origin === '') return true
94
+ const host = req.headers.host
95
+ if (host === undefined) return false
96
+ try {
97
+ return new URL(origin).host === host
98
+ } catch {
99
+ return false
100
+ }
101
+ }
102
+
103
+ async function readJsonBody(req: IncomingMessage): Promise<unknown> {
104
+ const contentType = (req.headers['content-type'] ?? '').split(';')[0]?.trim().toLowerCase()
105
+ if (contentType !== 'application/json') {
106
+ throw new HttpError(415, '请求体必须是 application/json')
107
+ }
108
+ const chunks: Buffer[] = []
109
+ let size = 0
110
+ for await (const chunk of req) {
111
+ const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk as Buffer
112
+ size += buffer.length
113
+ if (size > MAX_BODY_BYTES) throw new HttpError(413, '请求体过大')
114
+ chunks.push(buffer)
115
+ }
116
+ if (size === 0) throw new HttpError(400, '请求体为空')
117
+ try {
118
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
119
+ } catch {
120
+ throw new HttpError(400, '请求体不是合法 JSON')
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Parse one JSON-object body for an access write. HttpErrors are answered
126
+ * here (415/413/400); `undefined` means "already responded, stop".
127
+ */
128
+ async function readAccessBody(
129
+ req: IncomingMessage,
130
+ res: ServerResponse,
131
+ ): Promise<Record<string, unknown> | undefined> {
132
+ let body: unknown
133
+ try {
134
+ body = await readJsonBody(req)
135
+ } catch (error) {
136
+ if (error instanceof HttpError) {
137
+ sendJson(res, error.status, { ok: false, error: error.message })
138
+ return undefined
139
+ }
140
+ throw error
141
+ }
142
+ if (typeof body !== 'object' || body === null || Array.isArray(body)) {
143
+ sendJson(res, 400, { ok: false, error: '请求体必须是 JSON 对象' })
144
+ return undefined
145
+ }
146
+ return body as Record<string, unknown>
147
+ }
148
+
149
+ /** Wrap a handler so thrown errors become JSON instead of a dropped socket. */
150
+ function guarded(handler: WebHandler): WebHandler {
151
+ return async (req, res) => {
152
+ try {
153
+ await handler(req, res)
154
+ } catch (error) {
155
+ if (res.headersSent) {
156
+ res.end()
157
+ return
158
+ }
159
+ const status = error instanceof HttpError ? error.status : 500
160
+ sendJson(res, status, {
161
+ ok: false,
162
+ error: error instanceof HttpError ? error.message : `内部错误:${messageOf(error)}`,
163
+ })
164
+ }
165
+ }
166
+ }
167
+
168
+ function forbiddenWrite(res: ServerResponse): void {
169
+ sendJson(res, 403, { ok: false, error: '跨站写入被拒绝(Origin 与 Host 不一致)' })
170
+ }
171
+
172
+ /** Register the panel routes; the returned disposer removes all of them. */
173
+ export function registerSettingsRoutes(server: HostWebServer, surface: SettingsSurface): () => void {
174
+ const disposers = [
175
+ server.register({
176
+ kind: 'exact',
177
+ path: STATUS_ROUTE,
178
+ handler: guarded(async (req, res) => {
179
+ if (!allowOnly(req, res, ['GET', 'HEAD'])) return
180
+ if (methodOf(req) === 'HEAD') {
181
+ res.writeHead(200, { 'cache-control': 'no-store' })
182
+ res.end()
183
+ return
184
+ }
185
+ sendJson(res, 200, await surface.status())
186
+ }),
187
+ }),
188
+ server.register({
189
+ kind: 'exact',
190
+ path: PANEL_ROUTE,
191
+ handler: guarded(async (req, res) => {
192
+ if (!allowOnly(req, res, ['GET', 'HEAD'])) return
193
+ if (methodOf(req) === 'HEAD') {
194
+ res.writeHead(200, { 'cache-control': 'no-store' })
195
+ res.end()
196
+ return
197
+ }
198
+ sendJson(res, 200, await surface.panel())
199
+ }),
200
+ }),
201
+ server.register({
202
+ kind: 'exact',
203
+ path: CATALOG_ROUTE,
204
+ handler: guarded(async (req, res) => {
205
+ if (!allowOnly(req, res, ['GET', 'HEAD'])) return
206
+ if (methodOf(req) === 'HEAD') {
207
+ res.writeHead(200, { 'cache-control': 'no-store' })
208
+ res.end()
209
+ return
210
+ }
211
+ const url = new URL(req.url ?? '/', 'http://dsh.local')
212
+ sendJson(res, 200, await surface.catalogFor(
213
+ url.searchParams.get('provider') ?? undefined,
214
+ url.searchParams.get('model') ?? undefined,
215
+ ))
216
+ }),
217
+ }),
218
+ server.register({
219
+ kind: 'exact',
220
+ path: CONFIG_ROUTE,
221
+ handler: guarded(async (req, res) => {
222
+ if (!allowOnly(req, res, ['PUT', 'DELETE'])) return
223
+ if (!isSameOriginRequest(req)) {
224
+ forbiddenWrite(res)
225
+ return
226
+ }
227
+ if (methodOf(req) === 'DELETE') {
228
+ const result = await surface.clear()
229
+ sendJson(res, result.ok ? 200 : 400, result)
230
+ return
231
+ }
232
+ let body: unknown
233
+ try {
234
+ body = await readJsonBody(req)
235
+ } catch (error) {
236
+ if (error instanceof HttpError) {
237
+ sendJson(res, error.status, { ok: false, error: error.message })
238
+ return
239
+ }
240
+ throw error
241
+ }
242
+ const values = (body as { values?: unknown } | null)?.values
243
+ if (typeof values !== 'object' || values === null || Array.isArray(values)) {
244
+ sendJson(res, 400, { ok: false, error: '请求体缺少 values 对象' })
245
+ return
246
+ }
247
+ const result = await surface.save(values as EditableValues)
248
+ sendJson(res, result.ok ? 200 : 400, result)
249
+ }),
250
+ }),
251
+ server.register({
252
+ kind: 'exact',
253
+ path: CREDENTIALS_ROUTE,
254
+ handler: guarded(async (req, res) => {
255
+ if (!allowOnly(req, res, ['PUT', 'DELETE'])) return
256
+ if (!isSameOriginRequest(req)) {
257
+ forbiddenWrite(res)
258
+ return
259
+ }
260
+ let body: unknown
261
+ try {
262
+ body = await readJsonBody(req)
263
+ } catch (error) {
264
+ if (error instanceof HttpError) {
265
+ sendJson(res, error.status, { ok: false, error: error.message })
266
+ return
267
+ }
268
+ throw error
269
+ }
270
+ const payload = body as { appId?: unknown; appSecret?: unknown; field?: unknown } | null
271
+ if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
272
+ sendJson(res, 400, { ok: false, error: '请求体必须是 JSON 对象' })
273
+ return
274
+ }
275
+ if (methodOf(req) === 'DELETE') {
276
+ const field = payload.field
277
+ if (field !== 'appId' && field !== 'appSecret') {
278
+ sendJson(res, 400, { ok: false, error: 'field 必须是 appId 或 appSecret' })
279
+ return
280
+ }
281
+ const result = await surface.clearCredential(field as CredentialField)
282
+ sendJson(res, result.ok ? 200 : 400, result)
283
+ return
284
+ }
285
+ const values: SaveCredentialsRequest = {}
286
+ if (payload.appId !== undefined) values.appId = payload.appId as string
287
+ if (payload.appSecret !== undefined) values.appSecret = payload.appSecret as string
288
+ const result = await surface.saveCredentials(values)
289
+ sendJson(res, result.ok ? 200 : 400, result)
290
+ }),
291
+ }),
292
+ server.register({
293
+ kind: 'exact',
294
+ path: RECONNECT_ROUTE,
295
+ handler: guarded(async (req, res) => {
296
+ if (!allowOnly(req, res, ['POST'])) return
297
+ if (!isSameOriginRequest(req)) {
298
+ forbiddenWrite(res)
299
+ return
300
+ }
301
+ const result = await surface.reconnect()
302
+ sendJson(res, result.ok ? 200 : 500, result)
303
+ }),
304
+ }),
305
+ server.register({
306
+ kind: 'exact',
307
+ path: CLEANUP_ROUTE,
308
+ handler: guarded(async (req, res) => {
309
+ if (!allowOnly(req, res, ['POST'])) return
310
+ if (!isSameOriginRequest(req)) {
311
+ forbiddenWrite(res)
312
+ return
313
+ }
314
+ const result = await surface.runCleanup()
315
+ sendJson(res, result.ok ? 200 : 500, result)
316
+ }),
317
+ }),
318
+ server.register({
319
+ kind: 'exact',
320
+ path: ACCESS_ROUTE,
321
+ handler: guarded(async (req, res) => {
322
+ if (!allowOnly(req, res, ['GET', 'HEAD'])) return
323
+ if (methodOf(req) === 'HEAD') {
324
+ res.writeHead(200, { 'cache-control': 'no-store' })
325
+ res.end()
326
+ return
327
+ }
328
+ sendJson(res, 200, await surface.access())
329
+ }),
330
+ }),
331
+ server.register({
332
+ kind: 'exact',
333
+ path: ACCESS_GROUPS_ROUTE,
334
+ handler: guarded(async (req, res) => {
335
+ if (!allowOnly(req, res, ['POST', 'DELETE'])) return
336
+ if (!isSameOriginRequest(req)) {
337
+ forbiddenWrite(res)
338
+ return
339
+ }
340
+ const payload = await readAccessBody(req, res)
341
+ if (payload === undefined) return
342
+ const result = methodOf(req) === 'DELETE'
343
+ ? await surface.removeAccessGroup(payload.chatId)
344
+ : await surface.saveAccessGroup(payload.chatId, payload.name)
345
+ sendJson(res, result.ok ? 200 : 400, result)
346
+ }),
347
+ }),
348
+ server.register({
349
+ kind: 'exact',
350
+ path: ACCESS_USERS_ROUTE,
351
+ handler: guarded(async (req, res) => {
352
+ if (!allowOnly(req, res, ['POST', 'DELETE'])) return
353
+ if (!isSameOriginRequest(req)) {
354
+ forbiddenWrite(res)
355
+ return
356
+ }
357
+ const payload = await readAccessBody(req, res)
358
+ if (payload === undefined) return
359
+ const result = methodOf(req) === 'DELETE'
360
+ ? await surface.removeAccessUser(payload.openId)
361
+ : await surface.saveAccessUser(payload.openId, payload.name)
362
+ sendJson(res, result.ok ? 200 : 400, result)
363
+ }),
364
+ }),
365
+ server.register({
366
+ kind: 'exact',
367
+ path: ACCESS_REQUESTS_ROUTE,
368
+ handler: guarded(async (req, res) => {
369
+ if (!allowOnly(req, res, ['POST'])) return
370
+ if (!isSameOriginRequest(req)) {
371
+ forbiddenWrite(res)
372
+ return
373
+ }
374
+ const payload = await readAccessBody(req, res)
375
+ if (payload === undefined) return
376
+ const result = await surface.resolveAccessRequest(payload.token, payload.decision)
377
+ sendJson(res, result.ok ? 200 : 400, result)
378
+ }),
379
+ }),
380
+ ]
381
+ return () => {
382
+ for (const dispose of disposers) dispose()
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Mount the routes once the host carries a Web server. The optional
388
+ * `ctx.inject` keeps headless profiles from waiting on a service they will
389
+ * never have; embedded/test hosts that resolve services synchronously fall
390
+ * back to the direct attach path.
391
+ */
392
+ export function mountSettingsRoutes(ctx: HostContext, surface: SettingsSurface): void {
393
+ const attach = (webCtx: HostContext): void => {
394
+ const server = webCtx.get?.('webServer') as HostWebServer | undefined
395
+ if (server === undefined) return
396
+ webCtx.effect(
397
+ () => registerSettingsRoutes(server, surface),
398
+ 'dsh-channels: settings panel routes',
399
+ )
400
+ }
401
+ const inject = (ctx as {
402
+ inject?: (dependencies: string[], callback: (child: HostContext) => void) => unknown
403
+ }).inject
404
+ if (typeof inject === 'function') {
405
+ inject.call(ctx, ['webServer'], attach)
406
+ return
407
+ }
408
+ attach(ctx)
409
+ }