diff --git a/server/src/__tests__/health-dev-server-token.test.ts b/server/src/__tests__/health-dev-server-token.test.ts index e50b0930f1..c62a76489d 100644 --- a/server/src/__tests__/health-dev-server-token.test.ts +++ b/server/src/__tests__/health-dev-server-token.test.ts @@ -85,6 +85,12 @@ describe("GET /health dev-server supervisor access", () => { deploymentExposure: "private", authReady: true, companyDeletionEnabled: true, + // Pin server info so the commit field is deterministic (null) + // instead of picking up the checkout's real git metadata. + serverInfo: { + processStartedAt: "2026-03-20T11:00:00.000Z", + git: { available: false, unavailableReason: "git_unavailable" }, + }, }), ); @@ -97,6 +103,7 @@ describe("GET /health dev-server supervisor access", () => { status: "ok", deploymentMode: "authenticated", deploymentExposure: "private", + commit: null, bootstrapStatus: "ready", bootstrapInviteActive: false, devServer: { diff --git a/server/src/__tests__/health.test.ts b/server/src/__tests__/health.test.ts index ed95872338..eabeca9711 100644 --- a/server/src/__tests__/health.test.ts +++ b/server/src/__tests__/health.test.ts @@ -74,7 +74,7 @@ describe("GET /health", () => { const app = createApp(); const res = await request(app).get("/health"); expect(res.status).toBe(200); - expect(res.body).toEqual({ status: "ok", version: serverVersion, serverVersion: serverVersion, serverInfo: testServerInfo }); + expect(res.body).toEqual({ status: "ok", version: serverVersion, serverVersion: serverVersion, commit: testServerInfo.git.fullSha, serverInfo: testServerInfo }); }, 15_000); it("returns 200 when the database probe succeeds", async () => { @@ -107,6 +107,7 @@ describe("GET /health", () => { status: "unhealthy", version: serverVersion, serverVersion, + commit: testServerInfo.git.fullSha, error: "database_unreachable", serverInfo: testServerInfo, }); @@ -131,6 +132,8 @@ describe("GET /health", () => { unavailableReason: "git_unavailable", }, }); + // With no git metadata baked in, the exposed commit is null (not omitted). + expect(res.body.commit).toBeNull(); }); it("surfaces a stale database backup warning in full health details", async () => { @@ -281,6 +284,7 @@ describe("GET /health", () => { status: "ok", deploymentMode: "authenticated", deploymentExposure: "public", + commit: testServerInfo.git.fullSha, bootstrapStatus: "ready", bootstrapInviteActive: false, databaseBackup: { @@ -337,6 +341,7 @@ describe("GET /health", () => { status: "ok", deploymentMode: "authenticated", deploymentExposure: "public", + commit: testServerInfo.git.fullSha, bootstrapStatus: "ready", bootstrapInviteActive: false, }); @@ -374,6 +379,7 @@ describe("GET /health", () => { status: "ok", deploymentMode: "authenticated", deploymentExposure: "public", + commit: testServerInfo.git.fullSha, bootstrapStatus: "ready", bootstrapInviteActive: false, }); diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index 760e3e11da..c7ac588407 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -121,14 +121,19 @@ export function healthRoutes( // enableServerInfoDebugView experimental flag gates the UI surface, not this // already access-controlled field. const serverInfo = opts.serverInfo ?? getServerInfoSnapshot(); + // The build commit is a plain git SHA of a public repository — not a + // secret — so it is surfaced on every response, including the redacted + // one, unlike the fuller `serverInfo` block. Deploy tooling (and anyone) + // can read which commit this server is running without authenticating. + const commit = serverInfo.git.available ? serverInfo.git.fullSha : null; const exposeDevServerDetails = exposeFullDetails || hasDevServerStatusToken(req.get("x-paperclip-dev-server-status-token")); if (!db) { res.json( exposeFullDetails - ? { status: "ok", version: serverVersion, serverVersion: serverVersion, serverInfo } - : { status: "ok", deploymentMode: opts.deploymentMode }, + ? { status: "ok", version: serverVersion, serverVersion: serverVersion, commit, serverInfo } + : { status: "ok", deploymentMode: opts.deploymentMode, commit }, ); return; } @@ -141,6 +146,7 @@ export function healthRoutes( status: "unhealthy", version: serverVersion, serverVersion, + commit, error: "database_unreachable", ...(exposeFullDetails ? { serverInfo } : {}), }); @@ -210,6 +216,7 @@ export function healthRoutes( status: "ok", deploymentMode: opts.deploymentMode, deploymentExposure: opts.deploymentExposure, + commit, bootstrapStatus, bootstrapInviteActive, ...(redactedDatabaseBackup ? { databaseBackup: redactedDatabaseBackup } : {}), @@ -223,6 +230,7 @@ export function healthRoutes( status: "ok", version: serverVersion, serverVersion, + commit, deploymentMode: opts.deploymentMode, deploymentExposure: opts.deploymentExposure, authReady: opts.authReady, diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 9129f96fd8..43fd6a4fe4 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -1054,6 +1054,39 @@ function applyDocumentFixups(document: any): any { // ─── Health ────────────────────────────────────────────────────────────────── +// Shared by the healthy and database-unreachable responses: full details +// (including serverInfo) ride only on board/agent-actor responses. +const healthServerInfoSchema = z.object({ + processStartedAt: z.string().datetime(), + git: z.union([ + z.object({ + available: z.literal(true), + fullSha: z.string(), + shortSha: z.string(), + branchName: z.string().nullable(), + subject: z.string(), + committedAt: z.string().datetime().nullable(), + localChanges: z.union([ + z.object({ + available: z.literal(true), + hasLocalChanges: z.boolean(), + stagedFileCount: z.number().int().nonnegative(), + unstagedFileCount: z.number().int().nonnegative(), + untrackedFileCount: z.number().int().nonnegative(), + }).strict(), + z.object({ + available: z.literal(false), + unavailableReason: z.enum(["git_status_unavailable"]), + }).strict(), + ]), + }).strict(), + z.object({ + available: z.literal(false), + unavailableReason: z.enum(["git_unavailable", "invalid_git_metadata"]), + }).strict(), + ]), +}).strict(); + registry.registerPath({ method: "get", path: "/api/health", @@ -1063,6 +1096,9 @@ registry.registerPath({ 200: r.ok(z.object({ status: z.enum(["ok", "unhealthy"]), version: z.string().optional(), + // Running build commit (full git SHA), or null when git metadata is + // unavailable. Present on every response shape, including redacted ones. + commit: z.string().nullable(), deploymentMode: z.string().optional(), bootstrapStatus: z.enum(["ready", "bootstrap_pending"]).optional(), bootstrapInviteActive: z.boolean().optional(), @@ -1097,38 +1133,26 @@ registry.registerPath({ code: z.string(), message: z.string(), })).optional(), - serverInfo: z.object({ - processStartedAt: z.string().datetime(), - git: z.union([ - z.object({ - available: z.literal(true), - fullSha: z.string(), - shortSha: z.string(), - branchName: z.string().nullable(), - subject: z.string(), - committedAt: z.string().datetime().nullable(), - localChanges: z.union([ - z.object({ - available: z.literal(true), - hasLocalChanges: z.boolean(), - stagedFileCount: z.number().int().nonnegative(), - unstagedFileCount: z.number().int().nonnegative(), - untrackedFileCount: z.number().int().nonnegative(), - }).strict(), - z.object({ - available: z.literal(false), - unavailableReason: z.enum(["git_status_unavailable"]), - }).strict(), - ]), - }).strict(), - z.object({ - available: z.literal(false), - unavailableReason: z.enum(["git_unavailable", "invalid_git_metadata"]), - }).strict(), - ]), - }).strict().optional(), + serverInfo: healthServerInfoSchema.optional(), })), - 503: { description: "Service unavailable", content: { "application/json": { schema: ErrorSchema } } }, + // The database-unreachable body still carries version and commit so + // deployment tooling can verify the running build during an outage; + // serverInfo rides only on full-details (board/agent) responses. + 503: { + description: "Service unavailable", + content: { + "application/json": { + schema: z.object({ + status: z.literal("unhealthy"), + version: z.string(), + serverVersion: z.string(), + commit: z.string().nullable(), + error: z.literal("database_unreachable"), + serverInfo: healthServerInfoSchema.optional(), + }), + }, + }, + }, }, });