diff --git a/src/__tests__/admin-ops-endpoints.test.ts b/src/__tests__/admin-ops-endpoints.test.ts new file mode 100644 index 0000000..85b0c81 --- /dev/null +++ b/src/__tests__/admin-ops-endpoints.test.ts @@ -0,0 +1,397 @@ +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: "", + })), + // 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" }, + sources: [{ type: "code", name: "code", repo: "https://x/y" }], + tools: [], + })), + }; +}); + +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, + 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 prevAnalyticsTokenEnv = process.env.ANALYTICS_TOKEN; + + beforeEach(() => { + // 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); + }); + + afterEach(async () => { + await closeServer(server); + server = undefined; + __setAtlasOrchestratorForTesting(null); + __resetAnalyticsTokenForTesting(); + if (prevAnalyticsTokenEnv === undefined) delete process.env.ANALYTICS_TOKEN; + else process.env.ANALYTICS_TOKEN = prevAnalyticsTokenEnv; + vi.restoreAllMocks(); + }); + + 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, + }); + __resetAnalyticsTokenForTesting(); + 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, + }); + 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(), + }); + 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, + }); + 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://x/y" }, + }); + expect(res.status).toBe(202); + 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 () => { + 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(), + }); + 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 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: {}, + }); + 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 () => { + 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 () => stats, + }); + 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: "idle", + last_indexed: "2026-01-01T00:00:00.000Z", + commit: "abcdef12", + error: null, + }, + ]); + }); +}); 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/__tests__/atlas-ratification-endpoints.test.ts b/src/__tests__/atlas-ratification-endpoints.test.ts index 6548651..9d26486 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(); @@ -446,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 70a39ed..d6aa5e2 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"; @@ -85,6 +87,7 @@ import { approveAtlasSeedEntry, listPendingAtlasSeedCandidates, rejectAtlasSeedEntry, + AtlasSeedNotPendingError, } from "./db/atlas.js"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -330,7 +333,10 @@ export function __clearBashInstancesForTesting(): void { } export function __setAtlasOrchestratorForTesting( - orchestrator: Pick | null, + orchestrator: Pick< + IndexingOrchestrator, + "queueFullReindex" | "queueSourceReindex" | "queueIncrementalReindex" + > | null, ): void { orchestratorRef = orchestrator as IndexingOrchestrator | null; } @@ -2469,16 +2475,18 @@ 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, res: Response, next: express.NextFunction, opts: { - logPrefix: "analytics" | "atlas"; + logPrefix: "analytics" | "atlas" | "admin-ops"; configReadFailureDescription: string; requireAnalyticsEnabled: boolean; disabledResponse?: { status: number; body: Record }; @@ -2579,15 +2587,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; @@ -3221,7 +3229,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, @@ -3337,6 +3348,388 @@ 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: 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). +// --------------------------------------------------------------------------- + +/** + * 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; +} + +/** + * 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 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: "misconfigured", + error_description: "Admin ops config read failed", + }); + return; + } + + bearerTokenAuth(req, res, next, { + logPrefix: "admin-ops", + configReadFailureDescription: "Admin ops config read failed", + requireAnalyticsEnabled: false, + tokenDescription, + invalidTokenDescription: "Invalid admin token", + }); +} + +/** + * 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" } }; + } + + // 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) { + return { + status: 400, + body: { + error: "invalid_request", + error_description: 'source is required when scope is "source"', + }, + }; + } + 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 } } }; + } + + // 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"', + }, + }; + } + 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 } } }; +} + +/** + * 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 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), + ); + } + 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:", + formatErrorForLog(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 +4013,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, () => {