From 2f42a4968d5761fd62172e35ecf8188195b8d431 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Mon, 20 Jul 2026 15:59:08 -0400 Subject: [PATCH] Treat cloud-managed instances as bootstrapped in the health gate (#9912) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Paperclip instances can be self-hosted, or provisioned and managed by a cloud control plane that authenticates users through trusted headers validated against `PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN` (`resolveCloudTenantActor`) > - In `authenticated` deployment mode, the health route reports `bootstrapStatus: bootstrap_pending` until at least one `instance_admin` exists, and the UI locks everyone out at the "waiting on its first admin" claim screen until then — correct for self-hosted instances, where a human operator must claim the instance > - But the cloud-tenant trust middleware, by deliberate security hardening, never grants `instance_admin` and actively purges legacy grants — so a cloud-managed instance can never leave `bootstrap_pending`: the gate demands a role the middleware forbids > - Every control-plane-provisioned instance is therefore permanently locked at the claim screen even though its users and memberships exist > - This pull request makes the gate cloud-aware: when the tenant server token is configured, the instance is considered bootstrapped, because the control plane owns identity and there is no operator claim step > - The benefit is that cloud-managed instances become usable while self-hosted behavior stays byte-for-byte identical, now pinned by a previously missing regression test ## Linked Issues or Issue Description Refs #2927 (introduced the browser-native first-admin bootstrap flow this gate feeds). No existing public issue for the deadlock; inline description per the bug report template: - **What happened?**: an instance configured with `PAPERCLIP_DEPLOYMENT_MODE=authenticated` and `PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN` reports `bootstrapStatus: bootstrap_pending` forever. All users — including ones created via the trusted-header path with owner-level company membership — are locked out at the "This Paperclip is waiting on its first admin" screen. - **Expected behavior**: a control-plane-managed instance has no first-admin claim step; users arriving with control-plane identity should reach the app. - **Steps to reproduce**: 1. Run the server with `PAPERCLIP_DEPLOYMENT_MODE=authenticated` and a `PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN` set 2. Create users only through trusted cloud headers (the middleware upserts them but never grants `instance_admin`, and purges any legacy grants) 3. `GET /api/health` → `bootstrapStatus` stays `bootstrap_pending`; the UI shows the claim screen for every visitor, and no supported path exists to create the `instance_admin` the gate requires - **Paperclip version or commit**: reproducible on `master` as of 2026-07-20; present since the cloud-tenant `instance_admin` purge hardening landed. ## What Changed - `server/src/middleware/auth.ts`: new exported `isCloudManagedInstance()` predicate beside the trust middleware that defines the tenant-token contract. - `server/src/routes/health.ts`: the authenticated-mode first-admin gate is skipped when the instance is cloud-managed; `bootstrapStatus` reports `ready`. - `server/src/__tests__/health.test.ts`: two new tests — authenticated without the token → `bootstrap_pending` (previously untested regression baseline), and with the token → `ready` despite zero instance admins. ## Verification - `pnpm vitest run src/__tests__/health.test.ts` in `server/` — 13/13 - `pnpm vitest run src/middleware/cloud-tenant-actor.test.ts` — 6/6 - Manual: with the env vars from the repro steps set, `GET /api/health` now returns `bootstrapStatus: "ready"`; without the token, behavior is unchanged ## Risks - None for self-hosted deployments: without the env var the gate is the prior behavior, now pinned by the new regression test. - For cloud-managed instances the claim screen and `bootstrapInviteActive` flow no longer appear — intended; browser-based claim was already disabled in that configuration. ## Model Used - Claude (Anthropic) — model id `claude-fable-5`, via the Claude Code CLI harness with tool use (shell, file edits, test execution). Diagnosis and change agent-assisted, human-directed. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes (n/a — behavior documented in code comments and pinned by tests) - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- server/src/__tests__/health.test.ts | 74 +++++++++++++++++++++++++++++ server/src/middleware/auth.ts | 13 +++++ server/src/routes/health.ts | 9 +++- 3 files changed, 95 insertions(+), 1 deletion(-) 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)