koala73/worldmonitor · error

unauthorized

Error message

unauthorized

What it means

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' }.

Source

Thrown at consumer-prices-core/src/api/server.ts:51

  const candidates = Array.isArray(provided) ? provided : [provided];
  return candidates.some((candidate) => matchesApiKey(candidate, apiKey));
}

export function createServer(options: ConsumerPricesServerOptions = {}) {
  const apiKey = Buffer.from(requiredApiKey(options.apiKey));
  const server = Fastify({ logger: options.logger ?? { level: process.env.LOG_LEVEL ?? 'info' } });

  server.register(cors, {
    origin: options.corsOrigin ?? process.env.CORS_ORIGIN ?? '*',
    methods: ['GET'],
  });

  server.addHook('onRequest', async (request, reply) => {
    if (isHealthCheckPath(request.url)) return;

    const provided = request.headers['x-api-key'];
    if (!isAuthorizedApiKey(provided, apiKey)) {
      return reply.status(401).send({ error: 'unauthorized' });
    }
  });

  server.register(worldmonitorRoutes, { prefix: '/wm/consumer-prices/v1' });
  server.register(healthRoutes, { prefix: '/health' });

  return server;
}

function isInvokedAsScript(entryPath: string | undefined, moduleUrl: string): boolean {
  if (!entryPath) return false;
  try {
    const entry = pathToFileURL(realpathSync(entryPath)).href;
    const self = pathToFileURL(realpathSync(fileURLToPath(moduleUrl))).href;
    return entry === self;
  } catch {
    return moduleUrl === pathToFileURL(entryPath).href;
  }

View on GitHub (pinned to eeab0a219f)

Solutions

  1. Send the header on every data request: 'x-api-key: <WORLDMONITOR_SNAPSHOT_API_KEY>'. Only /health is exempt (isHealthCheckPath).
  2. 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).
  3. Trim the key when reading it client-side before setting the header — the comparison is length-exact plus timingSafeEqual, so trailing whitespace fails.
  4. Confirm reachability with GET /health first (no auth), then retry the /wm/consumer-prices/v1 route with the header.
  5. In tests, pass the key explicitly via createServer({ apiKey: 'test-key' }) and send that exact value.

Example fix

// before
const res = await fetch('http://localhost:3400/wm/consumer-prices/v1/snapshot');
// 401 { error: 'unauthorized' }

// after
const apiKey = process.env.WORLDMONITOR_SNAPSHOT_API_KEY!.trim();
const res = await fetch('http://localhost:3400/wm/consumer-prices/v1/snapshot', {
  headers: { 'x-api-key': apiKey },
});
if (res.status === 401) throw new Error('consumer-prices key rejected: check WORLDMONITOR_SNAPSHOT_API_KEY on client and server');
Defensive patterns

Strategy: validation

Validate before calling

// Fail fast before the request when the key is not configured on the caller side.
const apiKey = process.env.WORLDMONITOR_SNAPSHOT_API_KEY?.trim();
if (!apiKey) {
  throw new Error('WORLDMONITOR_SNAPSHOT_API_KEY is not set; the request would return 401');
}
const res = await fetch(url, { headers: { 'x-api-key': apiKey } });

Try / catch

// fetch does NOT throw on 401 — branch on the status; treat it as a config error, not a transient one.
const res = await fetch(url, { headers: { 'x-api-key': apiKey } });
if (res.status === 401) {
  throw new Error('consumer-prices rejected the API key: check WORLDMONITOR_SNAPSHOT_API_KEY on client and server');
}
if (!res.ok) throw new Error(`consumer-prices error ${res.status}`);
return res.json();

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Understand the failure class

Related errors


AI-assisted analysis of koala73/worldmonitor@eeab0a219f (2026-08-21). Data as JSON: /api/errors/3644b13f9f9cb77a. Report an issue: GitHub.