@goodandready/dsh-model-sync 0.5.5 → 0.5.6
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/adapter-registry.js +18 -0
- package/lib/diff-notifier.js +27 -1
- package/lib/http.js +33 -5
- package/lib/index.js +2 -1
- package/lib/synchronizer.js +12 -1
- package/lib/updater.js +0 -1
- package/package.json +1 -1
package/lib/adapter-registry.js
CHANGED
|
@@ -137,6 +137,24 @@ export function normalizeAdapterConfig(config, profile = {}) {
|
|
|
137
137
|
if (endpoint.protocol === 'http:' && !privateHttpHost(endpoint.hostname)) {
|
|
138
138
|
throw Object.assign(new Error('adapter endpoint must use https outside loopback'), { code: 'ADAPTER_CONFIG_INVALID' })
|
|
139
139
|
}
|
|
140
|
+
if (profile?.provider && PROVIDER_ADAPTERS[profile.provider]) {
|
|
141
|
+
const defaultEndpoint = PROVIDER_ADAPTERS[profile.provider].endpoint
|
|
142
|
+
let defaultUrl
|
|
143
|
+
try { defaultUrl = new URL(defaultEndpoint) } catch (_err) { defaultUrl = null }
|
|
144
|
+
if (defaultUrl) {
|
|
145
|
+
const knownHost = defaultUrl.hostname.toLowerCase()
|
|
146
|
+
const targetHost = endpoint.hostname.toLowerCase()
|
|
147
|
+
const isDomainMatch = targetHost === knownHost || targetHost.endsWith('.' + knownHost.replace(/^api\./, ''))
|
|
148
|
+
const isLoopback = privateHttpHost(targetHost)
|
|
149
|
+
const isExplicitlyAllowed = source.allowCustomHost === true || source.allowCustomEndpoint === true
|
|
150
|
+
if (!isDomainMatch && !isLoopback && !isExplicitlyAllowed) {
|
|
151
|
+
throw Object.assign(
|
|
152
|
+
new Error(`adapter endpoint host "${endpoint.hostname}" does not match known provider domain for "${profile.provider}"`),
|
|
153
|
+
{ code: 'ADAPTER_HOST_MISMATCH' }
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
140
158
|
const auth = source.auth === undefined ? 'bearer' : String(source.auth)
|
|
141
159
|
if (!SAFE_AUTH.has(auth)) throw Object.assign(new Error('adapter auth mode is unsupported'), { code: 'ADAPTER_CONFIG_INVALID' })
|
|
142
160
|
const parse = source.parser === undefined ? 'openai' : String(source.parser)
|
package/lib/diff-notifier.js
CHANGED
|
@@ -114,6 +114,20 @@ export async function sendWebhookNotification(url, secret, payload, { fetchImpl
|
|
|
114
114
|
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
|
|
115
115
|
return { ok: false, error: "URL must use http or https protocol" }
|
|
116
116
|
}
|
|
117
|
+
const hostname = parsedUrl.hostname.toLowerCase().replace(/^\[|\]$/g, '')
|
|
118
|
+
if (
|
|
119
|
+
hostname === '169.254.169.254' ||
|
|
120
|
+
hostname.startsWith('169.254.') ||
|
|
121
|
+
hostname === 'metadata.google.internal' ||
|
|
122
|
+
hostname === '0.0.0.0' ||
|
|
123
|
+
hostname === '255.255.255.255'
|
|
124
|
+
) {
|
|
125
|
+
return { ok: false, error: "webhook url targets a forbidden metadata or link-local address" }
|
|
126
|
+
}
|
|
127
|
+
const isLoopback = hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '::1' || hostname.startsWith('127.') || hostname.startsWith('::ffff:127.')
|
|
128
|
+
if (parsedUrl.protocol === "http:" && !isLoopback) {
|
|
129
|
+
return { ok: false, error: "insecure http webhook url is only permitted on loopback" }
|
|
130
|
+
}
|
|
117
131
|
} catch (err) {
|
|
118
132
|
return { ok: false, error: "Invalid URL: " + (err?.message || String(err)) }
|
|
119
133
|
}
|
|
@@ -181,9 +195,21 @@ export async function dispatchDiffNotifications(ctx, config, results, { fetchImp
|
|
|
181
195
|
|
|
182
196
|
let webhookResult = null
|
|
183
197
|
if (config?.diffWebhookUrl && typeof config.diffWebhookUrl === "string" && config.diffWebhookUrl.trim()) {
|
|
198
|
+
let secret = config.diffWebhookSecret || ''
|
|
199
|
+
const secretRef = config.diffWebhookSecretRef || (typeof config.diffWebhookSecret === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(config.diffWebhookSecret) ? config.diffWebhookSecret : '')
|
|
200
|
+
if (secretRef) {
|
|
201
|
+
try {
|
|
202
|
+
const res = await (ctx?.credentials?.resolve ? ctx.credentials.resolve(secretRef) : ctx?.get?.('credentials')?.resolve?.(secretRef))
|
|
203
|
+
if (res) secret = typeof res === 'string' ? res : res?.value || secret
|
|
204
|
+
} catch (err) {
|
|
205
|
+
if (typeof ctx?.logger?.debug === 'function') {
|
|
206
|
+
ctx.logger.debug('[dsh-model-sync] webhook secret resolution fallback:', err)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
184
210
|
webhookResult = await sendWebhookNotification(
|
|
185
211
|
config.diffWebhookUrl,
|
|
186
|
-
|
|
212
|
+
secret,
|
|
187
213
|
{
|
|
188
214
|
event: "model-sync.diff",
|
|
189
215
|
...events,
|
package/lib/http.js
CHANGED
|
@@ -2,8 +2,31 @@ import { validateAliasName } from './alias-resolver.js'
|
|
|
2
2
|
function normalizeWebhookTestRequest(body) {
|
|
3
3
|
if (!body || typeof body !== "object") throw Object.assign(new Error("body is required"), { code: "INVALID_REQUEST" })
|
|
4
4
|
if (typeof body.url !== "string" || !body.url.trim()) throw Object.assign(new Error("url is required"), { code: "INVALID_REQUEST" })
|
|
5
|
+
let parsed
|
|
6
|
+
try {
|
|
7
|
+
parsed = new URL(body.url.trim())
|
|
8
|
+
} catch (err) {
|
|
9
|
+
throw Object.assign(new Error("Invalid URL: " + (err?.message || String(err))), { code: "INVALID_REQUEST" })
|
|
10
|
+
}
|
|
11
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
12
|
+
throw Object.assign(new Error("URL must use http or https protocol"), { code: "INVALID_REQUEST" })
|
|
13
|
+
}
|
|
14
|
+
const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '')
|
|
15
|
+
const isLoopback = hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '::1' || hostname.startsWith('127.') || hostname.startsWith('::ffff:127.')
|
|
16
|
+
if (parsed.protocol === "http:" && !isLoopback) {
|
|
17
|
+
throw Object.assign(new Error("insecure http webhook url is only permitted on loopback"), { code: "INVALID_REQUEST" })
|
|
18
|
+
}
|
|
19
|
+
if (
|
|
20
|
+
hostname === '169.254.169.254' ||
|
|
21
|
+
hostname.startsWith('169.254.') ||
|
|
22
|
+
hostname === 'metadata.google.internal' ||
|
|
23
|
+
hostname === '0.0.0.0' ||
|
|
24
|
+
hostname === '255.255.255.255'
|
|
25
|
+
) {
|
|
26
|
+
throw Object.assign(new Error("webhook url targets a forbidden metadata or link-local address"), { code: "INVALID_REQUEST" })
|
|
27
|
+
}
|
|
5
28
|
return {
|
|
6
|
-
url:
|
|
29
|
+
url: parsed.toString(),
|
|
7
30
|
secret: typeof body.secret === "string" ? body.secret.trim() : "",
|
|
8
31
|
}
|
|
9
32
|
}
|
|
@@ -28,7 +51,9 @@ export function isLoopbackAddress(ip) {
|
|
|
28
51
|
export function trusted(req, { requireLoopback = false } = {}) {
|
|
29
52
|
const remoteAddress = req?.socket?.remoteAddress
|
|
30
53
|
if (requireLoopback || req?.method === 'POST') {
|
|
31
|
-
if (!isLoopbackAddress(remoteAddress)) return false
|
|
54
|
+
if (remoteAddress && !isLoopbackAddress(remoteAddress)) return false
|
|
55
|
+
if (!remoteAddress && req?.socket) return false
|
|
56
|
+
if (!remoteAddress && !isLoopbackAddress(req?.headers?.host?.split(':')[0])) return false
|
|
32
57
|
}
|
|
33
58
|
const fetchSite = req.headers?.['sec-fetch-site']
|
|
34
59
|
if (fetchSite === 'cross-site') return false
|
|
@@ -59,8 +84,10 @@ export function trusted(req, { requireLoopback = false } = {}) {
|
|
|
59
84
|
if (fetchSite === 'same-origin' || fetchSite === 'same-site') return true
|
|
60
85
|
// If sec-fetch-mode is present but origin/referer missing on a cross-site request, reject
|
|
61
86
|
if (req.headers?.['sec-fetch-mode'] && !host) return false
|
|
62
|
-
// For
|
|
63
|
-
if (req?.method === 'POST' && !origin && !referer
|
|
87
|
+
// For requests requiring loopback or POST without any browser origin/referer headers, require explicit loopback
|
|
88
|
+
if ((requireLoopback || req?.method === 'POST') && !origin && !referer) {
|
|
89
|
+
if (!isLoopbackAddress(remoteAddress ?? (req?.headers?.host ? req.headers.host.split(':')[0] : ''))) return false
|
|
90
|
+
}
|
|
64
91
|
return true
|
|
65
92
|
}
|
|
66
93
|
|
|
@@ -247,7 +274,8 @@ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () =>
|
|
|
247
274
|
return []
|
|
248
275
|
}
|
|
249
276
|
const makeHandler = (route) => async (req, res) => {
|
|
250
|
-
|
|
277
|
+
const isPublicStatus = route === 'status' && req.method === 'GET'
|
|
278
|
+
if (!trusted(req, { requireLoopback: !isPublicStatus })) return json(res, 403, { error: { code: 'forbidden', message: 'dsh-model-sync: cross-origin or non-loopback request rejected' } })
|
|
251
279
|
if (route === 'status') {
|
|
252
280
|
if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
|
|
253
281
|
const payload = { ...synchronizer.status(), scheduler: getSchedulerStatus(), providers: synchronizer.listProviders() }
|
package/lib/index.js
CHANGED
|
@@ -27,7 +27,8 @@ export const Config = Schema.object({
|
|
|
27
27
|
historyLimit: Schema.number().default(50).volatile(),
|
|
28
28
|
notificationLimit: Schema.number().default(50).volatile(),
|
|
29
29
|
diffWebhookUrl: Schema.string().default().volatile(),
|
|
30
|
-
|
|
30
|
+
diffWebhookSecretRef: Schema.string().role('credential-ref').default('').volatile(),
|
|
31
|
+
diffWebhookSecret: Schema.string().role('credential-ref').default('').volatile(),
|
|
31
32
|
notifyOnModelAdded: Schema.boolean().default(true).volatile(),
|
|
32
33
|
notifyOnModelDeprecated: Schema.boolean().default(true).volatile(),
|
|
33
34
|
notifyOnPriceChanged: Schema.boolean().default(true).volatile(),
|
package/lib/synchronizer.js
CHANGED
|
@@ -554,7 +554,18 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
|
|
|
554
554
|
notifications: notificationLedger,
|
|
555
555
|
updateNotification: updateNotificationState,
|
|
556
556
|
rollbackHistory,
|
|
557
|
-
testWebhook: async ({ url, secret }) =>
|
|
557
|
+
testWebhook: async ({ url, secret }) => {
|
|
558
|
+
let resolvedSecret = secret || ''
|
|
559
|
+
if (typeof secret === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(secret)) {
|
|
560
|
+
try {
|
|
561
|
+
const res = await (ctx?.credentials?.resolve ? ctx.credentials.resolve(secret) : ctx?.get?.('credentials')?.resolve?.(secret))
|
|
562
|
+
if (res) resolvedSecret = typeof res === 'string' ? res : res?.value || resolvedSecret
|
|
563
|
+
} catch (err) {
|
|
564
|
+
ctx?.logger?.debug?.('[dsh-model-sync] secret ref resolution fallback:', err)
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return sendWebhookNotification(url, resolvedSecret, { event: 'model-sync.test', timestamp: Date.now(), test: true })
|
|
568
|
+
},
|
|
558
569
|
status: () => ({ running: Boolean(running), lastRun, lastHealth, lastReport: structuredClone(lastReport), notifications: notificationLedger(), history: history(), policies: listPolicies(), lifecycle: structuredClone(getConfig()?.modelLifecycle ?? {}), aliases: structuredClone(getConfig()?.aliases ?? {}) }),
|
|
559
570
|
})
|
|
560
571
|
}
|
package/lib/updater.js
CHANGED
|
@@ -237,7 +237,6 @@ export async function installExact(target, packageSpec, options = {}) {
|
|
|
237
237
|
'--profile',
|
|
238
238
|
target.profileName,
|
|
239
239
|
'add',
|
|
240
|
-
'--config.minimumReleaseAge=0',
|
|
241
240
|
packageSpec,
|
|
242
241
|
`--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
|
|
243
242
|
],
|