@goodandready/dsh-model-sync 0.5.8 → 0.5.10

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.
@@ -97,14 +97,32 @@ function mappedRows(provider, rows, descriptor) {
97
97
  }
98
98
 
99
99
  function parseRows(provider, payload, descriptor) {
100
+ if (payload && (payload.error || payload.errors)) {
101
+ const message = typeof payload.error === 'string'
102
+ ? payload.error
103
+ : payload.error?.message || (Array.isArray(payload.errors) ? payload.errors.map(e => e?.message || e).join(', ') : 'endpoint returned error payload')
104
+ const err = new Error(`endpoint returned error payload: ${message}`)
105
+ err.adapterCode = 'schema'
106
+ throw err
107
+ }
100
108
  const parserKind = descriptor.parse || descriptor.parser
101
109
  if (parserKind === 'clinebot-plan') {
102
110
  const plan = payload?.data?.plan || payload?.data || payload?.plan || payload
103
111
  const included = plan?.features?.included || plan?.includedModels || []
112
+ if (!Array.isArray(included)) {
113
+ const err = new Error('model catalog response does not contain plan features')
114
+ err.adapterCode = 'schema'
115
+ throw err
116
+ }
104
117
  return parseClinePassModels(included)
105
118
  }
106
119
  if (parserKind === 'google') {
107
- const rows = Array.isArray(payload?.models) ? payload.models : []
120
+ const rows = payload?.models
121
+ if (!Array.isArray(rows)) {
122
+ const err = new Error('model catalog response does not contain models array')
123
+ err.adapterCode = 'schema'
124
+ throw err
125
+ }
108
126
  return normalizeModels(provider, rows.map((row) => ({
109
127
  ...row,
110
128
  id: String(row.name ?? '').replace(/^models\//, ''),
@@ -113,8 +131,13 @@ function parseRows(provider, payload, descriptor) {
113
131
  maxTokens: row.outputTokenLimit,
114
132
  })))
115
133
  }
116
- const rows = pathValue(payload, descriptor.modelsPath) ?? payload?.data ?? payload?.models ?? payload?.availableModels
117
- return normalizeModels(provider, Array.isArray(rows) ? mappedRows(provider, rows, descriptor) : [])
134
+ const rows = pathValue(payload, descriptor.modelsPath) ?? (Array.isArray(payload) ? payload : (payload?.data ?? payload?.models ?? payload?.availableModels))
135
+ if (!Array.isArray(rows)) {
136
+ const err = new Error('model catalog response does not contain models array')
137
+ err.adapterCode = 'schema'
138
+ throw err
139
+ }
140
+ return normalizeModels(provider, mappedRows(provider, rows, descriptor))
118
141
  }
119
142
 
120
143
  function privateHttpHost(hostname) {
package/lib/client.js CHANGED
@@ -600,6 +600,7 @@ window.__ModuleLoader__.load({
600
600
  setBusy(true); setWebhookErr(''); setWebhookMsg('')
601
601
  try {
602
602
  await scope.set('diffWebhookUrl', webhookDraft.diffWebhookUrl || '')
603
+ await scope.set('diffWebhookSecretRef', webhookDraft.diffWebhookSecretRef || webhookDraft.diffWebhookSecret || '')
603
604
  await scope.set('diffWebhookSecret', webhookDraft.diffWebhookSecret || '')
604
605
  await scope.set('notifyOnModelAdded', !!webhookDraft.notifyOnModelAdded)
605
606
  await scope.set('notifyOnModelDeprecated', !!webhookDraft.notifyOnModelDeprecated)
@@ -622,6 +623,7 @@ window.__ModuleLoader__.load({
622
623
  body: JSON.stringify({
623
624
  url: webhookDraft.diffWebhookUrl,
624
625
  secret: webhookDraft.diffWebhookSecret || '',
626
+ secretRef: webhookDraft.diffWebhookSecretRef || webhookDraft.diffWebhookSecret || '',
625
627
  }),
626
628
  })
627
629
  const body = await res.json()
@@ -38,9 +38,18 @@ function safeCode(error) {
38
38
  return 'credential-error'
39
39
  }
40
40
 
41
+ export function sanitizeSecretString(value) {
42
+ if (value === null || value === undefined) return ''
43
+ return String(value)
44
+ .replace(/(authorization)\s*[:=]\s*(?:bearer|basic|token)\s+[^\s,;&]+/gi, '$1=[redacted]')
45
+ .replace(/(api[_-]?key|token|secret|authorization|password)\s*[:=]\s*[^\s,;&]+/gi, '$1=[redacted]')
46
+ .replace(/\b(bearer\s+)[A-Za-z0-9._~+/-]+=*/gi, '$1[redacted]')
47
+ .replace(/([?&](?:key|token|api[_-]?key|access[_-]?token|secret)=)[^&#\s]+/gi, '$1[redacted]')
48
+ }
49
+
41
50
  function safeMessage(error) {
42
51
  const source = error instanceof Error ? error.message : String(error ?? '')
43
- return source.replace(/(api[_-]?key|token|secret|authorization|password)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]').slice(0, 160)
52
+ return sanitizeSecretString(source).slice(0, 160)
44
53
  }
45
54
 
46
55
  export function createCredentialResolver(ctx) {
@@ -104,6 +104,43 @@ export function extractDiffEvents(results, options = {}) {
104
104
  }
105
105
  }
106
106
 
107
+ export function isLoopbackIPv4(address) {
108
+ if (typeof address !== 'string' || !address.startsWith('127.')) return false
109
+ const parts = address.split('.')
110
+ if (parts.length !== 4) return false
111
+ return parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) >= 0 && Number(part) <= 255)
112
+ }
113
+
114
+ export function isForbiddenWebhookHost(rawHostname) {
115
+ const host = String(rawHostname || '').toLowerCase().replace(/^\[|\]$/g, '')
116
+ if (host === '169.254.169.254' || host.startsWith('169.254.')) return true
117
+ if (host.startsWith('::ffff:')) {
118
+ const v4 = host.slice('::ffff:'.length)
119
+ if (v4.startsWith('169.254.') || v4.startsWith('a9fe:')) return true
120
+ if (v4 === '100.100.100.200' || v4 === '6464:64c8') return true
121
+ }
122
+ if (host.startsWith('fe80:') || host.startsWith('fe80::')) return true
123
+ if (host === '100.100.100.200') return true
124
+ if (host === '0.0.0.0' || host === '255.255.255.255' || host === '::' || host === '0:0:0:0:0:0:0:0') return true
125
+ if (
126
+ host === 'metadata.google.internal' ||
127
+ host === 'instance-data' ||
128
+ host === 'metadata' ||
129
+ host.endsWith('.metadata.google.internal') ||
130
+ host.endsWith('.metadata')
131
+ ) return true
132
+ return false
133
+ }
134
+
135
+ export function isLoopbackHost(rawHostname) {
136
+ const host = String(rawHostname || '').toLowerCase().replace(/^\[|\]$/g, '')
137
+ if (host === '127.0.0.1' || host === 'localhost' || host === 'localhost.' || host === '::1') return true
138
+ if (host.startsWith('::ffff:')) {
139
+ return isLoopbackIPv4(host.slice('::ffff:'.length))
140
+ }
141
+ return isLoopbackIPv4(host)
142
+ }
143
+
107
144
  export async function sendWebhookNotification(url, secret, payload, { fetchImpl = globalThis.fetch, timeoutMs = 5000 } = {}) {
108
145
  if (typeof url !== "string" || !url.trim()) {
109
146
  return { ok: false, error: "URL is required" }
@@ -115,16 +152,10 @@ export async function sendWebhookNotification(url, secret, payload, { fetchImpl
115
152
  return { ok: false, error: "URL must use http or https protocol" }
116
153
  }
117
154
  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
- ) {
155
+ if (isForbiddenWebhookHost(hostname)) {
125
156
  return { ok: false, error: "webhook url targets a forbidden metadata or link-local address" }
126
157
  }
127
- const isLoopback = hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '::1' || hostname.startsWith('127.') || hostname.startsWith('::ffff:127.')
158
+ const isLoopback = isLoopbackHost(hostname)
128
159
  if (parsedUrl.protocol === "http:" && !isLoopback) {
129
160
  return { ok: false, error: "insecure http webhook url is only permitted on loopback" }
130
161
  }
@@ -195,17 +226,37 @@ export async function dispatchDiffNotifications(ctx, config, results, { fetchImp
195
226
 
196
227
  let webhookResult = null
197
228
  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 : '')
229
+ let secret = ''
230
+ const secretRef = (config.diffWebhookSecretRef || '').trim()
200
231
  if (secretRef) {
232
+ let resolvedSecret = ''
201
233
  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
234
+ const creds = ctx?.credentials || ctx?.get?.('credentials')
235
+ if (creds?.resolve) {
236
+ const res = await creds.resolve(secretRef)
237
+ resolvedSecret = typeof res === 'string' ? res : res?.value || ''
238
+ }
204
239
  } catch (err) {
205
- if (typeof ctx?.logger?.debug === 'function') {
206
- ctx.logger.debug('[dsh-model-sync] webhook secret resolution fallback:', err)
240
+ if (typeof ctx?.logger?.warn === 'function') {
241
+ ctx.logger.warn('[dsh-model-sync] webhook secret resolution failed:', err)
242
+ }
243
+ }
244
+ if (!resolvedSecret) {
245
+ // FAIL-CLOSED: do not dispatch unsigned webhook when secret was expected (#204)
246
+ if (typeof ctx?.logger?.warn === 'function') {
247
+ ctx.logger.warn(`[dsh-model-sync] webhook secret resolution failed for '${secretRef}', skipping webhook dispatch (fail-closed)`)
248
+ }
249
+ return {
250
+ ok: false,
251
+ error: 'credential_resolution_failed',
252
+ message: `Failed to resolve webhook secret credential reference '${secretRef}'`,
253
+ events,
254
+ dispatched: false,
207
255
  }
208
256
  }
257
+ secret = resolvedSecret
258
+ } else if (config.diffWebhookSecret) {
259
+ secret = String(config.diffWebhookSecret).trim()
209
260
  }
210
261
  webhookResult = await sendWebhookNotification(
211
262
  config.diffWebhookUrl,
@@ -75,8 +75,21 @@ async function readBody(response) {
75
75
  }
76
76
 
77
77
  function rowsFrom(payload) {
78
- const rows = payload?.data ?? payload?.models ?? payload?.availableModels
79
- return Array.isArray(rows) ? rows : []
78
+ if (payload && (payload.error || payload.errors)) {
79
+ const message = typeof payload.error === 'string'
80
+ ? payload.error
81
+ : payload.error?.message || (Array.isArray(payload.errors) ? payload.errors.map(e => e?.message || e).join(', ') : 'endpoint returned error payload')
82
+ const err = new Error(`endpoint returned error payload: ${message}`)
83
+ err.adapterCode = 'schema'
84
+ throw err
85
+ }
86
+ const rows = Array.isArray(payload) ? payload : (payload?.data ?? payload?.models ?? payload?.availableModels)
87
+ if (!Array.isArray(rows)) {
88
+ const err = new Error('model catalog response does not contain models array')
89
+ err.adapterCode = 'schema'
90
+ throw err
91
+ }
92
+ return rows
80
93
  }
81
94
 
82
95
  export function canUseGenericAdapter(profile) {
package/lib/history.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { sanitizeSecretString } from './credentials.js'
1
2
  import { normalizeModels } from './models.js'
2
3
 
3
4
  export const DEFAULT_HISTORY_LIMIT = 50
@@ -27,6 +28,11 @@ function cloneModel(provider, model) {
27
28
  if (normalized.pricing) compact.pricing = normalized.pricing
28
29
  if (normalized.contextWindow !== undefined) compact.contextWindow = normalized.contextWindow
29
30
  if (normalized.maxTokens !== undefined) compact.maxTokens = normalized.maxTokens
31
+ if (normalized.input !== undefined) compact.input = normalized.input
32
+ if (normalized.compat !== undefined) compact.compat = normalized.compat
33
+ if (normalized.tags !== undefined) compact.tags = normalized.tags
34
+ if (normalized.aliases !== undefined && normalized.aliases.length > 0) compact.aliases = normalized.aliases
35
+ if (normalized.lifecycle !== undefined) compact.lifecycle = normalized.lifecycle
30
36
  return compact
31
37
  }
32
38
 
@@ -36,7 +42,7 @@ function catalog(provider, models) {
36
42
 
37
43
  function message(value) {
38
44
  if (value === undefined || value === null) return undefined
39
- return String(value).replace(/(api[_-]?key|token|secret|authorization|password)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]').slice(0, MAX_MESSAGE_LENGTH)
45
+ return sanitizeSecretString(value).slice(0, MAX_MESSAGE_LENGTH)
40
46
  }
41
47
 
42
48
  function diffFor(provider, diff) {
package/lib/http.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { isForbiddenWebhookHost, isLoopbackHost } from './diff-notifier.js'
1
2
  import crypto from 'node:crypto'
2
3
  import { validateAliasName } from './alias-resolver.js'
3
4
  import { validatePolicy } from './policy.js'
@@ -15,22 +16,17 @@ function normalizeWebhookTestRequest(body) {
15
16
  throw Object.assign(new Error("URL must use http or https protocol"), { code: "INVALID_REQUEST" })
16
17
  }
17
18
  const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '')
18
- const isLoopback = hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '::1' || hostname.startsWith('127.') || hostname.startsWith('::ffff:127.')
19
+ if (isForbiddenWebhookHost(hostname)) {
20
+ throw Object.assign(new Error("webhook url targets a forbidden metadata or link-local address"), { code: "INVALID_REQUEST" })
21
+ }
22
+ const isLoopback = isLoopbackHost(hostname)
19
23
  if (parsed.protocol === "http:" && !isLoopback) {
20
24
  throw Object.assign(new Error("insecure http webhook url is only permitted on loopback"), { code: "INVALID_REQUEST" })
21
25
  }
22
- if (
23
- hostname === '169.254.169.254' ||
24
- hostname.startsWith('169.254.') ||
25
- hostname === 'metadata.google.internal' ||
26
- hostname === '0.0.0.0' ||
27
- hostname === '255.255.255.255'
28
- ) {
29
- throw Object.assign(new Error("webhook url targets a forbidden metadata or link-local address"), { code: "INVALID_REQUEST" })
30
- }
31
26
  return {
32
27
  url: parsed.toString(),
33
28
  secret: typeof body.secret === "string" ? body.secret.trim() : "",
29
+ ...(body.secretRef && typeof body.secretRef === "string" ? { secretRef: body.secretRef.trim() } : {}),
34
30
  }
35
31
  }
36
32
 
@@ -42,14 +38,17 @@ function json(res, status, body) {
42
38
  }
43
39
 
44
40
  export function isLoopbackAddress(ip) {
45
- if (!ip || typeof ip !== 'string') return false
46
- const clean = ip.toLowerCase().replace(/^\[|\]$/g, '')
47
- if (clean === '127.0.0.1' || clean === '::1' || clean === '::ffff:127.0.0.1' || clean === 'localhost') return true
48
- if (clean.startsWith('127.') || clean.startsWith('::ffff:127.')) return true
49
- return false
41
+ return isLoopbackHost(ip)
50
42
  }
51
43
 
52
- export function trusted(req, { requireLoopback = false } = {}) {
44
+ export function trusted(req, { requireLoopback = false, ctx = null } = {}) {
45
+ // 1. Core connection rejection check (#237)
46
+ const conn = ctx?.connection || ctx?.get?.('connection') || req?.connection
47
+ if (conn && typeof conn.requestRejection === 'function') {
48
+ const rejection = conn.requestRejection(req)
49
+ if (rejection !== undefined) return false
50
+ }
51
+
53
52
  const remoteAddress = req?.socket?.remoteAddress
54
53
  if (requireLoopback || req?.method === 'POST') {
55
54
  if (remoteAddress && !isLoopbackAddress(remoteAddress)) return false
@@ -66,7 +65,7 @@ export function trusted(req, { requireLoopback = false } = {}) {
66
65
  const url = new URL(origin)
67
66
  if (url.host !== host) return false
68
67
  if (requireLoopback || req?.method === 'POST') {
69
- if (!isLoopbackAddress(url.hostname) && url.hostname !== 'localhost') return false
68
+ if (!isLoopbackAddress(url.hostname)) return false
70
69
  }
71
70
  } catch { return false }
72
71
  }
@@ -77,7 +76,7 @@ export function trusted(req, { requireLoopback = false } = {}) {
77
76
  const url = new URL(referer)
78
77
  if (url.host !== host) return false
79
78
  if (requireLoopback || req?.method === 'POST') {
80
- if (!isLoopbackAddress(url.hostname) && url.hostname !== 'localhost') return false
79
+ if (!isLoopbackAddress(url.hostname)) return false
81
80
  }
82
81
  } catch { return false }
83
82
  }
@@ -273,8 +272,18 @@ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () =>
273
272
  return []
274
273
  }
275
274
  const makeHandler = (route) => async (req, res) => {
275
+ // 1. Core connection check on all routes (#237)
276
+ const conn = ctx?.connection || ctx?.get?.('connection') || req?.connection
277
+ if (conn && typeof conn.requestRejection === 'function') {
278
+ const rejection = conn.requestRejection(req)
279
+ if (rejection !== undefined) {
280
+ const statusCode = typeof rejection === 'number' ? rejection : 401
281
+ return json(res, statusCode, { error: { code: 'unauthorized', message: 'dsh-model-sync: core connection rejected request' } })
282
+ }
283
+ }
284
+
276
285
  const isPublicStatus = route === 'status' && req.method === 'GET'
277
- if (!trusted(req, { requireLoopback: !isPublicStatus })) return json(res, 403, { error: { code: 'forbidden', message: 'dsh-model-sync: cross-origin or non-loopback request rejected' } })
286
+ if (!trusted(req, { requireLoopback: !isPublicStatus, ctx })) return json(res, 403, { error: { code: 'forbidden', message: 'dsh-model-sync: cross-origin or non-loopback request rejected' } })
278
287
  if (route === 'status') {
279
288
  if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
280
289
  const payload = { ...synchronizer.status(), scheduler: getSchedulerStatus(), providers: synchronizer.listProviders() }
package/lib/index.js CHANGED
@@ -44,7 +44,7 @@ export const Config = Schema.object({
44
44
  title: Schema.string().required(),
45
45
  message: Schema.string().required(),
46
46
  providers: Schema.array(Schema.object({}).loose()).default([]),
47
- }).loose()).default([]),
47
+ }).loose()).default([]).volatile(),
48
48
  staleGraceRuns: Schema.number().default(2).volatile(),
49
49
  lifecycleRetentionRuns: Schema.number().default(20).volatile(),
50
50
  lifecycleRevision: Schema.number().default(0).volatile(),
@@ -54,14 +54,14 @@ export const Config = Schema.object({
54
54
  firstMissingAt: Schema.number().default(0),
55
55
  lastSeenAt: Schema.number().default(0),
56
56
  lastObservedRun: Schema.number().default(0),
57
- }).loose())).default({}),
57
+ }).loose())).default({}).volatile(),
58
58
  history: Schema.array(Schema.object({
59
59
  id: Schema.string().required(),
60
60
  version: Schema.number().required(),
61
61
  startedAt: Schema.number().required(),
62
62
  finishedAt: Schema.number().required(),
63
63
  providers: Schema.array(Schema.object({}).loose()).default([]),
64
- }).loose()).default([]),
64
+ }).loose()).default([]).volatile(),
65
65
  providers: Schema.array(Schema.object({
66
66
  provider: Schema.string().required(),
67
67
  enabled: Schema.boolean().default(true),
@@ -69,7 +69,7 @@ export const Config = Schema.object({
69
69
  jitterMinutes: Schema.number().default(0),
70
70
  ttlMinutes: Schema.number().default(0),
71
71
  autoApply: Schema.boolean().default(false),
72
- })).default([]),
72
+ })).default([]).volatile(),
73
73
  aliases: Schema.dict(Schema.object({
74
74
  provider: Schema.string(),
75
75
  model: Schema.string(),
@@ -79,7 +79,7 @@ export const Config = Schema.object({
79
79
  })),
80
80
  strategy: Schema.string(),
81
81
  updatedAt: Schema.number().default(0),
82
- }).loose()).default({}),
82
+ }).loose()).default({}).volatile(),
83
83
  modelSelections: Schema.dict(Schema.array(Schema.string())).default({}).volatile(),
