@modesto_ma/dsh-channels 0.5.0 → 0.7.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.
@@ -1,35 +1,38 @@
1
1
  /**
2
- * Web 设置面板的 HTTP 路由:status / panel / catalog / config(PUT,DELETE) /
3
- * credentials(PUT,DELETE) / reconnect / access。
2
+ * Web 设置面板的 HTTP 路由:DSH `/api` 下的精确 Fetch 路由
3
+ * `/api/dsh-channels.<方法>`(读用 GET,写一律 POST)。
4
4
  *
5
- * 路由注册进宿主可选的 `webServer` 服务(headless profile 没有它时面板自然
6
- * 不存在,飞书通道不受影响)。每个路由(读写都算)先交给宿主 `connection`
7
- * 服务的 `requestRejection`:与 DSH 自己的 `/api` 同一套 Host/Origin 校验和
8
- * 浏览器登录 cookie 鉴权,未通过返回 403 / 401,请求体不会被读取。没有
9
- * `connection` 服务的组合不挂载这些路由。写操作另保留同源校验。凭据值
10
- * 只走写路径,响应永远不包含它们。`catalog` 只读宿主 LLM / preset roster 的
11
- * 枚举结果,用于面板的 provider → model → 推理档位级联选择。`access` 提供
12
- * 准入快照与白名单/授权申请的写入口(与飞书卡片共用同一结算函数)。
5
+ * 路由注册进宿主 `connection.fetch`(headless profile 没有它时面板自然不存在,
6
+ * 飞书通道不受影响)。DSH 的 `/api` 路由先做 Host/Origin 校验和浏览器登录
7
+ * cookie 鉴权,只有通过的 owner 请求才会到达这里,未通过时由 DSH 直接返回
8
+ * 401 / 403。凭据值只走写路径,响应永远不包含它们。`catalog` 只读宿主
9
+ * LLM / preset roster 的枚举结果,用于面板的 provider → model → 推理档位
10
+ * 级联选择。`access` 提供准入快照与白名单/授权申请的写入口(与飞书卡片共用
11
+ * 同一结算函数)。
13
12
  *
14
13
  * @module dsh-channels/settings/routes
15
14
  */
16
15
 
17
- import type { IncomingMessage, ServerResponse } from 'node:http'
18
16
  import type { HostContext } from '../dsh-types.ts'
19
17
  import {
20
- ACCESS_GROUPS_ROUTE,
21
- ACCESS_REQUESTS_ROUTE,
22
18
  ACCESS_ROUTE,
23
- ACCESS_USERS_ROUTE,
24
- ACCESS_WIKI_USERS_ROUTE,
19
+ ADD_ACCESS_GROUP_ROUTE,
20
+ ADD_ACCESS_USER_ROUTE,
21
+ ADD_WIKI_USER_ROUTE,
25
22
  CATALOG_ROUTE,
26
- CONFIG_ROUTE,
27
- CREDENTIALS_ROUTE,
23
+ CLEAR_CONFIG_ROUTE,
24
+ CLEAR_CREDENTIAL_ROUTE,
28
25
  PANEL_ROUTE,
29
26
  PICK_DIRECTORY_ROUTE,
30
27
  PROFILES_MAX_IDS,
31
28
  PROFILES_ROUTE,
32
29
  RECONNECT_ROUTE,
30
+ REMOVE_ACCESS_GROUP_ROUTE,
31
+ REMOVE_ACCESS_USER_ROUTE,
32
+ REMOVE_WIKI_USER_ROUTE,
33
+ RESOLVE_ACCESS_REQUEST_ROUTE,
34
+ SAVE_CONFIG_ROUTE,
35
+ SAVE_CREDENTIALS_ROUTE,
33
36
  SESSION_ORIGIN_ROUTE,
34
37
  STATUS_ROUTE,
35
38
  } from './contract.ts'
@@ -41,29 +44,28 @@ import type {
41
44
  } from './contract.ts'
42
45
  import type { SettingsSurface } from './surface.ts'
43
46
 
