@goodandready/dsh-model-sync 0.3.0 → 0.3.1

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/client.js CHANGED
@@ -58,6 +58,8 @@ window.__ModuleLoader__.load({
58
58
  const locale = useLocale(ctx); const t = locale === 'ru' ? ru : en
59
59
  const [status, setStatus] = React.useState(null); const [provider, setProvider] = React.useState(''); const [dryRun, setDryRun] = React.useState(true); const [removeMissing, setRemoveMissing] = React.useState(false); const [busy, setBusy] = React.useState(false); const [error, setError] = React.useState(''); const [credentialStatus, setCredentialStatus] = React.useState(null)
60
60
  const [selectionProvider, setSelectionProvider] = React.useState(''); const [selectionDraft, setSelectionDraft] = React.useState([]); const [capabilityFilter, setCapabilityFilter] = React.useState([]); const [searchQuery, setSearchQuery] = React.useState(''); const [sortBy, setSortBy] = React.useState('name'); const [preview, setPreview] = React.useState(null); const [policyProvider, setPolicyProvider] = React.useState(''); const [policyDraft, setPolicyDraft] = React.useState({ include: '', exclude: '', requireCapabilities: '', denyCapabilities: '' })
61
+ const [tryState, setTryState] = React.useState({})
62
+ const doTry = (provider, modelId) => { setTryState((s)=>({ ...s, [provider+':'+modelId]: { loading: true } })); fetch(`${API}/try`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ provider, model: modelId }) }).then(async r=>{ const b=await r.json(); if(!r.ok) throw new Error(b?.error?.message||`HTTP ${r.status}`); return b }).then(b=>setTryState((s)=>({ ...s, [provider+':'+modelId]: { ok: b.ok, latency: b.latencyMs, status: b.status } }))).catch(e=>setTryState((s)=>({ ...s, [provider+':'+modelId]: { error: String(e.message||e) } }))) }
61
63
  const load = React.useCallback(() => { setError(''); return fetch(`${API}/status`).then((r) => r.json()).then(setStatus).catch((e) => setError(String(e?.message ?? e))) }, [])
62
64
  const loadCredentials = React.useCallback(() => { setError(''); return fetch(`${API}/credentials`).then(async (r) => { const body = await r.json(); if (!r.ok) throw new Error(body?.error?.message ?? `HTTP ${r.status}`); return body }).then(setCredentialStatus).catch((e) => setError(String(e?.message ?? e))) }, [])
63
65
  React.useEffect(() => { ensureStyles(); load(); loadCredentials(); const timer = setInterval(load, 15000); return () => clearInterval(timer) }, [load, loadCredentials])