84
84
  adapterRegistry: Schema.dict(Schema.object({
85
85
  enabled: Schema.boolean().default(true),
@@ -90,7 +90,7 @@ export const Config = Schema.object({
90
90
  fields: Schema.any().default({}),
91
91
  capabilityMap: Schema.any().default({}),
92
92
  headers: Schema.dict(Schema.string()).default({}),
93
- }).loose()).default({}),
93
+ }).loose()).default({}).volatile(),
94
94
  modelPolicies: Schema.dict(Schema.object({
95
95
  include: Schema.array(Schema.string()).default([]),
96
96
  exclude: Schema.array(Schema.string()).default([]),
@@ -100,7 +100,7 @@ export const Config = Schema.object({
100
100
  maxPricePerMillion: Schema.number(),
101
101
  enableContextFilter: Schema.boolean().default(false),
102
102
  minContextTokens: Schema.number(),
103
- }).loose()).default({}),
103
+ }).loose()).default({}).volatile(),
104
104
  // Full catalogs are cached separately from the allowlist applied to DSH.
105
105
  // This keeps the model picker useful after a restart without widening the
106
106
  // active llm-pi-ai catalog.
@@ -108,7 +108,7 @@ export const Config = Schema.object({
108
108
  provider: Schema.string().required(),
109
109
  id: Schema.string().required(),
110
110
  name: Schema.string().required(),
111
- }).loose())).default({}),
111
+ }).loose())).default({}).volatile(),
112
112
  })
