dsh-router-core 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 (42) hide show
  1. package/README.md +151 -0
  2. package/cordis.patch.yml +9 -0
  3. package/lib/client-registry.js +3006 -0
  4. package/lib/client.js +3006 -0
  5. package/lib/index.js +1756 -0
  6. package/lib/types/credential-store.d.ts +15 -0
  7. package/lib/types/index.d.ts +6 -0
  8. package/lib/types/keys.d.ts +39 -0
  9. package/lib/types/llm/adapter.d.ts +31 -0
  10. package/lib/types/router/index.d.ts +73 -0
  11. package/lib/types/router/types.d.ts +80 -0
  12. package/lib/types/shared.d.ts +138 -0
  13. package/lib/types/supplier-config.d.ts +28 -0
  14. package/lib/types/suppliers/contract.d.ts +98 -0
  15. package/lib/types/suppliers/loader.d.ts +41 -0
  16. package/lib/types/suppliers/registry.d.ts +33 -0
  17. package/package.json +86 -0
  18. package/src/client/CombosTab.tsx +458 -0
  19. package/src/client/EndpointTab.tsx +360 -0
  20. package/src/client/Modal.tsx +46 -0
  21. package/src/client/RouterView.tsx +186 -0
  22. package/src/client/SupplierDetail.tsx +943 -0
  23. package/src/client/index.tsx +18 -0
  24. package/src/client/router.css +2096 -0
  25. package/src/client/sidebar-entry.ts +167 -0
  26. package/src/client/workspace-controller.ts +26 -0
  27. package/src/client/workspace-mount.tsx +117 -0
  28. package/src/credential-store.ts +64 -0
  29. package/src/index.ts +468 -0
  30. package/src/keys.ts +143 -0
  31. package/src/llm/adapter.ts +273 -0
  32. package/src/router/index.ts +273 -0
  33. package/src/router/types.ts +85 -0
  34. package/src/shared.ts +152 -0
  35. package/src/supplier-config.ts +144 -0
  36. package/src/suppliers/contract.ts +73 -0
  37. package/src/suppliers/loader.ts +191 -0
  38. package/src/suppliers/nvidia/plugin.ts +268 -0
  39. package/src/suppliers/opencode/plugin.ts +200 -0
  40. package/src/suppliers/openrouter/plugin.ts +282 -0
  41. package/src/suppliers/registry.ts +365 -0
  42. package/src/types.d.ts +45 -0
