@fonlan/dsh-task-kanban 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.
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Fenced JSON API under /plugins/@fonlan/dsh-task-kanban/api/<method>. Same
3
+ * browser-trust fence as the /api gateway and /sidebar routes: loopback
4
+ * Host-header or configured trustedHosts, same-origin browser markers.
5
+ */
6
+ import type { IncomingMessage, ServerResponse } from 'node:http'
7
+ import type { Context } from '@deepseek-ai/cordis'
8
+ import type {} from '@deepseek-ai/dsh-host-webserver'
9
+ import type { Lane } from '../shared/card.js'
10
+ import type { KanbanRunner } from './runner.js'
11
+ import type { KanbanSettingsFace } from './settings.js'
12
+ import { listModels } from './models.js'
13
+
14
+ const API_PREFIX = '/plugins/@fonlan/dsh-task-kanban/api'
15
+
16
+ // ── browser-trust fence (mirrors dsh-client-connection's api-request-trust) ──
17
+
18
+ function header(headers: IncomingMessage['headers'], name: string): string | undefined {
19
+ const value = headers[name]
20
+ return typeof value === 'string' ? value : undefined
21
+ }
22
+
23
+ function isLoopbackHostname(hostname: string): boolean {
24
+ if (hostname === 'localhost' || hostname === '[::1]') return true
25
+ const parts = hostname.split('.')
26
+ return parts.length === 4 && parts[0] === '127' && parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255)
27
+ }
28
+
29
+ function isTrustedApiRequest(req: IncomingMessage, trustedHosts: readonly string[]): boolean {
30
+ const host = header(req.headers, 'host')
31
+ if (host === undefined) return false
32
+ let hostUrl: URL
33
+ try {
34
+ hostUrl = new URL(`http://${host}`)
35
+ } catch {
36
+ return false
37
+ }
38
+ if (!isLoopbackHostname(hostUrl.hostname) && !trustedHosts.includes(hostUrl.host) && !trustedHosts.includes(hostUrl.hostname)) {
39
+ if (!trustedHosts.some((entry) => entry === hostUrl.hostname || entry === hostUrl.host)) return false
40
+ }
41
+ if (header(req.headers, 'sec-fetch-site') === 'cross-site') return false
42
+ const origin = header(req.headers, 'origin')
43
+ if (origin === undefined) return true
44
+ try {
45
+ return new URL(origin).host === hostUrl.host
46
+ } catch {
47
+ return false
48
+ }
49
+ }
50
+
51
+ function trustedHostsOf(ctx: Context): string[] {
52
+ for (const entry of ctx.get('loader')?.entries?.() ?? []) {
53
+ if (entry.options?.name === 'connection') {
54
+ const config = entry.options.config as { trustedHosts?: string[] } | undefined
55
+ return config?.trustedHosts ?? []
56
+ }
57
+ }
58
+ return []
59
+ }
60
+
61
+ // ── wire helpers ────────────────────────────────────────────────────────────
62
+
63
+ function writeJson(res: ServerResponse, body: unknown): void {
64
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
65
+ res.end(JSON.stringify(body))
66
+ }
67
+
68
+ function writeError(res: ServerResponse, code: string, message: string, status = 400): void {
69
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
70
+ res.end(JSON.stringify({ ok: false, error: { code, message } }))
71
+ }
72
+
73
+ function requireString(value: unknown, name: string): string {
74
+ if (typeof value !== 'string' || value.trim() === '') {
75
+ throw new Error(`${name} 不能为空`)
76
+ }
77
+ return value
78
+ }
79
+
80
+ async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown>> {
81
+ const chunks: Buffer[] = []
82
+ for await (const chunk of req) chunks.push(chunk as Buffer)
83
+ const raw = Buffer.concat(chunks).toString('utf8')
84
+ if (raw.trim() === '') return {}
85
+ try {
86
+ const parsed = JSON.parse(raw) as unknown
87
+ return typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : {}
88
+ } catch {
89
+ return {}
90
+ }
91
+ }
92
+
93
+ // ── routes ──────────────────────────────────────────────────────────────────
94
+
95
+ export function registerApiRoutes(
96
+ ctx: Context,
97
+ runner: KanbanRunner,
98
+ settings: KanbanSettingsFace,
99
+ ): () => void {
100
+ const fence = (req: IncomingMessage): boolean => isTrustedApiRequest(req, trustedHostsOf(ctx))
101
+ const disposers: Array<() => void> = []
102
+
103
+ const route = (name: string, handler: (payload: Record<string, unknown>) => Promise<unknown>): void => {
104
+ disposers.push(ctx.webServer.register({
105
+ kind: 'prefix',
106
+ path: `${API_PREFIX}/${name}`,
107
+ handler: async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
108
+ if (!fence(req)) {
109
+ writeError(res, 'forbidden', 'forbidden', 403)
110
+ return
111
+ }
112
+ if (req.method !== 'POST' && req.method !== 'GET') {
113
+ writeError(res, 'method', 'method not allowed', 405)
114
+ return
115
+ }
116
+ const payload = await readJsonBody(req)
117
+ try {
118
+ const value = await handler(payload)
119
+ writeJson(res, { ok: true, value })
120
+ } catch (error) {
121
+ writeError(res, 'error', error instanceof Error ? error.message : String(error))
122
+ }
123
+ },
124
+ }))
125
+ }
126
+
127
+ route('list', async (p) => runner.listCards(requireString(p.workspacePath, 'workspacePath')))
128
+
129
+ route('create', async (p) => {
130
+ const workspacePath = requireString(p.workspacePath, 'workspacePath')
131
+ const requirement = requireString(p.requirement, 'requirement')
132
+ const model = typeof p.model === 'string' ? p.model : ''
133
+ const provider = typeof p.provider === 'string' ? p.provider : undefined
134
+ return runner.createTask({ workspacePath, requirement, model, provider })
135
+ })
136
+
137
+ route('move', async (p) => {
138
+ const cardId = requireString(p.cardId, 'cardId')
139
+ const toLane = p.toLane as Lane
140
+ if (!['demand', 'queue', 'running', 'completed', 'merged'].includes(toLane)) {
141
+ throw new Error('无效的目标泳道')
142
+ }
143
+ return runner.moveTask(cardId, toLane)
144
+ })
145
+
146
+ route('stop', async (p) => runner.stopTask(requireString(p.cardId, 'cardId')))
147
+
148
+ route('retry', async (p) => runner.retryTask(requireString(p.cardId, 'cardId')))
149
+
150
+ route('remove', async (p) => runner.deleteTask(requireString(p.cardId, 'cardId')))
151
+
152
+ route('settings.get', async () => settings.get())
153
+
154
+ route('settings.set', async (p) => {
155
+ const patch: { maxParallelWorkers?: number; defaultModel?: string } = {}
156
+ if (typeof p.maxParallelWorkers === 'number') patch.maxParallelWorkers = p.maxParallelWorkers
157
+ if (typeof p.defaultModel === 'string') patch.defaultModel = p.defaultModel
158
+ await settings.update(patch)
159
+ return settings.get()
160
+ })
161
+
162
+ route('models.list', async () => listModels(ctx))
163
+
164
+ return () => {
165
+ for (const d of disposers) d()
166
+ }
167
+ }