@goodandready/dsh-model-sync 0.3.11 → 0.3.13

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/lib/http.js CHANGED
@@ -1,237 +1,247 @@
1
- import { validatePolicy } from './policy.js'
2
-
3
- const BASE = '/dsh-model-sync'
4
-
5
- function json(res, status, body) {
6
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
7
- res.end(JSON.stringify(body))
8
- }
9
-
10
- function trusted(req) {
11
- const fetchSite = req.headers?.['sec-fetch-site']
12
- if (fetchSite === 'cross-site') return false
13
- const host = req.headers?.host
14
- const origin = req.headers?.origin
15
- if (origin) {
16
- if (!host) return false
17
- try { return new URL(origin).host === host } catch { return false }
18
- }
19
- const referer = req.headers?.referer
20
- if (referer) {
21
- if (!host) return false
22
- try { return new URL(referer).host === host } catch { return false }
23
- }
24
- // If sec-fetch-site is same-origin or same-site, allow
25
- if (fetchSite === 'same-origin' || fetchSite === 'same-site') return true
26
- // If sec-fetch-mode is present but origin/referer missing on a cross-site request, reject
27
- if (req.headers?.['sec-fetch-mode'] && !host) return false
28
- return true
29
- }
30
-
31
- function readJson(req) {
32
- return new Promise((resolve, reject) => {
33
- let raw = ''
34
- let failed = false
35
- req.on('data', (chunk) => {
36
- if (failed) return
37
- raw += chunk
38
- if (raw.length > 64 * 1024) {
39
- failed = true
40
- const err = new Error('request body is too large')
41
- if (typeof req.destroy === 'function') req.destroy(err)
42
- reject(err)
43
- }
44
- })
45
- req.on('end', () => {
46
- if (failed) return
47
- try { resolve(JSON.parse(raw || '{}')) } catch (error) { reject(error) }
48
- })
49
- req.on('error', (err) => {
50
- if (failed) return
51
- reject(err)
52
- })
53
- })
54
- }
55
-
56
- export function normalizeRunRequest(body) {
57
- if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
58
- if (body.provider !== undefined && (typeof body.provider !== 'string' || body.provider.length > 128)) throw new Error('provider must be a short string')
59
- if (body.dryRun !== undefined && typeof body.dryRun !== 'boolean') throw new Error('dryRun must be boolean')
60
- if (body.removeMissing !== undefined && typeof body.removeMissing !== 'boolean') throw new Error('removeMissing must be boolean')
61
- return {
62
- ...(body.provider ? { provider: body.provider } : {}),
63
- dryRun: body.dryRun !== false,
64
- removeMissing: body.removeMissing === true,
65
- }
66
- }
67
-
68
- export function normalizeCredentialRequest(url = '') {
69
- const parsed = new URL(url || '/', 'http://dsh-model-sync.local')
70
- const provider = parsed.searchParams.get('provider') || undefined
71
- if (provider && (provider.length > 128 || !/^[A-Za-z0-9._:-]+$/.test(provider))) throw new Error('provider must be a short string')
72
- return provider ? { provider } : {}
73
- }
74
-
75
- export function normalizeNotificationRequest(url = '') {
76
- const parsed = new URL(url || '/', 'http://dsh-model-sync.local')
77
- return { includeAcknowledged: parsed.searchParams.get('includeAcknowledged') !== 'false' }
78
- }
79
-
80
- export function normalizeNotificationAction(body) {
81
- if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
82
- if (typeof body.id !== 'string' || !body.id || body.id.length > 128) throw new Error('notification id is invalid')
83
- return { id: body.id.trim() }
84
- }
85
-
86
- export function normalizeCredentialCheckRequest(body) {
87
- if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
88
- if (body.provider !== undefined && (typeof body.provider !== 'string' || body.provider.length > 128)) throw new Error('provider must be a short string')
89
- return body.provider ? { provider: body.provider.trim() } : {}
90
- }
91
-
92
- export function normalizeTryRequest(body) {
93
- if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
94
- if (typeof body.provider !== 'string' || !body.provider) throw new Error('provider is required')
95
- if (typeof body.model !== 'string' || !body.model) throw new Error('model is required')
96
- return { provider: body.provider, model: body.model }
97
- }
98
-
99
- export function normalizeHealthRequest(body) {
100
- if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
101
- if (body.provider !== undefined && (typeof body.provider !== 'string' || body.provider.length > 128)) throw new Error('provider must be a short string')
102
- return body.provider ? { provider: body.provider } : {}
103
- }
104
-
105
- export function normalizePolicyRequest(body) {
106
- if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
107
- if (typeof body.provider !== 'string' || !body.provider || body.provider.length > 128) throw new Error('provider must be a short string')
108
- for (const field of ['include', 'exclude']) {
109
- if (body[field] !== undefined && (!Array.isArray(body[field]) || body[field].length > 100)) throw new Error(`${field} must be an array`)
110
- if (Array.isArray(body[field]) && body[field].some((value) => typeof value !== 'string' || value.length > 256)) throw new Error(`${field} patterns must be short strings`)
111
- }
112
- for (const field of ['requireCapabilities', 'denyCapabilities']) {
113
- if (body[field] !== undefined && (!body[field] || typeof body[field] !== 'object' || Array.isArray(body[field]))) throw new Error(`${field} must be an object`)
114
- if (body[field] && Object.keys(body[field]).length > 4) throw new Error(`${field} has too many capabilities`)
115
- if (body[field] && Object.values(body[field]).some((value) => typeof value !== 'boolean')) throw new Error(`${field} values must be boolean`)
116
- }
117
- return {
118
- provider: body.provider.trim(),
119
- policy: validatePolicy({
120
- include: body.include,
121
- exclude: body.exclude,
122
- requireCapabilities: body.requireCapabilities,
123
- denyCapabilities: body.denyCapabilities,
124
- }),
125
- }
126
- }
127
-
128
- export function normalizeHistoryRequest(url = '') {
129
- const parsed = new URL(url || '/', 'http://dsh-model-sync.local')
130
- const limitValue = Number(parsed.searchParams.get('limit') ?? 50)
131
- const limit = Number.isFinite(limitValue) ? Math.min(100, Math.max(1, Math.floor(limitValue))) : 50
132
- const provider = parsed.searchParams.get('provider') || undefined
133
- const historyId = parsed.searchParams.get('historyId') || undefined
134
- if (provider && provider.length > 128) throw new Error('provider must be a short string')
135
- if (historyId && historyId.length > 128) throw new Error('historyId must be a short string')
136
- return { limit, ...(provider ? { provider } : {}), ...(historyId ? { historyId } : {}), details: parsed.searchParams.get('details') === 'true' }
137
- }
138
-
139
- export function normalizeHistoryRollbackRequest(body) {
140
- if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
141
- if (typeof body.historyId !== 'string' || !body.historyId || body.historyId.length > 128) throw new Error('historyId must be a short string')
142
- if (typeof body.provider !== 'string' || !body.provider || body.provider.length > 128) throw new Error('provider must be a short string')
143
- return { historyId: body.historyId.trim(), provider: body.provider.trim() }
144
- }
145
-
146
- export function normalizeSelectionRequest(body) {
147
- if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
148
- if (typeof body.provider !== 'string' || !body.provider || body.provider.length > 128) throw new Error('provider must be a short string')
149
- if (!Array.isArray(body.models) || body.models.length > 5000) throw new Error('models must be an array')
150
- const models = []
151
- const seen = new Set()
152
- for (const model of body.models) {
153
- if (typeof model !== 'string' || model.length > 512) throw new Error('model ids must be short strings')
154
- const id = model.trim()
155
- if (id && !seen.has(id)) {
156
- seen.add(id)
157
- models.push(id)
158
- }
159
- }
160
- return { provider: body.provider, models }
161
- }
162
-
163
- export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () => null } = {}) {
164
- const makeHandler = (route) => async (req, res) => {
165
- if (!trusted(req)) return json(res, 403, { error: { code: 'forbidden', message: 'dsh-model-sync: cross-origin request rejected' } })
166
- if (route === 'status') {
167
- if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
168
- return json(res, 200, { ...synchronizer.status(), scheduler: getSchedulerStatus(), providers: synchronizer.listProviders() })
169
- }
170
- if (route === 'history') {
171
- if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
172
- try { return json(res, 200, { history: synchronizer.history(normalizeHistoryRequest(req.url)) }) }
173
- catch (error) { return json(res, 400, { error: { code: 'bad-request', message: error instanceof Error ? error.message : String(error) } }) }
174
- }
175
- if (route === 'credentials') {
176
- if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
177
- try { return json(res, 200, await synchronizer.credentialDiagnostics(normalizeCredentialRequest(req.url))) }
178
- catch (error) { return json(res, 400, { error: { code: 'bad-request', message: error instanceof Error ? error.message : String(error) } }) }
179
- }
180
- if (route === 'report') {
181
- if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
182
- return json(res, 200, { report: synchronizer.report() })
183
- }
184
- if (route === 'notifications') {
185
- if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
186
- return json(res, 200, { notifications: synchronizer.notifications(normalizeNotificationRequest(req.url)) })
187
- }
188
- if (req.method !== 'POST') return json(res, 405, { error: { code: 'method', message: 'POST only' } })
189
- let options
190
- try {
191
- const body = await readJson(req)
192
- options = route === 'selection'
193
- ? normalizeSelectionRequest(body)
194
- : route === 'policy' ? normalizePolicyRequest(body)
195
- : route === 'try' ? normalizeTryRequest(body)
196
- : route === 'health' ? normalizeHealthRequest(body)
197
- : route === 'credentials-check' ? normalizeCredentialCheckRequest(body)
198
- : route === 'notification-read' || route === 'notification-acknowledge' ? normalizeNotificationAction(body)
199
- : route === 'rollback' ? normalizeHistoryRollbackRequest(body)
200
- : normalizeRunRequest(body)
201
- }
202
- catch (error) { return json(res, 400, { error: { code: 'bad-request', message: error instanceof Error ? error.message : String(error) } }) }
203
- try {
204
- const result = route === 'selection'
205
- ? await synchronizer.setModelSelection(options.provider, options.models)
206
- : route === 'policy' ? await synchronizer.setModelPolicy(options.provider, options.policy)
207
- : route === 'try' ? await synchronizer.tryModel(options)
208
- : route === 'health' ? await synchronizer.health(options)
209
- : route === 'credentials-check' ? { health: await synchronizer.health(options), credentials: await synchronizer.credentialDiagnostics(options) }
210
- : route === 'notification-read' ? await synchronizer.updateNotification(options.id, 'read')
211
- : route === 'notification-acknowledge' ? await synchronizer.updateNotification(options.id, 'acknowledged')
212
- : route === 'rollback' ? await synchronizer.rollbackHistory(options)
213
- : await synchronizer.run(options)
214
- return json(res, 200, { ...result, providers: synchronizer.listProviders() })
215
- }
216
- catch (error) {
217
- const code = error?.code === 'SETTINGS_CONFLICT' ? 409 : error?.code === 'SETTINGS_UNAVAILABLE' || error?.code === 'CONFIG_UNAVAILABLE' ? 503 : error?.code === 'PROVIDER_NOT_CONFIGURED' || error?.code === 'UNKNOWN_MODEL' || error?.code === 'INVALID_POLICY' || error?.code === 'INVALID_HISTORY' || error?.code === 'HISTORY_NOT_FOUND' || error?.code === 'INVALID_NOTIFICATION' || error?.code === 'NOTIFICATION_NOT_FOUND' ? 400 : 500
218
- return json(res, code, { error: { code: error?.code ?? 'run-failed', message: error instanceof Error ? error.message : String(error) } })
219
- }
220
- }
221
- return [
222
- ctx.webServer.register({ kind: 'exact', path: `${BASE}/status`, handler: makeHandler('status') }),
223
- ctx.webServer.register({ kind: 'exact', path: `${BASE}/run`, handler: makeHandler('run') }),
224
- ctx.webServer.register({ kind: 'exact', path: `${BASE}/try`, handler: makeHandler('try') }),
225
- ctx.webServer.register({ kind: 'exact', path: `${BASE}/health`, handler: makeHandler('health') }),
226
- ctx.webServer.register({ kind: 'exact', path: `${BASE}/selection`, handler: makeHandler('selection') }),
227
- ctx.webServer.register({ kind: 'exact', path: `${BASE}/policy`, handler: makeHandler('policy') }),
228
- ctx.webServer.register({ kind: 'exact', path: `${BASE}/history`, handler: makeHandler('history') }),
229
- ctx.webServer.register({ kind: 'exact', path: `${BASE}/history/rollback`, handler: makeHandler('rollback') }),
230
- ctx.webServer.register({ kind: 'exact', path: `${BASE}/credentials`, handler: makeHandler('credentials') }),
231
- ctx.webServer.register({ kind: 'exact', path: `${BASE}/credentials/check`, handler: makeHandler('credentials-check') }),
232
- ctx.webServer.register({ kind: 'exact', path: BASE + '/report', handler: makeHandler('report') }),
233
- ctx.webServer.register({ kind: 'exact', path: BASE + '/notifications', handler: makeHandler('notifications') }),
234
- ctx.webServer.register({ kind: 'exact', path: BASE + '/notifications/read', handler: makeHandler('notification-read') }),
235
- ctx.webServer.register({ kind: 'exact', path: BASE + '/notifications/acknowledge', handler: makeHandler('notification-acknowledge') }),
236
- ]
237
- }
1
+ import crypto from 'node:crypto'
2
+ import { validatePolicy } from './policy.js'
3
+
4
+ const BASE = '/dsh-model-sync'
5
+
6
+ function json(res, status, body) {
7
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
8
+ res.end(JSON.stringify(body))
9
+ }
10
+
11
+ function trusted(req) {
12
+ const fetchSite = req.headers?.['sec-fetch-site']
13
+ if (fetchSite === 'cross-site') return false
14
+ const host = req.headers?.host
15
+ const origin = req.headers?.origin
16
+ if (origin) {
17
+ if (!host) return false
18
+ try { return new URL(origin).host === host } catch { return false }
19
+ }
20
+ const referer = req.headers?.referer
21
+ if (referer) {
22
+ if (!host) return false
23
+ try { return new URL(referer).host === host } catch { return false }
24
+ }
25
+ // If sec-fetch-site is same-origin or same-site, allow
26
+ if (fetchSite === 'same-origin' || fetchSite === 'same-site') return true
27
+ // If sec-fetch-mode is present but origin/referer missing on a cross-site request, reject
28
+ if (req.headers?.['sec-fetch-mode'] && !host) return false
29
+ return true
30
+ }
31
+
32
+ function readJson(req) {
33
+ return new Promise((resolve, reject) => {
34
+ let raw = ''
35
+ let failed = false
36
+ req.on('data', (chunk) => {
37
+ if (failed) return
38
+ raw += chunk
39
+ if (raw.length > 64 * 1024) {
40
+ failed = true
41
+ const err = new Error('request body is too large')
42
+ if (typeof req.destroy === 'function') req.destroy(err)
43
+ reject(err)
44
+ }
45
+ })
46
+ req.on('end', () => {
47
+ if (failed) return
48
+ try { resolve(JSON.parse(raw || '{}')) } catch (error) { reject(error) }
49
+ })
50
+ req.on('error', (err) => {
51
+ if (failed) return
52
+ reject(err)
53
+ })
54
+ })
55
+ }
56
+
57
+ export function normalizeRunRequest(body) {
58
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
59
+ if (body.provider !== undefined && (typeof body.provider !== 'string' || body.provider.length > 128)) throw new Error('provider must be a short string')
60
+ if (body.dryRun !== undefined && typeof body.dryRun !== 'boolean') throw new Error('dryRun must be boolean')
61
+ if (body.removeMissing !== undefined && typeof body.removeMissing !== 'boolean') throw new Error('removeMissing must be boolean')
62
+ return {
63
+ ...(body.provider ? { provider: body.provider } : {}),
64
+ dryRun: body.dryRun !== false,
65
+ removeMissing: body.removeMissing === true,
66
+ }
67
+ }
68
+
69
+ export function normalizeCredentialRequest(url = '') {
70
+ const parsed = new URL(url || '/', 'http://dsh-model-sync.local')
71
+ const provider = parsed.searchParams.get('provider') || undefined
72
+ if (provider && (provider.length > 128 || !/^[A-Za-z0-9._:-]+$/.test(provider))) throw new Error('provider must be a short string')
73
+ return provider ? { provider } : {}
74
+ }
75
+
76
+ export function normalizeNotificationRequest(url = '') {
77
+ const parsed = new URL(url || '/', 'http://dsh-model-sync.local')
78
+ return { includeAcknowledged: parsed.searchParams.get('includeAcknowledged') !== 'false' }
79
+ }
80
+
81
+ export function normalizeNotificationAction(body) {
82
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
83
+ if (typeof body.id !== 'string' || !body.id || body.id.length > 128) throw new Error('notification id is invalid')
84
+ return { id: body.id.trim() }
85
+ }
86
+
87
+ export function normalizeCredentialCheckRequest(body) {
88
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
89
+ if (body.provider !== undefined && (typeof body.provider !== 'string' || body.provider.length > 128)) throw new Error('provider must be a short string')
90
+ return body.provider ? { provider: body.provider.trim() } : {}
91
+ }
92
+
93
+ export function normalizeTryRequest(body) {
94
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
95
+ if (typeof body.provider !== 'string' || !body.provider) throw new Error('provider is required')
96
+ if (typeof body.model !== 'string' || !body.model) throw new Error('model is required')
97
+ return { provider: body.provider, model: body.model }
98
+ }
99
+
100
+ export function normalizeHealthRequest(body) {
101
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
102
+ if (body.provider !== undefined && (typeof body.provider !== 'string' || body.provider.length > 128)) throw new Error('provider must be a short string')
103
+ return body.provider ? { provider: body.provider } : {}
104
+ }
105
+
106
+ export function normalizePolicyRequest(body) {
107
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
108
+ if (typeof body.provider !== 'string' || !body.provider || body.provider.length > 128) throw new Error('provider must be a short string')
109
+ for (const field of ['include', 'exclude']) {
110
+ if (body[field] !== undefined && (!Array.isArray(body[field]) || body[field].length > 100)) throw new Error(`${field} must be an array`)
111
+ if (Array.isArray(body[field]) && body[field].some((value) => typeof value !== 'string' || value.length > 256)) throw new Error(`${field} patterns must be short strings`)
112
+ }
113
+ for (const field of ['requireCapabilities', 'denyCapabilities']) {
114
+ if (body[field] !== undefined && (!body[field] || typeof body[field] !== 'object' || Array.isArray(body[field]))) throw new Error(`${field} must be an object`)
115
+ if (body[field] && Object.keys(body[field]).length > 4) throw new Error(`${field} has too many capabilities`)
116
+ if (body[field] && Object.values(body[field]).some((value) => typeof value !== 'boolean')) throw new Error(`${field} values must be boolean`)
117
+ }
118
+ return {
119
+ provider: body.provider.trim(),
120
+ policy: validatePolicy({
121
+ include: body.include,
122
+ exclude: body.exclude,
123
+ requireCapabilities: body.requireCapabilities,
124
+ denyCapabilities: body.denyCapabilities,
125
+ }),
126
+ }
127
+ }
128
+
129
+ export function normalizeHistoryRequest(url = '') {
130
+ const parsed = new URL(url || '/', 'http://dsh-model-sync.local')
131
+ const limitValue = Number(parsed.searchParams.get('limit') ?? 50)
132
+ const limit = Number.isFinite(limitValue) ? Math.min(100, Math.max(1, Math.floor(limitValue))) : 50
133
+ const provider = parsed.searchParams.get('provider') || undefined
134
+ const historyId = parsed.searchParams.get('historyId') || undefined
135
+ if (provider && provider.length > 128) throw new Error('provider must be a short string')
136
+ if (historyId && historyId.length > 128) throw new Error('historyId must be a short string')
137
+ return { limit, ...(provider ? { provider } : {}), ...(historyId ? { historyId } : {}), details: parsed.searchParams.get('details') === 'true' }
138
+ }
139
+
140
+ export function normalizeHistoryRollbackRequest(body) {
141
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
142
+ if (typeof body.historyId !== 'string' || !body.historyId || body.historyId.length > 128) throw new Error('historyId must be a short string')
143
+ if (typeof body.provider !== 'string' || !body.provider || body.provider.length > 128) throw new Error('provider must be a short string')
144
+ return { historyId: body.historyId.trim(), provider: body.provider.trim() }
145
+ }
146
+
147
+ export function normalizeSelectionRequest(body) {
148
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
149
+ if (typeof body.provider !== 'string' || !body.provider || body.provider.length > 128) throw new Error('provider must be a short string')
150
+ if (!Array.isArray(body.models) || body.models.length > 5000) throw new Error('models must be an array')
151
+ const models = []
152
+ const seen = new Set()
153
+ for (const model of body.models) {
154
+ if (typeof model !== 'string' || model.length > 512) throw new Error('model ids must be short strings')
155
+ const id = model.trim()
156
+ if (id && !seen.has(id)) {
157
+ seen.add(id)
158
+ models.push(id)
159
+ }
160
+ }
161
+ return { provider: body.provider, models }
162
+ }
163
+
164
+ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () => null } = {}) {
165
+ const makeHandler = (route) => async (req, res) => {
166
+ if (!trusted(req)) return json(res, 403, { error: { code: 'forbidden', message: 'dsh-model-sync: cross-origin request rejected' } })
167
+ if (route === 'status') {
168
+ if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
169
+ const payload = { ...synchronizer.status(), scheduler: getSchedulerStatus(), providers: synchronizer.listProviders() }
170
+ const bodyStr = JSON.stringify(payload)
171
+ const etag = 'W/"' + crypto.createHash('sha1').update(bodyStr).digest('hex').slice(0, 16) + '"'
172
+ const clientEtag = req.headers?.['if-none-match']
173
+ if (clientEtag && clientEtag.trim() === etag) {
174
+ res.writeHead(304, { etag })
175
+ return res.end()
176
+ }
177
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', etag })
178
+ return res.end(bodyStr)
179
+ }
180
+ if (route === 'history') {
181
+ if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
182
+ try { return json(res, 200, { history: synchronizer.history(normalizeHistoryRequest(req.url)) }) }
183
+ catch (error) { return json(res, 400, { error: { code: 'bad-request', message: error instanceof Error ? error.message : String(error) } }) }
184
+ }
185
+ if (route === 'credentials') {
186
+ if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
187
+ try { return json(res, 200, await synchronizer.credentialDiagnostics(normalizeCredentialRequest(req.url))) }
188
+ catch (error) { return json(res, 400, { error: { code: 'bad-request', message: error instanceof Error ? error.message : String(error) } }) }
189
+ }
190
+ if (route === 'report') {
191
+ if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
192
+ return json(res, 200, { report: synchronizer.report() })
193
+ }
194
+ if (route === 'notifications') {
195
+ if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
196
+ return json(res, 200, { notifications: synchronizer.notifications(normalizeNotificationRequest(req.url)) })
197
+ }
198
+ if (req.method !== 'POST') return json(res, 405, { error: { code: 'method', message: 'POST only' } })
199
+ let options
200
+ try {
201
+ const body = await readJson(req)
202
+ options = route === 'selection'
203
+ ? normalizeSelectionRequest(body)
204
+ : route === 'policy' ? normalizePolicyRequest(body)
205
+ : route === 'try' ? normalizeTryRequest(body)
206
+ : route === 'health' ? normalizeHealthRequest(body)
207
+ : route === 'credentials-check' ? normalizeCredentialCheckRequest(body)
208
+ : route === 'notification-read' || route === 'notification-acknowledge' ? normalizeNotificationAction(body)
209
+ : route === 'rollback' ? normalizeHistoryRollbackRequest(body)
210
+ : normalizeRunRequest(body)
211
+ }
212
+ catch (error) { return json(res, 400, { error: { code: 'bad-request', message: error instanceof Error ? error.message : String(error) } }) }
213
+ try {
214
+ const result = route === 'selection'
215
+ ? await synchronizer.setModelSelection(options.provider, options.models)
216
+ : route === 'policy' ? await synchronizer.setModelPolicy(options.provider, options.policy)
217
+ : route === 'try' ? await synchronizer.tryModel(options)
218
+ : route === 'health' ? await synchronizer.health(options)
219
+ : route === 'credentials-check' ? { health: await synchronizer.health(options), credentials: await synchronizer.credentialDiagnostics(options) }
220
+ : route === 'notification-read' ? await synchronizer.updateNotification(options.id, 'read')
221
+ : route === 'notification-acknowledge' ? await synchronizer.updateNotification(options.id, 'acknowledged')
222
+ : route === 'rollback' ? await synchronizer.rollbackHistory(options)
223
+ : await synchronizer.run(options)
224
+ return json(res, 200, { ...result, providers: synchronizer.listProviders() })
225
+ }
226
+ catch (error) {
227
+ const code = error?.code === 'SETTINGS_CONFLICT' ? 409 : error?.code === 'SETTINGS_UNAVAILABLE' || error?.code === 'CONFIG_UNAVAILABLE' ? 503 : error?.code === 'PROVIDER_NOT_CONFIGURED' || error?.code === 'UNKNOWN_MODEL' || error?.code === 'INVALID_POLICY' || error?.code === 'INVALID_HISTORY' || error?.code === 'HISTORY_NOT_FOUND' || error?.code === 'INVALID_NOTIFICATION' || error?.code === 'NOTIFICATION_NOT_FOUND' ? 400 : 500
228
+ return json(res, code, { error: { code: error?.code ?? 'run-failed', message: error instanceof Error ? error.message : String(error) } })
229
+ }
230
+ }
231
+ return [
232
+ ctx.webServer.register({ kind: 'exact', path: `${BASE}/status`, handler: makeHandler('status') }),
233
+ ctx.webServer.register({ kind: 'exact', path: `${BASE}/run`, handler: makeHandler('run') }),
234
+ ctx.webServer.register({ kind: 'exact', path: `${BASE}/try`, handler: makeHandler('try') }),
235
+ ctx.webServer.register({ kind: 'exact', path: `${BASE}/health`, handler: makeHandler('health') }),
236
+ ctx.webServer.register({ kind: 'exact', path: `${BASE}/selection`, handler: makeHandler('selection') }),
237
+ ctx.webServer.register({ kind: 'exact', path: `${BASE}/policy`, handler: makeHandler('policy') }),
238
+ ctx.webServer.register({ kind: 'exact', path: `${BASE}/history`, handler: makeHandler('history') }),
239
+ ctx.webServer.register({ kind: 'exact', path: `${BASE}/history/rollback`, handler: makeHandler('rollback') }),
240
+ ctx.webServer.register({ kind: 'exact', path: `${BASE}/credentials`, handler: makeHandler('credentials') }),
241
+ ctx.webServer.register({ kind: 'exact', path: `${BASE}/credentials/check`, handler: makeHandler('credentials-check') }),
242
+ ctx.webServer.register({ kind: 'exact', path: BASE + '/report', handler: makeHandler('report') }),
243
+ ctx.webServer.register({ kind: 'exact', path: BASE + '/notifications', handler: makeHandler('notifications') }),
244
+ ctx.webServer.register({ kind: 'exact', path: BASE + '/notifications/read', handler: makeHandler('notification-read') }),
245
+ ctx.webServer.register({ kind: 'exact', path: BASE + '/notifications/acknowledge', handler: makeHandler('notification-acknowledge') }),
246
+ ]
247
+ }