[codex] Add private browser first-admin claim flow (#6755)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - Fresh self-hosted deployments need an operator path before any
invite exists.
> - Umbrel installs are private LAN deployments, so a one-time browser
claim is appropriate only when the deployment is private and unclaimed.
> - Public deployments and installs with active invites must keep the
existing invite-only model so admin creation is not exposed broadly.
> - GitHub PR #2927 established the useful direction, but it needed to
be adapted onto current `master` rather than merged as-is.
> - This pull request adds that adapted private-only claim flow across
server, UI, docs, and regression coverage.
> - The benefit is that a fresh private Umbrel-style install can be
claimed from the browser without weakening public deployment access.

## What Changed

- Added a first-admin claim service and access route support for
one-time admin claim eligibility on private unclaimed deployments.
- Updated the bootstrap/access UI so eligible private installs show a
setup claim path, while public and invited deployments keep invite-first
behavior.
- Added a bootstrap-pending setup UX lab covering claim, invite, public,
and signed-in access states.
- Updated deployment and local development docs for authenticated
private/public behavior and the Umbrel-style claim path.
- Added server and UI regression tests for private claim, public
no-claim, active invite fallback, existing board/no-access flows, and
health exposure reporting.
- Stabilized PR handoff verification by serializing the aggregate server
Vitest workspace run, forcing `NODE_ENV=test`, and relaxing the
heartbeat batching test around legitimate recovery follow-up runs.

## Verification

- `pnpm -r typecheck`
- `pnpm build`
- `pnpm vitest --run
server/src/__tests__/heartbeat-comment-wake-batching.test.ts`
- `pnpm vitest --run
server/src/__tests__/health-dev-server-token.test.ts`
- `pnpm test:run`
- QA validation: PAP-10115 passed browser validation with screenshots
for private fresh install claim, active invite versus claim conflict,
public invite-only/claim-absent behavior, existing invite fallback, and
normal board/no-access flows.
- GitHub closeout: issue #2579 and PR #2927 were updated with the
accepted direction: adapt the implementation, do not direct-merge #2927
as-is.

## Risks

- The claim endpoint must remain private-only and one-time; a regression
here could expose admin creation on public deployments.
- Existing invite behavior must remain intact for public deployments and
installs that already have an active invite.
- The stable Vitest harness now serializes the aggregate server
workspace group; this is slower, but it avoids DB-backed suite
collisions under root workspace mode.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected - check the roadmap
first. See `CONTRIBUTING.md`.
>
> ROADMAP.md checked: this is a scoped deployment bootstrap/access fix
and does not duplicate a listed roadmap project.

## Model Used

- OpenAI GPT-5 Codex via Paperclip `codex_local` for product
engineering, implementation, and verification, with tool-enabled local
code execution. Paperclip QA browser validation was performed in
PAP-10115 by the assigned QA agent; exact adapter model metadata for
that QA run is not exposed in this PR context.

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-05-27 21:15:01 -10:00
committed by GitHub
parent de36743583
commit 8da50dbcf8
19 changed files with 1058 additions and 80 deletions
@@ -0,0 +1,231 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createHash } from "node:crypto";
import { accessRoutes } from "../routes/access.js";
import { boardMutationGuard } from "../middleware/board-mutation-guard.js";
import { errorHandler } from "../middleware/index.js";
const claimFirstInstanceAdminMock = vi.hoisted(() => vi.fn());
const accessServiceMock = vi.hoisted(() => ({
isInstanceAdmin: vi.fn(),
canUser: vi.fn(),
hasPermission: vi.fn(),
ensureMembership: vi.fn(),
setPrincipalGrants: vi.fn(),
}));
vi.mock("../first-admin-claim.js", () => ({
claimFirstInstanceAdmin: claimFirstInstanceAdminMock,
}));
vi.mock("../services/index.js", () => ({
accessService: () => accessServiceMock,
agentService: () => ({
getById: vi.fn(),
}),
boardAuthService: () => ({
createCliAuthChallenge: vi.fn(),
resolveBoardAccess: vi.fn(),
assertCurrentBoardKey: vi.fn(),
revokeBoardApiKey: vi.fn(),
}),
deduplicateAgentName: vi.fn(),
logActivity: vi.fn(),
notifyHireApproved: vi.fn(),
}));
function hashToken(token: string) {
return createHash("sha256").update(token).digest("hex");
}
function createDb(invite?: Record<string, unknown>) {
return {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => Promise.resolve(invite ? [invite] : [])),
})),
})),
} as any;
}
function createApp(input: {
actor?: Record<string, unknown>;
deploymentMode?: "authenticated" | "local_trusted";
deploymentExposure?: "private" | "public";
guardMutations?: boolean;
db?: Record<string, unknown>;
}) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = input.actor ?? {
type: "board",
source: "session",
userId: "user-1",
};
next();
});
if (input.guardMutations) {
app.use(boardMutationGuard());
}
app.use(
"/api",
accessRoutes(input.db as any ?? createDb(), {
deploymentMode: input.deploymentMode ?? "authenticated",
deploymentExposure: input.deploymentExposure ?? "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
}),
);
app.use(errorHandler);
return app;
}
describe("POST /bootstrap/claim", () => {
beforeEach(() => {
vi.clearAllMocks();
claimFirstInstanceAdminMock.mockResolvedValue({
status: "claimed",
userId: "user-1",
value: null,
});
});
it("claims first admin for an authenticated private browser session", async () => {
const app = createApp({});
const res = await request(app).post("/api/bootstrap/claim").send({});
expect(res.status).toBe(200);
expect(res.body).toEqual({ claimed: true, userId: "user-1" });
expect(claimFirstInstanceAdminMock).toHaveBeenCalledWith(expect.anything(), { userId: "user-1" });
});
it("is not exposed in authenticated public mode", async () => {
const app = createApp({ deploymentExposure: "public" });
const res = await request(app).post("/api/bootstrap/claim").send({});
expect(res.status).toBe(404);
expect(claimFirstInstanceAdminMock).not.toHaveBeenCalled();
});
it("is not exposed in local trusted mode", async () => {
const app = createApp({ deploymentMode: "local_trusted" });
const res = await request(app).post("/api/bootstrap/claim").send({});
expect(res.status).toBe(404);
expect(claimFirstInstanceAdminMock).not.toHaveBeenCalled();
});
it.each([
[{ type: "none", source: "none" }, "anonymous caller"],
[{ type: "agent", source: "agent_key", agentId: "agent-1" }, "agent key"],
[{ type: "board", source: "board_key", userId: "user-1" }, "board API key"],
[{ type: "board", source: "local_implicit", userId: "local-board" }, "local implicit board"],
])("rejects %s before opening the first-admin transaction", async (actor) => {
const app = createApp({ actor });
const res = await request(app).post("/api/bootstrap/claim").send({});
expect(res.status).toBe(401);
expect(claimFirstInstanceAdminMock).not.toHaveBeenCalled();
});
it("returns conflict when first admin has already been claimed", async () => {
claimFirstInstanceAdminMock.mockResolvedValueOnce({
status: "already_claimed",
existingUserId: "user-2",
value: null,
});
const app = createApp({});
const res = await request(app).post("/api/bootstrap/claim").send({});
expect(res.status).toBe(409);
expect(res.body.error).toContain("already claimed");
});
it("stays behind the board mutation origin guard", async () => {
const app = createApp({ guardMutations: true });
const blocked = await request(app).post("/api/bootstrap/claim").send({});
expect(blocked.status).toBe(403);
expect(claimFirstInstanceAdminMock).not.toHaveBeenCalled();
const allowed = await request(app)
.post("/api/bootstrap/claim")
.set("Host", "paperclip.local")
.set("Origin", "http://paperclip.local")
.send({});
expect(allowed.status).toBe(200);
expect(claimFirstInstanceAdminMock).toHaveBeenCalledTimes(1);
});
});
describe("bootstrap invite first-admin acceptance", () => {
beforeEach(() => {
vi.clearAllMocks();
});
function createBootstrapInvite() {
return {
id: "invite-1",
companyId: null,
inviteType: "bootstrap_ceo",
allowedJoinTypes: "human",
tokenHash: hashToken("pcp_invite_test"),
defaultsPayload: {},
expiresAt: new Date("2027-03-10T00:00:00.000Z"),
invitedByUserId: null,
revokedAt: null,
acceptedAt: null,
createdAt: new Date("2026-03-07T00:00:00.000Z"),
updatedAt: new Date("2026-03-07T00:00:00.000Z"),
};
}
it("uses the shared first-admin helper for bootstrap invite acceptance", async () => {
const invite = createBootstrapInvite();
claimFirstInstanceAdminMock.mockResolvedValueOnce({
status: "claimed",
userId: "user-1",
value: { ...invite, acceptedAt: new Date("2026-03-07T00:01:00.000Z") },
});
const app = createApp({ db: createDb(invite) });
const res = await request(app)
.post("/api/invites/pcp_invite_test/accept")
.send({ requestType: "human" });
expect(res.status).toBe(202);
expect(res.body).toMatchObject({
inviteId: "invite-1",
inviteType: "bootstrap_ceo",
bootstrapAccepted: true,
userId: "user-1",
});
expect(claimFirstInstanceAdminMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ userId: "user-1", onClaim: expect.any(Function) }),
);
});
it("conflicts cleanly when browser claim already won before invite acceptance", async () => {
claimFirstInstanceAdminMock.mockResolvedValueOnce({
status: "already_claimed",
existingUserId: "user-2",
value: null,
});
const app = createApp({ db: createDb(createBootstrapInvite()) });
const res = await request(app)
.post("/api/invites/pcp_invite_test/accept")
.send({ requestType: "human" });
expect(res.status).toBe(409);
expect(res.body.error).toContain("already claimed");
});
});
@@ -0,0 +1,56 @@
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { createDb, instanceUserRoles } from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { claimFirstInstanceAdmin } from "../first-admin-claim.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
describeEmbeddedPostgres("claimFirstInstanceAdmin", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-first-admin-claim-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(instanceUserRoles);
});
afterAll(async () => {
await tempDb?.cleanup();
});
it("inserts exactly one first admin and reports later claims as conflicts", async () => {
const firstUserId = `user-${randomUUID()}`;
const first = await claimFirstInstanceAdmin(db, { userId: firstUserId });
expect(first).toMatchObject({ status: "claimed", userId: firstUserId });
const second = await claimFirstInstanceAdmin(db, { userId: `user-${randomUUID()}` });
expect(second).toMatchObject({ status: "already_claimed", existingUserId: firstUserId });
const roles = await db.select().from(instanceUserRoles);
expect(roles).toHaveLength(1);
expect(roles[0]).toMatchObject({ userId: firstUserId, role: "instance_admin" });
});
it("runs onClaim inside the winning transaction", async () => {
const userId = `user-${randomUUID()}`;
const result = await claimFirstInstanceAdmin(db, {
userId,
onClaim: async (tx) => {
const roles = await tx.select().from(instanceUserRoles);
return roles.map((role) => role.userId);
},
});
expect(result).toMatchObject({ status: "claimed", userId, value: [userId] });
});
});
@@ -96,6 +96,7 @@ describe("GET /health dev-server supervisor access", () => {
expect(res.body).toEqual({
status: "ok",
deploymentMode: "authenticated",
deploymentExposure: "private",
bootstrapStatus: "ready",
bootstrapInviteActive: false,
devServer: {
+2
View File
@@ -97,6 +97,7 @@ describe("GET /health", () => {
expect(res.body).toEqual({
status: "ok",
deploymentMode: "authenticated",
deploymentExposure: "public",
bootstrapStatus: "ready",
bootstrapInviteActive: false,
});
@@ -131,6 +132,7 @@ describe("GET /health", () => {
expect(res.body).toEqual({
status: "ok",
deploymentMode: "authenticated",
deploymentExposure: "public",
bootstrapStatus: "ready",
bootstrapInviteActive: false,
});
@@ -442,12 +442,18 @@ describe("heartbeat comment wake batching", () => {
gateway.releaseFirstWait();
await waitFor(() => gateway.getAgentPayloads().length === 2);
const secondPayload = gateway.getAgentPayloads()[1] ?? {};
const secondRunId = typeof secondPayload.idempotencyKey === "string" ? secondPayload.idempotencyKey : null;
if (!secondRunId) {
throw new Error("Expected forwarded gateway payload to include an idempotencyKey run id");
}
await waitFor(async () => {
const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId));
return runs.length === 2 && runs.every((run) => run.status === "succeeded");
const statusesByRunId = new Map(runs.map((run) => [run.id, run.status]));
return statusesByRunId.get(firstRun!.id) === "succeeded" && statusesByRunId.get(secondRunId) === "succeeded";
}, 90_000);
const secondPayload = gateway.getAgentPayloads()[1] ?? {};
expect(secondPayload.paperclip).toMatchObject({
wake: {
commentIds: [comment2.id, comment3.id],
+55
View File
@@ -0,0 +1,55 @@
import { eq, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { instanceUserRoles } from "@paperclipai/db";
type FirstAdminTransaction = Pick<Db, "execute" | "select" | "insert" | "update">;
export type FirstAdminClaimResult<T = unknown> =
| {
status: "claimed";
userId: string;
value: T | null;
}
| {
status: "already_claimed";
existingUserId: string | null;
value: null;
};
export async function claimFirstInstanceAdmin<T = unknown>(
db: Db,
input: {
userId: string;
onClaim?: (tx: FirstAdminTransaction) => Promise<T>;
},
): Promise<FirstAdminClaimResult<T>> {
return db.transaction(async (tx) => {
await tx.execute(sql`lock table ${instanceUserRoles} in share row exclusive mode`);
const existingAdmin = await tx
.select({ userId: instanceUserRoles.userId })
.from(instanceUserRoles)
.where(eq(instanceUserRoles.role, "instance_admin"))
.then((rows) => rows[0] ?? null);
if (existingAdmin) {
return {
status: "already_claimed" as const,
existingUserId: existingAdmin.userId ?? null,
value: null,
};
}
await tx.insert(instanceUserRoles).values({
userId: input.userId,
role: "instance_admin",
});
const value = input.onClaim ? await input.onClaim(tx) : null;
return {
status: "claimed" as const,
userId: input.userId,
value,
};
});
}
+50 -9
View File
@@ -79,6 +79,7 @@ import {
claimBoardOwnership,
inspectBoardClaimChallenge
} from "../board-claim.js";
import { claimFirstInstanceAdmin } from "../first-admin-claim.js";
import { getStorageService } from "../storage/index.js";
function hashToken(token: string) {
@@ -2453,6 +2454,31 @@ export function accessRoutes(
throw conflict("Board claim challenge is no longer available");
});
router.post("/bootstrap/claim", async (req, res) => {
if (
opts.deploymentMode !== "authenticated" ||
opts.deploymentExposure !== "private"
) {
throw notFound("Browser first-admin claim is not available");
}
if (
req.actor.type !== "board" ||
req.actor.source !== "session" ||
!req.actor.userId
) {
throw unauthorized("Sign in from a browser session before claiming first admin");
}
const claimed = await claimFirstInstanceAdmin(db, {
userId: req.actor.userId,
});
if (claimed.status === "already_claimed") {
throw conflict("Someone else has already claimed this instance");
}
res.json({ claimed: true, userId: claimed.userId });
});
router.post(
"/cli-auth/challenges",
validate(createCliAuthChallengeSchema),
@@ -3276,16 +3302,31 @@ export function accessRoutes(
);
}
const userId = req.actor.userId ?? "local-board";
const existingAdmin = await access.isInstanceAdmin(userId);
if (!existingAdmin) {
await access.promoteInstanceAdmin(userId);
const claimed = await claimFirstInstanceAdmin(db, {
userId,
onClaim: async (tx) => {
const updatedInvite = await tx
.update(invites)
.set({ acceptedAt: new Date(), updatedAt: new Date() })
.where(
and(
eq(invites.id, invite.id),
isNull(invites.acceptedAt),
isNull(invites.revokedAt)
)
)
.returning()
.then((rows) => rows[0] ?? null);
if (!updatedInvite) {
throw conflict("Bootstrap invite is no longer available");
}
return updatedInvite;
},
});
if (claimed.status === "already_claimed") {
throw conflict("Someone else has already claimed this instance");
}
const updatedInvite = await db
.update(invites)
.set({ acceptedAt: new Date(), updatedAt: new Date() })
.where(eq(invites.id, invite.id))
.returning()
.then((rows) => rows[0] ?? invite);
const updatedInvite = claimed.value ?? invite;
res.status(202).json({
inviteId: updatedInvite.id,
inviteType: updatedInvite.inviteType,
+1
View File
@@ -157,6 +157,7 @@ export function healthRoutes(
res.json({
status: "ok",
deploymentMode: opts.deploymentMode,
deploymentExposure: opts.deploymentExposure,
bootstrapStatus,
bootstrapInviteActive,
...(devServer ? { devServer } : {}),