{"record":{"id":"3644b13f9f9cb77a","repo":"koala73/worldmonitor","slug":"unauthorized","errorCode":null,"errorMessage":"unauthorized","messagePattern":"unauthorized","errorType":"http","errorClass":null,"httpStatus":401,"severity":"error","filePath":"consumer-prices-core/src/api/server.ts","lineNumber":51,"sourceCode":"  const candidates = Array.isArray(provided) ? provided : [provided];\n  return candidates.some((candidate) => matchesApiKey(candidate, apiKey));\n}\n\nexport function createServer(options: ConsumerPricesServerOptions = {}) {\n  const apiKey = Buffer.from(requiredApiKey(options.apiKey));\n  const server = Fastify({ logger: options.logger ?? { level: process.env.LOG_LEVEL ?? 'info' } });\n\n  server.register(cors, {\n    origin: options.corsOrigin ?? process.env.CORS_ORIGIN ?? '*',\n    methods: ['GET'],\n  });\n\n  server.addHook('onRequest', async (request, reply) => {\n    if (isHealthCheckPath(request.url)) return;\n\n    const provided = request.headers['x-api-key'];\n    if (!isAuthorizedApiKey(provided, apiKey)) {\n      return reply.status(401).send({ error: 'unauthorized' });\n    }\n  });\n\n  server.register(worldmonitorRoutes, { prefix: '/wm/consumer-prices/v1' });\n  server.register(healthRoutes, { prefix: '/health' });\n\n  return server;\n}\n\nfunction isInvokedAsScript(entryPath: string | undefined, moduleUrl: string): boolean {\n  if (!entryPath) return false;\n  try {\n    const entry = pathToFileURL(realpathSync(entryPath)).href;\n    const self = pathToFileURL(realpathSync(fileURLToPath(moduleUrl))).href;\n    return entry === self;\n  } catch {\n    return moduleUrl === pathToFileURL(entryPath).href;\n  }","sourceCodeStart":33,"sourceCodeEnd":69,"githubUrl":"https://github.com/koala73/worldmonitor/blob/eeab0a219fce0f02a00603b532dbae9041b934ac/consumer-prices-core/src/api/server.ts#L33-L69","documentation":"The consumer-prices-core Fastify server registers a global onRequest hook (consumer-prices-core/src/api/server.ts:46) that compares the request's 'x-api-key' header against the server key using a length + timingSafeEqual check (matchesApiKey, server.ts:26). The server key comes from options.apiKey or the WORLDMONITOR_SNAPSHOT_API_KEY env var, trimmed at startup. Any request other than GET /health whose header is missing or not byte-identical to that key gets HTTP 401 with body { error: 'unauthorized' }.","triggerScenarios":"Calling GET /wm/consumer-prices/v1/* without an x-api-key header; sending a key that differs from WORLDMONITOR_SNAPSHOT_API_KEY (stale or rotated value on the caller side); sending a key with a trailing newline or space (the header candidate is compared byte-exact, only the server-side env value is trimmed); using 'Authorization: Bearer ...' instead of 'x-api-key'; server constructed with createServer({ apiKey }) so the env value the client knows is not the one in effect.","commonSituations":"Local dev where the calling service does not load the .env holding WORLDMONITOR_SNAPSHOT_API_KEY (dotenv/config only runs inside the server process); key rotated in one deployment but not the other; curl or CI smoke tests forgetting the header; secrets-manager values copied with a trailing newline; testing against a preview deployment provisioned with a different key.","solutions":["Send the header on every data request: 'x-api-key: <WORLDMONITOR_SNAPSHOT_API_KEY>'. Only /health is exempt (isHealthCheckPath).","Verify both processes resolve the same value: the server reads options.apiKey ?? process.env.WORLDMONITOR_SNAPSHOT_API_KEY; confirm the caller's env var name and value match (compare a hash of the key, never log the secret).","Trim the key when reading it client-side before setting the header — the comparison is length-exact plus timingSafeEqual, so trailing whitespace fails.","Confirm reachability with GET /health first (no auth), then retry the /wm/consumer-prices/v1 route with the header.","In tests, pass the key explicitly via createServer({ apiKey: 'test-key' }) and send that exact value."],"exampleFix":"// before\nconst res = await fetch('http://localhost:3400/wm/consumer-prices/v1/snapshot');\n// 401 { error: 'unauthorized' }\n\n// after\nconst apiKey = process.env.WORLDMONITOR_SNAPSHOT_API_KEY!.trim();\nconst res = await fetch('http://localhost:3400/wm/consumer-prices/v1/snapshot', {\n  headers: { 'x-api-key': apiKey },\n});\nif (res.status === 401) throw new Error('consumer-prices key rejected: check WORLDMONITOR_SNAPSHOT_API_KEY on client and server');","handlingStrategy":"validation","validationCode":"// Fail fast before the request when the key is not configured on the caller side.\nconst apiKey = process.env.WORLDMONITOR_SNAPSHOT_API_KEY?.trim();\nif (!apiKey) {\n  throw new Error('WORLDMONITOR_SNAPSHOT_API_KEY is not set; the request would return 401');\n}\nconst res = await fetch(url, { headers: { 'x-api-key': apiKey } });","typeGuard":null,"tryCatchPattern":"// fetch does NOT throw on 401 — branch on the status; treat it as a config error, not a transient one.\nconst res = await fetch(url, { headers: { 'x-api-key': apiKey } });\nif (res.status === 401) {\n  throw new Error('consumer-prices rejected the API key: check WORLDMONITOR_SNAPSHOT_API_KEY on client and server');\n}\nif (!res.ok) throw new Error(`consumer-prices error ${res.status}`);\nreturn res.json();","preventionTips":["Store the key once in a shared secret source so server and callers rotate together.","Trim the secret when reading it from env or a secrets manager before setting the header.","Never retry-loop a 401 — it indicates mismatched credentials, not contention.","Smoke-test deployments with GET /health (exempt) before debugging authorized routes."],"tags":["fastify","api-key","authentication","http-401","x-api-key"],"backgroundTag":"api-key-authentication-failed","analyzedSha":"eeab0a219fce0f02a00603b532dbae9041b934ac","analyzedAt":"2026-08-21T16:51:25.751Z","contentChangedAt":"2026-08-21T16:51:25.751Z","schemaVersion":2},"datasetVersion":"2026-09-08T20:17:18.057Z"}