@@ -116,8 +118,12 @@ window.__ModuleLoader__.load({
116
118
  const latestRun = status?.lastRun?.results?.find((item) => item.provider === row.provider)
117
119
  const addedCount = latestRun?.status === 'ok' ? (latestRun.diff?.added?.length ?? 0) : 0
118
120
  const modelSummary = `${modelCount} models${addedCount > 0 ? ` +${addedCount}` : ''}`
119
- const providerCard = React.createElement('div', { className: 'dms-provider' }, React.createElement('span', null, `${row.provider}: ${row.configured ? t.ok : t.dormant} · ${modelSummary}${lifecycleText}${healthText}`), button(t.selectModels, () => openSelection(row)), button(t.policy, () => openPolicy(row)))
120
- const selectionEditor = selectionProvider === row.provider ? React.createElement('div', { className: 'dms-editor' }, React.createElement('strong', null, `${t.selectModels}: ${selectionProvider}`), React.createElement('div', { style: { display: 'flex', gap: 8, margin: '8px 0', flexWrap: 'wrap' } }, React.createElement('input', { placeholder: 'Поиск id/name/tag', value: searchQuery, onChange: (e)=>setSearchQuery(e.target.value), style: { flex: '1 1 160px', padding: '6px 8px', borderRadius: 6, border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-2)' } }), React.createElement('select', { value: sortBy, onChange: (e)=>setSortBy(e.target.value), style: { padding: '6px 8px', borderRadius: 6 } }, React.createElement('option', {value:'name'}, 'Имя A→Z'), React.createElement('option', {value:'price'}, 'Цена ↑'), React.createElement('option', {value:'context'}, 'Контекст ↓'), React.createElement('option', {value:'date'}, 'Новые ↓'))), React.createElement('div', { style: { margin: '8px 0' } }, choices.length === 0 ? React.createElement('small', null, t.noModels) : React.createElement(React.Fragment, null, availableCapabilities.length > 0 ? React.createElement('div', { style: { marginBottom: 8 } }, React.createElement('strong', null, t.capabilityFilter), availableCapabilities.map((capability) => React.createElement('label', { key: capability, style: { display: 'inline-block', marginLeft: 8 } }, React.createElement('input', { type: 'checkbox', checked: capabilityFilter.includes(capability), onChange: (e) => setCapabilityFilter((current) => e.target.checked ? [...current, capability] : current.filter((item) => item !== capability)) }), ' ' + (CAPABILITY_LABELS[capability]?.[locale] ?? capability)))) : React.createElement('small', { style: { display: 'block', marginBottom: 8 } }, t.noCapabilities), React.createElement('small', { style: { display: 'block', marginBottom: 8 } }, filteredChoices.length + '/' + choices.length + ' ' + t.capabilityCount), filteredChoices.map((model) => React.createElement('label', { key: model.id, style: { display: 'block', margin: '4px 0', padding: '4px 6px', borderRadius: 6, background: isNewModel(model,row) ? 'var(--dsw-alias-bg-layer-2)' : 'transparent' } }, React.createElement('input', { type: 'checkbox', checked: selectionDraft.includes(model.id), onChange: (e) => setSelectionDraft((current) => e.target.checked ? [...current, model.id] : current.filter((id) => id !== model.id)) }), ` ${model.name ?? model.id} (${model.id})`, isNewModel(model,row) ? React.createElement('span', { style: { marginLeft: 6, fontSize: 11, padding: '1px 5px', borderRadius: 4, background: 'var(--dsw-alias-state-business-primary)', color: 'white' } }, 'NEW') : null, model.tags && model.tags.length ? React.createElement('small', { style: { marginLeft: 6, color: 'var(--dsw-alias-label-secondary)' } }, model.tags.join(', ')) : null, model.description ? React.createElement('small', { title: model.description, style: { display: 'block', marginLeft: 22, color: 'var(--dsw-alias-label-secondary)', fontSize: 11 } }, model.description.slice(0,120)) : null, row.baseURL || row.api ? React.createElement('a', { href: (row.baseURL||'') + '/models/'+encodeURIComponent(model.id), target: '_blank', rel: 'noopener', style: { marginLeft: 8, fontSize: 11 } }, 'docs') : null)))), React.createElement('small', null, t.pickerNote), React.createElement('div', { style: { marginTop: 8 } }, button(t.allModels, () => setSelectionDraft(choices.map((model) => model.id))), button(t.saveSelection, saveSelection), button(t.cancel, closeSelection))) : null
121
+ const healthEntry = status?.lastHealth?.results?.find((item) => item.provider === row.provider)
122
+ const latencyText = healthEntry?.latencyMs ? ` · ${healthEntry.latencyMs}ms` : ''
123
+ const isOffline = latestRun?.status !== 'ok' && modelCount > 0
124
+ const offlineText = isOffline ? ` · кеш ${Math.max(0, Math.round((Date.now() - (status?.lastRun?.finishedAt ? new Date(status.lastRun.finishedAt).getTime() : Date.now()))/3600000))}ч назад` : ''
125
+ const providerCard = React.createElement('div', { className: 'dms-provider' }, React.createElement('span', null, `${row.provider}: ${row.configured ? t.ok : t.dormant} · ${modelSummary}${lifecycleText}${healthText}${latencyText}${offlineText}`), button(t.selectModels, () => openSelection(row)), button(t.policy, () => openPolicy(row)))
126
+ const selectionEditor = selectionProvider === row.provider ? React.createElement('div', { className: 'dms-editor' }, React.createElement('strong', null, `${t.selectModels}: ${selectionProvider}`), React.createElement('div', { style: { display: 'flex', gap: 8, margin: '8px 0', flexWrap: 'wrap' } }, React.createElement('input', { placeholder: 'Поиск id/name/tag', value: searchQuery, onChange: (e)=>setSearchQuery(e.target.value), style: { flex: '1 1 160px', padding: '6px 8px', borderRadius: 6, border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-2)' } }), React.createElement('select', { value: sortBy, onChange: (e)=>setSortBy(e.target.value), style: { padding: '6px 8px', borderRadius: 6 } }, React.createElement('option', {value:'name'}, 'Имя A→Z'), React.createElement('option', {value:'price'}, 'Цена ↑'), React.createElement('option', {value:'context'}, 'Контекст ↓'), React.createElement('option', {value:'date'}, 'Новые ↓'))), React.createElement('div', { style: { margin: '8px 0' } }, choices.length === 0 ? React.createElement('small', null, t.noModels) : React.createElement(React.Fragment, null, availableCapabilities.length > 0 ? React.createElement('div', { style: { marginBottom: 8 } }, React.createElement('strong', null, t.capabilityFilter), availableCapabilities.map((capability) => React.createElement('label', { key: capability, style: { display: 'inline-block', marginLeft: 8 } }, React.createElement('input', { type: 'checkbox', checked: capabilityFilter.includes(capability), onChange: (e) => setCapabilityFilter((current) => e.target.checked ? [...current, capability] : current.filter((item) => item !== capability)) }), ' ' + (CAPABILITY_LABELS[capability]?.[locale] ?? capability)))) : React.createElement('small', { style: { display: 'block', marginBottom: 8 } }, t.noCapabilities), React.createElement('small', { style: { display: 'block', marginBottom: 8 } }, filteredChoices.length + '/' + choices.length + ' ' + t.capabilityCount), filteredChoices.map((model) => React.createElement('label', { key: model.id, style: { display: 'block', margin: '4px 0', padding: '4px 6px', borderRadius: 6, background: isNewModel(model,row) ? 'var(--dsw-alias-bg-layer-2)' : 'transparent' } }, React.createElement('input', { type: 'checkbox', checked: selectionDraft.includes(model.id), onChange: (e) => setSelectionDraft((current) => e.target.checked ? [...current, model.id] : current.filter((id) => id !== model.id)) }), ` ${model.name ?? model.id} (${model.id})`, isNewModel(model,row) ? React.createElement('span', { style: { marginLeft: 6, fontSize: 11, padding: '1px 5px', borderRadius: 4, background: 'var(--dsw-alias-state-business-primary)', color: 'white' } }, 'NEW') : null, (model.contextWindow && model.contextWindow >= Math.max(...filteredChoices.map(m=>m.contextWindow||0))) ? React.createElement('span', { style: { marginLeft: 4, fontSize: 10, padding: '1px 4px', borderRadius: 3, background: 'var(--dsw-alias-state-warning-primary)', color: 'white' } }, '★') : null, (model.pricing && model.pricing.inputPerToken === Math.min(...filteredChoices.filter(m=>m.pricing?.inputPerToken!==undefined).map(m=>m.pricing.inputPerToken))) ? React.createElement('span', { style: { marginLeft: 4, fontSize: 10, padding: '1px 4px', borderRadius: 3, background: 'var(--dsw-alias-state-success-primary)', color: 'white' } }, '$') : null, model.tags && model.tags.length ? React.createElement('small', { style: { marginLeft: 6, color: 'var(--dsw-alias-label-secondary)' } }, model.tags.join(', ')) : null, model.pricing ? React.createElement('small', { style: { marginLeft: 6, color: 'var(--dsw-alias-label-secondary)', fontSize: 11 } }, `in $${(model.pricing.inputPerToken*1000000).toFixed(2)}/1M` + (model.pricing.cacheReadPerToken!==undefined ? ` / cache $${(model.pricing.cacheReadPerToken*1000000).toFixed(2)}` : '') + (model.pricing.outputPerToken!==undefined ? ` / out $${(model.pricing.outputPerToken*1000000).toFixed(2)}` : '')) : null, model.description ? React.createElement('small', { title: model.description, style: { display: 'block', marginLeft: 22, color: 'var(--dsw-alias-label-secondary)', fontSize: 11 } }, model.description.slice(0,120)) : null, row.baseURL || row.api ? React.createElement('a', { href: (row.baseURL||'') + '/models/'+encodeURIComponent(model.id), target: '_blank', rel: 'noopener', style: { marginLeft: 8, fontSize: 11 } }, 'docs') : null, React.createElement('button', { type: 'button', onClick: (e)=>{e.preventDefault(); doTry(row.provider, model.id);}, style: { marginLeft: 8, fontSize: 11, padding: '1px 6px', borderRadius: 4 } }, (tryState[row.provider+':'+model.id] && tryState[row.provider+':'+model.id].loading) ? '…' : '▶'), tryState[row.provider+':'+model.id] && !tryState[row.provider+':'+model.id].loading ? React.createElement('small', { style: { marginLeft: 4, color: tryState[row.provider+':'+model.id].ok ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-error-primary)', fontSize: 11 } }, tryState[row.provider+':'+model.id].ok ? `${tryState[row.provider+':'+model.id].latency}ms` : (tryState[row.provider+':'+model.id].error||tryState[row.provider+':'+model.id].status)) : null)))), React.createElement('small', null, t.pickerNote), React.createElement('div', { style: { marginTop: 8 } }, button(t.allModels, () => setSelectionDraft(choices.map((model) => model.id))), button(t.saveSelection, saveSelection), button(t.cancel, closeSelection))) : null
121
127
  const policyEditor = policyProvider === row.provider ? React.createElement('div', { className: 'dms-editor' }, React.createElement('strong', null, `${t.policy}: ${policyProvider}`), React.createElement('label', { style: { display: 'block', marginTop: 8 } }, t.policyInclude, React.createElement('textarea', { rows: 3, value: policyDraft.include, onChange: (e) => setPolicyDraft((current) => ({ ...current, include: e.target.value })), style: { display: 'block', width: '100%' } })), React.createElement('label', { style: { display: 'block', marginTop: 8 } }, t.policyExclude, React.createElement('textarea', { rows: 3, value: policyDraft.exclude, onChange: (e) => setPolicyDraft((current) => ({ ...current, exclude: e.target.value })), style: { display: 'block', width: '100%' } })), React.createElement('label', { style: { display: 'block', marginTop: 8 } }, t.policyRequire, React.createElement('input', { value: policyDraft.requireCapabilities, onChange: (e) => setPolicyDraft((current) => ({ ...current, requireCapabilities: e.target.value })), style: { display: 'block', width: '100%' } })), React.createElement('label', { style: { display: 'block', marginTop: 8 } }, t.policyDeny, React.createElement('input', { value: policyDraft.denyCapabilities, onChange: (e) => setPolicyDraft((current) => ({ ...current, denyCapabilities: e.target.value })), style: { display: 'block', width: '100%' } })), React.createElement('small', null, t.policyNote), React.createElement('div', { style: { marginTop: 8 } }, button(t.savePolicy, savePolicy), button(t.cancel, closePolicy))) : null
122
128
  return React.createElement('div', { key: row.provider, className: 'dms-provider-wrap' }, providerCard, selectionEditor, policyEditor)
123
129
  }),
