From c0b149a424ccad33fbc1e8437ccb08f596a99966 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Sun, 7 Jun 2026 21:32:56 -0700 Subject: [PATCH 1/9] Add an authenticated admin ops control surface with a reindex operation registry Introduce POST /admin/:op (plus a GET /admin/index-stats alias) on the existing server, dispatched through an operation registry so future ops are a one-line registration. Bearer auth reads PATHFINDER_ADMIN_TOKEN with a length-guarded constant-time compare and fails closed: when the token is unset/empty the routes return 503, so the feature can ship before the secret is provisioned. Seed the registry with reindex (full/source/repo, mapping to the orchestrator's existing queue methods, returning 202) and index-stats (reusing getIndexStats and the same projection /health uses). Each invocation is audit-logged and reindex fires a best-effort Slack notice; the registry documents how to add config-reload, setting-set, reembed, atlas-cache-invalidate, and smoke as future ops. --- src/server.ts | 349 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 349 insertions(+) diff --git a/src/server.ts b/src/server.ts index 70a39ed..663bc15 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3337,6 +3337,354 @@ export function registerAtlasRatificationRoutes(app: express.Express): void { ); } +// --------------------------------------------------------------------------- +// Admin ops control surface — POST /admin/:op (+ index-stats read op) +// +// A remotely-triggerable, authenticated control plane for operational tasks +// that previously required DB surgery + a redeploy (e.g. forcing a reindex). +// Deliberately built as an OPERATION REGISTRY rather than a set of one-off +// routes so that future ops are a single registration line. +// +// ---------------------------------------------------------------------- +// HOW TO REGISTER A NEW OP +// ---------------------------------------------------------------------- +// 1. Pick a kebab-case op name (it becomes the URL segment: POST /admin/). +// 2. Add one entry to `buildAdminOpRegistry()` below: +// +// "config-reload": async (_req, _body) => { +// reloadServerConfig(); // call the existing impl +// return { status: 200, body: { reloaded: true } }; +// }, +// +// 3. Return an `AdminOpResult` — `{ status, body }`. Validate inputs inside +// the handler and return `{ status: 400, body: { error, ... } }` on bad +// input; the dispatcher turns any thrown error into a 500. +// 4. Reuse EXISTING logic (orchestrator methods, DB queries, config helpers) +// — do not duplicate it here. The registry is plumbing, not business logic. +// +// Documented extension points (intentionally NOT implemented yet — register +// them here when the need is real, following the recipe above): +// - `config-reload` — hot-reload server/source config from disk. +// - `setting-set` — flip a runtime setting (e.g. search_mode). +// - `reembed` — re-embed existing chunks without re-crawl. +// - `atlas-cache-invalidate` — drop the Atlas page cache. +// - `smoke` — run a self-check / readiness probe. +// +// Auth: bearer token from PATHFINDER_ADMIN_TOKEN, constant-time compared. +// FAIL-CLOSED: when the env var is unset/empty the routes return 503 +// (disabled) so the feature can be merged/deployed safely before the secret +// is provisioned. 401 on a missing/invalid token once the token is set. +// --------------------------------------------------------------------------- + +/** + * Uniform result shape every admin op returns. The dispatcher writes + * `res.status(status).json(body)`. Keep ops side-effecting + returning this + * envelope rather than touching `res` directly, so the dispatcher owns the + * single response-writing path (and its error handling). + */ +export interface AdminOpResult { + status: number; + body: unknown; +} + +/** A registered admin op: validates `body`, performs the action, returns a result. */ +export type AdminOp = (req: Request, body: unknown) => Promise; + +/** + * Injectable boundaries for the admin ops routes so tests can exercise the + * real handlers without a live DB. Mirrors the `deps` pattern used by + * registerAnalyticsRoutes / registerHealthRoute. + */ +export interface AdminOpsRouteDeps { + getIndexStats?: typeof getIndexStats; +} + +function readAdminToken(): string | undefined { + const token = process.env.PATHFINDER_ADMIN_TOKEN?.trim(); + return token ? token : undefined; +} + +/** + * Bearer auth for the admin ops surface. Fail-closed: 503 when no token is + * configured (feature disabled), 401 on a missing/invalid token when enabled. + * Constant-time comparison mirrors `bearerTokenAuth` — length-guarded before + * timingSafeEqual (which throws on length mismatch). + */ +function adminOpsAuth(req: Request, res: Response, next: express.NextFunction): void { + const token = readAdminToken(); + if (!token) { + // Fail-closed: the secret hasn't been provisioned, so the control surface + // is OFF. 503 (not 401) tells operators the routes exist but are disabled + // pending PATHFINDER_ADMIN_TOKEN — safe to deploy before the secret lands. + res.status(503).json({ + error: "admin_ops_disabled", + error_description: + "Admin ops disabled — set PATHFINDER_ADMIN_TOKEN to enable.", + }); + return; + } + + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith("Bearer ")) { + res.status(401).json({ + error: "unauthorized", + error_description: + "Missing or invalid Authorization header. Use: Bearer ", + }); + return; + } + + const providedBuf = Buffer.from(authHeader.slice(7), "utf8"); + const tokenBuf = Buffer.from(token, "utf8"); + // crypto.timingSafeEqual REQUIRES equal-length buffers — guard first so a + // length mismatch returns 401 instead of throwing an unhandled 500. A + // length oracle here is not a meaningful leak (see bearerTokenAuth note). + if ( + providedBuf.length !== tokenBuf.length || + !timingSafeEqual(providedBuf, tokenBuf) + ) { + res.status(401).json({ + error: "unauthorized", + error_description: "Invalid admin token", + }); + return; + } + + next(); +} + +/** + * Validate + dispatch the `reindex` op against the live orchestrator. + * + * Body shapes: + * { "scope": "full" } → queueFullReindex() + * { "scope": "source", "source": "" } → queueSourceReindex(name) + * { "scope": "repo", "repo": "" } → queueIncrementalReindex(url) + * + * All three orchestrator methods are fire-and-forget (return void, dedupe + * internally), so we return 202 Accepted with `{ queued: }`. + */ +async function adminReindexOp( + _req: Request, + body: unknown, +): Promise { + const b = (body ?? {}) as Record; + const scope = b.scope; + + if (scope !== "full" && scope !== "source" && scope !== "repo") { + return { + status: 400, + body: { + error: "invalid_request", + error_description: 'scope must be one of "full", "source", "repo"', + }, + }; + } + + // orchestratorRef is only wired when search/knowledge tools are enabled. If + // it's absent, make the gap loud + actionable rather than silently 202-ing. + if (!orchestratorRef) { + console.error( + "[admin-ops] reindex requested but NO orchestrator is wired " + + "(search/knowledge tools disabled) — nothing was queued.", + ); + return { + status: 503, + body: { + error: "orchestrator_unavailable", + error_description: + "No indexing orchestrator is wired (search/knowledge tools disabled).", + }, + }; + } + + if (scope === "full") { + orchestratorRef.queueFullReindex(); + return { status: 202, body: { queued: "full" } }; + } + + if (scope === "source") { + const source = typeof b.source === "string" ? b.source.trim() : ""; + if (!source) { + return { + status: 400, + body: { + error: "invalid_request", + error_description: 'source is required when scope is "source"', + }, + }; + } + orchestratorRef.queueSourceReindex(source); + return { status: 202, body: { queued: { source } } }; + } + + // scope === "repo" + const repo = typeof b.repo === "string" ? b.repo.trim() : ""; + if (!repo) { + return { + status: 400, + body: { + error: "invalid_request", + error_description: 'repo is required when scope is "repo"', + }, + }; + } + orchestratorRef.queueIncrementalReindex(repo); + return { status: 202, body: { queued: { repo } } }; +} + +/** + * Best-effort Slack notification for an admin op invocation. Reuses the same + * SLACK_WEBHOOK_URL the reindex-audit notifier uses. Swallows failures but + * logs them loudly — a notifier outage must never fail the op. + */ +async function notifyAdminOpToSlack(op: string, summary: string): Promise { + let webhookUrl = ""; + try { + webhookUrl = getConfig().slackWebhookUrl; + } catch { + // getConfig can throw on a misconfigured environment; the op itself + // already succeeded, so don't surface this as an op failure. + return; + } + if (!webhookUrl) return; + try { + const response = await fetch(webhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + text: `🛠️ *Pathfinder admin op* — \`${op}\`: ${summary}`, + }), + }); + if (!response.ok) { + console.error( + `[admin-ops] Slack webhook returned ${response.status}: ${await response + .text() + .catch(() => "(no body)")}`, + ); + } + } catch (err) { + console.error("[admin-ops] Slack notify failed:", formatErrorForLog(err)); + } +} + +/** + * Build the op registry. `deps` lets tests inject the DB boundary for the + * read ops without a live database. + */ +function buildAdminOpRegistry( + deps: AdminOpsRouteDeps, +): Record { + const _getIndexStats = deps.getIndexStats ?? getIndexStats; + + return { + // Mutating op — queue an indexing job. Fires a best-effort Slack notice. + reindex: async (req, body) => { + const result = await adminReindexOp(req, body); + if (result.status === 202) { + notifyAdminOpToSlack( + "reindex", + JSON.stringify((result.body as { queued: unknown }).queued), + ).catch((err) => { + console.error( + "[admin-ops] Slack notify (reindex) failed:", + formatErrorForLog(err), + ); + }); + } + return result; + }, + + // Read op — per-source index state. Reuses the SAME getIndexStats() the + // /health route uses (and the SAME projection) so the shape stays in sync. + "index-stats": async () => { + try { + const stats = await _getIndexStats(); + return { + status: 200, + body: { + total_chunks: stats.totalChunks, + by_source: stats.bySource, + indexed_repos: stats.indexedRepos, + sources: stats.indexStates.map((s) => ({ + type: s.source_type, + key: s.source_key, + status: s.status, + last_indexed: s.last_indexed_at, + commit: s.last_commit_sha?.slice(0, 8) ?? null, + error: s.error_message ?? null, + })), + }, + }; + } catch (err) { + // /health intentionally hides err.message (DB URLs can leak); the + // admin surface is authenticated, but mirror the sanitized shape. + console.error("[admin-ops] index-stats failed:", err); + return { + status: 503, + body: { error: "index_unavailable" }, + }; + } + }, + }; +} + +/** + * Register the admin ops control surface. `POST /admin/:op` dispatches through + * the registry; unknown ops → 404. A `GET /admin/index-stats` alias is mounted + * for the read op so it can be polled with a plain GET. + */ +export function registerAdminOpsRoutes( + app: express.Express, + deps: AdminOpsRouteDeps = {}, +): void { + const registry = buildAdminOpRegistry(deps); + + const dispatch = async (req: Request, res: Response): Promise => { + const op = typeof req.params.op === "string" ? req.params.op : ""; + const handler = registry[op]; + if (!handler) { + res.status(404).json({ + error: "unknown_op", + error_description: `Unknown admin op "${op}".`, + available_ops: Object.keys(registry), + }); + return; + } + + const body = req.body ?? {}; + // Audit log: op, args summary, client IP, timestamp. Mirrors the file's + // bracket-prefixed console style. The body is summarized (not dumped) to + // keep the log line bounded; reindex bodies are tiny but a future op might + // carry a larger payload. + const ip = clientIp(req, isTrustingProxy()); + console.log( + `[admin-ops] ${new Date().toISOString()} op="${op}" ip=${ip} args=${JSON.stringify( + body, + ).slice(0, 500)}`, + ); + + try { + const result = await handler(req, body); + res.status(result.status).json(result.body); + } catch (err) { + console.error(`[admin-ops] op "${op}" threw:`, formatErrorForLog(err)); + res.status(500).json({ + error: "admin_op_failed", + error_description: `Admin op "${op}" failed.`, + }); + } + }; + + app.post("/admin/:op", adminOpsAuth, dispatch); + // Convenience GET alias for the read-only index-stats op so it can be polled + // without a request body. Mutating ops stay POST-only. + app.get("/admin/index-stats", adminOpsAuth, (req, res) => { + req.params.op = "index-stats"; + return dispatch(req, res); + }); +} + // --------------------------------------------------------------------------- // Startup // --------------------------------------------------------------------------- @@ -3620,6 +3968,7 @@ async function startServerInner(options?: ServerOptions): Promise { // is now the single call site for the production app. registerAtlasRatificationRoutes(app); registerAnalyticsRoutes(app); + registerAdminOpsRoutes(app); const serverName = serverCfg.server.name; const server = app.listen(port, () => { From 211da4ef7321701acfe5a8fba73f518d0fe22bc5 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Sun, 7 Jun 2026 21:33:00 -0700 Subject: [PATCH 2/9] Add tests for the admin ops control surface Cover fail-closed 503 when PATHFINDER_ADMIN_TOKEN is unset or empty, 401 on a missing or invalid token, 202 reindex dispatch for the full/source/repo scopes against an injected orchestrator, 400 on malformed bodies, 404 on unknown ops, and the index-stats response shape via an injected getIndexStats. --- src/__tests__/admin-ops-endpoints.test.ts | 305 ++++++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 src/__tests__/admin-ops-endpoints.test.ts diff --git a/src/__tests__/admin-ops-endpoints.test.ts b/src/__tests__/admin-ops-endpoints.test.ts new file mode 100644 index 0000000..9c4926a --- /dev/null +++ b/src/__tests__/admin-ops-endpoints.test.ts @@ -0,0 +1,305 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import express from "express"; +import http from "node:http"; + +vi.mock("../config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getConfig: vi.fn(() => ({ + port: 3001, + databaseUrl: "pglite:///tmp/test", + openaiApiKey: "", + githubToken: "", + githubWebhookSecret: "", + nodeEnv: "test", + logLevel: "info", + cloneDir: "/tmp/test", + slackBotToken: "", + slackSigningSecret: "", + discordBotToken: "", + discordPublicKey: "", + notionToken: "", + mcpJwtSecret: "x".repeat(32), + p2pTelemetryUrl: undefined, + p2pTelemetryDisabled: false, + packageVersion: "test", + slackWebhookUrl: "", + })), + // Source validation for the reindex op reads getServerConfig().sources. + getServerConfig: vi.fn(() => ({ + server: { name: "test-server" }, + sources: [{ type: "github", name: "code", repo: "https://x/y" }], + tools: [], + })), + }; +}); + +import { + __setAtlasOrchestratorForTesting, + registerAdminOpsRoutes, +} from "../server.js"; + +const ADMIN_TOKEN = "test-admin-token-1234567890"; + +function request( + server: http.Server, + method: string, + path: string, + opts: { headers?: Record; body?: unknown } = {}, +): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("server is not listening on a TCP port")); + return; + } + const body = + opts.body === undefined ? undefined : JSON.stringify(opts.body); + const req = http.request( + { + hostname: "127.0.0.1", + port: address.port, + path, + method, + headers: { + ...(body ? { "Content-Type": "application/json" } : {}), + ...(body + ? { "Content-Length": Buffer.byteLength(body).toString() } + : {}), + ...opts.headers, + }, + }, + (res) => { + let responseBody = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + responseBody += chunk; + }); + res.on("end", () => { + resolve({ status: res.statusCode ?? 0, body: responseBody }); + }); + }, + ); + req.on("error", reject); + if (body) req.write(body); + req.end(); + }); +} + +async function startServer(): Promise { + const app = express(); + app.use(express.json()); + registerAdminOpsRoutes(app); + const server = app.listen(0); + await new Promise((resolve) => server.once("listening", resolve)); + return server; +} + +async function closeServer(s: http.Server | undefined): Promise { + if (!s || !s.listening) return; + await new Promise((resolve, reject) => { + s.close((err) => (err ? reject(err) : resolve())); + }); +} + +describe("admin ops control surface", () => { + let server: http.Server | undefined; + const prevToken = process.env.PATHFINDER_ADMIN_TOKEN; + + beforeEach(() => { + process.env.PATHFINDER_ADMIN_TOKEN = ADMIN_TOKEN; + __setAtlasOrchestratorForTesting(null); + }); + + afterEach(async () => { + await closeServer(server); + server = undefined; + __setAtlasOrchestratorForTesting(null); + if (prevToken === undefined) delete process.env.PATHFINDER_ADMIN_TOKEN; + else process.env.PATHFINDER_ADMIN_TOKEN = prevToken; + vi.restoreAllMocks(); + }); + + it("returns 503 (fail-closed) when PATHFINDER_ADMIN_TOKEN is unset", async () => { + delete process.env.PATHFINDER_ADMIN_TOKEN; + server = await startServer(); + const res = await request(server, "POST", "/admin/index-stats", { + headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }, + body: {}, + }); + expect(res.status).toBe(503); + }); + + it("returns 503 (fail-closed) when PATHFINDER_ADMIN_TOKEN is empty", async () => { + process.env.PATHFINDER_ADMIN_TOKEN = ""; + server = await startServer(); + const res = await request(server, "POST", "/admin/index-stats", { + headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }, + body: {}, + }); + expect(res.status).toBe(503); + }); + + it("returns 401 on a missing Authorization header when enabled", async () => { + server = await startServer(); + const res = await request(server, "POST", "/admin/index-stats", { + body: {}, + }); + expect(res.status).toBe(401); + }); + + it("returns 401 on an invalid token when enabled", async () => { + server = await startServer(); + const res = await request(server, "POST", "/admin/index-stats", { + headers: { Authorization: "Bearer wrong-token-of-some-other-length" }, + body: {}, + }); + expect(res.status).toBe(401); + }); + + it("dispatches reindex {scope:full} → queueFullReindex()", async () => { + const queueFullReindex = vi.fn(); + const queueSourceReindex = vi.fn(); + const queueIncrementalReindex = vi.fn(); + __setAtlasOrchestratorForTesting({ + queueFullReindex, + queueSourceReindex, + queueIncrementalReindex, + } as never); + server = await startServer(); + + const res = await request(server, "POST", "/admin/reindex", { + headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }, + body: { scope: "full" }, + }); + expect(res.status).toBe(202); + expect(JSON.parse(res.body)).toMatchObject({ queued: "full" }); + expect(queueFullReindex).toHaveBeenCalledTimes(1); + expect(queueSourceReindex).not.toHaveBeenCalled(); + expect(queueIncrementalReindex).not.toHaveBeenCalled(); + }); + + it("dispatches reindex {scope:source, source} → queueSourceReindex(name)", async () => { + const queueSourceReindex = vi.fn(); + __setAtlasOrchestratorForTesting({ + queueFullReindex: vi.fn(), + queueSourceReindex, + queueIncrementalReindex: vi.fn(), + } as never); + server = await startServer(); + + const res = await request(server, "POST", "/admin/reindex", { + headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }, + body: { scope: "source", source: "code" }, + }); + expect(res.status).toBe(202); + expect(queueSourceReindex).toHaveBeenCalledWith("code"); + }); + + it("dispatches reindex {scope:repo, repo} → queueIncrementalReindex(url)", async () => { + const queueIncrementalReindex = vi.fn(); + __setAtlasOrchestratorForTesting({ + queueFullReindex: vi.fn(), + queueSourceReindex: vi.fn(), + queueIncrementalReindex, + } as never); + server = await startServer(); + + const res = await request(server, "POST", "/admin/reindex", { + headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }, + body: { scope: "repo", repo: "https://github.com/foo/bar" }, + }); + expect(res.status).toBe(202); + expect(queueIncrementalReindex).toHaveBeenCalledWith( + "https://github.com/foo/bar", + ); + }); + + it("returns 400 on a malformed reindex body (missing scope)", async () => { + server = await startServer(); + const res = await request(server, "POST", "/admin/reindex", { + headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }, + body: { nope: true }, + }); + expect(res.status).toBe(400); + }); + + it("returns 400 when reindex scope=source but source is missing", async () => { + __setAtlasOrchestratorForTesting({ + queueFullReindex: vi.fn(), + queueSourceReindex: vi.fn(), + queueIncrementalReindex: vi.fn(), + } as never); + server = await startServer(); + const res = await request(server, "POST", "/admin/reindex", { + headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }, + body: { scope: "source" }, + }); + expect(res.status).toBe(400); + }); + + it("returns 404 on an unknown op", async () => { + server = await startServer(); + const res = await request(server, "POST", "/admin/does-not-exist", { + headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }, + body: {}, + }); + expect(res.status).toBe(404); + }); + + it("index-stats returns per-source index state shape", async () => { + server = await startServer(); + const res = await request(server, "POST", "/admin/index-stats", { + headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }, + body: {}, + // getIndexStats hits the DB; inject a fake via the registry deps below. + }); + // index-stats uses an injectable getIndexStats; default impl reaches the + // DB which is unavailable here, so it returns 503. The shape assertions + // are exercised by the injected-deps variant below. + expect([200, 503]).toContain(res.status); + }); + + it("index-stats returns the expected shape with injected stats", async () => { + const app = express(); + app.use(express.json()); + registerAdminOpsRoutes(app, { + getIndexStats: async () => ({ + totalChunks: 42, + bySource: [{ source_name: "code", count: 42 }], + indexedRepos: 1, + indexStates: [ + { + source_type: "github", + source_key: "code", + status: "indexed", + last_indexed_at: "2026-01-01T00:00:00.000Z", + last_commit_sha: "abcdef1234567890", + error_message: null, + }, + ] as never, + }), + }); + server = app.listen(0); + await new Promise((resolve) => server!.once("listening", resolve)); + + const res = await request(server, "POST", "/admin/index-stats", { + headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }, + body: {}, + }); + expect(res.status).toBe(200); + const body = JSON.parse(res.body); + expect(body.total_chunks).toBe(42); + expect(body.sources).toEqual([ + { + type: "github", + key: "code", + status: "indexed", + last_indexed: "2026-01-01T00:00:00.000Z", + commit: "abcdef12", + error: null, + }, + ]); + }); +}); From 0271616b8f395827eb49607ce1d7839e559967a0 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Sun, 7 Jun 2026 21:34:06 -0700 Subject: [PATCH 3/9] Apply prettier formatting to the admin ops route handlers --- src/server.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/server.ts b/src/server.ts index 663bc15..1a1357a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3410,7 +3410,11 @@ function readAdminToken(): string | undefined { * Constant-time comparison mirrors `bearerTokenAuth` — length-guarded before * timingSafeEqual (which throws on length mismatch). */ -function adminOpsAuth(req: Request, res: Response, next: express.NextFunction): void { +function adminOpsAuth( + req: Request, + res: Response, + next: express.NextFunction, +): void { const token = readAdminToken(); if (!token) { // Fail-closed: the secret hasn't been provisioned, so the control surface @@ -3538,7 +3542,10 @@ async function adminReindexOp( * SLACK_WEBHOOK_URL the reindex-audit notifier uses. Swallows failures but * logs them loudly — a notifier outage must never fail the op. */ -async function notifyAdminOpToSlack(op: string, summary: string): Promise { +async function notifyAdminOpToSlack( + op: string, + summary: string, +): Promise { let webhookUrl = ""; try { webhookUrl = getConfig().slackWebhookUrl; From 7bc071afde153413ebae453f10c1fbe5837f4086 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Sun, 7 Jun 2026 21:43:13 -0700 Subject: [PATCH 4/9] Validate reindex source/repo against configured sources The reindex op accepted any non-empty source/repo string and returned 202, then silently no-oped in the orchestrator drain when the name was a typo. Now scoped reindexes are validated against getServerConfig().sources: an unknown source returns 400 unknown_source and an unknown repo returns 400 unknown_repo, with the orchestrator only invoked on a match. A throwing config read fails closed as 503 config_unavailable rather than 202. Also widen the test-only orchestrator setter to cover all three queue methods the reindex op calls, log index-stats failures through formatErrorForLog to match the other admin-ops log sites, and drop the dead .catch on the fire-and-forget Slack notify (it never rejects). --- src/server.ts | 64 +++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 55 insertions(+), 9 deletions(-) diff --git a/src/server.ts b/src/server.ts index 1a1357a..677545c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -34,7 +34,9 @@ import { import { isSlackSourceConfig, isDiscordSourceConfig, + isFileSourceConfig, type FaqChunkResult, + type ServerConfig, } from "./types.js"; import { IndexingOrchestrator } from "./indexing/orchestrator.js"; import { runReindexAudit } from "./indexing/reindex-audit.js"; @@ -330,7 +332,10 @@ export function __clearBashInstancesForTesting(): void { } export function __setAtlasOrchestratorForTesting( - orchestrator: Pick | null, + orchestrator: Pick< + IndexingOrchestrator, + "queueFullReindex" | "queueSourceReindex" | "queueIncrementalReindex" + > | null, ): void { orchestratorRef = orchestrator as IndexingOrchestrator | null; } @@ -3507,6 +3512,27 @@ async function adminReindexOp( return { status: 202, body: { queued: "full" } }; } + // For scoped reindexes we validate the target against the configured + // sources so a typo fails loud (400) rather than 202-ing then silently + // no-op-ing in the orchestrator drain. getServerConfig() can throw on a + // misconfigured environment — treat that as 503, never 202. + let configuredSources: ServerConfig["sources"]; + try { + configuredSources = getServerConfig().sources; + } catch (err) { + console.error( + "[admin-ops] reindex config read failed:", + formatErrorForLog(err), + ); + return { + status: 503, + body: { + error: "config_unavailable", + error_description: "Server configuration is unavailable.", + }, + }; + } + if (scope === "source") { const source = typeof b.source === "string" ? b.source.trim() : ""; if (!source) { @@ -3518,6 +3544,15 @@ async function adminReindexOp( }, }; } + if (!configuredSources.some((s) => s.name === source)) { + return { + status: 400, + body: { + error: "unknown_source", + error_description: `No configured source named '${source}'`, + }, + }; + } orchestratorRef.queueSourceReindex(source); return { status: 202, body: { queued: { source } } }; } @@ -3533,6 +3568,17 @@ async function adminReindexOp( }, }; } + if ( + !configuredSources.some((s) => isFileSourceConfig(s) && s.repo === repo) + ) { + return { + status: 400, + body: { + error: "unknown_repo", + error_description: `No configured source with repo '${repo}'`, + }, + }; + } orchestratorRef.queueIncrementalReindex(repo); return { status: 202, body: { queued: { repo } } }; } @@ -3589,15 +3635,12 @@ function buildAdminOpRegistry( reindex: async (req, body) => { const result = await adminReindexOp(req, body); if (result.status === 202) { - notifyAdminOpToSlack( + // notifyAdminOpToSlack swallows all its own errors and never rejects, + // so this is fire-and-forget; `void` marks the intentional non-await. + void notifyAdminOpToSlack( "reindex", JSON.stringify((result.body as { queued: unknown }).queued), - ).catch((err) => { - console.error( - "[admin-ops] Slack notify (reindex) failed:", - formatErrorForLog(err), - ); - }); + ); } return result; }, @@ -3626,7 +3669,10 @@ function buildAdminOpRegistry( } catch (err) { // /health intentionally hides err.message (DB URLs can leak); the // admin surface is authenticated, but mirror the sanitized shape. - console.error("[admin-ops] index-stats failed:", err); + console.error( + "[admin-ops] index-stats failed:", + formatErrorForLog(err), + ); return { status: 503, body: { error: "index_unavailable" }, From 79d6e9aaae51bdf443bcd196dd6591faf9883124 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Sun, 7 Jun 2026 21:43:22 -0700 Subject: [PATCH 5/9] Cover reindex source/repo validation and tighten admin-ops tests Add red-green coverage for the new validation: unknown source/repo return 400, and the happy-path tests now target a source/repo present in the getServerConfig mock so they still reach 202. Make the index-stats failure test deterministic by injecting a throwing getIndexStats and asserting the exact 503 index_unavailable body with no DB detail leak, and add a dispatch 500 no-leak test driving a throwing reindex handler. Drop the `as never` casts on the orchestrator fakes now that the setter type is widened, and supply the missing queue methods on the atlas-ratification fake. --- src/__tests__/admin-ops-endpoints.test.ts | 99 ++++++++++++++++--- .../atlas-ratification-endpoints.test.ts | 2 + 2 files changed, 86 insertions(+), 15 deletions(-) diff --git a/src/__tests__/admin-ops-endpoints.test.ts b/src/__tests__/admin-ops-endpoints.test.ts index 9c4926a..e7f05c4 100644 --- a/src/__tests__/admin-ops-endpoints.test.ts +++ b/src/__tests__/admin-ops-endpoints.test.ts @@ -29,7 +29,7 @@ vi.mock("../config.js", async (importOriginal) => { // Source validation for the reindex op reads getServerConfig().sources. getServerConfig: vi.fn(() => ({ server: { name: "test-server" }, - sources: [{ type: "github", name: "code", repo: "https://x/y" }], + sources: [{ type: "code", name: "code", repo: "https://x/y" }], tools: [], })), }; @@ -166,7 +166,7 @@ describe("admin ops control surface", () => { queueFullReindex, queueSourceReindex, queueIncrementalReindex, - } as never); + }); server = await startServer(); const res = await request(server, "POST", "/admin/reindex", { @@ -186,7 +186,7 @@ describe("admin ops control surface", () => { queueFullReindex: vi.fn(), queueSourceReindex, queueIncrementalReindex: vi.fn(), - } as never); + }); server = await startServer(); const res = await request(server, "POST", "/admin/reindex", { @@ -203,17 +203,75 @@ describe("admin ops control surface", () => { queueFullReindex: vi.fn(), queueSourceReindex: vi.fn(), queueIncrementalReindex, - } as never); + }); server = await startServer(); + // Use the repo configured in the getServerConfig mock above. const res = await request(server, "POST", "/admin/reindex", { headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }, - body: { scope: "repo", repo: "https://github.com/foo/bar" }, + body: { scope: "repo", repo: "https://x/y" }, }); expect(res.status).toBe(202); - expect(queueIncrementalReindex).toHaveBeenCalledWith( - "https://github.com/foo/bar", - ); + expect(queueIncrementalReindex).toHaveBeenCalledWith("https://x/y"); + }); + + it("returns 400 unknown_source when reindex scope=source names an unconfigured source", async () => { + const queueSourceReindex = vi.fn(); + __setAtlasOrchestratorForTesting({ + queueFullReindex: vi.fn(), + queueSourceReindex, + queueIncrementalReindex: vi.fn(), + }); + server = await startServer(); + + const res = await request(server, "POST", "/admin/reindex", { + headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }, + body: { scope: "source", source: "does-not-exist" }, + }); + expect(res.status).toBe(400); + expect(JSON.parse(res.body)).toMatchObject({ error: "unknown_source" }); + expect(queueSourceReindex).not.toHaveBeenCalled(); + }); + + it("returns 400 unknown_repo when reindex scope=repo names an unconfigured repo", async () => { + const queueIncrementalReindex = vi.fn(); + __setAtlasOrchestratorForTesting({ + queueFullReindex: vi.fn(), + queueSourceReindex: vi.fn(), + queueIncrementalReindex, + }); + server = await startServer(); + + const res = await request(server, "POST", "/admin/reindex", { + headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }, + body: { scope: "repo", repo: "https://github.com/foo/bar" }, + }); + expect(res.status).toBe(400); + expect(JSON.parse(res.body)).toMatchObject({ error: "unknown_repo" }); + expect(queueIncrementalReindex).not.toHaveBeenCalled(); + }); + + it("returns 500 admin_op_failed without leaking err.message when an op handler throws", async () => { + // Force the reindex handler to throw by injecting an orchestrator whose + // queueFullReindex throws with a sensitive-looking message. + __setAtlasOrchestratorForTesting({ + queueFullReindex: () => { + throw new Error("postgres://secret:pw@db:5432/leak"); + }, + queueSourceReindex: vi.fn(), + queueIncrementalReindex: vi.fn(), + }); + server = await startServer(); + + const res = await request(server, "POST", "/admin/reindex", { + headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }, + body: { scope: "full" }, + }); + expect(res.status).toBe(500); + const body = JSON.parse(res.body); + expect(body.error).toBe("admin_op_failed"); + expect(res.body).not.toContain("postgres://"); + expect(res.body).not.toContain("secret"); }); it("returns 400 on a malformed reindex body (missing scope)", async () => { @@ -248,17 +306,28 @@ describe("admin ops control surface", () => { expect(res.status).toBe(404); }); - it("index-stats returns per-source index state shape", async () => { - server = await startServer(); + it("index-stats returns 503 index_unavailable without leaking DB detail when getIndexStats throws", async () => { + // Deterministically exercise the failure path by injecting a getIndexStats + // that throws with a sensitive-looking message. The response must be the + // sanitized shape and must not echo the underlying error. + const app = express(); + app.use(express.json()); + registerAdminOpsRoutes(app, { + getIndexStats: async () => { + throw new Error("postgres://secret:pw@db:5432/internal"); + }, + }); + server = app.listen(0); + await new Promise((resolve) => server!.once("listening", resolve)); + const res = await request(server, "POST", "/admin/index-stats", { headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }, body: {}, - // getIndexStats hits the DB; inject a fake via the registry deps below. }); - // index-stats uses an injectable getIndexStats; default impl reaches the - // DB which is unavailable here, so it returns 503. The shape assertions - // are exercised by the injected-deps variant below. - expect([200, 503]).toContain(res.status); + expect(res.status).toBe(503); + expect(JSON.parse(res.body)).toEqual({ error: "index_unavailable" }); + expect(res.body).not.toContain("postgres://"); + expect(res.body).not.toContain("secret"); }); it("index-stats returns the expected shape with injected stats", async () => { diff --git a/src/__tests__/atlas-ratification-endpoints.test.ts b/src/__tests__/atlas-ratification-endpoints.test.ts index 6548651..ea11330 100644 --- a/src/__tests__/atlas-ratification-endpoints.test.ts +++ b/src/__tests__/atlas-ratification-endpoints.test.ts @@ -425,7 +425,9 @@ describe("Atlas ratification endpoints", () => { }); const queueSourceReindex = vi.fn(); __setAtlasOrchestratorForTesting({ + queueFullReindex: vi.fn(), queueSourceReindex, + queueIncrementalReindex: vi.fn(), }); server = await startServer(); From 04703af7324cf99578d1854ab85fe3dcd531f2fa Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Mon, 8 Jun 2026 09:43:31 -0700 Subject: [PATCH 6/9] Add a typed AtlasSeedNotPendingError and 409 route coverage Replace the brittle substring-matched not-pending error in the atlas db module with an exported AtlasSeedNotPendingError, and detect it by instanceof (with a defensive code-string fallback) in the ratification error handler. Add HTTP route tests asserting 409 when approving or rejecting a missing/non-pending candidate. --- .../atlas-ratification-endpoints.test.ts | 47 +++++++++++++++++++ src/db/atlas.ts | 21 ++++++--- src/server.ts | 6 ++- 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/src/__tests__/atlas-ratification-endpoints.test.ts b/src/__tests__/atlas-ratification-endpoints.test.ts index ea11330..9d26486 100644 --- a/src/__tests__/atlas-ratification-endpoints.test.ts +++ b/src/__tests__/atlas-ratification-endpoints.test.ts @@ -448,6 +448,53 @@ describe("Atlas ratification endpoints", () => { expect(queueSourceReindex).toHaveBeenCalledWith("atlas"); }); + it("returns 409 when approving a candidate that is missing or not pending", async () => { + server = await startServer(); + + const approved = await request( + server, + "POST", + "/api/atlas/candidates/approve", + { + headers: { + Authorization: "Bearer secret", + "X-Atlas-Actor": "reviewer@example.test", + }, + body: { canonicalKey: "runtime:does-not-exist" }, + }, + ); + + expect(approved.status).toBe(409); + expect(JSON.parse(approved.body)).toMatchObject({ + error: "atlas_candidate_not_approveable", + }); + }); + + it("returns 409 when rejecting a candidate that is missing or not pending", async () => { + server = await startServer(); + + const rejected = await request( + server, + "POST", + "/api/atlas/candidates/reject", + { + headers: { + Authorization: "Bearer secret", + "X-Atlas-Actor": "reviewer@example.test", + }, + body: { canonicalKey: "runtime:does-not-exist", reason: "no such key" }, + }, + ); + + expect(rejected.status).toBe(409); + expect(JSON.parse(rejected.body)).toMatchObject({ + error: "atlas_candidate_not_rejectable", + }); + }); + + // Note: reject action yields "atlas_candidate_not_rejectable" (no extra + // vowel) because the suffix is `${action}able` and action="reject". + it("keeps rejected candidates out of provider acquisition", async () => { await upsertAtlasSeedCandidate({ canonicalKey: "runtime:approved", diff --git a/src/db/atlas.ts b/src/db/atlas.ts index 55ee219..fb64fdd 100644 --- a/src/db/atlas.ts +++ b/src/db/atlas.ts @@ -25,6 +25,19 @@ export interface AtlasSeedEntry { updatedAt: Date; } +export class AtlasSeedNotPendingError extends Error { + readonly code = "ATLAS_SEED_NOT_PENDING" as const; + constructor( + public readonly canonicalKey: string, + public readonly action: "approve" | "reject", + ) { + super( + `Cannot ${action} atlas seed entry "${canonicalKey}" because it is missing or not pending`, + ); + this.name = "AtlasSeedNotPendingError"; + } +} + export interface UpsertAtlasSeedCandidateInput { canonicalKey: string; sourceName: string; @@ -346,9 +359,7 @@ export async function approveAtlasSeedEntry( [canonicalKey, actor], ); if (rows[0]) return mapSeedRow(rows[0] as Record); - throw new Error( - `Cannot approve atlas seed entry "${canonicalKey}" because it is missing or not pending`, - ); + throw new AtlasSeedNotPendingError(canonicalKey, "approve"); } export async function rejectAtlasSeedEntry( @@ -372,9 +383,7 @@ export async function rejectAtlasSeedEntry( [canonicalKey, actor, reason], ); if (rows[0]) return mapSeedRow(rows[0] as Record); - throw new Error( - `Cannot reject atlas seed entry "${canonicalKey}" because it is missing or not pending`, - ); + throw new AtlasSeedNotPendingError(canonicalKey, "reject"); } export async function listPendingAtlasSeedCandidates(filter?: { diff --git a/src/server.ts b/src/server.ts index 677545c..3f54a3a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -87,6 +87,7 @@ import { approveAtlasSeedEntry, listPendingAtlasSeedCandidates, rejectAtlasSeedEntry, + AtlasSeedNotPendingError, } from "./db/atlas.js"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -3226,7 +3227,10 @@ function handleAtlasRatificationError( err: unknown, ): void { const message = err instanceof Error ? err.message : String(err); - if (message.includes("missing or not pending")) { + if ( + err instanceof AtlasSeedNotPendingError || + (err as { code?: string })?.code === "ATLAS_SEED_NOT_PENDING" + ) { res.status(409).json({ error: `atlas_candidate_not_${action}able`, error_description: message, From a90c86c6a6876b3d493716b5f99e260b2114a40c Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Mon, 8 Jun 2026 09:43:42 -0700 Subject: [PATCH 7/9] Standardize wrong-token responses to 401 across the shared privileged-surface auth A wrong bearer token (length mismatch or byte mismatch) on the shared analytics/Atlas/admin-ops auth now returns 401 unauthorized instead of 403 forbidden, per RFC 7235. Update the analytics auth tests to assert the 401 unauthorized shape. --- src/__tests__/analytics-auth-length-check.test.ts | 10 +++++----- src/__tests__/analytics-endpoints.test.ts | 10 +++++----- src/__tests__/analytics-server.test.ts | 6 +++--- src/server.ts | 8 ++++---- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/__tests__/analytics-auth-length-check.test.ts b/src/__tests__/analytics-auth-length-check.test.ts index 95f303b..51aacdc 100644 --- a/src/__tests__/analytics-auth-length-check.test.ts +++ b/src/__tests__/analytics-auth-length-check.test.ts @@ -89,21 +89,21 @@ describe("analyticsAuth length-check early return (R4-18)", () => { await new Promise((r) => server.close(() => r())); }); - it("returns 403 for a token of DIFFERENT length without throwing", async () => { + it("returns 401 for a token of DIFFERENT length without throwing", async () => { const res = await httpGet(server, "/api/analytics/summary", { Authorization: "Bearer x", // 1 char vs 18-char correct token }); - expect(res.status).toBe(403); + expect(res.status).toBe(401); const body = JSON.parse(res.body) as { error: string }; - expect(body.error).toBe("forbidden"); + expect(body.error).toBe("unauthorized"); }); - it("returns 403 for a token of SAME length that differs byte-wise", async () => { + it("returns 401 for a token of SAME length that differs byte-wise", async () => { // "correct-token-1234" is 18 chars; "zzzzzzzzzzzzzzzzzz" is also 18. const res = await httpGet(server, "/api/analytics/summary", { Authorization: "Bearer zzzzzzzzzzzzzzzzzz", }); - expect(res.status).toBe(403); + expect(res.status).toBe(401); }); it("accepts the correct token", async () => { diff --git a/src/__tests__/analytics-endpoints.test.ts b/src/__tests__/analytics-endpoints.test.ts index 9f4e793..7cd9b4a 100644 --- a/src/__tests__/analytics-endpoints.test.ts +++ b/src/__tests__/analytics-endpoints.test.ts @@ -244,7 +244,7 @@ describe("analyticsAuth middleware", () => { expect(next).not.toHaveBeenCalled(); }); - it("returns 403 when token does not match", () => { + it("returns 401 when token does not match", () => { mockGetAnalyticsConfigFn.mockReturnValue({ enabled: true, log_queries: true, @@ -260,11 +260,11 @@ describe("analyticsAuth middleware", () => { next, ); - expect(res.status).toHaveBeenCalledWith(403); + expect(res.status).toHaveBeenCalledWith(401); expect(next).not.toHaveBeenCalled(); }); - it("returns 403 when same-length token differs by one char (exercises timing-safe path)", () => { + it("returns 401 when same-length token differs by one char (exercises timing-safe path)", () => { // With different-length tokens the short-circuit path in analyticsAuth // rejects before timingSafeEqual runs. Using a same-length 'secrit' // ensures timingSafeEqual is actually invoked — exercising the real @@ -284,7 +284,7 @@ describe("analyticsAuth middleware", () => { next, ); - expect(res.status).toHaveBeenCalledWith(403); + expect(res.status).toHaveBeenCalledWith(401); expect(next).not.toHaveBeenCalled(); }); @@ -362,7 +362,7 @@ describe("analyticsAuth middleware", () => { next, ); - expect(res.status).toHaveBeenCalledWith(403); + expect(res.status).toHaveBeenCalledWith(401); }); it("skips token check in development mode ONLY from localhost", () => { diff --git a/src/__tests__/analytics-server.test.ts b/src/__tests__/analytics-server.test.ts index 2bed827..91aea23 100644 --- a/src/__tests__/analytics-server.test.ts +++ b/src/__tests__/analytics-server.test.ts @@ -302,7 +302,7 @@ describe("Analytics server routes (HTTP-level)", () => { expect(body.queries_today).toBe(5); }); - it("returns 403 with an invalid token", async () => { + it("returns 401 with an invalid token", async () => { mockGetAnalyticsConfigFn.mockReturnValue({ enabled: true, log_queries: true, @@ -315,9 +315,9 @@ describe("Analytics server routes (HTTP-level)", () => { Authorization: "Bearer wrong-token", }); - expect(res.status).toBe(403); + expect(res.status).toBe(401); const body = JSON.parse(res.body); - expect(body.error).toBe("forbidden"); + expect(body.error).toBe("unauthorized"); expect(body.error_description).toBe("Invalid analytics token"); }); diff --git a/src/server.ts b/src/server.ts index 3f54a3a..6ede08f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2585,15 +2585,15 @@ function bearerTokenAuth( // JavaScript. The value of timingSafeEqual is protecting the BYTES of // the secret once the lengths match, which this structure preserves. if (providedBuf.length !== tokenBuf.length) { - res.status(403).json({ - error: "forbidden", + res.status(401).json({ + error: "unauthorized", error_description: opts.invalidTokenDescription, }); return; } if (!timingSafeEqual(providedBuf, tokenBuf)) { - res.status(403).json({ - error: "forbidden", + res.status(401).json({ + error: "unauthorized", error_description: opts.invalidTokenDescription, }); return; From c64b7be43db159f37d3906c1554a70dbae889080 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Mon, 8 Jun 2026 09:44:09 -0700 Subject: [PATCH 8/9] Unify admin ops onto the shared admin-access token Replace the separate PATHFINDER_ADMIN_TOKEN auth with adminOpsAuth delegating to the shared bearerTokenAuth (requireAnalyticsEnabled: false), so the existing ANALYTICS_TOKEN gates analytics, Atlas ratification, and admin ops with one credential. No new env var. Admin ops now return 503 when no token is configured, 401 on a missing or invalid token, and honor the dev-localhost bypass. Rewrite the admin-ops tests against the shared auth model and drop the as-never casts in favor of typed fixtures. --- src/__tests__/admin-ops-endpoints.test.ts | 87 ++++++++++++++--------- src/server.ts | 73 ++++++++----------- 2 files changed, 83 insertions(+), 77 deletions(-) diff --git a/src/__tests__/admin-ops-endpoints.test.ts b/src/__tests__/admin-ops-endpoints.test.ts index e7f05c4..85b0c81 100644 --- a/src/__tests__/admin-ops-endpoints.test.ts +++ b/src/__tests__/admin-ops-endpoints.test.ts @@ -26,6 +26,16 @@ vi.mock("../config.js", async (importOriginal) => { packageVersion: "test", slackWebhookUrl: "", })), + // Admin ops now reuse the shared admin-access bearer token + // (ANALYTICS_TOKEN) via bearerTokenAuth. Mock getAnalyticsConfig so the + // shared auth resolves a token. requireAnalyticsEnabled is false for the + // admin surface, so the `enabled` flag does not gate access. + getAnalyticsConfig: vi.fn(() => ({ + enabled: true, + log_queries: true, + retention_days: 90, + token: ADMIN_TOKEN, + })), // Source validation for the reindex op reads getServerConfig().sources. getServerConfig: vi.fn(() => ({ server: { name: "test-server" }, @@ -35,12 +45,16 @@ vi.mock("../config.js", async (importOriginal) => { }; }); +import { getAnalyticsConfig } from "../config.js"; +import type { IndexStats } from "../db/queries.js"; import { __setAtlasOrchestratorForTesting, + __resetAnalyticsTokenForTesting, registerAdminOpsRoutes, } from "../server.js"; const ADMIN_TOKEN = "test-admin-token-1234567890"; +const mockGetAnalyticsConfig = vi.mocked(getAnalyticsConfig); function request( server: http.Server, @@ -105,10 +119,20 @@ async function closeServer(s: http.Server | undefined): Promise { describe("admin ops control surface", () => { let server: http.Server | undefined; - const prevToken = process.env.PATHFINDER_ADMIN_TOKEN; + const prevAnalyticsTokenEnv = process.env.ANALYTICS_TOKEN; beforeEach(() => { - process.env.PATHFINDER_ADMIN_TOKEN = ADMIN_TOKEN; + // The shared admin-access token resolves from analytics config (or the + // ANALYTICS_TOKEN env var). Clear the env so the mocked config is the + // sole token source and reset the cached auto-generated token. + delete process.env.ANALYTICS_TOKEN; + mockGetAnalyticsConfig.mockReturnValue({ + enabled: true, + log_queries: true, + retention_days: 90, + token: ADMIN_TOKEN, + }); + __resetAnalyticsTokenForTesting(); __setAtlasOrchestratorForTesting(null); }); @@ -116,23 +140,21 @@ describe("admin ops control surface", () => { await closeServer(server); server = undefined; __setAtlasOrchestratorForTesting(null); - if (prevToken === undefined) delete process.env.PATHFINDER_ADMIN_TOKEN; - else process.env.PATHFINDER_ADMIN_TOKEN = prevToken; + __resetAnalyticsTokenForTesting(); + if (prevAnalyticsTokenEnv === undefined) delete process.env.ANALYTICS_TOKEN; + else process.env.ANALYTICS_TOKEN = prevAnalyticsTokenEnv; vi.restoreAllMocks(); }); - it("returns 503 (fail-closed) when PATHFINDER_ADMIN_TOKEN is unset", async () => { - delete process.env.PATHFINDER_ADMIN_TOKEN; - server = await startServer(); - const res = await request(server, "POST", "/admin/index-stats", { - headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }, - body: {}, + it("returns 503 (fail-closed) when no admin-access token is configured", async () => { + // No analytics.token, no ANALYTICS_TOKEN env, analytics not enabled → the + // shared auth cannot resolve a token and fails closed with 503. + mockGetAnalyticsConfig.mockReturnValue({ + enabled: false, + log_queries: false, + retention_days: 90, }); - expect(res.status).toBe(503); - }); - - it("returns 503 (fail-closed) when PATHFINDER_ADMIN_TOKEN is empty", async () => { - process.env.PATHFINDER_ADMIN_TOKEN = ""; + __resetAnalyticsTokenForTesting(); server = await startServer(); const res = await request(server, "POST", "/admin/index-stats", { headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }, @@ -288,7 +310,7 @@ describe("admin ops control surface", () => { queueFullReindex: vi.fn(), queueSourceReindex: vi.fn(), queueIncrementalReindex: vi.fn(), - } as never); + }); server = await startServer(); const res = await request(server, "POST", "/admin/reindex", { headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }, @@ -333,22 +355,23 @@ describe("admin ops control surface", () => { it("index-stats returns the expected shape with injected stats", async () => { const app = express(); app.use(express.json()); + const stats: IndexStats = { + totalChunks: 42, + bySource: [{ source_name: "code", count: 42 }], + indexedRepos: 1, + indexStates: [ + { + source_type: "github", + source_key: "code", + status: "idle", + last_indexed_at: new Date("2026-01-01T00:00:00.000Z"), + last_commit_sha: "abcdef1234567890", + error_message: null, + }, + ], + }; registerAdminOpsRoutes(app, { - getIndexStats: async () => ({ - totalChunks: 42, - bySource: [{ source_name: "code", count: 42 }], - indexedRepos: 1, - indexStates: [ - { - source_type: "github", - source_key: "code", - status: "indexed", - last_indexed_at: "2026-01-01T00:00:00.000Z", - last_commit_sha: "abcdef1234567890", - error_message: null, - }, - ] as never, - }), + getIndexStats: async () => stats, }); server = app.listen(0); await new Promise((resolve) => server!.once("listening", resolve)); @@ -364,7 +387,7 @@ describe("admin ops control surface", () => { { type: "github", key: "code", - status: "indexed", + status: "idle", last_indexed: "2026-01-01T00:00:00.000Z", commit: "abcdef12", error: null, diff --git a/src/server.ts b/src/server.ts index 6ede08f..8fd11ee 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2484,7 +2484,7 @@ function bearerTokenAuth( res: Response, next: express.NextFunction, opts: { - logPrefix: "analytics" | "atlas"; + logPrefix: "analytics" | "atlas" | "admin-ops"; configReadFailureDescription: string; requireAnalyticsEnabled: boolean; disabledResponse?: { status: number; body: Record }; @@ -3408,62 +3408,45 @@ export interface AdminOpsRouteDeps { getIndexStats?: typeof getIndexStats; } -function readAdminToken(): string | undefined { - const token = process.env.PATHFINDER_ADMIN_TOKEN?.trim(); - return token ? token : undefined; -} - /** - * Bearer auth for the admin ops surface. Fail-closed: 503 when no token is - * configured (feature disabled), 401 on a missing/invalid token when enabled. - * Constant-time comparison mirrors `bearerTokenAuth` — length-guarded before - * timingSafeEqual (which throws on length mismatch). + * Bearer auth for the admin ops surface. Reuses the shared admin-access + * bearer token (`ANALYTICS_TOKEN`) via `bearerTokenAuth`, mirroring + * `atlasRatificationAuth` — one credential gates analytics, Atlas + * ratification, AND admin ops. Decoupled from analytics.enabled + * (`requireAnalyticsEnabled: false`): 503 when no token is configured, + * 401 on a missing/invalid token, and honors the dev-localhost bypass. */ function adminOpsAuth( req: Request, res: Response, next: express.NextFunction, ): void { - const token = readAdminToken(); - if (!token) { - // Fail-closed: the secret hasn't been provisioned, so the control surface - // is OFF. 503 (not 401) tells operators the routes exist but are disabled - // pending PATHFINDER_ADMIN_TOKEN — safe to deploy before the secret lands. + const prodTokenMsg = + "Admin ops require ANALYTICS_TOKEN in production (env var or analytics.token in config)."; + const nonProdTokenMsg = + "Admin ops token unavailable — check analytics token config / logs."; + let tokenDescription: string; + try { + tokenDescription = + getConfig().nodeEnv === "production" ? prodTokenMsg : nonProdTokenMsg; + } catch (err) { + console.error( + `[admin-ops] auth misconfigured: config read failed: ${formatErrorForLog(err)}`, + ); res.status(503).json({ - error: "admin_ops_disabled", - error_description: - "Admin ops disabled — set PATHFINDER_ADMIN_TOKEN to enable.", - }); - return; - } - - const authHeader = req.headers.authorization; - if (!authHeader || !authHeader.startsWith("Bearer ")) { - res.status(401).json({ - error: "unauthorized", - error_description: - "Missing or invalid Authorization header. Use: Bearer ", - }); - return; - } - - const providedBuf = Buffer.from(authHeader.slice(7), "utf8"); - const tokenBuf = Buffer.from(token, "utf8"); - // crypto.timingSafeEqual REQUIRES equal-length buffers — guard first so a - // length mismatch returns 401 instead of throwing an unhandled 500. A - // length oracle here is not a meaningful leak (see bearerTokenAuth note). - if ( - providedBuf.length !== tokenBuf.length || - !timingSafeEqual(providedBuf, tokenBuf) - ) { - res.status(401).json({ - error: "unauthorized", - error_description: "Invalid admin token", + error: "misconfigured", + error_description: "Admin ops config read failed", }); return; } - next(); + bearerTokenAuth(req, res, next, { + logPrefix: "admin-ops", + configReadFailureDescription: "Admin ops config read failed", + requireAnalyticsEnabled: false, + tokenDescription, + invalidTokenDescription: "Invalid admin token", + }); } /** From 0a53b537cfe656523c58cd683443d626f71f4b7b Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Mon, 8 Jun 2026 09:44:17 -0700 Subject: [PATCH 9/9] Update admin-ops and shared-auth docs for the unified admin-access token Describe the bearer token as the shared admin-access credential (ANALYTICS_TOKEN) gating analytics, Atlas ratification, and admin ops, and note the 401-on-wrong-token and dev-localhost-bypass behavior. --- src/server.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/server.ts b/src/server.ts index 8fd11ee..d6aa5e2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2475,9 +2475,11 @@ function getAnalyticsToken(): string | undefined { } /** - * Shared bearer-token check used by analytics and Atlas admin endpoints. - * Atlas intentionally reuses the same configured token source without tying - * its availability to analytics.enabled. + * Shared bearer-token check for every privileged surface — analytics, Atlas + * ratification, AND admin ops. All three reuse the one admin-access bearer + * token (`ANALYTICS_TOKEN`); Atlas and admin ops intentionally reuse the same + * configured token source without tying availability to analytics.enabled + * (`requireAnalyticsEnabled: false`). A wrong token → 401 (RFC 7235). */ function bearerTokenAuth( req: Request, @@ -3379,10 +3381,13 @@ export function registerAtlasRatificationRoutes(app: express.Express): void { // - `atlas-cache-invalidate` — drop the Atlas page cache. // - `smoke` — run a self-check / readiness probe. // -// Auth: bearer token from PATHFINDER_ADMIN_TOKEN, constant-time compared. -// FAIL-CLOSED: when the env var is unset/empty the routes return 503 -// (disabled) so the feature can be merged/deployed safely before the secret -// is provisioned. 401 on a missing/invalid token once the token is set. +// Auth: the shared admin-access bearer token (`ANALYTICS_TOKEN`) — the SAME +// credential that gates analytics and Atlas ratification. One "admin access" +// secret governs all three privileged surfaces; no separate admin token is +// provisioned. FAIL-CLOSED: when no token is configured the routes return 503 +// (misconfigured); 401 on a missing/invalid token. Decoupled from +// analytics.enabled, and honors the same dev-localhost bypass as the other +// privileged surfaces (production is unaffected under trust_proxy). // --------------------------------------------------------------------------- /**