44
- /** The narrow `ctx.webServer` surface this module registers into. */
45
- export interface HostWebServer {
46
- register(route: {
47
- kind: 'exact'
48
- path: string
49
- handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
50
- }): () => void
47
+ /** One exact route on DSH's authenticated `/api` channel. */
48
+ export interface HostFetchRoute {
49
+ readonly path: string
50
+ readonly methods: readonly ('GET' | 'POST')[]
51
+ readonly requestBody: 'buffered'
52
+ readonly fetch: (request: Request) => Promise<Response>
51
53
  }
52
54
 
53
55
  /**
54
- * The narrow `ctx.connection` surface this module consumes: DSH's own
55
- * Host/Origin fence plus browser (login cookie) or API authentication.
56
+ * The narrow `ctx.connection` surface this module registers into. DSH applies
57
+ * its Host/Origin fence and login-cookie authentication before `fetch` runs.
56
58
  */
57
- export interface HostRequestGate {
58
- /**
59
- * @param request - incoming request headers.
60
- * @returns 403 (untrusted Host/Origin), 401 (unauthenticated), or undefined when allowed.
61
- */
62
- requestRejection(request: { readonly headers: IncomingMessage['headers'] }): 401 | 403 | undefined
59
+ export interface HostConnection {
60
+ readonly fetch: {
61
+ /**
62
+ * @param route - exact path, methods, and handler.
63
+ * @returns asynchronous disposer removing the route.
64
+ */
65
+ register(route: HostFetchRoute): () => Promise<void>
66
+ }
63
67
  }
64
68
 
65
- type WebHandler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
66
-
67
69
  /** Request body cap: the panel payload is a handful of primitives. */
68
70
  const MAX_BODY_BYTES = 64 * 1024
69
71
 
@@ -77,71 +79,30 @@ class HttpError extends Error {
77
79
  }
78
80
  }
79
81
 
80
- function methodOf(req: IncomingMessage): string {
81
- return (req.method ?? 'GET').toUpperCase()
82
- }
83
-
84
- function sendJson(res: ServerResponse, status: number, payload: unknown): void {
85
- const body = JSON.stringify(payload)
86
- res.writeHead(status, {
87
- 'content-type': 'application/json; charset=utf-8',
88
- 'cache-control': 'no-store',
89
- 'content-length': Buffer.byteLength(body),
90
- })
91
- res.end(body)
92
- }
93
-
94
- /** Reject non-allowed methods with 405 + Allow, returning false. */
95
- function allowOnly(req: IncomingMessage, res: ServerResponse, allowed: readonly string[]): boolean {
96
- const method = methodOf(req)
97
- if (allowed.includes(method)) return true
98
- const body = JSON.stringify({ ok: false, error: `方法 ${method} 不受支持` })
99
- res.writeHead(405, {
100
- allow: allowed.join(', '),
101
- 'content-type': 'application/json; charset=utf-8',
102
- 'cache-control': 'no-store',
103
- 'content-length': Buffer.byteLength(body),
82
+ function json(status: number, payload: unknown): Response {
83
+ return new Response(JSON.stringify(payload), {
84
+ status,
85
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
104
86
  })
105
- res.end(body)
106
- return false
107
87
  }
108
88
 
109
- /**
110
- * Mutating requests must come from this same origin. Missing Origin is allowed
111
- * (non-browser local clients, e.g. curl); a present-but-mismatched Origin is
112
- * a cross-site write and is refused.
113
- */
114
- export function isSameOriginRequest(req: IncomingMessage): boolean {
115
- const origin = req.headers.origin
116
- if (origin === undefined || origin === '') return true
117
- const host = req.headers.host
118
- if (host === undefined) return false
119
- try {
120
- return new URL(origin).host === host
121
- } catch {
122
- return false
123
- }
124
- }
125
-
126
- async function readJsonBody(req: IncomingMessage): Promise<unknown> {
127
- const contentType = (req.headers['content-type'] ?? '').split(';')[0]?.trim().toLowerCase()
128
- if (contentType !== 'application/json') {
129
- throw new HttpError(415, '请求体必须是 application/json')
130
- }
131
- const chunks: Buffer[] = []
132
- let size = 0
133
- for await (const chunk of req) {
134
- const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk as Buffer
135
- size += buffer.length
136
- if (size > MAX_BODY_BYTES) throw new HttpError(413, '请求体过大')
137
- chunks.push(buffer)
138
- }
139
- if (size === 0) throw new HttpError(400, '请求体为空')
89
+ /** Parse a JSON-object body; malformed bodies throw {@link HttpError} (415/413/400). */
90
+ async function readJsonObject(request: Request): Promise<Record<string, unknown>> {
91
+ const contentType = (request.headers.get('content-type') ?? '').split(';')[0]?.trim().toLowerCase()
92
+ if (contentType !== 'application/json') throw new HttpError(415, '请求体必须是 application/json')
93
+ const text = await request.text()
94
+ if (Buffer.byteLength(text) > MAX_BODY_BYTES) throw new HttpError(413, '请求体过大')
95
+ if (text === '') throw new HttpError(400, '请求体为空')
96
+ let body: unknown
140
97
  try {
141
- return JSON.parse(Buffer.concat(chunks).toString('utf8'))
98
+ body = JSON.parse(text)
142
99
  } catch {
143
100
  throw new HttpError(400, '请求体不是合法 JSON')
144
101
  }
102
+ if (typeof body !== 'object' || body === null || Array.isArray(body)) {
103
+ throw new HttpError(400, '请求体必须是 JSON 对象')
104
+ }
105
+ return body as Record<string, unknown>
145
106
  }
146
107
 
147
108
  /** Distinct non-empty entries of one comma-separated id query parameter. */
@@ -150,383 +111,138 @@ function idList(raw: string | null): string[] {
150
111
  return [...new Set(raw.split(',').map(id => id.trim()).filter(id => id !== ''))]
151
112
  }
152
113
 
153
- /**
154
- * Parse one JSON-object body for an access write. HttpErrors are answered
155
- * here (415/413/400); `undefined` means "already responded, stop".
156
- */
157
- async function readAccessBody(
158
- req: IncomingMessage,
159
- res: ServerResponse,
160
- ): Promise<Record<string, unknown> | undefined> {
161
- let body: unknown
162
- try {
163
- body = await readJsonBody(req)
164
- } catch (error) {
165
- if (error instanceof HttpError) {
166
- sendJson(res, error.status, { ok: false, error: error.message })
167
- return undefined
168
- }
169
- throw error
170
- }
171
- if (typeof body !== 'object' || body === null || Array.isArray(body)) {
172
- sendJson(res, 400, { ok: false, error: '请求体必须是 JSON 对象' })
173
- return undefined
174
- }
175
- return body as Record<string, unknown>
114
+ /** A write's answer: 200 on success, 400 for a refusal the body explains. */
115
+ function result(outcome: { ok: boolean }): Response {
116
+ return json(outcome.ok ? 200 : 400, outcome)
176
117
  }
177
118
 
178
- /**
179
- * Wrap a handler so the host gate runs before anything else and thrown
180
- * errors become JSON instead of a dropped socket.
181
- */
182
- function guardedBy(gate: HostRequestGate, handler: WebHandler): WebHandler {
183
- return async (req, res) => {
184
- const rejection = gate.requestRejection(req)
185
- if (rejection !== undefined) {
186
- sendJson(res, rejection, {
187
- ok: false,
188
- error: rejection === 401 ? '未登录 DSH,请从 DSH 启动链接打开页面' : '请求来源不受信任',
189
- })
190
- return
191
- }
119
+ /** Turn thrown errors into JSON answers instead of a dropped request. */
120
+ function answered(handler: (request: Request) => Promise<Response>): (request: Request) => Promise<Response> {
121
+ return async (request) => {
192
122
  try {
193
- await handler(req, res)
123
+ return await handler(request)
194
124
  } catch (error) {
195
- if (res.headersSent) {
196
- res.end()
197
- return
198
- }
199
- const status = error instanceof HttpError ? error.status : 500
200
- sendJson(res, status, {
201
- ok: false,
202
- error: error instanceof HttpError ? error.message : `内部错误:${messageOf(error)}`,
203
- })
125
+ if (error instanceof HttpError) return json(error.status, { ok: false, error: error.message })
126
+ return json(500, { ok: false, error: `内部错误:${messageOf(error)}` })
204
127
  }
205
128
  }
206
129
  }
207
130
 
208
- function forbiddenWrite(res: ServerResponse): void {
209
- sendJson(res, 403, { ok: false, error: '跨站写入被拒绝(Origin 与 Host 不一致)' })
210
- }
211
-
212
- /** Register the panel routes; the returned disposer removes all of them. */
213
- export function registerSettingsRoutes(
214
- server: HostWebServer,
215
- gate: HostRequestGate,
216
- surface: SettingsSurface,
217
- ): () => void {
218
- const guarded = (handler: WebHandler): WebHandler => guardedBy(gate, handler)
219
- const disposers = [
220
- server.register({
221
- kind: 'exact',
222
- path: STATUS_ROUTE,
223
- handler: guarded(async (req, res) => {
224
- if (!allowOnly(req, res, ['GET', 'HEAD'])) return
225
- if (methodOf(req) === 'HEAD') {
226
- res.writeHead(200, { 'cache-control': 'no-store' })
227
- res.end()
228
- return
229
- }
230
- sendJson(res, 200, await surface.status())
231
- }),
232
- }),
233
- server.register({
234
- kind: 'exact',
235
- path: PANEL_ROUTE,
236
- handler: guarded(async (req, res) => {
237
- if (!allowOnly(req, res, ['GET', 'HEAD'])) return
238
- if (methodOf(req) === 'HEAD') {
239
- res.writeHead(200, { 'cache-control': 'no-store' })
240
- res.end()
241
- return
242
- }
243
- sendJson(res, 200, await surface.panel())
244
- }),
245
- }),
246
- server.register({
247
- kind: 'exact',
248
- path: CATALOG_ROUTE,
249
- handler: guarded(async (req, res) => {
250
- if (!allowOnly(req, res, ['GET', 'HEAD'])) return
251
- if (methodOf(req) === 'HEAD') {
252
- res.writeHead(200, { 'cache-control': 'no-store' })
253
- res.end()
254
- return
255
- }
256
- const url = new URL(req.url ?? '/', 'http://dsh.local')
257
- sendJson(res, 200, await surface.catalogFor(
258
- url.searchParams.get('provider') ?? undefined,
259
- url.searchParams.get('model') ?? undefined,
260
- ))
261
- }),
262
- }),
263
- server.register({
264
- kind: 'exact',
265
- path: SESSION_ORIGIN_ROUTE,
266
- handler: guarded(async (req, res) => {
267
- if (!allowOnly(req, res, ['GET', 'HEAD'])) return
268
- if (methodOf(req) === 'HEAD') {
269
- res.writeHead(200, { 'cache-control': 'no-store' })
270
- res.end()
271
- return
272
- }
273
- const sessionId = new URL(req.url ?? '/', 'http://dsh.local').searchParams.get('sessionId')?.trim()
274
- if (sessionId === undefined || sessionId === '') {
275
- sendJson(res, 400, { ok: false, error: '缺少 sessionId 参数' })
276
- return
277
- }
278
- sendJson(res, 200, await surface.sessionOrigin(sessionId))
279
- }),
131
+ /**
132
+ * Register the panel routes.
133
+ * @param connection - the host `connection` service.
134
+ * @param surface - the panel's business side.
135
+ * @returns disposer removing every route.
136
+ */
137
+ export function registerSettingsRoutes(connection: HostConnection, surface: SettingsSurface): () => void {
138
+ const get = (path: string, handler: (url: URL) => Promise<Response>): HostFetchRoute => ({
139
+ path, methods: ['GET'], requestBody: 'buffered', fetch: answered(request => handler(new URL(request.url))),
140
+ })
141
+ const post = (path: string, handler: (request: Request) => Promise<Response>): HostFetchRoute => ({
142
+ path, methods: ['POST'], requestBody: 'buffered', fetch: answered(handler),
143
+ })
144
+ const routes: HostFetchRoute[] = [
145
+ get(STATUS_ROUTE, async () => json(200, await surface.status())),
146
+ get(PANEL_ROUTE, async () => json(200, await surface.panel())),
147
+ get(CATALOG_ROUTE, async url => json(200, await surface.catalogFor(
148
+ url.searchParams.get('provider') ?? undefined,
149
+ url.searchParams.get('model') ?? undefined,
150
+ ))),
151
+ get(SESSION_ORIGIN_ROUTE, async (url) => {
152
+ const sessionId = url.searchParams.get('sessionId')?.trim()
153
+ if (sessionId === undefined || sessionId === '') return json(400, { ok: false, error: '缺少 sessionId 参数' })
154
+ return json(200, await surface.sessionOrigin(sessionId))
280
155
  }),
281
- server.register({
282
- kind: 'exact',
283
- path: PROFILES_ROUTE,
284
- handler: guarded(async (req, res) => {
285
- if (!allowOnly(req, res, ['GET', 'HEAD'])) return
286
- if (methodOf(req) === 'HEAD') {
287
- res.writeHead(200, { 'cache-control': 'no-store' })
288
- res.end()
289
- return
290
- }
291
- const params = new URL(req.url ?? '/', 'http://dsh.local').searchParams
292
- const userIds = idList(params.get('users'))
293
- const chatIds = idList(params.get('chats'))
294
- if (userIds.length > PROFILES_MAX_IDS || chatIds.length > PROFILES_MAX_IDS) {
295
- sendJson(res, 400, { ok: false, error: `每次最多查询 ${PROFILES_MAX_IDS} 个用户和 ${PROFILES_MAX_IDS} 个群` })
296
- return
297
- }
298
- sendJson(res, 200, await surface.feishuProfiles(userIds, chatIds))
299
- }),
156
+ get(PROFILES_ROUTE, async (url) => {
157
+ const userIds = idList(url.searchParams.get('users'))
158
+ const chatIds = idList(url.searchParams.get('chats'))
159
+ if (userIds.length > PROFILES_MAX_IDS || chatIds.length > PROFILES_MAX_IDS) {
160
+ return json(400, { ok: false, error: `每次最多查询 ${PROFILES_MAX_IDS} 个用户和 ${PROFILES_MAX_IDS} 个群` })
161
+ }
162
+ return json(200, await surface.feishuProfiles(userIds, chatIds))
300
163
  }),
301
- server.register({
302
- kind: 'exact',
303
- path: CONFIG_ROUTE,
304
- handler: guarded(async (req, res) => {
305
- if (!allowOnly(req, res, ['PUT', 'DELETE'])) return
306
- if (!isSameOriginRequest(req)) {
307
- forbiddenWrite(res)
308
- return
309
- }
310
- if (methodOf(req) === 'DELETE') {
311
- const result = await surface.clear()
312
- sendJson(res, result.ok ? 200 : 400, result)
313
- return
314
- }
315
- let body: unknown
316
- try {
317
- body = await readJsonBody(req)
318
- } catch (error) {
319
- if (error instanceof HttpError) {
320
- sendJson(res, error.status, { ok: false, error: error.message })
321
- return
322
- }
323
- throw error
324
- }
325
- const values = (body as { values?: unknown } | null)?.values
326
- if (typeof values !== 'object' || values === null || Array.isArray(values)) {
327
- sendJson(res, 400, { ok: false, error: '请求体缺少 values 对象' })
328
- return
329
- }
330
- const result = await surface.save(values as EditableValues)
331
- sendJson(res, result.ok ? 200 : 400, result)
332
- }),
164
+ get(ACCESS_ROUTE, async () => json(200, await surface.access())),
165
+ post(SAVE_CONFIG_ROUTE, async (request) => {
166
+ const values = (await readJsonObject(request)).values
167
+ if (typeof values !== 'object' || values === null || Array.isArray(values)) {
168
+ return json(400, { ok: false, error: '请求体缺少 values 对象' })
169
+ }
170
+ return result(await surface.save(values as EditableValues))
333
171
  }),
334
- server.register({
335
- kind: 'exact',
336
- path: CREDENTIALS_ROUTE,
337
- handler: guarded(async (req, res) => {
338
- if (!allowOnly(req, res, ['PUT', 'DELETE'])) return
339
- if (!isSameOriginRequest(req)) {
340
- forbiddenWrite(res)
341
- return
342
- }
343
- let body: unknown
344
- try {
345
- body = await readJsonBody(req)
346
- } catch (error) {
347
- if (error instanceof HttpError) {
348
- sendJson(res, error.status, { ok: false, error: error.message })
349
- return
350
- }
351
- throw error
352
- }
353
- const payload = body as { appId?: unknown; appSecret?: unknown; analyzeToken?: unknown; field?: unknown } | null
354
- if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
355
- sendJson(res, 400, { ok: false, error: '请求体必须是 JSON 对象' })
356
- return
357
- }
358
- if (methodOf(req) === 'DELETE') {
359
- const field = payload.field
360
- if (field !== 'appId' && field !== 'appSecret' && field !== 'analyzeToken') {
361
- sendJson(res, 400, { ok: false, error: 'field 必须是 appId、appSecret 或 analyzeToken' })
362
- return
363
- }
364
- const result = await surface.clearCredential(field as CredentialField)
365
- sendJson(res, result.ok ? 200 : 400, result)
366
- return
367
- }
368
- const values: SaveCredentialsRequest = {}
369
- if (payload.appId !== undefined) values.appId = payload.appId as string
370
- if (payload.appSecret !== undefined) values.appSecret = payload.appSecret as string
371
- if (payload.analyzeToken !== undefined) values.analyzeToken = payload.analyzeToken as string
372
- const result = await surface.saveCredentials(values)
373
- sendJson(res, result.ok ? 200 : 400, result)
374
- }),
172
+ post(CLEAR_CONFIG_ROUTE, async () => result(await surface.clear())),
173
+ post(SAVE_CREDENTIALS_ROUTE, async (request) => {
174
+ const payload = await readJsonObject(request)
175
+ const values: SaveCredentialsRequest = {}
176
+ if (payload.appId !== undefined) values.appId = payload.appId as string
177
+ if (payload.appSecret !== undefined) values.appSecret = payload.appSecret as string
178
+ if (payload.analyzeToken !== undefined) values.analyzeToken = payload.analyzeToken as string
179
+ return result(await surface.saveCredentials(values))
375
180
  }),
376
- server.register({
377
- kind: 'exact',
378
- path: RECONNECT_ROUTE,
379
- handler: guarded(async (req, res) => {
380
- if (!allowOnly(req, res, ['POST'])) return
381
- if (!isSameOriginRequest(req)) {
382
- forbiddenWrite(res)
383
- return
384
- }
385
- const result = await surface.reconnect()
386
- // An incomplete configuration is a client-side state (the panel routes
387
- // the user to the credentials form), not a server failure.
388
- sendJson(res, result.ok ? 200 : (result.unconfigured === true ? 400 : 500), result)
389
- }),
181
+ post(CLEAR_CREDENTIAL_ROUTE, async (request) => {
182
+ const field = (await readJsonObject(request)).field
183
+ if (field !== 'appId' && field !== 'appSecret' && field !== 'analyzeToken') {
184
+ return json(400, { ok: false, error: 'field 必须是 appId、appSecret 或 analyzeToken' })
185
+ }
186
+ return result(await surface.clearCredential(field as CredentialField))
390
187
  }),
391
- server.register({
392
- kind: 'exact',
393
- path: PICK_DIRECTORY_ROUTE,
394
- handler: guarded(async (req, res) => {
395
- if (!allowOnly(req, res, ['POST'])) return
396
- if (!isSameOriginRequest(req)) {
397
- forbiddenWrite(res)
398
- return
399
- }
400
- // The chooser is a local desktop modal: it can stay open far longer
401
- // than a normal request, and the operator may close the page instead
402
- // of the dialog. Abort the host picker when the response socket goes
403
- // away so no orphaned modal outlives its panel.
404
- const controller = new AbortController()
405
- let answered = false
406
- if (typeof res.on === 'function') {
407
- res.on('close', () => {
408
- if (!answered) controller.abort()
409
- })
410
- }
411
- let path: string | null
412
- try {
413
- path = await surface.pickDirectory(controller.signal)
414
- } catch (error) {
415
- answered = true
416
- // A request the browser already dropped has nobody left to answer.
417
- if (controller.signal.aborted) {
418
- res.end()
419
- return
420
- }
421
- throw error
422
- }
423
- answered = true
424
- const payload: PickDirectoryResponse = path === null
425
- ? { ok: true, canceled: true }
426
- : { ok: true, path }
427
- sendJson(res, 200, payload)
428
- }),
188
+ post(RECONNECT_ROUTE, async () => {
189
+ const outcome = await surface.reconnect()
190
+ // An incomplete configuration is a client-side state (the panel routes
191
+ // the user to the credentials form), not a server failure.
192
+ return json(outcome.ok ? 200 : (outcome.unconfigured === true ? 400 : 500), outcome)
429
193
  }),
430
- server.register({
431
- kind: 'exact',
432
- path: ACCESS_ROUTE,
433
- handler: guarded(async (req, res) => {
434
- if (!allowOnly(req, res, ['GET', 'HEAD'])) return
435
- if (methodOf(req) === 'HEAD') {
436
- res.writeHead(200, { 'cache-control': 'no-store' })
437
- res.end()
438
- return
439
- }
440
- sendJson(res, 200, await surface.access())
441
- }),
194
+ post(PICK_DIRECTORY_ROUTE, async (request) => {
195
+ // The chooser is a local desktop modal that can stay open far longer
196
+ // than a normal request; DSH aborts `request.signal` when the browser
197
+ // drops the request, which closes the host picker with it.
198
+ const path = await surface.pickDirectory(request.signal)
199
+ const payload: PickDirectoryResponse = path === null ? { ok: true, canceled: true } : { ok: true, path }
200
+ return json(200, payload)
442
201
  }),
443
- server.register({
444
- kind: 'exact',
445
- path: ACCESS_GROUPS_ROUTE,
446
- handler: guarded(async (req, res) => {
447
- if (!allowOnly(req, res, ['POST', 'DELETE'])) return
448
- if (!isSameOriginRequest(req)) {
449
- forbiddenWrite(res)
450
- return
451
- }
452
- const payload = await readAccessBody(req, res)
453
- if (payload === undefined) return
454
- const result = methodOf(req) === 'DELETE'
455
- ? await surface.removeAccessGroup(payload.chatId)
456
- : await surface.saveAccessGroup(payload.chatId, payload.name)
457
- sendJson(res, result.ok ? 200 : 400, result)
458
- }),
202
+ post(ADD_ACCESS_GROUP_ROUTE, async (request) => {
203
+ const payload = await readJsonObject(request)
204
+ return result(await surface.saveAccessGroup(payload.chatId, payload.name))
459
205
  }),
460
- server.register({
461
- kind: 'exact',
462
- path: ACCESS_USERS_ROUTE,
463
- handler: guarded(async (req, res) => {
464
- if (!allowOnly(req, res, ['POST', 'DELETE'])) return
465
- if (!isSameOriginRequest(req)) {
466
- forbiddenWrite(res)
467
- return
468
- }
469
- const payload = await readAccessBody(req, res)
470
- if (payload === undefined) return
471
- const result = methodOf(req) === 'DELETE'
472
- ? await surface.removeAccessUser(payload.userId)
473
- : await surface.saveAccessUser(payload.userId, payload.name)
474
- sendJson(res, result.ok ? 200 : 400, result)
475
- }),
206
+ post(REMOVE_ACCESS_GROUP_ROUTE, async request => result(await surface.removeAccessGroup((await readJsonObject(request)).chatId))),
207
+ post(ADD_ACCESS_USER_ROUTE, async (request) => {
208
+ const payload = await readJsonObject(request)
209
+ return result(await surface.saveAccessUser(payload.userId, payload.name))
476
210
  }),
477
- server.register({
478
- kind: 'exact',
479
- path: ACCESS_WIKI_USERS_ROUTE,
480
- handler: guarded(async (req, res) => {
481
- if (!allowOnly(req, res, ['POST', 'DELETE'])) return
482
- if (!isSameOriginRequest(req)) {
483
- forbiddenWrite(res)
484
- return
485
- }
486
- const payload = await readAccessBody(req, res)
487
- if (payload === undefined) return
488
- const result = methodOf(req) === 'DELETE'
489
- ? await surface.removeWikiUser(payload.userId)
490
- : await surface.saveWikiUser(payload.userId, payload.name)
491
- sendJson(res, result.ok ? 200 : 400, result)
492
- }),
211
+ post(REMOVE_ACCESS_USER_ROUTE, async request => result(await surface.removeAccessUser((await readJsonObject(request)).userId))),
212
+ post(ADD_WIKI_USER_ROUTE, async (request) => {
213
+ const payload = await readJsonObject(request)
214
+ return result(await surface.saveWikiUser(payload.userId, payload.name))
493
215
  }),
494
- server.register({
495
- kind: 'exact',
496
- path: ACCESS_REQUESTS_ROUTE,
497
- handler: guarded(async (req, res) => {
498
- if (!allowOnly(req, res, ['POST'])) return
499
- if (!isSameOriginRequest(req)) {
500
- forbiddenWrite(res)
501
- return
502
- }
503
- const payload = await readAccessBody(req, res)
504
- if (payload === undefined) return
505
- const result = await surface.resolveAccessRequest(payload.token, payload.decision)
506
- sendJson(res, result.ok ? 200 : 400, result)
507
- }),
216
+ post(REMOVE_WIKI_USER_ROUTE, async request => result(await surface.removeWikiUser((await readJsonObject(request)).userId))),
217
+ post(RESOLVE_ACCESS_REQUEST_ROUTE, async (request) => {
218
+ const payload = await readJsonObject(request)
219
+ return result(await surface.resolveAccessRequest(payload.token, payload.decision))
508
220
  }),
509
221
  ]
222
+ const disposers = routes.map(route => connection.fetch.register(route))
510
223
  return () => {
511
- for (const dispose of disposers) dispose()
224
+ for (const dispose of disposers) {
225
+ void dispose().catch(() => {
226
+ // The route is already gone when the host connection was disposed first.
227
+ })
228
+ }
512
229
  }
513
230
  }
514
231
 
515
232
  /**
516
- * Mount the routes once the host carries both a Web server and the
517
- * `connection` service that authenticates its browser requests. The optional
518
- * `ctx.inject` keeps headless profiles from waiting on services they will
519
- * never have; embedded/test hosts that resolve services synchronously fall
520
- * back to the direct attach path. Without `connection` nothing is mounted, so
521
- * the panel never serves unauthenticated requests.
233
+ * Mount the routes once the host carries the `connection` service. The
234
+ * optional `ctx.inject` keeps headless profiles from waiting on a service
235
+ * they will never have; embedded/test hosts that resolve services
236
+ * synchronously fall back to the direct attach path.
237
+ * @param ctx - the plugin context.
238
+ * @param surface - the panel's business side.
522
239
  */
523
240
  export function mountSettingsRoutes(ctx: HostContext, surface: SettingsSurface): void {
524
- const attach = (webCtx: HostContext): void => {
525
- const server = webCtx.get?.('webServer') as HostWebServer | undefined
526
- const gate = webCtx.get?.('connection') as HostRequestGate | undefined
527
- if (server === undefined || gate === undefined) return
528
- webCtx.effect(
529
- () => registerSettingsRoutes(server, gate, surface),
241
+ const attach = (connectionCtx: HostContext): void => {
242
+ const connection = connectionCtx.get?.('connection') as HostConnection | undefined
243
+ if (connection?.fetch === undefined) return
244
+ connectionCtx.effect(
245
+ () => registerSettingsRoutes(connection, surface),
530
246
  'dsh-channels: settings panel routes',
531
247
  )
532
248
  }
@@ -534,7 +250,7 @@ export function mountSettingsRoutes(ctx: HostContext, surface: SettingsSurface):
534
250
  inject?: (dependencies: string[], callback: (child: HostContext) => void) => unknown
535
251
  }).inject
536
252
  if (typeof inject === 'function') {
537
- inject.call(ctx, ['webServer', 'connection'], attach)
253
+ inject.call(ctx, ['connection'], attach)
538
254
  return
539
255
  }
540
256
  attach(ctx)