From cd1c597ff037de546601bb92253660f6f6471626 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Sat, 5 Sep 2026 08:50:17 -0700 Subject: [PATCH] fix(server): expose build version in health endpoint (#3404) * fix(server): expose build version in health endpoint * fix(server): redact public health failure details --- apps/server/src/http/health.test.ts | 96 ++++++++++ apps/server/src/http/health.ts | 17 +- apps/server/src/openapi/generator.test.ts | 23 +++ apps/server/src/openapi/generator.ts | 48 +++++ docs/spec.json | 204 ++++++++++++++++++++++ 5 files changed, 385 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/http/health.test.ts diff --git a/apps/server/src/http/health.test.ts b/apps/server/src/http/health.test.ts new file mode 100644 index 000000000..6ed02cef3 --- /dev/null +++ b/apps/server/src/http/health.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { execute, healthcheck } = vi.hoisted(() => ({ execute: vi.fn(), healthcheck: vi.fn() })); + +vi.mock("@reactive-resume/db/client", () => ({ db: { execute } })); +vi.mock("@reactive-resume/api/features/storage", () => ({ getStorageService: () => ({ healthcheck }) })); +vi.mock("../app-version", () => ({ appVersion: "9.8.7" })); + +import { handleHealth } from "./health"; + +describe("health version reporting", () => { + beforeEach(() => { + execute.mockResolvedValue([]); + healthcheck.mockResolvedValue({ status: "healthy" }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it("reports the built application version when launched directly by Node", async () => { + vi.stubEnv("npm_package_version", undefined); + + const response = await handleHealth(); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ service: "reactive-resume", version: "9.8.7", status: "healthy" }); + }); + + it("ignores a package manager's workspace package version", async () => { + vi.stubEnv("npm_package_version", "0.0.0"); + + expect(await (await handleHealth()).json()).toMatchObject({ version: "9.8.7" }); + }); + + it("keeps the version available when a dependency is unhealthy", async () => { + vi.stubEnv("npm_package_version", undefined); + execute.mockRejectedValueOnce(new Error("Database unavailable")); + vi.spyOn(console, "warn").mockImplementation(() => {}); + + const response = await handleHealth(); + + expect(response.status).toBe(503); + expect(await response.json()).toMatchObject({ version: "9.8.7", status: "unhealthy" }); + }); + it.each(["database", "storage"])("keeps thrown %s error details in server logs only", async (dependency) => { + const detail = "Connection failed for private-user at internal.example:5432"; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + (dependency === "database" ? execute : healthcheck).mockRejectedValueOnce(new Error(detail)); + + const response = await handleHealth(); + const body = await response.json(); + + expect(response.status).toBe(503); + expect(JSON.stringify(body)).not.toContain(detail); + expect(body[dependency]).toMatchObject({ + status: "unhealthy", + error: expect.stringContaining("health check failed"), + }); + expect(warn).toHaveBeenCalledWith( + "[Healthcheck]", + expect.objectContaining({ + [dependency]: expect.objectContaining({ error: detail }), + }), + ); + }); + + it("redacts returned storage failures while preserving diagnostics in server logs", async () => { + const detail = "Access denied to bucket private-bucket on internal.example"; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + healthcheck.mockResolvedValueOnce({ + status: "unhealthy", + type: "s3", + message: detail, + error: detail, + internalDetail: detail, + }); + + const response = await handleHealth(); + const body = await response.json(); + + expect(response.status).toBe(503); + expect(body.storage).toEqual({ + status: "unhealthy", + type: "s3", + latencyMs: expect.any(Number), + error: "Storage health check failed.", + }); + expect(JSON.stringify(body)).not.toContain(detail); + expect(warn).toHaveBeenCalledWith( + "[Healthcheck]", + expect.objectContaining({ storage: expect.objectContaining({ error: detail, message: detail }) }), + ); + }); +}); diff --git a/apps/server/src/http/health.ts b/apps/server/src/http/health.ts index 27fd46073..4a044204f 100644 --- a/apps/server/src/http/health.ts +++ b/apps/server/src/http/health.ts @@ -2,6 +2,7 @@ import { sql } from "drizzle-orm"; import { withTimeout } from "es-toolkit"; import { getStorageService } from "@reactive-resume/api/features/storage"; import { db } from "@reactive-resume/db/client"; +import { appVersion } from "../app-version"; const HEALTHCHECK_TIMEOUT_MS = 1_500; @@ -31,6 +32,16 @@ async function runCheck(check: () => Promise): Promise { } } +function publicCheck(check: CheckResult, name: "Database" | "Storage"): CheckResult { + if (check.status === "healthy") return check; + return { + status: check.status, + latencyMs: check.latencyMs, + error: `${name} health check failed.`, + ...(check.type === "local" || check.type === "s3" ? { type: check.type } : {}), + }; +} + // ponytail: inner try/catches removed; runCheck's outer catch handles all errors async function checkDatabase() { await db.execute(sql`SELECT 1`); @@ -45,12 +56,12 @@ export async function handleHealth() { const checks = { service: "reactive-resume", - version: process.env.npm_package_version, + version: appVersion, status, timestamp: new Date().toISOString(), uptime: `${process.uptime().toFixed(2)}s`, - database, - storage, + database: publicCheck(database, "Database"), + storage: publicCheck(storage, "Storage"), }; if (status === "unhealthy") { diff --git a/apps/server/src/openapi/generator.test.ts b/apps/server/src/openapi/generator.test.ts index ddfec69fc..8699dc9b0 100644 --- a/apps/server/src/openapi/generator.test.ts +++ b/apps/server/src/openapi/generator.test.ts @@ -80,6 +80,29 @@ describe("generateOpenApiSpec", () => { }); }, 15_000); + it("documents the public health endpoint at its actual URL", async () => { + const spec = await generateSpec(); + const health = spec.paths?.["/api/health"]?.get; + + expect(health).toMatchObject({ + operationId: "getHealth", + security: [], + servers: [{ url: "https://rxresu.me" }], + }); + for (const status of ["200", "503"]) { + expect(health?.responses?.[status]).toMatchObject({ + content: { + "application/json": { + schema: { + required: expect.arrayContaining(["service", "version", "status"]), + properties: { version: { type: "string" } }, + }, + }, + }, + }); + } + }); + it("uses the canonical input-side ResumeData schema in update requests", async () => { const spec = (await generateSpec()) as GeneratedSpecView; const { $schema: _dialect, ...canonicalInputSchema } = createResumeDataJsonSchema(); diff --git a/apps/server/src/openapi/generator.ts b/apps/server/src/openapi/generator.ts index e4370dc06..72aa169fa 100644 --- a/apps/server/src/openapi/generator.ts +++ b/apps/server/src/openapi/generator.ts @@ -1,3 +1,4 @@ +import type { OpenAPI } from "@orpc/openapi"; import { OpenAPIGenerator } from "@orpc/openapi"; import { JSON_SCHEMA_INPUT_REGISTRY, ZodToJsonSchemaConverter } from "@orpc/zod/zod4"; import { downloadResumePdfProcedure } from "@reactive-resume/api/features/resume/export"; @@ -50,6 +51,31 @@ type GenerateOpenApiSpecOptions = { version: string; }; +const healthDependencySchema = { + type: "object", + properties: { + status: { type: "string", enum: ["healthy", "unhealthy"] }, + latencyMs: { type: "number" }, + error: { type: "string", description: "Generic failure message. Detailed diagnostics are logged on the server." }, + }, + required: ["status", "latencyMs"], + additionalProperties: true, +} satisfies OpenAPI.SchemaObject; + +const healthResponseSchema = { + type: "object", + properties: { + service: { type: "string", enum: ["reactive-resume"] }, + version: { type: "string", description: "The running application's build version." }, + status: { type: "string", enum: ["healthy", "unhealthy"] }, + timestamp: { type: "string", format: "date-time" }, + uptime: { type: "string" }, + database: healthDependencySchema, + storage: healthDependencySchema, + }, + required: ["service", "version", "status", "timestamp", "uptime", "database", "storage"], +} satisfies OpenAPI.SchemaObject; + export async function generateOpenApiSpec({ appUrl, version }: GenerateOpenApiSpecOptions) { return await openAPIGenerator.generate(openAPIRouter, { info: { @@ -60,6 +86,28 @@ export async function generateOpenApiSpec({ appUrl, version }: GenerateOpenApiSp contact: { name: "Amruth Pillai", email: "hello@amruthpillai.com", url: "https://amruthpillai.com" }, }, servers: [{ url: `${appUrl}/api/openapi` }], + paths: { + "/api/health": { + get: { + operationId: "getHealth", + tags: ["System"], + summary: "Get application health and version", + description: "Checks database and storage availability. Does not require authentication.", + servers: [{ url: appUrl }], + security: [], + responses: { + "200": { + description: "The application and its dependencies are healthy.", + content: { "application/json": { schema: healthResponseSchema } }, + }, + "503": { + description: "One or more application dependencies are unhealthy.", + content: { "application/json": { schema: healthResponseSchema } }, + }, + }, + }, + }, + }, externalDocs: { url: "https://docs.rxresu.me", description: "Reactive Resume Documentation" }, commonSchemas: { ResumeData: { schema: resumeDataSchema, strategy: "input" }, diff --git a/docs/spec.json b/docs/spec.json index dd76b1847..614ef2579 100644 --- a/docs/spec.json +++ b/docs/spec.json @@ -28878,6 +28878,210 @@ } } } + }, + "/api/health": { + "get": { + "operationId": "getHealth", + "tags": [ + "System" + ], + "summary": "Get application health and version", + "description": "Checks database and storage availability. Does not require authentication.", + "servers": [ + { + "url": "https://rxresu.me" + } + ], + "security": [], + "responses": { + "200": { + "description": "The application and its dependencies are healthy.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "service": { + "type": "string", + "enum": [ + "reactive-resume" + ] + }, + "version": { + "type": "string", + "description": "The running application's build version." + }, + "status": { + "type": "string", + "enum": [ + "healthy", + "unhealthy" + ] + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "uptime": { + "type": "string" + }, + "database": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "healthy", + "unhealthy" + ] + }, + "latencyMs": { + "type": "number" + }, + "error": { + "type": "string", + "description": "Generic failure message. Detailed diagnostics are logged on the server." + } + }, + "required": [ + "status", + "latencyMs" + ], + "additionalProperties": true + }, + "storage": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "healthy", + "unhealthy" + ] + }, + "latencyMs": { + "type": "number" + }, + "error": { + "type": "string", + "description": "Generic failure message. Detailed diagnostics are logged on the server." + } + }, + "required": [ + "status", + "latencyMs" + ], + "additionalProperties": true + } + }, + "required": [ + "service", + "version", + "status", + "timestamp", + "uptime", + "database", + "storage" + ] + } + } + } + }, + "503": { + "description": "One or more application dependencies are unhealthy.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "service": { + "type": "string", + "enum": [ + "reactive-resume" + ] + }, + "version": { + "type": "string", + "description": "The running application's build version." + }, + "status": { + "type": "string", + "enum": [ + "healthy", + "unhealthy" + ] + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "uptime": { + "type": "string" + }, + "database": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "healthy", + "unhealthy" + ] + }, + "latencyMs": { + "type": "number" + }, + "error": { + "type": "string", + "description": "Generic failure message. Detailed diagnostics are logged on the server." + } + }, + "required": [ + "status", + "latencyMs" + ], + "additionalProperties": true + }, + "storage": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "healthy", + "unhealthy" + ] + }, + "latencyMs": { + "type": "number" + }, + "error": { + "type": "string", + "description": "Generic failure message. Detailed diagnostics are logged on the server." + } + }, + "required": [ + "status", + "latencyMs" + ], + "additionalProperties": true + } + }, + "required": [ + "service", + "version", + "status", + "timestamp", + "uptime", + "database", + "storage" + ] + } + } + } + } + } + } } } }