Treat cloud-managed instances as bootstrapped in the health gate (#9912)
## 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
This commit is contained in:
parent
fa4f900d1a
commit
2f42a4968d
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<Express.Request["actor"] | null> {
|
||||
const expectedToken = process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN?.trim();
|
||||
if (!expectedToken) return null;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue