@v587d/capital-generation 2.12.0 → 2.13.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,241 @@
1
+ /**
2
+ * `/capital-automation/*` route. Authentication is the first statement: a rejected request
3
+ * never reads the body, a task id, or storage. Bodies are strict JSON and capped before parsing.
4
+ */
5
+ import { MAX_REQUEST_BODY_BYTES } from './defaults.js'
6
+ import { AutomationError } from './service.js'
7
+ import { SchemaError } from './schema.js'
8
+ import { sanitizeMessage } from './redact.js'
9
+
10
+ export const ROUTE_PREFIX = '/capital-automation'
11
+ const ID_PATTERN = /^[A-Za-z0-9_-]{1,120}$/
12
+
13
+ function send(res, status, payload, extraHeaders = {}) {
14
+ const body = JSON.stringify(payload)
15
+ res.statusCode = status
16
+ res.setHeader('content-type', 'application/json; charset=utf-8')
17
+ res.setHeader('cache-control', 'no-store')
18
+ res.setHeader('x-content-type-options', 'nosniff')
19
+ for (const [name, value] of Object.entries(extraHeaders)) res.setHeader(name, value)
20
+ res.end(body)
21
+ }
22
+
23
+ function plain(res, status, text) {
24
+ res.statusCode = status
25
+ res.setHeader('content-type', 'text/plain; charset=utf-8')
26
+ res.setHeader('cache-control', 'no-store')
27
+ res.end(text)
28
+ }
29
+
30
+ function requestIdOf() {
31
+ return `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`
32
+ }
33
+
34
+ const MAX_QUERY_KEYS = 4
35
+ const MAX_QUERY_VALUE_CHARS = 512
36
+ const MAX_REQUEST_URL_CHARS = 16 * 1024
37
+
38
+ function invalidJson() {
39
+ return new AutomationError('invalid_json', '请求体不是合法 JSON。', 400)
40
+ }
41
+
42
+ function bodyTextOf(value) {
43
+ if (typeof value === 'string') return value
44
+ if (Buffer.isBuffer(value)) return value.toString('utf8')
45
+ if (value instanceof Uint8Array) return Buffer.from(value).toString('utf8')
46
+ let text
47
+ try {
48
+ text = JSON.stringify(value)
49
+ } catch {
50
+ throw invalidJson()
51
+ }
52
+ if (typeof text !== 'string') throw invalidJson()
53
+ return text
54
+ }
55
+
56
+ function contentLengthOf(req) {
57
+ const headers = req?.headers
58
+ const raw = headers?.['content-length'] ?? headers?.['Content-Length']
59
+ if (raw === undefined) return undefined
60
+ const value = Number(raw)
61
+ if (!Number.isSafeInteger(value) || value < 0) throw invalidJson()
62
+ return value
63
+ }
64
+
65
+ async function readBody(req) {
66
+ const declaredLength = contentLengthOf(req)
67
+ if (declaredLength !== undefined && declaredLength > MAX_REQUEST_BODY_BYTES) {
68
+ throw new AutomationError('body_too_large', '请求体过大。', 413)
69
+ }
70
+ if (req.body !== undefined) {
71
+ const text = bodyTextOf(req.body)
72
+ if (Buffer.byteLength(text, 'utf8') > MAX_REQUEST_BODY_BYTES) {
73
+ throw new AutomationError('body_too_large', '请求体过大。', 413)
74
+ }
75
+ if (text.trim().length === 0) return undefined
76
+ try {
77
+ return JSON.parse(text)
78
+ } catch {
79
+ throw invalidJson()
80
+ }
81
+ }
82
+ if (typeof req[Symbol.asyncIterator] !== 'function') return undefined
83
+ const chunks = []
84
+ let bytes = 0
85
+ try {
86
+ for await (const chunk of req) {
87
+ const buffer = Buffer.isBuffer(chunk)
88
+ ? chunk
89
+ : typeof chunk === 'string' || chunk instanceof Uint8Array
90
+ ? Buffer.from(chunk)
91
+ : undefined
92
+ if (buffer === undefined) throw invalidJson()
93
+ bytes += buffer.byteLength
94
+ if (bytes > MAX_REQUEST_BODY_BYTES) {
95
+ throw new AutomationError('body_too_large', '请求体过大。', 413)
96
+ }
97
+ chunks.push(buffer)
98
+ }
99
+ } catch (error) {
100
+ if (error instanceof AutomationError) throw error
101
+ throw invalidJson()
102
+ }
103
+ const text = Buffer.concat(chunks).toString('utf8').trim()
104
+ if (text.length === 0) return undefined
105
+ try {
106
+ return JSON.parse(text)
107
+ } catch {
108
+ throw invalidJson()
109
+ }
110
+ }
111
+
112
+ function decodeId(raw) {
113
+ let id
114
+ try {
115
+ id = decodeURIComponent(raw)
116
+ } catch {
117
+ throw new AutomationError('invalid_id', '任务或运行 id 无效。', 400)
118
+ }
119
+ if (!ID_PATTERN.test(id)) throw new AutomationError('invalid_id', '任务或运行 id 无效。', 400)
120
+ return id
121
+ }
122
+
123
+ function queryOf(url, allowedKeys) {
124
+ const result = {}
125
+ let count = 0
126
+ for (const [key, value] of url.searchParams.entries()) {
127
+ count += 1
128
+ if (count > MAX_QUERY_KEYS || !allowedKeys.has(key) || Object.prototype.hasOwnProperty.call(result, key)) {
129
+ throw new AutomationError('invalid_query', '查询参数无效。', 400)
130
+ }
131
+ if (value.length > MAX_QUERY_VALUE_CHARS) {
132
+ throw new AutomationError('invalid_query', '查询参数过长。', 400)
133
+ }
134
+ result[key] = value
135
+ }
136
+ return result
137
+ }
138
+
139
+ export function createAutomationRouteHandler(service, options = {}) {
140
+ const authorize = options.authorize
141
+ const logger = options.logger
142
+ return async function handler(req, res) {
143
+ const requestId = requestIdOf()
144
+ res.setHeader('x-request-id', requestId)
145
+ if (typeof authorize !== 'function') {
146
+ plain(res, 401, 'unauthorized')
147
+ return
148
+ }
149
+ let rejection
150
+ try {
151
+ rejection = authorize(req)
152
+ } catch {
153
+ rejection = 401
154
+ }
155
+ if (rejection !== undefined && rejection !== null) {
156
+ plain(res, rejection, rejection === 401 ? 'unauthorized' : 'forbidden')
157
+ return
158
+ }
159
+ let url
160
+ const rawUrl = req.url ?? '/'
161
+ if (typeof rawUrl !== 'string') {
162
+ send(res, 400, { ok: false, code: 'bad_request', message: '请求地址无效。', requestId })
163
+ return
164
+ }
165
+ if (rawUrl.length > MAX_REQUEST_URL_CHARS) {
166
+ send(res, 414, { ok: false, code: 'url_too_long', message: '请求地址过长。', requestId })
167
+ return
168
+ }
169
+ try {
170
+ url = new URL(rawUrl, 'http://localhost')
171
+ } catch {
172
+ send(res, 400, { ok: false, code: 'bad_request', message: '请求地址无效。', requestId })
173
+ return
174
+ }
175
+ const pathname = url.pathname
176
+ if (pathname !== ROUTE_PREFIX && !pathname.startsWith(`${ROUTE_PREFIX}/`)) {
177
+ send(res, 404, { ok: false, code: 'not_found', message: '路径不存在。', requestId })
178
+ return
179
+ }
180
+ const suffix = pathname.slice(ROUTE_PREFIX.length) || '/'
181
+ const method = String(req.method ?? 'GET').toUpperCase()
182
+ try {
183
+ const payload = await dispatch({ req, method, suffix, url, service, requestId })
184
+ send(res, payload.status ?? 200, { ok: true, ...payload.body }, payload.headers)
185
+ } catch (error) {
186
+ const known = error instanceof AutomationError
187
+ const schema = error instanceof SchemaError
188
+ const storage = error?.code === 'storage_unavailable'
189
+ const status = known ? error.status : schema ? 400 : storage ? 503 : 500
190
+ const code = known ? error.code : schema ? 'invalid_request' : storage ? 'storage_unavailable' : 'internal_error'
191
+ const message = known || schema ? error.message : storage ? '投研自动化存储暂时不可用。' : '服务暂时不可用。'
192
+ if (!known && !schema && !storage) {
193
+ logger?.warn?.(`capital-automation: ${requestId} ${method} ${pathname} 失败:${String(error)}`)
194
+ }
195
+ send(res, status, {
196
+ ok: false,
197
+ code,
198
+ message: sanitizeMessage(message),
199
+ requestId,
200
+ ...(known ? error.extra : {}),
201
+ })
202
+ }
203
+ }
204
+ }
205
+
206
+ async function dispatch({ req, method, suffix, url, service, requestId }) {
207
+ if (suffix === '/meta') {
208
+ if (method !== 'GET') throw new AutomationError('method_not_allowed', '只支持 GET。', 405)
209
+ return { body: await service.meta() }
210
+ }
211
+ if (suffix === '/tasks' && method === 'GET') return { body: { tasks: await service.listTasks() } }
212
+ if (suffix === '/tasks' && method === 'POST') return { body: { task: await service.createTask(await readBody(req)) }, status: 201 }
213
+ const taskAction = /^\/tasks\/([^/]+)\/(activate|pause|hibernate|copy|run)$/.exec(suffix)
214
+ if (taskAction !== null && method === 'POST') {
215
+ const id = decodeId(taskAction[1])
216
+ const action = taskAction[2]
217
+ if (action === 'copy') return { body: { task: await service.copyTask(id) }, status: 201 }
218
+ if (action === 'run') return { body: { run: await service.runTask(id) }, status: 202 }
219
+ return { body: { task: await service.changeState(id, action) } }
220
+ }
221
+ const taskDetail = /^\/tasks\/([^/]+)$/.exec(suffix)
222
+ if (taskDetail !== null) {
223
+ const id = decodeId(taskDetail[1])
224
+ if (method === 'GET') return { body: await service.taskDetail(id) }
225
+ if (method === 'PATCH') return { body: { task: await service.patchTask(id, await readBody(req)) } }
226
+ if (method === 'DELETE') return { body: await service.deleteTask(id) }
227
+ throw new AutomationError('method_not_allowed', '只支持 GET / PATCH / DELETE。', 405)
228
+ }
229
+ if (suffix === '/runs' && method === 'GET') {
230
+ return { body: await service.listRuns(queryOf(url, new Set(['limit', 'before']))) }
231
+ }
232
+ if (suffix === '/notifications' && method === 'GET') {
233
+ return { body: await service.listNotifications(queryOf(url, new Set(['unread']))) }
234
+ }
235
+ if (suffix === '/notifications/read' && method === 'POST') return { body: await service.markNotificationsRead(await readBody(req)) }
236
+ const runAction = /^\/runs\/([^/]+)\/cancel$/.exec(suffix)
237
+ if (runAction !== null && method === 'POST') return { body: await service.cancelRun(decodeId(runAction[1])), status: 202 }
238
+ const runDetail = /^\/runs\/([^/]+)$/.exec(suffix)
239
+ if (runDetail !== null && method === 'GET') return { body: { run: await service.getRun(decodeId(runDetail[1])) } }
240
+ throw new AutomationError('not_found', '路径不存在。', 404, { requestId })
241
+ }