Skip to content

Commit fe87111

Browse files
philcunliffeclaude
andcommitted
Config loader: JSON config with hand-rolled schema validation (co-zdn.1)
Adds loadConfig(path) and ConfigError to src/config.js for the LLM proxy MVP config file. Hand-rolled, zero-dependency validation with JSON-pointer- style error paths and line/column on parse errors. Schema covers otel, proxy (with upstreams map and optional redact_headers), and sink (file-only in v0). When proxy is configured, sink is required. Coexists with the existing resolveOptions(argv, env) used by bare-mode CLI; both are exported from the same module since they're complementary options-resolving concerns. JSDoc typedefs give consumers IDE help. 17 new unit tests in test/config.test.js cover: missing file, invalid JSON, schema errors (unknown keys, missing required fields, wrong types, type-other-than-file sinks, empty upstreams, proxy-without-sink), and valid configs (otel-only, proxy-only, both, empty). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4986e27 commit fe87111

2 files changed

Lines changed: 434 additions & 2 deletions

File tree

src/config.js

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import fs from 'node:fs'
2+
13
/**
24
* Resolve collector options from CLI args and environment.
35
*
@@ -39,3 +41,229 @@ export function resolveOptions(argv, env) {
3941

4042
return options
4143
}
44+
45+
/**
46+
* @typedef {object} OtelConfig
47+
* @property {string} listen - host:port for the OTLP receiver (e.g. '0.0.0.0:4318').
48+
*/
49+
50+
/**
51+
* @typedef {object} UpstreamMatch
52+
* @property {string} path_prefix - Request path prefix that selects this upstream.
53+
*/
54+
55+
/**
56+
* @typedef {object} UpstreamConfig
57+
* @property {string} base_url - Origin to forward matched requests to.
58+
* @property {UpstreamMatch} match - Match rule for routing requests to this upstream.
59+
*/
60+
61+
/**
62+
* @typedef {object} ProxyConfig
63+
* @property {string} listen - host:port the proxy listens on.
64+
* @property {Object<string, UpstreamConfig>} upstreams - Named upstream targets.
65+
* @property {string[]} [redact_headers] - Header names to redact in recorded traffic.
66+
*/
67+
68+
/**
69+
* @typedef {object} FileSinkConfig
70+
* @property {'file'} type - Sink kind. Only 'file' is supported in v0.
71+
* @property {string} dir - Directory where recordings are written.
72+
*/
73+
74+
/**
75+
* @typedef {object} CollectivusConfig
76+
* @property {OtelConfig} [otel] - OTLP receiver. Omit to disable.
77+
* @property {ProxyConfig} [proxy] - Proxy listener. Omit to disable.
78+
* @property {FileSinkConfig} [sink] - Sink for proxy recordings. Required when `proxy` is set.
79+
*/
80+
81+
export class ConfigError extends Error {
82+
/**
83+
* @param {string} message
84+
* @param {{ pointer?: string }} [opts]
85+
*/
86+
constructor(message, opts = {}) {
87+
const { pointer } = opts
88+
super(pointer ? `${pointer}: ${message}` : message)
89+
this.name = 'ConfigError'
90+
/** @type {string | undefined} */
91+
this.pointer = pointer
92+
}
93+
}
94+
95+
const ALLOWED_TOP_KEYS = new Set(['otel', 'proxy', 'sink'])
96+
const ALLOWED_PROXY_KEYS = new Set(['listen', 'upstreams', 'redact_headers'])
97+
const ALLOWED_UPSTREAM_KEYS = new Set(['base_url', 'match'])
98+
const ALLOWED_SINK_KEYS = new Set(['type', 'dir'])
99+
100+
/**
101+
* Load and validate a collectivus JSON config file.
102+
*
103+
* @param {string} configPath - Absolute or relative path to a JSON config file.
104+
* @returns {CollectivusConfig} The parsed and validated config.
105+
* @throws {ConfigError} when the file is missing, JSON is invalid, or the schema check fails.
106+
*/
107+
export function loadConfig(configPath) {
108+
let raw
109+
try {
110+
raw = fs.readFileSync(configPath, 'utf8')
111+
} catch (err) {
112+
const code = err && typeof err === 'object' && 'code' in err ? err.code : null
113+
if (code === 'ENOENT') {
114+
throw new ConfigError(`config file not found: ${configPath}`)
115+
}
116+
const msg = err instanceof Error ? err.message : String(err)
117+
throw new ConfigError(`failed to read ${configPath}: ${msg}`)
118+
}
119+
120+
let parsed
121+
try {
122+
parsed = JSON.parse(raw)
123+
} catch (err) {
124+
const msg = err instanceof Error ? err.message : String(err)
125+
const location = jsonErrorLocation(raw, msg)
126+
throw new ConfigError(`invalid JSON in ${configPath}${location}: ${msg}`)
127+
}
128+
129+
validateConfig(parsed)
130+
return parsed
131+
}
132+
133+
/**
134+
* Extract `at line N, column M` from a `JSON.parse` error message.
135+
* V8 reports `position N`; older runtimes may report `line N column M`. If we
136+
* can't find an offset, return an empty string and leave the location off.
137+
*
138+
* @param {string} raw - The original JSON source.
139+
* @param {string} msg - The error message from JSON.parse.
140+
* @returns {string} A leading-space location string, or '' when not derivable.
141+
*/
142+
function jsonErrorLocation(raw, msg) {
143+
const posMatch = /position (\d+)/.exec(msg)
144+
if (posMatch) {
145+
const offset = Number.parseInt(posMatch[1], 10)
146+
const before = raw.slice(0, Math.min(offset, raw.length))
147+
const line = before.split('\n').length
148+
const lastNewline = before.lastIndexOf('\n')
149+
const column = lastNewline === -1 ? before.length + 1 : before.length - lastNewline
150+
return ` at line ${line}, column ${column}`
151+
}
152+
const lineMatch = /line (\d+) column (\d+)/.exec(msg)
153+
if (lineMatch) return ` at line ${lineMatch[1]}, column ${lineMatch[2]}`
154+
return ''
155+
}
156+
157+
/**
158+
* @param {unknown} cfg
159+
* @returns {asserts cfg is CollectivusConfig}
160+
*/
161+
function validateConfig(cfg) {
162+
assertObject(cfg, '')
163+
assertOnlyKeys(cfg, ALLOWED_TOP_KEYS, '')
164+
165+
if (cfg.otel !== undefined) validateOtel(cfg.otel)
166+
if (cfg.proxy !== undefined) validateProxy(cfg.proxy)
167+
if (cfg.proxy !== undefined && cfg.sink === undefined) {
168+
throw new ConfigError('sink is required when proxy is configured', { pointer: '/sink' })
169+
}
170+
if (cfg.sink !== undefined) validateSink(cfg.sink)
171+
}
172+
173+
/** @param {unknown} otel */
174+
function validateOtel(otel) {
175+
assertObject(otel, '/otel')
176+
assertOnlyKeys(otel, new Set(['listen']), '/otel')
177+
assertNonEmptyString(otel.listen, '/otel/listen')
178+
}
179+
180+
/** @param {unknown} proxy */
181+
function validateProxy(proxy) {
182+
assertObject(proxy, '/proxy')
183+
assertOnlyKeys(proxy, ALLOWED_PROXY_KEYS, '/proxy')
184+
assertNonEmptyString(proxy.listen, '/proxy/listen')
185+
186+
if (proxy.upstreams === undefined) {
187+
throw new ConfigError('upstreams is required', { pointer: '/proxy/upstreams' })
188+
}
189+
assertObject(proxy.upstreams, '/proxy/upstreams')
190+
const names = Object.keys(proxy.upstreams)
191+
if (names.length === 0) {
192+
throw new ConfigError('at least one upstream is required', { pointer: '/proxy/upstreams' })
193+
}
194+
for (const name of names) {
195+
validateUpstream(proxy.upstreams[name], `/proxy/upstreams/${name}`)
196+
}
197+
198+
if (proxy.redact_headers !== undefined) {
199+
if (!Array.isArray(proxy.redact_headers)) {
200+
throw new ConfigError('must be an array of strings', { pointer: '/proxy/redact_headers' })
201+
}
202+
proxy.redact_headers.forEach(function(h, i) {
203+
if (typeof h !== 'string' || h.length === 0) {
204+
throw new ConfigError('must be a non-empty string', { pointer: `/proxy/redact_headers/${i}` })
205+
}
206+
})
207+
}
208+
}
209+
210+
/**
211+
* @param {unknown} upstream
212+
* @param {string} pointer
213+
*/
214+
function validateUpstream(upstream, pointer) {
215+
assertObject(upstream, pointer)
216+
assertOnlyKeys(upstream, ALLOWED_UPSTREAM_KEYS, pointer)
217+
assertNonEmptyString(upstream.base_url, `${pointer}/base_url`)
218+
if (upstream.match === undefined) {
219+
throw new ConfigError('match is required', { pointer: `${pointer}/match` })
220+
}
221+
assertObject(upstream.match, `${pointer}/match`)
222+
assertOnlyKeys(upstream.match, new Set(['path_prefix']), `${pointer}/match`)
223+
assertNonEmptyString(upstream.match.path_prefix, `${pointer}/match/path_prefix`)
224+
}
225+
226+
/** @param {unknown} sink */
227+
function validateSink(sink) {
228+
assertObject(sink, '/sink')
229+
assertOnlyKeys(sink, ALLOWED_SINK_KEYS, '/sink')
230+
if (sink.type !== 'file') {
231+
throw new ConfigError('only sink type "file" is supported in v0', { pointer: '/sink/type' })
232+
}
233+
assertNonEmptyString(sink.dir, '/sink/dir')
234+
}
235+
236+
/**
237+
* @param {unknown} value
238+
* @param {string} pointer
239+
* @returns {asserts value is Record<string, unknown>}
240+
*/
241+
function assertObject(value, pointer) {
242+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
243+
throw new ConfigError('must be an object', { pointer: pointer || '/' })
244+
}
245+
}
246+
247+
/**
248+
* @param {Record<string, unknown>} obj
249+
* @param {Set<string>} allowed
250+
* @param {string} pointer - Pointer to the object being checked ('' for root).
251+
*/
252+
function assertOnlyKeys(obj, allowed, pointer) {
253+
for (const key of Object.keys(obj)) {
254+
if (!allowed.has(key)) {
255+
throw new ConfigError(`unknown key "${key}"`, { pointer: `${pointer}/${key}` })
256+
}
257+
}
258+
}
259+
260+
/**
261+
* @param {unknown} value
262+
* @param {string} pointer
263+
* @returns {asserts value is string}
264+
*/
265+
function assertNonEmptyString(value, pointer) {
266+
if (typeof value !== 'string' || value.length === 0) {
267+
throw new ConfigError('must be a non-empty string', { pointer })
268+
}
269+
}

0 commit comments

Comments
 (0)