@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.
- package/README.md +302 -2
- package/cordis.patch.yml +3 -0
- package/docs/access-control-design.md +550 -0
- package/docs/assets/web-settings-panel.png +0 -0
- package/docs/assets/web-settings-restart-pending.png +0 -0
- package/docs/feasibility-and-design.md +213 -0
- package/docs/feishu-onboarding.md +268 -0
- package/docs/persistence.md +199 -0
- package/docs/refactor-plan.md +56 -0
- package/docs/review-dsh-0.2.0.md +389 -0
- package/docs/slash-commands-implementation-plan.md +410 -0
- package/docs/web-display-only-surface-roadmap.md +363 -0
- package/lib/client.js +2226 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +10217 -0
- package/lib/index.js.map +1 -0
- package/package.json +98 -4
- package/src/access/cards.ts +188 -0
- package/src/access/controller.ts +201 -0
- package/src/access/index.ts +13 -0
- package/src/access/policy.ts +103 -0
- package/src/access/requests.ts +512 -0
- package/src/access/store.ts +220 -0
- package/src/approvals.ts +8 -0
- package/src/cards/approval.ts +114 -0
- package/src/cards/index.ts +11 -0
- package/src/cards/primitives.ts +82 -0
- package/src/cards/question.ts +141 -0
- package/src/cards/streaming.ts +563 -0
- package/src/cards.ts +8 -0
- package/src/cleanup.ts +398 -0
- package/src/client/api.ts +262 -0
- package/src/client/catalog.ts +92 -0
- package/src/client/index.ts +61 -0
- package/src/client/panel.tsx +1475 -0
- package/src/client/styles.ts +98 -0
- package/src/commands/registry.ts +155 -0
- package/src/commands.ts +335 -0
- package/src/config.ts +206 -0
- package/src/domain.ts +428 -0
- package/src/dsh-types.ts +11 -0
- package/src/feishu/agent-context.ts +62 -0
- package/src/feishu/delivery.ts +188 -0
- package/src/feishu/inbound-attachments.ts +436 -0
- package/src/feishu-channel.ts +339 -0
- package/src/health.ts +233 -0
- package/src/host/agent.ts +181 -0
- package/src/host/commands.ts +63 -0
- package/src/host/core.ts +345 -0
- package/src/host/index.ts +12 -0
- package/src/host/interaction.ts +101 -0
- package/src/host/services.ts +320 -0
- package/src/index.ts +122 -0
- package/src/interactions/approvals.ts +154 -0
- package/src/interactions/pending.ts +162 -0
- package/src/interactions/questions.ts +274 -0
- package/src/questions.ts +8 -0
- package/src/routing/inbound.ts +524 -0
- package/src/service.ts +2138 -0
- package/src/session/manager.ts +352 -0
- package/src/session/repair.ts +157 -0
- package/src/session/request-policy.ts +192 -0
- package/src/session/routes.ts +165 -0
- package/src/session-commands.ts +310 -0
- package/src/session-export.ts +112 -0
- package/src/settings/catalog.ts +309 -0
- package/src/settings/contract.ts +508 -0
- package/src/settings/overlay.ts +164 -0
- package/src/settings/routes.ts +409 -0
- package/src/settings/surface.ts +773 -0
- package/src/stats.ts +485 -0
- package/src/streamer.ts +1108 -0
- 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
|
+
}
|