package/src/index.ts ADDED
@@ -0,0 +1,468 @@
1
+ /**
2
+ * Host half of dsh-router — a simplified 9router.
3
+ *
4
+ * dsh-router IS the router: it exposes an OpenAI-compatible `/v1/*` endpoint
5
+ * on the DSH web server (http://localhost:3080/v1), and routes requests to
6
+ * internal suppliers.
7
+ *
8
+ * Routes:
9
+ * POST /v1/chat/completions OpenAI-compatible chat (stream + non-stream)
10
+ * GET /v1/models OpenAI-compatible model list
11
+ * GET /router/api/health router status (panel)
12
+ * GET /router/api/status supplier accounts (panel)
13
+ * GET /router/api/models merged model list (panel)
14
+ * GET /router/api/combos combo fallback chains (panel)
15
+ * GET /router/api/keys list keys (masked)
16
+ * POST /router/api/keys create key {name}
17
+ * DELETE /router/api/keys/:id delete key
18
+ * PATCH /router/api/keys/:id toggle key {isActive}
19
+ * GET /router/api/settings { requireApiKey }
20
+ * PATCH /router/api/settings { requireApiKey }
21
+ * POST /router/api/suppliers/:id/login generate login URL
22
+ * POST /router/api/suppliers/:id/login/callback {callbackUrl} → add account
23
+ * GET /router/api/suppliers/:id/models models with enabled state
24
+ * PATCH /router/api/suppliers/:id/models/:mid {enabled}
25
+ *
26
+ * `/v1/*` auth: gated by KeysStore.requireApiKey — when on, Bearer must be an
27
+ * active library key or TW2A_API_KEY env. `/router/api/*` is same-origin, no auth.
28
+ */
29
+ import type { IncomingMessage, ServerResponse } from 'node:http'
30
+ import { join, dirname } from 'node:path'
31
+ import { ROUTER_API_BASE } from './shared.ts'
32
+ import { Router } from './router/index.ts'
33
+ import { RouterAdapter } from './llm/adapter.ts'
34
+ import { KeysStore } from './keys.ts'
35
+ import { loadSuppliers, wrapModule, type LoadedSupplier } from './suppliers/loader.ts'
36
+ import { supplierRoutes } from './suppliers/registry.ts'
37
+ import type { SupplierEnv, SupplierModule } from './suppliers/contract.ts'
38
+ import { SupplierConfigStore } from './supplier-config.ts'
39
+ import { CredentialStore } from './credential-store.ts'
40
+
41
+ /** Plugin identity for cordis.yml rows. */
42
+ export const name = 'dsh-router'
43
+
44
+ /** Services required before mounting: the webserver (routes) + llm (设置-模型). */
45
+ export const inject = ['webServer', 'llm']
46
+
47
+ /** Minimal shape of the webServer service face used here. */
48
+ interface WebServerRoute {
49
+ kind: 'prefix' | 'exact'
50
+ path: string
51
+ handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
52
+ }
53
+ interface WebServer {
54
+ register: (route: WebServerRoute) => () => void
55
+ }
56
+
57
+ /** Minimal shape of the llm service faces used here (设置-模型 提供方/模型目录). */
58
+ interface LlmConfigurableProvider {
59
+ provider: string
60
+ displayName: string
61
+ settingsNs: string
62
+ settingsPath: readonly string[]
63
+ }
64
+ interface LlmModelDiscoveryRequest {
65
+ provider?: string
66
+ baseURL?: string
67
+ api?: string
68
+ apiKey?: string
69
+ signal?: AbortSignal
70
+ }
71
+ interface LlmDiscoveredModel {
72
+ id: string
73
+ name?: string
74
+ contextWindow?: number
75
+ maxTokens?: number
76
+ }
77
+ interface Llm {
78
+ registerConfigurableProviders: (entries: readonly LlmConfigurableProvider[]) => () => void
79
+ listConfigurableProviders: () => LlmConfigurableProvider[]
80
+ listProviders: () => Array<{ id: string; name: string }>
81
+ registerModelDiscovery: (
82
+ settingsNs: string,
83
+ discover: (request: LlmModelDiscoveryRequest) => Promise<readonly LlmDiscoveredModel[]>,
84
+ ) => () => void
85
+ registerAdapter: (providers: readonly string[], adapter: unknown) => () => void
86
+ }
87
+
88
+ function writeJson(res: ServerResponse, status: number, body: unknown): void {
89
+ const payload = JSON.stringify(body)
90
+ res.writeHead(status, {
91
+ 'Content-Type': 'application/json; charset=utf-8',
92
+ 'Cache-Control': 'no-store',
93
+ })
94
+ res.end(payload)
95
+ }
96
+
97
+ function readBody(req: IncomingMessage, limit: number): Promise<string> {
98
+ return new Promise((resolve, reject) => {
99
+ const chunks: Buffer[] = []
100
+ let size = 0
101
+ req.on('data', (chunk: Buffer) => {
102
+ size += chunk.length
103
+ if (size > limit) {
104
+ reject(new Error('body_too_large'))
105
+ req.destroy()
106
+ return
107
+ }
108
+ chunks.push(chunk)
109
+ })
110
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
111
+ req.on('error', reject)
112
+ })
113
+ }
114
+
115
+ /** Plugin body. */
116
+ export function apply(rawContext: unknown): void {
117
+ const ctx = rawContext as {
118
+ get: (key: string) => unknown
119
+ on: (name: string, listener: (...args: unknown[]) => void) => unknown
120
+ webServer: WebServer
121
+ llm?: Llm
122
+ logger: { info: (message: string) => void; warn: (message: string) => void }
123
+ effect: (fn: () => () => void, label?: string) => void
124
+ inject: (services: string[], callback: (sctx: unknown) => void) => void
125
+ }
126
+ const log = (msg: string): void => ctx.logger.info(`[dsh-router] ${msg}`)
127
+
128
+ const stateFile = process.env.TW2A_STATE_FILE ?? 'data/state.json'
129
+ const store = new SupplierConfigStore(stateFile)
130
+ const credentials = new CredentialStore(process.env.TW2A_AUTH_DIR ?? join(dirname(stateFile), 'auths'))
131
+ const router = new Router(stateFile)
132
+
133
+ const keys = new KeysStore(stateFile)
134
+
135
+ const disposers: Array<() => void> = []
136
+ const route = (
137
+ path: string,
138
+ handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>,
139
+ ): void => {
140
+ disposers.push(ctx.webServer.register({ kind: 'exact', path, handler }))
141
+ }
142
+
143
+ // ---- 供应商注册表:内置 + 用户自定义 js + 外部插件供应商 ----
144
+ const loadedSuppliers: LoadedSupplier[] = []
145
+ /** 每个供应商的 webServer 路由注销函数(注销时单独清理)。 */
146
+ const supplierDisposers = new Map<string, Array<() => void>>()
147
+ const registerSupplierRoutes = (loaded: LoadedSupplier): void => {
148
+ const dis: Array<() => void> = []
149
+ for (const r of supplierRoutes(ROUTER_API_BASE, loaded, store)) {
150
+ dis.push(ctx.webServer.register(r))
151
+ }
152
+ supplierDisposers.set(loaded.supplier.id, dis)
153
+ log(`supplier routes registered: ${loaded.supplier.id}`)
154
+ }
155
+ /** 注销一个供应商(路由 + 路由器 + 列表)。 */
156
+ const unregisterSupplier = (id: string): void => {
157
+ const i = loadedSuppliers.findIndex((l) => l.supplier.id === id)
158
+ if (i < 0) return
159
+ const [loaded] = loadedSuppliers.splice(i, 1)
160
+ for (const dispose of supplierDisposers.get(id) ?? []) dispose()
161
+ supplierDisposers.delete(id)
162
+ router.removeSupplier(id)
163
+ loaded?.supplier.dispose()
164
+ log(`supplier unregistered: ${id}`)
165
+ }
166
+
167
+ // 内置 + 用户 + 外部插件供应商(异步加载,完成后注册路由 + 加入路由器)
168
+ const dataDir = dirname(stateFile)
169
+ const registerLoaded = (loaded: LoadedSupplier): void => {
170
+ loadedSuppliers.push(loaded)
171
+ router.add(loaded.supplier)
172
+ registerSupplierRoutes(loaded)
173
+ }
174
+ /** 当前由 router.suppliers service 加载的供应商 id(外部插件卸载时全部注销)。 */
175
+ let externalSupplierIds: string[] = []
176
+ const loadExternal = (suppliers: Record<string, (env: SupplierEnv) => SupplierModule>): void => {
177
+ for (const [sid, factory] of Object.entries(suppliers)) {
178
+ if (externalSupplierIds.includes(sid)) continue // 已加载(internal/service + inject 可能重复触发)
179
+ try {
180
+ const module = factory({ dataDir, log, store, credentials })
181
+ const loaded = wrapModule(module, { dataDir, log, store, credentials }, `service router.suppliers.${sid}`)
182
+ registerLoaded(loaded)
183
+ externalSupplierIds.push(loaded.supplier.id)
184
+ log(`external supplier loaded: ${sid}`)
185
+ } catch (err) {
186
+ ctx.logger.warn(`[dsh-router] external supplier ${sid} load failed: ${(err as Error).message}`)
187
+ }
188
+ }
189
+ }
190
+ const unloadExternal = (): void => {
191
+ for (const id of externalSupplierIds.splice(0)) unregisterSupplier(id)
192
+ }
193
+ // 外部插件供应商(cordis service,其它 DSH 插件提供)。
194
+ // 监听 internal/service:service 提供时加载,移除(插件卸载)时注销——不依赖重启。
195
+ ctx.on('internal/service', (name: unknown, value: unknown) => {
196
+ if (name !== 'router.suppliers') return
197
+ if (value) {
198
+ loadExternal(value as Record<string, (env: SupplierEnv) => SupplierModule>)
199
+ } else {
200
+ unloadExternal()
201
+ }
202
+ })
203
+ // 兼容 cordis inject(service 已提供但 internal/service 事件可能早于本监听注册)
204
+ ctx.inject(['router.suppliers'], (sctx) => {
205
+ const c = sctx as { get?: (key: string) => unknown; router?: { suppliers?: Record<string, (env: SupplierEnv) => SupplierModule> } }
206
+ const service = (c.get?.('router.suppliers') ?? c.router?.suppliers) as Record<string, (env: SupplierEnv) => SupplierModule> | undefined
207
+ if (service) loadExternal(service)
208
+ })
209
+ void (async () => {
210
+ try {
211
+ const userDir = join(process.env.DSH_PROFILE_DIR ?? join(process.env.HOME ?? '', '.dsh', 'profiles', 'web'), 'suppliers')
212
+ const { suppliers, errors } = await loadSuppliers({
213
+ builtinDir: join(import.meta.dirname, 'suppliers'), // 内置 js(opencode 等)
214
+ userDir,
215
+ dataDir,
216
+ store,
217
+ credentials,
218
+ log,
219
+ })
220
+ for (const err of errors) ctx.logger.warn(`[dsh-router] ${err.error}`)
221
+ for (const loaded of suppliers) registerLoaded(loaded)
222
+ log(`suppliers loaded: ${suppliers.map((s) => s.supplier.id).join(', ') || '(none)'}`)
223
+ } catch (err) {
224
+ ctx.logger.warn(`[dsh-router] suppliers load failed: ${(err as Error).message}`)
225
+ }
226
+ })()
227
+
228
+ // ---- /v1/* (OpenAI-compatible, Bearer auth via KeysStore) ----
229
+
230
+ const withV1Auth = (handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>) => {
231
+ return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
232
+ const authz = req.headers.authorization ?? ''
233
+ const prefix = 'Bearer '
234
+ const bearer = authz.length >= prefix.length && authz.slice(0, prefix.length).toLowerCase() === prefix.toLowerCase()
235
+ ? authz.slice(prefix.length)
236
+ : undefined
237
+ if (!keys.verify(bearer)) {
238
+ writeJson(res, 401, { error: { message: 'missing or invalid API key', type: 'api_error', code: 'invalid_api_key' } })
239
+ return
240
+ }
241
+ await handler(req, res)
242
+ }
243
+ }
244
+
245
+ route('/v1/models', withV1Auth(async (_req, res) => {
246
+ const models = await router.listModels()
247
+ writeJson(res, 200, { object: 'list', data: models })
248
+ }))
249
+
250
+ route('/v1/chat/completions', withV1Auth(async (req, res) => {
251
+ let body: string
252
+ try {
253
+ body = await readBody(req, 8 << 20)
254
+ } catch {
255
+ writeJson(res, 413, { error: { message: 'request body too large', type: 'api_error', code: 'request_too_large' } })
256
+ return
257
+ }
258
+ let peek: { stream?: boolean; model?: string }
259
+ try {
260
+ peek = JSON.parse(body) as { stream?: boolean; model?: string }
261
+ } catch {
262
+ writeJson(res, 400, { error: { message: 'invalid JSON body', type: 'api_error', code: 'invalid_request' } })
263
+ return
264
+ }
265
+ await router.chatCompletions(
266
+ { rawBody: body, stream: !!peek.stream, model: typeof peek.model === 'string' ? peek.model : '' },
267
+ res,
268
+ )
269
+ }))
270
+
271
+ // ---- /router/api/* (panel, same-origin) ----
272
+
273
+ route(`${ROUTER_API_BASE}/health`, (_req, res) => {
274
+ const { suppliers } = router.status()
275
+ writeJson(res, 200, {
276
+ ok: true,
277
+ suppliers: suppliers.map((s) => {
278
+ const loaded = loadedSuppliers.find((l) => l.supplier.id === s.id)
279
+ return {
280
+ id: s.id,
281
+ name: s.name,
282
+ icon: loaded?.supplier.icon,
283
+ capabilities: loaded ? [...loaded.capabilities] : [],
284
+ source: loaded?.source ?? 'external',
285
+ }
286
+ }),
287
+ })
288
+ })
289
+
290
+ route(`${ROUTER_API_BASE}/status`, (_req, res) => {
291
+ const { suppliers } = router.status()
292
+ const accounts = suppliers.flatMap((s) =>
293
+ s.accounts.map((a) => ({ ...a, supplier: s.id })),
294
+ )
295
+ writeJson(res, 200, { ok: true, accounts })
296
+ })
297
+
298
+ route(`${ROUTER_API_BASE}/models`, async (_req, res) => {
299
+ try {
300
+ const models = await router.listModels()
301
+ writeJson(res, 200, { ok: true, models })
302
+ } catch (error) {
303
+ writeJson(res, 500, { ok: false, error: (error as Error).message })
304
+ }
305
+ })
306
+
307
+ route(`${ROUTER_API_BASE}/combos`, async (_req, res) => {
308
+ const [combos, groups] = await Promise.all([
309
+ router.combos(),
310
+ router.supplierModels().catch(() => []),
311
+ ])
312
+ writeJson(res, 200, { ok: true, combos, groups, aliases: router.aliases() })
313
+ })
314
+
315
+ route(`${ROUTER_API_BASE}/combos/create`, async (req, res) => {
316
+ let body: { name?: string; strategy?: string; models?: string[] }
317
+ try {
318
+ body = JSON.parse(await readBody(req, 64 << 10)) as { name?: string; strategy?: string; models?: string[] }
319
+ } catch {
320
+ writeJson(res, 400, { ok: false, error: 'invalid JSON body' })
321
+ return
322
+ }
323
+ const result = router.createCombo(body.name ?? '', body.strategy ?? 'fallback', body.models ?? [])
324
+ writeJson(res, result.ok ? 200 : 400, result)
325
+ })
326
+
327
+ route(`${ROUTER_API_BASE}/combos/update`, async (req, res) => {
328
+ let body: { id?: string; name?: string; strategy?: string; models?: string[] }
329
+ try {
330
+ body = JSON.parse(await readBody(req, 64 << 10)) as { id?: string; name?: string; strategy?: string; models?: string[] }
331
+ } catch {
332
+ writeJson(res, 400, { ok: false, error: 'invalid JSON body' })
333
+ return
334
+ }
335
+ const result = router.updateCombo(body.id ?? '', body.name ?? '', body.strategy ?? 'fallback', body.models ?? [])
336
+ writeJson(res, result.ok ? 200 : 400, result)
337
+ })
338
+
339
+ route(`${ROUTER_API_BASE}/combos/remove`, async (req, res) => {
340
+ let body: { id?: string }
341
+ try {
342
+ body = JSON.parse(await readBody(req, 64 << 10)) as { id?: string }
343
+ } catch {
344
+ writeJson(res, 400, { ok: false, error: 'invalid JSON body' })
345
+ return
346
+ }
347
+ const result = router.removeCombo(body.id ?? '')
348
+ writeJson(res, result.ok ? 200 : 400, result)
349
+ })
350
+
351
+ // ---- 端点与密钥:keys + settings ----
352
+
353
+ route(`${ROUTER_API_BASE}/keys`, async (req, res) => {
354
+ if (req.method === 'POST') {
355
+ let body: { name?: string }
356
+ try {
357
+ body = JSON.parse(await readBody(req, 64 << 10)) as { name?: string }
358
+ } catch {
359
+ writeJson(res, 400, { ok: false, error: 'invalid JSON body' })
360
+ return
361
+ }
362
+ const { key } = keys.create(typeof body.name === 'string' ? body.name : '')
363
+ writeJson(res, 201, { ok: true, key })
364
+ return
365
+ }
366
+ writeJson(res, 200, { ok: true, keys: keys.list() })
367
+ })
368
+
369
+ route(`${ROUTER_API_BASE}/keys/delete`, async (req, res) => {
370
+ // exact route 无 path 参数:用 POST body {id}
371
+ let body: { id?: string }
372
+ try {
373
+ body = JSON.parse(await readBody(req, 64 << 10)) as { id?: string }
374
+ } catch {
375
+ writeJson(res, 400, { ok: false, error: 'invalid JSON body' })
376
+ return
377
+ }
378
+ const ok = typeof body.id === 'string' && keys.remove(body.id)
379
+ writeJson(res, ok ? 200 : 404, ok ? { ok: true } : { ok: false, error: 'key not found' })
380
+ })
381
+
382
+ route(`${ROUTER_API_BASE}/keys/toggle`, async (req, res) => {
383
+ let body: { id?: string; isActive?: boolean }
384
+ try {
385
+ body = JSON.parse(await readBody(req, 64 << 10)) as { id?: string; isActive?: boolean }
386
+ } catch {
387
+ writeJson(res, 400, { ok: false, error: 'invalid JSON body' })
388
+ return
389
+ }
390
+ const ok = typeof body.id === 'string' && keys.setActive(body.id, !!body.isActive)
391
+ writeJson(res, ok ? 200 : 404, ok ? { ok: true } : { ok: false, error: 'key not found' })
392
+ })
393
+
394
+ route(`${ROUTER_API_BASE}/settings`, async (req, res) => {
395
+ if (req.method === 'PATCH') {
396
+ let body: { requireApiKey?: boolean }
397
+ try {
398
+ body = JSON.parse(await readBody(req, 64 << 10)) as { requireApiKey?: boolean }
399
+ } catch {
400
+ writeJson(res, 400, { ok: false, error: 'invalid JSON body' })
401
+ return
402
+ }
403
+ if (typeof body.requireApiKey === 'boolean') keys.requireApiKey = body.requireApiKey
404
+ }
405
+ writeJson(res, 200, { ok: true, requireApiKey: keys.requireApiKey })
406
+ })
407
+
408
+ // ---- 设置-模型:Router 提供方(固定卡片,插件注册,不可删)+ 模型目录(= 组合) ----
409
+
410
+ // llm 缺失时(如测试环境)跳过,不影响 /v1 核心。
411
+ if (ctx.llm !== undefined) {
412
+ const LLM_NS = 'llm-dsh-router'
413
+ try {
414
+ const providerReg = ctx.llm.registerConfigurableProviders([
415
+ {
416
+ provider: 'router',
417
+ displayName: 'Router',
418
+ settingsNs: LLM_NS,
419
+ settingsPath: [],
420
+ },
421
+ ])
422
+ disposers.push(providerReg)
423
+ disposers.push(ctx.llm.registerModelDiscovery(LLM_NS, async () => {
424
+ const combos = await router.combos()
425
+ return combos.map((c) => ({ id: c.name }))
426
+ }))
427
+ // adapter:模型目录自动带出组合;对话转发到本插件 /v1(组合路由在 /v1 内完成)。
428
+ disposers.push(ctx.llm.registerAdapter(['router'], new RouterAdapter('http://localhost:3080/v1', {
429
+ comboModels: async () => (await router.combos()).map((c) => ({ id: c.name })),
430
+ })))
431
+ log('llm provider (Router) + discovery + adapter registered ok')
432
+ } catch (err) {
433
+ ctx.logger.warn(`[dsh-router] llm registration failed: ${(err as Error).message}`)
434
+ }
435
+ } else {
436
+ log('llm service NOT available — skip 设置-模型 registration')
437
+ }
438
+
439
+ // 调试:Router provider 状态(设置-模型排查用)
440
+ route(`${ROUTER_API_BASE}/debug/llm`, async (_req, res) => {
441
+ let directory: unknown = 'n/a'
442
+ let registeredProviders: unknown = 'n/a'
443
+ try {
444
+ directory = ctx.llm !== undefined ? ctx.llm.listConfigurableProviders() : 'no llm'
445
+ } catch (err) {
446
+ directory = `error: ${(err as Error).message}`
447
+ }
448
+ try {
449
+ registeredProviders = ctx.llm !== undefined ? ctx.llm.listProviders() : 'no llm'
450
+ } catch (err) {
451
+ registeredProviders = `error: ${(err as Error).message}`
452
+ }
453
+ writeJson(res, 200, {
454
+ ok: true,
455
+ llmAvailable: ctx.llm !== undefined,
456
+ directory,
457
+ registeredProviders,
458
+ })
459
+ })
460
+
461
+ ctx.effect(
462
+ () => () => {
463
+ for (const dispose of disposers.splice(0)) dispose()
464
+ router.dispose()
465
+ },
466
+ 'dsh-router: teardown',
467
+ )
468
+ }
package/src/keys.ts ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * 密钥库 + 路由设置(requireApiKey)—— 9router「端点与密钥」核心。
3
+ * 持久化到 data/keys.json(与 pool 的 stateFile 同目录)。
4
+ *
5
+ * /v1/* 鉴权规则:
6
+ * - requireApiKey=false(默认)→ 不鉴权
7
+ * - requireApiKey=true → Bearer 必须是「启用的库内 key」或 TW2A_API_KEY env
8
+ */
9
+ import { randomBytes } from 'node:crypto'
10
+ import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
11
+ import { dirname, join } from 'node:path'
12
+ import { API_KEY_REF } from './shared.ts'
13
+
14
+ /** 库内一条 key。 */
15
+ export interface ApiKeyEntry {
16
+ id: string
17
+ name: string
18
+ /** 完整 key(仅创建时返回一次明文;列表返回脱敏)。 */
19
+ key: string
20
+ isActive: boolean
21
+ createdAt: string
22
+ }
23
+
24
+ interface KeysFile {
25
+ keys: ApiKeyEntry[]
26
+ requireApiKey: boolean
27
+ }
28
+
29
+ function newId(): string {
30
+ return randomBytes(6).toString('hex')
31
+ }
32
+
33
+ export function generateKey(): string {
34
+ return `dshr-${randomBytes(24).toString('hex')}`
35
+ }
36
+
37
+ function mask(k: string): string {
38
+ if (k.length <= 10) return k
39
+ return `${k.slice(0, 6)}${'•'.repeat(k.length - 10)}${k.slice(-4)}`
40
+ }
41
+
42
+ /** 密钥库。 */
43
+ export class KeysStore {
44
+ private fp = ''
45
+ private keys: ApiKeyEntry[] = []
46
+ private require = false
47
+
48
+ constructor(stateFile: string) {
49
+ this.fp = stateFile ? join(dirname(stateFile), 'keys.json') : ''
50
+ this.load()
51
+ }
52
+
53
+ private load(): void {
54
+ if (this.fp === '') return
55
+ let raw: string
56
+ try {
57
+ raw = readFileSync(this.fp, 'utf8')
58
+ } catch {
59
+ return
60
+ }
61
+ try {
62
+ const f = JSON.parse(raw) as KeysFile
63
+ this.keys = Array.isArray(f.keys) ? f.keys : []
64
+ this.require = !!f.requireApiKey
65
+ } catch {
66
+ // 损坏则用空库
67
+ }
68
+ }
69
+
70
+ private save(): void {
71
+ if (this.fp === '') return
72
+ try {
73
+ const dir = dirname(this.fp)
74
+ if (dir !== '' && dir !== '.') mkdirSync(dir, { recursive: true })
75
+ const raw = JSON.stringify({ keys: this.keys, requireApiKey: this.require }, null, 2)
76
+ const tmp = this.fp + '.tmp'
77
+ writeFileSync(tmp, raw, { mode: 0o600 })
78
+ renameSync(tmp, this.fp)
79
+ } catch {
80
+ // 持久化失败不阻断
81
+ }
82
+ }
83
+
84
+ list(): Array<{ id: string; name: string; key: string; masked: string; isActive: boolean; createdAt: string }> {
85
+ return this.keys.map((k) => ({
86
+ id: k.id,
87
+ name: k.name,
88
+ key: k.key,
89
+ masked: mask(k.key),
90
+ isActive: k.isActive,
91
+ createdAt: k.createdAt,
92
+ }))
93
+ }
94
+
95
+ /** 创建 key,返回明文(仅此一次)。 */
96
+ create(name: string): { entry: ApiKeyEntry; key: string } {
97
+ const entry: ApiKeyEntry = {
98
+ id: newId(),
99
+ name: name.trim() !== '' ? name.trim() : `Key ${this.keys.length + 1}`,
100
+ key: generateKey(),
101
+ isActive: true,
102
+ createdAt: new Date().toISOString(),
103
+ }
104
+ this.keys.push(entry)
105
+ this.save()
106
+ return { entry, key: entry.key }
107
+ }
108
+
109
+ remove(id: string): boolean {
110
+ const before = this.keys.length
111
+ this.keys = this.keys.filter((k) => k.id !== id)
112
+ if (this.keys.length === before) return false
113
+ this.save()
114
+ return true
115
+ }
116
+
117
+ setActive(id: string, isActive: boolean): boolean {
118
+ const k = this.keys.find((k) => k.id === id)
119
+ if (!k) return false
120
+ k.isActive = isActive
121
+ this.save()
122
+ return true
123
+ }
124
+
125
+ /** requireApiKey 开关。 */
126
+ get requireApiKey(): boolean {
127
+ return this.require
128
+ }
129
+
130
+ set requireApiKey(v: boolean) {
131
+ this.require = v
132
+ this.save()
133
+ }
134
+
135
+ /** 校验 Bearer key:requireApiKey 关 → 放行;开 → 库内启用 key 或 TW2A_API_KEY env。 */
136
+ verify(bearer: string | undefined): boolean {
137
+ if (!this.require) return true
138
+ if (bearer === undefined || bearer === '') return false
139
+ if (this.keys.some((k) => k.isActive && k.key === bearer)) return true
140
+ const envKey = process.env[API_KEY_REF]
141
+ return envKey !== undefined && envKey !== '' && envKey === bearer
142
+ }
143
+ }