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:
Devin Foley 2026-07-20 15:59:08 -04:00 committed by GitHub
parent fa4f900d1a
commit 2f42a4968d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 95 additions and 1 deletions

View File

@ -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,
});
});
});

View File

@ -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;

View File

@ -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)