@@ -128,9 +134,13 @@ window.__ModuleLoader__.load({
128
134
  }
129
135
  function ModelSyncCard({ ctx }) {
130
136
  const locale = useLocale(ctx); const t = locale === 'ru' ? ru : en; const [open, setOpen] = React.useState(false)
137
+ const [cardStatus, setCardStatus] = React.useState(null)
138
+ React.useEffect(() => { const load = () => fetch(`${API}/status`).then(r=>r.json()).then(setCardStatus).catch(()=>{}); load(); const id=setInterval(load,15000); return ()=>clearInterval(id)}, [])
139
+ const cardBadge = (cardStatus?.lastRun?.results?.reduce((s,r)=>s+(r.diff?.added?.length||0),0) || 0)
131
140
  return React.createElement('li', { className: 'dms-card' + (open ? ' dms-card-open' : '') },
132
141
  React.createElement('button', { type: 'button', className: 'dms-card-header', 'aria-expanded': open, 'aria-label': `${t[open ? 'hideSettings' : 'showSettings']}: ${t.title}`, onClick: () => setOpen((value) => !value) },
133
142
  React.createElement('span', { className: 'dms-card-head-text' }, React.createElement('span', { className: 'dms-card-name' }, t.title), React.createElement('span', { className: 'dms-card-description' }, t.desc)),
143
+ cardBadge ? React.createElement('span', { style: { background: 'var(--dsw-alias-state-business-primary)', color: 'white', padding: '2px 6px', borderRadius: 6, fontSize: 11, marginLeft: 8 } }, `+${cardBadge}`) : null,
134
144
  React.createElement('svg', { width: 14, height: 14, className: 'dms-card-chevron' + (open ? ' dms-card-chevron-open' : ''), viewBox: '0 0 14 14', fill: 'none', 'aria-hidden': 'true' }, React.createElement('path', { d: 'M11.8486 5.5L11.4238 5.92383L8.69727 8.65137C8.44157 8.90706 8.21562 9.13382 8.01172 9.29785C7.79912 9.46883 7.55595 9.61756 7.25 9.66602C7.08435 9.69222 6.91565 9.69222 6.75 9.66602C6.44405 9.61756 6.20088 9.46883 5.98828 9.29785C5.78438 9.13382 5.55843 8.90706 5.30273 8.65137L2.57617 5.92383L2.15137 5.5L3 4.65137L3.42383 5.07617L6.15137 7.80273C6.42595 8.07732 6.59876 8.24849 6.74023 8.3623C6.87291 8.46904 6.92272 8.47813 6.9375 8.48047C6.97895 8.48703 7.02105 8.48703 7.0625 8.48047C7.07728 8.47813 7.12709 8.46904 7.25977 8.3623C7.40124 8.24849 7.57405 8.07732 7.84863 7.80273L10.5762 5.07617L11 4.65137L11.8486 5.5', fill: 'currentColor' }))),
135
145
  open ? React.createElement('div', { className: 'dms-card-body' }, React.createElement(ModelSyncSection, { ctx, includeIntro: false })) : null)
136
146
  }
package/lib/http.js CHANGED
@@ -1,204 +1,214 @@
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
- if (req.headers?.['sec-fetch-site'] === 'cross-site') return false
12
- const origin = req.headers?.origin
13
- if (!origin) return true
14
- const host = req.headers?.host
15
- if (!host) return false
16
- try { return new URL(origin).host === host } catch { return false }
17
- }
18
-
19
- function readJson(req) {
20
- return new Promise((resolve, reject) => {
21
- let raw = ''
22
- req.on('data', (chunk) => {
23
- raw += chunk
24
- if (raw.length > 64 * 1024) reject(new Error('request body is too large'))
25
- })
26
- req.on('end', () => {
27
- try { resolve(JSON.parse(raw || '{}')) } catch (error) { reject(error) }
28
- })
29
- req.on('error', reject)
30
- })
31
- }
32
-
33
- export function normalizeRunRequest(body) {
34
- if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
35
- if (body.provider !== undefined && (typeof body.provider !== 'string' || body.provider.length > 128)) throw new Error('provider must be a short string')
36
- if (body.dryRun !== undefined && typeof body.dryRun !== 'boolean') throw new Error('dryRun must be boolean')
37
- if (body.removeMissing !== undefined && typeof body.removeMissing !== 'boolean') throw new Error('removeMissing must be boolean')
38
- return {
39
- ...(body.provider ? { provider: body.provider } : {}),
40
- dryRun: body.dryRun !== false,
41
- removeMissing: body.removeMissing === true,
42
- }
43
- }
44
-
45
- export function normalizeCredentialRequest(url = '') {
46
- const parsed = new URL(url || '/', 'http://dsh-model-sync.local')
47
- const provider = parsed.searchParams.get('provider') || undefined
48
- if (provider && (provider.length > 128 || !/^[A-Za-z0-9._:-]+$/.test(provider))) throw new Error('provider must be a short string')
49
- return provider ? { provider } : {}
50
- }
51
-
52
- export function normalizeNotificationRequest(url = '') {
53
- const parsed = new URL(url || '/', 'http://dsh-model-sync.local')
54
- return { includeAcknowledged: parsed.searchParams.get('includeAcknowledged') !== 'false' }
55
- }
56
-
57
- export function normalizeNotificationAction(body) {
58
- if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
59
- if (typeof body.id !== 'string' || !body.id || body.id.length > 128) throw new Error('notification id is invalid')
60
- return { id: body.id.trim() }
61
- }
62
-
63
- export function normalizeCredentialCheckRequest(body) {
64
- if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
65
- if (body.provider !== undefined && (typeof body.provider !== 'string' || body.provider.length > 128)) throw new Error('provider must be a short string')
66
- return body.provider ? { provider: body.provider.trim() } : {}
67
- }
68
-
69
- export function normalizeHealthRequest(body) {
70
- if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
71
- if (body.provider !== undefined && (typeof body.provider !== 'string' || body.provider.length > 128)) throw new Error('provider must be a short string')
72
- return body.provider ? { provider: body.provider } : {}
73
- }
74
-
75
- export function normalizePolicyRequest(body) {
76
- if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
77
- if (typeof body.provider !== 'string' || !body.provider || body.provider.length > 128) throw new Error('provider must be a short string')
78
- for (const field of ['include', 'exclude']) {
79
- if (body[field] !== undefined && (!Array.isArray(body[field]) || body[field].length > 100)) throw new Error(`${field} must be an array`)
80
- if (Array.isArray(body[field]) && body[field].some((value) => typeof value !== 'string' || value.length > 256)) throw new Error(`${field} patterns must be short strings`)
81
- }
82
- for (const field of ['requireCapabilities', 'denyCapabilities']) {
83
- if (body[field] !== undefined && (!body[field] || typeof body[field] !== 'object' || Array.isArray(body[field]))) throw new Error(`${field} must be an object`)
84
- if (body[field] && Object.keys(body[field]).length > 4) throw new Error(`${field} has too many capabilities`)
85
- if (body[field] && Object.values(body[field]).some((value) => typeof value !== 'boolean')) throw new Error(`${field} values must be boolean`)
86
- }
87
- return {
88
- provider: body.provider.trim(),
89
- policy: validatePolicy({
90
- include: body.include,
91
- exclude: body.exclude,
92
- requireCapabilities: body.requireCapabilities,
93
- denyCapabilities: body.denyCapabilities,
94
- }),
95
- }
96
- }
97
-
98
- export function normalizeHistoryRequest(url = '') {
99
- const parsed = new URL(url || '/', 'http://dsh-model-sync.local')
100
- const limitValue = Number(parsed.searchParams.get('limit') ?? 50)
101
- const limit = Number.isFinite(limitValue) ? Math.min(100, Math.max(1, Math.floor(limitValue))) : 50
102
- const provider = parsed.searchParams.get('provider') || undefined
103
- const historyId = parsed.searchParams.get('historyId') || undefined
104
- if (provider && provider.length > 128) throw new Error('provider must be a short string')
105
- if (historyId && historyId.length > 128) throw new Error('historyId must be a short string')
106
- return { limit, ...(provider ? { provider } : {}), ...(historyId ? { historyId } : {}), details: parsed.searchParams.get('details') === 'true' }
107
- }
108
-
109
- export function normalizeHistoryRollbackRequest(body) {
110
- if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
111
- if (typeof body.historyId !== 'string' || !body.historyId || body.historyId.length > 128) throw new Error('historyId must be a short string')
112
- if (typeof body.provider !== 'string' || !body.provider || body.provider.length > 128) throw new Error('provider must be a short string')
113
- return { historyId: body.historyId.trim(), provider: body.provider.trim() }
114
- }
115
-
116
- export function normalizeSelectionRequest(body) {
117
- if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
118
- if (typeof body.provider !== 'string' || !body.provider || body.provider.length > 128) throw new Error('provider must be a short string')
119
- if (!Array.isArray(body.models) || body.models.length > 5000) throw new Error('models must be an array')
120
- const models = []
121
- const seen = new Set()
122
- for (const model of body.models) {
123
- if (typeof model !== 'string' || model.length > 512) throw new Error('model ids must be short strings')
124
- const id = model.trim()
125
- if (id && !seen.has(id)) {
126
- seen.add(id)
127
- models.push(id)
128
- }
129
- }
130
- return { provider: body.provider, models }
131
- }
132
-
133
- export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () => null } = {}) {
134
- const makeHandler = (route) => async (req, res) => {
135
- if (!trusted(req)) return json(res, 403, { error: { code: 'forbidden', message: 'dsh-model-sync: cross-origin request rejected' } })
136
- if (route === 'status') {
137
- if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
138
- return json(res, 200, { ...synchronizer.status(), scheduler: getSchedulerStatus(), providers: synchronizer.listProviders() })
139
- }
140
- if (route === 'history') {
141
- if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
142
- try { return json(res, 200, { history: synchronizer.history(normalizeHistoryRequest(req.url)) }) }
143
- catch (error) { return json(res, 400, { error: { code: 'bad-request', message: error instanceof Error ? error.message : String(error) } }) }
144
- }
145
- if (route === 'credentials') {
146
- if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
147
- try { return json(res, 200, await synchronizer.credentialDiagnostics(normalizeCredentialRequest(req.url))) }
148
- catch (error) { return json(res, 400, { error: { code: 'bad-request', message: error instanceof Error ? error.message : String(error) } }) }
149
- }
150
- if (route === 'report') {
151
- if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
152
- return json(res, 200, { report: synchronizer.report() })
153
- }
154
- if (route === 'notifications') {
155
- if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
156
- return json(res, 200, { notifications: synchronizer.notifications(normalizeNotificationRequest(req.url)) })
157
- }
158
- if (req.method !== 'POST') return json(res, 405, { error: { code: 'method', message: 'POST only' } })
159
- let options
160
- try {
161
- const body = await readJson(req)
162
- options = route === 'selection'
163
- ? normalizeSelectionRequest(body)
164
- : route === 'policy' ? normalizePolicyRequest(body)
165
- : route === 'health' ? normalizeHealthRequest(body)
166
- : route === 'credentials-check' ? normalizeCredentialCheckRequest(body)
167
- : route === 'notification-read' || route === 'notification-acknowledge' ? normalizeNotificationAction(body)
168
- : route === 'rollback' ? normalizeHistoryRollbackRequest(body)
169
- : normalizeRunRequest(body)
170
- }
171
- catch (error) { return json(res, 400, { error: { code: 'bad-request', message: error instanceof Error ? error.message : String(error) } }) }
172
- try {
173
- const result = route === 'selection'
174
- ? await synchronizer.setModelSelection(options.provider, options.models)
175
- : route === 'policy' ? await synchronizer.setModelPolicy(options.provider, options.policy)
176
- : route === 'health' ? await synchronizer.health(options)
177
- : route === 'credentials-check' ? { health: await synchronizer.health(options), credentials: await synchronizer.credentialDiagnostics(options) }
178
- : route === 'notification-read' ? await synchronizer.updateNotification(options.id, 'read')
179
- : route === 'notification-acknowledge' ? await synchronizer.updateNotification(options.id, 'acknowledged')
180
- : route === 'rollback' ? await synchronizer.rollbackHistory(options)
181
- : await synchronizer.run(options)
182
- return json(res, 200, { ...result, providers: synchronizer.listProviders() })
183
- }
184
- catch (error) {
185
- 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
186
- return json(res, code, { error: { code: error?.code ?? 'run-failed', message: error instanceof Error ? error.message : String(error) } })
187
- }
188
- }
189
- return [
190
- ctx.webServer.register({ kind: 'exact', path: `${BASE}/status`, handler: makeHandler('status') }),
191
- ctx.webServer.register({ kind: 'exact', path: `${BASE}/run`, handler: makeHandler('run') }),
192
- ctx.webServer.register({ kind: 'exact', path: `${BASE}/health`, handler: makeHandler('health') }),
193
- ctx.webServer.register({ kind: 'exact', path: `${BASE}/selection`, handler: makeHandler('selection') }),
194
- ctx.webServer.register({ kind: 'exact', path: `${BASE}/policy`, handler: makeHandler('policy') }),
195
- ctx.webServer.register({ kind: 'exact', path: `${BASE}/history`, handler: makeHandler('history') }),
196
- ctx.webServer.register({ kind: 'exact', path: `${BASE}/history/rollback`, handler: makeHandler('rollback') }),
197
- ctx.webServer.register({ kind: 'exact', path: `${BASE}/credentials`, handler: makeHandler('credentials') }),
198
- ctx.webServer.register({ kind: 'exact', path: `${BASE}/credentials/check`, handler: makeHandler('credentials-check') }),
199
- ctx.webServer.register({ kind: 'exact', path: BASE + '/report', handler: makeHandler('report') }),
200
- ctx.webServer.register({ kind: 'exact', path: BASE + '/notifications', handler: makeHandler('notifications') }),
201
- ctx.webServer.register({ kind: 'exact', path: BASE + '/notifications/read', handler: makeHandler('notification-read') }),
202
- ctx.webServer.register({ kind: 'exact', path: BASE + '/notifications/acknowledge', handler: makeHandler('notification-acknowledge') }),
203
- ]
204
- }
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
+ if (req.headers?.['sec-fetch-site'] === 'cross-site') return false
12
+ const origin = req.headers?.origin
13
+ if (!origin) return true
14
+ const host = req.headers?.host
15
+ if (!host) return false
16
+ try { return new URL(origin).host === host } catch { return false }
17
+ }
18
+
19
+ function readJson(req) {
20
+ return new Promise((resolve, reject) => {
21
+ let raw = ''
22
+ req.on('data', (chunk) => {
23
+ raw += chunk
24
+ if (raw.length > 64 * 1024) reject(new Error('request body is too large'))
25
+ })
26
+ req.on('end', () => {
27
+ try { resolve(JSON.parse(raw || '{}')) } catch (error) { reject(error) }
28
+ })
29
+ req.on('error', reject)
30
+ })
31
+ }
32
+
33
+ export function normalizeRunRequest(body) {
34
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
35
+ if (body.provider !== undefined && (typeof body.provider !== 'string' || body.provider.length > 128)) throw new Error('provider must be a short string')
36
+ if (body.dryRun !== undefined && typeof body.dryRun !== 'boolean') throw new Error('dryRun must be boolean')
37
+ if (body.removeMissing !== undefined && typeof body.removeMissing !== 'boolean') throw new Error('removeMissing must be boolean')
38
+ return {
39
+ ...(body.provider ? { provider: body.provider } : {}),
40
+ dryRun: body.dryRun !== false,
41
+ removeMissing: body.removeMissing === true,
42
+ }
43
+ }
44
+
45
+ export function normalizeCredentialRequest(url = '') {
46
+ const parsed = new URL(url || '/', 'http://dsh-model-sync.local')
47
+ const provider = parsed.searchParams.get('provider') || undefined
48
+ if (provider && (provider.length > 128 || !/^[A-Za-z0-9._:-]+$/.test(provider))) throw new Error('provider must be a short string')
49
+ return provider ? { provider } : {}
50
+ }
51
+
52
+ export function normalizeNotificationRequest(url = '') {
53
+ const parsed = new URL(url || '/', 'http://dsh-model-sync.local')
54
+ return { includeAcknowledged: parsed.searchParams.get('includeAcknowledged') !== 'false' }
55
+ }
56
+
57
+ export function normalizeNotificationAction(body) {
58
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
59
+ if (typeof body.id !== 'string' || !body.id || body.id.length > 128) throw new Error('notification id is invalid')
60
+ return { id: body.id.trim() }
61
+ }
62
+
63
+ export function normalizeCredentialCheckRequest(body) {
64
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
65
+ if (body.provider !== undefined && (typeof body.provider !== 'string' || body.provider.length > 128)) throw new Error('provider must be a short string')
66
+ return body.provider ? { provider: body.provider.trim() } : {}
67
+ }
68
+
69
+ export function normalizeTryRequest(body) {
70
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
71
+ if (typeof body.provider !== 'string' || !body.provider) throw new Error('provider is required')
72
+ if (typeof body.model !== 'string' || !body.model) throw new Error('model is required')
73
+ return { provider: body.provider, model: body.model }
74
+ }
75
+
76
+ export function normalizeHealthRequest(body) {
77
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
78
+ if (body.provider !== undefined && (typeof body.provider !== 'string' || body.provider.length > 128)) throw new Error('provider must be a short string')
79
+ return body.provider ? { provider: body.provider } : {}
80
+ }
81
+
82
+ export function normalizePolicyRequest(body) {
83
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
84
+ if (typeof body.provider !== 'string' || !body.provider || body.provider.length > 128) throw new Error('provider must be a short string')
85
+ for (const field of ['include', 'exclude']) {
86
+ if (body[field] !== undefined && (!Array.isArray(body[field]) || body[field].length > 100)) throw new Error(`${field} must be an array`)
87
+ if (Array.isArray(body[field]) && body[field].some((value) => typeof value !== 'string' || value.length > 256)) throw new Error(`${field} patterns must be short strings`)
88
+ }
89
+ for (const field of ['requireCapabilities', 'denyCapabilities']) {
90
+ if (body[field] !== undefined && (!body[field] || typeof body[field] !== 'object' || Array.isArray(body[field]))) throw new Error(`${field} must be an object`)
91
+ if (body[field] && Object.keys(body[field]).length > 4) throw new Error(`${field} has too many capabilities`)
92
+ if (body[field] && Object.values(body[field]).some((value) => typeof value !== 'boolean')) throw new Error(`${field} values must be boolean`)
93
+ }
94
+ return {
95
+ provider: body.provider.trim(),
96
+ policy: validatePolicy({
97
+ include: body.include,
98
+ exclude: body.exclude,
99
+ requireCapabilities: body.requireCapabilities,
100
+ denyCapabilities: body.denyCapabilities,
101
+ }),
102
+ }
103
+ }
104
+
105
+ export function normalizeHistoryRequest(url = '') {
106
+ const parsed = new URL(url || '/', 'http://dsh-model-sync.local')
107
+ const limitValue = Number(parsed.searchParams.get('limit') ?? 50)
108
+ const limit = Number.isFinite(limitValue) ? Math.min(100, Math.max(1, Math.floor(limitValue))) : 50
109
+ const provider = parsed.searchParams.get('provider') || undefined
110
+ const historyId = parsed.searchParams.get('historyId') || undefined
111
+ if (provider && provider.length > 128) throw new Error('provider must be a short string')
112
+ if (historyId && historyId.length > 128) throw new Error('historyId must be a short string')
113
+ return { limit, ...(provider ? { provider } : {}), ...(historyId ? { historyId } : {}), details: parsed.searchParams.get('details') === 'true' }
114
+ }
115
+
116
+ export function normalizeHistoryRollbackRequest(body) {
117
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
118
+ if (typeof body.historyId !== 'string' || !body.historyId || body.historyId.length > 128) throw new Error('historyId must be a short string')
119
+ if (typeof body.provider !== 'string' || !body.provider || body.provider.length > 128) throw new Error('provider must be a short string')
120
+ return { historyId: body.historyId.trim(), provider: body.provider.trim() }
121
+ }
122
+
123
+ export function normalizeSelectionRequest(body) {
124
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
125
+ if (typeof body.provider !== 'string' || !body.provider || body.provider.length > 128) throw new Error('provider must be a short string')
126
+ if (!Array.isArray(body.models) || body.models.length > 5000) throw new Error('models must be an array')
127
+ const models = []
128
+ const seen = new Set()
129
+ for (const model of body.models) {
130
+ if (typeof model !== 'string' || model.length > 512) throw new Error('model ids must be short strings')
131
+ const id = model.trim()
132
+ if (id && !seen.has(id)) {
133
+ seen.add(id)
134
+ models.push(id)
135
+ }
136
+ }
137
+ return { provider: body.provider, models }
138
+ }
139
+
140
+ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () => null } = {}) {
141
+ const makeHandler = (route) => async (req, res) => {
142
+ if (!trusted(req)) return json(res, 403, { error: { code: 'forbidden', message: 'dsh-model-sync: cross-origin request rejected' } })
143
+ if (route === 'status') {
144
+ if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
145
+ return json(res, 200, { ...synchronizer.status(), scheduler: getSchedulerStatus(), providers: synchronizer.listProviders() })
146
+ }
147
+ if (route === 'history') {
148
+ if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
149
+ try { return json(res, 200, { history: synchronizer.history(normalizeHistoryRequest(req.url)) }) }
150
+ catch (error) { return json(res, 400, { error: { code: 'bad-request', message: error instanceof Error ? error.message : String(error) } }) }
151
+ }
152
+ if (route === 'credentials') {
153
+ if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
154
+ try { return json(res, 200, await synchronizer.credentialDiagnostics(normalizeCredentialRequest(req.url))) }
155
+ catch (error) { return json(res, 400, { error: { code: 'bad-request', message: error instanceof Error ? error.message : String(error) } }) }
156
+ }
157
+ if (route === 'report') {
158
+ if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
159
+ return json(res, 200, { report: synchronizer.report() })
160
+ }
161
+ if (route === 'notifications') {
162
+ if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
163
+ return json(res, 200, { notifications: synchronizer.notifications(normalizeNotificationRequest(req.url)) })
164
+ }
165
+ if (req.method !== 'POST') return json(res, 405, { error: { code: 'method', message: 'POST only' } })
166
+ let options
167
+ try {
168
+ const body = await readJson(req)
169
+ options = route === 'selection'
170
+ ? normalizeSelectionRequest(body)
171
+ : route === 'policy' ? normalizePolicyRequest(body)
172
+ : route === 'try' ? normalizeTryRequest(body)
173
+ : route === 'health' ? normalizeHealthRequest(body)
174
+ : route === 'credentials-check' ? normalizeCredentialCheckRequest(body)
175
+ : route === 'notification-read' || route === 'notification-acknowledge' ? normalizeNotificationAction(body)
176
+ : route === 'rollback' ? normalizeHistoryRollbackRequest(body)
177
+ : normalizeRunRequest(body)
178
+ }
179
+ catch (error) { return json(res, 400, { error: { code: 'bad-request', message: error instanceof Error ? error.message : String(error) } }) }
180
+ try {
181
+ const result = route === 'selection'
182
+ ? await synchronizer.setModelSelection(options.provider, options.models)
183
+ : route === 'policy' ? await synchronizer.setModelPolicy(options.provider, options.policy)
184
+ : route === 'try' ? await synchronizer.tryModel(options)
185
+ : route === 'health' ? await synchronizer.health(options)
186
+ : route === 'credentials-check' ? { health: await synchronizer.health(options), credentials: await synchronizer.credentialDiagnostics(options) }
187
+ : route === 'notification-read' ? await synchronizer.updateNotification(options.id, 'read')
188
+ : route === 'notification-acknowledge' ? await synchronizer.updateNotification(options.id, 'acknowledged')
189
+ : route === 'rollback' ? await synchronizer.rollbackHistory(options)
190
+ : await synchronizer.run(options)
191
+ return json(res, 200, { ...result, providers: synchronizer.listProviders() })
192
+ }
193
+ catch (error) {
194
+ 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
195
+ return json(res, code, { error: { code: error?.code ?? 'run-failed', message: error instanceof Error ? error.message : String(error) } })
196
+ }
197
+ }
198
+ return [
199
+ ctx.webServer.register({ kind: 'exact', path: `${BASE}/status`, handler: makeHandler('status') }),
200
+ ctx.webServer.register({ kind: 'exact', path: `${BASE}/run`, handler: makeHandler('run') }),
201
+ ctx.webServer.register({ kind: 'exact', path: `${BASE}/try`, handler: makeHandler('try') }),
202
+ ctx.webServer.register({ kind: 'exact', path: `${BASE}/health`, handler: makeHandler('health') }),
203
+ ctx.webServer.register({ kind: 'exact', path: `${BASE}/selection`, handler: makeHandler('selection') }),
204
+ ctx.webServer.register({ kind: 'exact', path: `${BASE}/policy`, handler: makeHandler('policy') }),
205
+ ctx.webServer.register({ kind: 'exact', path: `${BASE}/history`, handler: makeHandler('history') }),
206
+ ctx.webServer.register({ kind: 'exact', path: `${BASE}/history/rollback`, handler: makeHandler('rollback') }),
207
+ ctx.webServer.register({ kind: 'exact', path: `${BASE}/credentials`, handler: makeHandler('credentials') }),
208
+ ctx.webServer.register({ kind: 'exact', path: `${BASE}/credentials/check`, handler: makeHandler('credentials-check') }),
209
+ ctx.webServer.register({ kind: 'exact', path: BASE + '/report', handler: makeHandler('report') }),
210
+ ctx.webServer.register({ kind: 'exact', path: BASE + '/notifications', handler: makeHandler('notifications') }),
211
+ ctx.webServer.register({ kind: 'exact', path: BASE + '/notifications/read', handler: makeHandler('notification-read') }),
212
+ ctx.webServer.register({ kind: 'exact', path: BASE + '/notifications/acknowledge', handler: makeHandler('notification-acknowledge') }),
213
+ ]
214
+ }