113
113
 
114
114
  // DSH serves a namespace's settings form from the volatile fields of its profile
@@ -123,6 +123,7 @@ export function plainConfig(value) {
123
123
  }
124
124
 
125
125
  export function apply(ctx, config) {
126
+ let liveConfig = undefined
126
127
  const baseConfig = structuredClone(plainConfig(config || {}))
127
128
  const resolveConfig = (value) => {
128
129
  try {
@@ -134,7 +135,7 @@ export function apply(ctx, config) {
134
135
  return liveConfig ?? baseConfig
135
136
  }
136
137
  }
137
- let liveConfig = resolveConfig(baseConfig)
138
+ liveConfig = resolveConfig(baseConfig)
138
139
  let reconfigure = () => {}
139
140
  let saveConfigImpl = async (patch) => {
140
141
  const plainPatch = plainConfig(patch)
@@ -146,14 +147,16 @@ export function apply(ctx, config) {
146
147
  ctx.logger.debug('[dsh-model-sync] settings service read failed:', err)
147
148
  }
148
149
  }
149
- if (settings && typeof settings.update === 'function') {
150
- try {
151
- await settings.update(NS, plainPatch)
152
- } catch (err) {
153
- if (typeof ctx.logger?.warn === 'function') {
154
- ctx.logger.warn('[dsh-model-sync] settings.update failed:', err)
155
- }
150
+ if (!settings || typeof settings.update !== 'function') {
151
+ throw Object.assign(new Error('dsh-model-sync settings are not writable'), { code: 'CONFIG_UNAVAILABLE' })
152
+ }
153
+ try {
154
+ await settings.update(NS, plainPatch)
155
+ } catch (err) {
156
+ if (typeof ctx.logger?.warn === 'function') {
157
+ ctx.logger.warn('[dsh-model-sync] settings.update failed:', err)
156
158
  }
159
+ throw err
157
160
  }
158
161
  liveConfig = resolveConfig({ ...liveConfig, ...plainPatch })
159
162
  reconfigure()
package/lib/models.js CHANGED
@@ -273,6 +273,7 @@ function normalizePricing(row) {
273
273
  }
274
274
  if (typeof source.currency === 'string' && source.currency.trim()) pricing.currency = source.currency.trim()
275
275
  if (typeof source.unit === 'string' && source.unit.trim()) pricing.unit = source.unit.trim()
276
+ if (typeof source.source === 'string' && source.source.trim()) pricing.source = source.source.trim()
276
277
  return pricing
277
278
  }
278
279
 
package/lib/policy.js CHANGED
@@ -41,6 +41,12 @@ function isDangerousPattern(pattern) {
41
41
  if (/\([^\)]*([+*]|\{[0-9]+,[0-9]*\})[^\)]*\)([+*]|\{[0-9]+,[0-9]*\})/.test(pattern)) return true
42
42
  // Check for multiple consecutive open-ended wildcards (e.g. .*.*)
43
43
  if (/(\.\*|\.\+){2,}/.test(pattern)) return true
44
+ // Check for quantified alternation groups like (a|aa)+, (a|b)+, (a|b)*, (a|aa){2,}
45
+ let simplified = pattern
46
+ while (/\(\(([^()]+)\)\)/.test(simplified)) {
47
+ simplified = simplified.replace(/\(\(([^()]+)\)\)/g, '($1)')
48
+ }
49
+ if (/\((?:[^()]*\|)+[^()]*\)\s*([+*]|\{[0-9]+,[0-9]*\})/.test(simplified)) return true
44
50
  return false
45
51
  }
46
52
 
package/lib/reporting.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { sanitizeSecretString } from './credentials.js'
1
2
  import { createHash } from 'node:crypto'
2
3
 
3
4
  export const DEFAULT_NOTIFICATION_LIMIT = 50
@@ -14,10 +15,7 @@ export function notificationLimit(value) {
14
15
  }
15
16
 
16
17
  function safeMessage(value) {
17
- return String(value ?? '')
18
- .replace(/(api[_-]?key|token|secret|authorization|password)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]')
19
- .replace(/([?&](?:key|token|api[_-]?key|access[_-]?token)=)[^&#\s]+/gi, '$1[redacted]')
20
- .slice(0, 240)
18
+ return sanitizeSecretString(value).slice(0, 240)
21
19
  }
22
20
 
23
21
  function stable(value) {
@@ -1,3 +1,4 @@
1
+ import { sanitizeSecretString } from './credentials.js'
1
2
  import { statusOfError } from './reliability.js'
2
3
  import { historyEntry } from './history.js'
3
4
  import { normalizeModels } from './models.js'
@@ -89,7 +90,7 @@ export function errorMessage(error) {
89
90
  }
90
91
 
91
92
  export function diagnosticMessage(error) {
92
- return errorMessage(error).replace(/(api[_-]?key|token|secret|authorization|password)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]').slice(0, 160)
93
+ return sanitizeSecretString(errorMessage(error)).slice(0, 160)
93
94
  }
94
95
 
95
96
  export function adapterErrorType(error) {
@@ -159,12 +159,18 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
159
159
  recordCredentialOutcome(row.provider, { status: 'ok', retries })
160
160
  if (result && result.notModified) {
161
161
  const config = getConfig() ?? {}
162
- const cached = normalizeModels(row.provider, config.modelCatalogs?.[row.provider] ?? row.models ?? [])
162
+ const existing = providerHttpMeta.get(row.provider)
163
+ if (existing && (result.etag || result.lastModified)) {
164
+ if (result.etag) existing.etag = result.etag
165
+ if (result.lastModified) existing.lastModified = result.lastModified
166
+ }
167
+ const memoryModels = existing?.models
168
+ const cached = normalizeModels(row.provider, memoryModels ?? config.modelCatalogs?.[row.provider] ?? row.models ?? [])
163
169
  return { provider: row.provider, status: 'ok', models: cached, retries, notModified: true }
164
170
  }
165
171
  const models = Array.isArray(result) ? result : []
166
172
  if (result?.__httpMeta) {
167
- providerHttpMeta.set(row.provider, result.__httpMeta)
173
+ providerHttpMeta.set(row.provider, { ...result.__httpMeta, models })
168
174
  }
169
175
  return { provider: row.provider, status: 'ok', models, retries }
170
176
  } catch (error) {
@@ -332,7 +338,10 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
332
338
  const advertised = selectedModels.length
333
339
  ? policyModels.filter((model) => selectedModels.includes(model.id))
334
340
  : policyModels
335
- const explicitRemoveIds = new Set(lifecycle.removedIds)
341
+ const policyExcludedIds = current
342
+ .filter((model) => !filterModels([model], policy).length || (selectedModels.length && !selectedModels.includes(model.id)))
343
+ .map((model) => model.id)
344
+ const explicitRemoveIds = new Set([...lifecycle.removedIds, ...policyExcludedIds])
336
345
  const reconciliation = reconcileModels(row.provider, current, advertised, { removeMissing: false, removeIds: explicitRemoveIds })
337
346
  results.push({
338
347
  ...discovered,
@@ -562,14 +571,18 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
562
571
  notifications: notificationLedger,
563
572
  updateNotification: updateNotificationState,
564
573
  rollbackHistory,
565
- testWebhook: async ({ url, secret }) => {
574
+ testWebhook: async ({ url, secret, secretRef }) => {
566
575
  let resolvedSecret = secret || ''
567
- if (typeof secret === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(secret)) {
576
+ const targetRef = (secretRef || (typeof secret === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(secret) ? secret : '')).trim()
577
+ if (targetRef) {
568
578
  try {
569
- const res = await (ctx?.credentials?.resolve ? ctx.credentials.resolve(secret) : ctx?.get?.('credentials')?.resolve?.(secret))
570
- if (res) resolvedSecret = typeof res === 'string' ? res : res?.value || resolvedSecret
579
+ const res = await (ctx?.credentials?.resolve ? ctx.credentials.resolve(targetRef) : ctx?.get?.('credentials')?.resolve?.(targetRef))
580
+ if (res) resolvedSecret = typeof res === 'string' ? res : res?.value || ''
571
581
  } catch (err) {
572
- ctx?.logger?.debug?.('[dsh-model-sync] secret ref resolution fallback:', err)
582
+ ctx?.logger?.warn?.('[dsh-model-sync] secret ref resolution failed in testWebhook:', err)
583
+ }
584
+ if (!resolvedSecret && secretRef) {
585
+ return { ok: false, error: `Failed to resolve webhook secret credential reference '${secretRef}'` }
573
586
  }
574
587
  }
575
588
  return sendWebhookNotification(url, resolvedSecret, { event: 'model-sync.test', timestamp: Date.now(), test: true })
package/lib/updater.js CHANGED
@@ -20,19 +20,33 @@ function header(request, name) {
20
20
  return Array.isArray(value) ? value[0] : value
21
21
  }
22
22
 
23
+ function isLoopbackIPv4(address) {
24
+ if (typeof address !== 'string' || !address.startsWith('127.')) return false
25
+ const parts = address.split('.')
26
+ if (parts.length !== 4) return false
27
+ return parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) >= 0 && Number(part) <= 255)
28
+ }
29
+
23
30
  export function isLoopback(value) {
24
- const address = value?.toLowerCase().replace(/^\[|\]$/g, '')
25
- return (
26
- address === 'localhost' ||
27
- address === 'localhost.' ||
28
- address === '::1' ||
29
- address?.startsWith('127.') === true ||
30
- address?.startsWith('::ffff:127.') === true
31
- )
31
+ if (typeof value !== 'string') return false
32
+ const address = value.toLowerCase().replace(/^\[|\]$/g, '')
33
+ if (address === 'localhost' || address === 'localhost.' || address === '::1') return true
34
+ if (address.startsWith('::ffff:')) {
35
+ return isLoopbackIPv4(address.slice('::ffff:'.length))
36
+ }
37
+ return isLoopbackIPv4(address)
32
38
  }
33
39
 
34
- export function isTrustedUpdateRequest(request) {
40
+ export function isTrustedUpdateRequest(request, targetCtx = null) {
35
41
  if (header(request, UPDATE_HEADER) !== '1') return false
42
+
43
+ // 1. Core connection rejection check (#237)
44
+ const conn = targetCtx?.connection || targetCtx?.get?.('connection') || request?.connection
45
+ if (conn && typeof conn.requestRejection === 'function') {
46
+ const rejection = conn.requestRejection(request)
47
+ if (rejection !== undefined) return false
48
+ }
49
+
36
50
  const remote = request.socket?.remoteAddress
37
51
  if (!isLoopback(remote)) return false
38
52
  const site = header(request, 'sec-fetch-site')
@@ -295,6 +309,21 @@ export function registerPluginUpdater(ctx, options = {}) {
295
309
 
296
310
  const handler = async (request, response) => {
297
311
  try {
312
+ // 1. Core connection rejection check on all updater routes (#237)
313
+ const conn = ctx?.connection || ctx?.get?.('connection') || request?.connection
314
+ if (conn && typeof conn.requestRejection === 'function') {
315
+ const rejection = conn.requestRejection(request)
316
+ if (rejection !== undefined) {
317
+ const status = typeof rejection === 'number' ? rejection : 401
318
+ response.writeHead(status, {
319
+ 'content-type': 'application/json; charset=utf-8',
320
+ 'cache-control': 'no-store',
321
+ })
322
+ response.end(JSON.stringify({ error: 'unauthorized', code: status }))
323
+ return
324
+ }
325
+ }
326
+
298
327
  const target = runtime()
299
328
  if (request.method === 'GET' || request.method === 'HEAD') {
300
329
  const isFresh = Boolean(
@@ -317,7 +346,7 @@ export function registerPluginUpdater(ctx, options = {}) {
317
346
  response.end()
318
347
  return
319
348
  }
320
- if (!isTrustedUpdateRequest(request)) {
349
+ if (!isTrustedUpdateRequest(request, ctx)) {
321
350
  json(response, 403, { error: 'Rejected non-local or cross-origin update request.' })
322
351
  return
323
352
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-model-sync",
3
- "version": "0.5.8",
3
+ "version": "0.5.10",
4
4
  "description": "Automatic model catalog synchronization for API-key DeepSeek Harness providers.",
5
5
  "keywords": [
6
6
  "deepseek-harness",