diff --git a/server/src/__tests__/health.test.ts b/server/src/__tests__/health.test.ts index 69449e1f6e..ed95872338 100644 --- a/server/src/__tests__/health.test.ts +++ b/server/src/__tests__/health.test.ts @@ -68,6 +68,7 @@ describe("GET /health", () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllEnvs(); }); it("returns 200 with status ok", async () => { const app = createApp(); @@ -425,4 +426,77 @@ describe("GET /health", () => { serverInfo: testServerInfo, }); }); + + it("reports bootstrap_pending in authenticated mode when no instance admin exists", async () => { + const { healthRoutes } = await import("../routes/health.js"); + const db = { + execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]), + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn().mockResolvedValue([{ count: 0 }]), + })), + })), + } as unknown as Db; + const app = express(); + app.use((req, _res, next) => { + (req as any).actor = { type: "none", source: "none" }; + next(); + }); + app.use( + "/health", + healthRoutes(db, { + deploymentMode: "authenticated", + deploymentExposure: "public", + authReady: true, + companyDeletionEnabled: false, + serverInfo: testServerInfo, + }), + ); + + const res = await request(app).get("/health"); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + status: "ok", + bootstrapStatus: "bootstrap_pending", + bootstrapInviteActive: false, + }); + }); + + it("reports bootstrapStatus ready for cloud-managed instances regardless of instance admin count", async () => { + vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "test-tenant-server-token"); + const { healthRoutes } = await import("../routes/health.js"); + const db = { + execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]), + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn().mockResolvedValue([{ count: 0 }]), + })), + })), + } as unknown as Db; + const app = express(); + app.use((req, _res, next) => { + (req as any).actor = { type: "none", source: "none" }; + next(); + }); + app.use( + "/health", + healthRoutes(db, { + deploymentMode: "authenticated", + deploymentExposure: "public", + authReady: true, + companyDeletionEnabled: false, + serverInfo: testServerInfo, + }), + ); + + const res = await request(app).get("/health"); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + status: "ok", + bootstrapStatus: "ready", + bootstrapInviteActive: false, + }); + }); }); diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index 7b68a395ed..5a60ccee90 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -375,6 +375,19 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa }; } +/** + * Whether this instance is managed by a Paperclip Cloud control plane. + * When the tenant server token is configured, the control plane owns the + * user/identity lifecycle for this instance: users arrive through trusted + * headers (resolveCloudTenantActor) and are deliberately never granted + * instance_admin. Surfaces that assume a self-hosted operator will claim + * the instance (e.g. the first-admin bootstrap gate) should treat a + * cloud-managed instance as already set up. + */ +export function isCloudManagedInstance(): boolean { + return Boolean(process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN?.trim()); +} + export async function resolveCloudTenantActor(db: Db, req: Request): Promise { const expectedToken = process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN?.trim(); if (!expectedToken) return null; diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index fef83f7ad9..760e3e11da 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -5,6 +5,7 @@ import { and, count, eq, gt, inArray, isNull, sql } from "drizzle-orm"; import { heartbeatRuns, instanceUserRoles, invites } from "@paperclipai/db"; import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared"; import { readPersistedDevServerStatus, toDevServerHealthStatus, writeDevServerRestartRequest } from "../dev-server-status.js"; +import { isCloudManagedInstance } from "../middleware/auth.js"; import { logger } from "../middleware/logger.js"; import { getServerInfoSnapshot, type ServerInfoSnapshot } from "../server-info.js"; import { @@ -148,7 +149,13 @@ export function healthRoutes( let bootstrapStatus: "ready" | "bootstrap_pending" = "ready"; let bootstrapInviteActive = false; - if (opts.deploymentMode === "authenticated") { + // Cloud-managed instances have no first-admin concept: the control + // plane owns identity and its trusted-header users are deliberately + // never instance_admin, so the role-count gate below would report + // bootstrap_pending forever and lock every managed tenant out at the + // claim screen. Self-hosted deployments (no tenant server token) are + // unaffected. + if (opts.deploymentMode === "authenticated" && !isCloudManagedInstance()) { const roleCount = await db .select({ count: count() }) .from(instanceUserRoles)