@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.
@@ -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)
@@ -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
- config.diffWebhookSecret,
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: body.url.trim(),
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 POST write requests without any browser origin/referer headers, require explicit loopback remoteAddress
63
- if (req?.method === 'POST' && !origin && !referer && !isLoopbackAddress(remoteAddress)) return false
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
- if (!trusted(req, { requireLoopback: req.method === 'POST' })) return json(res, 403, { error: { code: 'forbidden', message: 'dsh-model-sync: cross-origin or non-loopback request rejected' } })
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
- diffWebhookSecret: Schema.string().default().volatile(),
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(),
@@ -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 }) => sendWebhookNotification(url, secret, { event: 'model-sync.test', timestamp: Date.now(), test: true }),
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
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-model-sync",
3
- "version": "0.5.5",
3
+ "version": "0.5.6",
4
4
  "description": "Automatic model catalog synchronization for API-key DeepSeek Harness providers.",
5
5
  "keywords": [
6
6
  "deepseek-harness",