diff --git a/.env.example b/.env.example index 7c698c3..2530cdf 100644 --- a/.env.example +++ b/.env.example @@ -359,6 +359,13 @@ ORCAROUTER_ENDPOINT=https://api.orcarouter.ai/v1/chat/completions # DESCRIPTION: HTTP port the proxy listens on. PORT=8080 +# DESCRIPTION: Interface to bind. 0.0.0.0 = all interfaces (default, unchanged). Use 127.0.0.1 to keep +# Lynkr reachable only from this machine (recommended for editor sidecars / laptops). Needs a restart. +# LYNKR_BIND_HOST=0.0.0.0 +# DESCRIPTION: Optional API key clients must present on /v1/*, /routing/*, /metrics* and /dashboard/api/* +# as `x-api-key`, `api-key` or `Authorization: Bearer`. Unset = open (previous behaviour). Health +# endpoints are always open. Hot-reloads with the rest of .env. +# LYNKR_API_KEY= # DESCRIPTION: Pino log level. # Values: trace | debug | info | warn | error | fatal | silent LOG_LEVEL=info diff --git a/documentation/production.md b/documentation/production.md index 15cdfc9..5037b94 100644 --- a/documentation/production.md +++ b/documentation/production.md @@ -15,6 +15,33 @@ Lynkr includes 16 production-ready features: --- +## Security: binding and API key + +By default Lynkr listens on **all interfaces** and accepts **unauthenticated** requests — fine behind a +firewall or inside Docker, not fine on a laptop or when an editor embeds Lynkr as a sidecar. Two settings +close that gap: + +```bash +# Bind to loopback only (restart required) +LYNKR_BIND_HOST=127.0.0.1 + +# Require a shared secret on the API (hot-reloads with .env) +LYNKR_API_KEY=$(openssl rand -hex 32) +``` + +With `LYNKR_API_KEY` set, every request under `/v1/*`, `/routing/*`, `/metrics*` and `/dashboard/api/*` +must carry the key as `x-api-key: `, `api-key: ` or `Authorization: Bearer `; otherwise +Lynkr answers `401` (`authentication_error` in the Anthropic error shape, `invalid_api_key` on the OpenAI +endpoints). `/health`, `/health/live` and `/health/ready` stay open for probes. Comparison is +constant-time. A client that also carries an *upstream* OAuth bearer for subscription passthrough can keep +it and put the Lynkr key in `x-api-key`. The dashboard HTML still loads, but its data calls need the key +(use a browser extension that adds the header, or leave the key unset on trusted networks). + +Clients: `lynkr wrap` / `claude` → `ANTHROPIC_API_KEY=` or `ANTHROPIC_AUTH_TOKEN`; OpenCode/Codex/Cursor → +the provider's `apiKey`; curl → `-H "x-api-key: $LYNKR_API_KEY"`. + +--- + ## Reliability Features ### 1. Circuit Breaker Pattern diff --git a/package.json b/package.json index 2353976..67dd2a6 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "start:supervised": "while true; do node index.js 2>&1 | npx pino-pretty --sync; code=$?; echo \"[supervisor] lynkr exited ($code) — restarting in 3s\"; sleep 3; done", "lint": "eslint src index.js", "test": "npm run test:unit && npm run test:performance", - "test:unit": "LYNKR_KNN_DIR=/tmp/lynkr-test-knn DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com LOG_FILE_ENABLED=false node --test test/routing.test.js test/hybrid-routing-integration.test.js test/retry-logic.test.js test/sse-transformer.test.js test/passthrough-stream.test.js test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js test/azure-openai-config.test.js test/azure-openai-format-conversion.test.js test/azure-openai-routing.test.js test/azure-openai-streaming.test.js test/azure-openai-error-resilience.test.js test/azure-openai-integration.test.js test/openai-integration.test.js test/atlas-integration.test.js test/cheaperinference-integration.test.js test/atmorouter-integration.test.js test/runcrate-integration.test.js test/toon-compression.test.js test/gcf-compression.test.js test/llamacpp-integration.test.js test/resilience.test.js test/telemetry-routing.test.js test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/memory/distiller.test.js test/memory/distiller-freeze.test.js test/memory/wiki.test.js test/memory/skills-cache.test.js test/memory/tencentdb-launcher.test.js test/distill.test.js test/large-payload.test.js test/prompt-cache-injection.test.js test/risk-analyzer.test.js test/interaction-block.test.js test/preflight.test.js test/token-reduction.test.js test/session-affinity.test.js test/cache-state.test.js test/cache-switch-cost.test.js test/lens-recommendations.test.js test/model-registry-cost.test.js test/output-format-guard.test.js test/tier-fallback.test.js test/wrap.test.js test/init.test.js test/tool-call-response-metadata.test.js test/degradation.test.js test/routing-telemetry-columns.test.js test/sticky-routing.test.js test/knn-ambiguous-escalate.test.js test/deescalator.test.js test/client-profiles.test.js test/openai-ingress-tool-injection.test.js test/tool-result-compression-gate.test.js test/strip-internal-fields.test.js test/complexity-tool-subtraction.test.js test/bandit.test.js test/routing-propensity.test.js test/reward-pipeline.test.js test/knn-cold-start.test.js test/calibration.test.js test/feedback-loop.test.js test/session-fingerprint.test.js test/side-request-guards.test.js test/verifier.test.js test/intent-score.test.js test/difficulty-classifier.test.js test/classifier-setup.test.js test/usage-stats.test.js test/loop-guard.test.js test/moonshot-model-mapping.test.js test/baidu-model-mapping.test.js test/tenant-policy-ingress-parity.test.js test/decide.test.js test/embeddings-degradation.test.js test/health-probe.test.js test/stuck-detector.test.js test/onnx-embedder.test.js test/ope.test.js test/hierarchical-budget.test.js test/token-rate-limit.test.js test/otel-export.test.js test/mcp-broker.test.js test/compression-budget.test.js test/gpt-utils.test.js test/dedup-observe-only.test.js test/context-window-header.test.js test/token-budget-auto.test.js test/opencode-setup.test.js test/auth-mode-first-party.test.js test/harness-envelope.test.js test/task-ledger.test.js test/jev-router.test.js test/jev-routing.test.js test/force-patterns.test.js test/upstream-fidelity.test.js test/tool-schema-compression.test.js test/passthrough-route.test.js test/quota-ledger.test.js test/shortfall-semantic.test.js test/routing-decisions.test.js test/routing-corpus.test.js test/switch-gate-grounding.test.js", + "test:unit": "LYNKR_KNN_DIR=/tmp/lynkr-test-knn DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com LOG_FILE_ENABLED=false node --test test/api-key-auth.test.js test/routing.test.js test/hybrid-routing-integration.test.js test/retry-logic.test.js test/sse-transformer.test.js test/passthrough-stream.test.js test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js test/azure-openai-config.test.js test/azure-openai-format-conversion.test.js test/azure-openai-routing.test.js test/azure-openai-streaming.test.js test/azure-openai-error-resilience.test.js test/azure-openai-integration.test.js test/openai-integration.test.js test/atlas-integration.test.js test/cheaperinference-integration.test.js test/atmorouter-integration.test.js test/toon-compression.test.js test/gcf-compression.test.js test/llamacpp-integration.test.js test/resilience.test.js test/telemetry-routing.test.js test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/memory/distiller.test.js test/memory/distiller-freeze.test.js test/memory/wiki.test.js test/memory/skills-cache.test.js test/memory/tencentdb-launcher.test.js test/distill.test.js test/large-payload.test.js test/prompt-cache-injection.test.js test/risk-analyzer.test.js test/interaction-block.test.js test/preflight.test.js test/token-reduction.test.js test/session-affinity.test.js test/cache-state.test.js test/cache-switch-cost.test.js test/lens-recommendations.test.js test/model-registry-cost.test.js test/output-format-guard.test.js test/tier-fallback.test.js test/wrap.test.js test/init.test.js test/tool-call-response-metadata.test.js test/degradation.test.js test/routing-telemetry-columns.test.js test/sticky-routing.test.js test/knn-ambiguous-escalate.test.js test/deescalator.test.js test/client-profiles.test.js test/openai-ingress-tool-injection.test.js test/tool-result-compression-gate.test.js test/strip-internal-fields.test.js test/complexity-tool-subtraction.test.js test/bandit.test.js test/routing-propensity.test.js test/reward-pipeline.test.js test/knn-cold-start.test.js test/calibration.test.js test/feedback-loop.test.js test/session-fingerprint.test.js test/side-request-guards.test.js test/verifier.test.js test/intent-score.test.js test/difficulty-classifier.test.js test/classifier-setup.test.js test/usage-stats.test.js test/loop-guard.test.js test/moonshot-model-mapping.test.js test/baidu-model-mapping.test.js test/tenant-policy-ingress-parity.test.js test/decide.test.js test/embeddings-degradation.test.js test/health-probe.test.js test/stuck-detector.test.js test/onnx-embedder.test.js test/ope.test.js test/hierarchical-budget.test.js test/token-rate-limit.test.js test/otel-export.test.js test/mcp-broker.test.js test/compression-budget.test.js test/gpt-utils.test.js test/dedup-observe-only.test.js test/context-window-header.test.js test/token-budget-auto.test.js test/opencode-setup.test.js test/auth-mode-first-party.test.js test/harness-envelope.test.js test/task-ledger.test.js test/jev-router.test.js test/jev-routing.test.js test/force-patterns.test.js test/upstream-fidelity.test.js test/tool-schema-compression.test.js test/passthrough-route.test.js test/quota-ledger.test.js test/shortfall-semantic.test.js test/routing-decisions.test.js test/routing-corpus.test.js test/switch-gate-grounding.test.js", "test:memory": "LYNKR_KNN_DIR=/tmp/lynkr-test-knn DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/memory/distiller.test.js test/memory/distiller-freeze.test.js test/memory/wiki.test.js test/memory/skills-cache.test.js test/memory/tencentdb-launcher.test.js", "test:new-features": "LYNKR_KNN_DIR=/tmp/lynkr-test-knn DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js", "test:performance": "LYNKR_KNN_DIR=/tmp/lynkr-test-knn DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node test/hybrid-routing-performance.test.js && DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node test/performance-tests.js", diff --git a/src/config/index.js b/src/config/index.js index 3d60d6d..b63d5c5 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -482,6 +482,12 @@ const defaultModel = (modelProvider === "azure-anthropic" ? "claude-opus-4-5" : "databricks-claude-sonnet-4-5"); const port = Number.parseInt(process.env.PORT ?? "8080", 10); +// Interface to bind. Default keeps today's behaviour (all interfaces); set 127.0.0.1 for a +// loopback-only sidecar (e.g. when an editor embeds Lynkr). Not hot-reloadable. +const bindHost = (process.env.LYNKR_BIND_HOST ?? "0.0.0.0").trim() || "0.0.0.0"; +// Optional API key required on /v1/*, /routing/*, /metrics* and /dashboard/api/* (see +// src/middleware/api-key-auth.js). Empty/unset = open, exactly as before. +const lynkrApiKey = (process.env.LYNKR_API_KEY ?? "").trim() || null; const sessionDbPath = process.env.SESSION_DB_PATH ?? path.join(process.cwd(), "data", "sessions.db"); const workspaceRoot = path.resolve(process.env.WORKSPACE_ROOT ?? process.cwd()); @@ -666,6 +672,8 @@ const workerOffloadThresholdBytes = Number.parseInt(process.env.WORKER_OFFLOAD_T var config = { env: process.env.NODE_ENV ?? "production", port: Number.isNaN(port) ? 8080 : port, + bindHost, + apiKey: lynkrApiKey, databricks: { baseUrl: rawBaseUrl, apiKey, @@ -1227,6 +1235,7 @@ function reloadConfig() { // Update mutable config values (those that can safely change at runtime) // API keys and endpoints config.databricks.apiKey = process.env.DATABRICKS_API_KEY; + config.apiKey = (process.env.LYNKR_API_KEY ?? "").trim() || null; config.azureAnthropic.apiKey = process.env.AZURE_ANTHROPIC_API_KEY ?? null; config.ollama.model = process.env.OLLAMA_MODEL ?? "qwen2.5-coder:7b"; config.openrouter.apiKey = process.env.OPENROUTER_API_KEY ?? null; diff --git a/src/middleware/api-key-auth.js b/src/middleware/api-key-auth.js new file mode 100644 index 0000000..e1e081e --- /dev/null +++ b/src/middleware/api-key-auth.js @@ -0,0 +1,77 @@ +/** + * Optional API-key gate for Lynkr's own HTTP surface. + * + * When `LYNKR_API_KEY` is set (config.apiKey), every request under /v1/*, /routing/*, /metrics* + * and /dashboard/api/* must present that key as `x-api-key: `, `api-key: ` or + * `Authorization: Bearer `. Health probes stay open. When no key is configured the + * middleware is a no-op, so existing deployments behave exactly as before. + * + * Why all three header forms: Anthropic-style clients send x-api-key, Azure-style clients send + * api-key, OpenAI-style clients send a bearer. A client may legitimately carry an *upstream* + * OAuth bearer (subscription passthrough, see src/auth-mode.js) alongside the Lynkr key in + * x-api-key — any one matching credential is enough, the others are left untouched for the + * downstream auth-mode logic. + */ +const crypto = require("node:crypto"); + +const PROTECTED = [/^\/v1(\/|$)/, /^\/routing(\/|$)/, /^\/metrics(\/|$)/, /^\/dashboard\/api(\/|$)/]; +const EXEMPT = new Set(["/health", "/health/live", "/health/ready"]); +const OPENAI_SURFACE = [/^\/v1\/chat\/completions/, /^\/v1\/responses/, /^\/v1\/embeddings/]; + +/** Constant-time string comparison (length leak only). */ +function safeEqual(a, b) { + const ab = Buffer.from(String(a ?? ""), "utf8"); + const bb = Buffer.from(String(b ?? ""), "utf8"); + if (ab.length === 0 || ab.length !== bb.length) return false; + return crypto.timingSafeEqual(ab, bb); +} + +/** Every credential the request presents, in header order of preference. */ +function presentedKeys(headers = {}) { + const out = []; + const get = (name) => { + const v = headers[name] ?? headers[name.toLowerCase()]; + return Array.isArray(v) ? v[0] : v; + }; + const xApiKey = get("x-api-key"); + if (xApiKey) out.push(String(xApiKey).trim()); + const apiKey = get("api-key"); + if (apiKey) out.push(String(apiKey).trim()); + const auth = get("authorization"); + if (auth && /^Bearer\s+/i.test(auth)) out.push(String(auth).replace(/^Bearer\s+/i, "").trim()); + return out; +} + +function isProtected(path) { + const p = String(path || "/").split("?")[0]; + if (EXEMPT.has(p)) return false; + return PROTECTED.some((re) => re.test(p)); +} + +function isOpenAiSurface(path) { + const p = String(path || "/").split("?")[0]; + return OPENAI_SURFACE.some((re) => re.test(p)); +} + +/** + * @param {string|null|(() => string|null)} keyOrGetter configured key, or a getter so hot-reloaded + * values (config.apiKey) are honoured without re-mounting. + */ +function createApiKeyAuth(keyOrGetter) { + const getKey = typeof keyOrGetter === "function" ? keyOrGetter : () => keyOrGetter; + return function apiKeyAuth(req, res, next) { + const key = getKey(); + if (!key) return next(); + const path = req.path || req.url; + if (!isProtected(path)) return next(); + if (presentedKeys(req.headers).some((k) => safeEqual(k, key))) return next(); + res.status(401); + res.set("WWW-Authenticate", 'Bearer realm="lynkr"'); + if (isOpenAiSurface(path)) { + return res.json({ error: { message: "invalid or missing API key", type: "invalid_request_error", code: "invalid_api_key" } }); + } + return res.json({ type: "error", error: { type: "authentication_error", message: "invalid or missing API key" } }); + }; +} + +module.exports = { createApiKeyAuth, isProtected, isOpenAiSurface, presentedKeys, safeEqual }; diff --git a/src/server.js b/src/server.js index e026005..80bfd36 100644 --- a/src/server.js +++ b/src/server.js @@ -29,6 +29,11 @@ function createApp() { const path = require('path'); const fs = require('fs'); + // Optional API key (LYNKR_API_KEY) — mounted first so it also covers the dashboard JSON API and + // /metrics* registered below. Reads config.apiKey through a getter so a hot-reloaded .env applies. + const { createApiKeyAuth } = require('./middleware/api-key-auth'); + app.use(createApiKeyAuth(() => config.apiKey)); + // Dashboard — registered first so it is never shadowed by the main router const DASHBOARD_HTML = path.resolve(__dirname, '../public/dashboard.html'); app.get('/dashboard', (_req, res) => { @@ -233,12 +238,15 @@ async function start() { } } - const server = app.listen(config.port, (err) => { + const bindHost = config.bindHost || "0.0.0.0"; + const server = app.listen(config.port, bindHost, (err) => { // Express 5 invokes this callback on bind errors too (it wires the // callback to the server 'error' event) — without the guard, a failed // bind prints "listening" right before the EADDRINUSE failure. if (err) return; - console.log(`Claude→Databricks proxy listening on http://localhost:${config.port}`); + const shown = bindHost === "0.0.0.0" || bindHost === "::" ? "localhost" : bindHost; + console.log(`Claude→Databricks proxy listening on http://${shown}:${config.port} (bound to ${bindHost}${config.apiKey ? ", API key required" : ""})`); + logger.info({ port: config.port, bindHost, apiKeyRequired: Boolean(config.apiKey) }, "HTTP server bound"); }); // Surface bind failures to the caller. Without this, EADDRINUSE from a diff --git a/test/api-key-auth.test.js b/test/api-key-auth.test.js new file mode 100644 index 0000000..f27fabf --- /dev/null +++ b/test/api-key-auth.test.js @@ -0,0 +1,141 @@ +/** + * LYNKR_API_KEY gate + LYNKR_BIND_HOST config. + * + * Contract pinned here: + * 1. no key configured → every route is open (byte-identical to pre-9.14.23 behaviour) + * 2. key configured → 401 without it on /v1/*, /routing/*, /metrics*, /dashboard/api/* + * 3. any of x-api-key / api-key / Authorization: Bearer unlocks; an upstream OAuth bearer + * next to a valid x-api-key is fine (subscription passthrough keeps working) + * 4. /health, /health/live, /health/ready are always open + * 5. OpenAI endpoints get the OpenAI error shape, everything else the Anthropic shape + * 6. comparison is constant-time and length-safe + * 7. config parses LYNKR_BIND_HOST (default 0.0.0.0) and LYNKR_API_KEY (trimmed, null when empty) + */ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const http = require("node:http"); +const express = require("express"); + +process.env.DATABRICKS_API_KEY = process.env.DATABRICKS_API_KEY || "test-key"; +process.env.DATABRICKS_API_BASE = process.env.DATABRICKS_API_BASE || "http://test.com"; +process.env.LOG_FILE_ENABLED = "false"; +process.env.LYNKR_BIND_HOST = " 127.0.0.1 "; +process.env.LYNKR_API_KEY = " cfg-key-123 "; + +const { createApiKeyAuth, isProtected, isOpenAiSurface, presentedKeys, safeEqual } = require("../src/middleware/api-key-auth"); + +function appWith(key) { + const app = express(); + app.use(createApiKeyAuth(key)); + const ok = (_req, res) => res.json({ ok: true }); + app.get("/health", ok); app.get("/health/live", ok); app.get("/health/ready", ok); + app.post("/v1/messages", ok); app.post("/v1/chat/completions", ok); app.post("/v1/responses", ok); + app.get("/v1/models", ok); app.get("/routing/stats", ok); app.get("/metrics", ok); app.get("/metrics/prometheus", ok); + app.get("/dashboard/api/overview", ok); app.get("/dashboard", ok); app.get("/open", ok); + return app; +} + +async function withServer(app, fn) { + const server = await new Promise((resolve) => { const s = app.listen(0, "127.0.0.1", () => resolve(s)); }); + try { return await fn(server.address().port); } finally { await new Promise((r) => server.close(r)); } +} + +function request(port, method, path, headers = {}) { + return new Promise((resolve, reject) => { + const req = http.request({ host: "127.0.0.1", port, method, path, headers }, (res) => { + let body = ""; res.on("data", (c) => (body += c)); res.on("end", () => resolve({ status: res.statusCode, headers: res.headers, body: body ? JSON.parse(body) : null })); + }); + req.on("error", reject); req.end(); + }); +} + +test("no key configured: everything is open", async () => { + await withServer(appWith(null), async (port) => { + for (const [m, p] of [["POST", "/v1/messages"], ["GET", "/routing/stats"], ["GET", "/metrics"], ["GET", "/dashboard/api/overview"], ["GET", "/health"]]) { + assert.equal((await request(port, m, p)).status, 200, `${m} ${p}`); + } + }); + await withServer(appWith(""), async (port) => { assert.equal((await request(port, "POST", "/v1/messages")).status, 200); }); +}); + +test("key configured: protected routes 401 without a key, health stays open", async () => { + await withServer(appWith("s3cret"), async (port) => { + for (const [m, p] of [["POST", "/v1/messages"], ["GET", "/v1/models"], ["GET", "/routing/stats"], ["GET", "/metrics"], ["GET", "/metrics/prometheus"], ["GET", "/dashboard/api/overview"]]) { + const r = await request(port, m, p); + assert.equal(r.status, 401, `${m} ${p}`); + assert.match(r.headers["www-authenticate"], /Bearer/); + } + for (const p of ["/health", "/health/live", "/health/ready", "/dashboard", "/open"]) { + assert.equal((await request(port, "GET", p)).status, 200, p); + } + }); +}); + +test("key configured: each header form unlocks; wrong key does not", async () => { + await withServer(appWith("s3cret"), async (port) => { + assert.equal((await request(port, "POST", "/v1/messages", { "x-api-key": "s3cret" })).status, 200); + assert.equal((await request(port, "POST", "/v1/messages", { "api-key": "s3cret" })).status, 200); + assert.equal((await request(port, "POST", "/v1/messages", { authorization: "Bearer s3cret" })).status, 200); + assert.equal((await request(port, "POST", "/v1/messages", { authorization: "bearer s3cret" })).status, 200, "scheme is case-insensitive"); + assert.equal((await request(port, "POST", "/v1/messages", { "x-api-key": "s3cre" })).status, 401); + assert.equal((await request(port, "POST", "/v1/messages", { "x-api-key": "s3cret-but-longer" })).status, 401); + assert.equal((await request(port, "POST", "/v1/messages", { authorization: "Basic czNjcmV0" })).status, 401, "non-bearer schemes do not count"); + }); +}); + +test("upstream OAuth bearer alongside a valid x-api-key passes (subscription passthrough)", async () => { + await withServer(appWith("s3cret"), async (port) => { + const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1In0.c2ln"; + assert.equal((await request(port, "POST", "/v1/messages", { "x-api-key": "s3cret", authorization: `Bearer ${jwt}` })).status, 200); + assert.equal((await request(port, "POST", "/v1/messages", { authorization: `Bearer ${jwt}` })).status, 401, "an OAuth bearer alone is not the Lynkr key"); + }); +}); + +test("error bodies: OpenAI shape on OpenAI endpoints, Anthropic shape elsewhere", async () => { + await withServer(appWith("s3cret"), async (port) => { + const oa = await request(port, "POST", "/v1/chat/completions"); + assert.deepEqual(oa.body, { error: { message: "invalid or missing API key", type: "invalid_request_error", code: "invalid_api_key" } }); + const resp = await request(port, "POST", "/v1/responses"); + assert.equal(resp.body.error.code, "invalid_api_key"); + const an = await request(port, "POST", "/v1/messages"); + assert.deepEqual(an.body, { type: "error", error: { type: "authentication_error", message: "invalid or missing API key" } }); + const rt = await request(port, "GET", "/routing/stats"); + assert.equal(rt.body.error.type, "authentication_error"); + }); +}); + +test("getter form honours hot-reloaded keys without remounting", async () => { + let current = null; + await withServer(appWith(() => current), async (port) => { + assert.equal((await request(port, "POST", "/v1/messages")).status, 200); + current = "later"; + assert.equal((await request(port, "POST", "/v1/messages")).status, 401); + assert.equal((await request(port, "POST", "/v1/messages", { "x-api-key": "later" })).status, 200); + current = null; + assert.equal((await request(port, "POST", "/v1/messages")).status, 200); + }); +}); + +test("isProtected / isOpenAiSurface / presentedKeys / safeEqual", () => { + for (const p of ["/v1", "/v1/messages", "/v1/admin/reload", "/routing/analyze", "/metrics", "/metrics/x", "/dashboard/api/logs"]) assert.equal(isProtected(p), true, p); + for (const p of ["/health", "/health/live", "/health/ready", "/dashboard", "/", "/v1x", "/routingx", "/metricsx"]) assert.equal(isProtected(p), false, p); + assert.equal(isProtected("/v1/messages?stream=1"), true); + assert.equal(isOpenAiSurface("/v1/chat/completions"), true); assert.equal(isOpenAiSurface("/v1/responses"), true); assert.equal(isOpenAiSurface("/v1/messages"), false); + assert.deepEqual(presentedKeys({ "x-api-key": " a ", "api-key": "b", authorization: "Bearer c " }), ["a", "b", "c"]); + assert.deepEqual(presentedKeys({ authorization: "Basic xyz" }), []); + assert.deepEqual(presentedKeys({ "x-api-key": ["arr", "ignored"] }), ["arr"]); + assert.equal(safeEqual("abc", "abc"), true); assert.equal(safeEqual("abc", "abd"), false); assert.equal(safeEqual("abc", "abcd"), false); + assert.equal(safeEqual("", ""), false, "empty never matches"); assert.equal(safeEqual(null, null), false); +}); + +test("config parses LYNKR_BIND_HOST and LYNKR_API_KEY (trimmed; empty → open)", () => { + const config = require("../src/config"); + assert.equal(config.bindHost, "127.0.0.1"); + assert.equal(config.apiKey, "cfg-key-123"); + process.env.LYNKR_API_KEY = " "; + config.reloadConfig(); + assert.equal(config.apiKey, null, "blank key after hot reload disables the gate"); + process.env.LYNKR_API_KEY = "rotated"; + config.reloadConfig(); + assert.equal(config.apiKey, "rotated"); +});